@open-slide/core 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-DHiRlpjn.js → build-DC3FTpWO.js} +2 -1
- package/dist/cli/bin.js +43 -4
- package/dist/{config-LZM903FE.js → config-Cuw0mC5h.js} +592 -63
- package/dist/design-BUML7uvZ.js +35 -0
- package/dist/{dev-B3JzCYn7.js → dev-BuWsdYvn.js} +2 -1
- package/dist/index.d.ts +55 -4
- package/dist/index.js +110 -1
- package/dist/{preview-UikovHEt.js → preview-CIcG-lP3.js} +2 -1
- package/dist/sync-3oqN1WyK.js +139 -0
- package/dist/sync-B4eLo2H6.js +3 -0
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +2 -1
- package/package.json +2 -1
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +81 -0
- package/skills/create-theme/SKILL.md +194 -0
- package/skills/slide-authoring/SKILL.md +288 -0
- package/src/app/{App.tsx → app.tsx} +8 -6
- package/src/app/components/{AssetView.tsx → asset-view.tsx} +41 -33
- package/src/app/components/{ClickNavZones.tsx → click-nav-zones.tsx} +1 -1
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +121 -0
- package/src/app/components/inspector/{CommentWidget.tsx → comment-widget.tsx} +1 -1
- package/src/app/components/inspector/{InspectOverlay.tsx → inspect-overlay.tsx} +1 -1
- package/src/app/components/inspector/{InspectorPanel.tsx → inspector-panel.tsx} +164 -212
- package/src/app/components/inspector/{InspectorProvider.tsx → inspector-provider.tsx} +186 -18
- package/src/app/components/inspector/save-bar.tsx +47 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +78 -0
- package/src/app/components/panel/save-card.tsx +139 -0
- package/src/app/components/pdf-progress-toast.tsx +25 -0
- package/src/app/components/player.tsx +341 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +204 -0
- package/src/app/components/present/help-overlay.tsx +56 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +40 -0
- package/src/app/components/present/overview-grid.tsx +184 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +44 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +71 -0
- package/src/app/components/present/use-touch-swipe.ts +63 -0
- package/src/app/components/sidebar/{FolderItem.tsx → folder-item.tsx} +62 -27
- package/src/app/components/sidebar/{IconPicker.tsx → icon-picker.tsx} +13 -10
- package/src/app/components/sidebar/{Sidebar.tsx → sidebar.tsx} +40 -34
- package/src/app/components/{SlideCanvas.tsx → slide-canvas.tsx} +35 -10
- package/src/app/components/style-panel/design-provider.tsx +139 -0
- package/src/app/components/style-panel/style-panel.tsx +326 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +57 -0
- package/src/app/components/thumbnail-rail.tsx +151 -0
- package/src/app/components/ui/button.tsx +51 -19
- package/src/app/components/ui/card.tsx +1 -1
- package/src/app/components/ui/dialog.tsx +25 -9
- package/src/app/components/ui/dropdown-menu.tsx +29 -12
- package/src/app/components/ui/input.tsx +13 -9
- package/src/app/components/ui/popover.tsx +5 -2
- package/src/app/components/ui/progress.tsx +2 -2
- package/src/app/components/ui/select.tsx +11 -5
- package/src/app/components/ui/separator.tsx +1 -1
- package/src/app/components/ui/slider.tsx +4 -4
- package/src/app/components/ui/sonner.tsx +11 -1
- package/src/app/components/ui/tabs.tsx +6 -6
- package/src/app/components/ui/textarea.tsx +11 -7
- package/src/app/components/ui/toggle-group.tsx +2 -2
- package/src/app/components/ui/toggle.tsx +6 -6
- package/src/app/components/ui/tooltip.tsx +5 -2
- package/src/app/lib/design.ts +64 -0
- package/src/app/lib/export-html.ts +10 -1
- package/src/app/lib/export-pdf.ts +7 -0
- package/src/app/lib/folders.ts +1 -1
- package/src/app/lib/inspector/{useEditor.ts → use-editor.ts} +2 -1
- package/src/app/lib/sdk.ts +5 -0
- package/src/app/lib/slides.ts +1 -1
- package/src/app/lib/utils.ts +1 -1
- package/src/app/main.tsx +5 -2
- package/src/app/routes/{Home.tsx → home.tsx} +266 -97
- package/src/app/routes/presenter.tsx +400 -0
- package/src/app/routes/slide.tsx +519 -0
- package/src/app/styles.css +338 -67
- package/src/app/components/PdfProgressToast.tsx +0 -23
- package/src/app/components/Player.tsx +0 -100
- package/src/app/components/ThumbnailRail.tsx +0 -68
- package/src/app/components/inspector/SaveBar.tsx +0 -77
- package/src/app/routes/Slide.tsx +0 -478
- /package/dist/{config-SXL5qIl6.d.ts → config-DweCbRkQ.d.ts} +0 -0
- /package/src/app/lib/inspector/{useComments.ts → use-comments.ts} +0 -0
- /package/src/app/lib/{useWheelPageNavigation.ts → use-wheel-page-navigation.ts} +0 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChevronLeft,
|
|
3
|
+
ChevronRight,
|
|
4
|
+
Loader2,
|
|
5
|
+
RotateCcw,
|
|
6
|
+
Square,
|
|
7
|
+
Sun,
|
|
8
|
+
} from 'lucide-react';
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
10
|
+
import { useParams } from 'react-router-dom';
|
|
11
|
+
import { Button } from '@/components/ui/button';
|
|
12
|
+
import { cn } from '@/lib/utils';
|
|
13
|
+
import {
|
|
14
|
+
type PresenterState,
|
|
15
|
+
usePresenterChannel,
|
|
16
|
+
} from '../components/present/use-presenter-channel';
|
|
17
|
+
import { SlideCanvas } from '../components/slide-canvas';
|
|
18
|
+
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
19
|
+
import type { SlideModule } from '../lib/sdk';
|
|
20
|
+
import { loadSlide } from '../lib/slides';
|
|
21
|
+
|
|
22
|
+
export function Presenter() {
|
|
23
|
+
const { slideId = '' } = useParams();
|
|
24
|
+
const [slide, setSlide] = useState<SlideModule | null>(null);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
// Presenter view is a passive mirror of the projection window. It only
|
|
28
|
+
// tracks the index it last heard about; navigation buttons send commands
|
|
29
|
+
// back to the projection so both windows stay in lock-step.
|
|
30
|
+
const [state, setState] = useState<PresenterState | null>(null);
|
|
31
|
+
// Local timer fallback — counts up from when the presenter window opened
|
|
32
|
+
// until the projection window publishes its actual `startedAt`.
|
|
33
|
+
const [localStart] = useState(() => Date.now());
|
|
34
|
+
const [hasProjection, setHasProjection] = useState(false);
|
|
35
|
+
const requestedRef = useRef(false);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
setSlide(null);
|
|
40
|
+
setError(null);
|
|
41
|
+
loadSlide(slideId)
|
|
42
|
+
.then((mod) => {
|
|
43
|
+
if (!cancelled) setSlide(mod);
|
|
44
|
+
})
|
|
45
|
+
.catch((e) => {
|
|
46
|
+
if (!cancelled) setError(String(e?.message ?? e));
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
cancelled = true;
|
|
50
|
+
};
|
|
51
|
+
}, [slideId]);
|
|
52
|
+
|
|
53
|
+
const channel = usePresenterChannel(slideId, (msg) => {
|
|
54
|
+
if (msg.type === 'state') {
|
|
55
|
+
setState(msg.state);
|
|
56
|
+
setHasProjection(true);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Hydrate from the projection window once.
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!channel.available || requestedRef.current) return;
|
|
63
|
+
requestedRef.current = true;
|
|
64
|
+
channel.send({ type: 'request-state' });
|
|
65
|
+
// If nothing answers within a beat, surface the "no projection" hint.
|
|
66
|
+
const t = setTimeout(() => setHasProjection((v) => v), 600);
|
|
67
|
+
return () => clearTimeout(t);
|
|
68
|
+
}, [channel]);
|
|
69
|
+
|
|
70
|
+
const send = channel.send;
|
|
71
|
+
const goPrev = useCallback(() => send({ type: 'prev' }), [send]);
|
|
72
|
+
const goNext = useCallback(() => send({ type: 'next' }), [send]);
|
|
73
|
+
const goTo = useCallback((i: number) => send({ type: 'goto', index: i }), [send]);
|
|
74
|
+
const toggleBlack = useCallback(
|
|
75
|
+
() => send({ type: 'toggle-blackout', mode: 'black' }),
|
|
76
|
+
[send],
|
|
77
|
+
);
|
|
78
|
+
const toggleWhite = useCallback(
|
|
79
|
+
() => send({ type: 'toggle-blackout', mode: 'white' }),
|
|
80
|
+
[send],
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Local-window key bindings mirror the projection's main shortcuts so the
|
|
84
|
+
// presenter can drive without the mouse.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const onKey = (e: KeyboardEvent) => {
|
|
87
|
+
if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
|
|
88
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
89
|
+
if (
|
|
90
|
+
e.key === 'ArrowRight' ||
|
|
91
|
+
e.key === 'ArrowDown' ||
|
|
92
|
+
e.key === ' ' ||
|
|
93
|
+
e.key === 'PageDown'
|
|
94
|
+
) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
goNext();
|
|
97
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
goPrev();
|
|
100
|
+
} else if (e.key === 'b' || e.key === 'B') {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
toggleBlack();
|
|
103
|
+
} else if (e.key === 'w' || e.key === 'W') {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
toggleWhite();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
window.addEventListener('keydown', onKey);
|
|
109
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
110
|
+
}, [goNext, goPrev, toggleBlack, toggleWhite]);
|
|
111
|
+
|
|
112
|
+
if (error) {
|
|
113
|
+
return (
|
|
114
|
+
<div className="grid h-dvh place-items-center bg-zinc-950 p-8 text-zinc-300">
|
|
115
|
+
<div className="max-w-md text-center">
|
|
116
|
+
<span className="eyebrow text-red-300/80">Load failed</span>
|
|
117
|
+
<h2 className="mt-2 font-heading text-xl font-semibold">Failed to load slide</h2>
|
|
118
|
+
<pre className="mt-4 overflow-auto rounded-[6px] border border-white/10 bg-black/40 p-4 text-left text-[11.5px] whitespace-pre-wrap">
|
|
119
|
+
{error}
|
|
120
|
+
</pre>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!slide) {
|
|
127
|
+
return (
|
|
128
|
+
<div className="grid h-dvh place-items-center bg-zinc-950 text-zinc-400">
|
|
129
|
+
<div className="flex items-center gap-2 text-[12.5px]">
|
|
130
|
+
<Loader2 className="size-4 animate-spin" /> Loading {slideId}…
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pages = slide.default;
|
|
137
|
+
const total = pages.length;
|
|
138
|
+
const index = Math.max(0, Math.min(total - 1, state?.index ?? 0));
|
|
139
|
+
const nextIndex = Math.min(total - 1, index + 1);
|
|
140
|
+
const hasNext = index < total - 1;
|
|
141
|
+
const note = slide.notes?.[index];
|
|
142
|
+
const blackout = state?.blackout ?? null;
|
|
143
|
+
const startedAt = state?.startedAt ?? localStart;
|
|
144
|
+
|
|
145
|
+
const CurrentPage = pages[index];
|
|
146
|
+
const NextPage = hasNext ? pages[nextIndex] : null;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className="flex h-dvh w-screen flex-col overflow-hidden bg-zinc-950 text-zinc-100">
|
|
150
|
+
<PresenterTopBar
|
|
151
|
+
index={index}
|
|
152
|
+
total={total}
|
|
153
|
+
startedAt={startedAt}
|
|
154
|
+
slideTitle={slide.meta?.title ?? slideId}
|
|
155
|
+
connected={hasProjection}
|
|
156
|
+
/>
|
|
157
|
+
|
|
158
|
+
<div className="grid min-h-0 flex-1 grid-cols-1 gap-6 px-6 pb-4 lg:grid-cols-[2fr_1fr]">
|
|
159
|
+
{/* Now-showing */}
|
|
160
|
+
<section className="flex min-h-0 flex-col gap-3">
|
|
161
|
+
<SectionLabel>Now showing</SectionLabel>
|
|
162
|
+
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[8px] bg-black ring-1 ring-white/10">
|
|
163
|
+
<SlideCanvas flat design={slide.design}>
|
|
164
|
+
<CurrentPage />
|
|
165
|
+
</SlideCanvas>
|
|
166
|
+
{blackout && (
|
|
167
|
+
<div
|
|
168
|
+
aria-hidden
|
|
169
|
+
className={cn(
|
|
170
|
+
'pointer-events-none absolute inset-0 grid place-items-center text-[11px] tracking-[0.16em] uppercase',
|
|
171
|
+
blackout === 'black' ? 'bg-black text-white/35' : 'bg-white text-black/35',
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{blackout === 'black' ? 'Black screen' : 'White screen'}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</section>
|
|
179
|
+
|
|
180
|
+
{/* Next + notes */}
|
|
181
|
+
<aside className="flex min-h-0 flex-col gap-4">
|
|
182
|
+
<div className="flex flex-col gap-2">
|
|
183
|
+
<SectionLabel>{hasNext ? 'Up next' : 'Last slide'}</SectionLabel>
|
|
184
|
+
<div
|
|
185
|
+
className="relative w-full overflow-hidden rounded-[6px] bg-black ring-1 ring-white/10"
|
|
186
|
+
style={{ aspectRatio: `${CANVAS_WIDTH}/${CANVAS_HEIGHT}` }}
|
|
187
|
+
>
|
|
188
|
+
{NextPage ? (
|
|
189
|
+
<SlideCanvas flat freezeMotion design={slide.design}>
|
|
190
|
+
<NextPage />
|
|
191
|
+
</SlideCanvas>
|
|
192
|
+
) : (
|
|
193
|
+
<div className="grid h-full place-items-center text-[11.5px] text-white/40">
|
|
194
|
+
End of deck
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
|
201
|
+
<SectionLabel>Speaker notes</SectionLabel>
|
|
202
|
+
<div className="min-h-0 flex-1 overflow-y-auto rounded-[6px] border border-white/10 bg-black/40 p-3 text-[13.5px] leading-relaxed whitespace-pre-wrap text-white/85">
|
|
203
|
+
{note?.trim() ? (
|
|
204
|
+
note
|
|
205
|
+
) : (
|
|
206
|
+
<span className="text-white/40">
|
|
207
|
+
No speaker notes for this slide. Add{' '}
|
|
208
|
+
<code className="rounded-[3px] bg-white/10 px-1 py-0.5 font-mono text-[12px]">
|
|
209
|
+
export const notes = […]
|
|
210
|
+
</code>{' '}
|
|
211
|
+
to your slide module to see notes here.
|
|
212
|
+
</span>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<PresenterJumpControl total={total} current={index} onJump={goTo} />
|
|
218
|
+
</aside>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<PresenterBottomBar
|
|
222
|
+
index={index}
|
|
223
|
+
total={total}
|
|
224
|
+
blackout={blackout}
|
|
225
|
+
onPrev={goPrev}
|
|
226
|
+
onNext={goNext}
|
|
227
|
+
onBlackout={toggleBlack}
|
|
228
|
+
onWhiteout={toggleWhite}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function PresenterTopBar({
|
|
235
|
+
index,
|
|
236
|
+
total,
|
|
237
|
+
startedAt,
|
|
238
|
+
slideTitle,
|
|
239
|
+
connected,
|
|
240
|
+
}: {
|
|
241
|
+
index: number;
|
|
242
|
+
total: number;
|
|
243
|
+
startedAt: number;
|
|
244
|
+
slideTitle: string;
|
|
245
|
+
connected: boolean;
|
|
246
|
+
}) {
|
|
247
|
+
return (
|
|
248
|
+
<header className="flex shrink-0 items-center justify-between border-b border-white/10 px-6 py-3">
|
|
249
|
+
<div className="flex items-baseline gap-3">
|
|
250
|
+
<span className="eyebrow text-white/45">Presenter</span>
|
|
251
|
+
<span className="truncate font-heading text-[14px] font-semibold tracking-tight">
|
|
252
|
+
{slideTitle}
|
|
253
|
+
</span>
|
|
254
|
+
{!connected && (
|
|
255
|
+
<span className="rounded-[3px] border border-amber-300/30 bg-amber-300/10 px-1.5 py-0.5 font-mono text-[10px] tracking-[0.06em] uppercase text-amber-200/85">
|
|
256
|
+
Not linked
|
|
257
|
+
</span>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
<div className="flex items-center gap-6">
|
|
261
|
+
<Clock />
|
|
262
|
+
<ElapsedClock startedAt={startedAt} />
|
|
263
|
+
<div className="font-mono text-[18px] tabular-nums">
|
|
264
|
+
<span className="text-white">{(index + 1).toString().padStart(2, '0')}</span>
|
|
265
|
+
<span className="text-white/35"> / </span>
|
|
266
|
+
<span className="text-white/55">{total.toString().padStart(2, '0')}</span>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</header>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function PresenterBottomBar({
|
|
274
|
+
index,
|
|
275
|
+
total,
|
|
276
|
+
blackout,
|
|
277
|
+
onPrev,
|
|
278
|
+
onNext,
|
|
279
|
+
onBlackout,
|
|
280
|
+
onWhiteout,
|
|
281
|
+
}: {
|
|
282
|
+
index: number;
|
|
283
|
+
total: number;
|
|
284
|
+
blackout: 'black' | 'white' | null;
|
|
285
|
+
onPrev: () => void;
|
|
286
|
+
onNext: () => void;
|
|
287
|
+
onBlackout: () => void;
|
|
288
|
+
onWhiteout: () => void;
|
|
289
|
+
}) {
|
|
290
|
+
return (
|
|
291
|
+
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-white/10 px-6 py-3">
|
|
292
|
+
<div className="flex items-center gap-2">
|
|
293
|
+
<Button variant="outline" onClick={onPrev} disabled={index === 0}>
|
|
294
|
+
<ChevronLeft className="size-4" /> Prev
|
|
295
|
+
</Button>
|
|
296
|
+
<Button variant="outline" onClick={onNext} disabled={index >= total - 1}>
|
|
297
|
+
Next <ChevronRight className="size-4" />
|
|
298
|
+
</Button>
|
|
299
|
+
</div>
|
|
300
|
+
<div className="flex items-center gap-2">
|
|
301
|
+
<Button
|
|
302
|
+
variant={blackout === 'black' ? 'brand' : 'outline'}
|
|
303
|
+
onClick={onBlackout}
|
|
304
|
+
aria-pressed={blackout === 'black'}
|
|
305
|
+
>
|
|
306
|
+
<Square className="size-4 fill-current" /> Black
|
|
307
|
+
</Button>
|
|
308
|
+
<Button
|
|
309
|
+
variant={blackout === 'white' ? 'brand' : 'outline'}
|
|
310
|
+
onClick={onWhiteout}
|
|
311
|
+
aria-pressed={blackout === 'white'}
|
|
312
|
+
>
|
|
313
|
+
<Sun className="size-4" /> White
|
|
314
|
+
</Button>
|
|
315
|
+
<Button variant="ghost" onClick={() => window.location.reload()} title="Reset timer">
|
|
316
|
+
<RotateCcw className="size-4" /> Reset
|
|
317
|
+
</Button>
|
|
318
|
+
</div>
|
|
319
|
+
</footer>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function PresenterJumpControl({
|
|
324
|
+
total,
|
|
325
|
+
current,
|
|
326
|
+
onJump,
|
|
327
|
+
}: {
|
|
328
|
+
total: number;
|
|
329
|
+
current: number;
|
|
330
|
+
onJump: (index: number) => void;
|
|
331
|
+
}) {
|
|
332
|
+
const [value, setValue] = useState('');
|
|
333
|
+
return (
|
|
334
|
+
<form
|
|
335
|
+
onSubmit={(e) => {
|
|
336
|
+
e.preventDefault();
|
|
337
|
+
const n = Number.parseInt(value, 10);
|
|
338
|
+
if (Number.isFinite(n) && n >= 1 && n <= total) {
|
|
339
|
+
onJump(n - 1);
|
|
340
|
+
setValue('');
|
|
341
|
+
}
|
|
342
|
+
}}
|
|
343
|
+
className="flex items-center gap-2"
|
|
344
|
+
>
|
|
345
|
+
<SectionLabel>Jump</SectionLabel>
|
|
346
|
+
<input
|
|
347
|
+
type="number"
|
|
348
|
+
min={1}
|
|
349
|
+
max={total}
|
|
350
|
+
value={value}
|
|
351
|
+
onChange={(e) => setValue(e.target.value)}
|
|
352
|
+
placeholder={(current + 1).toString()}
|
|
353
|
+
className="h-8 w-20 rounded-[5px] border border-white/15 bg-black/40 px-2 font-mono text-[12px] tabular-nums outline-none focus-visible:border-white/30"
|
|
354
|
+
/>
|
|
355
|
+
<span className="font-mono text-[11px] text-white/45">/ {total}</span>
|
|
356
|
+
</form>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
361
|
+
return <span className="eyebrow text-white/45">{children}</span>;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function Clock() {
|
|
365
|
+
const [now, setNow] = useState(() => new Date());
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
const id = setInterval(() => setNow(new Date()), 1000);
|
|
368
|
+
return () => clearInterval(id);
|
|
369
|
+
}, []);
|
|
370
|
+
return (
|
|
371
|
+
<time
|
|
372
|
+
title="Current time"
|
|
373
|
+
className="font-mono text-[12px] tabular-nums text-white/55"
|
|
374
|
+
>
|
|
375
|
+
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
376
|
+
</time>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function ElapsedClock({ startedAt }: { startedAt: number }) {
|
|
381
|
+
const [now, setNow] = useState(() => Date.now());
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
384
|
+
return () => clearInterval(id);
|
|
385
|
+
}, []);
|
|
386
|
+
const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
|
|
387
|
+
const h = Math.floor(elapsed / 3600);
|
|
388
|
+
const m = Math.floor((elapsed % 3600) / 60);
|
|
389
|
+
const s = elapsed % 60;
|
|
390
|
+
const text =
|
|
391
|
+
h > 0
|
|
392
|
+
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
|
393
|
+
: `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
394
|
+
return (
|
|
395
|
+
<time title="Elapsed" className="font-mono text-[18px] tabular-nums text-white">
|
|
396
|
+
{text}
|
|
397
|
+
</time>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|