+35
-3
@@ -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'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:', {
|
||||
|
||||
@@ -7,8 +7,31 @@ 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();
|
||||
@@ -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 {
|
||||
@@ -36,7 +59,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
.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'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<GalleryPreviewProps> = ({
|
||||
theme,
|
||||
branding,
|
||||
layoutType,
|
||||
className = ''
|
||||
}) => {
|
||||
@@ -64,6 +75,23 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
// 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';
|
||||
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
|
||||
@@ -163,14 +191,41 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
>
|
||||
{/* Preview Header */}
|
||||
<div
|
||||
className="px-4 py-3 border-b"
|
||||
className="px-4 py-3 border-b space-y-2"
|
||||
style={{
|
||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-medium">
|
||||
Gallery Preview - <span className="capitalize">{activeLayout}</span> Layout
|
||||
</h3>
|
||||
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
|
||||
{showLogo && (
|
||||
resolvedLogoUrl ? (
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={brandName}
|
||||
className="h-8 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
|
||||
<Camera className="w-4 h-4 text-neutral-500" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{showText && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{brandName}</p>
|
||||
{brandTagline && (
|
||||
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!showLogo && !showText && (
|
||||
<p className="text-sm font-semibold">{brandName}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 flex justify-between">
|
||||
<span>Gallery preview</span>
|
||||
<span className="capitalize">{activeLayout} layout</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
|
||||
@@ -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<GalleryFilterProps> = ({
|
||||
onFilterChange,
|
||||
feedbackEnabled,
|
||||
likeCount = 0,
|
||||
favoriteCount = 0,
|
||||
ratedCount = 0,
|
||||
className = '',
|
||||
isMobile = false,
|
||||
@@ -59,6 +61,15 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
||||
>
|
||||
<Heart className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onFilterChange('favorited')}
|
||||
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||
aria-label={t('gallery.favorited', 'Saved')}
|
||||
>
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
@@ -111,6 +122,16 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
||||
<span>{likeCount > 0 ? likeCount : t('gallery.liked', 'Liked')}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onFilterChange('favorited')}
|
||||
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
|
||||
>
|
||||
<Bookmark className="w-3 h-3" />
|
||||
<span>{favoriteCount > 0 ? favoriteCount : t('gallery.favorited', 'Saved')}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
@@ -153,6 +174,21 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onFilterChange('favorited')}
|
||||
className="text-xs sm:text-sm flex items-center gap-1"
|
||||
>
|
||||
<Bookmark className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">{t('gallery.favorited', 'Saved')}</span>
|
||||
{favoriteCount > 0 && (
|
||||
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
|
||||
{favoriteCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
|
||||
@@ -32,6 +32,7 @@ interface GallerySidebarProps {
|
||||
filterType?: FilterType;
|
||||
onFilterChange?: (filter: FilterType) => void;
|
||||
likeCount?: number;
|
||||
favoriteCount?: number;
|
||||
ratedCount?: number;
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
filterType = 'all',
|
||||
onFilterChange,
|
||||
likeCount = 0,
|
||||
favoriteCount = 0,
|
||||
ratedCount = 0
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -221,6 +223,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
}}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
likeCount={likeCount}
|
||||
favoriteCount={favoriteCount}
|
||||
ratedCount={ratedCount}
|
||||
className="w-full"
|
||||
variant="compact"
|
||||
|
||||
@@ -270,6 +270,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
case 'liked':
|
||||
photos = photos.filter(photo => (photo.like_count || 0) > 0);
|
||||
break;
|
||||
case 'favorited':
|
||||
photos = photos.filter(photo => (photo.favorite_count || 0) > 0);
|
||||
break;
|
||||
case 'rated':
|
||||
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
|
||||
break;
|
||||
@@ -314,6 +317,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
return photos;
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]);
|
||||
|
||||
const likeCount = useMemo(
|
||||
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
|
||||
[data?.photos]
|
||||
);
|
||||
|
||||
const favoriteCount = useMemo(
|
||||
() => data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0,
|
||||
[data?.photos]
|
||||
);
|
||||
|
||||
const ratedCount = useMemo(
|
||||
() => data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0,
|
||||
[data?.photos]
|
||||
);
|
||||
|
||||
// Check if downloads are allowed (both event setting and not expired)
|
||||
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
||||
|
||||
@@ -471,8 +489,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
filterType={filterType}
|
||||
onFilterChange={setFilterType}
|
||||
likeCount={data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0}
|
||||
ratedCount={data?.photos?.filter(p => (p.total_ratings || 0) > 0).length || 0}
|
||||
likeCount={likeCount}
|
||||
favoriteCount={favoriteCount}
|
||||
ratedCount={ratedCount}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Search, SortAsc, Grid, Heart, Star, MessageSquare } from 'lucide-react';
|
||||
import { Search, SortAsc, Grid, Heart, Star, MessageSquare, Bookmark } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../common';
|
||||
import type { FilterType } from './GalleryFilter';
|
||||
@@ -50,7 +50,6 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search and Sort */}
|
||||
@@ -195,6 +194,15 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
>
|
||||
<Heart className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onFilterChange('favorited')}
|
||||
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||
aria-label={t('gallery.favorited', 'Saved')}
|
||||
>
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
@@ -248,6 +256,15 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
>
|
||||
<Heart className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onFilterChange('favorited')}
|
||||
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||
aria-label={t('gallery.favorited', 'Saved')}
|
||||
>
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useGalleryInfo = (slug: string, token?: string) => {
|
||||
|
||||
export const useGalleryPhotos = (
|
||||
slug: string,
|
||||
filter?: 'liked' | 'commented' | 'rated' | 'all',
|
||||
filter?: 'liked' | 'favorited' | 'commented' | 'rated' | 'all',
|
||||
guestId?: string,
|
||||
enabled: boolean = true
|
||||
) => {
|
||||
|
||||
@@ -479,6 +479,12 @@
|
||||
"sortByName": "Nach Name sortieren",
|
||||
"sortBySize": "Nach Größe sortieren",
|
||||
"allPhotos": "Alle Fotos",
|
||||
"feedbackFilter": "Feedback-Filter",
|
||||
"all": "Alle",
|
||||
"liked": "Gefallen",
|
||||
"favorited": "Favorisiert",
|
||||
"rated": "Bewertet",
|
||||
"commented": "Kommentiert",
|
||||
"shareGallery": "Galerie teilen",
|
||||
"needHelp": "Hilfe benötigt? Kontaktieren Sie uns unter",
|
||||
"noPhotosFound": "Keine Fotos gefunden",
|
||||
@@ -588,6 +594,13 @@
|
||||
"photos": "Fotos",
|
||||
"categories": "Kategorien",
|
||||
"eventInformation": "Veranstaltungsinformationen",
|
||||
"sourceMode": "Quellenmodus",
|
||||
"sourceModeManaged": "Verwaltet (Upload nach PicPeak)",
|
||||
"sourceModeReference": "Externen Ordner referenzieren",
|
||||
"sourceModeHelp": "Nutzen Sie den verwalteten Modus für direkte Uploads oder verweisen Sie auf einen gemounteten /external-media Ordner.",
|
||||
"externalFolder": "Externer Ordner",
|
||||
"externalFolderHint": "Diese Ordner stammen aus dem /external-media Mount innerhalb des Containers oder Hosts.",
|
||||
"externalFolderRequired": "Bitte wählen Sie vor dem Speichern einen externen Ordner aus.",
|
||||
"welcomeMessage": "Willkommensnachricht",
|
||||
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
|
||||
"noWelcomeMessageSet": "Keine Willkommensnachricht festgelegt",
|
||||
|
||||
@@ -278,6 +278,13 @@
|
||||
"photos": "Photos",
|
||||
"categories": "Categories",
|
||||
"eventInformation": "Event Information",
|
||||
"sourceMode": "Source Mode",
|
||||
"sourceModeManaged": "Managed (upload to PicPeak)",
|
||||
"sourceModeReference": "Reference external folder",
|
||||
"sourceModeHelp": "Use managed mode for direct uploads or point to a mounted /external-media folder when using local storage.",
|
||||
"externalFolder": "External Folder",
|
||||
"externalFolderHint": "These folders are read from the /external-media mount inside your container or host.",
|
||||
"externalFolderRequired": "Please select an external folder before saving.",
|
||||
"welcomeMessage": "Welcome Message",
|
||||
"noWelcomeMessage": "No welcome message set",
|
||||
"created": "Created",
|
||||
|
||||
@@ -150,7 +150,13 @@ export const BrandingPage: React.FC = () => {
|
||||
try {
|
||||
const logoUrl = await settingsService.uploadLogo(file);
|
||||
setBrandingSettings(prev => ({ ...prev, logo_url: logoUrl }));
|
||||
setCurrentTheme(prev => ({ ...prev, logoUrl }));
|
||||
setCurrentTheme(prev => {
|
||||
const updated = { ...prev, logoUrl };
|
||||
if (isPreviewMode) {
|
||||
setTheme(updated);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
toast.success(t('toast.uploadSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Failed to upload logo:', error);
|
||||
@@ -159,6 +165,17 @@ export const BrandingPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLogo = () => {
|
||||
setBrandingSettings(prev => ({ ...prev, logo_url: '' }));
|
||||
setCurrentTheme(prev => {
|
||||
const updated = { ...prev, logoUrl: '' };
|
||||
if (isPreviewMode) {
|
||||
setTheme(updated);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
@@ -351,7 +368,7 @@ export const BrandingPage: React.FC = () => {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleBrandingChange('logo_url', '')}
|
||||
onClick={handleRemoveLogo}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
×
|
||||
@@ -686,6 +703,7 @@ export const BrandingPage: React.FC = () => {
|
||||
</h3>
|
||||
<GalleryPreview
|
||||
theme={currentTheme}
|
||||
branding={brandingSettings}
|
||||
className="shadow-lg"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -33,6 +33,13 @@ import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } fro
|
||||
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
|
||||
const resolveShareLink = (link: string): string => {
|
||||
if (!link) return '#';
|
||||
if (link.startsWith('http')) return link;
|
||||
if (link.startsWith('/')) return link;
|
||||
return `/gallery/${link}`;
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -104,15 +111,29 @@ export const EventDetailsPage: React.FC = () => {
|
||||
}
|
||||
}, [id, navigate]);
|
||||
|
||||
type EditFormState = {
|
||||
welcome_message: string;
|
||||
color_theme: string;
|
||||
expires_at: string;
|
||||
allow_user_uploads: boolean;
|
||||
upload_category_id: number | null;
|
||||
hero_photo_id: number | null;
|
||||
host_name: string;
|
||||
source_mode: 'managed' | 'reference';
|
||||
external_path: string;
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
const [editForm, setEditForm] = useState<EditFormState>({
|
||||
welcome_message: '',
|
||||
color_theme: '',
|
||||
expires_at: '',
|
||||
allow_user_uploads: false,
|
||||
upload_category_id: null as number | null,
|
||||
hero_photo_id: null as number | null,
|
||||
upload_category_id: null,
|
||||
hero_photo_id: null,
|
||||
host_name: '',
|
||||
source_mode: 'managed',
|
||||
external_path: '',
|
||||
});
|
||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||
feedback_enabled: false,
|
||||
@@ -251,6 +272,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
upload_category_id: event.upload_category_id || null,
|
||||
hero_photo_id: event.hero_photo_id || null,
|
||||
host_name: event.host_name || '',
|
||||
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||
external_path: event.external_path || '',
|
||||
});
|
||||
|
||||
// Set feedback settings if available
|
||||
@@ -299,6 +322,13 @@ export const EventDetailsPage: React.FC = () => {
|
||||
themeToSave = currentPresetName;
|
||||
}
|
||||
|
||||
const externalPathToSave = editForm.external_path?.trim() || '';
|
||||
|
||||
if (editForm.source_mode === 'reference' && !externalPathToSave) {
|
||||
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up the data - remove undefined values
|
||||
const updateData: any = {
|
||||
expires_at: editForm.expires_at,
|
||||
@@ -318,6 +348,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
if (editForm.hero_photo_id !== undefined) {
|
||||
updateData.hero_photo_id = editForm.hero_photo_id;
|
||||
}
|
||||
updateData.source_mode = editForm.source_mode;
|
||||
updateData.external_path = editForm.source_mode === 'reference'
|
||||
? externalPathToSave
|
||||
: null;
|
||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
||||
updateData.host_name = editForm.host_name;
|
||||
}
|
||||
@@ -461,11 +495,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
)}
|
||||
{event.share_link && !isEditing && (
|
||||
<a
|
||||
href={
|
||||
event.share_link.startsWith('http')
|
||||
? event.share_link
|
||||
: `/gallery/${event.share_link}`
|
||||
}
|
||||
href={resolveShareLink(event.share_link)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
|
||||
@@ -611,6 +641,47 @@ export const EventDetailsPage: React.FC = () => {
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.sourceMode', 'Source Mode')}
|
||||
</label>
|
||||
<select
|
||||
value={editForm.source_mode}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as 'managed' | 'reference';
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
source_mode: mode,
|
||||
external_path: mode === 'reference'
|
||||
? (prev.external_path || event.external_path || '')
|
||||
: ''
|
||||
}));
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="managed">{t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}</option>
|
||||
<option value="reference">{t('events.sourceModeReference', 'Reference external folder')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('events.sourceModeHelp', 'Use managed mode for direct uploads or reference an external folder that is mounted at /external-media in Docker.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{editForm.source_mode === 'reference' && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('events.externalFolder', 'External Folder')}
|
||||
</label>
|
||||
<ExternalFolderPicker
|
||||
value={editForm.external_path || ''}
|
||||
onChange={(folder) => setEditForm(prev => ({ ...prev, external_path: folder }))}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('events.externalFolderHint', 'These folders come from the /external-media mount inside the container. Ensure it is accessible to the backend process.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
|
||||
@@ -25,6 +25,13 @@ import { eventsService } from '../../services/events.service';
|
||||
import type { Event } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const resolveShareLink = (link: string): string => {
|
||||
if (!link) return '#';
|
||||
if (link.startsWith('http')) return link;
|
||||
if (link.startsWith('/')) return link;
|
||||
return `/gallery/${link}`;
|
||||
};
|
||||
|
||||
export const EventsListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
@@ -478,11 +485,7 @@ export const EventsListPage: React.FC = () => {
|
||||
</button>
|
||||
{event.share_link ? (
|
||||
<a
|
||||
href={
|
||||
event.share_link.startsWith('http')
|
||||
? event.share_link
|
||||
: `/gallery/${event.share_link}`
|
||||
}
|
||||
href={resolveShareLink(event.share_link)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||
|
||||
@@ -23,6 +23,35 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||
|
||||
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
||||
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 || ''
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,14 +18,16 @@ 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<GalleryData> {
|
||||
const params: any = {};
|
||||
if (filter && filter !== 'all' && guestId) {
|
||||
if (filter && filter !== 'all') {
|
||||
params.filter = filter;
|
||||
if (guestId) {
|
||||
params.guest_id = guestId;
|
||||
}
|
||||
}
|
||||
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user