diff --git a/README.md b/README.md
index 9047c055..31323977 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,27 @@
[](https://www.docker.com/)
[](https://nodejs.org/)
[](https://reactjs.org/)
+
+ [Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md)
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.

+## 🎮 Live Demo
+
+Try PicPeak without installing anything:
+
+| | |
+|---|---|
+| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
+| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
+| **Email** | `demo@picpeak.app` |
+| **Password** | `Demo2026!` |
+
+> The demo resets periodically. Uploaded content may be removed without notice.
+
## 🌟 Why Choose PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you:
@@ -327,6 +342,8 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
Made with ❤️ by photographers, for photographers
+ Homepage •
+ Live Demo •
GitHub •
Documentation •
Support
diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js
index e03ff0e3..e58a5b68 100644
--- a/backend/src/routes/adminCategories.js
+++ b/backend/src/routes/adminCategories.js
@@ -106,42 +106,53 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
// Update a category
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
- body('name').notEmpty().withMessage('Category name is required')
+ body('name').notEmpty().withMessage('Category name is required'),
+ body('hero_photo_id').optional({ nullable: true }).custom((value) => {
+ if (value === null || value === undefined) return true;
+ return Number.isInteger(Number(value));
+ }).withMessage('hero_photo_id must be an integer or null')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
-
+
const { id } = req.params;
- const { name } = req.body;
-
+ const { name, hero_photo_id } = req.body;
+
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
-
+
+ const updateData = {
+ name,
+ slug: name.toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .trim()
+ };
+
+ // Update hero_photo_id if provided (including null to clear it)
+ if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
+ updateData.hero_photo_id = hero_photo_id || null;
+ }
+
await db('photo_categories')
.where('id', id)
- .update({
- name,
- slug: name.toLowerCase()
- .replace(/[^\w\s-]/g, '')
- .replace(/\s+/g, '-')
- .replace(/-+/g, '-')
- .trim()
- });
-
+ .update(updateData);
+
const updated = await db('photo_categories').where('id', id).first();
-
+
// Log activity
await logActivity('category_updated',
- { categoryName: name },
+ { categoryName: name, heroPhotoId: hero_photo_id },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
-
+
res.json(updated);
} catch (error) {
console.error('Error updating category:', error);
@@ -149,6 +160,55 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
}
});
+// Set category hero photo (#163)
+router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
+ body('hero_photo_id').optional({ nullable: true }).custom((value) => {
+ if (value === null || value === undefined) return true;
+ return Number.isInteger(Number(value));
+ }).withMessage('hero_photo_id must be an integer or null')
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
+ const { id } = req.params;
+ const { hero_photo_id } = req.body;
+
+ const category = await db('photo_categories').where('id', id).first();
+ if (!category) {
+ return res.status(404).json({ error: 'Category not found' });
+ }
+
+ // If hero_photo_id is provided, verify it belongs to a photo in this category
+ if (hero_photo_id) {
+ const photo = await db('photos').where('id', hero_photo_id).first();
+ if (!photo) {
+ return res.status(404).json({ error: 'Photo not found' });
+ }
+ }
+
+ await db('photo_categories')
+ .where('id', id)
+ .update({ hero_photo_id: hero_photo_id || null });
+
+ const updated = await db('photo_categories').where('id', id).first();
+
+ // Log activity
+ await logActivity('category_hero_updated',
+ { categoryName: category.name, heroPhotoId: hero_photo_id },
+ category.event_id,
+ { type: 'admin', id: req.admin.id, name: req.admin.username }
+ );
+
+ res.json(updated);
+ } catch (error) {
+ console.error('Error updating category hero:', error);
+ res.status(500).json({ error: 'Failed to update category hero' });
+ }
+});
+
// Delete a category
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js
index 0d4519a4..25845e6f 100644
--- a/backend/src/routes/adminEvents.js
+++ b/backend/src/routes/adminEvents.js
@@ -632,7 +632,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
- body('expires_at').optional().isISO8601(),
+ body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
@@ -794,6 +794,17 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
+ // Enforce expires_at requirement based on app settings
+ if (Object.prototype.hasOwnProperty.call(updates, 'expires_at')) {
+ if (!updates.expires_at) {
+ const fieldReqs = await getEventFieldRequirements();
+ if (fieldReqs.require_expiration) {
+ return res.status(400).json({ error: 'Expiration date is required.' });
+ }
+ updates.expires_at = null;
+ }
+ }
+
// Format hero logo settings if provided
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 91c08f58..7c028cf2 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -309,14 +309,15 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds)
- .select('id', 'name', 'slug', 'is_global')
+ .select('id', 'name', 'slug', 'is_global', 'hero_photo_id')
.orderBy('name', 'asc');
categories = categoryDetails.map(cat => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
- is_global: cat.is_global
+ is_global: cat.is_global,
+ hero_photo_id: cat.hero_photo_id || null
}));
}
diff --git a/frontend/src/components/admin/EventCategoryManager.tsx b/frontend/src/components/admin/EventCategoryManager.tsx
index 8a0f66b2..ee0401ad 100644
--- a/frontend/src/components/admin/EventCategoryManager.tsx
+++ b/frontend/src/components/admin/EventCategoryManager.tsx
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Plus, X, Loader2 } from 'lucide-react';
+import { Plus, X, Loader2, Image as ImageIcon, Check } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
-import { Button } from '../common';
+import { photosService, type AdminPhoto } from '../../services/photos.service';
+import { Button, Card, AuthenticatedImage } from '../common';
import { useTranslation } from 'react-i18next';
interface EventCategoryManagerProps {
@@ -15,6 +16,7 @@ export const EventCategoryManager: React.FC = ({ even
const { t } = useTranslation();
const [isAdding, setIsAdding] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
+ const [heroPickerCategoryId, setHeroPickerCategoryId] = useState(null);
// Fetch categories for this event
const { data: categories = [], isLoading } = useQuery({
@@ -22,16 +24,23 @@ export const EventCategoryManager: React.FC = ({ even
queryFn: () => categoriesService.getEventCategories(eventId),
});
+ // Fetch photos for hero selection
+ const { data: photos = [] } = useQuery({
+ queryKey: ['admin-event-photos', eventId, {}],
+ queryFn: () => photosService.getEventPhotos(eventId, {}),
+ enabled: heroPickerCategoryId !== null,
+ });
+
// Filter to show only event-specific categories
const eventCategories = categories.filter(cat => !cat.is_global);
// Create category mutation
const createMutation = useMutation({
- mutationFn: (name: string) =>
- categoriesService.createCategory({
- name,
+ mutationFn: (name: string) =>
+ categoriesService.createCategory({
+ name,
is_global: false,
- event_id: eventId
+ event_id: eventId
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
@@ -56,6 +65,20 @@ export const EventCategoryManager: React.FC = ({ even
},
});
+ // Set hero photo mutation
+ const heroMutation = useMutation({
+ mutationFn: ({ categoryId, photoId }: { categoryId: number; photoId: number | null }) =>
+ categoriesService.setCategoryHeroPhoto(categoryId, photoId),
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
+ setHeroPickerCategoryId(null);
+ toast.success(variables.photoId ? t('categories.coverPhotoSet') : t('categories.coverPhotoRemoved'));
+ },
+ onError: (error: any) => {
+ toast.error(error.response?.data?.error || t('categories.failedToSetCoverPhoto'));
+ },
+ });
+
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
@@ -68,6 +91,14 @@ export const EventCategoryManager: React.FC = ({ even
}
};
+ const handleSelectHeroPhoto = (categoryId: number, photoId: number) => {
+ heroMutation.mutate({ categoryId, photoId });
+ };
+
+ const handleRemoveHeroPhoto = (categoryId: number) => {
+ heroMutation.mutate({ categoryId, photoId: null });
+ };
+
if (isLoading) {
return (
@@ -135,45 +166,172 @@ export const EventCategoryManager: React.FC
= ({ even
{t('categories.noEventSpecificCategories')}
) : (
-
- {eventCategories.map((category) => (
-
-
{category.name}
-
handleDelete(category)}
- className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
- title={t('categories.deleteCategoryTitle')}
- disabled={deleteMutation.isPending}
+
+ {eventCategories.map((category) => {
+ const heroPhoto = category.hero_photo_id
+ ? photos.find(p => p.id === category.hero_photo_id)
+ : null;
+ return (
+
- {deleteMutation.isPending ? (
-
- ) : (
-
- )}
-
-
- ))}
+
+ {/* Hero photo thumbnail */}
+
setHeroPickerCategoryId(category.id)}
+ className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 overflow-hidden bg-neutral-100 hover:border-primary-400 transition-colors flex items-center justify-center"
+ title={t('categories.setCoverPhoto')}
+ >
+ {heroPhoto ? (
+
+ ) : category.hero_photo_id ? (
+
+ ) : (
+
+ )}
+
+
{category.name}
+
+
handleDelete(category)}
+ className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
+ title={t('categories.deleteCategoryTitle')}
+ disabled={deleteMutation.isPending}
+ >
+ {deleteMutation.isPending ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
)}
{/* Show available global categories */}
{t('categories.globalCategoriesAlwaysAvailable')}
-
+
{categories
.filter(cat => cat.is_global)
- .map(cat => (
-
- {cat.name}
-
- ))}
+ .map(cat => {
+ const heroPhoto = cat.hero_photo_id
+ ? photos.find(p => p.id === cat.hero_photo_id)
+ : null;
+ return (
+
+
setHeroPickerCategoryId(cat.id)}
+ className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 overflow-hidden bg-neutral-100 hover:border-primary-400 transition-colors flex items-center justify-center"
+ title={t('categories.setCoverPhoto')}
+ >
+ {heroPhoto ? (
+
+ ) : cat.hero_photo_id ? (
+
+ ) : (
+
+ )}
+
+
{cat.name}
+
+ );
+ })}
+
+ {/* Hero Photo Picker Modal */}
+ {heroPickerCategoryId !== null && (
+
+
+
+
+
{t('categories.setCoverPhoto')}
+ setHeroPickerCategoryId(null)}
+ className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
+ >
+
+
+
+
+
+
+ {photos.length === 0 ? (
+
+ {t('events.noPhotosAvailable')}
+
+ ) : (
+
+ {photos.map((photo) => {
+ const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
+ const isSelected = photo.id === currentCategory?.hero_photo_id;
+ return (
+
handleSelectHeroPhoto(heroPickerCategoryId, photo.id)}
+ className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
+ isSelected
+ ? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2'
+ : 'border-transparent hover:border-neutral-300'
+ }`}
+ >
+
+ {isSelected && (
+
+
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+ {categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
+
handleRemoveHeroPhoto(heroPickerCategoryId)}
+ disabled={heroMutation.isPending}
+ >
+ {t('categories.removeCoverPhoto')}
+
+ )}
+
+
setHeroPickerCategoryId(null)}
+ >
+ {t('common.cancel')}
+
+
+
+
+ )}
);
};
-EventCategoryManager.displayName = 'EventCategoryManager';
\ No newline at end of file
+EventCategoryManager.displayName = 'EventCategoryManager';
diff --git a/frontend/src/components/common/__tests__/ProtectedImage.test.tsx b/frontend/src/components/common/__tests__/ProtectedImage.test.tsx
index cd8126c3..c06ceed3 100644
--- a/frontend/src/components/common/__tests__/ProtectedImage.test.tsx
+++ b/frontend/src/components/common/__tests__/ProtectedImage.test.tsx
@@ -1,51 +1,62 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { vi } from 'vitest';
import { ProtectedImage } from '../ProtectedImage';
-// Mock canvas and image APIs
-const mockCanvas = {
- getContext: jest.fn(() => ({
- clearRect: jest.fn(),
- drawImage: jest.fn(),
- getImageData: jest.fn(() => ({
- data: new Uint8ClampedArray(4).fill(255)
- })),
- putImageData: jest.fn(),
- fillRect: jest.fn(),
- fillText: jest.fn(),
- strokeText: jest.fn(),
- measureText: jest.fn(() => ({ width: 100 }))
+// Create a stable mock context (same reference for all getContext calls)
+const mockContext = {
+ clearRect: vi.fn(),
+ drawImage: vi.fn(),
+ getImageData: vi.fn(() => ({
+ data: new Uint8ClampedArray(400).fill(255)
})),
- width: 100,
- height: 100,
- style: {},
- addEventListener: jest.fn(),
- removeEventListener: jest.fn()
+ putImageData: vi.fn(),
+ fillRect: vi.fn(),
+ fillText: vi.fn(),
+ strokeText: vi.fn(),
+ measureText: vi.fn(() => ({ width: 100 })),
+ globalAlpha: 1.0,
+ globalCompositeOperation: 'source-over',
+ font: '',
+ fillStyle: '',
+ strokeStyle: '',
+ lineWidth: 1,
+ textAlign: 'center',
+ textBaseline: 'middle',
+ shadowColor: 'transparent',
+ shadowBlur: 0,
+ shadowOffsetX: 0,
+ shadowOffsetY: 0,
};
-// Mock HTMLCanvasElement
+// Mock HTMLCanvasElement.getContext to always return our stable context
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
- value: () => mockCanvas.getContext()
+ value: () => mockContext,
+ writable: true,
});
-// Mock Image constructor
-global.Image = class {
- onload: (() => void) | null = null;
- onerror: (() => void) | null = null;
- src = '';
- naturalWidth = 100;
- naturalHeight = 100;
- width = 100;
- height = 100;
- crossOrigin = '';
+// Default Image mock that simulates successful loading
+const createSuccessImage = () => {
+ return class {
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ src = '';
+ naturalWidth = 100;
+ naturalHeight = 100;
+ width = 100;
+ height = 100;
+ crossOrigin = '';
+ complete = true;
- constructor() {
- // Simulate image loading
- setTimeout(() => {
- if (this.onload) this.onload();
- }, 10);
- }
-} as any;
+ constructor() {
+ setTimeout(() => {
+ if (this.onload) this.onload();
+ }, 10);
+ }
+ } as unknown as typeof Image;
+};
+
+global.Image = createSuccessImage();
describe('ProtectedImage', () => {
const defaultProps = {
@@ -54,28 +65,34 @@ describe('ProtectedImage', () => {
};
beforeEach(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
+ // Reset Image mock to success variant
+ global.Image = createSuccessImage();
});
- it('renders loading state initially', () => {
+ it('renders canvas with loading styles initially', () => {
render(
);
- expect(screen.getByRole('img', { name: /loading test image/i })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toBeInTheDocument();
+ // While loading, canvas has opacity 0
+ expect(canvas).toHaveStyle({ opacity: '0' });
});
it('renders canvas after image loads', async () => {
render(
);
-
+
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
});
it('applies protection level classes and events', async () => {
- const onViolation = jest.fn();
-
+ const onViolation = vi.fn();
+
render(
-
@@ -89,31 +106,32 @@ describe('ProtectedImage', () => {
// Test context menu blocking
const canvas = screen.getByRole('img', { name: 'Test image' });
fireEvent.contextMenu(canvas);
-
+
expect(onViolation).toHaveBeenCalledWith('canvas_context_menu');
});
it('applies watermark text when specified', async () => {
render(
-
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Verify canvas context methods were called for watermark
- expect(mockCanvas.getContext().fillText).toHaveBeenCalled();
+ expect(mockContext.fillText).toHaveBeenCalled();
});
it('handles fragment grid rendering', async () => {
render(
-
{
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Verify multiple drawImage calls for fragments
- expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
+ expect(mockContext.drawImage).toHaveBeenCalled();
});
it('blocks interactions in maximum protection mode', async () => {
- const onViolation = jest.fn();
-
+ const onViolation = vi.fn();
+
render(
-
@@ -142,7 +161,7 @@ describe('ProtectedImage', () => {
await waitFor(() => {
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toBeInTheDocument();
-
+
// Test click blocking
fireEvent.click(canvas);
expect(onViolation).toHaveBeenCalledWith('canvas_interaction_blocked');
@@ -150,26 +169,37 @@ describe('ProtectedImage', () => {
});
it('handles image loading errors gracefully', async () => {
- // Mock image error
+ // Track how many times src is set to detect fallback attempts
+ let loadAttempt = 0;
+
global.Image = class {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
- src = '';
+ private _src = '';
+ naturalWidth = 0;
+ naturalHeight = 0;
+ width = 0;
+ height = 0;
+ crossOrigin = '';
+ complete = false;
- constructor() {
+ get src() { return this._src; }
+ set src(value: string) {
+ this._src = value;
+ loadAttempt++;
setTimeout(() => {
if (this.onerror) this.onerror();
}, 10);
}
- } as any;
+ } as unknown as typeof Image;
- const onViolation = jest.fn();
-
+ const onViolation = vi.fn();
+
+ // Render WITHOUT fallbackSrc so error state is reached immediately
render(
-
);
@@ -182,8 +212,8 @@ describe('ProtectedImage', () => {
it('applies invisible watermark for enhanced protection', async () => {
render(
- {
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Verify getImageData and putImageData called for steganography
- expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
- expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
+ expect(mockContext.getImageData).toHaveBeenCalled();
+ expect(mockContext.putImageData).toHaveBeenCalled();
});
it('scrambles fragments when enabled', async () => {
render(
- {
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Fragment scrambling should result in multiple drawImage calls
- expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
+ expect(mockContext.drawImage).toHaveBeenCalled();
});
it('adds random noise in maximum protection', async () => {
render(
-
);
await waitFor(() => {
- expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
+ const canvas = screen.getByRole('img', { name: 'Test image' });
+ expect(canvas).toHaveStyle({ opacity: '1' });
});
// Noise injection requires getImageData and putImageData
- expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
- expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
+ expect(mockContext.getImageData).toHaveBeenCalled();
+ expect(mockContext.putImageData).toHaveBeenCalled();
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index c7c01b0f..eed7520d 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -696,7 +696,12 @@
"noCategory": "Keine Kategorie",
"noCategoriesYet": "Noch keine Kategorien. Erstellen Sie Ihre erste Kategorie, um Fotos zu organisieren.",
"deleteConfirm": "Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten?",
- "cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu."
+ "cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu.",
+ "setCoverPhoto": "Titelbild festlegen",
+ "removeCoverPhoto": "Titelbild entfernen",
+ "coverPhotoSet": "Titelbild erfolgreich festgelegt",
+ "coverPhotoRemoved": "Titelbild entfernt",
+ "failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden"
},
"events": {
"noStatisticsAvailableYet": "Noch keine Statistiken verfügbar",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index d94ba9b6..2937a0b7 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -309,7 +309,12 @@
"noCategory": "No category",
"noCategoriesYet": "No categories yet. Create your first category to organize photos.",
"deleteConfirm": "Are you sure you want to delete \"{{name}}\"?",
- "cannotDelete": "Cannot delete category with photos. Please reassign photos first."
+ "cannotDelete": "Cannot delete category with photos. Please reassign photos first.",
+ "setCoverPhoto": "Set Cover Photo",
+ "removeCoverPhoto": "Remove Cover Photo",
+ "coverPhotoSet": "Cover photo set successfully",
+ "coverPhotoRemoved": "Cover photo removed",
+ "failedToSetCoverPhoto": "Failed to set cover photo"
},
"events": {
"title": "Events",
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index d78e7d18..3eef09a0 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -55,6 +55,7 @@ import { Button, Input, Card, Loading } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
+import { publicSettingsService } from '../../services/publicSettings.service';
import { api } from '../../config/api';
import { buildResourceUrl } from '../../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
@@ -314,6 +315,13 @@ export const EventDetailsPage: React.FC = () => {
}
}, [showMediaFilter, photoFilters.media_type]);
+ // Fetch public settings (for field requirement checks like expiration)
+ const { data: publicSettings } = useQuery({
+ queryKey: ['public-settings'],
+ queryFn: () => publicSettingsService.getPublicSettings(),
+ });
+ const requireExpiration = publicSettings?.event_require_expiration !== false;
+
// Fetch categories for the event
const { data: categories = [] } = useQuery({
queryKey: ['admin-event-categories', id],
@@ -517,10 +525,15 @@ export const EventDetailsPage: React.FC = () => {
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
return;
}
-
+
+ if (requireExpiration && !editForm.expires_at) {
+ toast.error(t('validation.expirationRequired', 'Expiration date is required.'));
+ return;
+ }
+
// Clean up the data - remove undefined values
const updateData: any = {
- expires_at: editForm.expires_at,
+ expires_at: editForm.expires_at || null,
allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password,
css_template_id: editForm.css_template_id,
diff --git a/frontend/src/services/categories.service.ts b/frontend/src/services/categories.service.ts
index 56c5bed8..383d3f13 100644
--- a/frontend/src/services/categories.service.ts
+++ b/frontend/src/services/categories.service.ts
@@ -6,6 +6,7 @@ export interface PhotoCategory {
slug: string;
is_global: boolean;
event_id: number | null;
+ hero_photo_id?: number | null;
created_at: string;
}
@@ -41,6 +42,12 @@ export const categoriesService = {
return response.data;
},
+ // Set category hero photo (#163)
+ async setCategoryHeroPhoto(id: number, heroPhotoId: number | null): Promise {
+ const response = await api.put(`/admin/categories/${id}/hero`, { hero_photo_id: heroPhotoId });
+ return response.data;
+ },
+
// Delete a category
async deleteCategory(id: number): Promise {
await api.delete(`/admin/categories/${id}`);
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 28f6de05..eeaacd29 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -102,6 +102,7 @@ export interface PhotoCategory {
name: string;
slug: string;
is_global: boolean;
+ hero_photo_id?: number | null;
}
export interface GalleryData {