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:
1 parent
770b4ea797
commit
eeab631ac9
5 files changed
+73
-26
No files matched your search
+33
-13
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user