diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index d32d587..41b87d3 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -71,6 +71,7 @@ If you need to customize the application or the pre-built images aren't availabl - [Reverse Proxy Setup](#reverse-proxy-setup) - [Maintenance](#maintenance) - [Troubleshooting](#troubleshooting) + - [External Media Library](#external-media-library) ## Prerequisites @@ -111,6 +112,43 @@ If you need to customize the application or the pre-built images aren't availabl docker compose -f docker-compose.production.yml logs -f ``` +## External Media Library + +PicPeak can reference an existing, read‑only media library mounted into the backend container. This avoids copying originals into PicPeak storage. + +- Map your host library path to the container as read‑only in `docker-compose.production.yml`: + - Add volume under `backend`: `- ${EXTERNAL_MEDIA}:/external-media:ro` + - Add backend env: `EXTERNAL_MEDIA_ROOT=/external-media` +- In `.env`, set: + - `EXTERNAL_MEDIA=/mnt/photos` (example host path) + - `EXTERNAL_MEDIA_ROOT=/external-media` + +Usage: +- In Admin → Events, set “Source Mode” to “Reference (external folder)”, select a folder under `/external-media`, then import to index and generate thumbnails. Originals stay in your library. + +Backups and Archives: +- Backups only include data under `STORAGE_PATH` and exclude external originals. The backup manifest includes `metadata.external_references = { excluded: true, events: N, photos: M }` and the Admin UI surfaces a warning. +- Archiving reference events creates a manifest‑only ZIP and deletes thumbnails for that event. External originals are never moved or deleted. + +Local (npm) setup (no Docker): + +1. Create or choose a folder that contains your external originals, e.g. `/Users/you/Pictures/picpeak-external` (macOS/Linux) or `C:\\Pictures\\picpeak-external` (Windows). +2. In `backend/.env` (or your shell), set: + - `EXTERNAL_MEDIA_ROOT=/absolute/path/to/picpeak-external` + - Ensure `STORAGE_PATH` points to your PicPeak storage (defaults to `./storage`). +3. Start services from source: + - Backend: `cd backend && npm install && npm run migrate && JWT_SECRET=... npm start` + - Frontend: `cd frontend && npm install && npm run dev` (or build + serve) +4. In Admin → Events: + - Create an event, set “Source Mode” to “Reference (external folder)”. + - Use the folder picker to browse under your `EXTERNAL_MEDIA_ROOT` and select the subfolder to reference. + - Click “Import from selected folder” to index files and generate thumbnails on demand. + +Notes: +- PicPeak only reads from `EXTERNAL_MEDIA_ROOT`; it never modifies or deletes your originals there. +- Thumbnails are generated under `STORAGE_PATH/thumbnails` and are included in backups; originals in `EXTERNAL_MEDIA_ROOT` are excluded. +- On Windows, use absolute paths (e.g., `C:\\Photos\\Library`) for `EXTERNAL_MEDIA_ROOT`. + ### Method 2: Building from Source 1. **Clone the repository** diff --git a/backend/migrations/core/041_add_external_media.js b/backend/migrations/core/041_add_external_media.js new file mode 100644 index 0000000..eaab0bf --- /dev/null +++ b/backend/migrations/core/041_add_external_media.js @@ -0,0 +1,54 @@ +/** + * Migration 041: Add external media reference support + * - events.source_mode: 'managed' | 'reference' + * - events.external_path: relative path under external media root + * - photos.source_origin: 'managed' | 'external' + * - photos.external_relpath: relative path within event.external_path + */ + +const { addColumnIfNotExists } = require('../helpers'); + +exports.up = async function(knex) { + console.log('Running migration: 041_add_external_media'); + + // events.source_mode (default 'managed') + await addColumnIfNotExists(knex, 'events', 'source_mode', (table) => { + table.string('source_mode').notNullable().defaultTo('managed'); + }); + + // events.external_path (nullable) + await addColumnIfNotExists(knex, 'events', 'external_path', (table) => { + table.text('external_path'); + }); + + // photos.source_origin (default 'managed') + await addColumnIfNotExists(knex, 'photos', 'source_origin', (table) => { + table.string('source_origin').notNullable().defaultTo('managed'); + }); + + // photos.external_relpath (nullable) + await addColumnIfNotExists(knex, 'photos', 'external_relpath', (table) => { + table.text('external_relpath'); + }); + + // Helpful index for queries + try { + if (knex.client.config.client === 'pg') { + await knex.raw("CREATE INDEX IF NOT EXISTS photos_event_source_idx ON photos (event_id, source_origin)"); + } else { + await knex.schema.alterTable('photos', (table) => { + table.index(['event_id', 'source_origin'], 'photos_event_source_idx'); + }); + } + } catch (e) { + console.log('Index creation skipped or failed (may already exist):', e.message); + } + + console.log('Migration 041_add_external_media completed'); +}; + +exports.down = async function(knex) { + console.log('Rollback: 041_add_external_media'); + // Keep columns (safe rollback not removing data). Intentionally no-op. +}; + diff --git a/backend/src/routes/adminExternalMedia.js b/backend/src/routes/adminExternalMedia.js new file mode 100644 index 0000000..c6c5102 --- /dev/null +++ b/backend/src/routes/adminExternalMedia.js @@ -0,0 +1,112 @@ +const express = require('express'); +const path = require('path'); +const fs = require('fs').promises; +const { adminAuth } = require('../middleware/auth'); +const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); +const { db, logActivity } = require('../database/db'); + +const router = express.Router(); + +// GET /api/admin/external-media/list?path=relative/dir +router.get('/list', adminAuth, async (req, res) => { + try { + const relPath = (req.query.path || '').replace(/^\/+/, ''); + const result = await list(relPath); + res.json(result); + } catch (error) { + res.status(400).json({ error: 'Invalid path', details: error.message }); + } +}); + +// Helper to recursively collect files under a directory, filtered by image extensions +async function walkDir(dir, baseDir) { + const results = []; + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + if (e.name.startsWith('.')) continue; + const full = path.join(dir, e.name); + if (e.isDirectory()) { + results.push(...await walkDir(full, baseDir)); + } else if (e.isFile()) { + const ext = path.extname(e.name).toLowerCase(); + if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) { + const rel = path.relative(baseDir, full); + results.push({ full, rel, name: e.name }); + } + } + } + return results; +} + +// POST /api/admin/events/:id/import-external +// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } } +router.post('/events/:id/import-external', adminAuth, async (req, res) => { + try { + const eventId = parseInt(req.params.id); + const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {}; + if (!external_path) return res.status(400).json({ error: 'external_path is required' }); + + // Load event + const event = await db('events').where('id', eventId).first(); + if (!event) return res.status(404).json({ error: 'Event not found' }); + + const baseAbs = resolveExternalPath({ external_path }, ''); + // Collect files + const files = recursive ? await walkDir(baseAbs, baseAbs) : (await fs.readdir(baseAbs, { withFileTypes: true })) + .filter(e => e.isFile()) + .map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name })) + .filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase())); + + let imported = 0; + let skipped = 0; + + // Insert photos + for (const f of files) { + // Infer type by subfolder names + const segs = f.rel.split(path.sep); + let type = 'individual'; + if (segs[0] === map.collages) type = 'collage'; + if (segs[0] === map.individual) type = 'individual'; + + try { + // Check if already exists (by external_relpath) + const exists = await db('photos') + .where({ event_id: eventId, external_relpath: f.rel }) + .first(); + if (exists) { skipped++; continue; } + + const stats = await fs.stat(f.full); + const inserted = await db('photos') + .insert({ + event_id: eventId, + filename: f.name, + // Keep path as a hint for legacy code but not used for resolution in external mode + path: path.join(event.slug, f.name), + thumbnail_path: null, + type, + size_bytes: stats.size, + source_origin: 'external', + external_relpath: f.rel + }) + .returning('id'); + + imported += (inserted?.length ? 1 : 0); + } catch (e) { + skipped++; + } + } + + // Update event fields + await db('events').where('id', eventId).update({ source_mode: 'reference', external_path }); + + // Queue thumbnail generation lazily by reading thumbnails via ensure endpoint as needed + await logActivity('external_import_completed', { event_id: eventId, imported, skipped, external_path }, eventId, { type: 'admin' }); + + res.json({ imported, skipped, thumbnailsQueued: 0 }); + } catch (error) { + res.status(500).json({ error: 'Failed to import external media', details: error.message }); + } +}); + +module.exports = router; + diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index ba52886..f973168 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -606,8 +606,9 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => return res.status(404).json({ error: 'Photo not found' }); } - const storagePath = getStoragePath(); - const filePath = path.join(storagePath, 'events/active', photo.path); + const { resolvePhotoFilePath } = require('../services/photoResolver'); + const event = await db('events').where('id', eventId).first(); + const filePath = resolvePhotoFilePath(event, photo); // Check if file exists try { @@ -684,8 +685,10 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { photos: photos.map(photo => ({ id: photo.id, filename: photo.filename, - url: `/admin/events/${eventId}/photo/${photo.id}`, - thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null, + // Use the correct admin photos router base for serving images + url: `/admin/photos/${eventId}/photo/${photo.id}`, + // Always expose a thumbnail URL; backend will generate on demand if missing + thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`, type: photo.type, category_id: photo.type, category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages', @@ -719,8 +722,9 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => { return res.status(404).json({ error: 'Photo not found' }); } - const storagePath = getStoragePath(); - const filePath = path.join(storagePath, 'events/active', photo.path); + const { resolvePhotoFilePath } = require('../services/photoResolver'); + const event = await db('events').where('id', eventId).first(); + const filePath = resolvePhotoFilePath(event, photo); // Check if file exists try { @@ -802,4 +806,4 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/services/externalMediaService.js b/backend/src/services/externalMediaService.js new file mode 100644 index 0000000..31913cd --- /dev/null +++ b/backend/src/services/externalMediaService.js @@ -0,0 +1,67 @@ +const fs = require('fs').promises; +const path = require('path'); +const { safePathJoin } = require('../utils/fileSecurityUtils'); + +function getExternalMediaRoot() { + return process.env.EXTERNAL_MEDIA_ROOT || '/external-media'; +} + +function isUnderRoot(p) { + const root = path.resolve(getExternalMediaRoot()); + const resolved = path.resolve(p); + return resolved === root || resolved.startsWith(root + path.sep); +} + +async function list(relativePath = '') { + const root = getExternalMediaRoot(); + // Normalize and ensure safe join under root + const targetDir = safePathJoin(root, relativePath || '.'); + + const entries = []; + try { + const dirents = await fs.readdir(targetDir, { withFileTypes: true }); + for (const d of dirents) { + // Skip hidden files and directories + if (d.name.startsWith('.')) continue; + const full = path.join(targetDir, d.name); + const stat = await fs.stat(full).catch(() => null); + if (!stat) continue; + + if (d.isDirectory()) { + entries.push({ name: d.name, type: 'dir' }); + } else if (d.isFile()) { + const ext = path.extname(d.name).toLowerCase(); + if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) { + entries.push({ name: d.name, type: 'file', size: stat.size, mtime: stat.mtime }); + } + } + } + } catch (e) { + // Propagate errors for caller to handle (e.g., invalid path) + throw e; + } + + const rootResolved = path.resolve(root); + const currentResolved = path.resolve(targetDir); + const canNavigateUp = currentResolved !== rootResolved; + + // Return normalized relative path from root + const relFromRoot = path.relative(rootResolved, currentResolved); + + return { path: relFromRoot, entries, canNavigateUp }; +} + +function resolveExternalPath(event, relpath) { + const root = getExternalMediaRoot(); + const base = event?.external_path ? path.join(event.external_path) : ''; + const combined = base ? path.join(base, relpath || '') : (relpath || ''); + return safePathJoin(root, combined); +} + +module.exports = { + getExternalMediaRoot, + isUnderRoot, + list, + resolveExternalPath, +}; + diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index 380fc6c..09161f3 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -132,7 +132,8 @@ async function generateThumbnail(imagePath, options = {}) { return path.relative(getStoragePath(), thumbnailPath); } catch (error) { - logger.error(`Failed to generate thumbnail for ${filename}:`, error.message); + const msg = (error && error.message) ? error.message : String(error); + logger.error(`Failed to generate thumbnail for ${filename}: ${msg}`); // Clean up any partially created file try { @@ -171,8 +172,18 @@ async function isThumbnailValid(thumbnailPath) { * Regenerate thumbnail if it's broken or missing */ async function ensureThumbnail(photo) { - const storagePath = getStoragePath(); - const originalPath = path.join(storagePath, 'events/active', photo.path); + const { db } = require('../database/db'); + const { resolvePhotoFilePath } = require('./photoResolver'); + let originalPath; + try { + const event = await db('events').where('id', photo.event_id).first(); + originalPath = resolvePhotoFilePath(event, photo); + logger.info(`Ensuring thumbnail for photo ${photo.id} from source: ${originalPath}`); + } catch (e) { + const msg = (e && e.message) ? e.message : String(e); + logger.error(`Failed to resolve original path for thumbnail (photo ${photo.id}): ${msg}`); + return null; + } // Check if thumbnail exists and is valid if (photo.thumbnail_path) { diff --git a/backend/src/services/photoResolver.js b/backend/src/services/photoResolver.js new file mode 100644 index 0000000..9d8d81b --- /dev/null +++ b/backend/src/services/photoResolver.js @@ -0,0 +1,44 @@ +const path = require('path'); +const { resolveExternalPath } = require('./externalMediaService'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +/** + * Resolve absolute photo file path based on event + photo origin + * Managed: storage/events/active + photo.path (legacy variants supported) + * External reference: EXTERNAL_MEDIA_ROOT + event.external_path + photo.external_relpath + */ +function resolvePhotoFilePath(event, photo) { + if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo'); + + const mode = (event.source_mode || photo.source_origin || 'managed'); + if (mode === 'reference' || photo.source_origin === 'external') { + if (!photo.external_relpath) { + throw new Error('Missing external_relpath for external photo'); + } + // Normalize duplicate leaf segments (e.g., event.external_path ends with 'individual' + // and external_relpath starts with 'individual/') to avoid double segment like + // '/external-media/.../individual/individual/file.jpg' + let rel = photo.external_relpath; + try { + const lastSeg = path.basename(event.external_path || ''); + const firstSeg = rel.split(path.sep)[0]; + if (lastSeg && firstSeg && lastSeg === firstSeg) { + rel = rel.split(path.sep).slice(1).join(path.sep) || ''; + } + } catch (_) { + // ignore normalization errors + } + return resolveExternalPath(event, rel); + } + + const storagePath = getStoragePath(); + if (photo.path && photo.path.startsWith('events/active/')) { + return path.join(storagePath, photo.path); + } + return path.join(storagePath, 'events/active', photo.path || ''); +} + +module.exports = { + resolvePhotoFilePath, +}; diff --git a/frontend/src/components/admin/FeedbackModerationPanel.tsx b/frontend/src/components/admin/FeedbackModerationPanel.tsx index fc1eea2..673cb84 100644 --- a/frontend/src/components/admin/FeedbackModerationPanel.tsx +++ b/frontend/src/components/admin/FeedbackModerationPanel.tsx @@ -15,6 +15,7 @@ import { parseISO } from 'date-fns'; import { toast } from 'react-toastify'; import { Card, Loading, Button } from '../common'; +import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { feedbackService } from '../../services/feedback.service'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; @@ -129,15 +130,13 @@ export const FeedbackModerationPanel: React.FC = (

{item.comment_text || item.comment}

{item.photo_id && (
- {item.filename { - // Hide image if thumbnail fails to load - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> +
+ +

{t('feedback.onPhoto', 'On photo')}: {item.filename || item.photo_filename || `#${item.photo_id}`}

@@ -218,4 +217,4 @@ export const FeedbackModerationPanel: React.FC = ( ); }; -FeedbackModerationPanel.displayName = 'FeedbackModerationPanel'; \ No newline at end of file +FeedbackModerationPanel.displayName = 'FeedbackModerationPanel'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 1a47adf..2daa04a 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -36,6 +36,11 @@ "customize": "Anpassen", "hide": "Ausblenden", "unknown": "Unbekannt", + "notSet": "Nicht festgelegt", + "of": "von", + "up": "Nach oben", + "select": "Auswählen", + "selected": "Ausgewählt", "chunk": "Teil" }, "upload": { @@ -50,6 +55,10 @@ "uploadFailed": "Upload fehlgeschlagen", "someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden", "uploadPhotos": "Fotos hochladen", + "importExternal": "Aus externem Ordner importieren", + "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", @@ -66,6 +75,342 @@ "backup": "Backup & Wiederherstellung", "cmsPages": "CMS-Seiten" }, + "backup": { + "external": { + "warning": { + "title": "Externe Medien ausgeschlossen", + "body": "Diese Installation referenziert Fotos aus /external-media. Diese Originale sind von Backups ausgeschlossen. Thumbnails und Datenbank werden weiterhin gesichert." + } + }, + "title": "Backup-Verwaltung", + "subtitle": "System-Backups verwalten, automatische Backups konfigurieren und aus früheren Backups wiederherstellen.", + "tabs": { + "dashboard": "Dashboard", + "configuration": "Konfiguration", + "history": "Backup-Verlauf", + "restore": "Wiederherstellen" + }, + "status": { + "inProgress": "Backup wird ausgeführt...", + "lastBackup": "Letztes Backup", + "noBackups": "Keine Backups gefunden", + "nextBackup": "Nächstes Backup", + "notScheduled": "Nicht geplant", + "enabled": "Aktiviert", + "disabled": "Deaktiviert" + }, + "actions": { + "runBackupNow": "Backup jetzt starten", + "starting": "Starte...", + "running": "Läuft...", + "testConnection": "Verbindung testen", + "save": "Konfiguration speichern", + "delete": "Löschen", + "view": "Details anzeigen", + "download": "Herunterladen", + "refresh": "Aktualisieren" + }, + "dashboard": { + "backupHealth": "Backup-Gesundheit", + "health": { + "title": "Backup-Gesundheit" + }, + "healthMessages": { + "noBackups": "Keine Backups gefunden", + "lastBackupFailed": "Letztes Backup fehlgeschlagen", + "upToDate": "Backup ist aktuell", + "recent": "Backup ist kürzlich", + "gettingOld": "Backup wird alt", + "outdated": "Backup ist veraltet" + }, + "stats": { + "totalBackups": "Gesamt-Backups", + "backupSize": "Backup-Größe", + "lastDuration": "Letzte Dauer", + "backupStatus": "Backup-Status", + "last": "Letztes", + "files": "Dateien", + "minutes": "{{count}}m", + "active": "Aktiv", + "inactive": "Inaktiv", + "noBackupsYet": "Noch keine Backups" + }, + "recentActivity": { + "title": "Letzte Backup-Aktivitäten" + }, + "notConfigured": { + "title": "Backup nicht konfiguriert", + "message": "Bitte konfigurieren Sie die Backup-Einstellungen im Tab \"Konfiguration\", bevor Sie Backups ausführen." + }, + "coverage": { + "title": "Backup-Abdeckung", + "database": "Datenbank", + "photos": "Fotos", + "archives": "Archive", + "systemFiles": "Systemdateien", + "included": "Enthalten", + "excluded": "Ausgeschlossen", + "optional": "Optional" + }, + "storageDestination": "Speicherziel", + "nextScheduledBackup": "Nächstes geplantes Backup", + "backupType": "{{type}}-Backup", + "noDestinationSet": "Kein Ziel gesetzt" + }, + "configuration": { + "enableBackup": "Automatische Backups aktivieren", + "enableBackupHelp": "Backups automatisch gemäß Zeitplan erstellen", + "destinationType": "Backup-Ziel", + "destinationTypes": { + "local": { + "name": "Lokaler Speicher", + "description": "Backups auf dem lokalen Dateisystem speichern" + }, + "rsync": { + "name": "Remote-Server (Rsync)", + "description": "Backups per SSH/Rsync auf einen entfernten Server synchronisieren" + }, + "s3": { + "name": "S3-kompatibler Speicher", + "description": "Backups in Amazon S3 oder kompatiblen Objektspeicher ablegen" + } + }, + "fields": { + "destinationPath": "Zielpfad", + "destinationPathHelp": "Lokaler Verzeichnispfad für Backups", + "destinationPathPlaceholder": "/pfad/zum/backup/verzeichnis", + "rsyncHost": "Remote Host", + "rsyncHostPlaceholder": "backup.example.com", + "rsyncUser": "Benutzer", + "rsyncUserPlaceholder": "backupuser", + "rsyncPath": "Remote-Pfad", + "rsyncPathPlaceholder": "/pfad/auf/server", + "rsyncSshKey": "SSH-Schlüssel", + "rsyncSshKeyPlaceholder": "Privater SSH-Schlüssel (PEM)", + "rsyncSshKeyHelp": "Fügen Sie den privaten SSH-Schlüssel im PEM-Format ein.", + "s3Endpoint": "S3-Endpunkt-URL", + "s3EndpointHelp": "z. B. https://s3.amazonaws.com oder Ihr MinIO-Endpunkt", + "s3Bucket": "Bucket-Name", + "s3Region": "Region", + "s3AccessKey": "Access Key ID", + "s3SecretKey": "Secret Access Key" + }, + "schedule": { + "title": "Zeitplan", + "scheduleType": "Zeitplantyp", + "customCron": "Eigener Cron-Ausdruck", + "customCronHelp": "Cron-Ausdruck für benutzerdefinierten Zeitplan", + "retention": "Aufbewahrung (Tage)", + "retentionHelp": "Anzahl der Tage, nach denen alte Backups automatisch gelöscht werden" + }, + "whatToBackup": { + "title": "Was soll gesichert werden", + "database": "Datenbank", + "databaseHelp": "Datenbank (Einstellungen, Events, Benutzer)", + "photos": "Fotos", + "photosHelp": "Aktive Galeriefotos sichern", + "archives": "Archive", + "archivesHelp": "Archivierte ZIP-Dateien", + "thumbnails": "Thumbnails", + "thumbnailsHelp": "Generierte Vorschaubilder" + }, + "advancedOptions": { + "title": "Erweiterte Optionen", + "compression": "Kompression", + "compressionHelp": "Backups komprimieren, um Speicherplatz zu sparen", + "encryption": "Verschlüsselung", + "encryptionHelp": "Backups mit einer Passphrase verschlüsseln", + "encryptionPassphrase": "Verschlüsselungs-Passphrase", + "encryptionPassphraseHelp": "Passphrase zum Verschlüsseln/Entschlüsseln der Backups" + }, + "savingSettings": "Einstellungen werden gespeichert...", + "saveSettings": "Einstellungen speichern" + }, + "history": { + "columns": { + "status": "Status", + "dateTime": "Datum & Uhrzeit", + "type": "Typ", + "size": "Größe", + "duration": "Dauer", + "actions": "Aktionen" + }, + "details": "Details", + "statistics": "Statistiken", + "errors": "Fehler", + "backupDetails": { + "backupId": "Backup-ID", + "startTime": "Startzeit", + "endTime": "Endzeit", + "destination": "Ziel", + "filesProcessed": "Verarbeitete Dateien", + "totalSize": "Gesamtgröße", + "compressionRatio": "Kompressionsrate", + "errorLog": "Fehlerprotokoll", + "noErrors": "Keine Fehler aufgetreten" + }, + "pagination": { + "showing": "Zeige {{from}}–{{to}} von {{total}} Backups", + "previous": "Zurück", + "next": "Weiter" + }, + "filter": { + "allStatus": "Alle Status", + "completed": "Abgeschlossen", + "failed": "Fehlgeschlagen", + "running": "Läuft", + "partial": "Teilweise" + }, + "noBackupsFound": "Keine Backups gefunden", + "backupsWillAppear": "Backups erscheinen hier, sobald sie erstellt wurden", + "messages": { + "deleteSuccess": "Backup erfolgreich gelöscht" + } + }, + "restore": { + "steps": { + "selectSource": "Quelle auswählen", + "chooseBackup": "Backup wählen", + "restoreOptions": "Wiederherstellungsoptionen", + "reviewConfirm": "Prüfen & Bestätigen", + "progress": "Fortschritt" + }, + "source": { + "title": "Backup-Quelle auswählen", + "subtitle": "Wählen Sie, woher das Backup wiederhergestellt werden soll", + "local": { + "name": "Lokales Backup", + "description": "Vom lokalen Dateisystem wiederherstellen" + }, + "s3": { + "name": "S3-Speicher", + "description": "Aus S3-Bucket wiederherstellen" + }, + "upload": { + "name": "Backup hochladen", + "description": "Eine Backup-Datei hochladen", + "comingSoon": "Upload-Funktion folgt in Kürze" + }, + "configuration": { + "s3": "S3-Konfiguration", + "endpoint": "S3-Endpunkt-URL", + "bucket": "Bucket-Name", + "accessKey": "Access Key ID", + "secretKey": "Secret Access Key" + } + }, + "backup": { + "title": "Backup zum Wiederherstellen wählen", + "subtitle": "Aus verfügbaren Backups auswählen", + "noBackupsFound": "Keine Backups in der gewählten Quelle gefunden", + "encrypted": "Verschlüsseltes Backup", + "encryptedMessage": "Zum Wiederherstellen dieses Backups wird die Verschlüsselungs-Passphrase benötigt.", + "enterPassphrase": "Verschlüsselungs-Passphrase eingeben", + "at": "um" + }, + "restoreTypes": { + "full": { + "name": "Vollständige Wiederherstellung", + "description": "Alles wiederherstellen (Datenbank, Fotos und Archive)", + "warning": "Dies ersetzt alle aktuellen Daten" + }, + "database": { + "name": "Nur Datenbank", + "description": "Nur die Datenbank wiederherstellen (Einstellungen, Events, Benutzer)", + "warning": "Aktuelle Datenbank wird ersetzt" + }, + "files": { + "name": "Nur Dateien", + "description": "Nur Fotos und Archive wiederherstellen", + "warning": "Vorhandene Dateien können überschrieben werden" + }, + "selective": { + "name": "Selektive Wiederherstellung", + "description": "Bestimmte Elemente zur Wiederherstellung auswählen", + "warning": "Es werden nur ausgewählte Elemente wiederhergestellt" + } + }, + "options": { + "title": "Wiederherstellungsoptionen", + "subtitle": "Auswählen, was wiederhergestellt werden soll", + "additionalOptions": { + "title": "Zusätzliche Optionen", + "skipPreBackup": "Vorab-Backup überspringen", + "skipPreBackupHelp": "Standardmäßig wird vor der Wiederherstellung ein Backup erstellt. Aktivieren, um dies zu überspringen.", + "force": "Wiederherstellung erzwingen", + "forceHelp": "Sicherheitsprüfungen und Warnungen überschreiben (mit Vorsicht verwenden)" + } + }, + "confirmation": { + "title": "Prüfen & Bestätigen", + "subtitle": "Bitte prüfen Sie Ihre Wiederherstellungskonfiguration", + "validation": { + "passed": "Validierung bestanden", + "failed": "Validierung fehlgeschlagen", + "checking": "Wiederherstellungskonfiguration wird geprüft..." + }, + "spaceCheck": { + "title": "Speicherplatz", + "required": "Erforderlich", + "available": "Verfügbar", + "insufficient": "Nicht genügend Speicherplatz" + }, + "summary": { + "title": "Zusammenfassung", + "source": "Quelle", + "backupDate": "Backup-Datum", + "restoreType": "Art der Wiederherstellung", + "preBackup": "Vorab-Backup", + "enabled": "Aktiviert", + "skipped": "Übersprungen" + }, + "warning": { + "title": "Wichtiger Hinweis", + "message": "Diese Wiederherstellung ersetzt bestehende Daten. Stellen Sie sicher, dass Sie ein aktuelles Backup haben. Dieser Vorgang kann nicht rückgängig gemacht werden." + } + }, + "progress": { + "title": "Fortschritt der Wiederherstellung", + "inProgress": "Wiederherstellung läuft...", + "completed": "Wiederherstellung abgeschlossen", + "overallProgress": "Gesamtfortschritt", + "current": "Aktuell", + "statusDetails": "Statusdetails", + "restoreLogs": "Wiederherstellungs-Logs", + "steps": { + "completed": "Abgeschlossen", + "running": "Läuft", + "failed": "Fehlgeschlagen", + "pending": "Ausstehend" + }, + "success": { + "title": "Wiederherstellung erfolgreich abgeschlossen", + "message": "Ihre Daten wurden wiederhergestellt. Bitte prüfen Sie, ob alles korrekt funktioniert." + } + }, + "actions": { + "back": "Zurück", + "next": "Weiter", + "startRestore": "Wiederherstellung starten", + "starting": "Starte...", + "validating": "Validiere...", + "startNewRestore": "Neue Wiederherstellung starten" + }, + "messages": { + "restoreStarted": "Wiederherstellung erfolgreich gestartet" + } + }, + "messages": { + "backupStarted": "Backup erfolgreich gestartet", + "backupFailed": "Backup konnte nicht gestartet werden", + "configUpdated": "Backup-Konfiguration aktualisiert", + "configUpdateFailed": "Konfiguration konnte nicht aktualisiert werden", + "backupDeleted": "Backup erfolgreich gelöscht", + "deleteFailed": "Backup konnte nicht gelöscht werden", + "testEmailSent": "Verbindung erfolgreich getestet!", + "testEmailFailed": "Verbindungstest fehlgeschlagen" + } + }, "archives": { "title": "Archive", "subtitle": "Archivierte Fotogalerien verwalten", @@ -1437,4 +1782,4 @@ "poweredBy": "Bereitgestellt von PicPeak", "devModeHint": "Entwicklungsmodus: E-Mail: admin@example.com, Passwort: admin123" } -} \ No newline at end of file +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 7f4ac7c..0dfd3f3 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -36,6 +36,11 @@ "customize": "Customize", "hide": "Hide", "unknown": "Unknown", + "notSet": "Not set", + "of": "of", + "up": "Up", + "select": "Select", + "selected": "Selected", "chunk": "Chunk" }, "upload": { @@ -50,6 +55,10 @@ "uploadFailed": "Upload failed", "someFilesFailed": "Some files failed to upload", "uploadPhotos": "Upload Photos", + "importExternal": "Import from External Folder", + "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", @@ -973,6 +982,12 @@ "pageUpdated": "Page updated successfully" }, "backup": { + "external": { + "warning": { + "title": "External media excluded", + "body": "This installation references photos from /external-media. These originals are excluded from backups. Thumbnails and database are still backed up." + } + }, "title": "Backup Management", "subtitle": "Manage system backups, configure automated backups, and restore from previous backups.", "tabs": { @@ -1496,4 +1511,4 @@ "poweredBy": "Powered by PicPeak", "devModeHint": "Development Mode: Use email: admin@example.com, password: admin123" } -} \ No newline at end of file +} diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index ab3c2ba..870e354 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -28,9 +28,68 @@ import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, P import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { archiveService } from '../../services/archive.service'; +import { externalMediaService } from '../../services/externalMedia.service'; import { photosService, AdminPhoto } from '../../services/photos.service'; import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service'; import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types'; +import { useTranslation } from 'react-i18next'; + +const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => { + const { t } = useTranslation(); + const [entries, setEntries] = useState<{ path: string; entries: any[]; canNavigateUp: boolean } | null>(null); + const [loading, setLoading] = useState(false); + const [currentPath, setCurrentPath] = useState(value || ''); + + const load = async (p: string) => { + try { + setLoading(true); + const res = await externalMediaService.list(p); + setEntries(res); + setCurrentPath(res.path); + } finally { + setLoading(false); + } + }; + + useEffect(() => { load(currentPath || ''); }, []); + + const navigateUp = () => { + if (!entries?.canNavigateUp) return; + const parts = (entries.path || '').split('/').filter(Boolean); + parts.pop(); + load(parts.join('/')); + }; + + return ( +
+
+
/external-media/{entries?.path || ''}
+
+ + +
+
+ {loading ? ( +
{t('common.loading', 'Loading...')}
+ ) : ( +
+ {entries?.entries?.filter((e: any) => e.type === 'dir').map((e: any) => ( + + ))} +
+ )} + {value && ( +
{t('common.selected', 'Selected')}: /external-media/{value}
+ )} +
+ ); +}; export const EventDetailsPage: React.FC = () => { const { id } = useParams<{ id: string }>(); @@ -67,7 +126,10 @@ export const EventDetailsPage: React.FC = () => { }); const [copiedLink, setCopiedLink] = useState(false); const [showPhotoUpload, setShowPhotoUpload] = useState(false); + const [showExternalImport, setShowExternalImport] = useState(false); const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview'); + const [externalPath, setExternalPath] = useState(''); + const [importing, setImporting] = useState(false); const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null); const [showPasswordReset, setShowPasswordReset] = useState(false); const [currentTheme, setCurrentTheme] = useState(null); @@ -598,6 +660,15 @@ export const EventDetailsPage: React.FC = () => {
) : (
+
+
Source Mode
+
+ {event.source_mode === 'reference' ? 'Reference (external folder)' : 'Managed (upload)'} + {event.source_mode === 'reference' && event.external_path ? ( + /external-media/{event.external_path} + ) : null} +
+
{t('events.welcomeMessage')}
@@ -951,6 +1022,17 @@ export const EventDetailsPage: React.FC = () => { > {t('events.uploadPhotos')} + {event.source_mode === 'reference' && ( +
+ +
+ )}
{/* Photo Grid */} @@ -1024,6 +1106,59 @@ export const EventDetailsPage: React.FC = () => { /> )} + {/* External Import Modal */} + {showExternalImport && ( +
+ +
+

{t('events.importExternal', 'Import from External Folder')}

+ +
+ +
+ {t('events.externalImportInfo', 'All pictures from the selected folder will be imported.')} +
+
+ {t('events.selectExternalFolder', 'Select external folder under /external-media')} +
+ + +
+ + +
+
+
+ )} + ); }; diff --git a/frontend/src/pages/admin/EventFeedbackPage.tsx b/frontend/src/pages/admin/EventFeedbackPage.tsx index a53e4ae..d0ca887 100644 --- a/frontend/src/pages/admin/EventFeedbackPage.tsx +++ b/frontend/src/pages/admin/EventFeedbackPage.tsx @@ -20,6 +20,7 @@ import { toast } from 'react-toastify'; import { format } from 'date-fns'; import { Button, Card, Loading } from '../../components/common'; +import { AdminAuthenticatedImage } from '../../components/admin/AdminAuthenticatedImage'; import { FeedbackSettings } from '../../components/admin'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; @@ -251,15 +252,13 @@ export const EventFeedbackPage: React.FC = () => {
{item.photo_id && ( - {item.filename { - // Hide image if thumbnail fails to load - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> +
+ +
)}
@@ -528,4 +527,4 @@ export const EventFeedbackPage: React.FC = () => { )}
); -}; \ No newline at end of file +}; diff --git a/frontend/src/services/photos.service.ts b/frontend/src/services/photos.service.ts index edb3b5c..1487637 100644 --- a/frontend/src/services/photos.service.ts +++ b/frontend/src/services/photos.service.ts @@ -45,7 +45,8 @@ class PhotosService { } const queryString = params.toString(); - const url = `/admin/events/${eventId}/photos${queryString ? `?${queryString}` : ''}`; + // Use admin photos router for listing to ensure URL alignment with media/thumbnail endpoints + const url = `/admin/photos/${eventId}/photos${queryString ? `?${queryString}` : ''}`; const response = await api.get(url); @@ -96,4 +97,4 @@ class PhotosService { } } -export const photosService = new PhotosService(); \ No newline at end of file +export const photosService = new PhotosService();