feat: group videos by year/month like photo library

Same groupByMonth + month-header pattern as Timeline/MonthGroup.
Grid uses auto-fill 200px columns with 16:9 aspect-ratio cells.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-18 11:24:54 -04:00
1 parent 6b4bd88943
commit 770b4ea797
1 file changed
+131 -96
+131 -96
View File
@@ -1,11 +1,33 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect } from 'react';
import { fetchVideos } from '../api/photos.js'; import { fetchVideos } from '../api/photos.js';
// ── Grouping (mirrors Timeline.jsx) ───────────────────────────────────────────
function groupByMonth(videos) {
const groups = [];
let current = null;
for (const v of videos) {
const key = v.date_taken ? v.date_taken.slice(0, 7) : 'undated';
if (!current || current.key !== key) {
current = { key, label: monthLabel(v.date_taken), videos: [] };
groups.push(current);
}
current.videos.push(v);
}
return groups;
}
function monthLabel(d) {
if (!d) return 'Undated';
return new Date(d).toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDuration(s) { function fmtDuration(s) {
if (!s) return null; if (s == null) return null;
const m = Math.floor(s / 60); const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, '0'); return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`;
return `${m}:${sec}`;
} }
function fmtDate(d) { function fmtDate(d) {
@@ -15,16 +37,17 @@ function fmtDate(d) {
function fmtSize(b) { function fmtSize(b) {
if (!b) return null; if (!b) return null;
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`; if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
if (b < 1073741824) return `${(b / 1048576).toFixed(1)} MB`; if (b < 1073741824) return `${(b / 1048576).toFixed(1)} MB`;
return `${(b / 1073741824).toFixed(2)} GB`; return `${(b / 1073741824).toFixed(2)} GB`;
} }
// ── Component ─────────────────────────────────────────────────────────────────
export default function VideosView() { export default function VideosView() {
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);
const videoRef = useRef(null);
useEffect(() => { useEffect(() => {
fetchVideos() fetchVideos()
@@ -32,7 +55,6 @@ export default function VideosView() {
.catch(e => setError(e.message)); .catch(e => setError(e.message));
}, []); }, []);
// Keyboard: Esc to close player
useEffect(() => { useEffect(() => {
if (!playing) return; if (!playing) return;
const onKey = (e) => { if (e.key === 'Escape') setPlaying(null); }; const onKey = (e) => { if (e.key === 'Escape') setPlaying(null); };
@@ -40,113 +62,76 @@ export default function VideosView() {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [playing]); }, [playing]);
if (error) return <p style={{ color: '#f87171', padding: 24, fontSize: 14 }}>{error}</p>; if (error) return <p style={{ color: '#f87171', padding: 24, fontSize: 14 }}>{error}</p>;
if (!videos) return <p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading…</p>; if (!videos) return <p style={{ color: '#777', padding: 24, fontSize: 13 }}>Loading…</p>;
const noVideos = videos.length === 0; if (videos.length === 0) {
return (
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
<div style={{ fontSize: 44, marginBottom: 18, opacity: 0.4 }}>🎬</div>
<p style={{ fontSize: 15, marginBottom: 8, color: '#777' }}>No videos indexed yet.</p>
<p style={{ fontSize: 13 }}>Add a folder with .mp4, .mov, or .m4v files and rescan.</p>
</div>
);
}
const groups = groupByMonth(videos);
return ( return (
<> <>
<div style={{ padding: '24px 20px', maxWidth: 1200, margin: '0 auto' }}> <main style={{ padding: '0 3px' }}>
<div style={{ marginBottom: 20 }}> {groups.map(group => (
<h1 style={{ fontSize: 15, fontWeight: 600, color: '#999', margin: 0 }}> <section key={group.key} style={{ marginBottom: 8 }}>
Videos
{videos.length > 0 && ( {/* Month header — identical style to MonthGroup.jsx */}
<span style={{ fontWeight: 400, color: '#555', marginLeft: 8 }}> <h2 style={{
{videos.length.toLocaleString()} padding: '18px 6px 8px',
fontSize: 13, fontWeight: 600,
color: '#666', letterSpacing: 0.4,
textTransform: 'uppercase', userSelect: 'none',
}}>
{group.label}
<span style={{ marginLeft: 8, color: '#666', fontWeight: 400 }}>
{group.videos.length.toLocaleString()}
</span> </span>
)} </h2>
</h1>
</div>
{noVideos && ( {/* Video grid */}
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}> <div style={{
<div style={{ fontSize: 44, marginBottom: 18, opacity: 0.4 }}>🎬</div> display: 'grid',
<p style={{ fontSize: 15, marginBottom: 8, color: '#777' }}>No videos indexed yet.</p> gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
<p style={{ fontSize: 13 }}>Add a folder containing .mp4, .mov, or .m4v files and rescan.</p> gap: 3,
</div> }}>
)} {group.videos.map(v => (
<VideoCard key={v.id} video={v} onPlay={setPlaying} />
{/* Video grid */} ))}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{videos.map(v => (
<div
key={v.id}
onClick={() => setPlaying(v)}
style={{
width: 220, height: 124, // 16:9
position: 'relative', cursor: 'pointer',
borderRadius: 6, overflow: 'hidden',
background: '#111', flexShrink: 0,
}}
onMouseEnter={e => e.currentTarget.querySelector('.play-ring').style.opacity = '1'}
onMouseLeave={e => e.currentTarget.querySelector('.play-ring').style.opacity = '0.7'}
>
{/* Thumbnail */}
{v.thumb_url
? <img src={v.thumb_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
: <div style={{ width: '100%', height: '100%', background: '#1a1a1a' }} />
}
{/* Play button overlay */}
<div className="play-ring" style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: 0.7, transition: 'opacity 0.15s',
}}>
<div style={{
width: 38, height: 38,
background: 'rgba(0,0,0,0.65)',
borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1.5px solid rgba(255,255,255,0.4)',
}}>
<span style={{ color: '#fff', fontSize: 16, marginLeft: 3, lineHeight: 1 }}>▶</span>
</div>
</div>
{/* Duration badge */}
{v.duration != null && (
<span style={{
position: 'absolute', bottom: 6, right: 6,
background: 'rgba(0,0,0,0.72)', color: '#fff',
fontSize: 11, padding: '2px 5px', borderRadius: 3, lineHeight: 1.4,
}}>
{fmtDuration(v.duration)}
</span>
)}
</div> </div>
))} </section>
</div> ))}
</div> </main>
{/* ── Video player overlay ── */} {/* ── Player overlay ── */}
{playing && ( {playing && (
<div <div
onClick={e => { if (e.target === e.currentTarget) setPlaying(null); }}
style={{ style={{
position: 'fixed', inset: 0, zIndex: 200, position: 'fixed', inset: 0, zIndex: 200,
background: 'rgba(0,0,0,0.95)', background: 'rgba(0,0,0,0.94)',
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', alignItems: 'center', justifyContent: 'center',
}} }}
onClick={e => { if (e.target === e.currentTarget) setPlaying(null); }}
> >
{/* Close */}
<button <button
onClick={() => setPlaying(null)} onClick={() => setPlaying(null)}
title="Close (Esc)"
style={{ style={{
position: 'absolute', top: 16, right: 20, position: 'absolute', top: 16, right: 20,
background: 'transparent', border: 'none', background: 'transparent', border: 'none',
color: '#777', fontSize: 22, cursor: 'pointer', lineHeight: 1, color: '#777', fontSize: 22, cursor: 'pointer', lineHeight: 1,
}} }}
title="Close (Esc)" >✕</button>
>
✕
</button>
{/* Video element */}
<video <video
ref={videoRef}
key={playing.id} key={playing.id}
src={`/api/photos/${playing.id}/video`} src={`/api/photos/${playing.id}/video`}
controls controls
@@ -159,18 +144,68 @@ export default function VideosView() {
}} }}
/> />
{/* Info bar */}
<div style={{ <div style={{
marginTop: 14, display: 'flex', gap: 16, alignItems: 'center', marginTop: 12, display: 'flex', gap: 14, flexWrap: 'wrap',
fontSize: 13, color: '#666', flexWrap: 'wrap', justifyContent: 'center', justifyContent: 'center', fontSize: 13, color: '#555',
}}> }}>
{fmtDate(playing.date_taken) && <span>{fmtDate(playing.date_taken)}</span>} {fmtDate(playing.date_taken) && <span>{fmtDate(playing.date_taken)}</span>}
{playing.duration && <span>{fmtDuration(playing.duration)}</span>} {playing.duration != null && <span>{fmtDuration(playing.duration)}</span>}
{playing.width && playing.height && <span>{playing.width} × {playing.height}</span>} {playing.width && playing.height && <span>{playing.width} × {playing.height}</span>}
{fmtSize(playing.filesize) && <span>{fmtSize(playing.filesize)}</span>} {fmtSize(playing.filesize) && <span>{fmtSize(playing.filesize)}</span>}
</div> </div>
</div> </div>
)} )}
</> </>
); );
} }
// ── VideoCard ─────────────────────────────────────────────────────────────────
function VideoCard({ video: v, onPlay }) {
return (
<div
onClick={() => onPlay(v)}
style={{
aspectRatio: '16 / 9',
position: 'relative', cursor: 'pointer',
borderRadius: 4, overflow: 'hidden',
background: '#111',
}}
onMouseEnter={e => { e.currentTarget.querySelector('.play-ring').style.opacity = '1'; }}
onMouseLeave={e => { e.currentTarget.querySelector('.play-ring').style.opacity = '0.65'; }}
>
{v.thumb_url
? <img src={v.thumb_url} alt="" loading="lazy" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
: <div style={{ width: '100%', height: '100%', background: '#1a1a1a' }} />
}
{/* Play button */}
<div className="play-ring" style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: 0.65, transition: 'opacity 0.15s',
}}>
<div style={{
width: 36, height: 36,
background: 'rgba(0,0,0,0.6)',
borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1.5px solid rgba(255,255,255,0.35)',
}}>
<span style={{ color: '#fff', fontSize: 14, marginLeft: 3, lineHeight: 1 }}>▶</span>
</div>
</div>
{/* Duration badge */}
{v.duration != null && (
<span style={{
position: 'absolute', bottom: 5, right: 5,
background: 'rgba(0,0,0,0.7)', color: '#fff',
fontSize: 11, padding: '2px 5px', borderRadius: 3, lineHeight: 1.4,
}}>
{fmtDuration(v.duration)}
</span>
)}
</div>
);
}