From 8611206396aeb16a2dcb1f901c697ba48c2158b6 Mon Sep 17 00:00:00 2001 From: paul Date: Sun, 21 Sep 2025 22:03:07 +0200 Subject: [PATCH] Fix PicPeak regressions and close #22 #24 #25 #26 #27 #28 --- backend/knexfile.js | 38 +++++++- backend/migrations/run-migrations-safe.js | 6 +- backend/src/routes/adminEvents.js | 23 ++++- backend/src/services/photoProcessor.js | 59 +++++++++++-- .../src/components/admin/GalleryPreview.tsx | 65 ++++++++++++-- .../src/components/gallery/GalleryFilter.tsx | 40 ++++++++- .../src/components/gallery/GallerySidebar.tsx | 3 + .../src/components/gallery/GalleryView.tsx | 23 ++++- .../src/components/gallery/PhotoFilterBar.tsx | 21 ++++- frontend/src/hooks/useGallery.ts | 2 +- frontend/src/i18n/locales/de.json | 17 +++- frontend/src/i18n/locales/en.json | 7 ++ frontend/src/pages/admin/BrandingPage.tsx | 26 +++++- frontend/src/pages/admin/EventDetailsPage.tsx | 87 +++++++++++++++++-- frontend/src/pages/admin/EventsListPage.tsx | 13 +-- frontend/src/pages/admin/SettingsPage.tsx | 55 +++++++++--- frontend/src/services/events.service.ts | 4 +- frontend/src/services/gallery.service.ts | 8 +- 18 files changed, 434 insertions(+), 63 deletions(-) diff --git a/backend/knexfile.js b/backend/knexfile.js index 47e9cf1..452e93e 100644 --- a/backend/knexfile.js +++ b/backend/knexfile.js @@ -3,8 +3,40 @@ require('dotenv').config(); const path = require('path'); // Database configuration for different environments +const resolveSqliteFilename = (filenameEnv) => { + const fallback = path.join(__dirname, './data/photo_sharing.db'); + + if (!filenameEnv) { + return fallback; + } + + const trimmed = String(filenameEnv).trim(); + if (!trimmed) { + return fallback; + } + + let resolved; + if (path.isAbsolute(trimmed)) { + resolved = trimmed; + } else if (trimmed.startsWith('./') || trimmed.startsWith('../')) { + resolved = path.resolve(__dirname, trimmed); + } else { + resolved = path.join(__dirname, trimmed); + } + + const normalized = path.normalize(resolved); + const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname)); + const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`; + + if (normalized.includes(duplicatePattern)) { + return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`); + } + + return normalized; +}; + const sqliteConnection = (filenameEnv) => ({ - filename: path.join(__dirname, filenameEnv || './data/photo_sharing.db') + filename: resolveSqliteFilename(filenameEnv) }); const baseSqliteConfig = { @@ -29,7 +61,7 @@ const config = { password: process.env.DB_PASSWORD || 'postgres', database: process.env.DB_NAME || 'photo_sharing' } : { - filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db') + filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db') }, useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg', migrations: { @@ -78,7 +110,7 @@ const config = { keepAliveInitialDelayMillis: 0 } : { - filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db') + filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db') }, useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg', pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg' diff --git a/backend/migrations/run-migrations-safe.js b/backend/migrations/run-migrations-safe.js index 9933c7a..99c5abe 100644 --- a/backend/migrations/run-migrations-safe.js +++ b/backend/migrations/run-migrations-safe.js @@ -120,7 +120,9 @@ async function runMigrations() { // Check if this is a new deployment // It's new if no essential tables exist OR no migrations have been applied - const isNewDeployment = (!hasEventsTable || !hasPhotosTable || !hasAdminTable || !hasActivityLogsTable) || appliedFilenames.length === 0; + const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable; + const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable; + const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables); // Only detect existing schema for truly existing deployments if (!isNewDeployment) { @@ -227,4 +229,4 @@ if (require.main === module) { waitAndRun(); } -module.exports = { runMigrations }; \ No newline at end of file +module.exports = { runMigrations }; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index d42e72e..a8b9e52 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -396,7 +396,9 @@ router.put('/:id', adminAuth, [ body('allow_downloads').optional().isBoolean(), body('disable_right_click').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(), - body('watermark_text').optional().trim() + body('watermark_text').optional().trim(), + body('source_mode').optional().isIn(['managed', 'reference']), + body('external_path').optional({ nullable: true }).isString().trim() ], async (req, res) => { try { const errors = validationResult(req); @@ -407,7 +409,24 @@ router.put('/:id', adminAuth, [ } const { id } = req.params; - const updates = req.body; + const updates = { ...req.body }; + + if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) { + updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed'; + } + + if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) { + const trimmedPath = updates.external_path ? String(updates.external_path).trim() : ''; + updates.external_path = trimmedPath || null; + } + + if (updates.source_mode === 'managed') { + updates.external_path = null; + } + + if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) { + return res.status(400).json({ error: 'external_path is required when source_mode is reference' }); + } // Log the update request for debugging console.log('Update event request:', { diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js index d0729ec..34938da 100644 --- a/backend/src/services/photoProcessor.js +++ b/backend/src/services/photoProcessor.js @@ -7,9 +7,32 @@ const { generatePhotoFilename } = require('../utils/filenameSanitizer'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +function normalizeFiles(files) { + if (!files) return []; + if (Array.isArray(files)) return files.filter(Boolean); + + // Multer may expose files as an iterable object + if (typeof files[Symbol.iterator] === 'function') { + return Array.from(files).filter(Boolean); + } + + if (typeof files === 'object') { + return Object.values(files) + .flatMap((value) => (Array.isArray(value) ? value : [value])) + .filter(Boolean); + } + + return []; +} + async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) { const uploadedPhotos = []; - + const fileList = normalizeFiles(files); + + if (fileList.length === 0) { + return uploadedPhotos; + } + // Get event details const event = await db('events').where({ id: eventId }).first(); if (!event) { @@ -17,7 +40,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ } // Process each file - for (const file of files) { + for (const file of fileList) { const trx = await db.transaction(); try { @@ -35,8 +58,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ .where({ event_id: eventId, type: photoType }) .count('id as count') .first(); - - counter = (existingCount.count || 0) + 1; + + const existingCountValue = Number(existingCount?.count ?? 0); + counter = existingCountValue + 1; // Generate new filename const extension = path.extname(file.originalname); @@ -53,9 +77,24 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ await fs.mkdir(destPath, { recursive: true }); const newPath = path.join(destPath, newFilename); + const tempPath = file?.path || file?.filepath || file?.tempFilePath; + + if (!tempPath) { + throw new Error('Uploaded file is missing a temporary path'); + } + // Use copyFile and unlink instead of rename to avoid cross-device issues - await fs.copyFile(file.path, newPath); - await fs.unlink(file.path); + try { + await fs.copyFile(tempPath, newPath); + } finally { + try { + await fs.unlink(tempPath); + } catch (unlinkErr) { + if (unlinkErr?.code !== 'ENOENT') { + console.warn(`Failed to clean up temp upload ${tempPath}:`, unlinkErr); + } + } + } // Generate thumbnail const thumbnailPath = await generateThumbnail(newPath); @@ -78,7 +117,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ path: relativePath, thumbnail_path: relativeThumbPath, type: photoType, - size_bytes: file.size + size_bytes: file.size, + uploaded_by: uploadedBy, + source_origin: 'managed' }) .returning('id'); } else { @@ -88,7 +129,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ path: relativePath, thumbnail_path: relativeThumbPath, type: photoType, - size_bytes: file.size + size_bytes: file.size, + uploaded_by: uploadedBy, + source_origin: 'managed' }); } diff --git a/frontend/src/components/admin/GalleryPreview.tsx b/frontend/src/components/admin/GalleryPreview.tsx index 9cc1e67..e89b7f8 100644 --- a/frontend/src/components/admin/GalleryPreview.tsx +++ b/frontend/src/components/admin/GalleryPreview.tsx @@ -1,9 +1,19 @@ import React, { useMemo } from 'react'; import { Camera } from 'lucide-react'; import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types'; +import { buildResourceUrl } from '../../utils/url'; + +interface GalleryPreviewBranding { + company_name?: string; + company_tagline?: string; + logo_url?: string; + logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text'; + logo_position?: 'left' | 'center' | 'right'; +} interface GalleryPreviewProps { theme: ThemeConfig; + branding?: GalleryPreviewBranding; layoutType?: GalleryLayoutType; className?: string; } @@ -56,6 +66,7 @@ const PreviewPhoto: React.FC<{ export const GalleryPreview: React.FC = ({ theme, + branding, layoutType, className = '' }) => { @@ -63,6 +74,23 @@ export const GalleryPreview: React.FC = ({ // Use the provided layoutType or fallback to theme's gallery layout const activeLayout = layoutType || theme.galleryLayout || 'grid'; + + const displayMode = branding?.logo_display_mode || 'logo_and_text'; + const showLogo = displayMode === 'logo_only' || displayMode === 'logo_and_text'; + const showText = displayMode === 'text_only' || displayMode === 'logo_and_text'; + const brandName = branding?.company_name?.trim() || 'Your Studio'; + const brandTagline = branding?.company_tagline?.trim() || ''; + const resolvedLogoUrl = showLogo && branding?.logo_url + ? (branding.logo_url.startsWith('http') + ? branding.logo_url + : buildResourceUrl(branding.logo_url)) + : null; + const logoPosition = branding?.logo_position || 'left'; + const brandFlexClass = logoPosition === 'center' + ? 'justify-center text-center' + : logoPosition === 'right' + ? 'justify-end text-right flex-row-reverse' + : 'justify-start text-left'; const renderLayout = () => { const spacing = theme.gallerySettings?.spacing || 'normal'; @@ -163,14 +191,41 @@ export const GalleryPreview: React.FC = ({ > {/* Preview Header */}
-

