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:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user