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
@@ -63,6 +63,8 @@ interface FormData {
// Client access (#172)
client_access_enabled: boolean;
client_password: string;
// Default photo sort
default_photo_sort: string;
}
// Fallback event types (used when API is unavailable)
@@ -120,6 +122,7 @@ export const CreateEventPage: React.FC = () => {
},
client_access_enabled: false,
client_password: '',
default_photo_sort: 'upload_date_desc',
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
@@ -319,6 +322,8 @@ export const CreateEventPage: React.FC = () => {
// Client access (#172)
client_access_enabled: formData.client_access_enabled,
client_password: formData.client_access_enabled ? formData.client_password : undefined,
// Default photo sort
default_photo_sort: formData.default_photo_sort,
};
createMutation.mutate(payload);
@@ -751,6 +756,25 @@ export const CreateEventPage: React.FC = () => {
</div>
</div>
{/* Default Photo Sort */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('photoSort.defaultSort', 'Default Photo Sort')}
</label>
<select
value={formData.default_photo_sort}
onChange={(e) => setFormData({ ...formData, default_photo_sort: e.target.value })}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="upload_date_desc">{t('photoSort.uploadDateNewest', 'Upload Date (Newest First)')}</option>
<option value="upload_date_asc">{t('photoSort.uploadDateOldest', 'Upload Date (Oldest First)')}</option>
<option value="capture_date_desc">{t('photoSort.captureDateNewest', 'Date Taken (Newest First)')}</option>
<option value="capture_date_asc">{t('photoSort.captureDateOldest', 'Date Taken (Oldest First)')}</option>
<option value="filename_asc">{t('photoSort.filenameAZ', 'Filename (A-Z)')}</option>
<option value="filename_desc">{t('photoSort.filenameZA', 'Filename (Z-A)')}</option>
</select>
</div>
{/* Client Access (#172) */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
<label className="flex items-start gap-2">
@@ -176,6 +176,8 @@ export const EventDetailsPage: React.FC = () => {
hero_image_anchor: string;
// Photo cap
photo_cap: number;
// Default photo sort
default_photo_sort: string;
};
const [isEditing, setIsEditing] = useState(false);
@@ -209,6 +211,8 @@ export const EventDetailsPage: React.FC = () => {
hero_image_anchor: 'center',
// Photo cap
photo_cap: 0,
// Default photo sort
default_photo_sort: 'upload_date_desc',
});
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false,
@@ -442,6 +446,8 @@ export const EventDetailsPage: React.FC = () => {
hero_image_anchor: event.hero_image_anchor || 'center',
// Photo cap
photo_cap: event.photo_cap || 0,
// Default photo sort
default_photo_sort: event.default_photo_sort || 'upload_date_desc',
});
setShowNewPassword(false);
@@ -577,6 +583,8 @@ export const EventDetailsPage: React.FC = () => {
hero_image_anchor: editForm.hero_image_anchor,
// Photo cap
photo_cap: editForm.photo_cap > 0 ? editForm.photo_cap : null,
// Default photo sort
default_photo_sort: editForm.default_photo_sort,
// Header style settings (decoupled from layout, #158)
header_style: currentTheme?.headerStyle || 'standard',
hero_divider_style: currentTheme?.heroDividerStyle || 'wave',
@@ -1125,6 +1133,25 @@ export const EventDetailsPage: React.FC = () => {
</div>
</div>
{/* Default Photo Sort */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('photoSort.defaultSort', 'Default Photo Sort')}
</label>
<select
value={editForm.default_photo_sort}
onChange={(e) => setEditForm(prev => ({ ...prev, default_photo_sort: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="upload_date_desc">{t('photoSort.uploadDateNewest', 'Upload Date (Newest First)')}</option>
<option value="upload_date_asc">{t('photoSort.uploadDateOldest', 'Upload Date (Oldest First)')}</option>
<option value="capture_date_desc">{t('photoSort.captureDateNewest', 'Date Taken (Newest First)')}</option>
<option value="capture_date_asc">{t('photoSort.captureDateOldest', 'Date Taken (Oldest First)')}</option>
<option value="filename_asc">{t('photoSort.filenameAZ', 'Filename (A-Z)')}</option>
<option value="filename_desc">{t('photoSort.filenameZA', 'Filename (Z-A)')}</option>
</select>
</div>
<div>
<label className="flex items-center">
<input