@autono/open-pages 0.5.0 → 0.7.0

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 (125) hide show
  1. package/dist/{build-L50pau9v.js → build-CCoxDxL-.js} +11 -10
  2. package/dist/cli/bin.d.ts +1 -1
  3. package/dist/cli/bin.js +434 -28
  4. package/dist/{config-CDUdFE8N.js → config-D_U_aCQz.js} +484 -460
  5. package/dist/{config-C9or4m4J.d.ts → config-p-TWNXbv.d.ts} +4 -4
  6. package/dist/{design-C13iz9_4.js → design-Cpqx7SJs.js} +1 -2
  7. package/dist/{dev-CzJYXFOO.js → dev-CTrB263u.js} +40 -20
  8. package/dist/{export-DbVwSmKW.js → export-enKL9R7x.js} +7 -10
  9. package/dist/index.d.ts +6 -8
  10. package/dist/index.js +2 -3
  11. package/dist/locale/index.d.ts +8 -14
  12. package/dist/locale/index.js +67 -32
  13. package/dist/{open-pages-plugin-C0poXLsx.js → open-pages-plugin-C8cFhMB9.js} +23 -38
  14. package/dist/preview-Dmzyd1cf.js +24 -0
  15. package/dist/sync-ui-BzRiAGCK.js +132 -0
  16. package/dist/{types-C5NCdDT-.d.ts → types-DDdUFUIS.d.ts} +19 -6
  17. package/dist/{update-CV62NhBT.js → update-CWaCKlnl.js} +14 -5
  18. package/dist/{update-package-Cxt57pz7.js → update-package-GaNsVdr8.js} +50 -22
  19. package/dist/{version-B6AiiGvT.js → version-DZrXLoAD.js} +19 -3
  20. package/dist/vite/index.d.ts +3 -7
  21. package/dist/vite/index.js +2 -7
  22. package/package.json +24 -22
  23. package/src/app/app.tsx +19 -14
  24. package/src/app/assets/autono-social.png +0 -0
  25. package/src/app/assets/autono.svg +7 -0
  26. package/src/app/components/asset-view.tsx +510 -26
  27. package/src/app/components/command/command-menu.tsx +5 -1
  28. package/src/app/components/command/command.tsx +4 -2
  29. package/src/app/components/command/home-command-menu.tsx +6 -6
  30. package/src/app/components/icon-tooltip.tsx +24 -15
  31. package/src/app/components/sidebar/folder-item.tsx +46 -19
  32. package/src/app/components/sidebar/icon-picker.tsx +1 -1
  33. package/src/app/components/sidebar/sidebar-footer.tsx +18 -7
  34. package/src/app/components/sidebar/sidebar.tsx +91 -64
  35. package/src/app/components/theme-toggle.tsx +4 -5
  36. package/src/app/components/themes/theme-detail.tsx +6 -3
  37. package/src/app/components/themes/themes-gallery.tsx +12 -7
  38. package/src/app/index.html +33 -2
  39. package/src/app/lib/assets.ts +33 -0
  40. package/src/app/lib/last-home-location.ts +18 -0
  41. package/src/app/main.tsx +10 -1
  42. package/src/app/routes/home-shell.tsx +82 -61
  43. package/src/app/routes/home.tsx +42 -39
  44. package/src/app/routes/page.tsx +18 -3
  45. package/src/app/routes/themes.tsx +6 -8
  46. package/src/app/styles.css +40 -4
  47. package/src/app/virtual.d.ts +1 -0
  48. package/src/locale/en.ts +17 -6
  49. package/src/locale/ja.ts +18 -6
  50. package/src/locale/types.ts +17 -4
  51. package/src/locale/zh-cn.ts +19 -8
  52. package/src/locale/zh-tw.ts +17 -6
  53. package/src/shared/update-package.ts +193 -0
  54. package/workspace/components.json +25 -0
  55. package/workspace/deps.json +24 -0
  56. package/workspace/hooks/use-mobile.ts +19 -0
  57. package/workspace/lib/utils.ts +6 -0
  58. package/workspace/styles/globals.css +135 -0
  59. package/workspace/ui/accordion.tsx +79 -0
  60. package/workspace/ui/alert-dialog.tsx +197 -0
  61. package/workspace/ui/alert.tsx +76 -0
  62. package/workspace/ui/aspect-ratio.tsx +11 -0
  63. package/workspace/ui/attachment.tsx +204 -0
  64. package/workspace/ui/avatar.tsx +110 -0
  65. package/workspace/ui/badge.tsx +49 -0
  66. package/workspace/ui/breadcrumb.tsx +122 -0
  67. package/workspace/ui/bubble.tsx +125 -0
  68. package/workspace/ui/button-group.tsx +83 -0
  69. package/workspace/ui/button.tsx +67 -0
  70. package/workspace/ui/calendar.tsx +222 -0
  71. package/workspace/ui/card.tsx +103 -0
  72. package/workspace/ui/carousel.tsx +240 -0
  73. package/workspace/ui/chart.tsx +373 -0
  74. package/workspace/ui/checkbox.tsx +31 -0
  75. package/workspace/ui/collapsible.tsx +33 -0
  76. package/workspace/ui/combobox.tsx +297 -0
  77. package/workspace/ui/command.tsx +195 -0
  78. package/workspace/ui/context-menu.tsx +261 -0
  79. package/workspace/ui/dialog.tsx +168 -0
  80. package/workspace/ui/direction.tsx +22 -0
  81. package/workspace/ui/drawer.tsx +132 -0
  82. package/workspace/ui/dropdown-menu.tsx +269 -0
  83. package/workspace/ui/empty.tsx +104 -0
  84. package/workspace/ui/field.tsx +236 -0
  85. package/workspace/ui/hover-card.tsx +42 -0
  86. package/workspace/ui/input-group.tsx +156 -0
  87. package/workspace/ui/input-otp.tsx +87 -0
  88. package/workspace/ui/input.tsx +19 -0
  89. package/workspace/ui/item.tsx +196 -0
  90. package/workspace/ui/kbd.tsx +26 -0
  91. package/workspace/ui/label.tsx +22 -0
  92. package/workspace/ui/marker.tsx +69 -0
  93. package/workspace/ui/menubar.tsx +280 -0
  94. package/workspace/ui/message-scroller.tsx +129 -0
  95. package/workspace/ui/message.tsx +92 -0
  96. package/workspace/ui/native-select.tsx +61 -0
  97. package/workspace/ui/navigation-menu.tsx +164 -0
  98. package/workspace/ui/pagination.tsx +129 -0
  99. package/workspace/ui/popover.tsx +87 -0
  100. package/workspace/ui/progress.tsx +31 -0
  101. package/workspace/ui/radio-group.tsx +42 -0
  102. package/workspace/ui/resizable.tsx +50 -0
  103. package/workspace/ui/scroll-area.tsx +53 -0
  104. package/workspace/ui/select.tsx +192 -0
  105. package/workspace/ui/separator.tsx +26 -0
  106. package/workspace/ui/sheet.tsx +147 -0
  107. package/workspace/ui/sidebar.tsx +702 -0
  108. package/workspace/ui/skeleton.tsx +13 -0
  109. package/workspace/ui/slider.tsx +59 -0
  110. package/workspace/ui/sonner.tsx +47 -0
  111. package/workspace/ui/spinner.tsx +10 -0
  112. package/workspace/ui/switch.tsx +33 -0
  113. package/workspace/ui/table.tsx +114 -0
  114. package/workspace/ui/tabs.tsx +90 -0
  115. package/workspace/ui/textarea.tsx +18 -0
  116. package/workspace/ui/toggle-group.tsx +87 -0
  117. package/workspace/ui/toggle.tsx +45 -0
  118. package/workspace/ui/tooltip.tsx +57 -0
  119. package/dist/export-BsK6OCpc.js +0 -4
  120. package/dist/preview-CPufRmuj.js +0 -21
  121. package/dist/sync-Be_hxSOF.js +0 -139
  122. package/dist/sync-lKEXyPOh.js +0 -3
  123. package/dist/version-CeffgdfZ.js +0 -3
  124. package/src/app/components/language-toggle.tsx +0 -46
  125. package/src/app/favicon.ico +0 -0
