@open-aippt/core 1.13.2
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/LICENSE +21 -0
- package/README.md +98 -0
- package/bin.js +2 -0
- package/dist/build-DxTqmvsO.js +17 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +86 -0
- package/dist/config-CjzqjrEA.js +4280 -0
- package/dist/config-DIC-yVPp.d.ts +23 -0
- package/dist/design-cpzS8aud.js +35 -0
- package/dist/dev-BYuTeJbA.js +20 -0
- package/dist/format-BCeKbTOM.js +1605 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +467 -0
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +3 -0
- package/dist/preview-DlQvnJPq.js +18 -0
- package/dist/sync-BPZ0m27m.js +139 -0
- package/dist/sync-EsYusbbL.js +3 -0
- package/dist/types-CHmFPIG_.d.ts +430 -0
- package/dist/vite/index.d.ts +14 -0
- package/dist/vite/index.js +4 -0
- package/env.d.ts +59 -0
- package/package.json +103 -0
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +91 -0
- package/skills/create-theme/SKILL.md +250 -0
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +625 -0
- package/src/app/app.tsx +47 -0
- package/src/app/components/asset-view.tsx +966 -0
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +243 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/comment-widget.tsx +93 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
- package/src/app/components/inspector/inspect-overlay.tsx +387 -0
- package/src/app/components/inspector/inspector-panel.tsx +1115 -0
- package/src/app/components/inspector/inspector-provider.tsx +1218 -0
- package/src/app/components/inspector/save-bar.tsx +48 -0
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/notes-drawer.tsx +120 -0
- package/src/app/components/overview-grid.tsx +363 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +80 -0
- package/src/app/components/panel/save-card.tsx +142 -0
- package/src/app/components/pdf-progress-toast.tsx +32 -0
- package/src/app/components/player.tsx +466 -0
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +315 -0
- package/src/app/components/present/help-overlay.tsx +57 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +39 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +46 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +66 -0
- package/src/app/components/present/use-touch-swipe.ts +66 -0
- package/src/app/components/shared-element.tsx +48 -0
- package/src/app/components/sidebar/folder-item.tsx +258 -0
- package/src/app/components/sidebar/icon-picker.tsx +61 -0
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
- package/src/app/components/sidebar/sidebar.tsx +284 -0
- package/src/app/components/slide-canvas.tsx +102 -0
- package/src/app/components/slide-transition-layer.tsx +844 -0
- package/src/app/components/style-panel/design-provider.tsx +148 -0
- package/src/app/components/style-panel/style-panel.tsx +349 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +59 -0
- package/src/app/components/themes/theme-detail.tsx +305 -0
- package/src/app/components/themes/themes-gallery.tsx +149 -0
- package/src/app/components/thumbnail-rail.tsx +805 -0
- package/src/app/components/ui/badge.tsx +45 -0
- package/src/app/components/ui/button.tsx +99 -0
- package/src/app/components/ui/card.tsx +92 -0
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/components/ui/dialog.tsx +157 -0
- package/src/app/components/ui/dropdown-menu.tsx +245 -0
- package/src/app/components/ui/input.tsx +25 -0
- package/src/app/components/ui/label.tsx +24 -0
- package/src/app/components/ui/popover.tsx +75 -0
- package/src/app/components/ui/progress.tsx +31 -0
- package/src/app/components/ui/scroll-area.tsx +53 -0
- package/src/app/components/ui/select.tsx +196 -0
- package/src/app/components/ui/separator.tsx +28 -0
- package/src/app/components/ui/slider.tsx +61 -0
- package/src/app/components/ui/sonner.tsx +48 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/components/ui/textarea.tsx +22 -0
- package/src/app/components/ui/toggle-group.tsx +83 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +58 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/index.html +13 -0
- package/src/app/lib/assets.ts +242 -0
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/lib/design.ts +58 -0
- package/src/app/lib/export-html.ts +326 -0
- package/src/app/lib/export-pdf.ts +298 -0
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/folders.ts +239 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +85 -0
- package/src/app/lib/inspector/use-comments.ts +74 -0
- package/src/app/lib/inspector/use-editor.ts +73 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/print-ready.test.ts +32 -0
- package/src/app/lib/print-ready.ts +51 -0
- package/src/app/lib/sdk.test.ts +13 -0
- package/src/app/lib/sdk.ts +37 -0
- package/src/app/lib/slides.ts +26 -0
- package/src/app/lib/step-context.tsx +261 -0
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/transition.ts +30 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-click-page-navigation.ts +60 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-locale.ts +8 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/lib/use-wheel-page-navigation.ts +99 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +213 -0
- package/src/app/routes/home.tsx +807 -0
- package/src/app/routes/presenter.tsx +418 -0
- package/src/app/routes/slide.tsx +1108 -0
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/styles.css +429 -0
- package/src/app/virtual.d.ts +51 -0
- package/src/locale/en.ts +416 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +422 -0
- package/src/locale/types.ts +443 -0
- package/src/locale/zh-cn.ts +414 -0
- package/src/locale/zh-tw.ts +414 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight, RotateCcw, Square, Sun } from 'lucide-react';
|
|
2
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useParams } from 'react-router-dom';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
import {
|
|
8
|
+
type PresenterState,
|
|
9
|
+
usePresenterChannel,
|
|
10
|
+
} from '../components/present/use-presenter-channel';
|
|
11
|
+
import { SlideCanvas } from '../components/slide-canvas';
|
|
12
|
+
import { SlidePageProvider } from '../lib/page-context';
|
|
13
|
+
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
14
|
+
import { type StepController, StepHost } from '../lib/step-context';
|
|
15
|
+
import { useSlideModule } from '../lib/use-slide-module';
|
|
16
|
+
|
|
17
|
+
export function Presenter() {
|
|
18
|
+
const { slideId = '' } = useParams();
|
|
19
|
+
const { slide, error } = useSlideModule(slideId);
|
|
20
|
+
|
|
21
|
+
// Presenter view is a passive mirror of the projection window. It only
|
|
22
|
+
// tracks the index it last heard about; navigation buttons send commands
|
|
23
|
+
// back to the projection so both windows stay in lock-step.
|
|
24
|
+
const [state, setState] = useState<PresenterState | null>(null);
|
|
25
|
+
// Local timer fallback — counts up from when the presenter window opened
|
|
26
|
+
// until the projection window publishes its actual `startedAt`.
|
|
27
|
+
const [localStart] = useState(() => Date.now());
|
|
28
|
+
const [hasProjection, setHasProjection] = useState(false);
|
|
29
|
+
const requestedRef = useRef(false);
|
|
30
|
+
const t = useLocale();
|
|
31
|
+
|
|
32
|
+
const channel = usePresenterChannel(slideId, (msg) => {
|
|
33
|
+
if (msg.type === 'state') {
|
|
34
|
+
setState(msg.state);
|
|
35
|
+
setHasProjection(true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Hydrate from the projection window once.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!channel.available || requestedRef.current) return;
|
|
42
|
+
requestedRef.current = true;
|
|
43
|
+
channel.send({ type: 'request-state' });
|
|
44
|
+
// If nothing answers within a beat, surface the "no projection" hint.
|
|
45
|
+
const t = setTimeout(() => setHasProjection((v) => v), 600);
|
|
46
|
+
return () => clearTimeout(t);
|
|
47
|
+
}, [channel]);
|
|
48
|
+
|
|
49
|
+
const send = channel.send;
|
|
50
|
+
const goPrev = useCallback(() => send({ type: 'prev' }), [send]);
|
|
51
|
+
const goNext = useCallback(() => send({ type: 'next' }), [send]);
|
|
52
|
+
const goTo = useCallback((i: number) => send({ type: 'goto', index: i }), [send]);
|
|
53
|
+
const toggleBlack = useCallback(() => send({ type: 'toggle-blackout', mode: 'black' }), [send]);
|
|
54
|
+
const toggleWhite = useCallback(() => send({ type: 'toggle-blackout', mode: 'white' }), [send]);
|
|
55
|
+
|
|
56
|
+
// Local-window key bindings mirror the projection's main shortcuts so the
|
|
57
|
+
// presenter can drive without the mouse.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const onKey = (e: KeyboardEvent) => {
|
|
60
|
+
if (e.target instanceof HTMLElement && e.target.matches('input, textarea')) return;
|
|
61
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
62
|
+
if (
|
|
63
|
+
e.key === 'ArrowRight' ||
|
|
64
|
+
e.key === 'ArrowDown' ||
|
|
65
|
+
e.key === ' ' ||
|
|
66
|
+
e.key === 'PageDown'
|
|
67
|
+
) {
|
|
68
|
+
e.preventDefault();
|
|
69
|
+
goNext();
|
|
70
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
goPrev();
|
|
73
|
+
} else if (e.key === 'b' || e.key === 'B') {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
toggleBlack();
|
|
76
|
+
} else if (e.key === 'w' || e.key === 'W') {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
toggleWhite();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
window.addEventListener('keydown', onKey);
|
|
82
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
83
|
+
}, [goNext, goPrev, toggleBlack, toggleWhite]);
|
|
84
|
+
|
|
85
|
+
if (error) {
|
|
86
|
+
return (
|
|
87
|
+
<div className="dark grid h-dvh place-items-center bg-background p-8 text-foreground">
|
|
88
|
+
<div className="max-w-md text-center">
|
|
89
|
+
<span className="eyebrow text-destructive/80">{t.common.loadFailed}</span>
|
|
90
|
+
<h2 className="mt-2 font-heading text-xl font-semibold">{t.common.failedToLoadSlide}</h2>
|
|
91
|
+
<pre className="mt-4 overflow-auto rounded-[6px] border border-border bg-card p-4 text-left text-[11.5px] whitespace-pre-wrap shadow-edge">
|
|
92
|
+
{error}
|
|
93
|
+
</pre>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!slide) {
|
|
100
|
+
return (
|
|
101
|
+
<div className="dark grid h-dvh place-items-center bg-background text-muted-foreground">
|
|
102
|
+
<div className="flex flex-col items-center gap-4">
|
|
103
|
+
<div className="relative h-px w-56 overflow-hidden bg-border">
|
|
104
|
+
<span
|
|
105
|
+
aria-hidden
|
|
106
|
+
className="line-loader-bar absolute inset-y-[-0.5px] left-0 w-1/4 bg-foreground"
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="text-[11.5px]">{format(t.presenter.loadingSlide, { slideId })}</div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const pages = slide.default;
|
|
116
|
+
const total = pages.length;
|
|
117
|
+
const index = Math.max(0, Math.min(total - 1, state?.index ?? 0));
|
|
118
|
+
const note = slide.notes?.[index];
|
|
119
|
+
const blackout = state?.blackout ?? null;
|
|
120
|
+
const startedAt = state?.startedAt ?? localStart;
|
|
121
|
+
const stepIndex = Math.max(0, state?.stepIndex ?? 0);
|
|
122
|
+
const stepCount = Math.max(0, state?.stepCount ?? 0);
|
|
123
|
+
|
|
124
|
+
const stepsRemaining = stepIndex < stepCount;
|
|
125
|
+
const hasNextSlide = index < total - 1;
|
|
126
|
+
const hasNext = stepsRemaining || hasNextSlide;
|
|
127
|
+
const nextPageIndex = stepsRemaining ? index : Math.min(total - 1, index + 1);
|
|
128
|
+
const nextRevealed = stepsRemaining ? stepIndex + 1 : 0;
|
|
129
|
+
|
|
130
|
+
const CurrentPage = pages[index];
|
|
131
|
+
const NextPage = hasNext ? pages[nextPageIndex] : null;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className="dark flex h-dvh w-screen flex-col overflow-hidden bg-background text-foreground">
|
|
135
|
+
<PresenterTopBar
|
|
136
|
+
index={index}
|
|
137
|
+
total={total}
|
|
138
|
+
startedAt={startedAt}
|
|
139
|
+
slideTitle={slide.meta?.title ?? slideId}
|
|
140
|
+
connected={hasProjection}
|
|
141
|
+
/>
|
|
142
|
+
|
|
143
|
+
<div className="grid min-h-0 flex-1 grid-cols-1 gap-6 px-6 pb-4 lg:grid-cols-[2fr_1fr]">
|
|
144
|
+
{/* Now-showing */}
|
|
145
|
+
<section className="flex min-h-0 flex-col gap-3">
|
|
146
|
+
<SectionLabel>{t.presenter.nowShowing}</SectionLabel>
|
|
147
|
+
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[8px] bg-black ring-1 ring-border">
|
|
148
|
+
<SlideCanvas flat design={slide.design}>
|
|
149
|
+
<SlidePageProvider index={index} total={total}>
|
|
150
|
+
<PreviewStepHost revealed={stepIndex}>
|
|
151
|
+
<CurrentPage />
|
|
152
|
+
</PreviewStepHost>
|
|
153
|
+
</SlidePageProvider>
|
|
154
|
+
</SlideCanvas>
|
|
155
|
+
{blackout && (
|
|
156
|
+
<div
|
|
157
|
+
aria-hidden
|
|
158
|
+
className={cn(
|
|
159
|
+
'pointer-events-none absolute inset-0 grid place-items-center text-[11px] tracking-[0.16em] uppercase',
|
|
160
|
+
blackout === 'black' ? 'bg-black text-white/35' : 'bg-white text-black/35',
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
{blackout === 'black' ? t.presenter.blackScreen : t.presenter.whiteScreen}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
|
|
169
|
+
{/* Next + notes */}
|
|
170
|
+
<aside className="flex min-h-0 flex-col gap-4">
|
|
171
|
+
<div className="flex flex-col gap-2">
|
|
172
|
+
<SectionLabel>{hasNext ? t.presenter.upNext : t.presenter.lastSlide}</SectionLabel>
|
|
173
|
+
<div
|
|
174
|
+
className="relative w-full overflow-hidden rounded-[8px] bg-black ring-1 ring-border"
|
|
175
|
+
style={{ aspectRatio: `${CANVAS_WIDTH}/${CANVAS_HEIGHT}` }}
|
|
176
|
+
>
|
|
177
|
+
{NextPage ? (
|
|
178
|
+
<SlideCanvas flat freezeMotion design={slide.design}>
|
|
179
|
+
<SlidePageProvider index={nextPageIndex} total={total}>
|
|
180
|
+
<PreviewStepHost revealed={nextRevealed}>
|
|
181
|
+
<NextPage />
|
|
182
|
+
</PreviewStepHost>
|
|
183
|
+
</SlidePageProvider>
|
|
184
|
+
</SlideCanvas>
|
|
185
|
+
) : (
|
|
186
|
+
<div className="grid h-full place-items-center text-[11.5px] text-muted-foreground">
|
|
187
|
+
{t.presenter.endOfDeck}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
|
194
|
+
<SectionLabel>{t.presenter.speakerNotes}</SectionLabel>
|
|
195
|
+
<div className="min-h-0 flex-1 overflow-y-auto rounded-[6px] border border-border bg-card p-3 text-[13.5px] leading-relaxed whitespace-pre-wrap text-card-foreground">
|
|
196
|
+
{note?.trim() ? (
|
|
197
|
+
note
|
|
198
|
+
) : (
|
|
199
|
+
<span className="text-muted-foreground">
|
|
200
|
+
{t.presenter.noNotesPrefix}
|
|
201
|
+
<code className="rounded-[3px] bg-muted px-1 py-0.5 font-mono text-[12px]">
|
|
202
|
+
export const notes = […]
|
|
203
|
+
</code>
|
|
204
|
+
{t.presenter.noNotesSuffix}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<PresenterJumpControl total={total} current={index} onJump={goTo} />
|
|
211
|
+
</aside>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<PresenterBottomBar
|
|
215
|
+
index={index}
|
|
216
|
+
total={total}
|
|
217
|
+
blackout={blackout}
|
|
218
|
+
onPrev={goPrev}
|
|
219
|
+
onNext={goNext}
|
|
220
|
+
onBlackout={toggleBlack}
|
|
221
|
+
onWhiteout={toggleWhite}
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function PresenterTopBar({
|
|
228
|
+
index,
|
|
229
|
+
total,
|
|
230
|
+
startedAt,
|
|
231
|
+
slideTitle,
|
|
232
|
+
connected,
|
|
233
|
+
}: {
|
|
234
|
+
index: number;
|
|
235
|
+
total: number;
|
|
236
|
+
startedAt: number;
|
|
237
|
+
slideTitle: string;
|
|
238
|
+
connected: boolean;
|
|
239
|
+
}) {
|
|
240
|
+
const t = useLocale();
|
|
241
|
+
return (
|
|
242
|
+
<header className="flex h-12 shrink-0 items-center justify-between border-b border-hairline px-6">
|
|
243
|
+
<div className="flex items-baseline gap-3">
|
|
244
|
+
<span className="eyebrow text-white/45">{t.presenter.eyebrow}</span>
|
|
245
|
+
<span className="truncate font-heading text-[14px] font-semibold tracking-tight">
|
|
246
|
+
{slideTitle}
|
|
247
|
+
</span>
|
|
248
|
+
{!connected && (
|
|
249
|
+
<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">
|
|
250
|
+
{t.presenter.notLinked}
|
|
251
|
+
</span>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
<div className="flex items-center gap-6">
|
|
255
|
+
<Clock />
|
|
256
|
+
<ElapsedClock startedAt={startedAt} />
|
|
257
|
+
<div className="font-mono text-[18px] tabular-nums">
|
|
258
|
+
<span className="text-foreground">{(index + 1).toString().padStart(2, '0')}</span>
|
|
259
|
+
<span className="text-foreground/30"> / </span>
|
|
260
|
+
<span className="text-muted-foreground">{total.toString().padStart(2, '0')}</span>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</header>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function PresenterBottomBar({
|
|
268
|
+
index,
|
|
269
|
+
total,
|
|
270
|
+
blackout,
|
|
271
|
+
onPrev,
|
|
272
|
+
onNext,
|
|
273
|
+
onBlackout,
|
|
274
|
+
onWhiteout,
|
|
275
|
+
}: {
|
|
276
|
+
index: number;
|
|
277
|
+
total: number;
|
|
278
|
+
blackout: 'black' | 'white' | null;
|
|
279
|
+
onPrev: () => void;
|
|
280
|
+
onNext: () => void;
|
|
281
|
+
onBlackout: () => void;
|
|
282
|
+
onWhiteout: () => void;
|
|
283
|
+
}) {
|
|
284
|
+
const t = useLocale();
|
|
285
|
+
return (
|
|
286
|
+
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-hairline px-6 py-3">
|
|
287
|
+
<div className="flex items-center gap-2">
|
|
288
|
+
<Button variant="outline" onClick={onPrev} disabled={index === 0}>
|
|
289
|
+
<ChevronLeft className="size-4" /> {t.presenter.prev}
|
|
290
|
+
</Button>
|
|
291
|
+
<Button variant="outline" onClick={onNext} disabled={index >= total - 1}>
|
|
292
|
+
{t.presenter.next} <ChevronRight className="size-4" />
|
|
293
|
+
</Button>
|
|
294
|
+
</div>
|
|
295
|
+
<div className="flex items-center gap-2">
|
|
296
|
+
<Button
|
|
297
|
+
variant={blackout === 'black' ? 'brand' : 'outline'}
|
|
298
|
+
onClick={onBlackout}
|
|
299
|
+
aria-pressed={blackout === 'black'}
|
|
300
|
+
>
|
|
301
|
+
<Square className="size-4 fill-current" /> {t.presenter.black}
|
|
302
|
+
</Button>
|
|
303
|
+
<Button
|
|
304
|
+
variant={blackout === 'white' ? 'brand' : 'outline'}
|
|
305
|
+
onClick={onWhiteout}
|
|
306
|
+
aria-pressed={blackout === 'white'}
|
|
307
|
+
>
|
|
308
|
+
<Sun className="size-4" /> {t.presenter.white}
|
|
309
|
+
</Button>
|
|
310
|
+
<Button
|
|
311
|
+
variant="ghost"
|
|
312
|
+
onClick={() => window.location.reload()}
|
|
313
|
+
title={t.presenter.resetTimer}
|
|
314
|
+
>
|
|
315
|
+
<RotateCcw className="size-4" /> {t.presenter.reset}
|
|
316
|
+
</Button>
|
|
317
|
+
</div>
|
|
318
|
+
</footer>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function PresenterJumpControl({
|
|
323
|
+
total,
|
|
324
|
+
current,
|
|
325
|
+
onJump,
|
|
326
|
+
}: {
|
|
327
|
+
total: number;
|
|
328
|
+
current: number;
|
|
329
|
+
onJump: (index: number) => void;
|
|
330
|
+
}) {
|
|
331
|
+
const [value, setValue] = useState('');
|
|
332
|
+
const t = useLocale();
|
|
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>{t.presenter.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-border bg-card px-2 font-mono text-[12px] tabular-nums outline-none focus-visible:border-foreground/30"
|
|
354
|
+
/>
|
|
355
|
+
<span className="font-mono text-[11px] text-muted-foreground">/ {total}</span>
|
|
356
|
+
</form>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
361
|
+
return <span className="eyebrow">{children}</span>;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function PreviewStepHost({ revealed, children }: { revealed: number; children: ReactNode }) {
|
|
365
|
+
const noopControllerRef = useRef<StepController | null>(null);
|
|
366
|
+
return (
|
|
367
|
+
<StepHost
|
|
368
|
+
isActivePage={false}
|
|
369
|
+
entryDirection="jump"
|
|
370
|
+
controllerRef={noopControllerRef}
|
|
371
|
+
controlledRevealed={revealed}
|
|
372
|
+
>
|
|
373
|
+
{children}
|
|
374
|
+
</StepHost>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function Clock() {
|
|
379
|
+
const [now, setNow] = useState(() => new Date());
|
|
380
|
+
const t = useLocale();
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
const id = setInterval(() => setNow(new Date()), 1000);
|
|
383
|
+
return () => clearInterval(id);
|
|
384
|
+
}, []);
|
|
385
|
+
return (
|
|
386
|
+
<time
|
|
387
|
+
title={t.presenter.currentTime}
|
|
388
|
+
className="font-mono text-[12px] tabular-nums text-muted-foreground"
|
|
389
|
+
>
|
|
390
|
+
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
391
|
+
</time>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function ElapsedClock({ startedAt }: { startedAt: number }) {
|
|
396
|
+
const [now, setNow] = useState(() => Date.now());
|
|
397
|
+
const t = useLocale();
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
400
|
+
return () => clearInterval(id);
|
|
401
|
+
}, []);
|
|
402
|
+
const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000));
|
|
403
|
+
const h = Math.floor(elapsed / 3600);
|
|
404
|
+
const m = Math.floor((elapsed % 3600) / 60);
|
|
405
|
+
const s = elapsed % 60;
|
|
406
|
+
const text =
|
|
407
|
+
h > 0
|
|
408
|
+
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
|
409
|
+
: `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
410
|
+
return (
|
|
411
|
+
<time
|
|
412
|
+
title={t.presenter.elapsed}
|
|
413
|
+
className="font-mono text-[18px] tabular-nums text-foreground"
|
|
414
|
+
>
|
|
415
|
+
{text}
|
|
416
|
+
</time>
|
|
417
|
+
);
|
|
418
|
+
}
|