@autono/open-pages 0.6.0 → 0.8.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.
- package/dist/{build-6hM9Ga2d.js → build-CCoxDxL-.js} +11 -10
- package/dist/cli/bin.d.ts +1 -1
- package/dist/cli/bin.js +427 -30
- package/dist/{config-CBbwLHOo.js → config-D_U_aCQz.js} +484 -460
- package/dist/{config-C-sasGou.d.ts → config-p-TWNXbv.d.ts} +4 -4
- package/dist/{design-C13iz9_4.js → design-Cpqx7SJs.js} +1 -2
- package/dist/{dev-BO0cHiYy.js → dev-CTrB263u.js} +40 -20
- package/dist/{export-DbVwSmKW.js → export-enKL9R7x.js} +7 -10
- package/dist/index.d.ts +6 -8
- package/dist/index.js +2 -3
- package/dist/locale/index.d.ts +8 -14
- package/dist/locale/index.js +67 -32
- package/dist/{open-pages-plugin-C0poXLsx.js → open-pages-plugin-C8cFhMB9.js} +23 -38
- package/dist/preview-Dmzyd1cf.js +24 -0
- package/dist/{sync-ui-CDydLpY4.js → sync-ui-BzRiAGCK.js} +3 -5
- package/dist/{types-BzxkFHhd.d.ts → types-DDdUFUIS.d.ts} +19 -6
- package/dist/{update-D1U8TVwd.js → update-CWaCKlnl.js} +4 -7
- package/dist/{update-package-D9zws9nc.js → update-package-GaNsVdr8.js} +44 -22
- package/dist/{version-B6AiiGvT.js → version-DZrXLoAD.js} +19 -3
- package/dist/vite/index.d.ts +3 -7
- package/dist/vite/index.js +2 -7
- package/package.json +22 -21
- package/skills/apply-comments/SKILL.md +1 -0
- package/skills/create-page/SKILL.md +5 -1
- package/skills/create-theme/SKILL.md +2 -0
- package/skills/page-authoring/SKILL.md +3 -0
- package/skills/web-design-guidelines/SKILL.md +41 -0
- package/src/app/app.tsx +19 -14
- package/src/app/assets/autono-social.png +0 -0
- package/src/app/assets/autono.svg +7 -0
- package/src/app/components/asset-view.tsx +510 -26
- package/src/app/components/command/command-menu.tsx +5 -1
- package/src/app/components/command/command.tsx +4 -2
- package/src/app/components/command/home-command-menu.tsx +6 -6
- package/src/app/components/icon-tooltip.tsx +24 -15
- package/src/app/components/sidebar/folder-item.tsx +46 -19
- package/src/app/components/sidebar/icon-picker.tsx +1 -1
- package/src/app/components/sidebar/sidebar-footer.tsx +18 -7
- package/src/app/components/sidebar/sidebar.tsx +91 -64
- package/src/app/components/theme-toggle.tsx +4 -5
- package/src/app/components/themes/theme-detail.tsx +6 -3
- package/src/app/components/themes/themes-gallery.tsx +12 -7
- package/src/app/index.html +33 -2
- package/src/app/lib/assets.ts +33 -0
- package/src/app/lib/last-home-location.ts +18 -0
- package/src/app/main.tsx +10 -1
- package/src/app/routes/home-shell.tsx +82 -61
- package/src/app/routes/home.tsx +42 -39
- package/src/app/routes/page.tsx +30 -10
- package/src/app/routes/themes.tsx +6 -8
- package/src/app/styles.css +40 -4
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +17 -6
- package/src/locale/ja.ts +18 -6
- package/src/locale/types.ts +17 -4
- package/src/locale/zh-cn.ts +19 -8
- package/src/locale/zh-tw.ts +17 -6
- package/src/shared/update-package.ts +193 -0
- package/workspace/deps.json +1 -1
- package/dist/export-BsK6OCpc.js +0 -4
- package/dist/preview-sOKhzyWB.js +0 -21
- package/dist/sync-Be_hxSOF.js +0 -139
- package/dist/sync-lKEXyPOh.js +0 -3
- package/dist/version-CeffgdfZ.js +0 -3
- package/src/app/components/language-toggle.tsx +0 -46
- 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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
710
|
+
const sliderContainerRef = useRef<HTMLDivElement>(null);
|
|
681
711
|
|
|
682
712
|
useEffect(() => {
|
|
683
|
-
const thumb =
|
|
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={
|
|
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'
|
|
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'
|
|
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 ?
|
|
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 ?
|
|
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,
|