@@ -17,6 +17,7 @@ import {
17
17
  Search,
18
18
  SearchX,
19
19
  Trash2,
20
+ Type,
20
21
  Upload,
21
22
  X,
22
23
  } from 'lucide-react';
@@ -59,11 +60,14 @@ import {
59
60
  import {
60
61
  type AssetEntry,
61
62
  type AssetUsage,
63
+ fetchFontAsFile,
62
64
  fetchSvgAsFile,
65
+ type GoogleFontItem,
63
66
  listAssetUsages,
64
67
  renamedCopy,
65
68
  revertAssetUsage,
66
69
  type SvglItem,
70
+ searchGoogleFonts,
67
71
  searchSvgl,
68
72
  useAssets,
69
73
  } from '~/lib/assets';
@@ -179,6 +183,7 @@ export function AssetView({ pageId }: Props) {
179
183
  const [viewMode, setViewMode] = useViewMode();
180
184
  const [sort, setSort] = useSortPreference();
181
185
  const [gridColumns, setGridColumns] = useGridColumns();
186
+ const [fontSearchOpen, setFontSearchOpen] = useState(false);
182
187
  const dragDepth = useRef(0);
183
188
  const inputId = useId();
184
189
  const t = useLocale();
@@ -360,6 +365,17 @@ export function AssetView({ pageId }: Props) {
360
365
  <Search className="size-3.5" />
361
366
  <span>{t.asset.searchLogos}</span>
362
367
  </button>
368
+ <button
369
+ type="button"
370
+ onClick={() => setFontSearchOpen(true)}
371
+ className={cn(
372
+ 'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-[5px] border border-border bg-card px-2.5 text-[12.5px] font-medium transition-colors',
373
+ 'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
374
+ )}
375
+ >
376
+ <Type className="size-3.5" />
377
+ <span>{t.asset.searchFonts}</span>
378
+ </button>
363
379
  <label
364
380
  htmlFor={inputId}
365
381
  className={cn(
@@ -547,21 +563,28 @@ export function AssetView({ pageId }: Props) {
547
563
  )}
548
564
  </div>
549
565
 
550
- {dragActive && (
551
- <div
552
- className="pointer-events-none absolute inset-0 z-30 animate-in fade-in-0 duration-200"
553
- aria-hidden="true"
554
- >
555
- <div className="absolute inset-0 bg-brand/5" />
556
- <div className="absolute inset-2 rounded-[10px] border border-dashed border-brand/40" />
557
- <div className="absolute inset-x-0 bottom-8 flex justify-center">
558
- <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 page-in-from-bottom-1 duration-300">
559
- <ArrowDownToLine className="size-3.5 text-brand" />
560
- <span>{t.asset.dropToUpload}</span>
561
- </div>
566
+ <div
567
+ className={cn(
568
+ 'pointer-events-none absolute inset-0 z-30 motion-safe:transition-opacity motion-safe:duration-150',
569
+ dragActive ? 'opacity-100' : 'opacity-0',
570
+ )}
571
+ aria-hidden="true"
572
+ >
573
+ <div className="absolute inset-0 bg-brand/5" />
574
+ <div className="absolute inset-2 rounded-[10px] border border-dashed border-brand/40" />
575
+ <div className="absolute inset-x-0 bottom-8 flex justify-center">
576
+ <div
577
+ className={cn(
578
+ 'flex items-center gap-2 rounded-[6px] border border-border bg-card px-3 py-1.5 text-[12px] font-medium shadow-floating',
579
+ 'ease-swift motion-safe:transition-[opacity,translate] motion-safe:duration-200',
580
+ dragActive ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0',
581
+ )}
582
+ >
583
+ <ArrowDownToLine className="size-3.5 text-brand" />
584
+ <span>{t.asset.dropToUpload}</span>
562
585
  </div>
563
586
  </div>
564
- )}
587
+ </div>
565
588
 
566
589
  {conflict && (
567
590
  <ConflictDialog
@@ -623,6 +646,13 @@ export function AssetView({ pageId }: Props) {
623
646
  onPick={(file) => handleFile(file)}
624
647
  />
625
648
  )}
649
+
650
+ {fontSearchOpen && (
651
+ <FontSearchDialog
652
+ onClose={() => setFontSearchOpen(false)}
653
+ onPick={(file) => handleFile(file)}
654
+ />
655
+ )}
626
656
  </section>
627
657
  );
628
658
  }
@@ -677,10 +707,10 @@ function GridColumnsControl({
677
707
  }) {
678
708
  const t = useLocale();
679
709
  const valueLabel = format(t.asset.gridColumnsValue, { count: value });
680
- const docrContainerRef = useRef<HTMLDivElement>(null);
710
+ const sliderContainerRef = useRef<HTMLDivElement>(null);
681
711
 
682
712
  useEffect(() => {
683
- const thumb = docrContainerRef.current?.querySelector<HTMLElement>('[role="slider"]');
713
+ const thumb = sliderContainerRef.current?.querySelector<HTMLElement>('[role="slider"]');
684
714
  thumb?.setAttribute('aria-label', t.asset.gridColumnsAria);
685
715
  thumb?.setAttribute('aria-valuetext', valueLabel);
686
716
  }, [t.asset.gridColumnsAria, valueLabel]);
@@ -692,7 +722,7 @@ function GridColumnsControl({
692
722
  className="m-0 hidden h-8 w-[176px] min-w-0 items-center gap-2 rounded-[6px] border border-border bg-background px-2.5 py-0 lg:flex"
693
723
  >
694
724
  <Columns3 className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
695
- <div ref={docrContainerRef} className="min-w-0 flex-1">
725
+ <div ref={sliderContainerRef} className="min-w-0 flex-1">
696
726
  <Slider
697
727
  min={MIN_GRID_COLUMNS}
698
728
  max={MAX_GRID_COLUMNS}
@@ -806,7 +836,7 @@ function SortableColumnHeader({
806
836
  <DirectionIcon className="size-2.5 shrink-0" aria-hidden />
807
837
  ) : (
808
838
  <ArrowUpDown
809
- className="size-2.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-60 group-focus-visible:opacity-60"
839
+ className="size-2.5 shrink-0 opacity-0 transition-opacity duration-150 group-hover:opacity-60 group-focus-visible:opacity-60 [@media(hover:none)]:opacity-40"
810
840
  aria-hidden
811
841
  />
812
842
  )}
@@ -872,7 +902,7 @@ function AssetCard({
872
902
  const isImage = asset.mime.startsWith('image/');
873
903
  const t = useLocale();
874
904
  return (
875
- <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">
905
+ <div className="group relative flex flex-col overflow-hidden rounded-[6px] border border-border bg-card shadow-edge transition-[box-shadow,scale] duration-150 hover:shadow-floating motion-safe:active:scale-[0.99] focus-within:ring-2 focus-within:ring-ring/30">
876
906
  <button
877
907
  type="button"
878
908
  onClick={onPreview}
@@ -934,7 +964,7 @@ function AssetListItem({
934
964
  const isImage = asset.mime.startsWith('image/');
935
965
  const t = useLocale();
936
966
  return (
937
- <div className="group flex min-h-14 items-center gap-3 rounded-[6px] border border-border bg-card p-2 shadow-edge transition-shadow hover:shadow-floating focus-within:ring-2 focus-within:ring-ring/30">
967
+ <div className="group flex min-h-14 items-center gap-3 rounded-[6px] border border-border bg-card p-2 shadow-edge transition-[box-shadow,scale] duration-150 hover:shadow-floating motion-safe:active:scale-[0.995] focus-within:ring-2 focus-within:ring-ring/30">
938
968
  <button
939
969
  type="button"
940
970
  onClick={onPreview}
@@ -1012,7 +1042,7 @@ function AssetActions({
1012
1042
  aria-label={format(t.asset.actionsAria, { name: asset.name })}
1013
1043
  className={cn(
1014
1044
  buttonVariants({ variant: 'ghost', size: 'icon-xs' }),
1015
- 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 aria-expanded:opacity-100',
1045
+ 'opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-visible:opacity-100 aria-expanded:opacity-100 [@media(hover:none)]:opacity-100',
1016
1046
  )}
1017
1047
  >
1018
1048
  <MoreVertical />
@@ -1337,6 +1367,8 @@ function PreviewDialog({
1337
1367
  className="max-h-[60vh] max-w-full object-contain"
1338
1368
  />
1339
1369
  </div>
1370
+ ) : isFontAsset(asset) ? (
1371
+ <FontPreview asset={asset} />
1340
1372
  ) : (
1341
1373
  <div className="flex items-center justify-center rounded-md border bg-muted/40 py-12 text-muted-foreground">
1342
1374
  <FileImage className="mr-2 size-5" />
@@ -1357,6 +1389,85 @@ function PreviewDialog({
1357
1389
  );
1358
1390
  }
1359
1391
 
1392
+ function isFontAsset(asset: AssetEntry): boolean {
1393
+ if (asset.mime.startsWith('font/') || /font/i.test(asset.mime)) return true;
1394
+ return /\.(ttf|otf|woff2?)$/i.test(asset.name);
1395
+ }
1396
+
1397
+ const FONT_PREVIEW_SIZES = [40, 28, 20, 14] as const;
1398
+
1399
+ function FontPreview({ asset }: { asset: AssetEntry }) {
1400
+ const t = useLocale();
1401
+ const family = useMemo(
1402
+ () => `os-fontpreview-${asset.name.replace(/[^a-zA-Z0-9]+/g, '-')}`,
1403
+ [asset.name],
1404
+ );
1405
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
1406
+ const [sample, setSample] = useState('');
1407
+
1408
+ useEffect(() => {
1409
+ let cancelled = false;
1410
+ setStatus('loading');
1411
+ const face = new FontFace(family, `url("${asset.url}")`);
1412
+ face
1413
+ .load()
1414
+ .then((loaded) => {
1415
+ if (cancelled) return;
1416
+ document.fonts.add(loaded);
1417
+ setStatus('ready');
1418
+ })
1419
+ .catch(() => {
1420
+ if (!cancelled) setStatus('error');
1421
+ });
1422
+ return () => {
1423
+ cancelled = true;
1424
+ document.fonts.delete(face);
1425
+ };
1426
+ }, [family, asset.url]);
1427
+
1428
+ if (status === 'error') {
1429
+ return (
1430
+ <div className="flex items-center justify-center rounded-md border bg-muted/40 py-12 text-muted-foreground">
1431
+ <FileImage className="mr-2 size-5" />
1432
+ <span className="text-sm">{t.asset.noPreview}</span>
1433
+ </div>
1434
+ );
1435
+ }
1436
+
1437
+ const text = sample.trim() || t.asset.fontPreviewSample;
1438
+ return (
1439
+ <div className="flex flex-col gap-3">
1440
+ <div className="relative">
1441
+ <Type className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
1442
+ <input
1443
+ value={sample}
1444
+ onChange={(e) => setSample(e.target.value)}
1445
+ placeholder={t.asset.fontSearchPreviewPlaceholder}
1446
+ 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"
1447
+ />
1448
+ </div>
1449
+ <div
1450
+ className={cn(
1451
+ 'flex max-h-[50vh] flex-col gap-4 overflow-y-auto rounded-md border bg-card px-5 py-5 transition-opacity',
1452
+ status === 'loading' && 'opacity-40',
1453
+ )}
1454
+ >
1455
+ {FONT_PREVIEW_SIZES.map((size) => (
1456
+ <div key={size} className="flex items-baseline gap-3">
1457
+ <span className="w-7 shrink-0 font-mono text-[10px] text-muted-foreground">{size}</span>
1458
+ <span
1459
+ className="min-w-0 break-words"
1460
+ style={{ fontFamily: `'${family}', sans-serif`, fontSize: size, lineHeight: 1.25 }}
1461
+ >
1462
+ {text}
1463
+ </span>
1464
+ </div>
1465
+ ))}
1466
+ </div>
1467
+ </div>
1468
+ );
1469
+ }
1470
+
1360
1471
  const SKELETON_SLOTS = ['s0', 's1', 's2', 's3', 's4', 's5'] as const;
1361
1472
 
1362
1473
  function LogoSearchDialog({
@@ -1457,7 +1568,7 @@ function LogoSearchDialog({
1457
1568
  {SKELETON_SLOTS.map((slot) => (
1458
1569
  <div
1459
1570
  key={slot}
1460
- className="aspect-square animate-pulse rounded-lg border bg-muted/40"
1571
+ className="aspect-square animate-pulse rounded-lg border bg-muted/40 motion-reduce:animate-none"
1461
1572
  />
1462
1573
  ))}
1463
1574
  </div>
@@ -1581,8 +1692,10 @@ function LogoResultCard({
1581
1692
  type="button"
1582
1693
  onClick={() => setVariant('light')}
1583
1694
  className={cn(
1584
- 'px-1.5 py-0.5 transition-colors',
1585
- variant === 'light' ? 'bg-foreground text-background' : 'hover:bg-muted',
1695
+ 'px-1.5 py-0.5 outline-none transition-colors duration-100 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring/40',
1696
+ variant === 'light'
1697
+ ? 'bg-foreground text-background'
1698
+ : 'hover:bg-muted active:bg-muted',
1586
1699
  )}
1587
1700
  >
1588
1701
  {t.asset.logoVariantLight}
@@ -1591,8 +1704,10 @@ function LogoResultCard({
1591
1704
  type="button"
1592
1705
  onClick={() => setVariant('dark')}
1593
1706
  className={cn(
1594
- 'border-l px-1.5 py-0.5 transition-colors',
1595
- variant === 'dark' ? 'bg-foreground text-background' : 'hover:bg-muted',
1707
+ 'border-l px-1.5 py-0.5 outline-none transition-colors duration-100 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring/40',
1708
+ variant === 'dark'
1709
+ ? 'bg-foreground text-background'
1710
+ : 'hover:bg-muted active:bg-muted',
1596
1711
  )}
1597
1712
  >
1598
1713
  {t.asset.logoVariantDark}
@@ -1613,14 +1728,383 @@ function LogoResultCard({
1613
1728
  }}
1614
1729
  className="ml-auto h-6 px-2 text-[11px]"
1615
1730
  >
1616
- {pending ? <Loader2 className="size-3 animate-spin" /> : t.common.add}
1731
+ {pending ? (
1732
+ <Loader2 className="size-3 animate-spin motion-reduce:animate-none" />
1733
+ ) : (
1734
+ t.common.add
1735
+ )}
1736
+ </Button>
1737
+ </div>
1738
+ </div>
1739
+ </div>
1740
+ );
1741
+ }
1742
+
1743
+ function FontNoResultsMessage({ query, t }: { query: string; t: ReturnType<typeof useLocale> }) {
1744
+ const [prefix, suffix] = t.asset.fontSearchNoResults.split('{query}');
1745
+ return (
1746
+ <>
1747
+ {prefix}
1748
+ <span className="font-mono text-foreground">{query}</span>
1749
+ {suffix}
1750
+ </>
1751
+ );
1752
+ }
1753
+
1754
+ function FontSearchDialog({
1755
+ onClose,
1756
+ onPick,
1757
+ }: {
1758
+ onClose: () => void;
1759
+ onPick: (file: File) => Promise<void> | void;
1760
+ }) {
1761
+ const [query, setQuery] = useState('');
1762
+ const [previewText, setPreviewText] = useState('');
1763
+ const [results, setResults] = useState<GoogleFontItem[] | null>(null);
1764
+ const [loading, setLoading] = useState(true);
1765
+ const [error, setError] = useState<string | null>(null);
1766
+ const [pending, setPending] = useState<Set<string>>(() => new Set());
1767
+ const [retryToken, setRetryToken] = useState(0);
1768
+ const inputRef = useRef<HTMLInputElement | null>(null);
1769
+ const t = useLocale();
1770
+
1771
+ useEffect(() => {
1772
+ queueMicrotask(() => inputRef.current?.focus());
1773
+ }, []);
1774
+
1775
+ // biome-ignore lint/correctness/useExhaustiveDependencies: retryToken is a bump-to-refetch trigger
1776
+ useEffect(() => {
1777
+ const ctrl = new AbortController();
1778
+ const timer = setTimeout(() => {
1779
+ setLoading(true);
1780
+ setError(null);
1781
+ searchGoogleFonts(query, ctrl.signal)
1782
+ .then((next) => {
1783
+ setResults(next);
1784
+ setLoading(false);
1785
+ })
1786
+ .catch((err: unknown) => {
1787
+ if (ctrl.signal.aborted) return;
1788
+ setError(err instanceof Error ? err.message : t.asset.toastSearchFailed);
1789
+ setLoading(false);
1790
+ });
1791
+ }, 200);
1792
+ return () => {
1793
+ clearTimeout(timer);
1794
+ ctrl.abort();
1795
+ };
1796
+ }, [query, retryToken]);
1797
+
1798
+ // Pull the actual web fonts so previews render in their real typeface.
1799
+ useEffect(() => {
1800
+ if (!results || results.length === 0) return;
1801
+ const link = document.createElement('link');
1802
+ link.rel = 'stylesheet';
1803
+ link.href = googleFontsCssUrl(results);
1804
+ document.head.appendChild(link);
1805
+ return () => {
1806
+ link.remove();
1807
+ };
1808
+ }, [results]);
1809
+
1810
+ return (
1811
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
1812
+ <DialogContent className="sm:max-w-2xl">
1813
+ <DialogHeader>
1814
+ <DialogTitle>{t.asset.fontSearchTitle}</DialogTitle>
1815
+ <DialogDescription>
1816
+ {t.asset.fontSearchPoweredByPrefix}
1817
+ <a
1818
+ href="https://fonts.google.com"
1819
+ target="_blank"
1820
+ rel="noopener noreferrer"
1821
+ className="underline underline-offset-2"
1822
+ >
1823
+ Google Fonts
1824
+ </a>
1825
+ .
1826
+ </DialogDescription>
1827
+ </DialogHeader>
1828
+
1829
+ <div className="flex flex-col gap-2 sm:flex-row">
1830
+ <div className="relative sm:flex-1">
1831
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
1832
+ <input
1833
+ ref={inputRef}
1834
+ value={query}
1835
+ onChange={(e) => setQuery(e.target.value)}
1836
+ placeholder={t.asset.fontSearchPlaceholder}
1837
+ 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"
1838
+ />
1839
+ </div>
1840
+ <div className="relative sm:flex-1">
1841
+ <Type className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
1842
+ <input
1843
+ value={previewText}
1844
+ onChange={(e) => setPreviewText(e.target.value)}
1845
+ placeholder={t.asset.fontSearchPreviewPlaceholder}
1846
+ 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"
1847
+ />
1848
+ </div>
1849
+ </div>
1850
+
1851
+ <div className="max-h-[60vh] min-h-[16rem] overflow-y-auto">
1852
+ {error ? (
1853
+ <div className="flex h-64 flex-col items-center justify-center gap-3 px-6 text-center">
1854
+ <div className="flex size-12 items-center justify-center rounded-full bg-muted">
1855
+ <CloudOff className="size-5 text-muted-foreground" />
1856
+ </div>
1857
+ <div>
1858
+ <p className="text-sm font-medium">{t.asset.fontSearchErrorTitle}</p>
1859
+ <p className="mt-1 text-xs text-muted-foreground">{t.asset.fontSearchErrorBody}</p>
1860
+ </div>
1861
+ <Button
1862
+ variant="outline"
1863
+ size="sm"
1864
+ onClick={() => setRetryToken((n) => n + 1)}
1865
+ className="gap-1.5"
1866
+ >
1867
+ <RotateCw className="size-3.5" />
1868
+ {t.common.tryAgain}
1869
+ </Button>
1870
+ </div>
1871
+ ) : loading && !results ? (
1872
+ <div className="flex flex-col gap-2.5">
1873
+ {SKELETON_SLOTS.map((slot) => (
1874
+ <div
1875
+ key={slot}
1876
+ className="h-[4.5rem] animate-pulse rounded-lg border bg-muted/40 motion-reduce:animate-none"
1877
+ />
1878
+ ))}
1879
+ </div>
1880
+ ) : results && results.length === 0 ? (
1881
+ <div className="flex h-64 flex-col items-center justify-center gap-3 px-6 text-center">
1882
+ <div className="flex size-12 items-center justify-center rounded-full bg-muted">
1883
+ <SearchX className="size-5 text-muted-foreground" />
1884
+ </div>
1885
+ <div>
1886
+ <p className="text-sm font-medium">
1887
+ {query.trim() ? (
1888
+ <FontNoResultsMessage query={query.trim()} t={t} />
1889
+ ) : (
1890
+ t.asset.fontSearchEmpty
1891
+ )}
1892
+ </p>
1893
+ <p className="mt-1 text-xs text-muted-foreground">
1894
+ {t.asset.fontSearchEmptyHintPrefix}
1895
+ <a
1896
+ href="https://fonts.google.com"
1897
+ target="_blank"
1898
+ rel="noopener noreferrer"
1899
+ className="underline underline-offset-2 hover:text-foreground"
1900
+ >
1901
+ Google Fonts
1902
+ </a>
1903
+ {t.asset.fontSearchEmptyHintSuffix}
1904
+ </p>
1905
+ </div>
1906
+ </div>
1907
+ ) : (
1908
+ <div className="flex flex-col gap-2.5">
1909
+ {results?.map((item) => (
1910
+ <FontResultCard
1911
+ key={item.family}
1912
+ item={item}
1913
+ previewText={previewText.trim()}
1914
+ pending={pending.has(item.family)}
1915
+ onAdd={async (file) => {
1916
+ setPending((prev) => {
1917
+ const next = new Set(prev);
1918
+ next.add(item.family);
1919
+ return next;
1920
+ });
1921
+ try {
1922
+ await onPick(file);
1923
+ } catch (err) {
1924
+ toast.error(
1925
+ err instanceof Error ? err.message : t.asset.toastFontDownloadFailed,
1926
+ );
1927
+ } finally {
1928
+ setPending((prev) => {
1929
+ const next = new Set(prev);
1930
+ next.delete(item.family);
1931
+ return next;
1932
+ });
1933
+ }
1934
+ }}
1935
+ />
1936
+ ))}
1937
+ </div>
1938
+ )}
1939
+ </div>
1940
+
1941
+ <DialogFooter>
1942
+ <Button variant="outline" onClick={onClose}>
1943
+ {t.common.done}
1617
1944
  </Button>
1945
+ </DialogFooter>
1946
+ </DialogContent>
1947
+ </Dialog>
1948
+ );
1949
+ }
1950
+
1951
+ function FontResultCard({
1952
+ item,
1953
+ previewText,
1954
+ pending,
1955
+ onAdd,
1956
+ }: {
1957
+ item: GoogleFontItem;
1958
+ previewText: string;
1959
+ pending: boolean;
1960
+ onAdd: (file: File) => Promise<void> | void;
1961
+ }) {
1962
+ const [variant, setVariant] = useState(() => defaultVariant(item.variants));
1963
+ // `pending` only covers the upload the parent runs; the download before it
1964
+ // needs its own flag or a second click starts a second download.
1965
+ const [downloading, setDownloading] = useState(false);
1966
+ const busy = pending || downloading;
1967
+ const t = useLocale();
1968
+ const sample = previewText || item.family;
1969
+ const filenameBase = `${item.family.replace(/\s+/g, '')}-${variantLabel(variant).replace(/\s+/g, '')}`;
1970
+ const italic = variant.endsWith('i');
1971
+ const weight = variantWeight(variant);
1972
+ const isDefaultVariant = variant === defaultVariant(item.variants);
1973
+
1974
+ // The dialog-level stylesheet only carries each family's default face, so
1975
+ // fetch the selected weight/style on demand for the preview.
1976
+ useEffect(() => {
1977
+ if (isDefaultVariant) return;
1978
+ const link = document.createElement('link');
1979
+ link.rel = 'stylesheet';
1980
+ link.href = googleFontsVariantCssUrl(item.family, weight, italic);
1981
+ document.head.appendChild(link);
1982
+ return () => {
1983
+ link.remove();
1984
+ };
1985
+ }, [isDefaultVariant, item.family, weight, italic]);
1986
+
1987
+ return (
1988
+ <div className="group flex items-center gap-3 rounded-lg border bg-card px-3 py-2.5">
1989
+ <div className="min-w-0 flex-1">
1990
+ <div className="flex items-center gap-2">
1991
+ <span className="truncate text-[11px] font-medium" title={item.family}>
1992
+ {item.family}
1993
+ </span>
1994
+ {item.category ? (
1995
+ <span className="shrink-0 text-[10px] text-muted-foreground">{item.category}</span>
1996
+ ) : null}
1997
+ </div>
1998
+ <div
1999
+ className="mt-1 truncate text-2xl leading-tight"
2000
+ style={{
2001
+ fontFamily: `'${item.family}', sans-serif`,
2002
+ fontWeight: weight,
2003
+ fontStyle: italic ? 'italic' : 'normal',
2004
+ }}
2005
+ title={sample}
2006
+ >
2007
+ {sample}
1618
2008
  </div>
1619
2009
  </div>
2010
+ <div className="flex shrink-0 items-center gap-1.5">
2011
+ {item.variants.length > 1 ? (
2012
+ <select
2013
+ aria-label={t.asset.fontWeightLabel}
2014
+ value={variant}
2015
+ onChange={(e) => {
2016
+ const next = item.variants.find((v) => v === e.target.value);
2017
+ if (next) setVariant(next);
2018
+ }}
2019
+ className="h-6 w-30 rounded-md border bg-background px-1.5 text-[11px] outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
2020
+ >
2021
+ {item.variants.map((v) => (
2022
+ <option key={v} value={v}>
2023
+ {variantLabel(v)}
2024
+ </option>
2025
+ ))}
2026
+ </select>
2027
+ ) : null}
2028
+ <Button
2029
+ size="sm"
2030
+ variant="outline"
2031
+ disabled={busy}
2032
+ onClick={async () => {
2033
+ setDownloading(true);
2034
+ try {
2035
+ const file = await fetchFontAsFile(item.family, variant, filenameBase);
2036
+ await onAdd(file);
2037
+ } catch (err) {
2038
+ toast.error(err instanceof Error ? err.message : t.asset.toastFontDownloadFailed);
2039
+ } finally {
2040
+ setDownloading(false);
2041
+ }
2042
+ }}
2043
+ className="h-6 px-2 text-[11px]"
2044
+ >
2045
+ {busy ? (
2046
+ <Loader2 className="size-3 animate-spin motion-reduce:animate-none" />
2047
+ ) : (
2048
+ t.common.add
2049
+ )}
2050
+ </Button>
2051
+ </div>
1620
2052
  </div>
1621
2053
  );
1622
2054
  }
1623
2055
 
2056
+ const WEIGHT_NAMES: Record<string, string> = {
2057
+ '100': 'Thin',
2058
+ '200': 'ExtraLight',
2059
+ '300': 'Light',
2060
+ '400': 'Regular',
2061
+ '500': 'Medium',
2062
+ '600': 'SemiBold',
2063
+ '700': 'Bold',
2064
+ '800': 'ExtraBold',
2065
+ '900': 'Black',
2066
+ };
2067
+
2068
+ function variantLabel(variant: string): string {
2069
+ const italic = variant.endsWith('i');
2070
+ const weight = italic ? variant.slice(0, -1) : variant;
2071
+ const base = WEIGHT_NAMES[weight] ?? weight;
2072
+ if (!italic) return base;
2073
+ return base === 'Regular' ? 'Italic' : `${base} Italic`;
2074
+ }
2075
+
2076
+ function defaultVariant(variants: string[]): string {
2077
+ if (variants.includes('400')) return '400';
2078
+ const firstUpright = variants.find((v) => !v.endsWith('i'));
2079
+ return firstUpright ?? variants[0] ?? '400';
2080
+ }
2081
+
2082
+ // Catalog variants are '100'..'900' with an optional 'i' suffix; anything
2083
+ // else collapses to 400 so only a known weight ever reaches a URL.
2084
+ function variantWeight(variant: string): number {
2085
+ const parsed = Number.parseInt(variant.endsWith('i') ? variant.slice(0, -1) : variant, 10);
2086
+ return Number.isInteger(parsed) && parsed >= 100 && parsed <= 900 ? parsed : 400;
2087
+ }
2088
+
2089
+ function googleFontsVariantCssUrl(family: string, weight: number, italic: boolean): string {
2090
+ const name = encodeURIComponent(family).replace(/%20/g, '+');
2091
+ const axis = italic ? `ital,wght@1,${weight}` : `wght@${weight}`;
2092
+ return `https://fonts.googleapis.com/css2?family=${name}:${axis}&display=swap`;
2093
+ }
2094
+
2095
+ function googleFontsCssUrl(items: GoogleFontItem[]): string {
2096
+ const params = items
2097
+ .map((item) => {
2098
+ const name = encodeURIComponent(item.family).replace(/%20/g, '+');
2099
+ // css2 without an ital axis 400s on (or silently drops) italic-only
2100
+ // families like Molle — request their italic face explicitly.
2101
+ const italicOnly = item.variants.length > 0 && item.variants.every((v) => v.endsWith('i'));
2102
+ return italicOnly ? `family=${name}:ital@1` : `family=${name}`;
2103
+ })
2104
+ .join('&');
2105
+ return `https://fonts.googleapis.com/css2?${params}&display=swap`;
2106
+ }
2107
+
1624
2108
  function basenameFromUrl(u: string): string {
1625
2109
  try {
1626
2110
  return new URL(u).pathname.split('/').pop() || '';
@@ -211,7 +211,11 @@ function useSharedCommandGroups(): CommandGroupSpec[] {
211
211
  {
212
212
  id: 'restart-dev-server',
213
213
  label: restarting ? t.home.restartingServer : t.home.restartServer,
214
- icon: restarting ? <Loader2 className="animate-spin" /> : <RotateCw />,
214
+ icon: restarting ? (
215
+ <Loader2 className="animate-spin motion-reduce:animate-none" />
216
+ ) : (
217
+ <RotateCw />
218
+ ),
215
219
  keywords: ['restart', 'dev', 'server', 'reload'],
216
220
  disabled: restarting,
217
221
  run: restartServer,