feat: sort photos by capture date with configurable default sort (#283)

Add per-event default photo sort setting with 6 options:
- Upload Date (Newest/Oldest First)
- Date Taken (Newest/Oldest First) — uses EXIF captured_at
- Filename (A-Z / Z-A)

Backend:
- Migration 077 adds default_photo_sort column to events table
- Event create/update handlers accept and validate the setting
- Gallery info endpoint returns default_photo_sort for frontend

Frontend:
- "Date Taken" added to gallery sort dropdown (alongside Date, Name,
  Size, Rating)
- Gallery initializes with event's default sort instead of hardcoded
  "date"
- "Default Photo Sort" dropdown in event create and edit forms
- Photos without EXIF dates fall back to upload date

i18n: All 5 locales (EN, DE, NL, PT, RU) updated with sort labels.

Closes #283
This commit is contained in:
Paul Nothaft
2026-04-09 15:10:51 +02:00
parent b23c51b386
commit 633d4a0f30
15 changed files with 192 additions and 10 deletions
@@ -356,7 +356,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<button
key={option.value}
onClick={() => {
onSortChange(option.value as 'date' | 'name' | 'size' | 'rating');
onSortChange(option.value as 'date' | 'name' | 'size' | 'rating' | 'capture_date');
if (isMobile) onClose();
}}
className={`
@@ -42,6 +42,25 @@ interface GalleryViewProps {
};
}
// Convert default_photo_sort DB value to internal sortBy state
const parseDefaultPhotoSort = (defaultSort?: string): { sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date'; sortDesc: boolean } => {
switch (defaultSort) {
case 'upload_date_asc':
return { sortBy: 'date', sortDesc: false };
case 'capture_date_desc':
return { sortBy: 'capture_date', sortDesc: true };
case 'capture_date_asc':
return { sortBy: 'capture_date', sortDesc: false };
case 'filename_asc':
return { sortBy: 'name', sortDesc: false };
case 'filename_desc':
return { sortBy: 'name', sortDesc: true };
case 'upload_date_desc':
default:
return { sortBy: 'date', sortDesc: true };
}
};
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation();
const { logout, isClient } = useGalleryAuth();
@@ -50,6 +69,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
const [defaultSortApplied, setDefaultSortApplied] = useState(false);
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -103,6 +123,15 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}, [data?.event?.protection_level]);
// Apply default photo sort from event settings
useEffect(() => {
if (!defaultSortApplied && data?.event?.default_photo_sort) {
const { sortBy: defaultSortBy } = parseDefaultPhotoSort(data.event.default_photo_sort);
setSortBy(defaultSortBy);
setDefaultSortApplied(true);
}
}, [data?.event?.default_photo_sort, defaultSortApplied]);
// Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
@@ -25,8 +25,8 @@ interface PhotoFilterBarProps {
onCategoryChange: (categoryId: number | string | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void;
photoCount: number;
// Feedback filter props
feedbackEnabled?: boolean;
@@ -82,9 +82,10 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
className="w-full md:w-auto text-sm md:text-base"
>
<span className="hidden md:inline">{t('common.sortBy')} </span>
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
sortBy === 'capture_date' ? t('photoSort.dateTaken', 'Date Taken') :
t('gallery.sortByRating', 'Rating')}
</Button>
@@ -101,6 +102,17 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
>
{t('gallery.sortByDate')}
</button>
<button
onClick={() => {
onSortChange('capture_date');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'capture_date' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
}`}
>
{t('photoSort.dateTaken', 'Date Taken')}
</button>
<button
onClick={() => {
onSortChange('name');