feat: add category hero/cover photo selection (#163)

Wire up the hero_photo_id column on photo_categories that was added in
the migration but never connected. Backend routes now accept and persist
hero_photo_id on category create/update, a dedicated PUT /:id/hero
endpoint is added, and the gallery API returns hero_photo_id for each
category. Frontend EventCategoryManager shows a clickable thumbnail per
category that opens a photo picker modal. Includes EN/DE i18n keys.
This commit is contained in:
Paul Nothaft
2026-02-03 15:43:23 +01:00
parent 329d224846
commit 6c30e2c2ed
11 changed files with 452 additions and 141 deletions
+17
View File
@@ -7,12 +7,27 @@
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/) [![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/) [![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md)
</div> </div>
**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. **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.
![PicPeak Gallery Preview](docs/screenshot-gallery.png) ![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🎮 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** | `[email protected]` |
| **Password** | `Demo2026!` |
> The demo resets periodically. Uploaded content may be removed without notice.
## 🌟 Why Choose PicPeak? ## 🌟 Why Choose PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you: Unlike expensive SaaS solutions, PicPeak gives you:
@@ -327,6 +342,8 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
<p align="center"> <p align="center">
Made with ❤️ by photographers, for photographers Made with ❤️ by photographers, for photographers
<br> <br>
<a href="https://www.picpeak.app">Homepage</a> •
<a href="https://demo.picpeak.app">Live Demo</a> •
<a href="https://github.com/the-luap/picpeak">GitHub</a> • <a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> • <a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a> <a href="https://github.com/the-luap/picpeak/issues">Support</a>
+77 -17
View File
@@ -106,42 +106,53 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
// Update a category // Update a category
router.put('/:id', adminAuth, requirePermission('settings.edit'), [ 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) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() }); return res.status(400).json({ errors: errors.array() });
} }
const { id } = req.params; 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(); const category = await db('photo_categories').where('id', id).first();
if (!category) { if (!category) {
return res.status(404).json({ error: 'Category not found' }); 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') await db('photo_categories')
.where('id', id) .where('id', id)
.update({ .update(updateData);
name,
slug: name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
});
const updated = await db('photo_categories').where('id', id).first(); const updated = await db('photo_categories').where('id', id).first();
// Log activity // Log activity
await logActivity('category_updated', await logActivity('category_updated',
{ categoryName: name }, { categoryName: name, heroPhotoId: hero_photo_id },
category.event_id, category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username } { type: 'admin', id: req.admin.id, name: req.admin.username }
); );
res.json(updated); res.json(updated);
} catch (error) { } catch (error) {
console.error('Error updating category:', 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 // Delete a category
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => { router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
+12 -1
View File
@@ -632,7 +632,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('event_name').optional().trim().notEmpty(), body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(), body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(), 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('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }), body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(), 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()); 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 // Format hero logo settings if provided
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) { if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible); updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
+3 -2
View File
@@ -309,14 +309,15 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
if (usedCategoryIds.length > 0) { if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories') const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds) .whereIn('id', usedCategoryIds)
.select('id', 'name', 'slug', 'is_global') .select('id', 'name', 'slug', 'is_global', 'hero_photo_id')
.orderBy('name', 'asc'); .orderBy('name', 'asc');
categories = categoryDetails.map(cat => ({ categories = categoryDetails.map(cat => ({
id: cat.id, id: cat.id,
name: cat.name, name: cat.name,
slug: cat.slug, slug: cat.slug,
is_global: cat.is_global is_global: cat.is_global,
hero_photo_id: cat.hero_photo_id || null
})); }));
} }
@@ -1,9 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; 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 { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service'; 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'; import { useTranslation } from 'react-i18next';
interface EventCategoryManagerProps { interface EventCategoryManagerProps {
@@ -15,6 +16,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
const { t } = useTranslation(); const { t } = useTranslation();
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryName, setNewCategoryName] = useState('');
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
// Fetch categories for this event // Fetch categories for this event
const { data: categories = [], isLoading } = useQuery({ const { data: categories = [], isLoading } = useQuery({
@@ -22,16 +24,23 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
queryFn: () => categoriesService.getEventCategories(eventId), 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 // Filter to show only event-specific categories
const eventCategories = categories.filter(cat => !cat.is_global); const eventCategories = categories.filter(cat => !cat.is_global);
// Create category mutation // Create category mutation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (name: string) => mutationFn: (name: string) =>
categoriesService.createCategory({ categoriesService.createCategory({
name, name,
is_global: false, is_global: false,
event_id: eventId event_id: eventId
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
@@ -56,6 +65,20 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ 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 = () => { const handleCreate = () => {
if (newCategoryName.trim()) { if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim()); createMutation.mutate(newCategoryName.trim());
@@ -68,6 +91,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
} }
}; };
const handleSelectHeroPhoto = (categoryId: number, photoId: number) => {
heroMutation.mutate({ categoryId, photoId });
};
const handleRemoveHeroPhoto = (categoryId: number) => {
heroMutation.mutate({ categoryId, photoId: null });
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center py-4"> <div className="flex justify-center items-center py-4">
@@ -135,45 +166,172 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
{t('categories.noEventSpecificCategories')} {t('categories.noEventSpecificCategories')}
</p> </p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-2">
{eventCategories.map((category) => ( {eventCategories.map((category) => {
<div const heroPhoto = category.hero_photo_id
key={category.id} ? photos.find(p => p.id === category.hero_photo_id)
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md" : null;
> return (
<span className="text-sm text-neutral-700">{category.name}</span> <div
<button key={category.id}
onClick={() => handleDelete(category)} className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
> >
{deleteMutation.isPending ? ( <div className="flex items-center gap-3 flex-1 min-w-0">
<Loader2 className="w-3 h-3 animate-spin" /> {/* Hero photo thumbnail */}
) : ( <button
<X className="w-3 h-3" /> onClick={() => 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"
</button> title={t('categories.setCoverPhoto')}
</div> >
))} {heroPhoto ? (
<AuthenticatedImage
src={heroPhoto.thumbnail_url || heroPhoto.url}
alt={category.name}
className="w-full h-full object-cover"
/>
) : category.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-primary-400" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
</button>
<span className="text-sm text-neutral-700 truncate">{category.name}</span>
</div>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</div>
);
})}
</div> </div>
)} )}
{/* Show available global categories */} {/* Show available global categories */}
<div className="mt-4 pt-3 border-t border-neutral-200"> <div className="mt-4 pt-3 border-t border-neutral-200">
<p className="text-xs font-medium text-neutral-500 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p> <p className="text-xs font-medium text-neutral-500 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
<div className="flex flex-wrap gap-1"> <div className="space-y-2">
{categories {categories
.filter(cat => cat.is_global) .filter(cat => cat.is_global)
.map(cat => ( .map(cat => {
<span key={cat.id} className="px-2 py-1 text-xs bg-neutral-100 text-neutral-600 rounded"> const heroPhoto = cat.hero_photo_id
{cat.name} ? photos.find(p => p.id === cat.hero_photo_id)
</span> : null;
))} return (
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 rounded-md">
<button
onClick={() => 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 ? (
<AuthenticatedImage
src={heroPhoto.thumbnail_url || heroPhoto.url}
alt={cat.name}
className="w-full h-full object-cover"
/>
) : cat.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-primary-400" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
</button>
<span className="text-sm text-neutral-600">{cat.name}</span>
</div>
);
})}
</div> </div>
</div> </div>
{/* Hero Photo Picker Modal */}
{heroPickerCategoryId !== null && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-neutral-200">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{t('categories.setCoverPhoto')}</h2>
<button
onClick={() => setHeroPickerCategoryId(null)}
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
{photos.length === 0 ? (
<p className="text-center text-neutral-500 py-8">
{t('events.noPhotosAvailable')}
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{photos.map((photo) => {
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
const isSelected = photo.id === currentCategory?.hero_photo_id;
return (
<div
key={photo.id}
onClick={() => 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'
}`}
>
<div className="aspect-square bg-neutral-100">
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover"
/>
</div>
{isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<Check className="w-4 h-4" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="p-6 border-t border-neutral-200 flex justify-between gap-3">
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
<Button
variant="outline"
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
disabled={heroMutation.isPending}
>
{t('categories.removeCoverPhoto')}
</Button>
)}
<div className="flex-1" />
<Button
variant="outline"
onClick={() => setHeroPickerCategoryId(null)}
>
{t('common.cancel')}
</Button>
</div>
</Card>
</div>
)}
</div> </div>
); );
}; };
EventCategoryManager.displayName = 'EventCategoryManager'; EventCategoryManager.displayName = 'EventCategoryManager';
@@ -1,51 +1,62 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { ProtectedImage } from '../ProtectedImage'; import { ProtectedImage } from '../ProtectedImage';
// Mock canvas and image APIs // Create a stable mock context (same reference for all getContext calls)
const mockCanvas = { const mockContext = {
getContext: jest.fn(() => ({ clearRect: vi.fn(),
clearRect: jest.fn(), drawImage: vi.fn(),
drawImage: jest.fn(), getImageData: vi.fn(() => ({
getImageData: jest.fn(() => ({ data: new Uint8ClampedArray(400).fill(255)
data: new Uint8ClampedArray(4).fill(255)
})),
putImageData: jest.fn(),
fillRect: jest.fn(),
fillText: jest.fn(),
strokeText: jest.fn(),
measureText: jest.fn(() => ({ width: 100 }))
})), })),
width: 100, putImageData: vi.fn(),
height: 100, fillRect: vi.fn(),
style: {}, fillText: vi.fn(),
addEventListener: jest.fn(), strokeText: vi.fn(),
removeEventListener: jest.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', { Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
value: () => mockCanvas.getContext() value: () => mockContext,
writable: true,
}); });
// Mock Image constructor // Default Image mock that simulates successful loading
global.Image = class { const createSuccessImage = () => {
onload: (() => void) | null = null; return class {
onerror: (() => void) | null = null; onload: (() => void) | null = null;
src = ''; onerror: (() => void) | null = null;
naturalWidth = 100; src = '';
naturalHeight = 100; naturalWidth = 100;
width = 100; naturalHeight = 100;
height = 100; width = 100;
crossOrigin = ''; height = 100;
crossOrigin = '';
complete = true;
constructor() { constructor() {
// Simulate image loading setTimeout(() => {
setTimeout(() => { if (this.onload) this.onload();
if (this.onload) this.onload(); }, 10);
}, 10); }
} } as unknown as typeof Image;
} as any; };
global.Image = createSuccessImage();
describe('ProtectedImage', () => { describe('ProtectedImage', () => {
const defaultProps = { const defaultProps = {
@@ -54,28 +65,34 @@ describe('ProtectedImage', () => {
}; };
beforeEach(() => { 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(<ProtectedImage {...defaultProps} />); render(<ProtectedImage {...defaultProps} />);
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 () => { it('renders canvas after image loads', async () => {
render(<ProtectedImage {...defaultProps} />); render(<ProtectedImage {...defaultProps} />);
await waitFor(() => { 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 () => { it('applies protection level classes and events', async () => {
const onViolation = jest.fn(); const onViolation = vi.fn();
render( render(
<ProtectedImage <ProtectedImage
{...defaultProps} {...defaultProps}
protectionLevel="enhanced" protectionLevel="enhanced"
onProtectionViolation={onViolation} onProtectionViolation={onViolation}
/> />
@@ -89,31 +106,32 @@ describe('ProtectedImage', () => {
// Test context menu blocking // Test context menu blocking
const canvas = screen.getByRole('img', { name: 'Test image' }); const canvas = screen.getByRole('img', { name: 'Test image' });
fireEvent.contextMenu(canvas); fireEvent.contextMenu(canvas);
expect(onViolation).toHaveBeenCalledWith('canvas_context_menu'); expect(onViolation).toHaveBeenCalledWith('canvas_context_menu');
}); });
it('applies watermark text when specified', async () => { it('applies watermark text when specified', async () => {
render( render(
<ProtectedImage <ProtectedImage
{...defaultProps} {...defaultProps}
watermarkText="Test Watermark" watermarkText="Test Watermark"
protectionLevel="standard" protectionLevel="standard"
/> />
); );
await waitFor(() => { 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 // Verify canvas context methods were called for watermark
expect(mockCanvas.getContext().fillText).toHaveBeenCalled(); expect(mockContext.fillText).toHaveBeenCalled();
}); });
it('handles fragment grid rendering', async () => { it('handles fragment grid rendering', async () => {
render( render(
<ProtectedImage <ProtectedImage
{...defaultProps} {...defaultProps}
fragmentGrid={true} fragmentGrid={true}
gridSize={4} gridSize={4}
protectionLevel="enhanced" protectionLevel="enhanced"
@@ -121,19 +139,20 @@ describe('ProtectedImage', () => {
); );
await waitFor(() => { 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 // Verify multiple drawImage calls for fragments
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled(); expect(mockContext.drawImage).toHaveBeenCalled();
}); });
it('blocks interactions in maximum protection mode', async () => { it('blocks interactions in maximum protection mode', async () => {
const onViolation = jest.fn(); const onViolation = vi.fn();
render( render(
<ProtectedImage <ProtectedImage
{...defaultProps} {...defaultProps}
protectionLevel="maximum" protectionLevel="maximum"
onProtectionViolation={onViolation} onProtectionViolation={onViolation}
/> />
@@ -142,7 +161,7 @@ describe('ProtectedImage', () => {
await waitFor(() => { await waitFor(() => {
const canvas = screen.getByRole('img', { name: 'Test image' }); const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toBeInTheDocument(); expect(canvas).toBeInTheDocument();
// Test click blocking // Test click blocking
fireEvent.click(canvas); fireEvent.click(canvas);
expect(onViolation).toHaveBeenCalledWith('canvas_interaction_blocked'); expect(onViolation).toHaveBeenCalledWith('canvas_interaction_blocked');
@@ -150,26 +169,37 @@ describe('ProtectedImage', () => {
}); });
it('handles image loading errors gracefully', async () => { 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 { global.Image = class {
onload: (() => void) | null = null; onload: (() => void) | null = null;
onerror: (() => 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(() => { setTimeout(() => {
if (this.onerror) this.onerror(); if (this.onerror) this.onerror();
}, 10); }, 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( render(
<ProtectedImage <ProtectedImage
{...defaultProps} {...defaultProps}
onProtectionViolation={onViolation} onProtectionViolation={onViolation}
fallbackSrc="/fallback.jpg"
/> />
); );
@@ -182,8 +212,8 @@ describe('ProtectedImage', () => {
it('applies invisible watermark for enhanced protection', async () => { it('applies invisible watermark for enhanced protection', async () => {
render( render(
<ProtectedImage <ProtectedImage
{...defaultProps} {...defaultProps}
watermarkText="Hidden" watermarkText="Hidden"
invisibleWatermark={true} invisibleWatermark={true}
protectionLevel="enhanced" protectionLevel="enhanced"
@@ -191,18 +221,19 @@ describe('ProtectedImage', () => {
); );
await waitFor(() => { 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 // Verify getImageData and putImageData called for steganography
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled(); expect(mockContext.getImageData).toHaveBeenCalled();
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled(); expect(mockContext.putImageData).toHaveBeenCalled();
}); });
it('scrambles fragments when enabled', async () => { it('scrambles fragments when enabled', async () => {
render( render(
<ProtectedImage <ProtectedImage
{...defaultProps} {...defaultProps}
fragmentGrid={true} fragmentGrid={true}
scrambleFragments={true} scrambleFragments={true}
protectionLevel="maximum" protectionLevel="maximum"
@@ -210,27 +241,29 @@ describe('ProtectedImage', () => {
); );
await waitFor(() => { 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 // 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 () => { it('adds random noise in maximum protection', async () => {
render( render(
<ProtectedImage <ProtectedImage
{...defaultProps} {...defaultProps}
protectionLevel="maximum" protectionLevel="maximum"
/> />
); );
await waitFor(() => { 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 // Noise injection requires getImageData and putImageData
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled(); expect(mockContext.getImageData).toHaveBeenCalled();
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled(); expect(mockContext.putImageData).toHaveBeenCalled();
}); });
}); });
+6 -1
View File
@@ -696,7 +696,12 @@
"noCategory": "Keine Kategorie", "noCategory": "Keine Kategorie",
"noCategoriesYet": "Noch keine Kategorien. Erstellen Sie Ihre erste Kategorie, um Fotos zu organisieren.", "noCategoriesYet": "Noch keine Kategorien. Erstellen Sie Ihre erste Kategorie, um Fotos zu organisieren.",
"deleteConfirm": "Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten?", "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": { "events": {
"noStatisticsAvailableYet": "Noch keine Statistiken verfügbar", "noStatisticsAvailableYet": "Noch keine Statistiken verfügbar",
+6 -1
View File
@@ -309,7 +309,12 @@
"noCategory": "No category", "noCategory": "No category",
"noCategoriesYet": "No categories yet. Create your first category to organize photos.", "noCategoriesYet": "No categories yet. Create your first category to organize photos.",
"deleteConfirm": "Are you sure you want to delete \"{{name}}\"?", "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": { "events": {
"title": "Events", "title": "Events",
+15 -2
View File
@@ -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 { 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import { publicSettingsService } from '../../services/publicSettings.service';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { buildResourceUrl } from '../../utils/url'; import { buildResourceUrl } from '../../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl'; import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
@@ -314,6 +315,13 @@ export const EventDetailsPage: React.FC = () => {
} }
}, [showMediaFilter, photoFilters.media_type]); }, [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 // Fetch categories for the event
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ['admin-event-categories', id], 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.')); toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
return; return;
} }
if (requireExpiration && !editForm.expires_at) {
toast.error(t('validation.expirationRequired', 'Expiration date is required.'));
return;
}
// Clean up the data - remove undefined values // Clean up the data - remove undefined values
const updateData: any = { const updateData: any = {
expires_at: editForm.expires_at, expires_at: editForm.expires_at || null,
allow_user_uploads: editForm.allow_user_uploads, allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password, require_password: editForm.require_password,
css_template_id: editForm.css_template_id, css_template_id: editForm.css_template_id,
@@ -6,6 +6,7 @@ export interface PhotoCategory {
slug: string; slug: string;
is_global: boolean; is_global: boolean;
event_id: number | null; event_id: number | null;
hero_photo_id?: number | null;
created_at: string; created_at: string;
} }
@@ -41,6 +42,12 @@ export const categoriesService = {
return response.data; return response.data;
}, },
// Set category hero photo (#163)
async setCategoryHeroPhoto(id: number, heroPhotoId: number | null): Promise<PhotoCategory> {
const response = await api.put<PhotoCategory>(`/admin/categories/${id}/hero`, { hero_photo_id: heroPhotoId });
return response.data;
},
// Delete a category // Delete a category
async deleteCategory(id: number): Promise<void> { async deleteCategory(id: number): Promise<void> {
await api.delete(`/admin/categories/${id}`); await api.delete(`/admin/categories/${id}`);
+1
View File
@@ -102,6 +102,7 @@ export interface PhotoCategory {
name: string; name: string;
slug: string; slug: string;
is_global: boolean; is_global: boolean;
hero_photo_id?: number | null;
} }
export interface GalleryData { export interface GalleryData {