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);
|
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
|
// Only attach CORS to API endpoints, not static assets
|
||||||
|
|||||||
@@ -431,6 +431,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
|
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({
|
res.json({
|
||||||
event: {
|
event: {
|
||||||
@@ -458,6 +465,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
hero_image_anchor: req.event.hero_image_anchor || 'center',
|
hero_image_anchor: req.event.hero_image_anchor || 'center',
|
||||||
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
|
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),
|
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
|
...protectionSettings
|
||||||
},
|
},
|
||||||
categories: categories,
|
categories: categories,
|
||||||
@@ -472,6 +482,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
return {
|
return {
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
filename: photo.filename,
|
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,
|
url: photoUrl,
|
||||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
|
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
|
// 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 { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
|
||||||
const { withLocalCopy } = require('../services/imageProcessor');
|
const { withLocalCopy } = require('../services/imageProcessor');
|
||||||
const { getStorage } = require('../services/storage');
|
const { getStorage } = require('../services/storage');
|
||||||
|
const {
|
||||||
|
getUseOriginalFilenames,
|
||||||
|
pickRawDownloadName,
|
||||||
|
} = require('../services/downloadFilenameService');
|
||||||
|
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -339,9 +344,16 @@ router.get('/:slug/secure-download/:photoId/:token',
|
|||||||
'download'
|
'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({
|
res.set({
|
||||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||||
'Content-Disposition': `attachment; filename="${photo.filename}"`,
|
'Content-Disposition': buildContentDisposition(downloadName),
|
||||||
'Content-Length': fileBuffer.length,
|
'Content-Length': fileBuffer.length,
|
||||||
'X-Download-Protected': 'true'
|
'X-Download-Protected': 'true'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ async function getRecipientLanguage(email, eventId = null) {
|
|||||||
{ domains: ['.nl', '.be'], language: 'nl' },
|
{ domains: ['.nl', '.be'], language: 'nl' },
|
||||||
{ domains: ['.br', '.pt'], language: 'pt' },
|
{ domains: ['.br', '.pt'], language: 'pt' },
|
||||||
{ domains: ['.ru', '.su'], language: 'ru' },
|
{ domains: ['.ru', '.su'], language: 'ru' },
|
||||||
|
{ domains: ['.es'], language: 'es' },
|
||||||
];
|
];
|
||||||
for (const { domains, language: lang } of domainLanguageMap) {
|
for (const { domains, language: lang } of domainLanguageMap) {
|
||||||
if (domains.some(d => domain.endsWith(d))) {
|
if (domains.some(d => domain.endsWith(d))) {
|
||||||
@@ -497,6 +498,7 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
nl: '(Om veiligheidsredenen niet weergegeven)',
|
nl: '(Om veiligheidsredenen niet weergegeven)',
|
||||||
pt: '(Não exibido por motivos de segurança)',
|
pt: '(Não exibido por motivos de segurança)',
|
||||||
ru: '(Не показано в целях безопасности)',
|
ru: '(Не показано в целях безопасности)',
|
||||||
|
es: '(No se muestra por razones de seguridad)',
|
||||||
};
|
};
|
||||||
const noPasswordI18n = {
|
const noPasswordI18n = {
|
||||||
en: 'No password required',
|
en: 'No password required',
|
||||||
@@ -504,6 +506,7 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
nl: 'Geen wachtwoord vereist',
|
nl: 'Geen wachtwoord vereist',
|
||||||
pt: 'Nenhuma senha necessária',
|
pt: 'Nenhuma senha necessária',
|
||||||
ru: 'Пароль не требуется',
|
ru: 'Пароль не требуется',
|
||||||
|
es: 'No se requiere contraseña',
|
||||||
};
|
};
|
||||||
// Sent by the publish-from-draft flow (adminEvents.js): by the time the
|
// 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
|
// 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',
|
nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld',
|
||||||
pt: 'A senha definida ao criar a galeria',
|
pt: 'A senha definida ao criar a galeria',
|
||||||
ru: 'Пароль, заданный при создании галереи',
|
ru: 'Пароль, заданный при создании галереи',
|
||||||
|
es: 'La contraseña que estableciste al crear la galería',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
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 remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
// Shared filter + per-upload-limit pipeline used by both the file-input
|
||||||
const files = Array.from(e.target.files || []);
|
// change handler and the drop handler. #504 — without the drop handler
|
||||||
const imageFiles = files.filter(file =>
|
// the dashed-border zone looked draggable but silently fell through to
|
||||||
allowedMimeTypes.includes(file.type)
|
// 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;
|
||||||
|
|
||||||
// Check total file count with existing files
|
|
||||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||||
if (totalFiles > maxFilesPerUpload) {
|
if (totalFiles > maxFilesPerUpload) {
|
||||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||||
@@ -101,11 +103,44 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||||
`Only ${allowedNewFiles} more files can be added (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;
|
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) => {
|
const removeFile = (index: number) => {
|
||||||
@@ -128,9 +163,14 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
setUploadIds([]);
|
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_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[][] = [];
|
const chunks: File[][] = [];
|
||||||
|
|
||||||
let currentChunk: File[] = [];
|
let currentChunk: File[] = [];
|
||||||
@@ -349,14 +389,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Input Area */}
|
{/* File Input Area — accepts both click-to-pick and drag-and-drop (#504). */}
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
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",
|
"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()}
|
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" />
|
<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">
|
<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>
|
</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 = [
|
export const SUPPORTED_LANGUAGES = [
|
||||||
{ code: 'en', name: 'English', Flag: GBFlag },
|
{ code: 'en', name: 'English', Flag: GBFlag },
|
||||||
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
|
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
|
||||||
@@ -61,6 +69,7 @@ export const SUPPORTED_LANGUAGES = [
|
|||||||
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
|
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
|
||||||
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
|
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
|
||||||
{ code: 'fr', name: 'Français', Flag: FRFlag },
|
{ code: 'fr', name: 'Français', Flag: FRFlag },
|
||||||
|
{ code: 'es', name: 'Español', Flag: ESFlag },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LanguageSelector: React.FC = () => {
|
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 disableRightClick = data?.event?.disable_right_click === true;
|
||||||
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
|
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
|
||||||
const useCanvasRendering = data?.event?.use_canvas_rendering === 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
|
// DevTools protection - enabled by individual setting OR legacy protection level
|
||||||
const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum';
|
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'}
|
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||||
welcomeMessage={event.welcome_message}
|
welcomeMessage={event.welcome_message}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
|
showOriginalFilename={showOriginalFilename}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Upload Modal for full-page layouts */}
|
{/* Upload Modal for full-page layouts */}
|
||||||
@@ -918,6 +922,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
welcomeMessage={event.welcome_message}
|
welcomeMessage={event.welcome_message}
|
||||||
isClient={isClient}
|
isClient={isClient}
|
||||||
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
|
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
|
||||||
|
showOriginalFilename={showOriginalFilename}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
// Client visibility controls (#172)
|
// Client visibility controls (#172)
|
||||||
isClient?: boolean;
|
isClient?: boolean;
|
||||||
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
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> = ({
|
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||||
@@ -105,7 +108,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
welcomeMessage,
|
welcomeMessage,
|
||||||
onLogout,
|
onLogout,
|
||||||
isClient = false,
|
isClient = false,
|
||||||
onToggleVisibility
|
onToggleVisibility,
|
||||||
|
showOriginalFilename = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -238,6 +242,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
onLogout,
|
onLogout,
|
||||||
isClient,
|
isClient,
|
||||||
onToggleVisibility,
|
onToggleVisibility,
|
||||||
|
showOriginalFilename,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if we should show hero header (decoupled from layout)
|
// Determine if we should show hero header (decoupled from layout)
|
||||||
@@ -379,6 +384,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
enableDevtoolsProtection={enableDevtoolsProtection}
|
enableDevtoolsProtection={enableDevtoolsProtection}
|
||||||
initialShowFeedback={openFeedbackInitially}
|
initialShowFeedback={openFeedbackInitially}
|
||||||
onFeedbackChange={onFeedbackChange}
|
onFeedbackChange={onFeedbackChange}
|
||||||
|
showOriginalFilename={showOriginalFilename}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ interface PhotoLightboxProps {
|
|||||||
onFeedbackChange?: () => void;
|
onFeedbackChange?: () => void;
|
||||||
disableRightClick?: boolean;
|
disableRightClick?: boolean;
|
||||||
enableDevtoolsProtection?: 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> = ({
|
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||||
@@ -40,6 +45,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
onFeedbackChange,
|
onFeedbackChange,
|
||||||
disableRightClick = false,
|
disableRightClick = false,
|
||||||
enableDevtoolsProtection = false,
|
enableDevtoolsProtection = false,
|
||||||
|
showOriginalFilename = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [zoom, setZoom] = useState(1);
|
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="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">
|
<p className="text-sm opacity-75">
|
||||||
{currentIndex + 1} / {photos.length}
|
{currentIndex + 1} / {photos.length}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-end">
|
<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 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
|
// Reserve the slot even when there's no neighbour (single-photo
|
||||||
// gallery) so the flex layout keeps slides aligned.
|
// gallery) so the flex layout keeps slides aligned.
|
||||||
if (!photo) {
|
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
|
// Neighbouring slides are plain thumbnails — they're only on
|
||||||
// screen during the swipe animation, so we save the work of a
|
// screen during the swipe animation, so we save the work of a
|
||||||
// protected canvas pipeline for them. The current slide keeps
|
// 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) {
|
if (!isCurrent) {
|
||||||
return (
|
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 ? (
|
{photo.media_type === 'video' && photo.thumbnail_url ? (
|
||||||
<img
|
<img
|
||||||
src={photo.thumbnail_url}
|
src={photo.thumbnail_url}
|
||||||
@@ -742,6 +779,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
key={slideKey(photo, slot)}
|
||||||
className="h-full flex items-center justify-center"
|
className="h-full flex items-center justify-center"
|
||||||
style={{ flex: '0 0 33.3333%' }}
|
style={{ flex: '0 0 33.3333%' }}
|
||||||
onClick={handleImageClick}
|
onClick={handleImageClick}
|
||||||
@@ -838,9 +876,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
}}
|
}}
|
||||||
onTransitionEnd={handleTrackTransitionEnd}
|
onTransitionEnd={handleTrackTransitionEnd}
|
||||||
>
|
>
|
||||||
{renderSlide(prevPhoto, false)}
|
{renderSlide(prevPhoto, false, 'prev')}
|
||||||
{renderSlide(currentPhoto, true)}
|
{renderSlide(currentPhoto, true, 'current')}
|
||||||
{renderSlide(nextPhoto, false)}
|
{renderSlide(nextPhoto, false, 'next')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
// bytes-on-wire for that file, so the UI can show "Processing…"
|
// bytes-on-wire for that file, so the UI can show "Processing…"
|
||||||
// instead of a static 100% bar while the backend works.
|
// instead of a static 100% bar while the backend works.
|
||||||
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
|
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
const { data: publicSettings } = usePublicSettings();
|
const { data: publicSettings } = usePublicSettings();
|
||||||
|
|
||||||
@@ -41,11 +42,9 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
[publicSettings?.allowed_file_types]
|
[publicSettings?.allowed_file_types]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
// Shared filter pipeline for both <input> change and drag-and-drop (#504).
|
||||||
const selectedFiles = Array.from(e.target.files || []);
|
const addFiles = (incoming: File[]) => {
|
||||||
|
const validFiles = incoming.filter((file) => {
|
||||||
// Validate file types
|
|
||||||
const validFiles = selectedFiles.filter(file => {
|
|
||||||
if (!allowedMimeTypes.includes(file.type)) {
|
if (!allowedMimeTypes.includes(file.type)) {
|
||||||
toast.error(`Invalid file type: ${file.name}`);
|
toast.error(`Invalid file type: ${file.name}`);
|
||||||
return false;
|
return false;
|
||||||
@@ -57,8 +56,38 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
}
|
}
|
||||||
return true;
|
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) => {
|
const removeFile = (index: number) => {
|
||||||
@@ -154,10 +183,18 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
|
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 p-4 sm:p-6 overflow-y-auto min-h-0">
|
<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">
|
<div className="mb-4 sm:mb-6">
|
||||||
<label className="block">
|
<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" />
|
<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">
|
<p className="text-sm font-medium text-muted-theme mb-1">
|
||||||
{t('upload.clickToUpload')}
|
{t('upload.clickToUpload')}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export interface BaseGalleryLayoutProps {
|
|||||||
// Client visibility controls (#172)
|
// Client visibility controls (#172)
|
||||||
isClient?: boolean;
|
isClient?: boolean;
|
||||||
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
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> {
|
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" />
|
<Download className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{feedbackOptions?.allowLikes && (
|
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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 Zoom from 'yet-another-react-lightbox/plugins/zoom';
|
||||||
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
|
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
|
||||||
import Download from 'yet-another-react-lightbox/plugins/download';
|
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/styles.css';
|
||||||
import 'yet-another-react-lightbox/plugins/thumbnails.css';
|
import 'yet-another-react-lightbox/plugins/thumbnails.css';
|
||||||
|
import 'yet-another-react-lightbox/plugins/captions.css';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Download as DownloadIcon, Heart, Check, Star, MessageSquare, Package, LogOut } from 'lucide-react';
|
import { Download as DownloadIcon, Heart, Check, Star, MessageSquare, Package, LogOut } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -42,6 +44,10 @@ interface PhotoCardProps {
|
|||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
useCanvasRendering?: boolean;
|
useCanvasRendering?: boolean;
|
||||||
feedbackEnabled?: 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;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +67,7 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
|
|||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
useCanvasRendering = false,
|
useCanvasRendering = false,
|
||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
|
allowLikes = false,
|
||||||
index
|
index
|
||||||
}) => {
|
}) => {
|
||||||
// Note: height is passed but not used as we maintain aspect ratio via width
|
// 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} />}
|
{isSelected && <Check className="w-3.5 h-3.5" strokeWidth={3} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Like Button */}
|
{/* Like Button — #506: only when feedback master is on AND the
|
||||||
<button
|
per-event "allow likes" sub-toggle is on. */}
|
||||||
onClick={onLike}
|
{feedbackEnabled && allowLikes && (
|
||||||
className={`gallery-premium-like-btn ${isLiked ? 'liked' : ''}`}
|
<button
|
||||||
>
|
onClick={onLike}
|
||||||
<Heart
|
className={`gallery-premium-like-btn ${isLiked ? 'liked' : ''}`}
|
||||||
className={`w-5 h-5 ${isLiked ? 'fill-current' : ''}`}
|
>
|
||||||
/>
|
<Heart
|
||||||
</button>
|
className={`w-5 h-5 ${isLiked ? 'fill-current' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Selection Border */}
|
{/* Selection Border */}
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
@@ -177,7 +187,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
feedbackOptions,
|
feedbackOptions,
|
||||||
heroPhotoOverride,
|
heroPhotoOverride,
|
||||||
onLogout
|
onLogout,
|
||||||
|
showOriginalFilename = false,
|
||||||
}) => {
|
}) => {
|
||||||
// These props are passed by parent but we use our own lightbox, so mark as intentionally unused
|
// These props are passed by parent but we use our own lightbox, so mark as intentionally unused
|
||||||
void _onPhotoClick;
|
void _onPhotoClick;
|
||||||
@@ -225,16 +236,20 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
}));
|
}));
|
||||||
}, [filteredPhotos]);
|
}, [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(() => {
|
const slides = useMemo(() => {
|
||||||
return filteredPhotos.map(photo => ({
|
return filteredPhotos.map(photo => ({
|
||||||
src: photo.url,
|
src: photo.url,
|
||||||
alt: photo.filename,
|
alt: photo.filename,
|
||||||
width: photo.width || 1200,
|
width: photo.width || 1200,
|
||||||
height: photo.height || 800,
|
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) => {
|
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -502,6 +517,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
useCanvasRendering={useCanvasRendering}
|
useCanvasRendering={useCanvasRendering}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
allowLikes={!!feedbackOptions?.allowLikes}
|
||||||
index={photoIndex}
|
index={photoIndex}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -528,7 +544,13 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
|||||||
close={() => setLightboxIndex(-1)}
|
close={() => setLightboxIndex(-1)}
|
||||||
index={lightboxIndex}
|
index={lightboxIndex}
|
||||||
slides={slides}
|
slides={slides}
|
||||||
plugins={allowDownloads ? [Thumbnails, Zoom, Fullscreen, Download] : [Thumbnails, Zoom, Fullscreen]}
|
plugins={[
|
||||||
|
Thumbnails,
|
||||||
|
Zoom,
|
||||||
|
Fullscreen,
|
||||||
|
...(allowDownloads ? [Download] : []),
|
||||||
|
...(showOriginalFilename ? [Captions] : []),
|
||||||
|
]}
|
||||||
animation={{ fade: 300, swipe: 250 }}
|
animation={{ fade: 300, swipe: 250 }}
|
||||||
styles={{
|
styles={{
|
||||||
container: { backgroundColor: 'rgba(0, 0, 0, 0.95)' },
|
container: { backgroundColor: 'rgba(0, 0, 0, 0.95)' },
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
|||||||
feedbackOptions,
|
feedbackOptions,
|
||||||
heroPhotoOverride,
|
heroPhotoOverride,
|
||||||
welcomeMessage,
|
welcomeMessage,
|
||||||
onLogout
|
onLogout,
|
||||||
|
showOriginalFilename = false,
|
||||||
}) => {
|
}) => {
|
||||||
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
|
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
|
||||||
void _onPhotoClick;
|
void _onPhotoClick;
|
||||||
@@ -379,6 +380,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
|||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
useCanvasRendering={useCanvasRendering}
|
useCanvasRendering={useCanvasRendering}
|
||||||
onFeedbackChange={onFeedbackChange}
|
onFeedbackChange={onFeedbackChange}
|
||||||
|
showOriginalFilename={showOriginalFilename}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{feedbackOptions?.allowLikes && (
|
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||||
<button
|
<button
|
||||||
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
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) => {
|
onClick={async (e) => {
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{feedbackOptions?.allowLikes && (
|
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||||
<button
|
<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'}`}
|
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) => {
|
onClick={async (e) => {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface GeneralSettings {
|
|||||||
max_file_size_mb: number;
|
max_file_size_mb: number;
|
||||||
max_files_per_upload: number;
|
max_files_per_upload: number;
|
||||||
allowed_file_types: string;
|
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_analytics: boolean;
|
||||||
enable_registration: boolean;
|
enable_registration: boolean;
|
||||||
maintenance_mode: boolean;
|
maintenance_mode: boolean;
|
||||||
@@ -91,6 +93,7 @@ export function useSettingsState() {
|
|||||||
max_file_size_mb: 50,
|
max_file_size_mb: 50,
|
||||||
max_files_per_upload: 500,
|
max_files_per_upload: 500,
|
||||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||||
|
max_upload_batch_size_mb: 95,
|
||||||
enable_analytics: true,
|
enable_analytics: true,
|
||||||
enable_registration: false,
|
enable_registration: false,
|
||||||
maintenance_mode: false,
|
maintenance_mode: false,
|
||||||
@@ -177,6 +180,7 @@ export function useSettingsState() {
|
|||||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||||
),
|
),
|
||||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
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_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, 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 })}
|
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1031,6 +1031,8 @@
|
|||||||
"maxFileSize": "Max. Dateigröße (MB)",
|
"maxFileSize": "Max. Dateigröße (MB)",
|
||||||
"maxFilesPerUpload": "Max. Dateien pro Upload",
|
"maxFilesPerUpload": "Max. Dateien pro Upload",
|
||||||
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
|
"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",
|
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||||
"featureToggles": "Funktionsschalter",
|
"featureToggles": "Funktionsschalter",
|
||||||
|
|||||||
@@ -670,6 +670,8 @@
|
|||||||
"maxFileSize": "Max File Size (MB)",
|
"maxFileSize": "Max File Size (MB)",
|
||||||
"maxFilesPerUpload": "Max Files per Upload",
|
"maxFilesPerUpload": "Max Files per Upload",
|
||||||
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
|
"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",
|
"allowedFileTypes": "Allowed File Types",
|
||||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||||
"featureToggles": "Feature Toggles",
|
"featureToggles": "Feature Toggles",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import {
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { Button, Card, Input, Loading } from '../../components/common';
|
import { Button, Card, Input, Loading } from '../../components/common';
|
||||||
|
import { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
|
||||||
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
|
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
|
||||||
import {
|
import {
|
||||||
customerAdminService,
|
customerAdminService,
|
||||||
@@ -227,11 +228,12 @@ export const CustomerDetailPage: React.FC = () => {
|
|||||||
onChange={setField('preferredLanguage')}
|
onChange={setField('preferredLanguage')}
|
||||||
className="input"
|
className="input"
|
||||||
>
|
>
|
||||||
<option value="en">English</option>
|
{/* Drive the option list from SUPPORTED_LANGUAGES so adding a
|
||||||
<option value="de">Deutsch</option>
|
locale (#510 added es; fr was already missing here) only
|
||||||
<option value="nl">Nederlands</option>
|
needs to touch LanguageSelector. */}
|
||||||
<option value="pt">Português</option>
|
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||||
<option value="ru">Русский</option>
|
<option key={lang.code} value={lang.code}>{lang.name}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
||||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||||
|
|
||||||
export const galleryService = {
|
export const galleryService = {
|
||||||
// Verify share token
|
// Verify share token
|
||||||
@@ -49,35 +50,38 @@ export const galleryService = {
|
|||||||
|
|
||||||
// Download single photo
|
// Download single photo
|
||||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||||
try {
|
// Honour the server's Content-Disposition filename so the #493
|
||||||
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
// "use original camera filename" toggle reaches disk for single
|
||||||
responseType: 'blob',
|
// 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 url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.setAttribute('download', filename);
|
link.setAttribute('download', serverFilename || filename);
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
};
|
||||||
// Fallback: use the view endpoint if direct download fails (e.g., missing original)
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
downloadFromResponse(response);
|
||||||
const link = document.createElement('a');
|
} catch {
|
||||||
link.href = url;
|
// Fallback: use the view endpoint if direct download fails (e.g., missing original).
|
||||||
link.setAttribute('download', filename);
|
// The view endpoint doesn't emit a download-oriented Content-Disposition,
|
||||||
document.body.appendChild(link);
|
// so we expect the caller-supplied filename to win here.
|
||||||
link.click();
|
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
||||||
link.remove();
|
responseType: 'blob',
|
||||||
window.URL.revokeObjectURL(url);
|
});
|
||||||
} catch (fallbackErr) {
|
downloadFromResponse(response);
|
||||||
throw fallbackErr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
|
import { parseContentDispositionFilename } from '../utils/contentDisposition';
|
||||||
|
|
||||||
export interface AdminPhoto {
|
export interface AdminPhoto {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -103,10 +104,17 @@ class PhotosService {
|
|||||||
responseType: 'blob'
|
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 url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = filename;
|
link.download = serverFilename || filename;
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ export interface GalleryInfo {
|
|||||||
export interface Photo {
|
export interface Photo {
|
||||||
id: number;
|
id: number;
|
||||||
filename: string;
|
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;
|
url: string;
|
||||||
thumbnail_url?: string;
|
thumbnail_url?: string;
|
||||||
hero_url?: string; // Hero-optimized image URL (1920x1080) for full-width hero sections
|
hero_url?: string; // Hero-optimized image URL (1920x1080) for full-width hero sections
|
||||||
@@ -168,6 +172,10 @@ export interface GalleryData {
|
|||||||
hero_image_anchor?: string;
|
hero_image_anchor?: string;
|
||||||
// Default photo sort order
|
// Default photo sort order
|
||||||
default_photo_sort?: string;
|
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[];
|
categories?: PhotoCategory[];
|
||||||
photos: Photo[];
|
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