diff --git a/client/src/App.jsx b/client/src/App.jsx index 133fb81..a47dca1 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -8,7 +8,8 @@ import DuplicatesView from './components/DuplicatesView.jsx'; import SettingsPanel from './components/SettingsPanel.jsx'; import { lazy, Suspense } from 'react'; -const MapView = lazy(() => import('./components/MapView.jsx')); +const MapView = lazy(() => import('./components/MapView.jsx')); +const MemoriesView = lazy(() => import('./components/MemoriesView.jsx')); const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' }; const SIDEBAR_W = 210; @@ -125,6 +126,7 @@ export default function App() { )} + setView('memories')}>Memories setView('map')}>Map setView('duplicates')}> Duplicates @@ -214,6 +216,13 @@ export default function App() { )} + {/* Memories */} + {view === 'memories' && ( + Loading…

}> + +
+ )} + {/* Map */} {view === 'map' && ( Loading map…

}> diff --git a/client/src/api/photos.js b/client/src/api/photos.js index 55eb710..4187fbc 100644 --- a/client/src/api/photos.js +++ b/client/src/api/photos.js @@ -109,6 +109,13 @@ export function triggerSync(source) { return api(`/api/sync/${source}`, { method: 'POST' }); } +// Memories +export function fetchMemories() { + const d = new Date(); + const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + return api(`/api/memories/today?date=${date}`); +} + // Duplicates export function fetchDuplicates() { return api('/api/duplicates'); diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx index d6291e2..481c2eb 100644 --- a/client/src/components/MapView.jsx +++ b/client/src/components/MapView.jsx @@ -81,7 +81,8 @@ export default function MapView({ onOpenPhoto }) { alt="" onClick={() => onOpenPhoto(p)} style={{ - width: '100%', maxWidth: 200, display: 'block', + width: 200, height: 150, + objectFit: 'cover', display: 'block', cursor: 'pointer', borderRadius: 3, }} /> diff --git a/client/src/components/MemoriesView.jsx b/client/src/components/MemoriesView.jsx new file mode 100644 index 0000000..951d3f1 --- /dev/null +++ b/client/src/components/MemoriesView.jsx @@ -0,0 +1,418 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { fetchMemories } from '../api/photos.js'; + +// ── Music ────────────────────────────────────────────────────────────────────── + +function createAmbientMusic(ctx) { + const now = ctx.currentTime; + + const master = ctx.createGain(); + master.gain.setValueAtTime(0, now); + master.gain.linearRampToValueAtTime(0.38, now + 4); + master.connect(ctx.destination); + + // Reverb convolver (4-second synthetic impulse) + const convolver = ctx.createConvolver(); + const sr = ctx.sampleRate; + const impulse = ctx.createBuffer(2, sr * 4, sr); + for (let ch = 0; ch < 2; ch++) { + const d = impulse.getChannelData(ch); + for (let i = 0; i < d.length; i++) { + d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 1.6); + } + } + convolver.buffer = impulse; + + const wet = ctx.createGain(); wet.gain.value = 0.65; + const dry = ctx.createGain(); dry.gain.value = 0.35; + convolver.connect(wet); wet.connect(master); + dry.connect(master); + + // Warmth filter + const lpf = ctx.createBiquadFilter(); + lpf.type = 'lowpass'; + lpf.frequency.value = 1900; + lpf.Q.value = 0.4; + lpf.connect(convolver); + lpf.connect(dry); + + // Pad: A minor chord — A2, E3, A3, C4, E4, G4 + [110, 164.81, 220, 261.63, 329.63, 392].forEach((freq, i) => { + const osc = ctx.createOscillator(); + const env = ctx.createGain(); + osc.type = i < 2 ? 'sine' : 'triangle'; + osc.frequency.value = freq + (Math.random() - 0.5) * 0.25; + env.gain.setValueAtTime(0, now); + env.gain.linearRampToValueAtTime(0.052 / (1 + i * 0.28), now + 1.5 + i * 0.65); + osc.connect(env); + env.connect(lpf); + + // Slow vibrato for shimmer + const lfo = ctx.createOscillator(); + const lfoG = ctx.createGain(); + lfo.frequency.value = 0.07 + i * 0.02; + lfoG.gain.value = freq * 0.0018; + lfo.connect(lfoG); lfoG.connect(osc.frequency); + lfo.start(now); osc.start(now); + }); + + // Melody: A pentatonic minor (A3, C4, D4, E4, G4, A4) + const scale = [329.63, 293.66, 261.63, 220, 261.63, 293.66, 329.63, 392, 440, 392, 329.63, 261.63, 293.66, 329.63]; + let cancelled = false; + let noteTime = now + 5.5; + let noteIdx = 0; + + function playNote() { + if (cancelled) return; + const freq = scale[noteIdx % scale.length]; + noteIdx++; + const osc = ctx.createOscillator(); + const env = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + const dur = 1.9; + env.gain.setValueAtTime(0, noteTime); + env.gain.linearRampToValueAtTime(0.1, noteTime + 0.06); + env.gain.setValueAtTime(0.1, noteTime + dur * 0.45); + env.gain.exponentialRampToValueAtTime(0.001, noteTime + dur); + osc.connect(env); env.connect(convolver); + osc.start(noteTime); osc.stop(noteTime + dur); + noteTime += 2.3; + const msUntil = (noteTime - ctx.currentTime - 0.6) * 1000; + setTimeout(playNote, Math.max(0, msUntil)); + } + playNote(); + + return { + master, + stop() { + cancelled = true; + master.gain.linearRampToValueAtTime(0, ctx.currentTime + 2.5); + }, + }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function groupByYear(photos) { + const map = {}; + for (const p of photos) { + const yr = new Date(p.date_taken).getFullYear(); + (map[yr] ??= []).push(p); + } + return Object.entries(map) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([year, photos]) => ({ year: Number(year), photos })); +} + +function yearsAgo(year) { + const n = new Date().getFullYear() - year; + if (n === 0) return 'This year'; + return n === 1 ? '1 year ago' : `${n} years ago`; +} + +function localDate() { + const d = new Date(); + return d.toLocaleDateString('en-US', { month: 'long', day: 'numeric' }); +} + +// ── Component ────────────────────────────────────────────────────────────────── + +export default function MemoriesView() { + const [groups, setGroups] = useState(null); + const [error, setError] = useState(null); + const [slideshow, setSlideshow] = useState(false); + const [ssIndex, setSsIndex] = useState(0); + const [opacity, setOpacity] = useState(1); + const [muted, setMuted] = useState(false); + + const audioCtxRef = useRef(null); + const musicRef = useRef(null); + const timerRef = useRef(null); + + useEffect(() => { + fetchMemories() + .then(d => setGroups(groupByYear(d.photos))) + .catch(e => setError(e.message)); + }, []); + + useEffect(() => () => { stopMusic(); clearInterval(timerRef.current); }, []); + + const allPhotos = groups ? groups.flatMap(g => g.photos) : []; + + // ── Music ── + + function startMusic() { + if (audioCtxRef.current) return; + const ctx = new (window.AudioContext || window.webkitAudioContext)(); + audioCtxRef.current = ctx; + musicRef.current = createAmbientMusic(ctx); + } + + function stopMusic() { + musicRef.current?.stop(); + const ctx = audioCtxRef.current; + if (ctx) { + setTimeout(() => { try { ctx.close(); } catch {} }, 3000); + audioCtxRef.current = null; + musicRef.current = null; + } + } + + function toggleMute() { + if (!musicRef.current || !audioCtxRef.current) return; + const next = !muted; + setMuted(next); + const g = musicRef.current.master.gain; + g.linearRampToValueAtTime(next ? 0 : 0.38, audioCtxRef.current.currentTime + 0.6); + } + + // ── Slideshow ── + + const advance = useCallback((dir) => { + setOpacity(0); + setTimeout(() => { + setSsIndex(i => (i + dir + allPhotos.length) % allPhotos.length); + setOpacity(1); + }, 330); + }, [allPhotos.length]); + + function startSlideshow(startIndex) { + setSsIndex(startIndex); + setOpacity(1); + setSlideshow(true); + startMusic(); + } + + function stopSlideshow() { + clearInterval(timerRef.current); + setSlideshow(false); + stopMusic(); + setMuted(false); + } + + // Auto-advance + useEffect(() => { + if (!slideshow) return; + timerRef.current = setInterval(() => advance(1), 5000); + return () => clearInterval(timerRef.current); + }, [slideshow, advance]); + + // Keyboard + useEffect(() => { + if (!slideshow) return; + function onKey(e) { + if (e.key === 'ArrowRight') advance(1); + else if (e.key === 'ArrowLeft') advance(-1); + else if (e.key === 'Escape') stopSlideshow(); + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [slideshow, advance]); + + // ── Render ── + + if (error) return

{error}

; + if (!groups) return

Loading…

; + + const noPhotos = allPhotos.length === 0; + const dateLabel = localDate(); + + return ( + <> + {/* ── Main view ── */} +
+ + {/* Header row */} +
+
+

+ {dateLabel} +

+

+ {noPhotos + ? 'No photos from this day in your library yet' + : `${allPhotos.length} photo${allPhotos.length !== 1 ? 's' : ''} across ${groups.length} year${groups.length !== 1 ? 's' : ''}`} +

+
+ {!noPhotos && ( + + )} +
+ + {/* Empty state */} + {noPhotos && ( +
+
📷
+

Nothing from {dateLabel} yet.

+

Keep shooting — they'll appear here on this date next year.

+
+ )} + + {/* Year sections */} + {groups.map(({ year, photos: yPhotos }) => ( +
+
+ {year} + {yearsAgo(year)} + · {yPhotos.length} photo{yPhotos.length !== 1 ? 's' : ''} +
+
+ {yPhotos.map(photo => { + const idx = allPhotos.indexOf(photo); + return ( +
startSlideshow(idx)} + style={{ + width: 168, height: 126, borderRadius: 5, + overflow: 'hidden', cursor: 'pointer', + flexShrink: 0, background: '#111', + position: 'relative', + }} + onMouseEnter={e => { e.currentTarget.style.opacity = '0.78'; }} + onMouseLeave={e => { e.currentTarget.style.opacity = '1'; }} + > + {photo.thumb_url + ? + :
▪
+ } +
+ ); + })} +
+
+ ))} +
+ + {/* ── Slideshow overlay ── */} + {slideshow && allPhotos.length > 0 && (() => { + const photo = allPhotos[ssIndex]; + const year = new Date(photo.date_taken).getFullYear(); + return ( +
+ + {/* Photo area */} +
+ + + {/* Year badge */} +
+
{year}
+
{yearsAgo(year)}
+
+ + {/* Prev */} + + + {/* Next */} + +
+ + {/* Bottom bar */} +
+ {/* Progress dots */} +
+ {allPhotos.map((_, i) => ( +
{ setOpacity(0); setTimeout(() => { setSsIndex(i); setOpacity(1); }, 330); }} + style={{ + width: i === ssIndex ? 20 : 6, height: 6, borderRadius: 3, + background: i === ssIndex ? '#4d9eff' : '#2a2a2a', + cursor: 'pointer', + transition: 'width 0.25s, background 0.25s', + flexShrink: 0, + }} + /> + ))} +
+ + {/* Counter */} + + {ssIndex + 1} / {allPhotos.length} + + + {/* Mute */} + + + {/* Exit */} + +
+
+ ); + })()} + + ); +} + +// ── Styles ───────────────────────────────────────────────────────────────────── + +function navBtn(side) { + return { + position: 'absolute', top: '50%', transform: 'translateY(-50%)', + [side]: 14, + background: 'rgba(0,0,0,0.45)', + border: 'none', borderRadius: 8, + color: '#fff', fontSize: 36, lineHeight: 1, + width: 44, height: 68, + cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', + opacity: 0.5, transition: 'opacity 0.18s', + }; +} + +const ctrlBtn = { + background: 'transparent', + border: '1px solid #222', borderRadius: 6, + color: '#888', cursor: 'pointer', + padding: '5px 11px', fontSize: 14, + flexShrink: 0, +}; diff --git a/server/index.js b/server/index.js index 3cb944a..eced631 100644 --- a/server/index.js +++ b/server/index.js @@ -501,6 +501,22 @@ app.post('/api/admin/clear-dismissals', (_req, res) => { res.sendStatus(204); }); +// ── Memories (photos on this day) ───────────────────────────────────────────── + +app.get('/api/memories/today', (req, res) => { + // Client sends its local date as YYYY-MM-DD so Docker's UTC clock doesn't cause off-by-one + const date = req.query.date ?? new Date().toISOString().slice(0, 10); + const monthDay = date.slice(5); // "MM-DD" + const rows = db.raw.prepare(` + SELECT ${SELECT_COLS} + FROM photos p + WHERE p.date_taken IS NOT NULL + AND strftime('%m-%d', p.date_taken) = ? + ORDER BY p.date_taken ASC + `).all(monthDay); + res.json({ photos: rows.map(mapPhoto) }); +}); + // ── Filesystem browser ──────────────────────────────────────────────────────── app.get('/api/fs/browse', (req, res) => {