Make photo upload limit configurable via admin settings (#40)
This commit is contained in:
@@ -6,6 +6,7 @@ import { api } from '../../config/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
@@ -13,6 +14,9 @@ interface PhotoUploadProps {
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -29,6 +33,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
});
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const maxFilesPerUpload = React.useMemo(() => {
|
||||
const rawValue = settings?.general_max_files_per_upload;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
|
||||
}, [settings]);
|
||||
|
||||
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
@@ -37,13 +57,19 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
if (totalFiles > 500) {
|
||||
const allowedNewFiles = 500 - selectedFiles.length;
|
||||
if (totalFiles > maxFilesPerUpload) {
|
||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||
if (allowedNewFiles <= 0) {
|
||||
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
|
||||
toast.error(
|
||||
t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files allowed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
|
||||
toast.warning(
|
||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
|
||||
);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
@@ -59,8 +85,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
// Validate file count
|
||||
if (selectedFiles.length > 500) {
|
||||
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
|
||||
if (selectedFiles.length > maxFilesPerUpload) {
|
||||
toast.error(
|
||||
t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files can be uploaded at once`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +97,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setUploadProgress(0);
|
||||
|
||||
// For large uploads, chunk the files to prevent memory issues
|
||||
const CHUNK_SIZE = 50; // Upload 50 files at a time
|
||||
const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
||||
@@ -187,7 +216,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirements')}
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
"text-xs mt-2",
|
||||
remainingSlots === 0 ? "text-red-600" : "text-neutral-500"
|
||||
)}
|
||||
>
|
||||
{remainingSlots === 0
|
||||
? t('upload.limitReached', { limit: maxFilesPerUpload })
|
||||
: t('upload.limitInfo', {
|
||||
selected: selectedFiles.length,
|
||||
limit: maxFilesPerUpload,
|
||||
remaining: remainingSlots,
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"noCategory": "Keine Kategorie",
|
||||
"eventSpecific": "(Veranstaltungsspezifisch)",
|
||||
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei)",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
|
||||
"selectedFiles": "Ausgewählte Dateien",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"uploadComplete": "Upload abgeschlossen!",
|
||||
@@ -59,9 +59,11 @@
|
||||
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
||||
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
||||
"importFromSelectedFolder": "Ausgewählten Ordner importieren",
|
||||
"maxFilesReached": "Maximal 500 Dateien erlaubt",
|
||||
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
|
||||
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden",
|
||||
"maxFilesReached": "Maximal {{limit}} Dateien erlaubt",
|
||||
"someFilesSkipped": "Nur {{allowed}} weitere Dateien erlaubt (Limit {{limit}})",
|
||||
"tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden",
|
||||
"limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)",
|
||||
"limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)",
|
||||
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
|
||||
},
|
||||
"navigation": {
|
||||
@@ -773,6 +775,8 @@
|
||||
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
|
||||
"maxFileSize": "Max. Dateigröße (MB)",
|
||||
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
|
||||
"maxFilesPerUpload": "Max. Dateien pro Upload",
|
||||
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
|
||||
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||
"featureToggles": "Funktionsschalter",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"noCategory": "No category",
|
||||
"eventSpecific": "(Event specific)",
|
||||
"clickToUpload": "Click to upload or drag and drop",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file)",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)",
|
||||
"selectedFiles": "Selected files",
|
||||
"uploading": "Uploading...",
|
||||
"uploadComplete": "Upload complete!",
|
||||
@@ -59,9 +59,11 @@
|
||||
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
||||
"selectExternalFolder": "Select external folder under /external-media",
|
||||
"importFromSelectedFolder": "Import from selected folder",
|
||||
"maxFilesReached": "Maximum 500 files allowed",
|
||||
"someFilesSkipped": "Some files were skipped (500 file limit)",
|
||||
"tooManyFiles": "Maximum 500 files can be uploaded at once",
|
||||
"maxFilesReached": "Maximum {{limit}} files allowed",
|
||||
"someFilesSkipped": "Only {{allowed}} more files can be added (limit {{limit}})",
|
||||
"tooManyFiles": "Maximum {{limit}} files can be uploaded at once",
|
||||
"limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)",
|
||||
"limitReached": "Upload limit reached ({{limit}} files per batch)",
|
||||
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
|
||||
},
|
||||
"navigation": {
|
||||
@@ -453,6 +455,8 @@
|
||||
"defaultExpirationHelp": "How long galleries remain active by default",
|
||||
"maxFileSize": "Max File Size (MB)",
|
||||
"maxFileSizeHelp": "Maximum size per uploaded photo",
|
||||
"maxFilesPerUpload": "Max Files per Upload",
|
||||
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
|
||||
"allowedFileTypes": "Allowed File Types",
|
||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||
"featureToggles": "Feature Toggles",
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
|
||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
||||
if (value === undefined || value === null) {
|
||||
@@ -93,6 +94,7 @@ export const SettingsPage: React.FC = () => {
|
||||
site_url: '',
|
||||
default_expiration_days: 30,
|
||||
max_file_size_mb: 50,
|
||||
max_files_per_upload: 500,
|
||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: false,
|
||||
enable_analytics: true,
|
||||
@@ -145,6 +147,10 @@ export const SettingsPage: React.FC = () => {
|
||||
site_url: settings.general_site_url || '',
|
||||
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
|
||||
max_files_per_upload: Math.min(
|
||||
MAX_FILES_PER_UPLOAD_LIMIT,
|
||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||
),
|
||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||
@@ -645,7 +651,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.defaultExpiration')}
|
||||
@@ -670,6 +676,29 @@ export const SettingsPage: React.FC = () => {
|
||||
max="500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.maxFilesPerUpload')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={generalSettings.max_files_per_upload}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
setGeneralSettings(prev => ({
|
||||
...prev,
|
||||
max_files_per_upload: Number.isFinite(parsed)
|
||||
? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
|
||||
: prev.max_files_per_upload
|
||||
}));
|
||||
}}
|
||||
min="1"
|
||||
max={MAX_FILES_PER_UPLOAD_LIMIT}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user