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';
|
||||
|
||||
// ── 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) {
|
||||
@@ -20,11 +42,12 @@ function fmtSize(b) {
|
||||
return `${(b / 1073741824).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function VideosView() {
|
||||
const [videos, setVideos] = useState(null);
|
||||
const [playing, setPlaying] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const videoRef = useRef(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); };
|
||||
@@ -43,110 +65,73 @@ export default function VideosView() {
|
||||
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>;
|
||||
|
||||
const noVideos = videos.length === 0;
|
||||
|
||||
if (videos.length === 0) {
|
||||
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={{ 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 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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
const groups = groupByMonth(videos);
|
||||
|
||||
{/* 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,
|
||||
return (
|
||||
<>
|
||||
<main style={{ padding: '0 3px' }}>
|
||||
{groups.map(group => (
|
||||
<section key={group.key} style={{ marginBottom: 8 }}>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
{/* 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>
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
|
||||
{/* ── Video player overlay ── */}
|
||||
{/* ── Player overlay ── */}
|
||||
{playing && (
|
||||
<div
|
||||
onClick={e => { 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 */}
|
||||
<button
|
||||
onClick={() => setPlaying(null)}
|
||||
title="Close (Esc)"
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 20,
|
||||
background: 'transparent', border: 'none',
|
||||
color: '#777', fontSize: 22, cursor: 'pointer', lineHeight: 1,
|
||||
}}
|
||||
title="Close (Esc)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
>✕</button>
|
||||
|
||||
{/* Video element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
key={playing.id}
|
||||
src={`/api/photos/${playing.id}/video`}
|
||||
controls
|
||||
@@ -159,13 +144,12 @@ export default function VideosView() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Info bar */}
|
||||
<div style={{
|
||||
marginTop: 14, display: 'flex', gap: 16, alignItems: 'center',
|
||||
fontSize: 13, color: '#666', flexWrap: 'wrap', justifyContent: 'center',
|
||||
marginTop: 12, display: 'flex', gap: 14, flexWrap: 'wrap',
|
||||
justifyContent: 'center', fontSize: 13, color: '#555',
|
||||
}}>
|
||||
{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>}
|
||||
{fmtSize(playing.filesize) && <span>{fmtSize(playing.filesize)}</span>}
|
||||
</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