feat: video sidebar with year/city filters like library view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 11:38:44 -04:00
1 parent 770b4ea797
commit eeab631ac9
5 files changed
+73 -26

No files matched your search

+33 -13
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { fetchPhotos, fetchLibraryStats } from './api/photos.js'; import { fetchPhotos, fetchLibraryStats, fetchVideoStats } from './api/photos.js';
import Timeline from './components/Timeline.jsx'; import Timeline from './components/Timeline.jsx';
import Lightbox from './components/Lightbox.jsx'; import Lightbox from './components/Lightbox.jsx';
import FilterSidebar from './components/FilterSidebar.jsx'; import FilterSidebar from './components/FilterSidebar.jsx';
@@ -12,7 +12,8 @@ const MapView = lazy(() => import('./components/MapView.jsx'));
const MemoriesView = lazy(() => import('./components/MemoriesView.jsx')); const MemoriesView = lazy(() => import('./components/MemoriesView.jsx'));
const VideosView = lazy(() => import('./components/VideosView.jsx')); const VideosView = lazy(() => import('./components/VideosView.jsx'));
const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' }; const EMPTY_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
const EMPTY_VIDEO_FILTERS = { q: '', from: '', to: '', tagId: '', collectionId: '', starred: '', city: '' };
const SIDEBAR_W = 210; const SIDEBAR_W = 210;
export default function App() { export default function App() {
@@ -24,8 +25,10 @@ export default function App() {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [dupeCount, setDupeCount] = useState(0); const [dupeCount, setDupeCount] = useState(0);
const [filters, setFilters] = useState(EMPTY_FILTERS); const [filters, setFilters] = useState(EMPTY_FILTERS);
const [stats, setStats] = useState(null); const [stats, setStats] = useState(null);
const [videoFilters, setVideoFilters] = useState(EMPTY_VIDEO_FILTERS);
const [videoStats, setVideoStats] = useState(null);
const [lbPhotos, setLbPhotos] = useState(null); const [lbPhotos, setLbPhotos] = useState(null);
const [lbIndex, setLbIndex] = useState(null); const [lbIndex, setLbIndex] = useState(null);
@@ -40,6 +43,12 @@ export default function App() {
useEffect(() => { loadStats(); }, []); useEffect(() => { loadStats(); }, []);
useEffect(() => {
if (view === 'videos') {
fetchVideoStats().then(setVideoStats).catch(() => {});
}
}, [view]);
// Reset + reload when filters change (debounce text search) // Reset + reload when filters change (debounce text search)
useEffect(() => { useEffect(() => {
const delay = filters.q ? 300 : 0; const delay = filters.q ? 300 : 0;
@@ -167,20 +176,31 @@ export default function App() {
{/* ── Body (sidebar + content) ── */} {/* ── Body (sidebar + content) ── */}
<div style={{ display: 'flex', alignItems: 'flex-start' }}> <div style={{ display: 'flex', alignItems: 'flex-start' }}>
{/* Sidebar — only in library view */} {/* Sidebar — library and videos views */}
{view === 'library' && ( {(view === 'library' || view === 'videos') && (
<aside style={{ <aside style={{
position: 'sticky', top: 52, position: 'sticky', top: 52,
width: SIDEBAR_W, flexShrink: 0, width: SIDEBAR_W, flexShrink: 0,
height: 'calc(100vh - 52px)', overflowY: 'auto', height: 'calc(100vh - 52px)', overflowY: 'auto',
borderRight: '1px solid #141414', background: '#0a0a0a', borderRight: '1px solid #141414', background: '#0a0a0a',
}}> }}>
<FilterSidebar {view === 'library' && (
filters={filters} <FilterSidebar
onChange={setFilters} filters={filters}
stats={stats} onChange={setFilters}
total={total} stats={stats}
/> total={total}
/>
)}
{view === 'videos' && (
<FilterSidebar
filters={videoFilters}
onChange={setVideoFilters}
stats={videoStats}
total={videoStats?.total}
allLabel="All Videos"
/>
)}
</aside> </aside>
)} )}
@@ -221,7 +241,7 @@ export default function App() {
{/* Videos */} {/* Videos */}
{view === 'videos' && ( {view === 'videos' && (
<Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}> <Suspense fallback={<p style={{ padding: 24, color: '#777', fontSize: 13 }}>Loading…</p>}>
<VideosView /> <VideosView filters={videoFilters} />
</Suspense> </Suspense>
)} )}
+11 -2
View File
@@ -110,8 +110,17 @@ export function triggerSync(source) {
} }
// Videos // Videos
export function fetchVideos() { export function fetchVideoStats() {
return api('/api/videos'); return api('/api/videos/stats');
}
export function fetchVideos({ from, to, city } = {}) {
const p = new URLSearchParams();
if (from) p.set('from', from);
if (to) p.set('to', to);
if (city) p.set('city', city);
const qs = p.toString();
return api(`/api/videos${qs ? '?' + qs : ''}`);
} }
// Memories // Memories
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
const MAX_DEFAULT = 5; const MAX_DEFAULT = 5;
export default function FilterSidebar({ filters, onChange, stats, total }) { export default function FilterSidebar({ filters, onChange, stats, total, allLabel = 'All Photos' }) {
const [expanded, setExpanded] = useState({}); const [expanded, setExpanded] = useState({});
function toggle(section) { function toggle(section) {
@@ -55,7 +55,7 @@ export default function FilterSidebar({ filters, onChange, stats, total }) {
active={isAll && !filters.q} active={isAll && !filters.q}
onClick={selectAll} onClick={selectAll}
icon="⊞" icon="⊞"
label="All Photos" label={allLabel}
count={stats?.total ?? total} count={stats?.total ?? total}
/> />
+5 -3
View File
@@ -44,16 +44,18 @@ function fmtSize(b) {
// ── Component ───────────────────────────────────────────────────────────────── // ── Component ─────────────────────────────────────────────────────────────────
export default function VideosView() { export default function VideosView({ filters = {} }) {
const [videos, setVideos] = useState(null); const [videos, setVideos] = useState(null);
const [playing, setPlaying] = useState(null); const [playing, setPlaying] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
fetchVideos() setVideos(null);
setError(null);
fetchVideos({ from: filters.from, to: filters.to, city: filters.city })
.then(d => setVideos(d.videos)) .then(d => setVideos(d.videos))
.catch(e => setError(e.message)); .catch(e => setError(e.message));
}, []); }, [filters.from, filters.to, filters.city]);
useEffect(() => { useEffect(() => {
if (!playing) return; if (!playing) return;
+22 -6
View File
@@ -515,12 +515,28 @@ app.post('/api/admin/clear-dismissals', (_req, res) => {
// ── Videos ──────────────────────────────────────────────────────────────────── // ── Videos ────────────────────────────────────────────────────────────────────
app.get('/api/videos', (_req, res) => { app.get('/api/videos/stats', (_req, res) => {
const rows = db.raw.prepare(` const total = db.raw.prepare("SELECT COUNT(*) AS n FROM photos WHERE media_type = 'video'").get().n;
SELECT ${SELECT_COLS} FROM photos p const years = db.raw.prepare(
WHERE p.media_type = 'video' "SELECT strftime('%Y', date_taken) AS year, COUNT(*) AS count FROM photos WHERE media_type = 'video' AND date_taken IS NOT NULL GROUP BY year ORDER BY year DESC"
ORDER BY p.date_taken DESC, p.id DESC ).all();
`).all(); const cities = db.raw.prepare(
"SELECT place_city AS city, place_country AS country, COUNT(*) AS count FROM photos WHERE media_type = 'video' AND place_city IS NOT NULL GROUP BY place_city ORDER BY count DESC LIMIT 100"
).all();
res.json({ total, years, cities, starred: 0, tags: [], collections: [] });
});
app.get('/api/videos', (req, res) => {
const { from, to, city } = req.query;
const conditions = ["p.media_type = 'video'"];
const params = [];
if (from) { conditions.push('p.date_taken >= ?'); params.push(from); }
if (to) { conditions.push('p.date_taken <= ?'); params.push(to); }
if (city) { conditions.push('p.place_city = ?'); params.push(city); }
const where = 'WHERE ' + conditions.join(' AND ');
const rows = db.raw.prepare(
`SELECT ${SELECT_COLS} FROM photos p ${where} ORDER BY p.date_taken DESC, p.id DESC`
).all(...params);
res.json({ videos: rows.map(mapPhoto), total: rows.length }); res.json({ videos: rows.map(mapPhoto), total: rows.length });
}); });