feat(gallery): reveal mode — hide gallery from guests until reveal (#838) (#856)

* 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:
Paul Nothaft
2026-07-22 20:59:59 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 3d6c9848dc
commit 2f05fcc39d
29 changed files with 1108 additions and 24 deletions
@@ -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';
+15
View File
@@ -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",
+15
View File
@@ -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",
+15
View File
@@ -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",
+15
View File
@@ -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",
+15
View File
@@ -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",
+15
View File
@@ -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",
+15
View File
@@ -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": "Создать событие",
+15
View File
@@ -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',
];
+9
View File
@@ -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;
+8
View File
@@ -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 {