Merge pull request #286 from the-luap/feat/photo-sort-by-capture-date

feat: sort photos by capture date with configurable default sort (#283)
This commit is contained in:
Paul Nothaft
2026-04-09 15:11:19 +02:00
committed by GitHub
15 changed files with 192 additions and 10 deletions
@@ -0,0 +1,17 @@
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.string('default_photo_sort', 50).defaultTo('upload_date_desc');
});
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('default_photo_sort');
});
}
};
+16 -3
View File
@@ -259,7 +259,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString()
body('client_password').optional().isString(),
body('default_photo_sort').optional().isIn([
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -314,7 +319,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
client_access_enabled = false,
client_password = null,
// Draft mode
is_draft = true
is_draft = true,
// Default photo sort
default_photo_sort = 'upload_date_desc'
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
@@ -483,6 +490,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
default_photo_sort: default_photo_sort || 'upload_date_desc',
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
@@ -856,7 +864,12 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString(),
body('regenerate_client_token').optional().isBoolean()
body('regenerate_client_token').optional().isBoolean(),
body('default_photo_sort').optional().isIn([
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
], async (req, res) => {
try {
const errors = validationResult(req);
+5 -2
View File
@@ -123,7 +123,8 @@ router.get('/:slug/info', async (req, res) => {
'header_style',
'hero_divider_style',
'hero_image_anchor',
'is_draft'
'is_draft',
'default_photo_sort'
)
.first();
@@ -182,7 +183,8 @@ router.get('/:slug/info', async (req, res) => {
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center'
hero_image_anchor: event.hero_image_anchor || 'center',
default_photo_sort: event.default_photo_sort || 'upload_date_desc'
});
} catch (error) {
console.error('Error fetching gallery info:', error);
@@ -397,6 +399,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
header_style: req.event.header_style || 'standard',
hero_divider_style: req.event.hero_divider_style || 'wave',
hero_image_anchor: req.event.hero_image_anchor || 'center',
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
...protectionSettings
},
categories: categories,
@@ -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');
+10
View File
@@ -2396,5 +2396,15 @@
"sanitizationWarning": "Einige CSS-Muster wurden aus Sicherheitsgründen blockiert",
"reset": "Vorlage auf Standard zurückgesetzt",
"resetFailed": "Vorlage konnte nicht zurückgesetzt werden"
},
"photoSort": {
"defaultSort": "Standard-Fotosortierung",
"uploadDateNewest": "Hochladedatum (Neueste zuerst)",
"uploadDateOldest": "Hochladedatum (Älteste zuerst)",
"captureDateNewest": "Aufnahmedatum (Neueste zuerst)",
"captureDateOldest": "Aufnahmedatum (Älteste zuerst)",
"filenameAZ": "Dateiname (A-Z)",
"filenameZA": "Dateiname (Z-A)",
"dateTaken": "Aufnahmedatum"
}
}
+10
View File
@@ -2396,5 +2396,15 @@
"exportSelected": "Export {{count}} selected",
"exportFiltered": "Export filtered photos",
"hint": "Select photos or apply filters to export"
},
"photoSort": {
"defaultSort": "Default Photo Sort",
"uploadDateNewest": "Upload Date (Newest First)",
"uploadDateOldest": "Upload Date (Oldest First)",
"captureDateNewest": "Date Taken (Newest First)",
"captureDateOldest": "Date Taken (Oldest First)",
"filenameAZ": "Filename (A-Z)",
"filenameZA": "Filename (Z-A)",
"dateTaken": "Date Taken"
}
}
+10
View File
@@ -2396,5 +2396,15 @@
"linkLabel": "Klanttoegangslink",
"regenerateToken": "Link opnieuw genereren",
"tokenRegenerated": "Klanttoegangslink opnieuw gegenereerd"
},
"photoSort": {
"defaultSort": "Standaard fotovolgorde",
"uploadDateNewest": "Uploaddatum (Nieuwste eerst)",
"uploadDateOldest": "Uploaddatum (Oudste eerst)",
"captureDateNewest": "Opnamedatum (Nieuwste eerst)",
"captureDateOldest": "Opnamedatum (Oudste eerst)",
"filenameAZ": "Bestandsnaam (A-Z)",
"filenameZA": "Bestandsnaam (Z-A)",
"dateTaken": "Opnamedatum"
}
}
+10
View File
@@ -2396,5 +2396,15 @@
"exportSelected": "Exportar {{count}} selecionados",
"exportFiltered": "Exportar fotos filtradas",
"hint": "Selecione fotos ou aplique filtros para exportar"
},
"photoSort": {
"defaultSort": "Ordenação padrão das fotos",
"uploadDateNewest": "Data de envio (Mais recentes)",
"uploadDateOldest": "Data de envio (Mais antigas)",
"captureDateNewest": "Data da captura (Mais recentes)",
"captureDateOldest": "Data da captura (Mais antigas)",
"filenameAZ": "Nome do arquivo (A-Z)",
"filenameZA": "Nome do arquivo (Z-A)",
"dateTaken": "Data da captura"
}
}
+10
View File
@@ -2396,5 +2396,15 @@
"exportSelected": "Экспортировать {{count}} выбранных",
"exportFiltered": "Экспортировать отфильтрованные фото",
"hint": "Выберите фото или примените фильтры для экспорта"
},
"photoSort": {
"defaultSort": "Сортировка фото по умолчанию",
"uploadDateNewest": "Дата загрузки (Сначала новые)",
"uploadDateOldest": "Дата загрузки (Сначала старые)",
"captureDateNewest": "Дата съёмки (Сначала новые)",
"captureDateOldest": "Дата съёмки (Сначала старые)",
"filenameAZ": "Имя файла (А-Я)",
"filenameZA": "Имя файла (Я-А)",
"dateTaken": "Дата съёмки"
}
}
@@ -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
+2
View File
@@ -40,6 +40,7 @@ interface CreateEventData {
moderate_comments?: boolean;
show_feedback_to_guests?: boolean;
photo_cap?: number | null;
default_photo_sort?: string;
}
interface UpdateEventData {
@@ -60,6 +61,7 @@ interface UpdateEventData {
source_mode?: 'managed' | 'reference';
external_path?: string | null;
photo_cap?: number | null;
default_photo_sort?: string;
}
interface EventsListResponse {
+5
View File
@@ -60,6 +60,8 @@ export interface Event {
// Client access (#172)
client_access_enabled?: boolean;
client_share_token?: string;
// Default photo sort order
default_photo_sort?: string;
}
export type GalleryAccessLevel = 'guest' | 'client';
@@ -73,6 +75,7 @@ export interface GalleryInfo {
is_expired: boolean;
requires_password?: boolean;
color_theme?: string;
default_photo_sort?: string;
}
export interface Photo {
@@ -151,6 +154,8 @@ export interface GalleryData {
hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';
// Hero image anchor position (#162) keyword or "X% Y%" focal point
hero_image_anchor?: string;
// Default photo sort order
default_photo_sort?: string;
};
categories?: PhotoCategory[];
photos: Photo[];