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:
1 parent
6b4bd88943
commit
770b4ea797
1 file changed
+119
-84
@@ -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) {
|
||||||
@@ -20,11 +42,12 @@ function fmtSize(b) {
|
|||||||
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); };
|
||||||
@@ -43,110 +65,73 @@ export default function VideosView() {
|
|||||||
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 (
|
return (
|
||||||
<>
|
|
||||||
<div style={{ padding: '24px 20px', maxWidth: 1200, margin: '0 auto' }}>
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<h1 style={{ fontSize: 15, fontWeight: 600, color: '#999', margin: 0 }}>
|
|
||||||
Videos
|
|
||||||
{videos.length > 0 && (
|
|
||||||
<span style={{ fontWeight: 400, color: '#555', marginLeft: 8 }}>
|
|
||||||
{videos.length.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{noVideos && (
|
|
||||||
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
|
<div style={{ textAlign: 'center', paddingTop: 60, color: '#555' }}>
|
||||||
<div style={{ fontSize: 44, marginBottom: 18, opacity: 0.4 }}>🎬</div>
|
<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: 15, marginBottom: 8, color: '#777' }}>No videos indexed yet.</p>
|
||||||
<p style={{ fontSize: 13 }}>Add a folder containing .mp4, .mov, or .m4v files and rescan.</p>
|
<p style={{ fontSize: 13 }}>Add a folder with .mp4, .mov, or .m4v files and rescan.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
|
|
||||||
{/* 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 */}
|
const groups = groupByMonth(videos);
|
||||||
<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 */}
|
return (
|
||||||
{v.duration != null && (
|
<>
|
||||||
<span style={{
|
<main style={{ padding: '0 3px' }}>
|
||||||
position: 'absolute', bottom: 6, right: 6,
|
{groups.map(group => (
|
||||||
background: 'rgba(0,0,0,0.72)', color: '#fff',
|
<section key={group.key} style={{ marginBottom: 8 }}>
|
||||||
fontSize: 11, padding: '2px 5px', borderRadius: 3, lineHeight: 1.4,
|
|
||||||
|
{/* Month header — identical style to MonthGroup.jsx */}
|
||||||
|
<h2 style={{
|
||||||
|
padding: '18px 6px 8px',
|
||||||
|
fontSize: 13, fontWeight: 600,
|
||||||
|
color: '#666', letterSpacing: 0.4,
|
||||||
|
textTransform: 'uppercase', userSelect: 'none',
|
||||||
}}>
|
}}>
|
||||||
{fmtDuration(v.duration)}
|
{group.label}
|
||||||
|
<span style={{ marginLeft: 8, color: '#666', fontWeight: 400 }}>
|
||||||
|
{group.videos.length.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</h2>
|
||||||
</div>
|
|
||||||
|
{/* Video grid */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
|
gap: 3,
|
||||||
|
}}>
|
||||||
|
{group.videos.map(v => (
|
||||||
|
<VideoCard key={v.id} video={v} onPlay={setPlaying} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
))}
|
||||||
|
</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,13 +144,12 @@ 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>
|
||||||
@@ -174,3 +158,54 @@ export default function VideosView() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user