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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
@@ -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}}') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user