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:
@@ -7,12 +7,27 @@
|
|||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://nodejs.org/)
|
[](https://nodejs.org/)
|
||||||
[](https://reactjs.org/)
|
[](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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
## 🎮 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>
|
||||||
|
|||||||
@@ -106,7 +106,11 @@ 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);
|
||||||
@@ -115,29 +119,36 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 }
|
||||||
);
|
);
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,6 +24,13 @@ 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);
|
||||||
|
|
||||||
@@ -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,43 +166,170 @@ 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,24 +65,30 @@ 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
|
||||||
@@ -103,11 +120,12 @@ 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 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 () => {
|
||||||
@@ -121,15 +139,16 @@ 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
|
||||||
@@ -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"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -191,12 +221,13 @@ 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 () => {
|
||||||
@@ -210,11 +241,12 @@ 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 () => {
|
||||||
@@ -226,11 +258,12 @@ 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' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
@@ -518,9 +526,14 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
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}`);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user