Full self-hosted photo manager with: - Ingestion worker (chokidar + sharp + exifr, dHash perceptual hashing) - SQLite schema with WAL mode (photos, tags, collections, duplicate_pairs) - Express API with keyset pagination, tag/collection/search/date filters - React + Vite UI: timeline, lightbox, detail panel, tag/collection editors - Duplicate detection (Union-Find grouping) with review + dismiss UI - Map view (Leaflet + OpenStreetMap, marker clustering) - Docker multi-stage build + Compose with inline env config - GitHub Actions: CI build check + ghcr.io image publish on main/tags Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
49 lines
1.1 KiB
React
49 lines
1.1 KiB
React
import MonthGroup from './MonthGroup.jsx';
|
|
|
|
function groupByMonth(photos) {
|
|
const groups = [];
|
|
let current = null;
|
|
|
|
for (const photo of photos) {
|
|
const key = photo.date_taken ? photo.date_taken.slice(0, 7) : 'undated';
|
|
|
|
if (!current || current.key !== key) {
|
|
current = { key, label: monthLabel(photo.date_taken), photos: [] };
|
|
groups.push(current);
|
|
}
|
|
current.photos.push(photo);
|
|
}
|
|
|
|
return groups;
|
|
}
|
|
|
|
function monthLabel(dateTaken) {
|
|
if (!dateTaken) return 'Undated';
|
|
return new Date(dateTaken).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
|
}
|
|
|
|
export default function Timeline({ photos, onSelect }) {
|
|
if (!photos.length) return null;
|
|
|
|
const groups = groupByMonth(photos);
|
|
let globalIdx = 0;
|
|
|
|
return (
|
|
<main style={{ padding: '0 3px' }}>
|
|
{groups.map(group => {
|
|
const startIdx = globalIdx;
|
|
globalIdx += group.photos.length;
|
|
return (
|
|
<MonthGroup
|
|
key={group.key}
|
|
label={group.label}
|
|
photos={group.photos}
|
|
startIdx={startIdx}
|
|
onSelect={onSelect}
|
|
/>
|
|
);
|
|
})}
|
|
</main>
|
|
);
|
|
}
|