@open-slide/core 1.0.4 → 1.0.6

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.
Files changed (55) hide show
  1. package/dist/{build-DqfKmw9h.js → build-4wOJF1l4.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-DweCbRkQ.d.ts → config-D2y1AXaN.d.ts} +3 -0
  4. package/dist/{config-CN7J0RDO.js → config-evLWCV1-.js} +378 -222
  5. package/dist/{dev-jWxtWHAG.js → dev-BUr0S-Ij.js} +1 -1
  6. package/dist/index.d.ts +3 -2
  7. package/dist/locale/index.d.ts +24 -0
  8. package/dist/locale/index.js +1189 -0
  9. package/dist/{preview-CSA05Gfm.js → preview-DP_gIphz.js} +1 -1
  10. package/dist/types-BVvl_xup.d.ts +314 -0
  11. package/dist/vite/index.d.ts +2 -1
  12. package/dist/vite/index.js +1 -1
  13. package/package.json +7 -1
  14. package/src/app/app.tsx +6 -2
  15. package/src/app/components/asset-view.tsx +87 -64
  16. package/src/app/components/click-nav-zones.tsx +4 -2
  17. package/src/app/components/inspector/comment-widget.tsx +9 -7
  18. package/src/app/components/inspector/inspect-overlay.tsx +79 -17
  19. package/src/app/components/inspector/inspector-panel.tsx +68 -39
  20. package/src/app/components/inspector/inspector-provider.tsx +185 -58
  21. package/src/app/components/inspector/save-bar.tsx +6 -5
  22. package/src/app/components/panel/save-card.tsx +12 -9
  23. package/src/app/components/pdf-progress-toast.tsx +11 -4
  24. package/src/app/components/player.tsx +7 -25
  25. package/src/app/components/present/control-bar.tsx +17 -10
  26. package/src/app/components/present/help-overlay.tsx +18 -17
  27. package/src/app/components/present/overview-grid.tsx +6 -9
  28. package/src/app/components/present/use-presenter-channel.ts +3 -10
  29. package/src/app/components/sidebar/folder-item.tsx +16 -9
  30. package/src/app/components/sidebar/icon-picker.tsx +4 -5
  31. package/src/app/components/sidebar/sidebar.tsx +87 -25
  32. package/src/app/components/slide-canvas.tsx +1 -10
  33. package/src/app/components/style-panel/design-provider.tsx +2 -6
  34. package/src/app/components/style-panel/style-panel.tsx +26 -18
  35. package/src/app/components/theme-toggle.tsx +7 -5
  36. package/src/app/components/thumbnail-rail.tsx +4 -2
  37. package/src/app/favicon.ico +0 -0
  38. package/src/app/lib/export-html.ts +1 -9
  39. package/src/app/lib/export-pdf.ts +0 -5
  40. package/src/app/lib/inspector/use-editor.ts +9 -7
  41. package/src/app/lib/print-ready.ts +0 -4
  42. package/src/app/lib/sdk.ts +1 -2
  43. package/src/app/lib/use-locale.ts +20 -0
  44. package/src/app/routes/home.tsx +90 -45
  45. package/src/app/routes/presenter.tsx +45 -25
  46. package/src/app/routes/slide.tsx +37 -24
  47. package/src/app/styles.css +28 -0
  48. package/src/app/virtual.d.ts +4 -0
  49. package/src/locale/en.ts +303 -0
  50. package/src/locale/format.ts +12 -0
  51. package/src/locale/index.ts +6 -0
  52. package/src/locale/ja.ts +307 -0
  53. package/src/locale/types.ts +323 -0
  54. package/src/locale/zh-cn.ts +303 -0
  55. package/src/locale/zh-tw.ts +303 -0
@@ -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 ?? 'Draft';
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
- remove(id);
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) => assign(slideId, folderId)}
106
- onDropToDraft={(slideId) => assign(slideId, null)}
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">open-slide</h1>
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="Draft"
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="Search slides"
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="Clear search"
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">No matches</p>
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
- Nothing matches <span className="font-medium text-foreground">&ldquo;{query}&rdquo;</span>{' '}
256
- in this folder.
286
+ {t.home.nothingMatchesPrefix}
287
+ <span className="font-medium text-foreground">&ldquo;{query}&rdquo;</span>
288
+ {t.home.nothingMatchesSuffix}
257
289
  </p>
