feat(downloads): per-gallery download resolutions (#858) (#1022)

Clients who need smaller files no longer make the photographer re-export. Two capabilities, both off by default.

STANDARD RESOLUTION — the size a gallery hands out for every ordinary download (single, selected, download-all). Global default in Settings, overridable per gallery with the NULL=inherit tri-state. The pre-built download-all zip is built AT the standard resolution, so changing it invalidates those archives, including a fan-out to inheriting galleries.

RESOLUTION PICKER — opt-in modal letting guests choose a different size. Custom archives are built as a DB-backed job the client polls, never cached. The picker never offers a size above the standard, and Original reappears only when the admin explicitly allows it.

Resize is fit:'inside' + withoutEnlargement — aspect preserved, never upscaled — applied before the watermark, since the mark is sized relative to its input.

Three rounds of external review hardened this: job archives are bound to the requester's visibility scope and re-validated at delivery, the streamed download-all path applies the cap, queue admission is bounded, and rejected resolutions no longer inflate download stats.

Closes #858.
This commit is contained in:
Paul Nothaft
2026-08-11 09:46:46 +02:00
committed by GitHub
parent 02deac9f10
commit 8e3573788b
29 changed files with 2716 additions and 82 deletions
@@ -0,0 +1,193 @@
/**
* <DownloadResolutionCard>
*
* Per-event override for the download-resolution settings (#858). The globals
* live in Settings → Download resolutions; this card lets one gallery differ.
*
* Every field is tri-state — "Inherit" writes NULL and the gallery follows the
* global, matching the show_watermark / show_qr convention. The card shows the
* inherited value inline so an admin can see what "Inherit" currently means
* without leaving the page.
*
* Reads GET /api/admin/events/:id/download-resolutions (which returns the raw
* overrides, the globals, and the resolved effective policy) and saves through
* PATCH on the same path.
*/
import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Download, Save } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { api } from '../../config/api';
import type { DownloadResolutionChoice } from '../../types';
const INHERIT = '__inherit__';
const ORIGINAL = 'original';
interface Payload {
overrides: {
download_standard_resolution: string | null;
download_resolution_picker_enabled: boolean | null;
download_allow_original: boolean | null;
};
globals: {
standard_resolution: string;
picker_enabled: boolean;
allow_original: boolean;
resolutions: DownloadResolutionChoice[];
};
effective: {
standard: string;
picker_enabled: boolean;
allow_original: boolean;
choices: DownloadResolutionChoice[];
};
}
export interface DownloadResolutionCardProps {
eventId: number;
onChanged?: () => void;
}
/** null → "Inherit"; true/false → explicit. */
const triToSelect = (v: boolean | null | undefined) =>
(v === null || v === undefined ? INHERIT : String(v));
const selectToTri = (v: string) => (v === INHERIT ? null : v === 'true');
export const DownloadResolutionCard: React.FC<DownloadResolutionCardProps> = ({ eventId, onChanged }) => {
const { t } = useTranslation();
const [standard, setStandard] = useState<string>(INHERIT);
const [picker, setPicker] = useState<string>(INHERIT);
const [allowOriginal, setAllowOriginal] = useState<string>(INHERIT);
const [saving, setSaving] = useState(false);
const { data, isLoading, refetch } = useQuery<Payload>({
queryKey: ['event-download-resolutions', eventId],
queryFn: async () => (await api.get(`/admin/events/${eventId}/download-resolutions`)).data,
});
useEffect(() => {
if (!data) return;
setStandard(data.overrides.download_standard_resolution ?? INHERIT);
setPicker(triToSelect(data.overrides.download_resolution_picker_enabled));
setAllowOriginal(triToSelect(data.overrides.download_allow_original));
}, [data]);
if (isLoading || !data) {
return <Card><Loading /></Card>;
}
const globalStandardLabel = data.globals.standard_resolution === ORIGINAL
? t('settings.downloads.original', 'Original (full size)')
: data.globals.standard_resolution;
const save = async () => {
setSaving(true);
try {
await api.patch(`/admin/events/${eventId}/download-resolutions`, {
download_standard_resolution: standard === INHERIT ? null : standard,
download_resolution_picker_enabled: selectToTri(picker),
download_allow_original: selectToTri(allowOriginal),
});
toast.success(t('settings.saved', 'Settings saved'));
await refetch();
onChanged?.();
} catch (e: unknown) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
toast.error(msg || t('settings.saveError', 'Failed to save settings'));
} finally {
setSaving(false);
}
};
// Same input styling as the other admin cards (SlideshowStyleFields) —
// notably the explicit text colour, without which the select renders
// muted and reads as disabled.
const selectClass = 'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm';
return (
<Card>
<div className="flex items-center gap-2 mb-1">
<Download className="w-5 h-5 text-neutral-500" />
<h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
{t('settings.downloads.eventTitle', 'Download resolution')}
</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.downloads.eventIntro',
'Override the site-wide download settings for this gallery only. "Inherit" follows Settings → Download resolutions.')}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
{t('settings.downloads.standard', 'Standard resolution')}
</label>
<select className={selectClass} value={standard} onChange={(e) => setStandard(e.target.value)}>
<option value={INHERIT}>
{t('settings.downloads.inheritWith', 'Inherit ({{value}})', { value: globalStandardLabel })}
</option>
<option value={ORIGINAL}>{t('settings.downloads.original', 'Original (full size)')}</option>
{data.globals.resolutions.map((r) => (
<option key={r.id} value={r.id}>{r.label} {r.width} × {r.height}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
{t('settings.downloads.picker', 'Let guests choose a download size')}
</label>
<select className={selectClass} value={picker} onChange={(e) => setPicker(e.target.value)}>
<option value={INHERIT}>
{t('settings.downloads.inheritWith', 'Inherit ({{value}})', {
value: data.globals.picker_enabled ? t('common.on', 'on') : t('common.off', 'off'),
})}
</option>
<option value="true">{t('common.on', 'on')}</option>
<option value="false">{t('common.off', 'off')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
{t('settings.downloads.allowOriginal', 'Offer "Original" in the picker')}
</label>
<select
className={selectClass}
value={allowOriginal}
onChange={(e) => setAllowOriginal(e.target.value)}
>
<option value={INHERIT}>
{t('settings.downloads.inheritWith', 'Inherit ({{value}})', {
value: data.globals.allow_original ? t('common.on', 'on') : t('common.off', 'off'),
})}
</option>
<option value="true">{t('common.on', 'on')}</option>
<option value="false">{t('common.off', 'off')}</option>
</select>
</div>
</div>
{/* What the gallery actually does right now, after the cascade. */}
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-4">
{t('settings.downloads.effective', 'Currently hands out: {{standard}}', {
standard: data.effective.standard === ORIGINAL
? t('settings.downloads.original', 'Original (full size)')
: data.effective.standard,
})}
{data.effective.picker_enabled
? ` · ${t('settings.downloads.pickerOn', 'guests may choose another size')}`
: ''}
</p>
<div className="flex justify-end mt-4">
<Button variant="primary" onClick={save} disabled={saving} leftIcon={<Save className="w-4 h-4" />}>
{t('common.save', 'Save')}
</Button>
</div>
</Card>
);
};
@@ -0,0 +1,248 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Download, Check, AlertCircle, X, Loader2 } from 'lucide-react';
import { Button, Card } from '../common';
import { galleryService } from '../../services/gallery.service';
import type { DownloadResolutionChoice, DownloadJobStatus } from '../../types';
/**
* Resolution picker for gallery downloads (#858).
*
* Shown after "Download all"/"Download selected" when the gallery has the
* picker enabled. A non-standard size has nothing cached behind it and can
* take minutes to build, so the server prepares it as a job and this modal
* walks the three states the build actually has:
*
* choose → preparing (poll) → ready (click to download)
*
* The download itself is a native browser navigation, so the archive streams
* with Content-Length and the browser shows a real progress bar rather than
* us buffering a multi-GB blob in memory.
*/
const POLL_MS = 1500;
// Give up after ~10 minutes of polling. The job may well still finish server
// side; this only stops the modal spinning forever in front of the user.
const MAX_POLLS = (10 * 60 * 1000) / POLL_MS;
type Phase = 'choose' | 'preparing' | 'ready' | 'error';
interface DownloadResolutionModalProps {
slug: string;
choices: DownloadResolutionChoice[];
/** The gallery's own standard size — served from the pre-built archive. */
standardResolution?: string;
/** Omitted = the whole gallery. */
photoIds?: number[];
onClose: () => void;
}
export const DownloadResolutionModal: React.FC<DownloadResolutionModalProps> = ({
slug,
choices,
standardResolution,
photoIds,
onClose,
}) => {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>('choose');
const [selected, setSelected] = useState<string>(choices[0]?.id ?? 'original');
const [error, setError] = useState<string | null>(null);
const [photoCount, setPhotoCount] = useState(0);
const tokenRef = useRef<string | null>(null);
// Guards the polling loop against running on after unmount / close.
const activeRef = useRef(true);
useEffect(() => () => { activeRef.current = false; }, []);
const filename = `${slug}-${selected === 'original' ? 'original' : selected}.zip`;
const poll = useCallback(async (token: string) => {
for (let i = 0; i < MAX_POLLS; i += 1) {
if (!activeRef.current) return;
await new Promise((r) => setTimeout(r, POLL_MS));
if (!activeRef.current) return;
try {
const state = await galleryService.getDownloadJob(slug, token);
setPhotoCount(state.photo_count || 0);
if (state.status === 'ready') {
setPhase('ready');
return;
}
if (state.status === 'failed') {
setError(state.error || t('gallery.downloadPrepFailed', 'Preparation failed'));
setPhase('error');
return;
}
} catch {
setError(t('gallery.downloadPrepFailed', 'Preparation failed'));
setPhase('error');
return;
}
}
setError(t('gallery.downloadPrepTimeout', 'This is taking longer than expected. Please try again.'));
setPhase('error');
}, [slug, t]);
const start = useCallback(async () => {
// Whole-gallery download at the gallery's OWN standard size is exactly
// what the pre-built archive already contains — take it instead of
// re-resizing and re-packaging the entire gallery for the same bytes.
if (!photoIds && selected === standardResolution) {
await galleryService.downloadAllPhotos(slug, true);
onClose();
return;
}
setPhase('preparing');
setError(null);
try {
const job = await galleryService.startDownloadJob(slug, selected, photoIds);
tokenRef.current = job.token;
// A job can be deduped onto an already-finished build, in which case
// there is nothing to wait for.
if ((job.status as DownloadJobStatus) === 'ready') {
setPhase('ready');
return;
}
await poll(job.token);
} catch {
setError(t('gallery.downloadPrepFailed', 'Preparation failed'));
setPhase('error');
}
}, [slug, selected, photoIds, poll, t, standardResolution, onClose]);
const download = useCallback(() => {
if (!tokenRef.current) return;
galleryService.downloadJobFile(slug, tokenRef.current, filename);
onClose();
}, [slug, filename, onClose]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={t('gallery.chooseResolution', 'Choose a download size')}
>
<Card
className="max-w-md w-full"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<div className="flex items-start justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('gallery.chooseResolution', 'Choose a download size')}
</h2>
<button
type="button"
onClick={onClose}
aria-label={t('common.close', 'Close')}
className="text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"
>
<X className="w-5 h-5" />
</button>
</div>
{phase === 'choose' && (
<>
<div className="space-y-2 mb-5">
{choices.map((choice) => (
<label
key={choice.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selected === choice.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
<input
type="radio"
name="download-resolution"
value={choice.id}
checked={selected === choice.id}
onChange={() => setSelected(choice.id)}
className="accent-primary-600"
/>
<span className="flex-1 min-w-0">
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
{choice.label}
</span>
{choice.width && choice.height && (
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{t('gallery.resolutionUpTo', 'up to {{width}} × {{height}} px', {
width: choice.width,
height: choice.height,
})}
</span>
)}
</span>
</label>
))}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button variant="primary" onClick={start} leftIcon={<Download className="w-4 h-4" />}>
{t('gallery.prepareDownload', 'Prepare download')}
</Button>
</div>
</>
)}
{phase === 'preparing' && (
<div className="py-6 text-center">
<Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-primary-600" />
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('gallery.preparingDownload', 'Preparing your download…')}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{photoCount > 0
? t('gallery.preparingProgress', '{{count}} photos packaged', { count: photoCount })
: t('gallery.preparingHint', 'Resizing photos — this can take a moment for large galleries.')}
</p>
</div>
)}
{phase === 'ready' && (
<div className="py-6 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<Check className="w-6 h-6 text-green-600 dark:text-green-400" />
</div>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-4">
{t('gallery.downloadReady', 'Your download is ready')}
</p>
<Button variant="primary" onClick={download} leftIcon={<Download className="w-4 h-4" />}>
{t('gallery.downloadNow', 'Download')}
</Button>
</div>
)}
{phase === 'error' && (
<div className="py-6 text-center">
<AlertCircle className="w-8 h-8 mx-auto mb-3 text-red-600 dark:text-red-400" />
<p className="text-sm text-neutral-700 dark:text-neutral-300 mb-4">{error}</p>
<div className="flex justify-center gap-2">
<Button variant="outline" onClick={onClose}>
{t('common.close', 'Close')}
</Button>
<Button variant="primary" onClick={() => setPhase('choose')}>
{t('common.retry', 'Try again')}
</Button>
</div>
</div>
)}
</Card>
</div>
);
};
@@ -8,6 +8,7 @@ import { GallerySkeleton } from './GallerySkeleton';
import { useGalleryAuth, useTheme } from '../../contexts';
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
import { DownloadResolutionModal } from './DownloadResolutionModal';
import { ExpirationBanner } from './ExpirationBanner';
import { CountdownTimer } from './CountdownTimer';
import { GalleryLayout } from './GalleryLayout';
@@ -73,6 +74,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { setTheme, theme } = useTheme();
const queryClient = useQueryClient();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
// Download size picker (#858). `showResolutionPicker` covers "download all";
// `resolutionPickerIds` covers a selection (sidebar / full-page layouts).
const [showResolutionPicker, setShowResolutionPicker] = useState(false);
const [resolutionPickerIds, setResolutionPickerIds] = useState<number[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
const [sortDesc, setSortDesc] = useState(true);
@@ -607,12 +612,25 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Check if downloads are allowed (both event setting and not expired)
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
// Resolution picker choices (#858). More than one option means there is an
// actual choice to make; a single option is just the standard size, so skip
// the modal and download straight away.
const downloadChoices = data?.event?.download_resolution?.picker_enabled
? (data.event.download_resolution.choices || [])
: [];
const handleDownloadAll = () => {
// Prevent downloads if gallery is expired or downloads disabled
if (!allowDownloads) {
return;
}
// Hand off to the picker; it builds the archive as a job and downloads it.
if (downloadChoices.length > 1) {
setShowResolutionPicker(true);
return;
}
downloadAllMutation.mutate({ slug, zipReady: data?.event?.download_zip_ready });
// Track download all action
@@ -625,12 +643,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return;
// Prevent downloads if gallery is expired or downloads disabled
if (!allowDownloads) {
return;
}
// Resolution picker (#858): sidebar-driven selections get the same choice
// as the grid's own control, rather than silently downloading at the
// gallery standard.
if (downloadChoices.length > 1) {
setResolutionPickerIds(Array.from(selectedPhotos));
return;
}
const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
// Track bulk download
@@ -771,6 +797,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onClose={() => setShowUploadModal(false)}
/>
)}
{/* Download size picker (#858) — "download all", or a selection. */}
{(showResolutionPicker || resolutionPickerIds) && (
<DownloadResolutionModal
slug={slug}
choices={downloadChoices}
standardResolution={data?.event?.download_resolution?.standard}
photoIds={resolutionPickerIds || undefined}
onClose={() => {
setShowResolutionPicker(false);
setResolutionPickerIds(null);
}}
/>
)}
</GalleryLayout>
);
}
@@ -814,6 +854,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
eventDate={event.event_date}
expiresAt={event.expires_at}
allowDownloads={allowDownloads}
downloadChoices={downloadChoices}
downloadStandard={data?.event?.download_resolution?.standard}
protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'}
disableRightClick={disableRightClick}
@@ -842,6 +884,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onClose={() => setShowUploadModal(false)}
/>
)}
{/* Download size picker (#858) — "download all", or a selection. */}
{(showResolutionPicker || resolutionPickerIds) && (
<DownloadResolutionModal
slug={slug}
choices={downloadChoices}
standardResolution={data?.event?.download_resolution?.standard}
photoIds={resolutionPickerIds || undefined}
onClose={() => {
setShowResolutionPicker(false);
setResolutionPickerIds(null);
}}
/>
)}
</>
);
}
@@ -1071,6 +1127,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
eventDate={event.event_date}
expiresAt={event.expires_at}
allowDownloads={allowDownloads}
downloadChoices={downloadChoices}
downloadStandard={data?.event?.download_resolution?.standard}
protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'}
disableRightClick={disableRightClick}
@@ -1102,6 +1160,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onClose={() => setShowUploadModal(false)}
/>
)}
{/* Download size picker (#858) — "download all", or a selection. */}
{(showResolutionPicker || resolutionPickerIds) && (
<DownloadResolutionModal
slug={slug}
choices={downloadChoices}
standardResolution={data?.event?.download_resolution?.standard}
photoIds={resolutionPickerIds || undefined}
onClose={() => {
setShowResolutionPicker(false);
setResolutionPickerIds(null);
}}
/>
)}
</GalleryLayout>
</>
</GuestIdentityProvider>
@@ -3,9 +3,10 @@ import { Package } from 'lucide-react';
import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import type { Photo } from '../../types';
import type { Photo, DownloadResolutionChoice } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { PhotoLightbox } from './PhotoLightbox';
import { DownloadResolutionModal } from './DownloadResolutionModal';
import { Button } from '../common';
import { galleryService } from '../../services/gallery.service';
import { analyticsService } from '../../services/analytics.service';
@@ -42,6 +43,10 @@ interface PhotoGridWithLayoutsProps {
expiresAt?: string | null;
feedbackEnabled?: boolean;
allowDownloads?: boolean;
// Resolution picker choices (#858). Empty/absent = no picker, download
// straight at the gallery's standard size.
downloadChoices?: DownloadResolutionChoice[];
downloadStandard?: string;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
@@ -87,6 +92,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
feedbackOptions,
onFeedbackChange,
allowDownloads = true,
downloadChoices,
downloadStandard,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
@@ -117,6 +124,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
const [openFeedbackInitially, setOpenFeedbackInitially] = useState<boolean>(false);
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
const [localSelectionMode, setLocalSelectionMode] = useState(false);
// Non-null while the resolution picker is open (#858); holds the ids it applies to.
const [resolutionPickerIds, setResolutionPickerIds] = useState<number[] | null>(null);
const downloadPhotoMutation = useDownloadPhoto();
// Use parent state if provided, otherwise use local state
@@ -183,6 +192,14 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return;
const ids = Array.from(selectedPhotos);
// Resolution picker (#858): when the gallery offers a choice, hand off to
// the modal — it drives the job build and does the download itself.
if (downloadChoices && downloadChoices.length > 1) {
setResolutionPickerIds(ids);
return;
}
toastify.info(t('gallery.downloading', { count: ids.length }));
try {
@@ -215,6 +232,11 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
const layoutProps = {
photos,
slug,
// Full-page layouts own their bulk-download control, so the resolution
// picker has to reach them too (#858) — otherwise premium/story galleries
// silently skip the choice the admin enabled.
downloadChoices,
onPickResolution: (ids: number[]) => setResolutionPickerIds(ids),
onPhotoClick: handlePhotoClick,
onOpenPhotoWithFeedback: handleOpenWithFeedback,
onFeedbackChange: onFeedbackChange,
@@ -387,6 +409,25 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
showOriginalFilename={showOriginalFilename}
/>
)}
{/* Download size picker (#858) — drives the job build and the download. */}
{resolutionPickerIds && downloadChoices && (
<DownloadResolutionModal
slug={slug}
choices={downloadChoices}
standardResolution={downloadStandard}
photoIds={resolutionPickerIds}
onClose={() => {
setResolutionPickerIds(null);
setSelectedPhotos(new Set());
if (parentToggleSelectionMode) {
parentToggleSelectionMode();
} else {
setLocalSelectionMode(false);
}
}}
/>
)}
</>
);
};
@@ -1,5 +1,5 @@
import React from 'react';
import type { Photo } from '../../../types';
import type { Photo, DownloadResolutionChoice } from '../../../types';
export interface BaseGalleryLayoutProps {
photos: Photo[];
@@ -20,6 +20,11 @@ export interface BaseGalleryLayoutProps {
eventDate?: string | null;
expiresAt?: string | null;
allowDownloads?: boolean;
// Resolution picker choices (#858). More than one entry means the gallery
// offers a real choice, so bulk downloads must route through the modal
// instead of calling downloadSelectedPhotos directly.
downloadChoices?: DownloadResolutionChoice[];
onPickResolution?: (photoIds: number[]) => void;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
@@ -183,6 +183,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
eventName,
eventDate,
allowDownloads = true,
downloadChoices,
onPickResolution,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
@@ -398,6 +400,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const handleDownloadSelected = useCallback(async () => {
if (selectedPhotos.size === 0) return;
const ids = Array.from(selectedPhotos);
// #858: hand off to the resolution picker when the gallery offers a choice.
if (downloadChoices && downloadChoices.length > 1 && onPickResolution) {
onPickResolution(ids);
return;
}
toast.info(t('gallery.downloading', { count: ids.length }));
try {
@@ -406,7 +413,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
} catch {
toast.error(t('gallery.downloadError'));
}
}, [selectedPhotos, slug, t]);
}, [selectedPhotos, slug, t, downloadChoices, onPickResolution]);
const handleDownloadFromLightbox = useCallback((slide: { src?: string }) => {
if (!allowDownloads || !slide.src) return;
@@ -51,6 +51,8 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
eventName,
eventDate,
allowDownloads = true,
downloadChoices,
onPickResolution,
protectionLevel = 'standard',
useEnhancedProtection = false,
useCanvasRendering = false,
@@ -232,15 +234,20 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
}, [selectedPhotoForFeedback, ratings, slug, savedIdentity, onFeedbackChange]);
const handleDownloadAll = useCallback(async () => {
const ids = photos.map(p => p.id);
// #858: hand off to the resolution picker when the gallery offers a choice.
if (downloadChoices && downloadChoices.length > 1 && onPickResolution) {
onPickResolution(ids);
return;
}
toast.info(t('gallery.downloading', { count: photos.length }));
try {
const ids = photos.map(p => p.id);
await galleryService.downloadSelectedPhotos(slug, ids);
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
} catch {
toast.error(t('gallery.downloadError'));
}
}, [photos, slug, t]);
}, [photos, slug, t, downloadChoices, onPickResolution]);
if (photos.length === 0) {
return (
+1
View File
@@ -16,6 +16,7 @@ export { ModerationTab } from './tabs/ModerationTab';
export { StylingTab } from './tabs/StylingTab';
export { SEOTab } from './tabs/SEOTab';
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
export { DownloadsTab } from './tabs/DownloadsTab';
export { ApiTokensTab } from './tabs/ApiTokensTab';
export { WebhooksTab } from './tabs/WebhooksTab';
export { AccountingTab } from './tabs/AccountingTab';
@@ -0,0 +1,239 @@
import React, { useEffect, useState } from 'react';
import { Save, Download, Plus, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Button, Card, Input, Loading } from '../../../components/common';
import { api } from '../../../config/api';
/**
* Download resolutions (#858).
*
* The STANDARD resolution is what every ordinary download hands out — single
* photos, selected photos and download-all alike. The PICKER is an opt-in
* modal letting guests choose a different size; those archives are built on
* demand rather than served from the cache.
*
* Both are global defaults here; individual galleries can override them.
*/
interface Preset {
id?: string;
label: string;
width: number;
height: number;
}
interface DownloadSettings {
standard_resolution: string;
picker_enabled: boolean;
allow_original: boolean;
resolutions: Preset[];
}
const ORIGINAL = 'original';
export const DownloadsTab: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [form, setForm] = useState<DownloadSettings | null>(null);
const { data, isLoading } = useQuery<DownloadSettings>({
queryKey: ['admin-download-settings'],
queryFn: async () => (await api.get('/admin/settings/downloads')).data,
});
useEffect(() => {
if (data) setForm(data);
}, [data]);
const save = useMutation({
mutationFn: async (payload: DownloadSettings) => {
await api.put('/admin/settings/downloads', {
download_standard_resolution: payload.standard_resolution,
download_resolution_picker_enabled: payload.picker_enabled,
download_allow_original: payload.allow_original,
download_resolutions: payload.resolutions.map((r) => ({
label: r.label, width: r.width, height: r.height,
})),
});
},
onSuccess: () => {
toast.success(t('settings.saved', 'Settings saved'));
queryClient.invalidateQueries({ queryKey: ['admin-download-settings'] });
},
onError: (e: unknown) => {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error;
toast.error(msg || t('settings.saveError', 'Failed to save settings'));
},
});
// Matches SlideshowStyleFields' input styling — the explicit text colour
// matters, without it the select renders muted and looks disabled.
const selectClass = 'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm';
if (isLoading || !form) return <Loading />;
const setPreset = (i: number, patch: Partial<Preset>) => {
const old = form.resolutions[i];
const next = { ...old, ...patch };
const resolutions = form.resolutions.map((r, idx) => (idx === i ? next : r));
// A preset's id IS its dimensions, so editing width/height of the preset
// that is currently the standard would leave standard_resolution pointing
// at an id that no longer exists and the save would 400. Follow the edit.
const standard = `${old.width}x${old.height}` === form.standard_resolution
? `${next.width}x${next.height}`
: form.standard_resolution;
setForm({ ...form, resolutions, standard_resolution: standard });
};
const removePreset = (i: number) => {
const removed = form.resolutions[i];
const resolutions = form.resolutions.filter((_, idx) => idx !== i);
// Keep the invariant the API enforces: the standard must stay a real
// preset, otherwise the save is rejected.
const standard = `${removed.width}x${removed.height}` === form.standard_resolution
? ORIGINAL
: form.standard_resolution;
setForm({ ...form, resolutions, standard_resolution: standard });
};
const addPreset = () => setForm({
...form,
resolutions: [...form.resolutions, { label: 'Custom', width: 2000, height: 1500 }],
});
return (
<div className="space-y-6">
<Card>
<div className="flex items-center gap-2 mb-1">
<Download className="w-5 h-5 text-neutral-500" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('settings.downloads.title', 'Download resolutions')}
</h2>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-5">
{t('settings.downloads.intro',
'The standard size is what every gallery hands out by default. Individual galleries can override this.')}
</p>
<label className="block text-sm font-medium mb-1 text-neutral-800 dark:text-neutral-200">
{t('settings.downloads.standard', 'Standard resolution')}
</label>
<select
className={`mb-5 ${selectClass}`}
value={form.standard_resolution}
onChange={(e) => setForm({ ...form, standard_resolution: e.target.value })}
>
<option value={ORIGINAL}>{t('settings.downloads.original', 'Original (full size)')}</option>
{form.resolutions.map((r) => (
<option key={`${r.width}x${r.height}`} value={`${r.width}x${r.height}`}>
{r.label} {r.width} × {r.height}
</option>
))}
</select>
<label className="flex items-start gap-3 mb-3 cursor-pointer">
<input
type="checkbox"
className="mt-1 accent-primary-600"
checked={form.picker_enabled}
onChange={(e) => setForm({ ...form, picker_enabled: e.target.checked })}
/>
<span>
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('settings.downloads.picker', 'Let guests choose a download size')}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.downloads.pickerHint',
'Adds a size picker to bulk downloads. Custom sizes are prepared on demand and are never larger than the standard.')}
</span>
</span>
</label>
<label className="flex items-start gap-3 mb-5 cursor-pointer">
<input
type="checkbox"
className="mt-1 accent-primary-600"
checked={form.allow_original}
disabled={!form.picker_enabled}
onChange={(e) => setForm({ ...form, allow_original: e.target.checked })}
/>
<span>
<span className="block text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('settings.downloads.allowOriginal', 'Offer "Original" in the picker')}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.downloads.allowOriginalHint',
'Off by default: lowering the standard size normally means full-resolution files should not be handed out.')}
</span>
</span>
</label>
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('settings.downloads.presets', 'Available sizes')}
</h3>
<Button variant="outline" size="sm" onClick={addPreset} leftIcon={<Plus className="w-4 h-4" />}>
{t('common.add', 'Add')}
</Button>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('settings.downloads.presetsHint',
'Sizes are an upper bound — the aspect ratio is kept and photos are never enlarged.')}
</p>
<div className="space-y-2">
{form.resolutions.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={r.label}
onChange={(e) => setPreset(i, { label: e.target.value })}
className="flex-1"
aria-label={t('settings.downloads.label', 'Label')}
/>
<Input
type="number"
value={String(r.width)}
onChange={(e) => setPreset(i, { width: parseInt(e.target.value, 10) || 0 })}
className="w-28"
aria-label={t('settings.downloads.width', 'Width')}
/>
<span className="text-neutral-400">×</span>
<Input
type="number"
value={String(r.height)}
onChange={(e) => setPreset(i, { height: parseInt(e.target.value, 10) || 0 })}
className="w-28"
aria-label={t('settings.downloads.height', 'Height')}
/>
<button
type="button"
onClick={() => removePreset(i)}
disabled={form.resolutions.length <= 1}
aria-label={t('common.remove', 'Remove')}
className="p-2 text-neutral-500 hover:text-red-600 disabled:opacity-40"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
<div className="flex justify-end mt-6">
<Button
variant="primary"
onClick={() => save.mutate(form)}
disabled={save.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('common.save', 'Save')}
</Button>
</div>
</Card>
</div>
);
};
+34 -2
View File
@@ -153,7 +153,9 @@
"showAll": "Alle anzeigen",
"confirm": "Bestätigen",
"show": "Einblenden",
"poweredBy": "Bereitgestellt von"
"poweredBy": "Bereitgestellt von",
"on": "an",
"off": "aus"
},
"upload": {
"photoCategory": "Fotokategorie",
@@ -1044,7 +1046,17 @@
"socials": "Soziale Netzwerke"
},
"photosCount_one": "{{count}} Foto",
"photosCount_other": "{{count}} Fotos"
"photosCount_other": "{{count}} Fotos",
"chooseResolution": "Downloadgröße wählen",
"resolutionUpTo": "bis zu {{width}} × {{height}} px",
"prepareDownload": "Download vorbereiten",
"preparingDownload": "Download wird vorbereitet…",
"preparingHint": "Fotos werden verkleinert — bei großen Galerien kann das einen Moment dauern.",
"preparingProgress": "{{count}} Fotos verpackt",
"downloadReady": "Ihr Download ist bereit",
"downloadNow": "Herunterladen",
"downloadPrepFailed": "Vorbereitung fehlgeschlagen",
"downloadPrepTimeout": "Das dauert länger als erwartet. Bitte erneut versuchen."
},
"categories": {
"title": "Fotokategorien",
@@ -2284,6 +2296,26 @@
"logoutFromIdpHint": "Die Abmeldung von PicPeak beendet auch die IdP-Sitzung (RP-initiated Logout). Gilt nur für Sitzungen, die per SSO angemeldet wurden; ohne diese Option bleibt die IdP-Sitzung bestehen und der nächste SSO-Klick meldet direkt wieder an.",
"postLogoutRedirectUri": "Post-Logout-Redirect-URI (beim IdP-Client registrieren, z. B. Keycloak „Valid post logout redirect URIs“)"
}
},
"downloads": {
"title": "Download-Auflösungen",
"intro": "Die Standardgröße gibt jede Galerie standardmäßig heraus. Einzelne Galerien können das überschreiben.",
"standard": "Standardauflösung",
"original": "Original (volle Größe)",
"picker": "Gäste die Downloadgröße wählen lassen",
"pickerHint": "Fügt Sammel-Downloads eine Größenauswahl hinzu. Eigene Größen werden bei Bedarf erzeugt und sind nie größer als der Standard.",
"allowOriginal": "„Original“ in der Auswahl anbieten",
"allowOriginalHint": "Standardmäßig aus: Wer die Standardgröße reduziert, möchte in der Regel keine Dateien in voller Auflösung herausgeben.",
"presets": "Verfügbare Größen",
"presetsHint": "Größen sind eine Obergrenze — das Seitenverhältnis bleibt erhalten und Fotos werden nie vergrößert.",
"label": "Bezeichnung",
"width": "Breite",
"height": "Höhe",
"eventTitle": "Download-Auflösung",
"eventIntro": "Überschreibt die globalen Download-Einstellungen nur für diese Galerie. „Erben“ folgt Einstellungen → Download-Auflösungen.",
"inheritWith": "Erben ({{value}})",
"effective": "Gibt derzeit heraus: {{standard}}",
"pickerOn": "Gäste dürfen eine andere Größe wählen"
}
},
"branding": {
+34 -2
View File
@@ -153,7 +153,9 @@
"showAll": "Show all",
"confirm": "Confirm",
"show": "Show",
"poweredBy": "Powered by"
"poweredBy": "Powered by",
"on": "on",
"off": "off"
},
"upload": {
"photoCategory": "Photo Category",
@@ -589,7 +591,17 @@
"photosSelected_one": "{{count}} photo selected",
"photosSelected_other": "{{count}} photos selected",
"downloadSelected_one": "Download {{count}} photo",
"downloadSelected_other": "Download {{count}} photos"
"downloadSelected_other": "Download {{count}} photos",
"chooseResolution": "Choose a download size",
"resolutionUpTo": "up to {{width}} × {{height}} px",
"prepareDownload": "Prepare download",
"preparingDownload": "Preparing your download…",
"preparingHint": "Resizing photos — this can take a moment for large galleries.",
"preparingProgress": "{{count}} photos packaged",
"downloadReady": "Your download is ready",
"downloadNow": "Download",
"downloadPrepFailed": "Preparation failed",
"downloadPrepTimeout": "This is taking longer than expected. Please try again."
},
"categories": {
"title": "Photo Categories",
@@ -1829,6 +1841,26 @@
"logoutFromIdpHint": "Logging out of PicPeak also ends the IdP session (RP-initiated logout). Only applies to sessions that signed in via SSO; without this, logging out of PicPeak leaves the IdP session alive and the next SSO click signs straight back in.",
"postLogoutRedirectUri": "Post-logout redirect URI (register this on your IdP client, e.g. Keycloak \"Valid post logout redirect URIs\")"
}
},
"downloads": {
"title": "Download resolutions",
"intro": "The standard size is what every gallery hands out by default. Individual galleries can override this.",
"standard": "Standard resolution",
"original": "Original (full size)",
"picker": "Let guests choose a download size",
"pickerHint": "Adds a size picker to bulk downloads. Custom sizes are prepared on demand and are never larger than the standard.",
"allowOriginal": "Offer \"Original\" in the picker",
"allowOriginalHint": "Off by default: lowering the standard size normally means full-resolution files should not be handed out.",
"presets": "Available sizes",
"presetsHint": "Sizes are an upper bound — the aspect ratio is kept and photos are never enlarged.",
"label": "Label",
"width": "Width",
"height": "Height",
"eventTitle": "Download resolution",
"eventIntro": "Override the site-wide download settings for this gallery only. \"Inherit\" follows Settings → Download resolutions.",
"inheritWith": "Inherit ({{value}})",
"effective": "Currently hands out: {{standard}}",
"pickerOn": "guests may choose another size"
}
},
"analytics": {
+6 -1
View File
@@ -11,6 +11,7 @@ import {
Image as ImageIcon,
Search,
Tags,
Download as DownloadIcon,
Tag,
BarChart3,
Flag,
@@ -38,6 +39,7 @@ import {
StylingTab,
SEOTab,
ThumbnailsTab,
DownloadsTab,
ApiTokensTab,
WebhooksTab,
AccountingTab,
@@ -68,6 +70,7 @@ type TabType =
| 'branding'
| 'categories'
| 'thumbnails'
| 'downloads'
| 'styling'
| 'cms'
| 'email'
@@ -104,7 +107,7 @@ interface NavGroup {
const ALL_TAB_KEYS: TabType[] = [
'features', 'general', 'events', 'eventTypes',
'branding', 'categories', 'thumbnails', 'styling', 'cms',
'branding', 'categories', 'thumbnails', 'downloads', 'styling', 'cms',
'email', 'moderation',
'security', 'sso', 'imageSecurity', 'seo',
'apiTokens', 'webhooks',
@@ -244,6 +247,7 @@ export const SettingsPage: React.FC = () => {
{ key: 'branding', label: t('settings.branding.title', 'Branding'), icon: Palette },
{ key: 'categories', label: t('settings.categories.title'), icon: Tags },
{ key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails'), icon: ImageIcon },
{ key: 'downloads', label: t('settings.downloads.title', 'Download resolutions'), icon: DownloadIcon },
{ key: 'styling', label: t('settings.styling.title', 'Custom CSS'), icon: Code },
{ key: 'cms', label: t('settings.cms.title', 'CMS Pages'), icon: FileText },
...(flags.slideshow
@@ -495,6 +499,7 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
{activeTab === 'thumbnails' && <ThumbnailsTab />}
{activeTab === 'downloads' && <DownloadsTab />}
{activeTab === 'categories' && <CategoriesTab />}
{activeTab === 'analytics' && (
@@ -3,6 +3,7 @@ import type { Event } from '../../../types';
import { FeedbackModerationPanel } from '../../../components/admin';
import { EventReminderOverrideCard } from '../../../components/admin/EventReminderOverrideCard';
import { SlideshowSettingsCard } from '../../../components/admin/SlideshowSettingsCard';
import { DownloadResolutionCard } from '../../../components/admin/DownloadResolutionCard';
import { ShortUrlsCard } from '../../../components/admin/ShortUrlsCard';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import type { AdminPhoto } from '../../../services/photos.service';
@@ -116,6 +117,10 @@ export const OverviewTab: React.FC<OverviewTabProps> = ({
{/* Client Access (#172) */}
<ClientAccessCard event={event} refetchEvent={refetchEvent} />
{/* Per-gallery download resolution override (#858). Sits with the
other "what the customer receives" controls. */}
<DownloadResolutionCard eventId={event.id} onChanged={() => refetchEvent()} />
{/* Live Slideshow ("Diashow") link + live display settings (migrations 138/139).
Gated behind the `slideshow` feature flag. */}
{flags.slideshow && (
+37 -1
View File
@@ -1,5 +1,8 @@
import { api } from '../config/api';
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
import type {
GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier,
DownloadJobStatus, DownloadJobState,
} from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
import { parseContentDispositionFilename } from '../utils/contentDisposition';
@@ -280,6 +283,39 @@ export const galleryService = {
window.URL.revokeObjectURL(url);
},
// ── Custom-resolution downloads (#858) ──────────────────────────────────
// A non-standard resolution has nothing cached behind it and can take
// minutes to build, so the server does it as a job we poll rather than
// holding a request open past the proxy timeout.
// Kick off a build. `photoIds` omitted = the whole gallery.
async startDownloadJob(
slug: string,
resolution: string,
photoIds?: number[]
): Promise<{ token: string; status: DownloadJobStatus }> {
const body: Record<string, unknown> = { resolution };
if (photoIds && photoIds.length) body.photo_ids = photoIds;
const response = await api.post(`/gallery/${slug}/download-jobs`, body);
return response.data;
},
async getDownloadJob(slug: string, token: string): Promise<DownloadJobState> {
const response = await api.get(`/gallery/${slug}/download-jobs/${token}`);
return response.data;
},
// Native browser download so the archive streams with Content-Length
// (real progress bar, no in-memory blob for a multi-GB gallery).
downloadJobFile(slug: string, token: string, filename: string): void {
const link = document.createElement('a');
link.href = withAdminPreview(`/api/gallery/${slug}/download-jobs/${token}/file`);
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
},
// iOS-only Web Share path for a selection of photos.
//
// Returns:
+25
View File
@@ -173,6 +173,24 @@ export interface Photo {
favorite_count?: number;
}
// Download resolutions (#858).
export type DownloadJobStatus = 'pending' | 'building' | 'ready' | 'failed';
export interface DownloadResolutionChoice {
id: string; // 'original' | '<width>x<height>'
label: string;
width: number | null;
height: number | null;
}
export interface DownloadJobState {
status: DownloadJobStatus;
resolution: string;
photo_count: number;
size_bytes: number | null;
error?: string;
}
export interface PhotoCategory {
id: number | string;
name: string;
@@ -187,6 +205,13 @@ export interface GalleryData {
event_name: string;
event_type: string;
event_date: string | null;
// Download resolutions (#858). `choices` is empty when the picker is off,
// so the UI never offers a size the server would reject.
download_resolution?: {
standard: string;
picker_enabled: boolean;
choices: DownloadResolutionChoice[];
};
welcome_message?: string;
color_theme?: string;
expires_at: string | null;