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:
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user