258
290
  <Button variant="ghost" size="sm" className="mt-4" onClick={onClear}>
259
- Clear search
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
- No slides yet
313
+ {t.home.noSlidesYet}
277
314
  </p>
278
315
  <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
279
- Create{' '}
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
- that{' '}
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
- {folderName ?? 'This folder'} is empty
330
+ {folderEmptyTitle}
294
331
  </p>
295
332
  <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
296
- Drag a slide from Draft into this folder in the sidebar.
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
- Loading
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="Slide actions"
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
- Rename
488
+ {tCard.common.rename}
451
489
  </DropdownMenuItem>
452
490
  <DropdownMenuItem onSelect={() => setDialog('move')}>
453
491
  <FolderInput />
454
- Move to folder…
492
+ {tCard.home.moveToFolder}
455
493
  </DropdownMenuItem>
456
494
  <DropdownMenuItem variant="destructive" onSelect={() => setDialog('delete')}>
457
495
  <Trash2 />
458
- Delete
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">Rename</span>
544
- <DialogTitle>Rename slide</DialogTitle>
545
- <DialogDescription>Give this slide a new display name.</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="Slide name"
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
- Cancel
602
+ {t.common.cancel}
564
603
  </Button>
565
604
  <Button size="sm" disabled={submitting} onClick={submit}>
566
- Save
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">Move</span>
617
- <DialogTitle>Move slide</DialogTitle>
656
+ <span className="eyebrow">{t.home.moveDialogEyebrow}</span>
657
+ <DialogTitle>{t.home.moveDialogTitle}</DialogTitle>
618
658
  <DialogDescription>
619
- Choose a folder for <span className="font-medium text-foreground">{slideName}</span>.
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="Draft"
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
- Cancel
683
+ {t.common.cancel}
642
684
  </Button>
643
685
  <Button size="sm" disabled={submitting || selected === currentFolderId} onClick={submit}>
644
- Move
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
- Selected
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">Destructive</span>
715
- <DialogTitle>Delete slide?</DialogTitle>
758
+ <span className="eyebrow text-destructive/80">{t.home.deleteDialogEyebrow}</span>
759
+ <DialogTitle>{t.home.deleteDialogTitle}</DialogTitle>
716
760
  <DialogDescription>
717
- This permanently removes{' '}
718
- <span className="font-medium text-foreground">{slideName}</span> and its files from
719
- disk. This action cannot be undone.
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
- Cancel
769
+ {t.common.cancel}
725
770
  </Button>
726
771
  <Button variant="destructive" size="sm" disabled={submitting} onClick={confirm}>
727
- Delete
772
+ {t.common.delete}
728
773
  </Button>
729
774
  </DialogFooter>
730
775
  </DialogContent>
@@ -1,15 +1,16 @@
1
- import { ChevronLeft, ChevronRight, Loader2, RotateCcw, Square, Sun } from 'lucide-react';
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">Load failed</span>
104
- <h2 className="mt-2 font-heading text-xl font-semibold">Failed to load slide</h2>
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-2 text-[12.5px]">
117
- <Loader2 className="size-4 animate-spin" /> Loading {slideId}…
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>Now showing</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' ? 'Black screen' : 'White screen'}
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 ? 'Up next' : 'Last slide'}</SectionLabel>
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
- End of deck
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>Speaker notes</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
- No speaker notes for this slide. Add{' '}
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
- to your slide module to see notes here.
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">Presenter</span>
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
- Not linked
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" /> Prev
291
+ <ChevronLeft className="size-4" /> {t.presenter.prev}
282
292
  </Button>
283
293
  <Button variant="outline" onClick={onNext} disabled={index >= total - 1}>
284
- Next <ChevronRight className="size-4" />
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" /> Black
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" /> White
310
+ <Sun className="size-4" /> {t.presenter.white}
301
311
  </Button>
302
- <Button variant="ghost" onClick={() => window.location.reload()} title="Reset timer">
303
- <RotateCcw className="size-4" /> Reset
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>Jump</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 title="Current time" className="font-mono text-[12px] tabular-nums text-white/55">
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="Elapsed" className="font-mono text-[18px] tabular-nums text-white">
399
+ <time title={t.presenter.elapsed} className="font-mono text-[18px] tabular-nums text-white">
380
400
  {text}
381
401
  </time>
382
402
  );