diff --git a/README.md b/README.md index 9047c055..31323977 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,27 @@ [![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/) [![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) **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) +## 🎮 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 DemoGitHubDocumentationSupport 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} - -
- ))} +
+ {/* Hero photo thumbnail */} + + {category.name} +
+ +
+ ); + })}
)} {/* 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 ( +
+ + {cat.name} +
+ ); + })}
+ + {/* Hero Photo Picker Modal */} + {heroPickerCategoryId !== null && ( +
+ +
+
+

{t('categories.setCoverPhoto')}

+ +
+
+ +
+ {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 && ( +
+ +
+ )} +
+

{photo.filename}

+
+
+ ); + })} +
+ )} +
+ +
+ {categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && ( + + )} +
+ +
+ +
+ )}
); }; -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 {