Merge pull request #513 from the-luap/fix/bug-batch

fix/feat: bug batch — drag-drop, lightbox, likes, downloads, upload, i18n (#504-510)
This commit is contained in:
Paul Nothaft
2026-05-17 01:01:49 +02:00
committed by GitHub
28 changed files with 3013 additions and 81 deletions
@@ -0,0 +1,38 @@
/**
* Migration: re-add the configurable upload batch size setting (#509).
*
* Originally shipped via PR #214 (#208 fix) — users behind Cloudflare
* Tunnel and other reverse proxies with per-request size caps need to
* bound the chunked-upload size so they don't lose every batch >100MB.
* That migration + frontend wiring was lost during a `Merge main into
* beta for release/beta-to-main` resolution that picked main's older
* tree over beta's, silently deleting the file and reinstating the
* hardcoded 500MB chunk in PhotoUpload.tsx.
*
* Re-introducing the exact same migration here. Idempotent: skips the
* insert if the row already exists (e.g. installs that did go through
* the original 072 between #214 merge and the main-into-beta merge,
* where the migrations-table row was preserved even after the file
* was deleted).
*/
exports.up = async function(knex) {
const exists = await knex('app_settings')
.where({ setting_key: 'general_max_upload_batch_size_mb' })
.first();
if (!exists) {
await knex('app_settings').insert({
setting_key: 'general_max_upload_batch_size_mb',
setting_value: JSON.stringify(95),
setting_type: 'general',
updated_at: new Date()
});
}
};
exports.down = async function(knex) {
await knex('app_settings')
.where({ setting_key: 'general_max_upload_batch_size_mb' })
.del();
};
@@ -0,0 +1,121 @@
/**
* Migration: seed Spanish (es) email-template translations (#510).
*
* Contributed by @AloePacci on issue #510. Covers the four
* customer-facing gallery delivery templates that already had
* en/de/nl/pt/ru rows from migration 075. Templates without an `es`
* row (admin_*, backup_*, restore_*, customer_*, version_update_*)
* continue to fall back to `en` via the resolution chain in
* emailProcessor.processTemplate — no functional gap, just untranslated
* copy until someone fills them in.
*
* Same idempotency pattern as 099_seed_missing_email_template_translations:
* checks (template_id, language) before inserting so re-runs are safe.
*/
const TRANSLATIONS = {
gallery_created: {
es: {
subject: 'Su galería de fotos está lista!',
body_html: `<h2>Galería creada con éxito</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha sido creada con éxito!</p>
<p><strong>Detalles de la galería:</strong></p>
<ul>
<li>Fecha del evento: {{event_date}}</li>
<li>Enlace de la galería: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Contraseña: {{gallery_password}}</li>
<li>Expira en: {{expiry_date}}</li>
</ul>
<p>Comparta este enlace y contraseña con sus invitados para que puedan ver y descargar las fotos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: 'Galería creada con éxito\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido creada con éxito!\n\nEnlace de la galería: {{gallery_link}}\nContraseña: {{gallery_password}}\nExpira en: {{expiry_date}}',
},
},
expiration_warning: {
es: {
subject: 'Su galería de fotos expirará pronto',
body_html: `<h2>Galería expirando pronto</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.</p>
<p>Después de la expiración, la galería será archivada y ya no estará accesible para los invitados.</p>
<p><a href="{{gallery_link}}">Visitar galería</a></p>`,
body_text: 'Galería expirando pronto\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.\n\nGalería: {{gallery_link}}',
},
},
gallery_expired: {
es: {
subject: 'Su galería de fotos está caducada',
body_html: `<h2>Galería vencida</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha caducado y por tanto ya no es accesible.</p>
<p>Las fotos han sido archivadas. Si necesita acceso, por favor póngase en contacto con el administrador a través de {{admin_email}}.</p>`,
body_text: 'Galería caducada\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha caducado y ya no está accesible.\n\nContacto: {{admin_email}}',
},
},
archive_complete: {
es: {
subject: 'Archivado completado: {{event_name}}',
body_html: `<h2>Archivado completado</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha sido archivada con éxito.</p>
<p><strong>Detalles del archivo:</strong></p>
<ul>
<li>Número de fotos: {{photo_count}}</li>
<li>Tamaño del archivo: {{archive_size}}</li>
<li>Fecha del archivado: {{archive_date}}</li>
</ul>`,
body_text: 'Archivado completado\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido archivada con éxito.\n\nFotos: {{photo_count}}\nTamaño: {{archive_size}}',
},
},
};
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
if (!(await knex.schema.hasTable('email_template_translations'))) return;
const rows = await knex('email_templates')
.whereIn('template_key', Object.keys(TRANSLATIONS))
.select('id', 'template_key');
const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id]));
let inserted = 0;
let skipped = 0;
for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
const templateId = keyToId[key];
if (!templateId) continue;
for (const [language, content] of Object.entries(perLocale)) {
const existing = await knex('email_template_translations')
.where({ template_id: templateId, language })
.first();
if (existing) {
skipped += 1;
continue;
}
await knex('email_template_translations').insert({
template_id: templateId,
language,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
inserted += 1;
}
}
console.log(`106_seed_es_email_template_translations: inserted=${inserted}, skipped=${skipped}`);
};
exports.down = async function(knex) {
// No-op: same rationale as 099. An admin may have hand-edited the
// `es` rows in the Templates UI after this migration ran, and we
// can't tell apart inserted-by-us rows from edited-by-admin rows.
// Rollback by hand if you truly need to drop them.
};
+6 -1
View File
@@ -161,7 +161,12 @@ const corsOptions = {
callback(null, false);
}
},
credentials: true
credentials: true,
// Expose Content-Disposition so split (cross-origin) frontend
// deployments can read the server's chosen download filename. Used
// by the gallery/admin download flows to honour the #493 "original
// camera filename" toggle on individual photo downloads (#507).
exposedHeaders: ['Content-Disposition'],
};
// Only attach CORS to API endpoints, not static assets
+13
View File
@@ -430,6 +430,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
// keeps working with the original. logger.debug to avoid noise.
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
}
// #508: when the admin has flipped the "use original camera filenames"
// toggle (#493), the lightbox surfaces each photo's original_filename
// alongside the position counter so the photographer can map a guest's
// selection back to source files. Tied to the same toggle as downloads —
// one switch controls both surfaces.
const useOriginalFilenames = await getUseOriginalFilenames();
res.json({
@@ -458,6 +465,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
hero_image_anchor: req.event.hero_image_anchor || 'center',
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at),
// Mirror of the admin-side toggle so the lightbox can decide
// whether to surface original camera filenames (#508).
use_original_filenames: useOriginalFilenames,
...protectionSettings
},
categories: categories,
@@ -472,6 +482,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
return {
id: photo.id,
filename: photo.filename,
// Raw camera filename (or null for pre-migration-062 uploads).
// The lightbox renders it when `use_original_filenames` is on.
original_filename: photo.original_filename || null,
url: photoUrl,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
// Hero-optimized image URL (1920x1080) for full-width hero sections
+13 -1
View File
@@ -8,6 +8,11 @@ const { formatBoolean } = require('../utils/dbCompat');
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
const {
getUseOriginalFilenames,
pickRawDownloadName,
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const router = express.Router();
@@ -339,9 +344,16 @@ router.get('/:slug/secure-download/:photoId/:token',
'download'
);
// #493/#507: respect the original-filename toggle here too. The
// regular `/gallery/:slug/download/:photoId` route already does
// this — secure-images was missed in the original PR and ran
// even when the admin had opted into original camera filenames.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': `attachment; filename="${photo.filename}"`,
'Content-Disposition': buildContentDisposition(downloadName),
'Content-Length': fileBuffer.length,
'X-Download-Protected': 'true'
});
+4
View File
@@ -140,6 +140,7 @@ async function getRecipientLanguage(email, eventId = null) {
{ domains: ['.nl', '.be'], language: 'nl' },
{ domains: ['.br', '.pt'], language: 'pt' },
{ domains: ['.ru', '.su'], language: 'ru' },
{ domains: ['.es'], language: 'es' },
];
for (const { domains, language: lang } of domainLanguageMap) {
if (domains.some(d => domain.endsWith(d))) {
@@ -497,6 +498,7 @@ async function processTemplate(template, variables, language = 'en') {
nl: '(Om veiligheidsredenen niet weergegeven)',
pt: '(Não exibido por motivos de segurança)',
ru: '(Не показано в целях безопасности)',
es: '(No se muestra por razones de seguridad)',
};
const noPasswordI18n = {
en: 'No password required',
@@ -504,6 +506,7 @@ async function processTemplate(template, variables, language = 'en') {
nl: 'Geen wachtwoord vereist',
pt: 'Nenhuma senha necessária',
ru: 'Пароль не требуется',
es: 'No se requiere contraseña',
};
// Sent by the publish-from-draft flow (adminEvents.js): by the time the
// event is published, only the bcrypt hash is stored, so the plaintext
@@ -515,6 +518,7 @@ async function processTemplate(template, variables, language = 'en') {
nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld',
pt: 'A senha definida ao criar a galeria',
ru: 'Пароль, заданный при создании галереи',
es: 'La contraseña que estableciste al crear la galería',
};
if (processedVariables.gallery_password === '{{password_security_message}}') {
+63 -15
View File
@@ -79,14 +79,16 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
);
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
const [isDragOver, setIsDragOver] = useState(false);
// Shared filter + per-upload-limit pipeline used by both the file-input
// change handler and the drop handler. #504 — without the drop handler
// the dashed-border zone looked draggable but silently fell through to
// the browser's default "open the file in a new tab" behaviour.
const addFiles = (incoming: File[]) => {
const imageFiles = incoming.filter((file) => allowedMimeTypes.includes(file.type));
if (imageFiles.length === 0) return;
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file =>
allowedMimeTypes.includes(file.type)
);
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > maxFilesPerUpload) {
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
@@ -101,11 +103,44 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
setSelectedFiles((prev) => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...imageFiles]);
setSelectedFiles((prev) => [...prev, ...imageFiles]);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(e.target.files || []));
// Reset the input so picking the same files again still fires onChange.
if (e.target.value) e.target.value = '';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// dropEffect must be set on every dragover for the cursor to render
// the "copy" affordance in Chrome/Firefox.
e.dataTransfer.dropEffect = 'copy';
if (!isDragOver) setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// dragleave fires for every child node the cursor passes — only flip
// the highlight off when the cursor leaves the zone itself, otherwise
// it strobes on/off as the user moves over the icon and text.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files || []);
addFiles(files);
};
const removeFile = (index: number) => {
@@ -128,9 +163,14 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setUploadProgress(0);
setUploadIds([]);
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
// For large uploads, chunk the files by both count AND size to prevent memory/network issues.
// #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other
// reverse proxies with request-size limits can drop it below their proxy's cap. Falls back
// to 95MB (Cloudflare-safe headroom under 100MB) when the setting is unset — that matches
// the value the migration seeds and is what worked in #208's resolution.
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB)
const maxBatchSizeMb = Number(settings?.general_max_upload_batch_size_mb) || 95;
const MAX_BYTES_PER_CHUNK = maxBatchSizeMb * 1024 * 1024;
const chunks: File[][] = [];
let currentChunk: File[] = [];
@@ -349,14 +389,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
</label>
</div>
{/* File Input Area */}
{/* File Input Area — accepts both click-to-pick and drag-and-drop (#504). */}
<div
className={clsx(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer",
"hover:border-accent-dark hover:bg-accent-dark/15",
selectedFiles.length > 0 ? "border-accent-dark bg-accent-dark/15" : "border-neutral-300 dark:border-neutral-600"
isDragOver
? "border-accent-dark bg-accent-dark/25"
: selectedFiles.length > 0
? "border-accent-dark bg-accent-dark/15"
: "border-neutral-300 dark:border-neutral-600"
)}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="w-12 h-12 mx-auto text-neutral-400 dark:text-neutral-500 mb-4" />
<p className="text-neutral-700 dark:text-neutral-300 font-medium mb-1">
@@ -54,6 +54,14 @@ const FRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
</svg>
);
const ESFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#AA151B" d="M0 0h640v120H0z"/>
<path fill="#F1BF00" d="M0 120h640v240H0z"/>
<path fill="#AA151B" d="M0 360h640v120H0z"/>
</svg>
);
export const SUPPORTED_LANGUAGES = [
{ code: 'en', name: 'English', Flag: GBFlag },
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
@@ -61,6 +69,7 @@ export const SUPPORTED_LANGUAGES = [
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
{ code: 'fr', name: 'Français', Flag: FRFlag },
{ code: 'es', name: 'Español', Flag: ESFlag },
];
export const LanguageSelector: React.FC = () => {
@@ -143,6 +143,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
const useCanvasRendering = data?.event?.use_canvas_rendering === true;
// #508 — surface original camera filenames in the lightbox when the
// admin has flipped the same toggle that drives original-name downloads.
const showOriginalFilename = data?.event?.use_original_filenames === true;
// DevTools protection - enabled by individual setting OR legacy protection level
const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum';
@@ -677,6 +680,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
onLogout={logout}
showOriginalFilename={showOriginalFilename}
/>
{/* Upload Modal for full-page layouts */}
@@ -918,6 +922,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
welcomeMessage={event.welcome_message}
isClient={isClient}
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
showOriginalFilename={showOriginalFilename}
/>
</div>
@@ -71,6 +71,9 @@ interface PhotoGridWithLayoutsProps {
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
// Mirror of the admin original-filename toggle (#508). When true, the
// lightbox bottom toolbar surfaces each photo's original camera name.
showOriginalFilename?: boolean;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -105,7 +108,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
welcomeMessage,
onLogout,
isClient = false,
onToggleVisibility
onToggleVisibility,
showOriginalFilename = false,
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -238,6 +242,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
onLogout,
isClient,
onToggleVisibility,
showOriginalFilename,
};
// Determine if we should show hero header (decoupled from layout)
@@ -379,6 +384,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
enableDevtoolsProtection={enableDevtoolsProtection}
initialShowFeedback={openFeedbackInitially}
onFeedbackChange={onFeedbackChange}
showOriginalFilename={showOriginalFilename}
/>
)}
</>
@@ -24,6 +24,11 @@ interface PhotoLightboxProps {
onFeedbackChange?: () => void;
disableRightClick?: boolean;
enableDevtoolsProtection?: boolean;
// When true, surface each photo's original camera filename in the
// bottom toolbar — useful for photographers matching guest selections
// back to source files (#508). Tied to the admin-side toggle that
// also drives original-filename downloads (#493).
showOriginalFilename?: boolean;
}
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -40,6 +45,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onFeedbackChange,
disableRightClick = false,
enableDevtoolsProtection = false,
showOriginalFilename = false,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
@@ -593,10 +599,23 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}}
>
<div className="max-w-4xl mx-auto flex items-center justify-between gap-2 flex-wrap">
<div className="text-white">
<div className="text-white min-w-0">
<p className="text-sm opacity-75">
{currentIndex + 1} / {photos.length}
</p>
{/* #508 original camera filename next to the counter when
the admin has flipped the matching toggle. Falls back to
the storage filename only if `original_filename` is null
(pre-migration-062 uploads). truncate + max-w keep long
names from pushing the action row to another line. */}
{showOriginalFilename && (currentPhoto.original_filename || currentPhoto.filename) && (
<p
className="text-xs opacity-60 truncate max-w-[14rem] sm:max-w-md mt-0.5"
title={currentPhoto.original_filename || currentPhoto.filename}
>
{currentPhoto.original_filename || currentPhoto.filename}
</p>
)}
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-end">
@@ -696,20 +715,38 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{(() => {
const isVideoCurrent = currentPhoto.media_type === 'video';
const renderSlide = (photo: Photo | null, isCurrent: boolean) => {
// Stable per-slide keys so React's reconciler can MOVE existing
// DOM nodes across slot positions on commit rather than
// re-fetching the AuthenticatedImage at the new position (#505 —
// that re-fetch is what caused the black blink during swipe).
// Edge case: 2-photo galleries assign the same photo to both
// `prev` and `next`; fall back to slot-prefixed keys to keep
// siblings unique. >2-photo galleries (the common case) get
// plain photo.id keys so a "next becomes current" commit
// preserves the loaded image instance.
const slideKey = (photo: Photo | null, slot: 'prev' | 'current' | 'next') => {
if (!photo) return `empty-${slot}`;
if (photos.length === 2) return `${slot}-${photo.id}`;
return `photo-${photo.id}`;
};
const renderSlide = (photo: Photo | null, isCurrent: boolean, slot: 'prev' | 'current' | 'next') => {
// Reserve the slot even when there's no neighbour (single-photo
// gallery) so the flex layout keeps slides aligned.
if (!photo) {
return <div className="h-full" style={{ flex: '0 0 33.3333%' }} aria-hidden="true" />;
return <div key={slideKey(photo, slot)} className="h-full" style={{ flex: '0 0 33.3333%' }} aria-hidden="true" />;
}
// Neighbouring slides are plain thumbnails — they're only on
// screen during the swipe animation, so we save the work of a
// protected canvas pipeline for them. The current slide keeps
// the full protection chain.
// the full protection chain. Wrapper className matches the
// current slide so object-contain sizing renders the same
// visible height (#505 — earlier `px-2` made wide images
// shorter on neighbours than on current).
if (!isCurrent) {
return (
<div className="h-full flex items-center justify-center px-2" style={{ flex: '0 0 33.3333%' }}>
<div key={slideKey(photo, slot)} className="h-full flex items-center justify-center" style={{ flex: '0 0 33.3333%' }}>
{photo.media_type === 'video' && photo.thumbnail_url ? (
<img
src={photo.thumbnail_url}
@@ -742,6 +779,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
return (
<div
key={slideKey(photo, slot)}
className="h-full flex items-center justify-center"
style={{ flex: '0 0 33.3333%' }}
onClick={handleImageClick}
@@ -838,9 +876,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}}
onTransitionEnd={handleTrackTransitionEnd}
>
{renderSlide(prevPhoto, false)}
{renderSlide(currentPhoto, true)}
{renderSlide(nextPhoto, false)}
{renderSlide(prevPhoto, false, 'prev')}
{renderSlide(currentPhoto, true, 'current')}
{renderSlide(nextPhoto, false, 'next')}
</div>
)}
</div>
@@ -28,6 +28,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
// bytes-on-wire for that file, so the UI can show "Processing…"
// instead of a static 100% bar while the backend works.
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
const [isDragOver, setIsDragOver] = useState(false);
const { data: publicSettings } = usePublicSettings();
@@ -41,11 +42,9 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
[publicSettings?.allowed_file_types]
);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
// Validate file types
const validFiles = selectedFiles.filter(file => {
// Shared filter pipeline for both <input> change and drag-and-drop (#504).
const addFiles = (incoming: File[]) => {
const validFiles = incoming.filter((file) => {
if (!allowedMimeTypes.includes(file.type)) {
toast.error(`Invalid file type: ${file.name}`);
return false;
@@ -57,8 +56,38 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
}
return true;
});
if (validFiles.length === 0) return;
setFiles((prev) => [...prev, ...validFiles]);
};
setFiles(prev => [...prev, ...validFiles]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(e.target.files || []));
// Reset so re-selecting the same file fires onChange again.
if (e.target.value) e.target.value = '';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (!isDragOver) setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// dragleave fires for every child node — only flip off when the cursor
// leaves the zone itself.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (uploading) return;
addFiles(Array.from(e.dataTransfer.files || []));
};
const removeFile = (index: number) => {
@@ -154,10 +183,18 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{/* Scrollable Content */}
<div className="flex-1 p-4 sm:p-6 overflow-y-auto min-h-0">
{/* Upload Area */}
{/* Upload Area — accepts both click-to-pick and drag-and-drop (#504). */}
<div className="mb-4 sm:mb-6">
<label className="block">
<div className="border-2 border-dashed border-surface rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer">
<div
className={`border-2 border-dashed rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer ${
isDragOver ? 'border-accent-dark bg-accent-dark/10' : 'border-surface'
}`}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
<p className="text-sm font-medium text-muted-theme mb-1">
{t('upload.clickToUpload')}
@@ -36,6 +36,9 @@ export interface BaseGalleryLayoutProps {
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
// Mirror of the admin original-filename toggle (#508). Forwarded to the
// lightbox by layouts that mount their own (story/premium).
showOriginalFilename?: boolean;
}
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
@@ -147,7 +147,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5" />
</Button>
)}
{feedbackOptions?.allowLikes && (
{feedbackEnabled && feedbackOptions?.allowLikes && (
<Button
variant="ghost"
size="sm"
@@ -6,8 +6,10 @@ import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails';
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
import Download from 'yet-another-react-lightbox/plugins/download';
import Captions from 'yet-another-react-lightbox/plugins/captions';
import 'yet-another-react-lightbox/styles.css';
import 'yet-another-react-lightbox/plugins/thumbnails.css';
import 'yet-another-react-lightbox/plugins/captions.css';
import { motion, AnimatePresence } from 'framer-motion';
import { Download as DownloadIcon, Heart, Check, Star, MessageSquare, Package, LogOut } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -42,6 +44,10 @@ interface PhotoCardProps {
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean;
// #506: track the per-event "allow likes" toggle so the per-photo
// Like button respects it. `feedbackEnabled` alone isn't enough —
// an event can have feedback on but likes specifically disabled.
allowLikes?: boolean;
index: number;
}
@@ -61,6 +67,7 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
allowLikes = false,
index
}) => {
// Note: height is passed but not used as we maintain aspect ratio via width
@@ -113,15 +120,18 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
{isSelected && <Check className="w-3.5 h-3.5" strokeWidth={3} />}
</button>
{/* Like Button */}
<button
onClick={onLike}
className={`gallery-premium-like-btn ${isLiked ? 'liked' : ''}`}
>
<Heart
className={`w-5 h-5 ${isLiked ? 'fill-current' : ''}`}
/>
</button>
{/* Like Button #506: only when feedback master is on AND the
per-event "allow likes" sub-toggle is on. */}
{feedbackEnabled && allowLikes && (
<button
onClick={onLike}
className={`gallery-premium-like-btn ${isLiked ? 'liked' : ''}`}
>
<Heart
className={`w-5 h-5 ${isLiked ? 'fill-current' : ''}`}
/>
</button>
)}
{/* Selection Border */}
{isSelected && (
@@ -177,7 +187,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
onLogout
onLogout,
showOriginalFilename = false,
}) => {
// These props are passed by parent but we use our own lightbox, so mark as intentionally unused
void _onPhotoClick;
@@ -225,16 +236,20 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
}));
}, [filteredPhotos]);
// Lightbox slides
// Lightbox slides. `title` powers the Captions plugin — only emitted
// when the admin has flipped the original-filenames toggle (#508).
const slides = useMemo(() => {
return filteredPhotos.map(photo => ({
src: photo.url,
alt: photo.filename,
width: photo.width || 1200,
height: photo.height || 800,
download: allowDownloads ? photo.url : undefined
download: allowDownloads ? photo.url : undefined,
title: showOriginalFilename
? (photo.original_filename || photo.filename)
: undefined,
}));
}, [filteredPhotos, allowDownloads]);
}, [filteredPhotos, allowDownloads, showOriginalFilename]);
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
@@ -502,6 +517,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
allowLikes={!!feedbackOptions?.allowLikes}
index={photoIndex}
/>
);
@@ -528,7 +544,13 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
close={() => setLightboxIndex(-1)}
index={lightboxIndex}
slides={slides}
plugins={allowDownloads ? [Thumbnails, Zoom, Fullscreen, Download] : [Thumbnails, Zoom, Fullscreen]}
plugins={[
Thumbnails,
Zoom,
Fullscreen,
...(allowDownloads ? [Download] : []),
...(showOriginalFilename ? [Captions] : []),
]}
animation={{ fade: 300, swipe: 250 }}
styles={{
container: { backgroundColor: 'rgba(0, 0, 0, 0.95)' },
@@ -58,7 +58,8 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
feedbackOptions,
heroPhotoOverride,
welcomeMessage,
onLogout
onLogout,
showOriginalFilename = false,
}) => {
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
void _onPhotoClick;
@@ -379,6 +380,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
onFeedbackChange={onFeedbackChange}
showOriginalFilename={showOriginalFilename}
/>
)}
@@ -105,7 +105,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackOptions?.allowLikes && (
{feedbackEnabled && feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
@@ -144,7 +144,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackOptions?.allowLikes && (
{feedbackEnabled && feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
@@ -16,6 +16,8 @@ export interface GeneralSettings {
max_file_size_mb: number;
max_files_per_upload: number;
allowed_file_types: string;
// #509 — re-added after the main-into-beta merge dropped it.
max_upload_batch_size_mb: number;
enable_analytics: boolean;
enable_registration: boolean;
maintenance_mode: boolean;
@@ -91,6 +93,7 @@ export function useSettingsState() {
max_file_size_mb: 50,
max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp',
max_upload_batch_size_mb: 95,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
@@ -177,6 +180,7 @@ export function useSettingsState() {
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
max_upload_batch_size_mb: toNumber(settings.general_max_upload_batch_size_mb, 95),
enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
@@ -162,6 +162,28 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.maxUploadBatchSize')}
</label>
<Input
type="number"
value={generalSettings.max_upload_batch_size_mb}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
setGeneralSettings(prev => ({
...prev,
max_upload_batch_size_mb: Number.isFinite(parsed)
? Math.max(1, parsed)
: prev.max_upload_batch_size_mb
}));
}}
min="1"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.maxUploadBatchSizeHelp')}
</p>
</div>
</div>
<div>
+2
View File
@@ -1031,6 +1031,8 @@
"maxFileSize": "Max. Dateigröße (MB)",
"maxFilesPerUpload": "Max. Dateien pro Upload",
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
"maxUploadBatchSize": "Max. Upload-Paketgröße (MB)",
"maxUploadBatchSizeHelp": "Maximale Größe pro Upload-Anfrage. Reduzieren Sie diesen Wert bei Nutzung eines Reverse-Proxys mit Größenbeschränkung (z.B. Cloudflare: 100MB).",
"allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"featureToggles": "Funktionsschalter",
+2
View File
@@ -670,6 +670,8 @@
"maxFileSize": "Max File Size (MB)",
"maxFilesPerUpload": "Max Files per Upload",
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
"maxUploadBatchSize": "Max Upload Batch Size (MB)",
"maxUploadBatchSizeHelp": "Maximum size per upload request. Lower this if behind a reverse proxy with request size limits (e.g. Cloudflare: 100MB).",
"allowedFileTypes": "Allowed File Types",
"allowedFileTypesHelp": "Comma-separated list of file extensions",
"featureToggles": "Feature Toggles",
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@ import {
import { format } from 'date-fns';
import { Button, Card, Input, Loading } from '../../components/common';
import { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
import {
customerAdminService,
@@ -227,11 +228,12 @@ export const CustomerDetailPage: React.FC = () => {
onChange={setField('preferredLanguage')}
className="input"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="nl">Nederlands</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
{/* Drive the option list from SUPPORTED_LANGUAGES so adding a
locale (#510 added es; fr was already missing here) only
needs to touch LanguageSelector. */}
{SUPPORTED_LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>{lang.name}</option>
))}
</select>
</div>
</div>
+26 -22
View File
@@ -1,6 +1,7 @@
import { api } from '../config/api';
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
import { parseContentDispositionFilename } from '../utils/contentDisposition';
export const galleryService = {
// Verify share token
@@ -49,35 +50,38 @@ export const galleryService = {
// Download single photo
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
try {
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
responseType: 'blob',
});
// Honour the server's Content-Disposition filename so the #493
// "use original camera filename" toggle reaches disk for single
// downloads (it already worked for zips because those skip the
// `<a download>` attribute). Falls back to the caller-provided
// sanitized filename if the header is unreadable.
const downloadFromResponse = (response: { data: Blob; headers: Record<string, string> }) => {
const headerName =
response.headers['content-disposition'] || response.headers['Content-Disposition'];
const serverFilename = parseContentDispositionFilename(headerName);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
link.setAttribute('download', serverFilename || filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
// Fallback: use the view endpoint if direct download fails (e.g., missing original)
try {
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (fallbackErr) {
throw fallbackErr;
}
};
try {
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
responseType: 'blob',
});
downloadFromResponse(response);
} catch {
// Fallback: use the view endpoint if direct download fails (e.g., missing original).
// The view endpoint doesn't emit a download-oriented Content-Disposition,
// so we expect the caller-supplied filename to win here.
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
responseType: 'blob',
});
downloadFromResponse(response);
}
},
+10 -2
View File
@@ -1,4 +1,5 @@
import { api } from '../config/api';
import { parseContentDispositionFilename } from '../utils/contentDisposition';
export interface AdminPhoto {
id: number;
@@ -102,11 +103,18 @@ class PhotosService {
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
responseType: 'blob'
});
// Read the filename from the server's Content-Disposition so the
// #493 original-filename toggle reaches disk for admin downloads
// too (see contentDisposition.ts).
const headerName =
response.headers['content-disposition'] || response.headers['Content-Disposition'];
const serverFilename = parseContentDispositionFilename(headerName);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.download = serverFilename || filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
+8
View File
@@ -88,6 +88,10 @@ export interface GalleryInfo {
export interface Photo {
id: number;
filename: string;
// Original camera filename (e.g. DSC_1234.jpg) — populated for uploads
// post migration 062. Null for legacy rows. Surfaced in the lightbox
// when the admin toggles `use_original_filenames` on (#508).
original_filename?: string | null;
url: string;
thumbnail_url?: string;
hero_url?: string; // Hero-optimized image URL (1920x1080) for full-width hero sections
@@ -168,6 +172,10 @@ export interface GalleryData {
hero_image_anchor?: string;
// Default photo sort order
default_photo_sort?: string;
// Mirror of admin's `general_use_original_filenames_for_downloads`.
// When true, the lightbox surfaces each photo's `original_filename`
// alongside the position counter (#508).
use_original_filenames?: boolean;
};
categories?: PhotoCategory[];
photos: Photo[];
+45
View File
@@ -0,0 +1,45 @@
/**
* Parse the `filename` out of a `Content-Disposition` response header.
*
* Prefers the RFC 5987 form (`filename*=UTF-8''<percent-encoded>`) so unicode
* camera filenames round-trip correctly, and falls back to the plain
* `filename="..."` token. Returns null when the header is missing or
* unparseable so the caller can choose its own fallback (typically the
* client-side photo.filename).
*
* Why this exists: backend download routes emit a Content-Disposition with
* the user-facing filename (which may be the original camera name when the
* #493 toggle is on). The frontend used to override that with a hardcoded
* `<a download="X">` attribute, so the server's filename never reached
* disk. Reading it back from the response means the server stays the
* single source of truth.
*/
export function parseContentDispositionFilename(header: string | null | undefined): string | null {
if (!header) return null;
// RFC 5987: filename*=UTF-8''<percent-encoded-bytes>
// The optional language tag (e.g. UTF-8'en'foo.jpg) is rarely emitted by
// servers; we accept the empty case only since that's what we produce.
const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header);
if (star && star[1]) {
try {
return decodeURIComponent(star[1].trim());
} catch {
// Malformed percent-encoding — fall through to the plain form.
}
}
// Plain quoted form: filename="..."
const quoted = /filename\s*=\s*"([^"]+)"/i.exec(header);
if (quoted && quoted[1]) {
return quoted[1];
}
// Plain unquoted form: filename=... (terminated by ; or end-of-string)
const unquoted = /filename\s*=\s*([^;]+)/i.exec(header);
if (unquoted && unquoted[1]) {
return unquoted[1].trim();
}
return null;
}