1import { useEffect, useState } from 'react';
2import type { VideoSummary } from '../../shared/types.ts';
3import { navigate } from '../App.tsx';
4import { api } from '../api.ts';
5import { Icon } from '../components/Icon.tsx';
6import { ConfirmDialog, Modal, type ConfirmRequest } from '../components/Modal.tsx';
7import { fmtDate, fmtDuration } from '../lib/format.ts';
9export function Library() {
10 const [videos, setVideos] = useState<VideoSummary[] | null>(null);
11 const [error, setError] = useState<string | null>(null);
12 const [filter, setFilter] = useState('');
13 const [dragId, setDragId] = useState<string | null>(null);
14 const [confirm, setConfirm] = useState<ConfirmRequest | null>(null);
15 const [creating, setCreating] = useState(false);
17 const load = () =>
18 api
19 .videos()
20 .then(setVideos)
21 .catch((e) => setError(e.message));
23 useEffect(() => {
24 document.title = 'Takes';
25 load();
26 }, []);
28 const q = filter.trim().toLowerCase();
29 const shown = (videos ?? []).filter((v) => !q || v.title.toLowerCase().includes(q) || v.description.toLowerCase().includes(q));
31 const onDragOver = (overId: string) => {
32 if (!dragId || dragId === overId || !videos) return;
33 const from = videos.findIndex((v) => v.id === dragId);
34 const to = videos.findIndex((v) => v.id === overId);
35 const next = videos.slice();
36 const [item] = next.splice(from, 1);
37 next.splice(to, 0, item);
38 setVideos(next);
39 };
41 const onDragEnd = () => {
42 setDragId(null);
43 if (videos) api.setOrder(videos.map((v) => v.id)).catch((e) => setError(e.message));
44 };
46 const askDelete = (v: VideoSummary) =>
47 setConfirm({
48 title: 'Delete video?',
49 message: (
50 <>
51 “{v.title}” and all of its takes will be moved to the <code>.trash</code> folder in your library directory.
52 </>
53 ),
54 confirmLabel: 'Delete',
55 danger: true,
56 onConfirm: async () => {
57 try {
58 await api.deleteVideo(v.id);
59 } catch (e) {
60 setError((e as Error).message);
61 }
62 load();
63 },
64 });
66 return (
67 <div className="page">
68 <header className="topbar">
69 <div className="brand">
70 <span className="brand-dot" /> Takes
71 </div>
72 <input className="search" placeholder="Search videos" value={filter} onChange={(e) => setFilter(e.target.value)} />
73 <div className="spacer" />
74 <a className="btn ghost" href="#/settings">
75 <Icon name="settings" /> Settings
76 </a>
77 <button className="btn primary" onClick={() => setCreating(true)}>
78 <Icon name="plus" /> New video
79 </button>
80 </header>
82 {error && (
83 <div className="banner error" onClick={() => setError(null)}>
84 {error}
85 </div>
86 )}
88 <main className="library">
89 {videos && videos.length === 0 && (
90 <div className="empty">
91 <h2>No videos yet</h2>
92 <p className="muted">Create a video, then record yourself, your screen, or both.</p>
93 <button className="btn primary big" onClick={() => setCreating(true)}>
94 <Icon name="plus" /> New video
95 </button>
96 </div>
97 )}
98 <div className="grid">
99 {shown.map((v) => (
100 <div
101 key={v.id}
102 className={`card${dragId === v.id ? ' dragging' : ''}`}
103 draggable={!q}
104 onDragStart={(e) => {
105 e.dataTransfer.effectAllowed = 'move';
106 setDragId(v.id);
107 }}
108 onDragOver={(e) => {
109 e.preventDefault();
110 onDragOver(v.id);
111 }}
112 onDragEnd={onDragEnd}
113 onClick={() => navigate(`video/${v.id}`)}
114 >
115 <div className="thumb">
116 {v.thumbnailUrl ? <img src={v.thumbnailUrl} alt="" draggable={false} /> : <div className="thumb-empty"><Icon name="film" size={32} /></div>}
117 {v.duration > 0 && <span className="duration">{fmtDuration(v.duration)}</span>}
118 <span className="kinds">
119 {v.hasCamera && <Icon name="camera" size={14} />}
120 {v.hasScreen && <Icon name="screen" size={14} />}
121 </span>
122 </div>
123 <div className="card-body">
124 <div className="card-title">{v.title}</div>
125 {v.description && <div className="card-desc">{v.description}</div>}
126 <div className="card-meta">
127 <span>{fmtDate(v.createdAt)}</span>
128 <span>
129 {v.takeCount} take{v.takeCount === 1 ? '' : 's'}
130 </span>
131 {v.exportState === 'current' && <span className="tag ok">exported</span>}
132 {v.exportState === 'stale' && <span className="tag warn">export outdated</span>}
133 </div>
134 </div>
135 <div className="card-actions" onClick={(e) => e.stopPropagation()}>
136 <button className="icon-btn" title="Record a take" onClick={() => navigate(`video/${v.id}/record`)}>
137 <Icon name="record" />
138 </button>
139 <button className="icon-btn danger" title="Delete" onClick={() => askDelete(v)}>
140 <Icon name="trash" />
141 </button>
142 </div>
143 {!q && (
144 <div className="grip" title="Drag to reorder">
145 <Icon name="grip" />
146 </div>
147 )}
148 </div>
149 ))}
150 </div>
151 </main>
153 {creating && <NewVideoDialog onClose={() => setCreating(false)} />}
154 {confirm && <ConfirmDialog req={confirm} onClose={() => setConfirm(null)} />}
155 </div>
156 );
157}
159function NewVideoDialog({ onClose }: { onClose: () => void }) {
160 const [title, setTitle] = useState('');
161 const [description, setDescription] = useState('');
162 const [notes, setNotes] = useState('');
163 const [busy, setBusy] = useState(false);
164 const [error, setError] = useState<string | null>(null);
166 const create = async (record: boolean) => {
167 setBusy(true);
168 try {
169 const v = await api.createVideo({ title, description, notes });
170 navigate(record ? `video/${v.id}/record` : `video/${v.id}`);
171 } catch (e) {
172 setError((e as Error).message);
173 setBusy(false);
174 }
175 };
177 return (
178 <Modal
179 title="New video"
180 onClose={onClose}
181 wide
182 footer={
183 <>
184 <button className="btn" onClick={onClose}>
185 Cancel
186 </button>
187 <button className="btn" disabled={busy} onClick={() => create(false)}>
188 Create
189 </button>
190 <button className="btn primary" disabled={busy} onClick={() => create(true)}>
191 <Icon name="record" /> Create and record
192 </button>
193 </>
194 }
195 >
196 <label className="field">
197 <span>Title</span>
198 <input autoFocus value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. How the solver handles boundary conditions" />
199 </label>
200 <label className="field">
201 <span>Description</span>
202 <textarea rows={3} value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What this video explains or demonstrates" />
203 </label>
204 <label className="field">
205 <span>Notes (shown on the teleprompter while recording)</span>
206 <textarea rows={8} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={'- Point one\n- Point two\n- Demo: ...'} />
207 </label>
208 {error && <div className="banner error">{error}</div>}
209 </Modal>
210 );
211}