Merge main into beta for release/beta-to-main

This commit is contained in:
Paul Nothaft
2026-03-11 20:12:52 +01:00
20 changed files with 649 additions and 180 deletions
@@ -109,8 +109,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
const maxBatchSizeMb = Number(settings?.general_max_upload_batch_size_mb) || 95;
const MAX_BYTES_PER_CHUNK = maxBatchSizeMb * 1024 * 1024;
const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB)
const chunks: File[][] = [];
let currentChunk: File[] = [];
@@ -215,6 +215,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
useCanvasRendering,
isSelectionMode,
onPhotoSelect: handlePhotoSelect,
onSelectAll: selectAll,
onDeselectAll: deselectAll,
eventName,
eventLogo,
eventDate,
@@ -13,6 +13,8 @@ export interface BaseGalleryLayoutProps {
selectedPhotos?: Set<number>;
isSelectionMode?: boolean;
onPhotoSelect?: (photoId: number) => void;
onSelectAll?: () => void;
onDeselectAll?: () => void;
eventName?: string;
eventLogo?: string | null;
eventDate?: string | null;
@@ -165,6 +165,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
selectedPhotos = new Set(),
isSelectionMode = false,
onPhotoSelect,
onSelectAll,
onDeselectAll,
eventName,
eventDate,
allowDownloads = true,
@@ -287,17 +289,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const handleSelectAll = useCallback(() => {
if (selectedPhotos.size === filteredPhotos.length) {
// Deselect all
filteredPhotos.forEach(p => onPhotoSelect?.(p.id));
onDeselectAll?.();
} else {
// Select all
filteredPhotos.forEach(p => {
if (!selectedPhotos.has(p.id)) {
onPhotoSelect?.(p.id);
}
});
onSelectAll?.();
}
}, [selectedPhotos, filteredPhotos, onPhotoSelect]);
}, [selectedPhotos, filteredPhotos, onSelectAll, onDeselectAll]);
const handleDownloadSelected = useCallback(async () => {
if (selectedPhotos.size === 0) return;
@@ -16,7 +16,6 @@ export interface GeneralSettings {
max_file_size_mb: number;
max_files_per_upload: number;
allowed_file_types: string;
max_upload_batch_size_mb: number;
enable_analytics: boolean;
enable_registration: boolean;
maintenance_mode: boolean;
@@ -88,7 +87,6 @@ export function useSettingsState() {
max_file_size_mb: 50,
max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp',
max_upload_batch_size_mb: 95,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
@@ -171,7 +169,6 @@ export function useSettingsState() {
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
max_upload_batch_size_mb: toNumber(settings.general_max_upload_batch_size_mb, 95),
enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
@@ -161,28 +161,6 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.maxUploadBatchSize')}
</label>
<Input
type="number"
value={generalSettings.max_upload_batch_size_mb}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
setGeneralSettings(prev => ({
...prev,
max_upload_batch_size_mb: Number.isFinite(parsed)
? Math.max(1, parsed)
: prev.max_upload_batch_size_mb
}));
}}
min="1"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.maxUploadBatchSizeHelp')}
</p>
</div>
</div>
<div>
@@ -7,9 +7,12 @@ import {
Clock,
HardDrive,
Activity,
Ruler,
} from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../../../config/api';
import { settingsService } from '../../../services/settings.service';
import { useStatusTab } from '../hooks/useStatusTab';
import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings';
@@ -53,6 +56,27 @@ export const StatusTab: React.FC<StatusTabProps> = ({
}) => {
const { t } = useTranslation();
const { storageInfo, systemStatus } = useStatusTab(isActive);
const queryClient = useQueryClient();
const { data: dimensionStatus } = useQuery({
queryKey: ['photo-dimension-status'],
queryFn: async () => {
const res = await api.get('/admin/photos/repair-dimensions/status');
return res.data;
},
enabled: isActive,
refetchInterval: 10000,
});
const repairMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/admin/photos/repair-dimensions');
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['photo-dimension-status'] });
},
});
// Sync soft limit from storage info
useEffect(() => {
@@ -528,6 +552,61 @@ export const StatusTab: React.FC<StatusTabProps> = ({
</>
)}
{/* Photo Dimensions */}
{dimensionStatus && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Ruler className="w-5 h-5" />
{t('settings.photoDimensions.title')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.photoDimensions.description')}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{dimensionStatus.total}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.totalPhotos')}</p>
</div>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{dimensionStatus.withDimensions}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.withDimensions')}</p>
</div>
<div className={`rounded-lg p-3 text-center ${Number(dimensionStatus.withoutDimensions) > 0 ? 'bg-amber-50 dark:bg-amber-900/30' : 'bg-neutral-50 dark:bg-neutral-800'}`}>
<p className={`text-2xl font-bold ${Number(dimensionStatus.withoutDimensions) > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-neutral-900 dark:text-neutral-100'}`}>{dimensionStatus.withoutDimensions}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.missingDimensions')}</p>
</div>
</div>
{dimensionStatus.lastResult && (
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.photoDimensions.resultSuccess', {
success: dimensionStatus.lastResult.success,
failed: dimensionStatus.lastResult.failed,
})}
</p>
)}
<div className="flex justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => repairMutation.mutate()}
isLoading={repairMutation.isPending || dimensionStatus.isRunning}
disabled={Number(dimensionStatus.withoutDimensions) === 0 || dimensionStatus.isRunning}
leftIcon={<Ruler className="w-4 h-4" />}
>
{dimensionStatus.isRunning
? t('settings.photoDimensions.repairing')
: Number(dimensionStatus.withoutDimensions) === 0
? t('settings.photoDimensions.noneToRepair')
: t('settings.photoDimensions.repairButton')}
</Button>
</div>
</Card>
)}
{/* Update Notification Settings */}
<UpdateNotificationSettings />
+13 -2
View File
@@ -1012,8 +1012,6 @@
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
"maxFilesPerUpload": "Max. Dateien pro Upload",
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
"maxUploadBatchSize": "Max. Upload-Paketgröße (MB)",
"maxUploadBatchSizeHelp": "Maximale Größe pro Upload-Anfrage. Reduzieren Sie diesen Wert bei Nutzung eines Reverse-Proxys mit Größenbeschränkung (z.B. Cloudflare: 100MB).",
"allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"featureToggles": "Funktionsschalter",
@@ -1281,6 +1279,19 @@
"failed": "Fehlgeschlagen",
"lastUpdate": "Letzte Aktualisierung"
},
"photoDimensions": {
"title": "Foto-Abmessungen",
"totalPhotos": "Fotos gesamt",
"withDimensions": "Mit Abmessungen",
"missingDimensions": "Fehlende Abmessungen",
"repairButton": "Abmessungen reparieren",
"repairing": "Repariere...",
"alreadyRunning": "Reparatur läuft bereits",
"started": "Reparatur von {{count}} Fotos gestartet",
"noneToRepair": "Alle Fotos haben bereits Abmessungen",
"resultSuccess": "Letzte Reparatur: {{success}} aktualisiert, {{failed}} fehlgeschlagen",
"description": "Fehlende Breite/Höhe für Fotos ergänzen, die vor der Dimensionsverfolgung hochgeladen wurden. Erforderlich für Masonry- und Mosaik-Layouts."
},
"updateNotifications": {
"title": "Update-Benachrichtigungen",
"description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.",
+13 -2
View File
@@ -637,8 +637,6 @@
"maxFileSizeHelp": "Maximum size per uploaded photo",
"maxFilesPerUpload": "Max Files per Upload",
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
"maxUploadBatchSize": "Max Upload Batch Size (MB)",
"maxUploadBatchSizeHelp": "Maximum size per upload request. Lower this if behind a reverse proxy with request size limits (e.g. Cloudflare: 100MB).",
"allowedFileTypes": "Allowed File Types",
"allowedFileTypesHelp": "Comma-separated list of file extensions",
"featureToggles": "Feature Toggles",
@@ -801,6 +799,19 @@
"failed": "Failed",
"lastUpdate": "Last update"
},
"photoDimensions": {
"title": "Photo Dimensions",
"totalPhotos": "Total Photos",
"withDimensions": "With Dimensions",
"missingDimensions": "Missing Dimensions",
"repairButton": "Repair Dimensions",
"repairing": "Repairing...",
"alreadyRunning": "Repair is already running",
"started": "Started repairing {{count}} photos",
"noneToRepair": "All photos already have dimensions",
"resultSuccess": "Last repair: {{success}} updated, {{failed}} failed",
"description": "Backfill missing width/height for photos uploaded before dimension tracking was added. Required for Masonry and Mosaic layouts."
},
"updateNotifications": {
"title": "Update Notifications",
"description": "Receive email notifications when new versions of PicPeak are available.",