Make photo upload limit configurable via admin settings (#40)
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
|
||||||
|
|
||||||
|
exports.up = async function up(knex) {
|
||||||
|
const settingKey = 'general_max_files_per_upload';
|
||||||
|
|
||||||
|
const existing = await knex('app_settings')
|
||||||
|
.where({ setting_key: settingKey })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Normalize existing value into allowed bounds
|
||||||
|
let parsedValue;
|
||||||
|
try {
|
||||||
|
parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
|
||||||
|
} catch {
|
||||||
|
parsedValue = existing.setting_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numeric = Number(parsedValue);
|
||||||
|
let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||||
|
if (Number.isFinite(numeric) && numeric >= 1) {
|
||||||
|
normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized !== numeric) {
|
||||||
|
await knex('app_settings')
|
||||||
|
.where({ setting_key: settingKey })
|
||||||
|
.update({
|
||||||
|
setting_value: JSON.stringify(normalized),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex('app_settings').insert({
|
||||||
|
setting_key: settingKey,
|
||||||
|
setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
|
||||||
|
setting_type: 'general',
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function down(knex) {
|
||||||
|
await knex('app_settings')
|
||||||
|
.where({ setting_key: 'general_max_files_per_upload' })
|
||||||
|
.del();
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ const { generateThumbnail, ensureThumbnail } = require('../services/imageProcess
|
|||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||||
|
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
@@ -48,7 +49,7 @@ const upload = multer({
|
|||||||
storage: storage,
|
storage: storage,
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
||||||
files: 500, // Maximum 500 files
|
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||||
// Set a reasonable field size limit to prevent memory issues
|
// Set a reasonable field size limit to prevent memory issues
|
||||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||||
// Add part size limits to prevent incomplete uploads
|
// Add part size limits to prevent incomplete uploads
|
||||||
@@ -99,17 +100,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Upload photos for an event
|
// Upload photos for an event
|
||||||
// Increased limit to 500 files, but recommend chunked uploads for better performance
|
// Max file count is configurable via general settings
|
||||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
|
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||||
upload.array('photos', 500)(req, res, (err) => {
|
let maxFilesPerUpload;
|
||||||
|
try {
|
||||||
|
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve max files per upload:', error);
|
||||||
|
return res.status(500).json({ error: 'Unable to determine upload limits' });
|
||||||
|
}
|
||||||
|
|
||||||
|
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Multer error:', err);
|
console.error('Multer error:', err);
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
||||||
}
|
}
|
||||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||||
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
|
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const {
|
|||||||
} = require('../services/publicSiteService');
|
} = require('../services/publicSiteService');
|
||||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||||
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
@@ -472,9 +473,24 @@ router.put('/theme', adminAuth, async (req, res) => {
|
|||||||
router.put('/general', adminAuth, async (req, res) => {
|
router.put('/general', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = { ...req.body };
|
const settings = { ...req.body };
|
||||||
|
let uploadLimitTouched = false;
|
||||||
|
|
||||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
|
||||||
|
uploadLimitTouched = true;
|
||||||
|
const rawValue = Number(settings.general_max_files_per_upload);
|
||||||
|
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
|
||||||
|
|
||||||
|
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.general_max_files_per_upload = normalizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
if (publicSiteKeysTouched) {
|
if (publicSiteKeysTouched) {
|
||||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||||||
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||||||
@@ -529,6 +545,9 @@ router.put('/general', adminAuth, async (req, res) => {
|
|||||||
if (publicSiteKeysTouched) {
|
if (publicSiteKeysTouched) {
|
||||||
clearPublicSiteCache();
|
clearPublicSiteCache();
|
||||||
}
|
}
|
||||||
|
if (uploadLimitTouched) {
|
||||||
|
clearMaxFilesPerUploadCache();
|
||||||
|
}
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||||
|
const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
|
||||||
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||||
|
let cacheExpiresAt = 0;
|
||||||
|
|
||||||
|
const parseSettingValue = (setting) => {
|
||||||
|
if (!setting || setting.setting_value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawValue = setting.setting_value;
|
||||||
|
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
try {
|
||||||
|
rawValue = JSON.parse(rawValue);
|
||||||
|
} catch {
|
||||||
|
// keep original string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawValue === 'number') {
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeLimit = (value) => {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intValue = Math.floor(value);
|
||||||
|
if (intValue < 1) {
|
||||||
|
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||||
|
}
|
||||||
|
if (intValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
|
||||||
|
return MAX_ALLOWED_FILES_PER_UPLOAD;
|
||||||
|
}
|
||||||
|
return intValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMaxFilesPerUpload = async () => {
|
||||||
|
if (Date.now() < cacheExpiresAt) {
|
||||||
|
return cachedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const setting = await db('app_settings')
|
||||||
|
.where({ setting_key: 'general_max_files_per_upload' })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const parsedValue = normalizeLimit(parseSettingValue(setting));
|
||||||
|
cachedValue = parsedValue;
|
||||||
|
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||||
|
return parsedValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read max files per upload setting:', error.message);
|
||||||
|
cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||||
|
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||||
|
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearMaxFilesPerUploadCache = () => {
|
||||||
|
cacheExpiresAt = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getMaxFilesPerUpload,
|
||||||
|
clearMaxFilesPerUploadCache,
|
||||||
|
DEFAULT_MAX_FILES_PER_UPLOAD,
|
||||||
|
MAX_ALLOWED_FILES_PER_UPLOAD
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { api } from '../../config/api';
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { categoriesService } from '../../services/categories.service';
|
import { categoriesService } from '../../services/categories.service';
|
||||||
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface PhotoUploadProps {
|
interface PhotoUploadProps {
|
||||||
@@ -13,6 +14,9 @@ interface PhotoUploadProps {
|
|||||||
onUploadComplete?: () => void;
|
onUploadComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||||
|
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||||
|
|
||||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
@@ -29,6 +33,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
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 handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
const imageFiles = files.filter(file =>
|
const imageFiles = files.filter(file =>
|
||||||
@@ -37,13 +57,19 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
|
|
||||||
// Check total file count with existing files
|
// Check total file count with existing files
|
||||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||||
if (totalFiles > 500) {
|
if (totalFiles > maxFilesPerUpload) {
|
||||||
const allowedNewFiles = 500 - selectedFiles.length;
|
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||||
if (allowedNewFiles <= 0) {
|
if (allowedNewFiles <= 0) {
|
||||||
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
|
toast.error(
|
||||||
|
t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
|
||||||
|
`Maximum ${maxFilesPerUpload} files allowed`
|
||||||
|
);
|
||||||
return;
|
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)]);
|
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -59,8 +85,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
|
|
||||||
// Validate file count
|
// Validate file count
|
||||||
if (selectedFiles.length > 500) {
|
if (selectedFiles.length > maxFilesPerUpload) {
|
||||||
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
|
toast.error(
|
||||||
|
t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
|
||||||
|
`Maximum ${maxFilesPerUpload} files can be uploaded at once`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +97,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
|
|
||||||
// For large uploads, chunk the files to prevent memory issues
|
// 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 = [];
|
const chunks = [];
|
||||||
|
|
||||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
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')}
|
{t('upload.clickToUpload')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-neutral-500">
|
<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>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"noCategory": "Keine Kategorie",
|
"noCategory": "Keine Kategorie",
|
||||||
"eventSpecific": "(Veranstaltungsspezifisch)",
|
"eventSpecific": "(Veranstaltungsspezifisch)",
|
||||||
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
|
"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",
|
"selectedFiles": "Ausgewählte Dateien",
|
||||||
"uploading": "Wird hochgeladen...",
|
"uploading": "Wird hochgeladen...",
|
||||||
"uploadComplete": "Upload abgeschlossen!",
|
"uploadComplete": "Upload abgeschlossen!",
|
||||||
@@ -59,9 +59,11 @@
|
|||||||
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
||||||
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
||||||
"importFromSelectedFolder": "Ausgewählten Ordner importieren",
|
"importFromSelectedFolder": "Ausgewählten Ordner importieren",
|
||||||
"maxFilesReached": "Maximal 500 Dateien erlaubt",
|
"maxFilesReached": "Maximal {{limit}} Dateien erlaubt",
|
||||||
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
|
"someFilesSkipped": "Nur {{allowed}} weitere Dateien erlaubt (Limit {{limit}})",
|
||||||
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden",
|
"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..."
|
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
@@ -773,6 +775,8 @@
|
|||||||
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
|
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
|
||||||
"maxFileSize": "Max. Dateigröße (MB)",
|
"maxFileSize": "Max. Dateigröße (MB)",
|
||||||
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
|
"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",
|
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||||
"featureToggles": "Funktionsschalter",
|
"featureToggles": "Funktionsschalter",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"noCategory": "No category",
|
"noCategory": "No category",
|
||||||
"eventSpecific": "(Event specific)",
|
"eventSpecific": "(Event specific)",
|
||||||
"clickToUpload": "Click to upload or drag and drop",
|
"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",
|
"selectedFiles": "Selected files",
|
||||||
"uploading": "Uploading...",
|
"uploading": "Uploading...",
|
||||||
"uploadComplete": "Upload complete!",
|
"uploadComplete": "Upload complete!",
|
||||||
@@ -59,9 +59,11 @@
|
|||||||
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
||||||
"selectExternalFolder": "Select external folder under /external-media",
|
"selectExternalFolder": "Select external folder under /external-media",
|
||||||
"importFromSelectedFolder": "Import from selected folder",
|
"importFromSelectedFolder": "Import from selected folder",
|
||||||
"maxFilesReached": "Maximum 500 files allowed",
|
"maxFilesReached": "Maximum {{limit}} files allowed",
|
||||||
"someFilesSkipped": "Some files were skipped (500 file limit)",
|
"someFilesSkipped": "Only {{allowed}} more files can be added (limit {{limit}})",
|
||||||
"tooManyFiles": "Maximum 500 files can be uploaded at once",
|
"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..."
|
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
@@ -453,6 +455,8 @@
|
|||||||
"defaultExpirationHelp": "How long galleries remain active by default",
|
"defaultExpirationHelp": "How long galleries remain active by default",
|
||||||
"maxFileSize": "Max File Size (MB)",
|
"maxFileSize": "Max File Size (MB)",
|
||||||
"maxFileSizeHelp": "Maximum size per uploaded photo",
|
"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",
|
"allowedFileTypes": "Allowed File Types",
|
||||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||||
"featureToggles": "Feature Toggles",
|
"featureToggles": "Feature Toggles",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useAdminAuth } from '../../contexts';
|
import { useAdminAuth } from '../../contexts';
|
||||||
|
|
||||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||||
|
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||||
|
|
||||||
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
@@ -93,6 +94,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
site_url: '',
|
site_url: '',
|
||||||
default_expiration_days: 30,
|
default_expiration_days: 30,
|
||||||
max_file_size_mb: 50,
|
max_file_size_mb: 50,
|
||||||
|
max_files_per_upload: 500,
|
||||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||||
enable_watermark: false,
|
enable_watermark: false,
|
||||||
enable_analytics: true,
|
enable_analytics: true,
|
||||||
@@ -145,6 +147,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
site_url: settings.general_site_url || '',
|
site_url: settings.general_site_url || '',
|
||||||
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||||
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
|
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',
|
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||||
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
||||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||||
@@ -645,7 +651,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
{t('settings.general.defaultExpiration')}
|
{t('settings.general.defaultExpiration')}
|
||||||
@@ -670,6 +676,29 @@ export const SettingsPage: React.FC = () => {
|
|||||||
max="500"
|
max="500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user