- Gallery Preview - {activeLayout} Layout -

+
+ {showLogo && ( + resolvedLogoUrl ? ( + {brandName} + ) : ( +
+ +
+ ) + )} + {showText && ( +
+

{brandName}

+ {brandTagline && ( +

{brandTagline}

+ )} +
+ )} + {!showLogo && !showText && ( +

{brandName}

+ )} +
+
+ Gallery preview + {activeLayout} layout +
{/* Preview Content */} @@ -181,4 +236,4 @@ export const GalleryPreview: React.FC = ({ ); }; -GalleryPreview.displayName = 'GalleryPreview'; \ No newline at end of file +GalleryPreview.displayName = 'GalleryPreview'; diff --git a/frontend/src/components/gallery/GalleryFilter.tsx b/frontend/src/components/gallery/GalleryFilter.tsx index c539563..d27e7ac 100644 --- a/frontend/src/components/gallery/GalleryFilter.tsx +++ b/frontend/src/components/gallery/GalleryFilter.tsx @@ -1,15 +1,16 @@ import React from 'react'; -import { Heart, Star, MessageSquare } from 'lucide-react'; +import { Heart, Star, MessageSquare, Bookmark } from 'lucide-react'; import { Button } from '../common'; import { useTranslation } from 'react-i18next'; -export type FilterType = 'all' | 'liked' | 'rated' | 'commented'; +export type FilterType = 'all' | 'liked' | 'favorited' | 'rated' | 'commented'; interface GalleryFilterProps { currentFilter: FilterType; onFilterChange: (filter: FilterType) => void; feedbackEnabled: boolean; likeCount?: number; + favoriteCount?: number; ratedCount?: number; className?: string; isMobile?: boolean; @@ -21,6 +22,7 @@ export const GalleryFilter: React.FC = ({ onFilterChange, feedbackEnabled, likeCount = 0, + favoriteCount = 0, ratedCount = 0, className = '', isMobile = false, @@ -59,6 +61,15 @@ export const GalleryFilter: React.FC = ({ > + + + + + + + {event.share_link ? ( { + if (value === undefined || value === null) { + return defaultValue; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (Number.isNaN(value)) return defaultValue; + return value !== 0; + } + if (typeof value === 'string') { + const normalized = value.toLowerCase().trim(); + if (normalized === 'true' || normalized === '1') return true; + if (normalized === 'false' || normalized === '0') return false; + if (normalized === '') return defaultValue; + return Boolean(normalized); + } + return defaultValue; +}; + +const toNumber = (value: unknown, defaultValue: number): number => { + if (value === undefined || value === null || value === '') { + return defaultValue; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : defaultValue; +}; + export const SettingsPage: React.FC = () => { const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general'); const queryClient = useQueryClient(); @@ -100,13 +129,13 @@ export const SettingsPage: React.FC = () => { // Extract general settings setGeneralSettings({ site_url: settings.general_site_url || '', - default_expiration_days: settings.general_default_expiration_days || 30, - max_file_size_mb: settings.general_max_file_size_mb || 50, + default_expiration_days: toNumber(settings.general_default_expiration_days, 30), + max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50), allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp', - enable_watermark: settings.general_enable_watermark || false, - enable_analytics: settings.general_enable_analytics || true, - enable_registration: settings.general_enable_registration || false, - maintenance_mode: settings.general_maintenance_mode || false, + enable_watermark: toBoolean(settings.general_enable_watermark, false), + enable_analytics: toBoolean(settings.general_enable_analytics, true), + enable_registration: toBoolean(settings.general_enable_registration, false), + maintenance_mode: toBoolean(settings.general_maintenance_mode, false), default_language: settings.general_default_language || 'en', date_format: settings.general_date_format ? (typeof settings.general_date_format === 'string' @@ -117,20 +146,20 @@ export const SettingsPage: React.FC = () => { // Extract security settings setSecuritySettings({ - require_password: settings.security_require_password ?? true, - password_min_length: settings.security_password_min_length ?? 8, + require_password: toBoolean(settings.security_require_password, true), + password_min_length: toNumber(settings.security_password_min_length, 8), password_complexity: settings.security_password_complexity ?? 'moderate', - enable_2fa: settings.security_enable_2fa ?? false, - session_timeout_minutes: settings.security_session_timeout_minutes ?? 60, - max_login_attempts: settings.security_max_login_attempts ?? 5, - enable_recaptcha: settings.security_enable_recaptcha ?? false, + enable_2fa: toBoolean(settings.security_enable_2fa, false), + session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60), + max_login_attempts: toNumber(settings.security_max_login_attempts, 5), + enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false), recaptcha_site_key: settings.security_recaptcha_site_key ?? '', recaptcha_secret_key: settings.security_recaptcha_secret_key ?? '' }); // Extract analytics settings setAnalyticsSettings({ - umami_enabled: settings.analytics_umami_enabled || false, + umami_enabled: toBoolean(settings.analytics_umami_enabled, false), umami_url: settings.analytics_umami_url || '', umami_website_id: settings.analytics_umami_website_id || '', umami_share_url: settings.analytics_umami_share_url || '' diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 41cb8d1..6426cc9 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -28,6 +28,8 @@ interface UpdateEventData { allow_user_uploads?: boolean; upload_category_id?: number | null; hero_photo_id?: number | null; + source_mode?: 'managed' | 'reference'; + external_path?: string | null; } interface EventsListResponse { @@ -124,4 +126,4 @@ export const eventsService = { const response = await api.post(`/admin/events/${eventId}/resend-email`); return response.data; }, -}; \ No newline at end of file +}; diff --git a/frontend/src/services/gallery.service.ts b/frontend/src/services/gallery.service.ts index 658eac6..296282f 100644 --- a/frontend/src/services/gallery.service.ts +++ b/frontend/src/services/gallery.service.ts @@ -18,13 +18,15 @@ export const galleryService = { // Get gallery photos (requires auth) async getGalleryPhotos( slug: string, - filter?: 'liked' | 'commented' | 'rated' | 'all', + filter?: 'liked' | 'favorited' | 'commented' | 'rated' | 'all', guestId?: string ): Promise { const params: any = {}; - if (filter && filter !== 'all' && guestId) { + if (filter && filter !== 'all') { params.filter = filter; - params.guest_id = guestId; + if (guestId) { + params.guest_id = guestId; + } } const response = await api.get(`/gallery/${slug}/photos`, { params }); return response.data;