@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.
Files changed (46) hide show
  1. package/dist/{build-DqfKmw9h.js → build-CoON6kTb.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-CN7J0RDO.js → config-Bxtztw-H.js} +373 -221
  4. package/dist/{config-DweCbRkQ.d.ts → config-D2y1AXaN.d.ts} +3 -0
  5. package/dist/{dev-jWxtWHAG.js → dev-IezNC17X.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-BwYjtENY.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/inspector-panel.tsx +68 -39
  19. package/src/app/components/inspector/inspector-provider.tsx +185 -58
  20. package/src/app/components/inspector/save-bar.tsx +6 -2
  21. package/src/app/components/panel/save-card.tsx +12 -9
  22. package/src/app/components/pdf-progress-toast.tsx +11 -4
  23. package/src/app/components/present/control-bar.tsx +17 -10
  24. package/src/app/components/present/help-overlay.tsx +18 -17
  25. package/src/app/components/present/overview-grid.tsx +6 -4
  26. package/src/app/components/sidebar/folder-item.tsx +16 -7
  27. package/src/app/components/sidebar/icon-picker.tsx +4 -2
  28. package/src/app/components/sidebar/sidebar.tsx +87 -25
  29. package/src/app/components/style-panel/style-panel.tsx +26 -18
  30. package/src/app/components/theme-toggle.tsx +7 -5
  31. package/src/app/components/thumbnail-rail.tsx +4 -2
  32. package/src/app/favicon.ico +0 -0
  33. package/src/app/lib/inspector/use-editor.ts +9 -7
  34. package/src/app/lib/use-locale.ts +20 -0
  35. package/src/app/routes/home.tsx +90 -45
  36. package/src/app/routes/presenter.tsx +45 -25
  37. package/src/app/routes/slide.tsx +37 -24
  38. package/src/app/styles.css +28 -0
  39. package/src/app/virtual.d.ts +4 -0
  40. package/src/locale/en.ts +303 -0
  41. package/src/locale/format.ts +12 -0
  42. package/src/locale/index.ts +6 -0
  43. package/src/locale/ja.ts +307 -0
  44. package/src/locale/types.ts +323 -0
  45. package/src/locale/zh-cn.ts +303 -0
  46. package/src/locale/zh-tw.ts +303 -0
@@ -37,6 +37,7 @@ import {
37
37
  searchSvgl,
38
38
  useAssets,
39
39
  } from '@/lib/assets';
40
+ import { format, useLocale } from '@/lib/use-locale';
40
41
  import { cn } from '@/lib/utils';
41
42
 
42
43
  type Props = { slideId: string };
@@ -56,6 +57,7 @@ export function AssetView({ slideId }: Props) {
56
57
  const [logoSearchOpen, setLogoSearchOpen] = useState(false);
57
58
  const dragDepth = useRef(0);
58
59
  const inputId = useId();
60
+ const t = useLocale();
59
61
 
60
62
  const existingNames = new Set(assets.map((a) => a.name));
61
63
 
@@ -68,19 +70,19 @@ export function AssetView({ slideId }: Props) {
68
70
  if (decision === 'cancel') return;
69
71
  if (decision === 'replace') {
70
72
  const res = await upload(file, { overwrite: true });
71
- if (!res.ok) toast.error(`Upload failed (${res.status})`);
72
- else toast.success(`Replaced ${file.name}`);
73
+ if (!res.ok) toast.error(format(t.asset.toastUploadFailed, { status: res.status }));
74
+ else toast.success(format(t.asset.toastReplaced, { name: file.name }));
73
75
  return;
74
76
  }
75
77
  const next = renamedCopy(file, existingNames);
76
78
  const res = await upload(next, { overwrite: false });
77
- if (!res.ok) toast.error(`Upload failed (${res.status})`);
78
- else toast.success(`Uploaded as ${next.name}`);
79
+ if (!res.ok) toast.error(format(t.asset.toastUploadFailed, { status: res.status }));
80
+ else toast.success(format(t.asset.toastUploadedAs, { name: next.name }));
79
81
  return;
80
82
  }
81
83
  const res = await upload(file);
82
- if (!res.ok) toast.error(`Upload failed (${res.status})`);
83
- else toast.success(`Uploaded ${file.name}`);
84
+ if (!res.ok) toast.error(format(t.asset.toastUploadFailed, { status: res.status }));
85
+ else toast.success(format(t.asset.toastUploaded, { name: file.name }));
84
86
  }
85
87
 
86
88
  async function handleFiles(files: FileList | File[]) {
@@ -95,14 +97,14 @@ export function AssetView({ slideId }: Props) {
95
97
  if (!available) {
96
98
  return (
97
99
  <div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">
98
- Asset management is only available in dev mode.
100
+ {t.asset.devOnlyMessage}
99
101
  </div>
100
102
  );
101
103
  }
102
104
 
103
105
  return (
104
106
  <section
105
- aria-label="Slide assets"
107
+ aria-label={t.asset.sectionAria}
106
108
  className={cn('relative flex h-full flex-col bg-background')}
107
109
  onDragEnter={(e) => {
108
110
  if (!hasFiles(e)) return;
@@ -131,16 +133,16 @@ export function AssetView({ slideId }: Props) {
131
133
  >
132
134
  <div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline bg-sidebar px-6 py-3">
133
135
  <div className="min-w-0">
134
- <span className="eyebrow">Assets</span>
136
+ <span className="eyebrow">{t.asset.eyebrow}</span>
135
137
  <p className="mt-0.5 truncate text-[12px] text-muted-foreground">
136
138
  <span className="font-mono text-[11.5px]">slides/{slideId}/assets/</span>
137
139
  {!loading && (
138
140
  <>
139
141
  <span className="mx-2 opacity-50">·</span>
140
142
  <span className="folio">
141
- {assets.length.toString().padStart(2, '0')}
142
- <span className="opacity-40"> </span>
143
- {assets.length === 1 ? 'file' : 'files'}
143
+ {format(assets.length === 1 ? t.asset.fileCount.one : t.asset.fileCount.other, {
144
+ count: assets.length.toString().padStart(2, '0'),
145
+ })}
144
146
  </span>
145
147
  </>
146
148
  )}
@@ -156,7 +158,7 @@ export function AssetView({ slideId }: Props) {
156
158
  )}
157
159
  >
158
160
  <Search className="size-3.5" />
159
- <span>Search logos</span>
161
+ <span>{t.asset.searchLogos}</span>
160
162
  </button>
161
163
  <label
162
164
  htmlFor={inputId}
@@ -167,7 +169,7 @@ export function AssetView({ slideId }: Props) {
167
169
  )}
168
170
  >
169
171
  <Upload className="size-3.5" />
170
- <span>Upload</span>
172
+ <span>{t.asset.upload}</span>
171
173
  </label>
172
174
  <input
173
175
  id={inputId}
@@ -187,7 +189,7 @@ export function AssetView({ slideId }: Props) {
187
189
  <div className="min-h-0 flex-1 overflow-y-auto">
188
190
  {loading ? (
189
191
  <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
190
- Loading…
192
+ {t.asset.loading}
191
193
  </div>
192
194
  ) : assets.length === 0 ? (
193
195
  <EmptyState />
@@ -205,15 +207,15 @@ export function AssetView({ slideId }: Props) {
205
207
  return;
206
208
  }
207
209
  if (existingNames.has(next)) {
208
- toast.error('A file with that name already exists.');
210
+ toast.error(t.asset.nameAlreadyExists);
209
211
  return;
210
212
  }
211
213
  const res = await rename(asset.name, next);
212
214
  if (!res.ok) {
213
- toast.error(`Rename failed (${res.status})`);
215
+ toast.error(format(t.asset.toastRenameFailed, { status: res.status }));
214
216
  return;
215
217
  }
216
- toast.success(`Renamed to ${next}`);
218
+ toast.success(format(t.asset.toastRenamed, { name: next }));
217
219
  setRenaming(null);
218
220
  }}
219
221
  />
@@ -241,7 +243,7 @@ export function AssetView({ slideId }: Props) {
241
243
  <div className="absolute inset-x-0 bottom-8 flex justify-center">
242
244
  <div className="flex animate-in items-center gap-2 rounded-[6px] border border-border bg-card px-3 py-1.5 text-[12px] font-medium shadow-floating fade-in-0 slide-in-from-bottom-1 duration-300">
243
245
  <ArrowDownToLine className="size-3.5 text-brand" />
244
- <span>Drop to upload</span>
246
+ <span>{t.asset.dropToUpload}</span>
245
247
  </div>
246
248
  </div>
247
249
  </div>
@@ -265,8 +267,8 @@ export function AssetView({ slideId }: Props) {
265
267
  const target = confirmDelete;
266
268
  setConfirmDelete(null);
267
269
  const res = await remove(target.name);
268
- if (!res.ok) toast.error(`Delete failed (${res.status})`);
269
- else toast.success(`Deleted ${target.name}`);
270
+ if (!res.ok) toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
271
+ else toast.success(format(t.asset.toastDeleted, { name: target.name }));
270
272
  }}
271
273
  />
272
274
  )}
@@ -284,16 +286,20 @@ export function AssetView({ slideId }: Props) {
284
286
  }
285
287
 
286
288
  function EmptyState() {
289
+ const t = useLocale();
287
290
  return (
288
291
  <div className="flex h-full flex-col items-center justify-center gap-4 px-6 py-16 text-center">
289
292
  <div className="flex size-12 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground">
290
293
  <ImageIcon className="size-5" />
291
294
  </div>
292
295
  <div>
293
- <p className="font-heading text-[14px] font-semibold tracking-tight">No assets yet</p>
296
+ <p className="font-heading text-[14px] font-semibold tracking-tight">
297
+ {t.asset.noAssetsYet}
298
+ </p>
294
299
  <p className="mt-1 max-w-xs text-[12.5px] leading-relaxed text-muted-foreground">
295
- Drop files anywhere here, or use <span className="font-mono text-foreground">Upload</span>
296
- .
300
+ {t.asset.noAssetsHintPrefix}
301
+ <span className="font-mono text-foreground">{t.asset.upload}</span>
302
+ {t.asset.noAssetsHintSuffix}
297
303
  </p>
298
304
  </div>
299
305
  </div>
@@ -334,12 +340,13 @@ function AssetCard({
334
340
  onDelete: () => void;
335
341
  }) {
336
342
  const isImage = asset.mime.startsWith('image/');
343
+ const t = useLocale();
337
344
  return (
338
345
  <div className="group relative flex flex-col overflow-hidden rounded-[6px] border border-border bg-card shadow-edge transition-shadow hover:shadow-floating focus-within:ring-2 focus-within:ring-ring/30">
339
346
  <button
340
347
  type="button"
341
348
  onClick={onPreview}
342
- aria-label={`Preview ${asset.name}`}
349
+ aria-label={format(t.asset.previewAria, { name: asset.name })}
343
350
  className="relative flex aspect-square w-full items-center justify-center overflow-hidden border-b border-hairline bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:14px_14px]"
344
351
  >
345
352
  {isImage ? (
@@ -367,7 +374,7 @@ function AssetCard({
367
374
  <DropdownMenu>
368
375
  <DropdownMenuTrigger
369
376
  type="button"
370
- aria-label={`Actions for ${asset.name}`}
377
+ aria-label={format(t.asset.actionsAria, { name: asset.name })}
371
378
  className={cn(
372
379
  buttonVariants({ variant: 'ghost', size: 'icon-xs' }),
373
380
  'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 aria-expanded:opacity-100',
@@ -378,15 +385,15 @@ function AssetCard({
378
385
  <DropdownMenuContent align="end" className="min-w-[160px]">
379
386
  <DropdownMenuItem onSelect={onPreview}>
380
387
  <ImageIcon />
381
- Preview
388
+ {t.asset.previewMenuItem}
382
389
  </DropdownMenuItem>
383
390
  <DropdownMenuItem onSelect={onRename}>
384
391
  <Pencil />
385
- Rename
392
+ {t.asset.renameMenuItem}
386
393
  </DropdownMenuItem>
387
394
  <DropdownMenuItem onSelect={onDelete}>
388
395
  <Trash2 />
389
- Delete
396
+ {t.asset.deleteMenuItem}
390
397
  </DropdownMenuItem>
391
398
  </DropdownMenuContent>
392
399
  </DropdownMenu>
@@ -474,23 +481,27 @@ function ConflictDialog({
474
481
  file: File;
475
482
  onChoose: (decision: 'replace' | 'rename' | 'cancel') => void;
476
483
  }) {
484
+ const t = useLocale();
485
+ const [descPrefix, descSuffix] = t.asset.conflictDescription.split('{name}');
477
486
  return (
478
487
  <Dialog open onOpenChange={(open) => !open && onChoose('cancel')}>
479
488
  <DialogContent>
480
489
  <DialogHeader>
481
- <DialogTitle>File already exists</DialogTitle>
490
+ <DialogTitle>{t.asset.conflictTitle}</DialogTitle>
482
491
  <DialogDescription>
483
- <span className="font-mono">{file.name}</span> is already in this slide's assets folder.
492
+ {descPrefix}
493
+ <span className="font-mono">{file.name}</span>
494
+ {descSuffix}
484
495
  </DialogDescription>
485
496
  </DialogHeader>
486
497
  <DialogFooter>
487
498
  <Button variant="outline" onClick={() => onChoose('cancel')}>
488
- Cancel
499
+ {t.common.cancel}
489
500
  </Button>
490
501
  <Button variant="outline" onClick={() => onChoose('rename')}>
491
- Rename copy
502
+ {t.asset.conflictRenameCopy}
492
503
  </Button>
493
- <Button onClick={() => onChoose('replace')}>Replace</Button>
504
+ <Button onClick={() => onChoose('replace')}>{t.asset.conflictReplace}</Button>
494
505
  </DialogFooter>
495
506
  </DialogContent>
496
507
  </Dialog>
@@ -506,22 +517,25 @@ function DeleteDialog({
506
517
  onCancel: () => void;
507
518
  onConfirm: () => void;
508
519
  }) {
520
+ const t = useLocale();
521
+ const [descPrefix, descSuffix] = t.asset.deleteAssetDescription.split('{name}');
509
522
  return (
510
523
  <Dialog open onOpenChange={(open) => !open && onCancel()}>
511
524
  <DialogContent>
512
525
  <DialogHeader>
513
- <DialogTitle>Delete asset</DialogTitle>
526
+ <DialogTitle>{t.asset.deleteAssetTitle}</DialogTitle>
514
527
  <DialogDescription>
515
- Delete <span className="font-mono">{asset.name}</span>? Imports referencing this file in
516
- the slide will break.
528
+ {descPrefix}
529
+ <span className="font-mono">{asset.name}</span>
530
+ {descSuffix}
517
531
  </DialogDescription>
518
532
  </DialogHeader>
519
533
  <DialogFooter>
520
534
  <Button variant="outline" onClick={onCancel}>
521
- Cancel
535
+ {t.common.cancel}
522
536
  </Button>
523
537
  <Button variant="destructive" onClick={onConfirm}>
524
- Delete
538
+ {t.common.delete}
525
539
  </Button>
526
540
  </DialogFooter>
527
541
  </DialogContent>
@@ -529,9 +543,21 @@ function DeleteDialog({
529
543
  );
530
544
  }
531
545
 
546
+ function NoResultsMessage({ query, t }: { query: string; t: ReturnType<typeof useLocale> }) {
547
+ const [prefix, suffix] = t.asset.logoSearchNoResults.split('{query}');
548
+ return (
549
+ <>
550
+ {prefix}
551
+ <span className="font-mono text-foreground">{query}</span>
552
+ {suffix}
553
+ </>
554
+ );
555
+ }
556
+
532
557
  function PreviewDialog({ asset, onClose }: { asset: AssetEntry; onClose: () => void }) {
533
558
  const isImage = asset.mime.startsWith('image/');
534
559
  const importPath = `./assets/${asset.name}`;
560
+ const t = useLocale();
535
561
  return (
536
562
  <Dialog open onOpenChange={(open) => !open && onClose()}>
537
563
  <DialogContent className="sm:max-w-2xl">
@@ -552,13 +578,13 @@ function PreviewDialog({ asset, onClose }: { asset: AssetEntry; onClose: () => v
552
578
  ) : (
553
579
  <div className="flex items-center justify-center rounded-md border bg-muted/40 py-12 text-muted-foreground">
554
580
  <FileImage className="mr-2 size-5" />
555
- <span className="text-sm">No preview available</span>
581
+ <span className="text-sm">{t.asset.noPreview}</span>
556
582
  </div>
557
583
  )}
558
584
  <div className="rounded-[5px] border border-hairline bg-muted/50 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
559
- <span className="text-muted-foreground">import asset from </span>
585
+ <span className="text-muted-foreground">{t.asset.importHintComment}</span>
560
586
  <span className="text-brand">'{importPath}'</span>
561
- <span className="text-muted-foreground">;</span>
587
+ <span className="text-muted-foreground">{t.asset.importHintSemi}</span>
562
588
  </div>
563
589
  </DialogContent>
564
590
  </Dialog>
@@ -581,6 +607,7 @@ function LogoSearchDialog({
581
607
  const [pending, setPending] = useState<Set<number>>(() => new Set());
582
608
  const [retryToken, setRetryToken] = useState(0);
583
609
  const inputRef = useRef<HTMLInputElement | null>(null);
610
+ const t = useLocale();
584
611
 
585
612
  useEffect(() => {
586
613
  queueMicrotask(() => inputRef.current?.focus());
@@ -599,7 +626,7 @@ function LogoSearchDialog({
599
626
  })
600
627
  .catch((err: unknown) => {
601
628
  if (ctrl.signal.aborted) return;
602
- setError(err instanceof Error ? err.message : 'Search failed');
629
+ setError(err instanceof Error ? err.message : t.asset.toastSearchFailed);
603
630
  setLoading(false);
604
631
  });
605
632
  }, 200);
@@ -613,9 +640,9 @@ function LogoSearchDialog({
613
640
  <Dialog open onOpenChange={(open) => !open && onClose()}>
614
641
  <DialogContent className="sm:max-w-2xl">
615
642
  <DialogHeader>
616
- <DialogTitle>Search logos</DialogTitle>
643
+ <DialogTitle>{t.asset.logoSearchTitle}</DialogTitle>
617
644
  <DialogDescription>
618
- Powered by{' '}
645
+ {t.asset.logoSearchPoweredByPrefix}
619
646
  <a
620
647
  href="https://svgl.app"
621
648
  target="_blank"
@@ -634,7 +661,7 @@ function LogoSearchDialog({
634
661
  ref={inputRef}
635
662
  value={query}
636
663
  onChange={(e) => setQuery(e.target.value)}
637
- placeholder="Search by brand…"
664
+ placeholder={t.asset.logoSearchPlaceholder}
638
665
  className="h-9 w-full rounded-[6px] border border-border bg-background py-2 pl-8 pr-3 text-[13px] outline-none focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30"
639
666
  />
640
667
  </div>
@@ -646,10 +673,8 @@ function LogoSearchDialog({
646
673
  <CloudOff className="size-5 text-muted-foreground" />
647
674
  </div>
648
675
  <div>
649
- <p className="text-sm font-medium">Couldn't reach svgl</p>
650
- <p className="mt-1 text-xs text-muted-foreground">
651
- Check your connection and try again.
652
- </p>
676
+ <p className="text-sm font-medium">{t.asset.logoSearchErrorTitle}</p>
677
+ <p className="mt-1 text-xs text-muted-foreground">{t.asset.logoSearchErrorBody}</p>
653
678
  </div>
654
679
  <Button
655
680
  variant="outline"
@@ -658,7 +683,7 @@ function LogoSearchDialog({
658
683
  className="gap-1.5"
659
684
  >
660
685
  <RotateCw className="size-3.5" />
661
- Try again
686
+ {t.common.tryAgain}
662
687
  </Button>
663
688
  </div>
664
689
  ) : loading && !results ? (
@@ -678,16 +703,13 @@ function LogoSearchDialog({
678
703
  <div>
679
704
  <p className="text-sm font-medium">
680
705
  {query.trim() ? (
681
- <>
682
- No logos for{' '}
683
- <span className="font-mono text-foreground">"{query.trim()}"</span>
684
- </>
706
+ <NoResultsMessage query={query.trim()} t={t} />
685
707
  ) : (
686
- 'No logos available'
708
+ t.asset.logoSearchEmpty
687
709
  )}
688
710
  </p>
689
711
  <p className="mt-1 text-xs text-muted-foreground">
690
- Try a different brand name, or browse the full catalog at{' '}
712
+ {t.asset.logoSearchEmptyHintPrefix}
691
713
  <a
692
714
  href="https://svgl.app"
693
715
  target="_blank"
@@ -696,7 +718,7 @@ function LogoSearchDialog({
696
718
  >
697
719
  svgl.app
698
720
  </a>
699
- .
721
+ {t.asset.logoSearchEmptyHintSuffix}
700
722
  </p>
701
723
  </div>
702
724
  </div>
@@ -716,7 +738,7 @@ function LogoSearchDialog({
716
738
  try {
717
739
  await onPick(file);
718
740
  } catch (err) {
719
- toast.error(err instanceof Error ? err.message : 'Failed to download logo');
741
+ toast.error(err instanceof Error ? err.message : t.asset.toastDownloadFailed);
720
742
  } finally {
721
743
  setPending((prev) => {
722
744
  const next = new Set(prev);
@@ -733,7 +755,7 @@ function LogoSearchDialog({
733
755
 
734
756
  <DialogFooter>
735
757
  <Button variant="outline" onClick={onClose}>
736
- Done
758
+ {t.common.done}
737
759
  </Button>
738
760
  </DialogFooter>
739
761
  </DialogContent>
@@ -752,6 +774,7 @@ function LogoResultCard({
752
774
  }) {
753
775
  const hasVariants = typeof item.route === 'object' && item.route !== null;
754
776
  const [variant, setVariant] = useState<'light' | 'dark'>('light');
777
+ const t = useLocale();
755
778
 
756
779
  const previewUrl = useMemo(() => {
757
780
  if (typeof item.route === 'string') return item.route;
@@ -796,7 +819,7 @@ function LogoResultCard({
796
819
  variant === 'light' ? 'bg-foreground text-background' : 'hover:bg-muted',
797
820
  )}
798
821
  >
799
- Light
822
+ {t.asset.logoVariantLight}
800
823
  </button>
801
824
  <button
802
825
  type="button"
@@ -806,7 +829,7 @@ function LogoResultCard({
806
829
  variant === 'dark' ? 'bg-foreground text-background' : 'hover:bg-muted',
807
830
  )}
808
831
  >
809
- Dark
832
+ {t.asset.logoVariantDark}
810
833
  </button>
811
834
  </div>
812
835
  ) : null}
@@ -819,12 +842,12 @@ function LogoResultCard({
819
842
  const file = await fetchSvgAsFile(previewUrl, filename);
820
843
  await onAdd(file);
821
844
  } catch (err) {
822
- toast.error(err instanceof Error ? err.message : 'Failed to download logo');
845
+ toast.error(err instanceof Error ? err.message : t.asset.toastDownloadFailed);
823
846
  }
824
847
  }}
825
848
  className="ml-auto h-6 px-2 text-[11px]"
826
849
  >
827
- {pending ? <Loader2 className="size-3 animate-spin" /> : 'Add'}
850
+ {pending ? <Loader2 className="size-3 animate-spin" /> : t.common.add}
828
851
  </Button>
829
852
  </div>
830
853
  </div>
@@ -1,3 +1,4 @@
1
+ import { useLocale } from '@/lib/use-locale';
1
2
  import { useInspector } from './inspector/inspector-provider';
2
3
 
3
4
  type Props = {
@@ -9,13 +10,14 @@ type Props = {
9
10
 
10
11
  export function ClickNavZones({ onPrev, onNext, canPrev, canNext }: Props) {
11
12
  const { active } = useInspector();
13
+ const t = useLocale();
12
14
  if (active) return null;
13
15
 
14
16
  return (
15
17
  <>
16
18
  <button
17
19
  type="button"
18
- aria-label="Previous page"
20
+ aria-label={t.clickNav.prevAria}
19
21
  onClick={onPrev}
20
22
  disabled={!canPrev}
21
23
  data-inspector-ui
@@ -23,7 +25,7 @@ export function ClickNavZones({ onPrev, onNext, canPrev, canNext }: Props) {
23
25
  />
24
26
  <button
25
27
  type="button"
26
- aria-label="Next page"
28
+ aria-label={t.clickNav.nextAria}
27
29
  onClick={onNext}
28
30
  disabled={!canNext}
29
31
  data-inspector-ui
@@ -1,8 +1,10 @@
1
1
  import { MessageSquare, Trash2, X } from 'lucide-react';
2
2
  import { useState } from 'react';
3
+ import { format, plural, useLocale } from '@/lib/use-locale';
3
4
  import { useInspector } from './inspector-provider';
4
5
 
5
6
  export function CommentWidget() {
7
+ const t = useLocale();
6
8
  const { comments, remove, error } = useInspector();
7
9
  const [open, setOpen] = useState(false);
8
10
  const count = comments.length;
@@ -13,7 +15,7 @@ export function CommentWidget() {
13
15
  <div className="w-80 rounded-md border bg-card shadow-xl animate-in fade-in-0 slide-in-from-bottom-2 duration-200">
14
16
  <div className="flex items-center justify-between border-b px-3 py-2">
15
17
  <span className="text-xs font-semibold">
16
- {count} comment{count === 1 ? '' : 's'}
18
+ {format(plural(count, t.inspector.commentsCount), { count })}
17
19
  </span>
18
20
  <button
19
21
  type="button"
@@ -26,7 +28,7 @@ export function CommentWidget() {
26
28
  {error && <p className="px-3 py-2 text-xs text-red-600">{error}</p>}
27
29
  {count === 0 ? (
28
30
  <p className="px-3 py-6 text-center text-xs text-muted-foreground">
29
- No comments yet. Toggle Inspect and click a slide element.
31
+ {t.inspector.commentsEmpty}
30
32
  </p>
31
33
  ) : (
32
34
  <>
@@ -38,7 +40,7 @@ export function CommentWidget() {
38
40
  >
39
41
  <div className="min-w-0 flex-1">
40
42
  <div className="text-[10px] font-mono text-muted-foreground">
41
- line {c.line}
43
+ {format(t.inspector.commentLineLabel, { n: c.line })}
42
44
  </div>
43
45
  <div className="mt-0.5 text-xs break-words">{c.note}</div>
44
46
  </div>
@@ -46,7 +48,7 @@ export function CommentWidget() {
46
48
  type="button"
47
49
  onClick={() => remove(c.id)}
48
50
  className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted hover:text-red-600"
49
- title="Delete"
51
+ title={t.inspector.commentDeleteAria}
50
52
  >
51
53
  <Trash2 className="size-3.5" />
52
54
  </button>
@@ -54,11 +56,11 @@ export function CommentWidget() {
54
56
  ))}
55
57
  </ul>
56
58
  <div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
57
- Run{' '}
59
+ {t.inspector.commentsApplyHintPrefix}
58
60
  <code className="rounded bg-muted px-1 py-0.5 font-mono text-foreground">
59
61
  /apply-comments
60
- </code>{' '}
61
- in your agent to apply these.
62
+ </code>
63
+ {t.inspector.commentsApplyHintSuffix}
62
64
  </div>
63
65
  </>
64
66
  )}