feat(categories): per-event category ordering — global default + override (#782)
Order a gallery's categories in the flow of the day instead of A–Z. Two layers, resolved per event: per-event override > global default > name. - migration 158: photo_categories.display_order (global default), backfilled from the current alphabetical order so existing galleries don't reshuffle. - migration 159: event_category_order (event_id, category_id, position) — the per-event override; no backfill, every event starts on the default. - utils/categoryOrder: shared resolution used by the admin event view and the public gallery; fails safe to the global default if the table is absent. - adminCategories: POST /reorder sets a per-event override (globals + event-specific, interleaved); DELETE /reorder/:eventId resets; POST /reorder-global sets the global default. Ordering endpoints + create append. - gallery renders the resolved order. - Settings → Photo Categories reorders the global default; an event's Categories tab reorders that gallery (one combined list + Reset to default). Up/down buttons — no drag-and-drop dependency. - en/de strings.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
@@ -13,12 +13,36 @@ export const CategoryManager: React.FC = () => {
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
// Fetch global categories
|
||||
// Fetch global categories (ordered by the global default display_order)
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['global-categories'],
|
||||
queryFn: categoriesService.getGlobalCategories,
|
||||
});
|
||||
|
||||
// Local copy so the up/down reorder buttons feel instant; resynced when the
|
||||
// query data changes.
|
||||
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
|
||||
useEffect(() => {
|
||||
setOrdered(categories);
|
||||
}, [categories]);
|
||||
|
||||
// Set the GLOBAL default order (#782). Applies to every gallery that hasn't
|
||||
// set its own per-event override.
|
||||
const reorderMutation = useMutationWithToast({
|
||||
mutationFn: (orderedIds: number[]) => categoriesService.reorderGlobalCategories(orderedIds),
|
||||
invalidateKeys: [['global-categories']],
|
||||
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
|
||||
});
|
||||
|
||||
const handleMove = (index: number, dir: -1 | 1) => {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= ordered.length) return;
|
||||
const next = [...ordered];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setOrdered(next); // optimistic
|
||||
reorderMutation.mutate(next.map((c) => c.id));
|
||||
};
|
||||
|
||||
// Create category mutation
|
||||
const createMutation = useMutationWithToast({
|
||||
mutationFn: (name: string) =>
|
||||
@@ -144,12 +168,12 @@ export const CategoryManager: React.FC = () => {
|
||||
|
||||
{/* Categories list */}
|
||||
<div className="space-y-2">
|
||||
{categories.length === 0 ? (
|
||||
{ordered.length === 0 ? (
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('categories.noCategoriesYet')}
|
||||
</p>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
ordered.map((category, index) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
|
||||
@@ -189,9 +213,33 @@ export const CategoryManager: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Global default order (#782). The gallery uses this order
|
||||
unless a specific event overrides it. */}
|
||||
<div className="flex flex-col -space-y-1">
|
||||
<button
|
||||
onClick={() => handleMove(index, -1)}
|
||||
disabled={index === 0 || reorderMutation.isPending}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveUp', 'Move up')}
|
||||
aria-label={t('categories.moveUp', 'Move up')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMove(index, 1)}
|
||||
disabled={index === ordered.length - 1 || reorderMutation.isPending}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveDown', 'Move down')}
|
||||
aria-label={t('categories.moveDown', 'Move down')}
|
||||
>
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 truncate">/{category.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react';
|
||||
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
@@ -17,7 +17,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
|
||||
|
||||
// Fetch categories for this event
|
||||
// Fetch this event's categories (globals + event-specific), already resolved
|
||||
// to the event's effective order by the backend (#782).
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['event-categories', eventId],
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
@@ -30,17 +31,21 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
enabled: heroPickerCategoryId !== null,
|
||||
});
|
||||
|
||||
// Filter to show only event-specific categories
|
||||
const eventCategories = categories.filter(cat => !cat.is_global);
|
||||
// Combined list (globals + event-specific) in the resolved order, kept in
|
||||
// local state so the up/down reorder buttons feel instant; resynced whenever
|
||||
// the query data changes (e.g. after a reorder or reset persists).
|
||||
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
|
||||
useEffect(() => {
|
||||
setOrdered(categories);
|
||||
}, [categories]);
|
||||
|
||||
// Create category mutation
|
||||
// The event is "customised" when it has its own per-event override.
|
||||
const isCustomised = ordered.some((c) => c.override_position != null);
|
||||
|
||||
// Create category mutation (always event-specific)
|
||||
const createMutation = useMutationWithToast({
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({
|
||||
name,
|
||||
is_global: false,
|
||||
event_id: eventId
|
||||
}),
|
||||
categoriesService.createCategory({ name, is_global: false, event_id: eventId }),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
successMessage: t('categories.categoryCreatedSuccess'),
|
||||
onSuccess: () => {
|
||||
@@ -71,9 +76,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
errorMessage: t('categories.failedToSetCoverPhoto'),
|
||||
});
|
||||
|
||||
// Toggle per-category download permission (#640). The backend AND's this
|
||||
// with the event-level `allow_downloads`, so disabling at either level
|
||||
// blocks downloads for this category's photos.
|
||||
// Toggle per-category download permission (#640). Event-specific only.
|
||||
const downloadToggleMutation = useMutationWithToast({
|
||||
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
|
||||
categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
|
||||
@@ -85,6 +88,32 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'),
|
||||
});
|
||||
|
||||
// Per-event order override (#782). Sends the full ordered id list; the backend
|
||||
// pins it for this gallery only. Up/down buttons match the invoice line-item
|
||||
// convention (no drag-and-drop dependency).
|
||||
const reorderMutation = useMutationWithToast({
|
||||
mutationFn: (orderedIds: number[]) => categoriesService.reorderCategories(eventId, orderedIds),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
|
||||
});
|
||||
|
||||
// Revert this gallery to the global default order.
|
||||
const resetMutation = useMutationWithToast({
|
||||
mutationFn: () => categoriesService.resetEventOrder(eventId),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
successMessage: t('categories.orderReset', 'Reverted to the default order'),
|
||||
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
|
||||
});
|
||||
|
||||
const handleMove = (index: number, dir: -1 | 1) => {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= ordered.length) return;
|
||||
const next = [...ordered];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setOrdered(next); // optimistic — instant feedback
|
||||
reorderMutation.mutate(next.map((c) => c.id));
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
createMutation.mutate(newCategoryName.trim());
|
||||
@@ -105,6 +134,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
heroMutation.mutate({ categoryId, photoId: null });
|
||||
};
|
||||
|
||||
const busy = reorderMutation.isPending || resetMutation.isPending;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
@@ -115,23 +146,38 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
|
||||
{!addingModal.isOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addingModal.open}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.galleryOrder', 'Gallery order')}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCustomised && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={busy}
|
||||
leftIcon={<RotateCcw className="w-3 h-3" />}
|
||||
>
|
||||
{t('categories.resetToDefault', 'Reset to default')}
|
||||
</Button>
|
||||
)}
|
||||
{!addingModal.isOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addingModal.open}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint about hero photo fallback */}
|
||||
{/* Explain the two ordering layers */}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.categoryHeroHint')}
|
||||
{isCustomised
|
||||
? t('categories.orderCustomisedHint', 'This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).')
|
||||
: t('categories.orderDefaultHint', 'Drag the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).')}
|
||||
</p>
|
||||
|
||||
{/* Add new category form */}
|
||||
@@ -152,11 +198,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
onClick={handleCreate}
|
||||
disabled={!newCategoryName.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
t('common.add')
|
||||
)}
|
||||
{createMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -171,14 +213,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
{/* Combined, reorderable category list (globals + event-specific) */}
|
||||
{ordered.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{eventCategories.map((category) => {
|
||||
{ordered.map((category, index) => {
|
||||
const heroPhoto = category.hero_photo_id
|
||||
? photos.find(p => p.id === category.hero_photo_id)
|
||||
: null;
|
||||
@@ -187,7 +229,30 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
key={category.id}
|
||||
className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* Reorder controls (#782). The gallery renders categories in
|
||||
this order; changes here override the global default for
|
||||
this event only. */}
|
||||
<div className="flex flex-col -space-y-1">
|
||||
<button
|
||||
onClick={() => handleMove(index, -1)}
|
||||
disabled={index === 0 || busy}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveUp', 'Move up')}
|
||||
aria-label={t('categories.moveUp', 'Move up')}
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMove(index, 1)}
|
||||
disabled={index === ordered.length - 1 || busy}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveDown', 'Move down')}
|
||||
aria-label={t('categories.moveDown', 'Move down')}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Hero photo thumbnail */}
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(category.id)}
|
||||
@@ -207,49 +272,56 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
|
||||
{category.is_global && (
|
||||
<span className="flex-shrink-0 text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-500 dark:text-neutral-400">
|
||||
{t('categories.sharedBadge', 'Shared')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Per-category downloads toggle (#640). Green DownloadCloud
|
||||
icon when on, struck-through outline when off. The
|
||||
event-level `allow_downloads` AND's with this — if the
|
||||
whole event has downloads off, this toggle is cosmetic. */}
|
||||
<button
|
||||
onClick={() => downloadToggleMutation.mutate({
|
||||
category,
|
||||
allow: category.allow_downloads === false,
|
||||
})}
|
||||
className={`p-1 transition-colors ${
|
||||
category.allow_downloads === false
|
||||
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
|
||||
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
|
||||
}`}
|
||||
title={
|
||||
category.allow_downloads === false
|
||||
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||
}
|
||||
disabled={downloadToggleMutation.isPending}
|
||||
>
|
||||
{downloadToggleMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : category.allow_downloads === false ? (
|
||||
<Download className="w-3 h-3" />
|
||||
) : (
|
||||
<DownloadCloud className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 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>
|
||||
{/* Download toggle + delete apply to event-specific categories
|
||||
only. Global categories are managed in Settings. */}
|
||||
{!category.is_global && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => downloadToggleMutation.mutate({
|
||||
category,
|
||||
allow: category.allow_downloads === false,
|
||||
})}
|
||||
className={`p-1 transition-colors ${
|
||||
category.allow_downloads === false
|
||||
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
|
||||
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
|
||||
}`}
|
||||
title={
|
||||
category.allow_downloads === false
|
||||
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||
}
|
||||
disabled={downloadToggleMutation.isPending}
|
||||
>
|
||||
{downloadToggleMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : category.allow_downloads === false ? (
|
||||
<Download className="w-3 h-3" />
|
||||
) : (
|
||||
<DownloadCloud className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 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>
|
||||
);
|
||||
@@ -257,41 +329,10 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show available global categories */}
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||
<div className="space-y-2">
|
||||
{categories
|
||||
.filter(cat => cat.is_global)
|
||||
.map(cat => {
|
||||
const heroPhoto = cat.hero_photo_id
|
||||
? photos.find(p => p.id === cat.hero_photo_id)
|
||||
: null;
|
||||
return (
|
||||
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(cat.id)}
|
||||
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark 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-accent" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{cat.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Hint about hero photo fallback */}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.categoryHeroHint')}
|
||||
</p>
|
||||
|
||||
{/* Hero Photo Picker Modal */}
|
||||
{heroPickerCategoryId !== null && (
|
||||
@@ -317,7 +358,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
) : (
|
||||
<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 currentCategory = ordered.find(c => c.id === heroPickerCategoryId);
|
||||
const isSelected = photo.id === currentCategory?.hero_photo_id;
|
||||
return (
|
||||
<div
|
||||
@@ -352,7 +393,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3">
|
||||
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
||||
{ordered.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
|
||||
|
||||
@@ -947,6 +947,15 @@
|
||||
"coverPhotoRemoved": "Titelbild entfernt",
|
||||
"failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden",
|
||||
"categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet.",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten",
|
||||
"failedToReorder": "Kategorie-Reihenfolge konnte nicht aktualisiert werden",
|
||||
"galleryOrder": "Galerie-Reihenfolge",
|
||||
"resetToDefault": "Auf Standard zurücksetzen",
|
||||
"orderReset": "Auf Standardreihenfolge zurückgesetzt",
|
||||
"orderCustomisedHint": "Diese Galerie verwendet eine eigene Reihenfolge. Zurücksetzen, um der globalen Standardreihenfolge zu folgen (Einstellungen → Fotokategorien).",
|
||||
"orderDefaultHint": "Mit den Pfeilen die Reihenfolge für diese Galerie festlegen. Andernfalls gilt die globale Standardreihenfolge (Einstellungen → Fotokategorien).",
|
||||
"sharedBadge": "Geteilt",
|
||||
"downloadsEnabled": "Downloads für diese Kategorie aktiviert",
|
||||
"downloadsDisabled": "Downloads für diese Kategorie deaktiviert",
|
||||
"enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie",
|
||||
|
||||
@@ -494,6 +494,15 @@
|
||||
"coverPhotoRemoved": "Cover photo removed",
|
||||
"failedToSetCoverPhoto": "Failed to set cover photo",
|
||||
"categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used.",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"failedToReorder": "Failed to update category order",
|
||||
"galleryOrder": "Gallery order",
|
||||
"resetToDefault": "Reset to default",
|
||||
"orderReset": "Reverted to the default order",
|
||||
"orderCustomisedHint": "This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).",
|
||||
"orderDefaultHint": "Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).",
|
||||
"sharedBadge": "Shared",
|
||||
"downloadsEnabled": "Downloads enabled for this category",
|
||||
"downloadsDisabled": "Downloads disabled for this category",
|
||||
"enableDownloadsTitle": "Click to enable downloads for this category",
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface PhotoCategory {
|
||||
// Per-category download permission (#640). Defaults true (server-side) so
|
||||
// categories created before migration 135 keep working.
|
||||
allow_downloads?: boolean;
|
||||
// Global default sort order (#782). Backfilled from the previous alphabetical
|
||||
// order on migration, so existing galleries don't reshuffle.
|
||||
display_order?: number;
|
||||
// Per-event override position (#782). Non-null on the /event/:id response when
|
||||
// this gallery has customised its order; null means it follows the default.
|
||||
override_position?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -61,5 +67,31 @@ export const categoriesService = {
|
||||
// Delete a category
|
||||
async deleteCategory(id: number): Promise<void> {
|
||||
await api.delete(`/admin/categories/${id}`);
|
||||
},
|
||||
|
||||
// Set a per-event order override (#782). Sends the full ordered id list for
|
||||
// this event — globals + event-specific — and returns the resolved order.
|
||||
// Overrides the global default for this gallery only.
|
||||
async reorderCategories(eventId: number, orderedIds: number[]): Promise<PhotoCategory[]> {
|
||||
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder', {
|
||||
event_id: eventId,
|
||||
orderedIds
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Clear an event's override — revert this gallery to the global default order.
|
||||
async resetEventOrder(eventId: number): Promise<PhotoCategory[]> {
|
||||
const response = await api.delete<PhotoCategory[]>(`/admin/categories/reorder/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Set the GLOBAL default order for shared categories (#782). Applies to every
|
||||
// gallery that hasn't set its own override.
|
||||
async reorderGlobalCategories(orderedIds: number[]): Promise<PhotoCategory[]> {
|
||||
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder-global', {
|
||||
orderedIds
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user