Compare commits

...

11 Commits

Author SHA1 Message Date
Gitea Actions Bot cab5b0d795 chore: bump backend version to 1.1.2 2025-09-22 17:08:29 +00:00
paul ba95aad3c6 Switch backend image to Node 20 to address cross-spawn CVE
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m6s
2025-09-22 19:03:33 +02:00
paul c1be7d6785 Harden photo resolver path handling
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Has been cancelled
2025-09-22 18:59:49 +02:00
paul 0024686dc2 Align simple setup storage paths (#27)
Test and Lint / backend-test (push) Successful in 1m46s
Test and Lint / frontend-test (push) Successful in 2m12s
2025-09-22 18:54:13 +02:00
paul 96b8b77792 Fix release workflow when tag already exists
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m10s
2025-09-22 14:46:45 +02:00
Gitea Actions Bot 9d2726b3d3 chore: bump frontend version to 1.1.1 2025-09-22 12:41:16 +00:00
paul 8d6ddd257d Fix gallery login persistence and favorites (#29)
Test and Lint / backend-test (push) Successful in 2m6s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-22 14:33:11 +02:00
Gitea Actions Bot e0865b81b6 chore: bump backend version to 1.1.1 2025-09-21 20:47:47 +00:00
paul d4404e39bd fix: prefer admin token on admin routes (#23 #28)
Test and Lint / backend-test (push) Successful in 2m9s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-21 22:37:30 +02:00
paul 8611206396 Fix PicPeak regressions and close #22 #24 #25 #26 #27 #28 2025-09-21 22:03:07 +02:00
paul 39d2244e1e chore: switch versioning workflows to manual triggers 2025-09-19 22:31:55 +02:00
34 changed files with 788 additions and 108 deletions
+3 -5
View File
@@ -1,10 +1,7 @@
name: Mirror to GitHub
on:
push:
branches:
- main
workflow_dispatch: # Allow manual triggering
workflow_dispatch: # Allow manual triggering only
jobs:
mirror:
@@ -131,4 +128,5 @@ jobs:
run: |
echo "✅ Mirror to GitHub workflow completed successfully!"
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
echo "🔒 Sensitive files have been removed from the mirror"
echo "🔒 Sensitive files have been removed from the mirror"
+12 -10
View File
@@ -1,12 +1,7 @@
name: Version and Release
on:
push:
branches: [ main ]
paths-ignore:
- '**.md'
- '.gitea/**'
- '.drone.yml'
workflow_dispatch:
jobs:
version-bump:
@@ -72,8 +67,8 @@ jobs:
echo "Root files changed: $ROOT_CHANGED"
# Get current versions
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.1.0")
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.1.0")
echo "Current backend version: $BACKEND_VERSION"
echo "Current frontend version: $FRONTEND_VERSION"
@@ -133,10 +128,17 @@ jobs:
MINOR="${version_parts[1]}"
PATCH="${version_parts[2]}"
# Increment patch version
# Increment patch version and ensure tag uniqueness
git fetch --tags --quiet || true
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
NEW_PATCH=$((NEW_PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
done
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
@@ -264,4 +266,4 @@ jobs:
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
echo "Drone will automatically trigger on the new tag"
# Drone CI will automatically trigger on the tag push event
# Drone CI will automatically trigger on the tag push event
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:18-alpine AS builder
FROM node:20-alpine AS builder
# Add build arguments
ARG CACHEBUST=1
@@ -23,7 +23,7 @@ RUN npm ci --only=production
COPY . .
# Production stage
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
+35 -3
View File
@@ -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'
+4 -2
View File
@@ -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 };
module.exports = { runMigrations };
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.130",
"version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.130",
"version": "1.1.2",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.130",
"version": "1.1.2",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+25 -5
View File
@@ -75,17 +75,37 @@ if (enableHsts) {
app.use(cookieParser());
app.use((req, res, next) => {
if (!req.headers.authorization) {
const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
const galleryToken = getGalleryTokenFromRequest(req, slug);
const adminToken = getAdminTokenFromRequest(req);
if (req.headers.authorization) {
return next();
}
const path = req.path || '';
const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
const adminToken = getAdminTokenFromRequest(req);
const galleryToken = getGalleryTokenFromRequest(req, slug);
const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin');
const isGalleryRequest = Boolean(slugMatch)
|| path.startsWith('/api/gallery')
|| path.startsWith('/gallery')
|| path.startsWith('/api/secure-images');
// Prefer admin credentials on admin routes so gallery sessions cannot override them.
if (isAdminRequest) {
if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
}
} else if (isGalleryRequest) {
if (galleryToken) {
req.headers.authorization = `Bearer ${galleryToken}`;
} else if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
}
} else if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
} else if (galleryToken) {
req.headers.authorization = `Bearer ${galleryToken}`;
}
next();
+21 -2
View File
@@ -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:', {
+51 -8
View File
@@ -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'
});
}
+8 -2
View File
@@ -1,5 +1,6 @@
const path = require('path');
const { resolveExternalPath } = require('./externalMediaService');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -33,10 +34,15 @@ function resolvePhotoFilePath(event, photo) {
}
const storagePath = getStoragePath();
const eventsRoot = path.join(storagePath, 'events/active');
if (photo.path && photo.path.startsWith('events/active/')) {
return path.join(storagePath, photo.path);
// Legacy paths already include prefix; normalize via safe join
return safePathJoin(storagePath, photo.path.replace(/^events\/active\/?/, 'events/active/'));
}
return path.join(storagePath, 'events/active', photo.path || '');
const relativeSegment = photo.path ? photo.path.replace(/^\/+/, '') : '';
return safePathJoin(eventsRoot, relativeSegment);
}
module.exports = {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.130",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.130",
"version": "1.1.1",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.130",
"version": "1.1.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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 = ''
}) => {
@@ -63,6 +74,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';
@@ -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 */}
@@ -181,4 +236,4 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
);
};
GalleryPreview.displayName = 'GalleryPreview';
GalleryPreview.displayName = 'GalleryPreview';
@@ -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"
@@ -110,6 +121,16 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Heart className="w-3 h-3" />
<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'}
@@ -152,6 +173,21 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</span>
)}
</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'}
@@ -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}
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { PhotoRating } from './PhotoRating';
import { PhotoLikes } from './PhotoLikes';
import { PhotoFavorites } from './PhotoFavorites';
import { PhotoComments } from './PhotoComments';
import { Skeleton } from '../common';
@@ -42,13 +43,17 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
const [currentRating, setCurrentRating] = useState(0);
const [isLiked, setIsLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(0);
// Update local state when data loads
useEffect(() => {
if (feedbackData) {
setCurrentRating(feedbackData.my_feedback.rating || 0);
setIsLiked(feedbackData.my_feedback.liked);
setLikeCount(feedbackData.summary.like_count);
setIsLiked(Boolean(feedbackData.my_feedback.liked));
setLikeCount(Number(feedbackData.summary.like_count) || 0);
setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
}
}, [feedbackData]);
@@ -64,6 +69,12 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
if (onFeedbackUpdate) onFeedbackUpdate();
};
const handleFavoriteChange = (favorited: boolean) => {
setIsFavorited(favorited);
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
if (onFeedbackUpdate) onFeedbackUpdate();
};
if (settingsLoading) {
return (
<div className={`space-y-3 ${className}`}>
@@ -77,8 +88,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
return null;
}
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
settings.allow_comments;
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
settings.allow_comments || settings.allow_favorites;
if (!hasAnyFeedbackType) {
return null;
@@ -101,8 +112,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
)}
{/* Action Buttons */}
{settings.allow_likes && (
<div className="flex items-center gap-2">
{(settings.allow_likes || settings.allow_favorites) && (
<div className="flex flex-wrap items-center gap-2">
{settings.allow_likes && (
<PhotoLikes
photoId={photoId}
@@ -114,6 +125,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
onLikeChange={handleLikeChange}
/>
)}
{settings.allow_favorites && (
<PhotoFavorites
photoId={photoId}
gallerySlug={gallerySlug}
isFavorited={isFavorited}
favoriteCount={favoriteCount}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
onFavoriteChange={handleFavoriteChange}
/>
)}
</div>
)}
@@ -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"
+61 -1
View File
@@ -1,4 +1,10 @@
import axios from 'axios';
import axios, { AxiosHeaders } from 'axios';
import {
getActiveGallerySlug,
getGalleryToken,
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../utils/galleryAuthStorage';
// Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
@@ -23,6 +29,60 @@ api.interceptors.request.use(
delete config.headers?.['Content-Type'];
}
if (typeof window !== 'undefined') {
const pathSlug = resolveSlugFromRequestUrl(config.url || '');
const params = config.params as Record<string, unknown> | undefined;
const paramSlug = typeof params?.slug === 'string' ? (params.slug as string) : null;
const rawPath = (() => {
if (!config.url) return '';
try {
if (config.url.startsWith('http://') || config.url.startsWith('https://')) {
return new URL(config.url).pathname;
}
} catch (error) {
return config.url;
}
return config.url;
})();
const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
const isGalleryEndpoint = /^\/gallery\//.test(pathname)
|| /^\/secure-images\//.test(pathname)
|| /^\/auth\/gallery\//.test(pathname);
const isGallerySessionCheck = pathname === '/auth/session'
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
if (isGalleryEndpoint || isGallerySessionCheck) {
const fallbackSlug = getActiveGallerySlug()
|| inferGallerySlugFromLocation();
const slug = pathSlug || paramSlug || fallbackSlug;
if (slug) {
const token = getGalleryToken(slug);
if (token) {
if (!config.headers) {
config.headers = new AxiosHeaders();
}
if (config.headers instanceof AxiosHeaders) {
const existing = config.headers.get('Authorization');
if (!existing) {
config.headers.set('Authorization', `Bearer ${token}`);
}
} else {
const headersRecord = config.headers as Record<string, string | undefined>;
if (!headersRecord.Authorization) {
headersRecord.Authorization = `Bearer ${token}`;
}
}
}
}
}
}
return config;
},
(error) => {
+32 -3
View File
@@ -3,6 +3,12 @@ import type { ReactNode } from 'react';
import { api } from '../config/api';
import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
import {
clearActiveGallerySlug,
clearGalleryToken,
setActiveGallerySlug,
storeGalleryToken,
} from '../utils/galleryAuthStorage';
interface GalleryEvent {
id: number;
@@ -55,6 +61,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
useEffect(() => {
cleanupOldGalleryAuth();
const slugAtMount = getCurrentGallerySlug();
if (slugAtMount) {
setActiveGallerySlug(slugAtMount);
} else {
clearActiveGallerySlug();
}
const initialise = async () => {
const currentSlug = getCurrentGallerySlug();
@@ -63,6 +76,8 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
return;
}
setActiveGallerySlug(currentSlug);
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
if (storedEvent) {
try {
@@ -109,6 +124,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setEvent(response.event);
setIsAuthenticated(true);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
if (response.token) {
storeGalleryToken(currentSlug, response.token);
}
setActiveGallerySlug(currentSlug);
return;
}
}
@@ -118,16 +137,21 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
clearGalleryToken(currentSlug);
} catch (error) {
setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null);
clearGalleryToken(currentSlug);
} finally {
setIsLoading(false);
}
};
initialise();
return () => {
clearActiveGallerySlug();
};
}, []);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
@@ -137,7 +161,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event);
setIsAuthenticated(true);
if (response.token) {
storeGalleryToken(slug, response.token);
}
setActiveGallerySlug(slug);
// Store event data for quick reloads (non-sensitive)
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
} catch (err: any) {
@@ -152,12 +180,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const currentSlug = getCurrentGallerySlug();
if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
clearGalleryToken(currentSlug);
}
authService.galleryLogout(currentSlug || undefined);
setIsAuthenticated(false);
setEvent(null);
}
;
clearActiveGallerySlug();
};
return (
<GalleryAuthContext.Provider
+1 -1
View File
@@ -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
) => {
+15 -2
View File
@@ -478,8 +478,14 @@
"sortByDate": "Nach Datum sortieren",
"sortByName": "Nach Name sortieren",
"sortBySize": "Nach Größe sortieren",
"allPhotos": "Alle Fotos",
"shareGallery": "Galerie teilen",
"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",
"failedToLoad": "Fotos konnten nicht geladen werden",
@@ -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",
+7
View File
@@ -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",
+22 -4
View File
@@ -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"
>
×
@@ -685,7 +702,8 @@ export const BrandingPage: React.FC = () => {
{t('branding.livePreview')}
</h3>
<GalleryPreview
theme={currentTheme}
theme={currentTheme}
branding={brandingSettings}
className="shadow-lg"
/>
</Card>
@@ -708,4 +726,4 @@ export const BrandingPage: React.FC = () => {
</div>
</ErrorBoundary>
);
};
};
@@ -225,7 +225,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
if (!validateForm()) {
return;
}
const feedbackSettings = formData.feedback_settings;
const payload = {
event_type: formData.event_type,
event_name: formData.event_name,
@@ -239,9 +241,16 @@ export const CreateEventPageEnhanced: React.FC = () => {
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
feedback_settings: formData.feedback_settings,
feedback_enabled: feedbackSettings.feedback_enabled,
allow_ratings: feedbackSettings.allow_ratings,
allow_likes: feedbackSettings.allow_likes,
allow_comments: feedbackSettings.allow_comments,
allow_favorites: feedbackSettings.allow_favorites,
require_name_email: feedbackSettings.require_name_email,
moderate_comments: feedbackSettings.moderate_comments,
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
};
createMutation.mutate(payload);
};
+79 -8
View File
@@ -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
@@ -298,6 +321,13 @@ export const EventDetailsPage: React.FC = () => {
// Use preset name for non-custom themes
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 = {
@@ -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"
@@ -610,6 +640,47 @@ export const EventDetailsPage: React.FC = () => {
onSelect={(photoId) => setEditForm(prev => ({ ...prev, hero_photo_id: photoId }))}
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">
+8 -5
View File
@@ -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"
+42 -13
View File
@@ -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 || ''
+11 -1
View File
@@ -13,6 +13,14 @@ interface CreateEventData {
expiration_days: number;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
feedback_enabled?: boolean;
allow_ratings?: boolean;
allow_likes?: boolean;
allow_comments?: boolean;
allow_favorites?: boolean;
require_name_email?: boolean;
moderate_comments?: boolean;
show_feedback_to_guests?: boolean;
}
interface UpdateEventData {
@@ -28,6 +36,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 +134,4 @@ export const eventsService = {
const response = await api.post(`/admin/events/${eventId}/resend-email`);
return response.data;
},
};
};
+5 -3
View File
@@ -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<GalleryData> {
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<GalleryData>(`/gallery/${slug}/photos`, { params });
return response.data;
+1
View File
@@ -23,4 +23,5 @@ export const cleanupOldGalleryAuth = () => {
// Also clear session storage
sessionStorage.removeItem('gallery_event');
sessionStorage.removeItem('gallery_token');
sessionStorage.removeItem('gallery_active_slug');
};
+110
View File
@@ -0,0 +1,110 @@
const TOKEN_STORAGE_PREFIX = 'gallery_token_';
const ACTIVE_SLUG_KEY = 'gallery_active_slug';
const isBrowser = typeof window !== 'undefined';
const getSessionStorage = (): Storage | null => {
if (!isBrowser) return null;
try {
return window.sessionStorage;
} catch (error) {
console.warn('Session storage unavailable', error);
return null;
}
};
const extractSlugFromPath = (path: string): string | null => {
if (!path) return null;
const match = path.match(/\/gallery\/([^\/?#]+)/);
return match ? decodeURIComponent(match[1]) : null;
};
export const inferGallerySlugFromLocation = (): string | null => {
if (!isBrowser) return null;
return extractSlugFromPath(window.location.pathname);
};
export const setActiveGallerySlug = (slug: string | null) => {
const storage = getSessionStorage();
if (!storage) return;
if (slug) {
storage.setItem(ACTIVE_SLUG_KEY, slug);
} else {
storage.removeItem(ACTIVE_SLUG_KEY);
}
};
export const getActiveGallerySlug = (): string | null => {
const storage = getSessionStorage();
if (!storage) return null;
return storage.getItem(ACTIVE_SLUG_KEY);
};
export const clearActiveGallerySlug = () => {
const storage = getSessionStorage();
if (!storage) return;
storage.removeItem(ACTIVE_SLUG_KEY);
};
export const storeGalleryToken = (slug: string, token: string) => {
const storage = getSessionStorage();
if (!storage || !slug) return;
storage.setItem(`${TOKEN_STORAGE_PREFIX}${slug}`, token);
};
export const getGalleryToken = (slug?: string | null): string | null => {
const storage = getSessionStorage();
if (!storage) return null;
const resolvedSlug = slug || getActiveGallerySlug() || inferGallerySlugFromLocation();
if (!resolvedSlug) return null;
return storage.getItem(`${TOKEN_STORAGE_PREFIX}${resolvedSlug}`);
};
export const clearGalleryToken = (slug?: string | null) => {
const storage = getSessionStorage();
if (!storage) return;
if (slug) {
storage.removeItem(`${TOKEN_STORAGE_PREFIX}${slug}`);
return;
}
const active = storage.getItem(ACTIVE_SLUG_KEY);
if (active) {
storage.removeItem(`${TOKEN_STORAGE_PREFIX}${active}`);
}
};
export const clearAllGalleryTokens = () => {
const storage = getSessionStorage();
if (!storage) return;
const keysToRemove: string[] = [];
for (let i = 0; i < storage.length; i += 1) {
const key = storage.key(i);
if (key && key.startsWith(TOKEN_STORAGE_PREFIX)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => storage.removeItem(key));
};
export const resolveSlugFromRequestUrl = (url?: string | null): string | null => {
if (!url) return null;
let pathname = url;
try {
if (url.startsWith('http://') || url.startsWith('https://')) {
pathname = new URL(url).pathname;
}
} catch (error) {
// Leave pathname as provided if URL parsing fails
}
if (!pathname.startsWith('/')) {
pathname = `/${pathname}`;
}
return extractSlugFromPath(pathname);
};
+45 -2
View File
@@ -132,6 +132,37 @@ command_exists() {
command -v "$1" >/dev/null 2>&1
}
ensure_storage_layout() {
local base_dir="$1"
local storage_root="$base_dir/storage"
local storage_events_dir="$storage_root/events"
mkdir -p "$storage_events_dir/active" \
"$storage_events_dir/archived" \
"$storage_root/thumbnails" \
"$storage_root/tmp"
local legacy_dir="$base_dir/events"
if [[ -d "$legacy_dir" ]]; then
log_step "Migrating legacy events directory to storage/events..."
mkdir -p "$storage_events_dir"
local existing=""
if [[ -d "$storage_events_dir" ]]; then
existing=$(ls -A "$storage_events_dir" 2>/dev/null || true)
fi
if [[ ! -d "$storage_events_dir" || -z "$existing" ]]; then
rm -rf "$storage_events_dir"
mv "$legacy_dir" "$storage_events_dir"
else
cp -a "$legacy_dir/." "$storage_events_dir/"
rm -rf "$legacy_dir"
fi
fi
mkdir -p "$storage_events_dir/active" "$storage_events_dir/archived"
}
generate_password() {
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
}
@@ -602,7 +633,8 @@ setup_native_installation() {
# Create application directory
log_step "Creating application directory..."
mkdir -p "$NATIVE_APP_DIR"/{app,events/{active,archived},logs,config}
mkdir -p "$NATIVE_APP_DIR"/{app,logs,config}
ensure_storage_layout "$NATIVE_APP_DIR"
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
# Clone repository
@@ -668,7 +700,7 @@ DATABASE_CLIENT=sqlite3
DATABASE_PATH=$NATIVE_APP_DIR/app/backend/data/photo_sharing.db
# Storage root (thumbnails/uploads live under this path)
STORAGE_PATH=$NATIVE_APP_DIR
STORAGE_PATH=$NATIVE_APP_DIR/storage
# Email
SMTP_ENABLED=${SMTP_HOST:+true}
@@ -1101,6 +1133,17 @@ update_native_installation() {
if ! grep -q '^FRONTEND_DIR=' "$NATIVE_APP_DIR/app/backend/.env"; then
echo "FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist" >> "$NATIVE_APP_DIR/app/backend/.env"
fi
ensure_storage_layout "$NATIVE_APP_DIR"
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR/storage"
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
if grep -q '^STORAGE_PATH=' "$NATIVE_APP_DIR/app/backend/.env"; then
sed -i "s|^STORAGE_PATH=.*|STORAGE_PATH=$NATIVE_APP_DIR/storage|" "$NATIVE_APP_DIR/app/backend/.env"
else
echo "STORAGE_PATH=$NATIVE_APP_DIR/storage" >> "$NATIVE_APP_DIR/app/backend/.env"
fi
fi
# Restart services
systemctl restart picpeak-backend