@open-slide/core 1.0.4 → 1.0.5
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-DqfKmw9h.js → build-CoON6kTb.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-CN7J0RDO.js → config-Bxtztw-H.js} +373 -221
- package/dist/{config-DweCbRkQ.d.ts → config-D2y1AXaN.d.ts} +3 -0
- package/dist/{dev-jWxtWHAG.js → dev-IezNC17X.js} +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +1189 -0
- package/dist/{preview-CSA05Gfm.js → preview-BwYjtENY.js} +1 -1
- package/dist/types-BVvl_xup.d.ts +314 -0
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +1 -1
- package/package.json +7 -1
- package/src/app/app.tsx +6 -2
- package/src/app/components/asset-view.tsx +87 -64
- package/src/app/components/click-nav-zones.tsx +4 -2
- package/src/app/components/inspector/comment-widget.tsx +9 -7
- package/src/app/components/inspector/inspector-panel.tsx +68 -39
- package/src/app/components/inspector/inspector-provider.tsx +185 -58
- package/src/app/components/inspector/save-bar.tsx +6 -2
- package/src/app/components/panel/save-card.tsx +12 -9
- package/src/app/components/pdf-progress-toast.tsx +11 -4
- package/src/app/components/present/control-bar.tsx +17 -10
- package/src/app/components/present/help-overlay.tsx +18 -17
- package/src/app/components/present/overview-grid.tsx +6 -4
- package/src/app/components/sidebar/folder-item.tsx +16 -7
- package/src/app/components/sidebar/icon-picker.tsx +4 -2
- package/src/app/components/sidebar/sidebar.tsx +87 -25
- package/src/app/components/style-panel/style-panel.tsx +26 -18
- package/src/app/components/theme-toggle.tsx +7 -5
- package/src/app/components/thumbnail-rail.tsx +4 -2
- package/src/app/favicon.ico +0 -0
- package/src/app/lib/inspector/use-editor.ts +9 -7
- package/src/app/lib/use-locale.ts +20 -0
- package/src/app/routes/home.tsx +90 -45
- package/src/app/routes/presenter.tsx +45 -25
- package/src/app/routes/slide.tsx +37 -24
- package/src/app/styles.css +28 -0
- package/src/app/virtual.d.ts +4 -0
- package/src/locale/en.ts +303 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +307 -0
- package/src/locale/types.ts +323 -0
- package/src/locale/zh-cn.ts +303 -0
- package/src/locale/zh-tw.ts +303 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import config from 'virtual:open-slide/config';
|
|
2
|
+
import { en } from '../../locale/en';
|
|
3
|
+
import type { Locale, Plural } from '../../locale/types';
|
|
4
|
+
|
|
5
|
+
const resolved: Locale = (config.locale as Locale | undefined) ?? en;
|
|
6
|
+
|
|
7
|
+
export function useLocale(): Locale {
|
|
8
|
+
return resolved;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function format(template: string, vars: Record<string, string | number>): string {
|
|
12
|
+
return template.replace(/\{(\w+)\}/g, (m, key) => {
|
|
13
|
+
const v = vars[key];
|
|
14
|
+
return v === undefined ? m : String(v);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function plural(count: number, forms: Plural): string {
|
|
19
|
+
return count === 1 ? forms.one : forms.other;
|
|
20
|
+
}
|
package/src/app/routes/home.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FolderInput, FolderPlus, MoreHorizontal, Pencil, Search, Trash2, X } from 'lucide-react';
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
3
|
import { Link, useSearchParams } from 'react-router-dom';
|
|
4
|
+
import { toast } from 'sonner';
|
|
4
5
|
import { Button } from '@/components/ui/button';
|
|
5
6
|
import {
|
|
6
7
|
Dialog,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
DropdownMenuTrigger,
|
|
18
19
|
} from '@/components/ui/dropdown-menu';
|
|
19
20
|
import { useFolders } from '@/lib/folders';
|
|
21
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
20
22
|
import { cn } from '@/lib/utils';
|
|
21
23
|
import { FolderIconChip, SLIDE_DND_MIME } from '../components/sidebar/folder-item';
|
|
22
24
|
import { DRAFT_ID, Sidebar } from '../components/sidebar/sidebar';
|
|
@@ -28,6 +30,7 @@ export function Home() {
|
|
|
28
30
|
const { manifest, create, update, remove, assign, renameSlide, deleteSlide } = useFolders();
|
|
29
31
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
30
32
|
const selectedId = searchParams.get('f') ?? DRAFT_ID;
|
|
33
|
+
const t = useLocale();
|
|
31
34
|
|
|
32
35
|
const selectFolder = (id: string) => {
|
|
33
36
|
setSearchParams(
|
|
@@ -64,7 +67,7 @@ export function Home() {
|
|
|
64
67
|
selectedId === DRAFT_ID ? null : (manifest.folders.find((f) => f.id === selectedId) ?? null);
|
|
65
68
|
const visibleSlides = selectedId === DRAFT_ID ? draftSlides : (slidesByFolder[selectedId] ?? []);
|
|
66
69
|
|
|
67
|
-
const title = selectedFolder?.name ??
|
|
70
|
+
const title = selectedFolder?.name ?? t.home.draft;
|
|
68
71
|
const headerIcon = selectedFolder?.icon ?? { type: 'emoji' as const, value: '📝' };
|
|
69
72
|
const isDraft = selectedId === DRAFT_ID;
|
|
70
73
|
|
|
@@ -76,6 +79,24 @@ export function Home() {
|
|
|
76
79
|
);
|
|
77
80
|
}, []);
|
|
78
81
|
|
|
82
|
+
const moveSlideWithToast = useCallback(
|
|
83
|
+
async (slideId: string, folderId: string | null) => {
|
|
84
|
+
if (manifest.assignments[slideId] === (folderId ?? undefined)) return;
|
|
85
|
+
const slideName = titleMap[slideId] ?? slideId;
|
|
86
|
+
const folderName =
|
|
87
|
+
folderId === null
|
|
88
|
+
? t.home.draft
|
|
89
|
+
: (manifest.folders.find((f) => f.id === folderId)?.name ?? folderId);
|
|
90
|
+
try {
|
|
91
|
+
await assign(slideId, folderId);
|
|
92
|
+
toast.success(format(t.home.toastSlideMoved, { slide: slideName, folder: folderName }));
|
|
93
|
+
} catch {
|
|
94
|
+
toast.error(t.home.toastSlideMoveFailed);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
[assign, manifest, titleMap, t],
|
|
98
|
+
);
|
|
99
|
+
|
|
79
100
|
const trimmedQuery = query.trim().toLowerCase();
|
|
80
101
|
const filteredSlides = useMemo(() => {
|
|
81
102
|
if (!trimmedQuery) return visibleSlides;
|
|
@@ -98,25 +119,31 @@ export function Home() {
|
|
|
98
119
|
onCreate={(name, icon) => create(name, icon)}
|
|
99
120
|
onRename={(id, name) => update(id, { name })}
|
|
100
121
|
onChangeIcon={(id, icon) => update(id, { icon })}
|
|
101
|
-
onDelete={(id) => {
|
|
122
|
+
onDelete={async (id) => {
|
|
123
|
+
const name = manifest.folders.find((f) => f.id === id)?.name ?? id;
|
|
102
124
|
if (selectedId === id) selectFolder(DRAFT_ID);
|
|
103
|
-
|
|
125
|
+
try {
|
|
126
|
+
await remove(id);
|
|
127
|
+
toast.success(format(t.home.toastFolderDeleted, { name }));
|
|
128
|
+
} catch {
|
|
129
|
+
toast.error(t.home.toastFolderDeleteFailed);
|
|
130
|
+
}
|
|
104
131
|
}}
|
|
105
|
-
onDropToFolder={(folderId, slideId) =>
|
|
106
|
-
onDropToDraft={(slideId) =>
|
|
132
|
+
onDropToFolder={(folderId, slideId) => moveSlideWithToast(slideId, folderId)}
|
|
133
|
+
onDropToDraft={(slideId) => moveSlideWithToast(slideId, null)}
|
|
107
134
|
/>
|
|
108
135
|
</div>
|
|
109
136
|
|
|
110
137
|
<div className="paper relative flex min-w-0 flex-1 flex-col overflow-y-auto bg-canvas">
|
|
111
138
|
{/* Mobile chrome */}
|
|
112
139
|
<div className="flex items-center justify-between border-b border-hairline bg-sidebar px-4 py-3 md:hidden">
|
|
113
|
-
<h1 className="font-heading text-lg font-bold tracking-tight">
|
|
140
|
+
<h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
|
|
114
141
|
</div>
|
|
115
142
|
<div className="border-b border-hairline bg-sidebar px-4 py-2 md:hidden">
|
|
116
143
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
|
117
144
|
<MobileFolderPill
|
|
118
145
|
icon={{ type: 'emoji', value: '📝' }}
|
|
119
|
-
label=
|
|
146
|
+
label={t.home.draft}
|
|
120
147
|
count={countFor(null)}
|
|
121
148
|
active={selectedId === DRAFT_ID}
|
|
122
149
|
onClick={() => selectFolder(DRAFT_ID)}
|
|
@@ -216,6 +243,7 @@ function MobileFolderPill({
|
|
|
216
243
|
}
|
|
217
244
|
|
|
218
245
|
function SearchInput({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
|
246
|
+
const t = useLocale();
|
|
219
247
|
return (
|
|
220
248
|
<div className="relative w-full md:w-[240px]">
|
|
221
249
|
<Search
|
|
@@ -226,14 +254,14 @@ function SearchInput({ value, onChange }: { value: string; onChange: (value: str
|
|
|
226
254
|
type="text"
|
|
227
255
|
value={value}
|
|
228
256
|
onChange={(e) => onChange(e.target.value)}
|
|
229
|
-
placeholder=
|
|
257
|
+
placeholder={t.home.searchPlaceholder}
|
|
230
258
|
className="h-8 w-full rounded-[6px] border border-border bg-background pl-8 pr-7 text-[12.5px] outline-none placeholder:text-muted-foreground/70 focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
231
259
|
/>
|
|
232
260
|
{value && (
|
|
233
261
|
<button
|
|
234
262
|
type="button"
|
|
235
263
|
onClick={() => onChange('')}
|
|
236
|
-
aria-label=
|
|
264
|
+
aria-label={t.home.clearSearch}
|
|
237
265
|
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
238
266
|
>
|
|
239
267
|
<X className="size-3" />
|
|
@@ -244,19 +272,23 @@ function SearchInput({ value, onChange }: { value: string; onChange: (value: str
|
|
|
244
272
|
}
|
|
245
273
|
|
|
246
274
|
function NoResultsState({ query, onClear }: { query: string; onClear: () => void }) {
|
|
275
|
+
const t = useLocale();
|
|
247
276
|
return (
|
|
248
277
|
<div className="rounded-[10px] border border-dashed border-border bg-card/60 px-8 py-20">
|
|
249
278
|
<div className="mx-auto flex max-w-md flex-col items-center text-center">
|
|
250
279
|
<div className="flex size-12 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground">
|
|
251
280
|
<Search className="size-5" />
|
|
252
281
|
</div>
|
|
253
|
-
<p className="mt-4 font-heading text-[15px] font-semibold tracking-tight">
|
|
282
|
+
<p className="mt-4 font-heading text-[15px] font-semibold tracking-tight">
|
|
283
|
+
{t.home.noMatches}
|
|
284
|
+
</p>
|
|
254
285
|
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
255
|
-
|
|
256
|
-
|
|
286
|
+
{t.home.nothingMatchesPrefix}
|
|
287
|
+
<span className="font-medium text-foreground">“{query}”</span>
|
|
288
|
+
{t.home.nothingMatchesSuffix}
|
|
257
289
|
</p>
|
|
258
290
|
<Button variant="ghost" size="sm" className="mt-4" onClick={onClear}>
|
|
259
|
-
|
|
291
|
+
{t.home.clearSearch}
|
|
260
292
|
</Button>
|
|
261
293
|
</div>
|
|
262
294
|
</div>
|
|
@@ -264,6 +296,11 @@ function NoResultsState({ query, onClear }: { query: string; onClear: () => void
|
|
|
264
296
|
}
|
|
265
297
|
|
|
266
298
|
function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: string }) {
|
|
299
|
+
const t = useLocale();
|
|
300
|
+
const folderEmptyTitle = t.home.folderEmptyTitle.replace(
|
|
301
|
+
'{name}',
|
|
302
|
+
folderName ?? t.home.folderEmptyTitle,
|
|
303
|
+
);
|
|
267
304
|
return (
|
|
268
305
|
<div className="rounded-[10px] border border-dashed border-border bg-card/60 px-8 py-20">
|
|
269
306
|
<div className="mx-auto flex max-w-md flex-col items-center text-center">
|
|
@@ -273,27 +310,27 @@ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: st
|
|
|
273
310
|
{isDraft ? (
|
|
274
311
|
<>
|
|
275
312
|
<p className="mt-4 font-heading text-[15px] font-semibold tracking-tight">
|
|
276
|
-
|
|
313
|
+
{t.home.noSlidesYet}
|
|
277
314
|
</p>
|
|
278
315
|
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
279
|
-
|
|
316
|
+
{t.home.createSlideHintPrefix}
|
|
280
317
|
<code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
|
|
281
318
|
slides/my-slide/index.tsx
|
|
282
|
-
</code>
|
|
283
|
-
|
|
319
|
+
</code>
|
|
320
|
+
{t.home.createSlideHintMid}
|
|
284
321
|
<code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
|
|
285
322
|
export default [Page1, Page2]
|
|
286
323
|
</code>
|
|
287
|
-
.
|
|
324
|
+
{t.home.createSlideHintSuffix}
|
|
288
325
|
</p>
|
|
289
326
|
</>
|
|
290
327
|
) : (
|
|
291
328
|
<>
|
|
292
329
|
<p className="mt-4 font-heading text-[15px] font-semibold tracking-tight">
|
|
293
|
-
{
|
|
330
|
+
{folderEmptyTitle}
|
|
294
331
|
</p>
|
|
295
332
|
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
296
|
-
|
|
333
|
+
{t.home.folderEmptyHint}
|
|
297
334
|
</p>
|
|
298
335
|
</>
|
|
299
336
|
)}
|
|
@@ -367,6 +404,7 @@ function SlideCard({
|
|
|
367
404
|
const [slide, setSlide] = useState<SlideModule | null>(null);
|
|
368
405
|
const [dragging, setDragging] = useState(false);
|
|
369
406
|
const [dialog, setDialog] = useState<DialogKind>(null);
|
|
407
|
+
const tCard = useLocale();
|
|
370
408
|
|
|
371
409
|
useEffect(() => {
|
|
372
410
|
let cancelled = false;
|
|
@@ -416,7 +454,7 @@ function SlideCard({
|
|
|
416
454
|
</div>
|
|
417
455
|
) : (
|
|
418
456
|
<div className="grid h-full w-full place-items-center text-[10px] tracking-[0.16em] uppercase text-muted-foreground/60">
|
|
419
|
-
|
|
457
|
+
{tCard.common.loading}
|
|
420
458
|
</div>
|
|
421
459
|
)}
|
|
422
460
|
</div>
|
|
@@ -439,7 +477,7 @@ function SlideCard({
|
|
|
439
477
|
e.preventDefault();
|
|
440
478
|
}}
|
|
441
479
|
className="flex size-7 items-center justify-center rounded-[5px] bg-card/90 text-foreground shadow-edge ring-1 ring-border opacity-0 backdrop-blur hover:bg-card group-hover:opacity-100 aria-expanded:opacity-100 motion-safe:transition-opacity"
|
|
442
|
-
aria-label=
|
|
480
|
+
aria-label={tCard.home.slideActions}
|
|
443
481
|
>
|
|
444
482
|
<MoreHorizontal className="size-3.5" />
|
|
445
483
|
</button>
|
|
@@ -447,15 +485,15 @@ function SlideCard({
|
|
|
447
485
|
<DropdownMenuContent align="end" className="min-w-[160px]">
|
|
448
486
|
<DropdownMenuItem onSelect={() => setDialog('rename')}>
|
|
449
487
|
<Pencil />
|
|
450
|
-
|
|
488
|
+
{tCard.common.rename}
|
|
451
489
|
</DropdownMenuItem>
|
|
452
490
|
<DropdownMenuItem onSelect={() => setDialog('move')}>
|
|
453
491
|
<FolderInput />
|
|
454
|
-
|
|
492
|
+
{tCard.home.moveToFolder}
|
|
455
493
|
</DropdownMenuItem>
|
|
456
494
|
<DropdownMenuItem variant="destructive" onSelect={() => setDialog('delete')}>
|
|
457
495
|
<Trash2 />
|
|
458
|
-
|
|
496
|
+
{tCard.common.delete}
|
|
459
497
|
</DropdownMenuItem>
|
|
460
498
|
</DropdownMenuContent>
|
|
461
499
|
</DropdownMenu>
|
|
@@ -510,6 +548,7 @@ function RenameDialog({
|
|
|
510
548
|
const [value, setValue] = useState(initialName);
|
|
511
549
|
const [submitting, setSubmitting] = useState(false);
|
|
512
550
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
551
|
+
const t = useLocale();
|
|
513
552
|
|
|
514
553
|
useEffect(() => {
|
|
515
554
|
if (open) {
|
|
@@ -540,9 +579,9 @@ function RenameDialog({
|
|
|
540
579
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
541
580
|
<DialogContent>
|
|
542
581
|
<DialogHeader>
|
|
543
|
-
<span className="eyebrow">
|
|
544
|
-
<DialogTitle>
|
|
545
|
-
<DialogDescription>
|
|
582
|
+
<span className="eyebrow">{t.home.renameDialogEyebrow}</span>
|
|
583
|
+
<DialogTitle>{t.home.renameDialogTitle}</DialogTitle>
|
|
584
|
+
<DialogDescription>{t.home.renameDialogDescription}</DialogDescription>
|
|
546
585
|
</DialogHeader>
|
|
547
586
|
<input
|
|
548
587
|
ref={inputRef}
|
|
@@ -555,15 +594,15 @@ function RenameDialog({
|
|
|
555
594
|
}
|
|
556
595
|
}}
|
|
557
596
|
maxLength={80}
|
|
558
|
-
placeholder=
|
|
597
|
+
placeholder={t.home.slideNamePlaceholder}
|
|
559
598
|
className="h-9 w-full rounded-[6px] border border-border bg-background px-3 text-[13px] outline-none focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
560
599
|
/>
|
|
561
600
|
<DialogFooter>
|
|
562
601
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
|
563
|
-
|
|
602
|
+
{t.common.cancel}
|
|
564
603
|
</Button>
|
|
565
604
|
<Button size="sm" disabled={submitting} onClick={submit}>
|
|
566
|
-
|
|
605
|
+
{t.common.save}
|
|
567
606
|
</Button>
|
|
568
607
|
</DialogFooter>
|
|
569
608
|
</DialogContent>
|
|
@@ -588,6 +627,7 @@ function MoveDialog({
|
|
|
588
627
|
}) {
|
|
589
628
|
const [selected, setSelected] = useState<string | null>(currentFolderId);
|
|
590
629
|
const [submitting, setSubmitting] = useState(false);
|
|
630
|
+
const t = useLocale();
|
|
591
631
|
|
|
592
632
|
useEffect(() => {
|
|
593
633
|
if (open) {
|
|
@@ -613,16 +653,18 @@ function MoveDialog({
|
|
|
613
653
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
614
654
|
<DialogContent>
|
|
615
655
|
<DialogHeader>
|
|
616
|
-
<span className="eyebrow">
|
|
617
|
-
<DialogTitle>
|
|
656
|
+
<span className="eyebrow">{t.home.moveDialogEyebrow}</span>
|
|
657
|
+
<DialogTitle>{t.home.moveDialogTitle}</DialogTitle>
|
|
618
658
|
<DialogDescription>
|
|
619
|
-
|
|
659
|
+
{t.home.moveDialogDescriptionPrefix}
|
|
660
|
+
<span className="font-medium text-foreground">{slideName}</span>
|
|
661
|
+
{t.home.moveDialogDescriptionSuffix}
|
|
620
662
|
</DialogDescription>
|
|
621
663
|
</DialogHeader>
|
|
622
664
|
<div className="max-h-[320px] overflow-y-auto rounded-[6px] border border-border bg-background">
|
|
623
665
|
<FolderOption
|
|
624
666
|
icon={{ type: 'emoji', value: '📝' }}
|
|
625
|
-
label=
|
|
667
|
+
label={t.home.draft}
|
|
626
668
|
active={selected === null}
|
|
627
669
|
onClick={() => setSelected(null)}
|
|
628
670
|
/>
|
|
@@ -638,10 +680,10 @@ function MoveDialog({
|
|
|
638
680
|
</div>
|
|
639
681
|
<DialogFooter>
|
|
640
682
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
|
641
|
-
|
|
683
|
+
{t.common.cancel}
|
|
642
684
|
</Button>
|
|
643
685
|
<Button size="sm" disabled={submitting || selected === currentFolderId} onClick={submit}>
|
|
644
|
-
|
|
686
|
+
{t.common.move}
|
|
645
687
|
</Button>
|
|
646
688
|
</DialogFooter>
|
|
647
689
|
</DialogContent>
|
|
@@ -660,6 +702,7 @@ function FolderOption({
|
|
|
660
702
|
active: boolean;
|
|
661
703
|
onClick: () => void;
|
|
662
704
|
}) {
|
|
705
|
+
const tOpt = useLocale();
|
|
663
706
|
return (
|
|
664
707
|
<button
|
|
665
708
|
type="button"
|
|
@@ -674,7 +717,7 @@ function FolderOption({
|
|
|
674
717
|
{active && (
|
|
675
718
|
<span className="ml-auto inline-flex items-center gap-1 text-[10.5px] text-brand">
|
|
676
719
|
<span className="inline-block size-1 rounded-full bg-brand" aria-hidden />
|
|
677
|
-
|
|
720
|
+
{tOpt.common.selected}
|
|
678
721
|
</span>
|
|
679
722
|
)}
|
|
680
723
|
</button>
|
|
@@ -693,6 +736,7 @@ function DeleteDialog({
|
|
|
693
736
|
onConfirm: () => Promise<void> | void;
|
|
694
737
|
}) {
|
|
695
738
|
const [submitting, setSubmitting] = useState(false);
|
|
739
|
+
const t = useLocale();
|
|
696
740
|
|
|
697
741
|
useEffect(() => {
|
|
698
742
|
if (open) setSubmitting(false);
|
|
@@ -711,20 +755,21 @@ function DeleteDialog({
|
|
|
711
755
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
712
756
|
<DialogContent>
|
|
713
757
|
<DialogHeader>
|
|
714
|
-
<span className="eyebrow text-destructive/80">
|
|
715
|
-
<DialogTitle>
|
|
758
|
+
<span className="eyebrow text-destructive/80">{t.home.deleteDialogEyebrow}</span>
|
|
759
|
+
<DialogTitle>{t.home.deleteDialogTitle}</DialogTitle>
|
|
716
760
|
<DialogDescription>
|
|
717
|
-
|
|
718
|
-
<span className="font-medium text-foreground">{slideName}</span>
|
|
719
|
-
|
|
761
|
+
{t.home.deleteDialogDescriptionPrefix}
|
|
762
|
+
<span className="font-medium text-foreground">{slideName}</span>
|
|
763
|
+
{t.home.deleteDialogDescriptionMid}
|
|
764
|
+
{t.home.deleteDialogDescriptionSuffix}
|
|
720
765
|
</DialogDescription>
|
|
721
766
|
</DialogHeader>
|
|
722
767
|
<DialogFooter>
|
|
723
768
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
|
|
724
|
-
|
|
769
|
+
{t.common.cancel}
|
|
725
770
|
</Button>
|
|
726
771
|
<Button variant="destructive" size="sm" disabled={submitting} onClick={confirm}>
|
|
727
|
-
|
|
772
|
+
{t.common.delete}
|
|
728
773
|
</Button>
|
|
729
774
|
</DialogFooter>
|
|
730
775
|
</DialogContent>
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { ChevronLeft, ChevronRight,
|
|
1
|
+
import { ChevronLeft, ChevronRight, RotateCcw, Square, Sun } from 'lucide-react';
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { useParams } from 'react-router-dom';
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
5
6
|
import { cn } from '@/lib/utils';
|
|
6
7
|
import {
|
|
7
8
|
type PresenterState,
|
|
8
9
|
usePresenterChannel,
|
|
9
10
|
} from '../components/present/use-presenter-channel';
|
|
10
11
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
11
|
-
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
12
12
|
import type { SlideModule } from '../lib/sdk';
|
|
13
|
+
import { CANVAS_HEIGHT, CANVAS_WIDTH } from '../lib/sdk';
|
|
13
14
|
import { loadSlide } from '../lib/slides';
|
|
14
15
|
|
|
15
16
|
export function Presenter() {
|
|
@@ -26,6 +27,7 @@ export function Presenter() {
|
|
|
26
27
|
const [localStart] = useState(() => Date.now());
|
|
27
28
|
const [hasProjection, setHasProjection] = useState(false);
|
|
28
29
|
const requestedRef = useRef(false);
|
|
30
|
+
const t = useLocale();
|
|
29
31
|
|
|
30
32
|
useEffect(() => {
|
|
31
33
|
let cancelled = false;
|
|
@@ -100,8 +102,8 @@ export function Presenter() {
|
|
|
100
102
|
return (
|
|
101
103
|
<div className="grid h-dvh place-items-center bg-zinc-950 p-8 text-zinc-300">
|
|
102
104
|
<div className="max-w-md text-center">
|
|
103
|
-
<span className="eyebrow text-red-300/80">
|
|
104
|
-
<h2 className="mt-2 font-heading text-xl font-semibold">
|
|
105
|
+
<span className="eyebrow text-red-300/80">{t.common.loadFailed}</span>
|
|
106
|
+
<h2 className="mt-2 font-heading text-xl font-semibold">{t.common.failedToLoadSlide}</h2>
|
|
105
107
|
<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">
|
|
106
108
|
{error}
|
|
107
109
|
</pre>
|
|
@@ -113,8 +115,14 @@ export function Presenter() {
|
|
|
113
115
|
if (!slide) {
|
|
114
116
|
return (
|
|
115
117
|
<div className="grid h-dvh place-items-center bg-zinc-950 text-zinc-400">
|
|
116
|
-
<div className="flex items-center gap-
|
|
117
|
-
<
|
|
118
|
+
<div className="flex flex-col items-center gap-4">
|
|
119
|
+
<div className="relative h-px w-56 overflow-hidden bg-white/10">
|
|
120
|
+
<span
|
|
121
|
+
aria-hidden
|
|
122
|
+
className="line-loader-bar absolute inset-y-[-0.5px] left-0 w-1/4 bg-zinc-100"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
<div className="text-[12.5px]">{format(t.presenter.loadingSlide, { slideId })}</div>
|
|
118
126
|
</div>
|
|
119
127
|
</div>
|
|
120
128
|
);
|
|
@@ -145,7 +153,7 @@ export function Presenter() {
|
|
|
145
153
|
<div className="grid min-h-0 flex-1 grid-cols-1 gap-6 px-6 pb-4 lg:grid-cols-[2fr_1fr]">
|
|
146
154
|
{/* Now-showing */}
|
|
147
155
|
<section className="flex min-h-0 flex-col gap-3">
|
|
148
|
-
<SectionLabel>
|
|
156
|
+
<SectionLabel>{t.presenter.nowShowing}</SectionLabel>
|
|
149
157
|
<div className="relative min-h-0 flex-1 overflow-hidden rounded-[8px] bg-black ring-1 ring-white/10">
|
|
150
158
|
<SlideCanvas flat design={slide.design}>
|
|
151
159
|
<CurrentPage />
|
|
@@ -158,7 +166,7 @@ export function Presenter() {
|
|
|
158
166
|
blackout === 'black' ? 'bg-black text-white/35' : 'bg-white text-black/35',
|
|
159
167
|
)}
|
|
160
168
|
>
|
|
161
|
-
{blackout === 'black' ?
|
|
169
|
+
{blackout === 'black' ? t.presenter.blackScreen : t.presenter.whiteScreen}
|
|
162
170
|
</div>
|
|
163
171
|
)}
|
|
164
172
|
</div>
|
|
@@ -167,7 +175,7 @@ export function Presenter() {
|
|
|
167
175
|
{/* Next + notes */}
|
|
168
176
|
<aside className="flex min-h-0 flex-col gap-4">
|
|
169
177
|
<div className="flex flex-col gap-2">
|
|
170
|
-
<SectionLabel>{hasNext ?
|
|
178
|
+
<SectionLabel>{hasNext ? t.presenter.upNext : t.presenter.lastSlide}</SectionLabel>
|
|
171
179
|
<div
|
|
172
180
|
className="relative w-full overflow-hidden rounded-[6px] bg-black ring-1 ring-white/10"
|
|
173
181
|
style={{ aspectRatio: `${CANVAS_WIDTH}/${CANVAS_HEIGHT}` }}
|
|
@@ -178,24 +186,24 @@ export function Presenter() {
|
|
|
178
186
|
</SlideCanvas>
|
|
179
187
|
) : (
|
|
180
188
|
<div className="grid h-full place-items-center text-[11.5px] text-white/40">
|
|
181
|
-
|
|
189
|
+
{t.presenter.endOfDeck}
|
|
182
190
|
</div>
|
|
183
191
|
)}
|
|
184
192
|
</div>
|
|
185
193
|
</div>
|
|
186
194
|
|
|
187
195
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
|
188
|
-
<SectionLabel>
|
|
196
|
+
<SectionLabel>{t.presenter.speakerNotes}</SectionLabel>
|
|
189
197
|
<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">
|
|
190
198
|
{note?.trim() ? (
|
|
191
199
|
note
|
|
192
200
|
) : (
|
|
193
201
|
<span className="text-white/40">
|
|
194
|
-
|
|
202
|
+
{t.presenter.noNotesPrefix}
|
|
195
203
|
<code className="rounded-[3px] bg-white/10 px-1 py-0.5 font-mono text-[12px]">
|
|
196
204
|
export const notes = […]
|
|
197
|
-
</code>
|
|
198
|
-
|
|
205
|
+
</code>
|
|
206
|
+
{t.presenter.noNotesSuffix}
|
|
199
207
|
</span>
|
|
200
208
|
)}
|
|
201
209
|
</div>
|
|
@@ -231,16 +239,17 @@ function PresenterTopBar({
|
|
|
231
239
|
slideTitle: string;
|
|
232
240
|
connected: boolean;
|
|
233
241
|
}) {
|
|
242
|
+
const t = useLocale();
|
|
234
243
|
return (
|
|
235
244
|
<header className="flex shrink-0 items-center justify-between border-b border-white/10 px-6 py-3">
|
|
236
245
|
<div className="flex items-baseline gap-3">
|
|
237
|
-
<span className="eyebrow text-white/45">
|
|
246
|
+
<span className="eyebrow text-white/45">{t.presenter.eyebrow}</span>
|
|
238
247
|
<span className="truncate font-heading text-[14px] font-semibold tracking-tight">
|
|
239
248
|
{slideTitle}
|
|
240
249
|
</span>
|
|
241
250
|
{!connected && (
|
|
242
251
|
<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">
|
|
243
|
-
|
|
252
|
+
{t.presenter.notLinked}
|
|
244
253
|
</span>
|
|
245
254
|
)}
|
|
246
255
|
</div>
|
|
@@ -274,14 +283,15 @@ function PresenterBottomBar({
|
|
|
274
283
|
onBlackout: () => void;
|
|
275
284
|
onWhiteout: () => void;
|
|
276
285
|
}) {
|
|
286
|
+
const t = useLocale();
|
|
277
287
|
return (
|
|
278
288
|
<footer className="flex shrink-0 items-center justify-between gap-3 border-t border-white/10 px-6 py-3">
|
|
279
289
|
<div className="flex items-center gap-2">
|
|
280
290
|
<Button variant="outline" onClick={onPrev} disabled={index === 0}>
|
|
281
|
-
<ChevronLeft className="size-4" />
|
|
291
|
+
<ChevronLeft className="size-4" /> {t.presenter.prev}
|
|
282
292
|
</Button>
|
|
283
293
|
<Button variant="outline" onClick={onNext} disabled={index >= total - 1}>
|
|
284
|
-
|
|
294
|
+
{t.presenter.next} <ChevronRight className="size-4" />
|
|
285
295
|
</Button>
|
|
286
296
|
</div>
|
|
287
297
|
<div className="flex items-center gap-2">
|
|
@@ -290,17 +300,21 @@ function PresenterBottomBar({
|
|
|
290
300
|
onClick={onBlackout}
|
|
291
301
|
aria-pressed={blackout === 'black'}
|
|
292
302
|
>
|
|
293
|
-
<Square className="size-4 fill-current" />
|
|
303
|
+
<Square className="size-4 fill-current" /> {t.presenter.black}
|
|
294
304
|
</Button>
|
|
295
305
|
<Button
|
|
296
306
|
variant={blackout === 'white' ? 'brand' : 'outline'}
|
|
297
307
|
onClick={onWhiteout}
|
|
298
308
|
aria-pressed={blackout === 'white'}
|
|
299
309
|
>
|
|
300
|
-
<Sun className="size-4" />
|
|
310
|
+
<Sun className="size-4" /> {t.presenter.white}
|
|
301
311
|
</Button>
|
|
302
|
-
<Button
|
|
303
|
-
|
|
312
|
+
<Button
|
|
313
|
+
variant="ghost"
|
|
314
|
+
onClick={() => window.location.reload()}
|
|
315
|
+
title={t.presenter.resetTimer}
|
|
316
|
+
>
|
|
317
|
+
<RotateCcw className="size-4" /> {t.presenter.reset}
|
|
304
318
|
</Button>
|
|
305
319
|
</div>
|
|
306
320
|
</footer>
|
|
@@ -317,6 +331,7 @@ function PresenterJumpControl({
|
|
|
317
331
|
onJump: (index: number) => void;
|
|
318
332
|
}) {
|
|
319
333
|
const [value, setValue] = useState('');
|
|
334
|
+
const t = useLocale();
|
|
320
335
|
return (
|
|
321
336
|
<form
|
|
322
337
|
onSubmit={(e) => {
|
|
@@ -329,7 +344,7 @@ function PresenterJumpControl({
|
|
|
329
344
|
}}
|
|
330
345
|
className="flex items-center gap-2"
|
|
331
346
|
>
|
|
332
|
-
<SectionLabel>
|
|
347
|
+
<SectionLabel>{t.presenter.jump}</SectionLabel>
|
|
333
348
|
<input
|
|
334
349
|
type="number"
|
|
335
350
|
min={1}
|
|
@@ -350,12 +365,16 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
|
350
365
|
|
|
351
366
|
function Clock() {
|
|
352
367
|
const [now, setNow] = useState(() => new Date());
|
|
368
|
+
const t = useLocale();
|
|
353
369
|
useEffect(() => {
|
|
354
370
|
const id = setInterval(() => setNow(new Date()), 1000);
|
|
355
371
|
return () => clearInterval(id);
|
|
356
372
|
}, []);
|
|
357
373
|
return (
|
|
358
|
-
<time
|
|
374
|
+
<time
|
|
375
|
+
title={t.presenter.currentTime}
|
|
376
|
+
className="font-mono text-[12px] tabular-nums text-white/55"
|
|
377
|
+
>
|
|
359
378
|
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
360
379
|
</time>
|
|
361
380
|
);
|
|
@@ -363,6 +382,7 @@ function Clock() {
|
|
|
363
382
|
|
|
364
383
|
function ElapsedClock({ startedAt }: { startedAt: number }) {
|
|
365
384
|
const [now, setNow] = useState(() => Date.now());
|
|
385
|
+
const t = useLocale();
|
|
366
386
|
useEffect(() => {
|
|
367
387
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
368
388
|
return () => clearInterval(id);
|
|
@@ -376,7 +396,7 @@ function ElapsedClock({ startedAt }: { startedAt: number }) {
|
|
|
376
396
|
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
|
377
397
|
: `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
378
398
|
return (
|
|
379
|
-
<time title=
|
|
399
|
+
<time title={t.presenter.elapsed} className="font-mono text-[18px] tabular-nums text-white">
|
|
380
400
|
{text}
|
|
381
401
|
</time>
|
|
382
402
|
);
|