From 770b4ea797e9d3cc447a3bdb3de9246f1c0663e0 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:24:54 -0400 Subject: [PATCH] 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 --- client/src/components/VideosView.jsx | 227 ++++++++++++++++----------- 1 file changed, 131 insertions(+), 96 deletions(-) diff --git a/client/src/components/VideosView.jsx b/client/src/components/VideosView.jsx index 33ca18a..4a736de 100644 --- a/client/src/components/VideosView.jsx +++ b/client/src/components/VideosView.jsx @@ -1,11 +1,33 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; 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) { - if (!s) return null; + if (s == null) return null; const m = Math.floor(s / 60); - const sec = Math.floor(s % 60).toString().padStart(2, '0'); - return `${m}:${sec}`; + return `${m}:${Math.floor(s % 60).toString().padStart(2, '0')}`; } function fmtDate(d) { @@ -15,16 +37,17 @@ function fmtDate(d) { function fmtSize(b) { 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`; return `${(b / 1073741824).toFixed(2)} GB`; } +// ── Component ───────────────────────────────────────────────────────────────── + export default function VideosView() { - const [videos, setVideos] = useState(null); + const [videos, setVideos] = useState(null); const [playing, setPlaying] = useState(null); - const [error, setError] = useState(null); - const videoRef = useRef(null); + const [error, setError] = useState(null); useEffect(() => { fetchVideos() @@ -32,7 +55,6 @@ export default function VideosView() { .catch(e => setError(e.message)); }, []); - // Keyboard: Esc to close player useEffect(() => { if (!playing) return; const onKey = (e) => { if (e.key === 'Escape') setPlaying(null); }; @@ -40,113 +62,76 @@ export default function VideosView() { return () => window.removeEventListener('keydown', onKey); }, [playing]); - if (error) return

{error}

; - if (!videos) return

Loading…

; + if (error) return

{error}

; + if (!videos) return

Loading…

; - const noVideos = videos.length === 0; + if (videos.length === 0) { + return ( +
+
🎬
+

No videos indexed yet.

+

Add a folder with .mp4, .mov, or .m4v files and rescan.

+
+ ); + } + + const groups = groupByMonth(videos); return ( <> -
-
-

- Videos - {videos.length > 0 && ( - - {videos.length.toLocaleString()} +
+ {groups.map(group => ( +
+ + {/* Month header — identical style to MonthGroup.jsx */} +

+ {group.label} + + {group.videos.length.toLocaleString()} - )} -

-

+ - {noVideos && ( -
-
🎬
-

No videos indexed yet.

-

Add a folder containing .mp4, .mov, or .m4v files and rescan.

-
- )} - - {/* Video grid */} -
- {videos.map(v => ( -
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 - ? - :
- } - - {/* Play button overlay */} -
-
- ▶ -
-
- - {/* Duration badge */} - {v.duration != null && ( - - {fmtDuration(v.duration)} - - )} + {/* Video grid */} +
+ {group.videos.map(v => ( + + ))}
- ))} -
-
+ + ))} + - {/* ── Video player overlay ── */} + {/* ── Player overlay ── */} {playing && (
{ if (e.target === e.currentTarget) setPlaying(null); }} style={{ position: 'fixed', inset: 0, zIndex: 200, - background: 'rgba(0,0,0,0.95)', + background: 'rgba(0,0,0,0.94)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', }} - onClick={e => { if (e.target === e.currentTarget) setPlaying(null); }} > - {/* Close */} + >✕ - {/* Video element */}
)} ); } + +// ── VideoCard ───────────────────────────────────────────────────────────────── + +function VideoCard({ video: v, onPlay }) { + return ( +
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 + ? + :
+ } + + {/* Play button */} +
+
+ ▶ +
+
+ + {/* Duration badge */} + {v.duration != null && ( + + {fmtDuration(v.duration)} + + )} +
+ ); +}