diff --git a/backend/src/services/uploadSettings.js b/backend/src/services/uploadSettings.js
index 06319cca..e102b7c8 100644
--- a/backend/src/services/uploadSettings.js
+++ b/backend/src/services/uploadSettings.js
@@ -29,6 +29,11 @@ const EXTENSION_TO_MIME = {
'webm': 'video/webm',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
+ // HEIC/HEIF (iPhone). Sharp's bundled libvips decodes `heif` input, so
+ // thumbnails generate fine. (iOS Safari usually transcodes to JPEG at file
+ // selection, but a genuine .heic upload is handled when it does arrive.)
+ 'heic': 'image/heic',
+ 'heif': 'image/heif',
};
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx
index 0f4ffef9..5aef8b43 100644
--- a/frontend/src/components/gallery/UserPhotoUpload.tsx
+++ b/frontend/src/components/gallery/UserPhotoUpload.tsx
@@ -5,7 +5,7 @@ import { toast } from 'react-toastify';
import { Button } from '../common';
import { api } from '../../config/api';
import { usePublicSettings } from '../../hooks/usePublicSettings';
-import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
+import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
interface UserPhotoUploadProps {
eventId: number;
@@ -61,6 +61,13 @@ export const UserPhotoUpload: React.FC = ({
[publicSettings?.allowed_file_types]
);
+ // #821 — the requirements hint used to hardcode "JPEG, PNG or WebP"; render
+ // the actually-configured formats so it never contradicts what's accepted.
+ const formatsLabel = useMemo(
+ () => extensionsToLabel(publicSettings?.allowed_file_types),
+ [publicSettings?.allowed_file_types]
+ );
+
// Shared filter pipeline for both change and drag-and-drop (#504).
const addFiles = (incoming: File[]) => {
const validFiles = incoming.filter((file) => {
@@ -236,7 +243,7 @@ export const UserPhotoUpload: React.FC = ({
{/* #613 — pass { limit } so `{{limit}}` interpolates
with the real number from settings instead of
rendering literally. */}
- {t('upload.fileRequirements', { limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
+ {t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
{
+ describe('extensionsToMimeTypes', () => {
+ it('maps known extensions to MIME types', () => {
+ expect(extensionsToMimeTypes('jpg,png,mov')).toEqual(['image/jpeg', 'image/png', 'video/quicktime']);
+ });
+ it('supports HEIC/HEIF (#821)', () => {
+ expect(extensionsToMimeTypes('heic,heif')).toEqual(['image/heic', 'image/heif']);
+ });
+ it('drops unknown extensions and falls back to default when nothing maps', () => {
+ expect(extensionsToMimeTypes('dng,xyz')).toEqual(['image/jpeg', 'image/png', 'image/webp']);
+ });
+ });
+
+ describe('extensionsToLabel', () => {
+ it('renders a de-duplicated, upper-cased list of the configured formats', () => {
+ expect(extensionsToLabel('jpg,jpeg,png,webp,mov')).toBe('JPG, JPEG, PNG, WEBP, MOV');
+ });
+ it('only lists supported extensions (drops unknowns like dng)', () => {
+ expect(extensionsToLabel('jpg,png,dng')).toBe('JPG, PNG');
+ });
+ it('falls back to the default set when empty', () => {
+ expect(extensionsToLabel('')).toBe('JPG, JPEG, PNG, WEBP');
+ expect(extensionsToLabel(null)).toBe('JPG, JPEG, PNG, WEBP');
+ });
+ });
+
+ describe('extensionsToAcceptString', () => {
+ it('joins MIME types for the input accept attribute', () => {
+ expect(extensionsToAcceptString('jpg,heic')).toBe('image/jpeg,image/heic');
+ });
+ });
+});
diff --git a/frontend/src/utils/fileTypes.ts b/frontend/src/utils/fileTypes.ts
index ba5514c9..f4881ec4 100644
--- a/frontend/src/utils/fileTypes.ts
+++ b/frontend/src/utils/fileTypes.ts
@@ -12,6 +12,9 @@ const EXTENSION_TO_MIME: Record = {
webm: 'video/webm',
mov: 'video/quicktime',
avi: 'video/x-msvideo',
+ // HEIC/HEIF (iPhone) — kept in sync with the backend EXTENSION_TO_MIME.
+ heic: 'image/heic',
+ heif: 'image/heif',
};
const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp';
@@ -46,3 +49,24 @@ export function extensionsToMimeTypes(extString?: string | null): string[] {
export function extensionsToAcceptString(extString?: string | null): string {
return extensionsToMimeTypes(extString).join(',');
}
+
+/**
+ * Human-readable, de-duplicated list of the configured extensions for the
+ * upload requirements hint, e.g. "JPG, PNG, WEBP, MOV". Only extensions the
+ * app actually supports (present in EXTENSION_TO_MIME) are shown, so the hint
+ * never advertises a format the backend would reject.
+ */
+export function extensionsToLabel(extString?: string | null): string {
+ const input = extString?.trim() || DEFAULT_ALLOWED;
+ const seen = new Set();
+ const labels: string[] = [];
+ input.split(',').forEach(ext => {
+ const cleaned = ext.trim().toLowerCase().replace(/^\./, '');
+ if (cleaned && EXTENSION_TO_MIME[cleaned] && !seen.has(cleaned)) {
+ seen.add(cleaned);
+ labels.push(cleaned.toUpperCase());
+ }
+ });
+ if (labels.length === 0) return extensionsToLabel(DEFAULT_ALLOWED);
+ return labels.join(', ');
+}