feat: initial Luma implementation
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>
This commit is contained in:
1 parent
03e6d6b628
commit
e0f4e8ff0d
32 files changed
+4845
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { addToCollection, removeFromCollection, fetchCollections, createCollection } from '../api/photos.js';
|
||||
import { Chip, Btn, iStyle } from './TagEditor.jsx';
|
||||
|
||||
export default function CollectionEditor({ photoId, initialCollections }) {
|
||||
const [collections, setCollections] = useState(initialCollections);
|
||||
const [all, setAll] = useState([]);
|
||||
const [mode, setMode] = useState('select'); // 'select' | 'create'
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCollections().then(setAll).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const currentIds = new Set(collections.map(c => c.id));
|
||||
const available = all.filter(c => !currentIds.has(c.id));
|
||||
|
||||
async function handleAdd() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (mode === 'select') {
|
||||
if (!selectedId) return;
|
||||
const col = all.find(c => c.id === parseInt(selectedId, 10));
|
||||
await addToCollection(photoId, col.id);
|
||||
setCollections(prev => [...prev, col]);
|
||||
setSelectedId('');
|
||||
} else {
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
const col = await createCollection(name);
|
||||
setAll(prev => [...prev, col].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
await addToCollection(photoId, col.id);
|
||||
setCollections(prev => [...prev, col]);
|
||||
setNewName('');
|
||||
setMode('select');
|
||||
}
|
||||
} catch {}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function handleRemove(colId) {
|
||||
setCollections(prev => prev.filter(c => c.id !== colId));
|
||||
try {
|
||||
await removeFromCollection(photoId, colId);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{collections.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10 }}>
|
||||
{collections.map(c => (
|
||||
<Chip key={c.id} label={c.name} color="green" onRemove={() => handleRemove(c.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'select' ? (
|
||||
<div style={{ display: 'flex', gap: 5 }}>
|
||||
<select
|
||||
value={selectedId}
|
||||
onChange={e => setSelectedId(e.target.value)}
|
||||
style={{ ...iStyle, cursor: 'pointer' }}
|
||||
>
|
||||
<option value="">Add to collection…</option>
|
||||
{available.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedId && <Btn onClick={handleAdd} disabled={busy}>+</Btn>}
|
||||
<Btn
|
||||
type="button"
|
||||
onClick={() => setMode('create')}
|
||||
style={{ width: 'auto', padding: '0 10px', fontSize: 11 }}
|
||||
>
|
||||
New
|
||||
</Btn>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 5 }}>
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAdd(); if (e.key === 'Escape') setMode('select'); }}
|
||||
placeholder="Collection name…"
|
||||
maxLength={200}
|
||||
style={iStyle}
|
||||
/>
|
||||
<Btn onClick={handleAdd} disabled={!newName.trim() || busy}>+</Btn>
|
||||
<Btn type="button" onClick={() => setMode('select')}>×</Btn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchPhotoDetail } from '../api/photos.js';
|
||||
import TagEditor from './TagEditor.jsx';
|
||||
import CollectionEditor from './CollectionEditor.jsx';
|
||||
|
||||
export default function DetailPanel({ photoId }) {
|
||||
const [photo, setPhoto] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setPhoto(null);
|
||||
setLoading(true);
|
||||
fetchPhotoDetail(photoId)
|
||||
.then(setPhoto)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [photoId]);
|
||||
|
||||
if (loading) {
|
||||
return <div style={panelPad}><span style={{ color: '#444', fontSize: 13 }}>Loading…</span></div>;
|
||||
}
|
||||
if (!photo) return null;
|
||||
|
||||
const filename = photo.filepath?.split('/').pop();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '14px 16px 32px', fontSize: 13, color: '#ccc', lineHeight: 1.5 }}>
|
||||
|
||||
<Section label="File">
|
||||
{filename && <Row k="Name" v={filename} mono />}
|
||||
{photo.date_taken && (
|
||||
<Row k="Taken" v={new Date(photo.date_taken).toLocaleDateString('en-US', { dateStyle: 'full' })} />
|
||||
)}
|
||||
{photo.date_imported && (
|
||||
<Row k="Added" v={new Date(photo.date_imported).toLocaleDateString('en-US', { dateStyle: 'medium' })} />
|
||||
)}
|
||||
{photo.width && photo.height && (
|
||||
<Row k="Pixels" v={`${photo.width.toLocaleString()} × ${photo.height.toLocaleString()}`} />
|
||||
)}
|
||||
{photo.filesize && (
|
||||
<Row k="Size" v={formatBytes(photo.filesize)} />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{photo.camera_model && (
|
||||
<Section label="Camera">
|
||||
<Row k="Model" v={photo.camera_model} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{photo.gps_lat != null && (
|
||||
<Section label="Location">
|
||||
<Row k="GPS" v={`${photo.gps_lat.toFixed(5)}, ${photo.gps_lon.toFixed(5)}`} />
|
||||
<a
|
||||
href={`https://maps.google.com/?q=${photo.gps_lat},${photo.gps_lon}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: 12, color: '#5a9', marginTop: 4, display: 'inline-block' }}
|
||||
>
|
||||
Open in Maps ↗
|
||||
</a>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section label="Tags">
|
||||
<TagEditor photoId={photoId} initialTags={photo.tags ?? []} />
|
||||
</Section>
|
||||
|
||||
<Section label="Collections">
|
||||
<CollectionEditor photoId={photoId} initialCollections={photo.collections ?? []} />
|
||||
</Section>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ fontSize: 10, fontWeight: 700, color: '#3a3a3a', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10 }}>
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ k, v, mono }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 5 }}>
|
||||
<span style={{ color: '#555', flexShrink: 0, width: 60, fontSize: 12 }}>{k}</span>
|
||||
<span style={{
|
||||
color: '#aaa',
|
||||
fontFamily: mono ? "'SF Mono', 'Fira Code', monospace" : undefined,
|
||||
fontSize: mono ? 11 : 13,
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{v}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1048576) return `${(b / 1024).toFixed(1)} KB`;
|
||||
return `${(b / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const panelPad = { padding: '14px 16px' };
|
||||
@@ -0,0 +1,100 @@
|
||||
function fmtSize(b) {
|
||||
if (!b) return null;
|
||||
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
|
||||
return `${(b / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return 'Undated';
|
||||
return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
export default function DuplicateGroup({ photos, onDismiss, onOpen }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: '#111',
|
||||
border: '1px solid #1d1d1d',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Photo row */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 1, background: '#0c0c0c' }}>
|
||||
{photos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
style={{ flex: '1 1 200px', minWidth: 160, maxWidth: 320, display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
<div
|
||||
onClick={() => onOpen(photo)}
|
||||
style={{
|
||||
aspectRatio: '4/3',
|
||||
cursor: 'pointer',
|
||||
background: '#0c0c0c',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{photo.thumb_url ? (
|
||||
<img
|
||||
src={photo.thumb_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#222', fontSize: 24 }}>▪</div>
|
||||
)}
|
||||
|
||||
{/* Index badge */}
|
||||
<span style={{
|
||||
position: 'absolute', top: 6, left: 6,
|
||||
background: 'rgba(0,0,0,0.6)', borderRadius: 4,
|
||||
fontSize: 10, color: '#888', padding: '2px 5px',
|
||||
}}>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div style={{ padding: '7px 10px 8px', fontSize: 12, color: '#555', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ color: '#888' }}>{fmtDate(photo.date_taken)}</span>
|
||||
<span>
|
||||
{[fmtSize(photo.filesize), photo.width && `${photo.width}×${photo.height}`].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
{photo.camera_model && (
|
||||
<span style={{ color: '#3a3a3a', fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{photo.camera_model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '8px 14px',
|
||||
borderTop: '1px solid #1a1a1a',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: '#333', flex: 1 }}>
|
||||
{photos.length} similar photos
|
||||
</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: 5,
|
||||
color: '#666',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 12px',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Not duplicates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchDuplicates, scanDuplicates, dismissGroup } from '../api/photos.js';
|
||||
import DuplicateGroup from './DuplicateGroup.jsx';
|
||||
|
||||
export default function DuplicatesView({ onOpenPhoto, onGroupCountChange }) {
|
||||
const [groups, setGroups] = useState(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanResult, setScanResult] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const data = await fetchDuplicates();
|
||||
setGroups(data.groups);
|
||||
onGroupCountChange?.(data.total);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScan() {
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await scanDuplicates();
|
||||
setScanResult(result);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
setScanning(false);
|
||||
}
|
||||
|
||||
async function handleDismiss(photoIds) {
|
||||
try {
|
||||
await dismissGroup(photoIds);
|
||||
setGroups(prev => {
|
||||
const next = prev.filter(g => {
|
||||
const s = new Set(g.map(p => p.id));
|
||||
return !photoIds.every(id => s.has(id));
|
||||
});
|
||||
onGroupCountChange?.(next.length);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px 20px', maxWidth: 1100, margin: '0 auto' }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 28, flexWrap: 'wrap' }}>
|
||||
<h1 style={{ fontSize: 15, fontWeight: 600, color: '#999', margin: 0 }}>Similar photos</h1>
|
||||
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
style={{
|
||||
background: '#1a1a1a', border: '1px solid #2a2a2a', borderRadius: 6,
|
||||
color: scanning ? '#444' : '#aaa', cursor: scanning ? 'default' : 'pointer',
|
||||
padding: '5px 14px', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{scanning ? 'Scanning…' : 'Scan library'}
|
||||
</button>
|
||||
|
||||
{scanResult && (
|
||||
<span style={{ fontSize: 12, color: '#555' }}>
|
||||
{scanResult.newPairs === 0
|
||||
? 'No new pairs'
|
||||
: `${scanResult.newPairs} new pair${scanResult.newPairs !== 1 ? 's' : ''} found`}
|
||||
{' · '}{scanResult.photos.toLocaleString()} photos · {scanResult.duration}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p style={{ color: '#f87171', fontSize: 13, marginBottom: 16 }}>{error}</p>
|
||||
)}
|
||||
|
||||
{groups === null && (
|
||||
<p style={{ color: '#555', fontSize: 13 }}>Loading…</p>
|
||||
)}
|
||||
|
||||
{groups !== null && groups.length === 0 && (
|
||||
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
|
||||
<p style={{ fontSize: 15, marginBottom: 8 }}>No similar photos to review.</p>
|
||||
<p style={{ fontSize: 13 }}>Click "Scan library" to compare perceptual hashes across your library.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups !== null && groups.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<p style={{ fontSize: 12, color: '#444', marginBottom: 4 }}>
|
||||
{groups.length} group{groups.length !== 1 ? 's' : ''} — click a photo to inspect it, "Not duplicates" to dismiss
|
||||
</p>
|
||||
{groups.map((group, i) => (
|
||||
<DuplicateGroup
|
||||
key={i}
|
||||
photos={group}
|
||||
onDismiss={() => handleDismiss(group.map(p => p.id))}
|
||||
onOpen={(photo) => onOpenPhoto(photo, group)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
const EMPTY = { q: '', from: '', to: '', tagId: '', collectionId: '' };
|
||||
|
||||
function isActive(f) {
|
||||
return f.q || f.from || f.to || f.tagId || f.collectionId;
|
||||
}
|
||||
|
||||
export default function FilterBar({ filters, onChange, tags, collections }) {
|
||||
const s = { // shared input style
|
||||
background: '#111', border: '1px solid #222', borderRadius: 5,
|
||||
color: '#ccc', fontSize: 12, padding: '4px 8px',
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 20px', borderBottom: '1px solid #141414',
|
||||
background: '#0c0c0c', flexWrap: 'wrap',
|
||||
}}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search…"
|
||||
value={filters.q}
|
||||
onChange={e => onChange({ ...filters, q: e.target.value })}
|
||||
style={{ ...s, width: 160 }}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={e => onChange({ ...filters, from: e.target.value })}
|
||||
style={{ ...s, colorScheme: 'dark' }}
|
||||
title="From date"
|
||||
/>
|
||||
<span style={{ color: '#333', fontSize: 11 }}>–</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
onChange={e => onChange({ ...filters, to: e.target.value })}
|
||||
style={{ ...s, colorScheme: 'dark' }}
|
||||
title="To date"
|
||||
/>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<select
|
||||
value={filters.tagId}
|
||||
onChange={e => onChange({ ...filters, tagId: e.target.value })}
|
||||
style={{ ...s }}
|
||||
>
|
||||
<option value="">All tags</option>
|
||||
{tags.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{collections.length > 0 && (
|
||||
<select
|
||||
value={filters.collectionId}
|
||||
onChange={e => onChange({ ...filters, collectionId: e.target.value })}
|
||||
style={{ ...s }}
|
||||
>
|
||||
<option value="">All collections</option>
|
||||
{collections.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{isActive(filters) && (
|
||||
<button
|
||||
onClick={() => onChange(EMPTY)}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid #2a2a2a',
|
||||
borderRadius: 5, color: '#555', cursor: 'pointer',
|
||||
padding: '4px 10px', fontSize: 11,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import DetailPanel from './DetailPanel.jsx';
|
||||
|
||||
export default function Lightbox({ photos, index, onClose, onChange }) {
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const photo = photos[index];
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e) {
|
||||
// Don't hijack shortcuts while typing in the detail panel
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') return;
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft' && index > 0) onChange(index - 1);
|
||||
if (e.key === 'ArrowRight' && index < photos.length - 1) onChange(index + 1);
|
||||
if (e.key === 'i') setShowInfo(s => !s);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [index, photos.length, onClose, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, []);
|
||||
|
||||
if (!photo) return null;
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', background: '#080808' }}>
|
||||
|
||||
{/* ── Image area ── */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{ flex: 1, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', minWidth: 0 }}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ position: 'absolute', top: 0, right: 0, display: 'flex', gap: 4, padding: 10, zIndex: 10 }}
|
||||
>
|
||||
<IconBtn
|
||||
onClick={() => setShowInfo(s => !s)}
|
||||
active={showInfo}
|
||||
title="Info [i]"
|
||||
>
|
||||
<InfoIcon />
|
||||
</IconBtn>
|
||||
<IconBtn onClick={onClose} title="Close [Esc]">✕</IconBtn>
|
||||
</div>
|
||||
|
||||
{/* Left nav */}
|
||||
{index > 0 && (
|
||||
<NavBtn side="left" onClick={e => { e.stopPropagation(); onChange(index - 1); }}>‹</NavBtn>
|
||||
)}
|
||||
{/* Right nav */}
|
||||
{index < photos.length - 1 && (
|
||||
<NavBtn side="right" onClick={e => { e.stopPropagation(); onChange(index + 1); }}>›</NavBtn>
|
||||
)}
|
||||
|
||||
<img
|
||||
key={photo.id}
|
||||
src={photo.preview_url || photo.thumb_url}
|
||||
alt=""
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 20px)',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Detail panel ── */}
|
||||
{showInfo && (
|
||||
<div style={{
|
||||
width: 320,
|
||||
flexShrink: 0,
|
||||
background: '#111',
|
||||
borderLeft: '1px solid #1d1d1d',
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<DetailPanel photoId={photo.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconBtn({ onClick, active, title, children }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
style={{
|
||||
background: active ? 'rgba(255,255,255,0.15)' : 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 6,
|
||||
color: active ? '#fff' : '#888',
|
||||
cursor: 'pointer',
|
||||
width: 32, height: 32,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 14,
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NavBtn({ side, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
position: 'absolute', top: '50%', transform: 'translateY(-50%)',
|
||||
[side]: 10,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: '50%',
|
||||
width: 44, height: 44,
|
||||
fontSize: 24, color: '#ccc',
|
||||
cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<circle cx="7" cy="7" r="6" />
|
||||
<line x1="7" y1="6" x2="7" y2="10" />
|
||||
<circle cx="7" cy="3.5" r="0.75" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { fetchMapPhotos } from '../api/photos.js';
|
||||
|
||||
// Custom dot icon — avoids Leaflet's broken default asset URLs in Vite
|
||||
const dotIcon = (color = '#4d9eff') =>
|
||||
L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="
|
||||
width:12px;height:12px;border-radius:50%;
|
||||
background:${color};border:2px solid rgba(255,255,255,0.85);
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.5);
|
||||
"></div>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
popupAnchor: [0, -8],
|
||||
});
|
||||
|
||||
const ICON = dotIcon();
|
||||
|
||||
function centroid(points) {
|
||||
const lat = points.reduce((s, p) => s + p.lat, 0) / points.length;
|
||||
const lon = points.reduce((s, p) => s + p.lon, 0) / points.length;
|
||||
return [lat, lon];
|
||||
}
|
||||
|
||||
export default function MapView({ onOpenPhoto }) {
|
||||
const [points, setPoints] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMapPhotos()
|
||||
.then(setPoints)
|
||||
.catch(e => setError(e.message));
|
||||
}, []);
|
||||
|
||||
const center = useMemo(() => {
|
||||
if (!points?.length) return [20, 0];
|
||||
return centroid(points);
|
||||
}, [points]);
|
||||
|
||||
if (error) return (
|
||||
<p style={{ color: '#f87171', padding: 24, fontSize: 13 }}>{error}</p>
|
||||
);
|
||||
|
||||
if (points === null) return (
|
||||
<p style={{ color: '#555', padding: 24, fontSize: 13 }}>Loading map…</p>
|
||||
);
|
||||
|
||||
if (points.length === 0) return (
|
||||
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
|
||||
<p style={{ fontSize: 15, marginBottom: 8 }}>No geotagged photos.</p>
|
||||
<p style={{ fontSize: 13 }}>Photos with GPS metadata will appear here once indexed.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: 'calc(100vh - 52px)' }}>
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={4}
|
||||
style={{ height: '100%', width: '100%', background: '#1a1a1a' }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
||||
maxZoom={19}
|
||||
/>
|
||||
|
||||
<MarkerClusterGroup chunkedLoading>
|
||||
{points.map(p => (
|
||||
<Marker key={p.id} position={[p.lat, p.lon]} icon={ICON}>
|
||||
<Popup maxWidth={220} className="luma-popup">
|
||||
<div style={{ margin: 0, padding: 0 }}>
|
||||
{p.thumb_url && (
|
||||
<img
|
||||
src={p.thumb_url}
|
||||
alt=""
|
||||
onClick={() => onOpenPhoto(p)}
|
||||
style={{
|
||||
width: '100%', maxWidth: 200, display: 'block',
|
||||
cursor: 'pointer', borderRadius: 3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{p.date_taken && (
|
||||
<div style={{ fontSize: 11, color: '#888', marginTop: 4, textAlign: 'center' }}>
|
||||
{new Date(p.date_taken).toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MarkerClusterGroup>
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import PhotoCard from './PhotoCard.jsx';
|
||||
|
||||
export default function MonthGroup({ label, photos, startIdx, onSelect }) {
|
||||
return (
|
||||
<section style={{ marginBottom: 8 }}>
|
||||
<h2 style={{
|
||||
padding: '18px 6px 8px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
letterSpacing: 0.4,
|
||||
textTransform: 'uppercase',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{label}
|
||||
<span style={{ marginLeft: 8, color: '#3a3a3a', fontWeight: 400 }}>
|
||||
{photos.length.toLocaleString()}
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||
gap: 3,
|
||||
}}>
|
||||
{photos.map((photo, i) => (
|
||||
<PhotoCard
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
onClick={() => onSelect(startIdx + i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function PhotoCard({ photo, onClick }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={e => e.key === 'Enter' && onClick()}
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
background: '#181818',
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{photo.thumb_url && !error ? (
|
||||
<img
|
||||
src={photo.thumb_url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setError(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: 'opacity 0.15s ease',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#2a2a2a', fontSize: 20,
|
||||
}}>
|
||||
▪
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { addTag, removeTag } from '../api/photos.js';
|
||||
|
||||
export default function TagEditor({ photoId, initialTags }) {
|
||||
const [tags, setTags] = useState(initialTags);
|
||||
const [input, setInput] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
async function handleAdd(e) {
|
||||
e.preventDefault();
|
||||
const name = input.trim();
|
||||
if (!name || busy) return;
|
||||
if (tags.some(t => t.name === name)) { setInput(''); return; }
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const tag = await addTag(photoId, name);
|
||||
setTags(prev => [...prev, tag].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setInput('');
|
||||
} catch {}
|
||||
setBusy(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
async function handleRemove(tagId) {
|
||||
setTags(prev => prev.filter(t => t.id !== tagId)); // optimistic
|
||||
try {
|
||||
await removeTag(photoId, tagId);
|
||||
} catch {
|
||||
// On failure re-fetch would be ideal; for now the optimistic update stays
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{tags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10 }}>
|
||||
{tags.map(t => (
|
||||
<Chip key={t.id} label={t.name} color="neutral" onRemove={() => handleRemove(t.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 5 }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd(e)}
|
||||
placeholder="Add tag…"
|
||||
disabled={busy}
|
||||
maxLength={100}
|
||||
style={iStyle}
|
||||
/>
|
||||
<Btn type="submit" disabled={!input.trim() || busy}>+</Btn>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── shared with CollectionEditor ─────────────────────────────────────────────
|
||||
|
||||
export function Chip({ label, onRemove, color = 'neutral' }) {
|
||||
const colors = {
|
||||
neutral: { bg: '#222', border: '#333', text: '#ccc' },
|
||||
green: { bg: '#1a2a1a', border: '#2a3a2a', text: '#8c8' },
|
||||
};
|
||||
const c = colors[color] ?? colors.neutral;
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
background: c.bg, border: `1px solid ${c.border}`,
|
||||
borderRadius: 4, padding: '3px 7px',
|
||||
fontSize: 12, color: c.text,
|
||||
}}>
|
||||
{label}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
style={{ background: 'none', border: 'none', color: '#555', cursor: 'pointer', fontSize: 15, lineHeight: 1, padding: 0, marginLeft: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const iStyle = {
|
||||
flex: 1,
|
||||
background: '#181818', border: '1px solid #2a2a2a', borderRadius: 4,
|
||||
padding: '5px 8px', color: '#ccc', fontSize: 12, outline: 'none',
|
||||
minWidth: 0,
|
||||
};
|
||||
|
||||
export function Btn({ children, style: overrides, disabled, ...props }) {
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
style={{
|
||||
background: '#222', border: '1px solid #333', borderRadius: 4,
|
||||
color: '#999', fontSize: 16, cursor: 'pointer',
|
||||
width: 30, height: 30, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
...overrides,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user