* feat(gallery): reveal mode — hide gallery from guests until reveal (#838) Guests can upload during the event but see no photos until the host reveals the gallery, manually ("Reveal now") or at a scheduled time. - migration 165: events.reveal_mode / reveal_at / revealed_at. Effective visibility is computed at REQUEST time (reveal_at <= now opens the gate exactly on schedule); the minutely scheduler only stamps revealed_at durably and emits a gallery.revealed workflow trigger - server-side enforcement in gallery.js: /photos returns the event shell with photos: [] + hidden_until_reveal for plain guests; image/download/stats endpoints 403 with GALLERY_HIDDEN (photo IDs are sequential — listing-only gating would be probeable); feedback-summary gated too. Slideshow tokens (surprise beamer), client access and the admin preview bypass; the guest upload route stays open - admin: reveal toggle + optional scheduled datetime next to the guest upload settings, status line and "Reveal now" button on the overview; re-enabling the toggle clears revealed_at so a gallery can re-hide - guest UI: upload-only view (hero, friendly message, scheduled time, upload button) for every layout; i18n for all 8 locales - timestamps written as ISO strings — the SQLite driver stringifies raw Date objects into garbage; ISO round-trips on both engines - 14 integration tests over minted gallery/slideshow/client/admin tokens * fix(gallery): reveal/re-arm semantics + upload button i18n key (#838) - "Reveal now" also clears a pending reveal_at: the schedule is consumed, so the full-form admin save can't accidentally re-hide a revealed gallery with a stale future date - setting a FUTURE reveal_at on a revealed gallery re-arms hiding — the one intentional way to re-hide without double-toggling the mode - guest upload button uses the existing upload.uploadPhotos key (gallery.uploadPhotos never existed; the button showed EN everywhere) * fix(gallery): close reveal bypasses from review round 1 (#838) - the hero-derivative route and the secure-images token-mint + secure-download routes are now reveal-gated: hero serves a 1920px derivative of ANY sequential photo id and secure tokens fetch originals — both were open bypasses while hidden. blockHiddenGallery moved to utils/revealMode.js and shared - customer-portal tokens (via:'customer', no accessLevel) now bypass reveal mode — they are the host/customer, not a guest, and were getting the upload-only view - an open hidden guest view refetches exactly at reveal_at plus a 60s fallback poll, so the gallery appears without a manual reload - gallery.revealed added to the workflow editor's trigger picker so the advertised notification hook is reachable in the UI - migration 165 guards each column independently (partial-state safe) * fix(gallery): reveal round 2 — remaining bypass surfaces + lifecycle edges (#838) - legacy /api/images router reveal-gated (view, secure-token + signed-url minting), and the signed-URL SERVE path re-checks hidden state via a backward-compatible bypass flag in the token payload - secure-image tokens record revealBypass at mint and are re-validated at serve time — a re-hide kills in-flight guest tokens within the request, while slideshow/client tokens keep working - OG metadata and the unauthenticated /og cover fall back to the brand logo / 404 while hidden — no hero-photo spoiler for social crawlers - photo-feedback GET/POST reveal-gated (sequential ids were enumerable); /my-feedback returns the empty back-compat shape (rows leak filename + storage path) - the reveal scheduler skips drafts — no premature stamp/notification for unpublished galleries - emitWorkflowEvent gains an additive dedupSuffix; both reveal emitters pass the reveal timestamp so a re-hidden gallery's second reveal fires workflows again instead of deduping into silence * fix(gallery): reveal round 3 — schedule consumption + two-way client sync (#838) - the scheduler now consumes reveal_at when stamping (matching "Reveal now"), and re-arming via a partial API update clears a stale PAST schedule — previously {reveal_mode:true} without reveal_at could instantly re-open the gate through the leftover date - /photos exposes reveal_armed so an open VISIBLE gallery keeps a 60s poll while the mode is on — a re-hide now propagates to open clients in both directions, not just hidden→visible Codex round-3 claim about timestamp-without-timezone drift on non-UTC Postgres was verified FALSE: knex's table.timestamp() creates timestamptz on PG (confirmed via information_schema on a live install), which stores absolute instants regardless of server TZ. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
3d6c9848dc
commit
2f05fcc39d
@@ -140,6 +140,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}, [data?.event?.default_photo_sort, defaultSortApplied]);
|
||||
|
||||
// Reveal mode (#838): an already-open view must follow reveal-state
|
||||
// changes in BOTH directions — hidden→visible at reveal_at (or manual
|
||||
// "Reveal now"), and visible→hidden on a re-hide. Refetch right at
|
||||
// reveal_at plus a 60s poll while the mode is armed; there is no push
|
||||
// channel.
|
||||
const hiddenUntilReveal = data?.hidden_until_reveal === true;
|
||||
const revealArmed = (data?.event as { reveal_armed?: boolean } | undefined)?.reveal_armed === true;
|
||||
const revealAtMs = data?.reveal_at ? new Date(data.reveal_at).getTime() : null;
|
||||
useEffect(() => {
|
||||
if (!hiddenUntilReveal && !revealArmed) return undefined;
|
||||
const timers: Array<ReturnType<typeof setTimeout>> = [];
|
||||
if (revealAtMs && revealAtMs > Date.now()) {
|
||||
timers.push(setTimeout(() => { refetch(); }, Math.min(revealAtMs - Date.now() + 1000, 2 ** 31 - 1)));
|
||||
}
|
||||
const interval = setInterval(() => { refetch(); }, 60_000);
|
||||
return () => { timers.forEach(clearTimeout); clearInterval(interval); };
|
||||
}, [hiddenUntilReveal, revealArmed, revealAtMs, refetch]);
|
||||
|
||||
// Get individual protection settings from event
|
||||
const disableRightClick = data?.event?.disable_right_click === true;
|
||||
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
|
||||
@@ -701,6 +719,58 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
&& settingsData?.gallery_show_filter_bar !== false
|
||||
&& (data?.photos?.length ?? 0) > 0;
|
||||
|
||||
// Reveal mode (#838): the server returned the event shell with no photos —
|
||||
// render the upload-only view for EVERY layout. Enforcement is server-side
|
||||
// (the photo endpoints refuse plain guests); this is the friendly face.
|
||||
if (hiddenUntilReveal) {
|
||||
const uploadsOn = Boolean(data?.event?.allow_user_uploads || event?.allow_user_uploads);
|
||||
return (
|
||||
<GalleryLayout event={event} brandingSettings={brandingSettings}>
|
||||
<div className="max-w-xl mx-auto text-center py-16 px-4">
|
||||
<div className="mx-auto mb-5 w-16 h-16 rounded-full bg-surface flex items-center justify-center">
|
||||
<EyeOff className="w-8 h-8 text-muted-theme" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold mb-3" style={{ color: 'var(--color-text, #171717)' }}>
|
||||
{t('gallery.revealPendingTitle', 'The photos are still a surprise')}
|
||||
</h2>
|
||||
<p className="text-muted-theme mb-2">
|
||||
{t('gallery.revealPendingMessage', 'The host will reveal the gallery later — check back soon!')}
|
||||
</p>
|
||||
{data.reveal_at && (
|
||||
<p className="text-sm text-muted-theme mb-6">
|
||||
{t('gallery.revealScheduledFor', 'Reveal scheduled for {{date}}', {
|
||||
date: new Date(data.reveal_at).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{uploadsOn && (
|
||||
<div className="mt-6">
|
||||
<p className="text-sm text-muted-theme mb-3">
|
||||
{t('gallery.revealUploadHint', 'You can already add your own photos to the collection:')}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
leftIcon={<Upload className="w-5 h-5" />}
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
>
|
||||
{t('upload.uploadPhotos', 'Upload Photos')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showUploadModal && uploadsOn && (
|
||||
<UserPhotoUpload
|
||||
eventId={data?.event?.id || event?.id}
|
||||
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
|
||||
onUploadComplete={() => setShowUploadModal(false)}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
)}
|
||||
</GalleryLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// Full-page layouts (gallery-premium, gallery-story) have their own integrated UI
|
||||
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
|
||||
const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story';
|
||||
|
||||
@@ -813,6 +813,10 @@
|
||||
}
|
||||
},
|
||||
"gallery": {
|
||||
"revealPendingTitle": "Die Fotos sind noch eine Überraschung",
|
||||
"revealPendingMessage": "Der Gastgeber gibt die Galerie später frei — schauen Sie bald wieder vorbei!",
|
||||
"revealScheduledFor": "Freigabe geplant für {{date}}",
|
||||
"revealUploadHint": "Sie können schon jetzt eigene Fotos zur Sammlung beitragen:",
|
||||
"expires": "Läuft ab",
|
||||
"expired": "Abgelaufen",
|
||||
"contactOrganizer": "Bitte kontaktieren Sie den Veranstalter, wenn Sie Zugriff auf diese Fotos benötigen.",
|
||||
@@ -964,6 +968,17 @@
|
||||
"failedToToggleDownloads": "Aktualisierung der Download-Berechtigung fehlgeschlagen"
|
||||
},
|
||||
"events": {
|
||||
"revealMode": "Reveal-Modus (Galerie bis zur Freigabe verbergen)",
|
||||
"revealModeHelp": "Gäste können hochladen, sehen aber keine Fotos, bis Sie die Galerie freigeben — manuell oder zum geplanten Zeitpunkt. Diashow und Kundenzugang funktionieren weiter.",
|
||||
"revealAt": "Geplante Freigabe (optional)",
|
||||
"revealAtHelp": "Leer lassen, um manuell mit „Jetzt freigeben\" freizugeben.",
|
||||
"revealModeStatus": "Reveal-Modus",
|
||||
"revealed": "Freigegeben",
|
||||
"hiddenUntilReveal": "Für Gäste verborgen",
|
||||
"revealScheduled": "Geplant: {{date}}",
|
||||
"revealNow": "Jetzt freigeben",
|
||||
"revealedToast": "Galerie freigegeben — Gäste sehen die Fotos jetzt",
|
||||
"revealError": "Galerie konnte nicht freigegeben werden",
|
||||
"totalPhotos": "Gesamtfotos",
|
||||
"totalViews": "Gesamtaufrufe",
|
||||
"totalDownloads": "Gesamte Downloads",
|
||||
|
||||
@@ -360,6 +360,10 @@
|
||||
}
|
||||
},
|
||||
"gallery": {
|
||||
"revealPendingTitle": "The photos are still a surprise",
|
||||
"revealPendingMessage": "The host will reveal the gallery later — check back soon!",
|
||||
"revealScheduledFor": "Reveal scheduled for {{date}}",
|
||||
"revealUploadHint": "You can already add your own photos to the collection:",
|
||||
"expires": "Expires",
|
||||
"expired": "Expired",
|
||||
"contactOrganizer": "Please contact the event organizer if you need access to these photos.",
|
||||
@@ -511,6 +515,17 @@
|
||||
"failedToToggleDownloads": "Failed to update download permission"
|
||||
},
|
||||
"events": {
|
||||
"revealMode": "Reveal mode (hide gallery until reveal)",
|
||||
"revealModeHelp": "Guests can upload but see no photos until you reveal the gallery — manually or at the scheduled time. Slideshow and client access keep working.",
|
||||
"revealAt": "Scheduled reveal (optional)",
|
||||
"revealAtHelp": "Leave empty to reveal manually with the \"Reveal now\" button.",
|
||||
"revealModeStatus": "Reveal mode",
|
||||
"revealed": "Revealed",
|
||||
"hiddenUntilReveal": "Hidden from guests",
|
||||
"revealScheduled": "Scheduled: {{date}}",
|
||||
"revealNow": "Reveal now",
|
||||
"revealedToast": "Gallery revealed — guests can see the photos now",
|
||||
"revealError": "Failed to reveal the gallery",
|
||||
"title": "Events",
|
||||
"create": "Create",
|
||||
"createEvent": "Create Event",
|
||||
|
||||
@@ -223,6 +223,10 @@
|
||||
"passwordHint": "La contraseña la proporcionó el organizador del evento. Contacta con ellos si no la tienes."
|
||||
},
|
||||
"gallery": {
|
||||
"revealPendingTitle": "Las fotos siguen siendo una sorpresa",
|
||||
"revealPendingMessage": "El anfitrión revelará la galería más tarde — ¡vuelve pronto!",
|
||||
"revealScheduledFor": "Revelación programada para {{date}}",
|
||||
"revealUploadHint": "Ya puedes añadir tus propias fotos a la colección:",
|
||||
"title": "Galería de fotos",
|
||||
"welcomeMessage": "Mensaje de bienvenida",
|
||||
"expiresOn": "Expira el",
|
||||
@@ -342,6 +346,17 @@
|
||||
"categoryHeroHint": "Si no se establece una foto de portada para una categoría, se usará la foto hero por defecto."
|
||||
},
|
||||
"events": {
|
||||
"revealMode": "Modo revelación (ocultar la galería hasta revelarla)",
|
||||
"revealModeHelp": "Los invitados pueden subir fotos pero no verlas hasta que reveles la galería — manualmente o a la hora programada. La presentación y el acceso de clientes siguen funcionando.",
|
||||
"revealAt": "Revelación programada (opcional)",
|
||||
"revealAtHelp": "Déjalo vacío para revelar manualmente con el botón \"Revelar ahora\".",
|
||||
"revealModeStatus": "Modo revelación",
|
||||
"revealed": "Revelada",
|
||||
"hiddenUntilReveal": "Oculta para los invitados",
|
||||
"revealScheduled": "Programada: {{date}}",
|
||||
"revealNow": "Revelar ahora",
|
||||
"revealedToast": "Galería revelada — los invitados ya pueden ver las fotos",
|
||||
"revealError": "No se pudo revelar la galería",
|
||||
"title": "Eventos",
|
||||
"create": "Crear",
|
||||
"createEvent": "Crear evento",
|
||||
|
||||
@@ -221,6 +221,10 @@
|
||||
"passwordHint": "Le mot de passe a été fourni par l'organisateur de l'événement. Contactez-le si vous ne l'avez pas."
|
||||
},
|
||||
"gallery": {
|
||||
"revealPendingTitle": "Les photos sont encore une surprise",
|
||||
"revealPendingMessage": "L'hôte dévoilera la galerie plus tard — revenez bientôt !",
|
||||
"revealScheduledFor": "Révélation prévue le {{date}}",
|
||||
"revealUploadHint": "Vous pouvez déjà ajouter vos propres photos à la collection :",
|
||||
"expires": "Expire",
|
||||
"expired": "Expiré",
|
||||
"contactOrganizer": "Veuillez contacter l'organisateur de l'événement si vous avez besoin d'accéder à ces photos.",
|
||||
@@ -358,6 +362,17 @@
|
||||
"categoryHeroHint": "Si aucune photo de couverture n'est définie pour une catégorie, la photo par défaut sera utilisée."
|
||||
},
|
||||
"events": {
|
||||
"revealMode": "Mode révélation (masquer la galerie jusqu'à la révélation)",
|
||||
"revealModeHelp": "Les invités peuvent téléverser mais ne voient aucune photo tant que vous ne révélez pas la galerie — manuellement ou à l'heure programmée. Le diaporama et l'accès client continuent de fonctionner.",
|
||||
"revealAt": "Révélation programmée (optionnel)",
|
||||
"revealAtHelp": "Laissez vide pour révéler manuellement avec le bouton « Révéler maintenant ».",
|
||||
"revealModeStatus": "Mode révélation",
|
||||
"revealed": "Révélée",
|
||||
"hiddenUntilReveal": "Masquée pour les invités",
|
||||
"revealScheduled": "Programmée : {{date}}",
|
||||
"revealNow": "Révéler maintenant",
|
||||
"revealedToast": "Galerie révélée — les invités peuvent maintenant voir les photos",
|
||||
"revealError": "Impossible de révéler la galerie",
|
||||
"title": "Événements",
|
||||
"create": "Créer",
|
||||
"createEvent": "Créer un événement",
|
||||
|
||||
@@ -221,6 +221,10 @@
|
||||
"passwordHint": "Het wachtwoord is verstrekt door de organisator van het evenement. Neem contact op als u het niet heeft."
|
||||
},
|
||||
"gallery": {
|
||||
"revealPendingTitle": "De foto's zijn nog een verrassing",
|
||||
"revealPendingMessage": "De gastheer onthult de galerij later — kom snel terug!",
|
||||
"revealScheduledFor": "Onthulling gepland voor {{date}}",
|
||||
"revealUploadHint": "Je kunt nu al je eigen foto's aan de collectie toevoegen:",
|
||||
"expires": "Verloopt",
|
||||
"expired": "Verlopen",
|
||||
"contactOrganizer": "Neem contact op met de organisator als u toegang tot deze foto's nodig heeft.",
|
||||
@@ -358,6 +362,17 @@
|
||||
"categoryHeroHint": "Als er geen omslagfoto is ingesteld voor een categorie, wordt de standaard hero-foto gebruikt."
|
||||
},
|
||||
"events": {
|
||||
"revealMode": "Onthullingsmodus (galerij verbergen tot de onthulling)",
|
||||
"revealModeHelp": "Gasten kunnen uploaden maar zien geen foto's totdat je de galerij onthult — handmatig of op het geplande tijdstip. Diavoorstelling en klanttoegang blijven werken.",
|
||||
"revealAt": "Geplande onthulling (optioneel)",
|
||||
"revealAtHelp": "Laat leeg om handmatig te onthullen met de knop \"Nu onthullen\".",
|
||||
"revealModeStatus": "Onthullingsmodus",
|
||||
"revealed": "Onthuld",
|
||||
"hiddenUntilReveal": "Verborgen voor gasten",
|
||||
"revealScheduled": "Gepland: {{date}}",
|
||||
"revealNow": "Nu onthullen",
|
||||
"revealedToast": "Galerij onthuld — gasten kunnen de foto's nu zien",
|
||||
"revealError": "Galerij onthullen mislukt",
|
||||
"title": "Evenementen",
|
||||
"create": "Aanmaken",
|
||||
"createEvent": "Evenement aanmaken",
|
||||
|
||||
@@ -224,6 +224,10 @@
|
||||
"passwordHint": "A senha foi fornecida pelo organizador do evento. Entre em contato se não a tiver."
|
||||
},
|
||||
"gallery": {
|
||||
"revealPendingTitle": "As fotos ainda são uma surpresa",
|
||||
"revealPendingMessage": "O anfitrião revelará a galeria mais tarde — volte em breve!",
|
||||
"revealScheduledFor": "Revelação agendada para {{date}}",
|
||||
"revealUploadHint": "Você já pode adicionar suas próprias fotos à coleção:",
|
||||
"expires": "Expira",
|
||||
"expired": "Expirada",
|
||||
"contactOrganizer": "Entre em contato com o organizador do evento se precisar de acesso a estas fotos",
|
||||
@@ -366,6 +370,17 @@
|
||||
"categoryHeroHint": "Se nenhuma foto de capa for definida, a foto de destaque padrão será usada."
|
||||
},
|
||||
"events": {
|
||||
"revealMode": "Modo revelação (ocultar a galeria até a revelação)",
|
||||
"revealModeHelp": "Os convidados podem enviar fotos, mas não as veem até você revelar a galeria — manualmente ou no horário agendado. A apresentação de slides e o acesso do cliente continuam funcionando.",
|
||||
"revealAt": "Revelação agendada (opcional)",
|
||||
"revealAtHelp": "Deixe vazio para revelar manualmente com o botão \"Revelar agora\".",
|
||||
"revealModeStatus": "Modo revelação",
|
||||
"revealed": "Revelada",
|
||||
"hiddenUntilReveal": "Oculta para os convidados",
|
||||
"revealScheduled": "Agendada: {{date}}",
|
||||
"revealNow": "Revelar agora",
|
||||
"revealedToast": "Galeria revelada — os convidados já podem ver as fotos",
|
||||
"revealError": "Falha ao revelar a galeria",
|
||||
"title": "Eventos",
|
||||
"create": "Criar",
|
||||
"createEvent": "Criar Evento",
|
||||
|
||||
@@ -227,6 +227,10 @@
|
||||
"passwordHint": "Пароль предоставлен организатором события. Свяжитесь с ним, если у вас его нет."
|
||||
},
|
||||
"gallery": {
|
||||
"revealPendingTitle": "Фотографии пока остаются сюрпризом",
|
||||
"revealPendingMessage": "Организатор откроет галерею позже — загляните ещё раз!",
|
||||
"revealScheduledFor": "Открытие запланировано на {{date}}",
|
||||
"revealUploadHint": "Вы уже можете добавить свои фотографии в коллекцию:",
|
||||
"expires": "Истекает",
|
||||
"expired": "Истекла",
|
||||
"contactOrganizer": "Свяжитесь с организатором события, если вам нужен доступ к этим фотографиям",
|
||||
@@ -374,6 +378,17 @@
|
||||
"categoryHeroHint": "Если обложка для категории не задана, будет использоваться стандартное главное фото."
|
||||
},
|
||||
"events": {
|
||||
"revealMode": "Режим сюрприза (скрыть галерею до открытия)",
|
||||
"revealModeHelp": "Гости могут загружать фото, но не видят их, пока вы не откроете галерею — вручную или в запланированное время. Слайд-шоу и клиентский доступ продолжают работать.",
|
||||
"revealAt": "Запланированное открытие (необязательно)",
|
||||
"revealAtHelp": "Оставьте пустым, чтобы открыть вручную кнопкой «Открыть сейчас».",
|
||||
"revealModeStatus": "Режим сюрприза",
|
||||
"revealed": "Открыта",
|
||||
"hiddenUntilReveal": "Скрыта от гостей",
|
||||
"revealScheduled": "Запланировано: {{date}}",
|
||||
"revealNow": "Открыть сейчас",
|
||||
"revealedToast": "Галерея открыта — гости теперь видят фотографии",
|
||||
"revealError": "Не удалось открыть галерею",
|
||||
"title": "События",
|
||||
"create": "Создать",
|
||||
"createEvent": "Создать событие",
|
||||
|
||||
@@ -221,6 +221,10 @@
|
||||
"passwordHint": "Geslo vam je posredoval organizator dogodka. Če ga nimate, se obrnite nanj."
|
||||
},
|
||||
"gallery": {
|
||||
"revealPendingTitle": "Fotografije so še presenečenje",
|
||||
"revealPendingMessage": "Gostitelj bo galerijo razkril pozneje — kmalu preverite znova!",
|
||||
"revealScheduledFor": "Razkritje načrtovano za {{date}}",
|
||||
"revealUploadHint": "Svoje fotografije lahko dodate v zbirko že zdaj:",
|
||||
"expires": "Poteče",
|
||||
"expired": "Poteklo",
|
||||
"contactOrganizer": "Če potrebujete dostop do teh fotografij, se obrnite na organizatorja dogodka.",
|
||||
@@ -358,6 +362,17 @@
|
||||
"categoryHeroHint": "Če za kategorijo ni nastavljena naslovna fotografija, bo uporabljena privzeta hero fotografija."
|
||||
},
|
||||
"events": {
|
||||
"revealMode": "Način razkritja (skrij galerijo do razkritja)",
|
||||
"revealModeHelp": "Gostje lahko nalagajo fotografije, vendar jih ne vidijo, dokler galerije ne razkrijete — ročno ali ob načrtovanem času. Diaprojekcija in dostop za stranke delujeta naprej.",
|
||||
"revealAt": "Načrtovano razkritje (neobvezno)",
|
||||
"revealAtHelp": "Pustite prazno za ročno razkritje z gumbom »Razkrij zdaj«.",
|
||||
"revealModeStatus": "Način razkritja",
|
||||
"revealed": "Razkrita",
|
||||
"hiddenUntilReveal": "Skrita za goste",
|
||||
"revealScheduled": "Načrtovano: {{date}}",
|
||||
"revealNow": "Razkrij zdaj",
|
||||
"revealedToast": "Galerija razkrita — gostje zdaj vidijo fotografije",
|
||||
"revealError": "Galerije ni bilo mogoče razkriti",
|
||||
"title": "Dogodki",
|
||||
"create": "Ustvari",
|
||||
"createEvent": "Ustvari dogodek",
|
||||
|
||||
@@ -206,6 +206,18 @@ export const EventDetailsPage: React.FC = () => {
|
||||
});
|
||||
|
||||
// Archive mutation
|
||||
// Reveal now (#838)
|
||||
const revealMutation = useMutation({
|
||||
mutationFn: () => eventsService.revealEvent(Number(id)),
|
||||
onSuccess: () => {
|
||||
toast.success(t('events.revealedToast', 'Gallery revealed — guests can see the photos now'));
|
||||
refetchEvent();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('events.revealError', 'Failed to reveal the gallery'));
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
|
||||
onSuccess: () => {
|
||||
@@ -289,6 +301,11 @@ export const EventDetailsPage: React.FC = () => {
|
||||
css_template_id: event.css_template_id || null,
|
||||
expires_at: expiresAtDate ? format(expiresAtDate, 'yyyy-MM-dd') : '',
|
||||
allow_user_uploads: event.allow_user_uploads || false,
|
||||
reveal_mode: event.reveal_mode || false,
|
||||
// datetime-local wants local "YYYY-MM-DDTHH:mm"
|
||||
reveal_at: event.reveal_at
|
||||
? (() => { const d = new Date(event.reveal_at); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); })()
|
||||
: '',
|
||||
upload_category_id: event.upload_category_id || null,
|
||||
hero_photo_id: event.hero_photo_id || null,
|
||||
customer_name: event.customer_name || '',
|
||||
@@ -428,6 +445,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const updateData: any = {
|
||||
expires_at: editForm.expires_at || null,
|
||||
allow_user_uploads: editForm.allow_user_uploads,
|
||||
reveal_mode: editForm.allow_user_uploads && editForm.reveal_mode,
|
||||
reveal_at: editForm.allow_user_uploads && editForm.reveal_mode && editForm.reveal_at
|
||||
? new Date(editForm.reveal_at).toISOString()
|
||||
: null,
|
||||
require_password: editForm.require_password,
|
||||
css_template_id: editForm.css_template_id,
|
||||
// Download protection settings
|
||||
@@ -566,6 +587,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
photos={photos}
|
||||
phoneFieldEnabled={phoneFieldEnabled}
|
||||
daysUntilExpiration={daysUntilExpiration}
|
||||
onRevealNow={() => revealMutation.mutate()}
|
||||
refetchEvent={refetchEvent}
|
||||
setActiveTab={setActiveTab}
|
||||
setShowPasswordReset={setShowPasswordReset}
|
||||
|
||||
@@ -43,6 +43,8 @@ interface EventInformationCardProps {
|
||||
photos: AdminPhoto[];
|
||||
phoneFieldEnabled: boolean;
|
||||
daysUntilExpiration: number | null;
|
||||
// Reveal mode (#838): stamps revealed_at via POST /events/:id/reveal
|
||||
onRevealNow?: () => void;
|
||||
}
|
||||
|
||||
export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
@@ -58,7 +60,8 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
categories,
|
||||
photos,
|
||||
phoneFieldEnabled,
|
||||
daysUntilExpiration
|
||||
daysUntilExpiration,
|
||||
onRevealNow
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
@@ -430,6 +433,42 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reveal mode (#838) — only meaningful with guest uploads */}
|
||||
{editForm.allow_user_uploads && (
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.reveal_mode}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, reveal_mode: e.target.checked }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('events.revealMode', 'Reveal mode (hide gallery until reveal)')}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
|
||||
{t('events.revealModeHelp', 'Guests can upload but see no photos until you reveal the gallery — manually or at the scheduled time. Slideshow and client access keep working.')}
|
||||
</p>
|
||||
{editForm.reveal_mode && (
|
||||
<div className="mt-2 ml-6">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.revealAt', 'Scheduled reveal (optional)')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editForm.reveal_at}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, reveal_at: e.target.value }))}
|
||||
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('events.revealAtHelp', 'Leave empty to reveal manually with the "Reveal now" button.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('feedback.settings.title', 'Guest Feedback Settings')}</h3>
|
||||
@@ -834,6 +873,39 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{Boolean(event.reveal_mode) && (
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.revealModeStatus', 'Reveal mode')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.revealed_at ? (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/40 rounded">
|
||||
{t('events.revealed', 'Revealed')}
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 bg-amber-100 dark:bg-amber-900/40 rounded">
|
||||
{t('events.hiddenUntilReveal', 'Hidden from guests')}
|
||||
</span>
|
||||
{event.reveal_at && (
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400">
|
||||
{t('events.revealScheduled', 'Scheduled: {{date}}', { date: new Date(event.reveal_at).toLocaleString() })}
|
||||
</p>
|
||||
)}
|
||||
{onRevealNow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRevealNow}
|
||||
className="block px-3 py-1.5 text-xs font-medium text-white bg-accent hover:bg-accent-dark rounded transition-colors"
|
||||
>
|
||||
{t('events.revealNow', 'Reveal now')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Protection Display */}
|
||||
<div className="pt-3 mt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400 flex items-center gap-2">
|
||||
|
||||
@@ -32,6 +32,7 @@ interface OverviewTabProps {
|
||||
photos: AdminPhoto[];
|
||||
phoneFieldEnabled: boolean;
|
||||
daysUntilExpiration: number | null;
|
||||
onRevealNow?: () => void;
|
||||
refetchEvent: () => void;
|
||||
setActiveTab: (tab: EventDetailsTab) => void;
|
||||
setShowPasswordReset: (show: boolean) => void;
|
||||
@@ -63,6 +64,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
photos,
|
||||
phoneFieldEnabled,
|
||||
daysUntilExpiration,
|
||||
onRevealNow,
|
||||
refetchEvent,
|
||||
setActiveTab,
|
||||
setShowPasswordReset,
|
||||
@@ -100,6 +102,7 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
|
||||
photos={photos}
|
||||
phoneFieldEnabled={phoneFieldEnabled}
|
||||
daysUntilExpiration={daysUntilExpiration}
|
||||
onRevealNow={onRevealNow}
|
||||
/>
|
||||
|
||||
{/* Share Link */}
|
||||
|
||||
@@ -6,6 +6,9 @@ export type EditFormState = {
|
||||
css_template_id: number | null;
|
||||
expires_at: string;
|
||||
allow_user_uploads: boolean;
|
||||
// Reveal mode (#838): reveal_at is a datetime-local input string ('' = none)
|
||||
reveal_mode: boolean;
|
||||
reveal_at: string;
|
||||
upload_category_id: number | null;
|
||||
hero_photo_id: number | null;
|
||||
customer_name: string;
|
||||
@@ -54,6 +57,8 @@ export const INITIAL_EDIT_FORM: EditFormState = {
|
||||
css_template_id: null,
|
||||
expires_at: '',
|
||||
allow_user_uploads: false,
|
||||
reveal_mode: false,
|
||||
reveal_at: '',
|
||||
upload_category_id: null,
|
||||
hero_photo_id: null,
|
||||
customer_name: '',
|
||||
|
||||
@@ -32,7 +32,7 @@ const TRIGGERS = [
|
||||
'quote.sent', 'quote.accepted', 'quote.declined',
|
||||
'contract.sent', 'contract.signed',
|
||||
'event.date_approaching',
|
||||
'gallery.published', 'gallery.expiring', 'gallery.expired',
|
||||
'gallery.published', 'gallery.expiring', 'gallery.expired', 'gallery.revealed',
|
||||
'customer.created',
|
||||
];
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ interface UpdateEventData {
|
||||
expires_at?: string;
|
||||
is_active?: boolean;
|
||||
allow_user_uploads?: boolean;
|
||||
// Reveal mode (#838)
|
||||
reveal_mode?: boolean;
|
||||
reveal_at?: string | null;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
source_mode?: 'managed' | 'reference';
|
||||
@@ -129,6 +132,12 @@ export const eventsService = {
|
||||
},
|
||||
|
||||
// Update event (admin)
|
||||
// Reveal now (#838): stamps revealed_at so the gallery opens for guests.
|
||||
async revealEvent(id: number): Promise<{ revealed_at: string }> {
|
||||
const response = await api.post(`/admin/events/${id}/reveal`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
|
||||
const response = await api.put<Event>(`/admin/events/${id}`, data);
|
||||
return response.data;
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface Event {
|
||||
uploaded_at: string;
|
||||
}>;
|
||||
allow_user_uploads?: boolean;
|
||||
// Reveal mode (#838)
|
||||
reveal_mode?: boolean;
|
||||
reveal_at?: string | null;
|
||||
revealed_at?: string | null;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
total_views?: number;
|
||||
@@ -208,6 +212,10 @@ export interface GalleryData {
|
||||
};
|
||||
categories?: PhotoCategory[];
|
||||
photos: Photo[];
|
||||
// Reveal mode (#838): the server returns the event shell with photos: []
|
||||
// and this flag while the gallery is hidden from guests.
|
||||
hidden_until_reveal?: boolean;
|
||||
reveal_at?: string | null;
|
||||
}
|
||||
|
||||
export interface GalleryStats {
|
||||
|
||||
Reference in New Issue
Block a user