@autono/open-pages 0.1.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 (110) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +108 -0
  3. package/bin.js +2 -0
  4. package/dist/build-TP72kiw7.js +35 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +100 -0
  7. package/dist/config-C9or4m4J.d.ts +23 -0
  8. package/dist/config-L0IbktJ0.js +3135 -0
  9. package/dist/design-C13iz9_4.js +35 -0
  10. package/dist/dev-I2IyEh5W.js +97 -0
  11. package/dist/export-B-kBRWB1.js +4 -0
  12. package/dist/export-ic28osQP.js +131 -0
  13. package/dist/index.d.ts +56 -0
  14. package/dist/index.js +3 -0
  15. package/dist/locale/index.d.ts +24 -0
  16. package/dist/locale/index.js +881 -0
  17. package/dist/open-pages-plugin-D_DN_zjh.js +632 -0
  18. package/dist/preview-BrDuvgAN.js +21 -0
  19. package/dist/sync-Be_hxSOF.js +139 -0
  20. package/dist/sync-lKEXyPOh.js +3 -0
  21. package/dist/types-C5NCdDT-.d.ts +219 -0
  22. package/dist/update-CV62NhBT.js +37 -0
  23. package/dist/update-package-Cxt57pz7.js +152 -0
  24. package/dist/version-B6AiiGvT.js +26 -0
  25. package/dist/version-CeffgdfZ.js +3 -0
  26. package/dist/vite/index.d.ts +14 -0
  27. package/dist/vite/index.js +7 -0
  28. package/env.d.ts +59 -0
  29. package/package.json +106 -0
  30. package/skills/apply-comments/SKILL.md +86 -0
  31. package/skills/create-page/SKILL.md +92 -0
  32. package/skills/create-theme/SKILL.md +223 -0
  33. package/skills/current-page/SKILL.md +106 -0
  34. package/skills/page-authoring/SKILL.md +148 -0
  35. package/skills/page-authoring/references/assets-and-fonts.md +85 -0
  36. package/skills/page-authoring/references/html-pages.md +65 -0
  37. package/skills/page-authoring/references/interactivity.md +77 -0
  38. package/skills/page-authoring/references/layout-and-responsive.md +71 -0
  39. package/skills/page-authoring/references/typography-and-color.md +60 -0
  40. package/src/app/app.tsx +45 -0
  41. package/src/app/components/asset-view.tsx +1665 -0
  42. package/src/app/components/command/command-menu.tsx +227 -0
  43. package/src/app/components/command/command.tsx +142 -0
  44. package/src/app/components/command/home-command-menu.tsx +96 -0
  45. package/src/app/components/icon-tooltip.tsx +34 -0
  46. package/src/app/components/language-toggle.tsx +46 -0
  47. package/src/app/components/sidebar/folder-item.tsx +269 -0
  48. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  49. package/src/app/components/sidebar/sidebar-footer.tsx +139 -0
  50. package/src/app/components/sidebar/sidebar.tsx +302 -0
  51. package/src/app/components/theme-toggle.tsx +66 -0
  52. package/src/app/components/themes/theme-detail.tsx +226 -0
  53. package/src/app/components/themes/themes-gallery.tsx +104 -0
  54. package/src/app/components/ui/badge.tsx +48 -0
  55. package/src/app/components/ui/button.tsx +93 -0
  56. package/src/app/components/ui/card.tsx +92 -0
  57. package/src/app/components/ui/context-menu.tsx +247 -0
  58. package/src/app/components/ui/dialog.tsx +155 -0
  59. package/src/app/components/ui/dropdown-menu.tsx +267 -0
  60. package/src/app/components/ui/input.tsx +25 -0
  61. package/src/app/components/ui/label.tsx +21 -0
  62. package/src/app/components/ui/popover.tsx +82 -0
  63. package/src/app/components/ui/progress.tsx +33 -0
  64. package/src/app/components/ui/scroll-area.tsx +53 -0
  65. package/src/app/components/ui/select.tsx +195 -0
  66. package/src/app/components/ui/separator.tsx +26 -0
  67. package/src/app/components/ui/slider.tsx +68 -0
  68. package/src/app/components/ui/sonner.tsx +48 -0
  69. package/src/app/components/ui/tabs.tsx +79 -0
  70. package/src/app/components/ui/textarea.tsx +22 -0
  71. package/src/app/components/ui/toggle-group.tsx +84 -0
  72. package/src/app/components/ui/toggle.tsx +45 -0
  73. package/src/app/components/ui/tooltip.tsx +75 -0
  74. package/src/app/favicon.ico +0 -0
  75. package/src/app/frame/inspect.ts +140 -0
  76. package/src/app/frame/main.tsx +91 -0
  77. package/src/app/frame.html +12 -0
  78. package/src/app/index.html +13 -0
  79. package/src/app/lib/asset-filter.test.ts +176 -0
  80. package/src/app/lib/asset-filter.ts +80 -0
  81. package/src/app/lib/assets.ts +257 -0
  82. package/src/app/lib/design.ts +58 -0
  83. package/src/app/lib/folders.ts +239 -0
  84. package/src/app/lib/frame.ts +38 -0
  85. package/src/app/lib/locale-store.ts +67 -0
  86. package/src/app/lib/page-thumb.tsx +46 -0
  87. package/src/app/lib/pages.ts +28 -0
  88. package/src/app/lib/sdk.ts +43 -0
  89. package/src/app/lib/themes.ts +22 -0
  90. package/src/app/lib/use-locale.ts +8 -0
  91. package/src/app/lib/use-page-module.ts +48 -0
  92. package/src/app/lib/use-page-titles.ts +29 -0
  93. package/src/app/lib/use-restart-server.ts +78 -0
  94. package/src/app/lib/utils.test.ts +25 -0
  95. package/src/app/lib/utils.ts +6 -0
  96. package/src/app/main.tsx +14 -0
  97. package/src/app/routes/assets.tsx +9 -0
  98. package/src/app/routes/home-shell.tsx +252 -0
  99. package/src/app/routes/home.tsx +846 -0
  100. package/src/app/routes/page.tsx +329 -0
  101. package/src/app/routes/themes.tsx +34 -0
  102. package/src/app/styles.css +405 -0
  103. package/src/app/virtual.d.ts +53 -0
  104. package/src/locale/en.ts +218 -0
  105. package/src/locale/format.ts +12 -0
  106. package/src/locale/index.ts +6 -0
  107. package/src/locale/ja.ts +221 -0
  108. package/src/locale/types.ts +215 -0
  109. package/src/locale/zh-cn.ts +218 -0
  110. package/src/locale/zh-tw.ts +218 -0
@@ -0,0 +1,1665 @@
1
+ import {
2
+ ArrowDown,
3
+ ArrowDownToLine,
4
+ ArrowUp,
5
+ ArrowUpDown,
6
+ CloudOff,
7
+ Columns3,
8
+ File as FileIcon,
9
+ FileImage,
10
+ ImageIcon,
11
+ LayoutGrid,
12
+ List,
13
+ Loader2,
14
+ MoreVertical,
15
+ Pencil,
16
+ RotateCw,
17
+ Search,
18
+ SearchX,
19
+ Trash2,
20
+ Upload,
21
+ X,
22
+ } from 'lucide-react';
23
+ import { useDeferredValue, useEffect, useId, useMemo, useRef, useState } from 'react';
24
+ import { toast } from 'sonner';
25
+ import { Button, buttonVariants } from '@/components/ui/button';
26
+ import {
27
+ Dialog,
28
+ DialogContent,
29
+ DialogDescription,
30
+ DialogFooter,
31
+ DialogHeader,
32
+ DialogTitle,
33
+ } from '@/components/ui/dialog';
34
+ import {
35
+ DropdownMenu,
36
+ DropdownMenuContent,
37
+ DropdownMenuItem,
38
+ DropdownMenuTrigger,
39
+ } from '@/components/ui/dropdown-menu';
40
+ import {
41
+ Select,
42
+ SelectContent,
43
+ SelectItem,
44
+ SelectTrigger,
45
+ SelectValue,
46
+ } from '@/components/ui/select';
47
+ import { Slider } from '@/components/ui/slider';
48
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
49
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
50
+ import {
51
+ type AssetSortDirection,
52
+ type AssetSortKey,
53
+ type AssetSortOptions,
54
+ type AssetTypeFilter,
55
+ type AssetUsageFilter,
56
+ filterAssets,
57
+ sortAssets,
58
+ } from '@/lib/asset-filter';
59
+ import {
60
+ type AssetEntry,
61
+ type AssetUsage,
62
+ fetchSvgAsFile,
63
+ listAssetUsages,
64
+ renamedCopy,
65
+ revertAssetUsage,
66
+ type SvglItem,
67
+ searchSvgl,
68
+ useAssets,
69
+ } from '@/lib/assets';
70
+ import { format, useLocale } from '@/lib/use-locale';
71
+ import { cn } from '@/lib/utils';
72
+
73
+ type Props = { pageId: string | null };
74
+
75
+ type Scope = 'page' | 'global';
76
+ type ViewMode = 'grid' | 'list';
77
+
78
+ const GLOBAL_DOC_ID = '@global';
79
+ const VIEW_MODE_STORAGE_KEY = 'open-pages:asset-view-mode';
80
+ const SORT_STORAGE_KEY = 'open-pages:asset-sort-v1';
81
+ const GRID_COLUMNS_STORAGE_KEY = 'open-pages:asset-grid-columns-v1';
82
+ const MIN_GRID_COLUMNS = 2;
83
+ const MAX_GRID_COLUMNS = 10;
84
+ const DEFAULT_GRID_COLUMNS = 4;
85
+ const SORT_KEYS: readonly AssetSortKey[] = ['name', 'modified', 'size', 'type'];
86
+ const DEFAULT_SORT: AssetSortOptions = { key: 'name', direction: 'asc' };
87
+ const DEFAULT_SORT_DIRECTIONS: Record<AssetSortKey, AssetSortDirection> = {
88
+ name: 'asc',
89
+ modified: 'desc',
90
+ size: 'desc',
91
+ type: 'asc',
92
+ };
93
+
94
+ function readViewMode(): ViewMode {
95
+ if (typeof window === 'undefined') return 'grid';
96
+ try {
97
+ return window.localStorage.getItem(VIEW_MODE_STORAGE_KEY) === 'list' ? 'list' : 'grid';
98
+ } catch {
99
+ return 'grid';
100
+ }
101
+ }
102
+
103
+ function useViewMode(): [ViewMode, (next: ViewMode) => void] {
104
+ const [viewMode, setViewMode] = useState<ViewMode>(readViewMode);
105
+ const update = (next: ViewMode) => {
106
+ setViewMode(next);
107
+ try {
108
+ window.localStorage.setItem(VIEW_MODE_STORAGE_KEY, next);
109
+ } catch {}
110
+ };
111
+ return [viewMode, update];
112
+ }
113
+
114
+ function readSortPreference(): AssetSortOptions {
115
+ if (typeof window === 'undefined') return DEFAULT_SORT;
116
+ try {
117
+ const [key, direction] = window.localStorage.getItem(SORT_STORAGE_KEY)?.split(':') ?? [];
118
+ if (SORT_KEYS.includes(key as AssetSortKey) && (direction === 'asc' || direction === 'desc')) {
119
+ return { key: key as AssetSortKey, direction };
120
+ }
121
+ } catch {}
122
+ return DEFAULT_SORT;
123
+ }
124
+
125
+ function useSortPreference(): [AssetSortOptions, (next: AssetSortOptions) => void] {
126
+ const [sort, setSort] = useState<AssetSortOptions>(readSortPreference);
127
+ const update = (next: AssetSortOptions) => {
128
+ setSort(next);
129
+ try {
130
+ window.localStorage.setItem(SORT_STORAGE_KEY, `${next.key}:${next.direction}`);
131
+ } catch {}
132
+ };
133
+ return [sort, update];
134
+ }
135
+
136
+ function readGridColumns(): number {
137
+ if (typeof window === 'undefined') return DEFAULT_GRID_COLUMNS;
138
+ try {
139
+ const value = Number(window.localStorage.getItem(GRID_COLUMNS_STORAGE_KEY));
140
+ if (Number.isInteger(value) && value >= MIN_GRID_COLUMNS && value <= MAX_GRID_COLUMNS) {
141
+ return value;
142
+ }
143
+ } catch {}
144
+ return DEFAULT_GRID_COLUMNS;
145
+ }
146
+
147
+ function useGridColumns(): [number, (next: number) => void] {
148
+ const [gridColumns, setGridColumns] = useState(readGridColumns);
149
+ const update = (next: number) => {
150
+ const value = Math.min(MAX_GRID_COLUMNS, Math.max(MIN_GRID_COLUMNS, Math.round(next)));
151
+ setGridColumns(value);
152
+ try {
153
+ window.localStorage.setItem(GRID_COLUMNS_STORAGE_KEY, String(value));
154
+ } catch {}
155
+ };
156
+ return [gridColumns, update];
157
+ }
158
+
159
+ type ConflictState = {
160
+ file: File;
161
+ resolve: (decision: 'replace' | 'rename' | 'cancel') => void;
162
+ };
163
+
164
+ export function AssetView({ pageId }: Props) {
165
+ const lockedToGlobal = pageId === null;
166
+ const [scope, setScope] = useState<Scope>(lockedToGlobal ? 'global' : 'page');
167
+ const effectiveDocId = scope === 'global' || pageId === null ? GLOBAL_DOC_ID : pageId;
168
+ const { assets, loading, available, upload, rename, remove } = useAssets(effectiveDocId);
169
+ const [dragActive, setDragActive] = useState(false);
170
+ const [conflict, setConflict] = useState<ConflictState | null>(null);
171
+ const [preview, setPreview] = useState<AssetEntry | null>(null);
172
+ const [confirmDelete, setConfirmDelete] = useState<AssetEntry | null>(null);
173
+ const [confirmDeleteUsages, setConfirmDeleteUsages] = useState<AssetUsage[] | null>(null);
174
+ const [renaming, setRenaming] = useState<string | null>(null);
175
+ const [logoSearchOpen, setLogoSearchOpen] = useState(false);
176
+ const [query, setQuery] = useState('');
177
+ const [usageFilter, setUsageFilter] = useState<AssetUsageFilter>('all');
178
+ const [typeFilter, setTypeFilter] = useState<AssetTypeFilter>('all');
179
+ const [viewMode, setViewMode] = useViewMode();
180
+ const [sort, setSort] = useSortPreference();
181
+ const [gridColumns, setGridColumns] = useGridColumns();
182
+ const dragDepth = useRef(0);
183
+ const inputId = useId();
184
+ const t = useLocale();
185
+
186
+ const deferredQuery = useDeferredValue(query);
187
+ const visibleAssets = useMemo(
188
+ () =>
189
+ sortAssets(
190
+ filterAssets(assets, {
191
+ usage: usageFilter,
192
+ type: typeFilter,
193
+ search: deferredQuery,
194
+ }),
195
+ { key: sort.key, direction: sort.direction },
196
+ ),
197
+ [assets, deferredQuery, sort.direction, sort.key, typeFilter, usageFilter],
198
+ );
199
+ const existingNames = useMemo(() => new Set(assets.map((asset) => asset.name)), [assets]);
200
+
201
+ const clearFilters = () => {
202
+ setQuery('');
203
+ setUsageFilter('all');
204
+ setTypeFilter('all');
205
+ };
206
+
207
+ const changeSortKey = (key: AssetSortKey) => {
208
+ setSort({ key, direction: DEFAULT_SORT_DIRECTIONS[key] });
209
+ };
210
+
211
+ const toggleSortDirection = () => {
212
+ setSort({ key: sort.key, direction: sort.direction === 'asc' ? 'desc' : 'asc' });
213
+ };
214
+
215
+ const sortByColumn = (key: AssetSortKey) => {
216
+ setSort({
217
+ key,
218
+ direction:
219
+ sort.key === key
220
+ ? sort.direction === 'asc'
221
+ ? 'desc'
222
+ : 'asc'
223
+ : DEFAULT_SORT_DIRECTIONS[key],
224
+ });
225
+ };
226
+
227
+ async function handleFile(file: File) {
228
+ if (!available) return;
229
+ if (existingNames.has(file.name)) {
230
+ const decision = await new Promise<'replace' | 'rename' | 'cancel'>((resolve) => {
231
+ setConflict({ file, resolve });
232
+ });
233
+ if (decision === 'cancel') return;
234
+ if (decision === 'replace') {
235
+ const res = await upload(file, { overwrite: true });
236
+ if (!res.ok) toast.error(format(t.asset.toastUploadFailed, { status: res.status }));
237
+ else toast.success(format(t.asset.toastReplaced, { name: file.name }));
238
+ return;
239
+ }
240
+ const next = renamedCopy(file, existingNames);
241
+ const res = await upload(next, { overwrite: false });
242
+ if (!res.ok) toast.error(format(t.asset.toastUploadFailed, { status: res.status }));
243
+ else toast.success(format(t.asset.toastUploadedAs, { name: next.name }));
244
+ return;
245
+ }
246
+ const res = await upload(file);
247
+ if (!res.ok) toast.error(format(t.asset.toastUploadFailed, { status: res.status }));
248
+ else toast.success(format(t.asset.toastUploaded, { name: file.name }));
249
+ }
250
+
251
+ async function handleFiles(files: FileList | File[]) {
252
+ const list = Array.from(files);
253
+ for (const f of list) {
254
+ // Sequential — keeps the conflict dialog UX coherent and avoids
255
+ // hammering the dev server's filesystem mutations in parallel.
256
+ await handleFile(f);
257
+ }
258
+ }
259
+
260
+ async function handleRename(asset: AssetEntry, next: string) {
261
+ if (next === asset.name) {
262
+ setRenaming(null);
263
+ return;
264
+ }
265
+ if (existingNames.has(next)) {
266
+ toast.error(t.asset.nameAlreadyExists);
267
+ return;
268
+ }
269
+ const res = await rename(asset.name, next);
270
+ if (!res.ok) {
271
+ toast.error(format(t.asset.toastRenameFailed, { status: res.status }));
272
+ return;
273
+ }
274
+ toast.success(format(t.asset.toastRenamed, { name: next }));
275
+ setRenaming(null);
276
+ }
277
+
278
+ function prepareDelete(asset: AssetEntry) {
279
+ setConfirmDelete(asset);
280
+ setConfirmDeleteUsages(null);
281
+ listAssetUsages(effectiveDocId, asset.name)
282
+ .then((usages) => setConfirmDeleteUsages(usages))
283
+ .catch(() => setConfirmDeleteUsages([]));
284
+ }
285
+
286
+ if (!available) {
287
+ return (
288
+ <div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">
289
+ {t.asset.devOnlyMessage}
290
+ </div>
291
+ );
292
+ }
293
+
294
+ return (
295
+ <section
296
+ aria-label={t.asset.sectionAria}
297
+ className={cn('relative flex h-full flex-col bg-background')}
298
+ onDragEnter={(e) => {
299
+ if (!hasFiles(e)) return;
300
+ e.preventDefault();
301
+ dragDepth.current += 1;
302
+ setDragActive(true);
303
+ }}
304
+ onDragOver={(e) => {
305
+ if (!hasFiles(e)) return;
306
+ e.preventDefault();
307
+ e.dataTransfer.dropEffect = 'copy';
308
+ }}
309
+ onDragLeave={() => {
310
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
311
+ if (dragDepth.current === 0) setDragActive(false);
312
+ }}
313
+ onDrop={(e) => {
314
+ if (!hasFiles(e)) return;
315
+ e.preventDefault();
316
+ dragDepth.current = 0;
317
+ setDragActive(false);
318
+ if (e.dataTransfer.files.length > 0) {
319
+ handleFiles(e.dataTransfer.files).catch(() => {});
320
+ }
321
+ }}
322
+ >
323
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline bg-sidebar px-6 py-3">
324
+ <div className="flex min-w-0 items-center gap-3">
325
+ {lockedToGlobal ? (
326
+ <span className="eyebrow">{t.asset.eyebrow}</span>
327
+ ) : (
328
+ <Tabs value={scope} onValueChange={(next) => setScope(next as Scope)}>
329
+ <TabsList>
330
+ <TabsTrigger value="page">{t.asset.scopeDoc}</TabsTrigger>
331
+ <TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
332
+ </TabsList>
333
+ </Tabs>
334
+ )}
335
+ <p className="min-w-0 truncate text-[12px] text-muted-foreground">
336
+ <span className="font-mono text-[11.5px]">
337
+ {scope === 'global' ? 'assets/' : `pages/${pageId}/assets/`}
338
+ </span>
339
+ {!loading && (
340
+ <>
341
+ <span className="mx-2 opacity-50">·</span>
342
+ <span className="folio">
343
+ {format(assets.length === 1 ? t.asset.fileCount.one : t.asset.fileCount.other, {
344
+ count: assets.length.toString().padStart(2, '0'),
345
+ })}
346
+ </span>
347
+ </>
348
+ )}
349
+ </p>
350
+ </div>
351
+ <div className="flex shrink-0 items-center gap-1.5">
352
+ <button
353
+ type="button"
354
+ onClick={() => setLogoSearchOpen(true)}
355
+ className={cn(
356
+ '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',
357
+ 'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
358
+ )}
359
+ >
360
+ <Search className="size-3.5" />
361
+ <span>{t.asset.searchLogos}</span>
362
+ </button>
363
+ <label
364
+ htmlFor={inputId}
365
+ className={cn(
366
+ 'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-[5px] bg-foreground px-3 text-[12.5px] font-medium text-background transition-colors',
367
+ 'shadow-[inset_0_1px_0_oklch(1_0_0/0.12),0_1px_0_oklch(0_0_0/0.12)]',
368
+ 'hover:bg-foreground/90 active:translate-y-px',
369
+ )}
370
+ >
371
+ <Upload className="size-3.5" />
372
+ <span>{t.asset.upload}</span>
373
+ </label>
374
+ <input
375
+ id={inputId}
376
+ type="file"
377
+ multiple
378
+ className="sr-only"
379
+ onChange={(e) => {
380
+ if (e.target.files && e.target.files.length > 0) {
381
+ handleFiles(e.target.files).catch(() => {});
382
+ e.target.value = '';
383
+ }
384
+ }}
385
+ />
386
+ </div>
387
+ </div>
388
+
389
+ <div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-hairline bg-background px-4 py-2.5 sm:px-6">
390
+ <div className="relative min-w-[180px] flex-1 md:max-w-[280px]">
391
+ <Search
392
+ className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
393
+ aria-hidden
394
+ />
395
+ <input
396
+ type="text"
397
+ value={query}
398
+ onChange={(event) => setQuery(event.target.value)}
399
+ aria-label={t.asset.assetSearchPlaceholder}
400
+ placeholder={t.asset.assetSearchPlaceholder}
401
+ 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"
402
+ />
403
+ {query ? (
404
+ <button
405
+ type="button"
406
+ onClick={() => setQuery('')}
407
+ aria-label={t.asset.clearAssetSearch}
408
+ className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
409
+ >
410
+ <X className="size-3" />
411
+ </button>
412
+ ) : null}
413
+ </div>
414
+
415
+ <Select
416
+ items={{
417
+ all: t.asset.usageAll,
418
+ used: t.asset.usageUsed,
419
+ unused: t.asset.usageUnused,
420
+ }}
421
+ value={usageFilter}
422
+ onValueChange={(next) => setUsageFilter(next as AssetUsageFilter)}
423
+ >
424
+ <SelectTrigger aria-label={t.asset.usageFilterAria} className="h-8 min-w-[112px]">
425
+ <SelectValue />
426
+ </SelectTrigger>
427
+ <SelectContent align="start">
428
+ <SelectItem value="all">{t.asset.usageAll}</SelectItem>
429
+ <SelectItem value="used">{t.asset.usageUsed}</SelectItem>
430
+ <SelectItem value="unused">{t.asset.usageUnused}</SelectItem>
431
+ </SelectContent>
432
+ </Select>
433
+
434
+ <Select
435
+ items={{
436
+ all: t.asset.typeAll,
437
+ image: t.asset.typeImage,
438
+ font: t.asset.typeFont,
439
+ video: t.asset.typeVideo,
440
+ other: t.asset.typeOther,
441
+ }}
442
+ value={typeFilter}
443
+ onValueChange={(next) => setTypeFilter(next as AssetTypeFilter)}
444
+ >
445
+ <SelectTrigger aria-label={t.asset.typeFilterAria} className="h-8 min-w-[108px]">
446
+ <SelectValue />
447
+ </SelectTrigger>
448
+ <SelectContent align="start">
449
+ <SelectItem value="all">{t.asset.typeAll}</SelectItem>
450
+ <SelectItem value="image">{t.asset.typeImage}</SelectItem>
451
+ <SelectItem value="font">{t.asset.typeFont}</SelectItem>
452
+ <SelectItem value="video">{t.asset.typeVideo}</SelectItem>
453
+ <SelectItem value="other">{t.asset.typeOther}</SelectItem>
454
+ </SelectContent>
455
+ </Select>
456
+
457
+ <AssetSortControl
458
+ sort={sort}
459
+ onKeyChange={changeSortKey}
460
+ onToggleDirection={toggleSortDirection}
461
+ />
462
+
463
+ <div className="ml-auto flex items-center gap-2">
464
+ {viewMode === 'grid' ? (
465
+ <GridColumnsControl value={gridColumns} onChange={setGridColumns} />
466
+ ) : null}
467
+ <ToggleGroup
468
+ value={[viewMode]}
469
+ onValueChange={(value) => {
470
+ const next = value[0];
471
+ if (next) setViewMode(next as ViewMode);
472
+ }}
473
+ variant="outline"
474
+ >
475
+ <ToggleGroupItem
476
+ value="grid"
477
+ aria-label={t.asset.gridViewAria}
478
+ title={t.asset.gridViewAria}
479
+ className="size-8 px-0"
480
+ >
481
+ <LayoutGrid className="size-3.5" />
482
+ </ToggleGroupItem>
483
+ <ToggleGroupItem
484
+ value="list"
485
+ aria-label={t.asset.listViewAria}
486
+ title={t.asset.listViewAria}
487
+ className="size-8 px-0"
488
+ >
489
+ <List className="size-3.5" />
490
+ </ToggleGroupItem>
491
+ </ToggleGroup>
492
+ </div>
493
+ </div>
494
+
495
+ <div className="min-h-0 flex-1 overflow-y-auto">
496
+ {loading ? (
497
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
498
+ {t.asset.loading}
499
+ </div>
500
+ ) : assets.length === 0 ? (
501
+ <EmptyState />
502
+ ) : visibleAssets.length === 0 ? (
503
+ <NoMatchingAssets onClear={clearFilters} />
504
+ ) : (
505
+ <div
506
+ className={cn(
507
+ 'p-4 sm:p-6',
508
+ viewMode === 'grid'
509
+ ? 'grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4 lg:grid-cols-[repeat(var(--asset-grid-columns),minmax(0,1fr))]'
510
+ : 'flex flex-col gap-1',
511
+ )}
512
+ style={
513
+ viewMode === 'grid'
514
+ ? ({ '--asset-grid-columns': gridColumns } as React.CSSProperties)
515
+ : undefined
516
+ }
517
+ >
518
+ {viewMode === 'list' ? <AssetListHeader sort={sort} onSort={sortByColumn} /> : null}
519
+ {visibleAssets.map((asset) =>
520
+ renaming === asset.name ? (
521
+ <RenameAsset
522
+ key={asset.name}
523
+ asset={asset}
524
+ viewMode={viewMode}
525
+ onCancel={() => setRenaming(null)}
526
+ onSubmit={(next) => handleRename(asset, next)}
527
+ />
528
+ ) : viewMode === 'grid' ? (
529
+ <AssetCard
530
+ key={asset.name}
531
+ asset={asset}
532
+ onPreview={() => setPreview(asset)}
533
+ onRename={() => setRenaming(asset.name)}
534
+ onDelete={() => prepareDelete(asset)}
535
+ />
536
+ ) : (
537
+ <AssetListItem
538
+ key={asset.name}
539
+ asset={asset}
540
+ onPreview={() => setPreview(asset)}
541
+ onRename={() => setRenaming(asset.name)}
542
+ onDelete={() => prepareDelete(asset)}
543
+ />
544
+ ),
545
+ )}
546
+ </div>
547
+ )}
548
+ </div>
549
+
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>
562
+ </div>
563
+ </div>
564
+ )}
565
+
566
+ {conflict && (
567
+ <ConflictDialog
568
+ file={conflict.file}
569
+ onChoose={(decision) => {
570
+ conflict.resolve(decision);
571
+ setConflict(null);
572
+ }}
573
+ />
574
+ )}
575
+
576
+ {confirmDelete && (
577
+ <DeleteDialog
578
+ asset={confirmDelete}
579
+ usages={confirmDeleteUsages}
580
+ onCancel={() => {
581
+ setConfirmDelete(null);
582
+ setConfirmDeleteUsages(null);
583
+ }}
584
+ onConfirm={async () => {
585
+ const target = confirmDelete;
586
+ const usages = confirmDeleteUsages ?? [];
587
+ setConfirmDelete(null);
588
+ setConfirmDeleteUsages(null);
589
+ const assetPath =
590
+ scope === 'global' ? `@assets/${target.name}` : `./assets/${target.name}`;
591
+ for (const u of usages) {
592
+ const rev = await revertAssetUsage(u.pageId, assetPath);
593
+ if (!rev.ok) {
594
+ toast.error(format(t.asset.toastRevertFailed, { pageId: u.pageId }));
595
+ return;
596
+ }
597
+ }
598
+ const res = await remove(target.name);
599
+ if (!res.ok) {
600
+ toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
601
+ return;
602
+ }
603
+ const totalUsages = usages.reduce((acc, u) => acc + u.count, 0);
604
+ if (totalUsages > 0) {
605
+ toast.success(
606
+ format(t.asset.toastDeletedWithRevert, {
607
+ name: target.name,
608
+ count: totalUsages,
609
+ }),
610
+ );
611
+ } else {
612
+ toast.success(format(t.asset.toastDeleted, { name: target.name }));
613
+ }
614
+ }}
615
+ />
616
+ )}
617
+
618
+ {preview && <PreviewDialog asset={preview} scope={scope} onClose={() => setPreview(null)} />}
619
+
620
+ {logoSearchOpen && (
621
+ <LogoSearchDialog
622
+ onClose={() => setLogoSearchOpen(false)}
623
+ onPick={(file) => handleFile(file)}
624
+ />
625
+ )}
626
+ </section>
627
+ );
628
+ }
629
+
630
+ function EmptyState() {
631
+ const t = useLocale();
632
+ return (
633
+ <div className="flex h-full flex-col items-center justify-center gap-4 px-6 py-16 text-center">
634
+ <div className="flex size-12 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground">
635
+ <ImageIcon className="size-5" />
636
+ </div>
637
+ <div>
638
+ <p className="font-heading text-[14px] font-semibold tracking-tight">
639
+ {t.asset.noAssetsYet}
640
+ </p>
641
+ <p className="mt-1 max-w-xs text-[12.5px] leading-relaxed text-muted-foreground">
642
+ {t.asset.noAssetsHintPrefix}
643
+ <span className="font-mono text-foreground">{t.asset.upload}</span>
644
+ {t.asset.noAssetsHintSuffix}
645
+ </p>
646
+ </div>
647
+ </div>
648
+ );
649
+ }
650
+
651
+ function NoMatchingAssets({ onClear }: { onClear: () => void }) {
652
+ const t = useLocale();
653
+ return (
654
+ <div className="flex h-full flex-col items-center justify-center gap-4 px-6 py-16 text-center">
655
+ <div className="flex size-12 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground">
656
+ <SearchX className="size-5" />
657
+ </div>
658
+ <div>
659
+ <p className="font-heading text-[14px] font-semibold tracking-tight">
660
+ {t.asset.noMatchingAssets}
661
+ </p>
662
+ <p className="mt-1 text-[12.5px] text-muted-foreground">{t.asset.noMatchingAssetsHint}</p>
663
+ </div>
664
+ <Button variant="outline" size="sm" onClick={onClear}>
665
+ {t.asset.clearFilters}
666
+ </Button>
667
+ </div>
668
+ );
669
+ }
670
+
671
+ function GridColumnsControl({
672
+ value,
673
+ onChange,
674
+ }: {
675
+ value: number;
676
+ onChange: (value: number) => void;
677
+ }) {
678
+ const t = useLocale();
679
+ const valueLabel = format(t.asset.gridColumnsValue, { count: value });
680
+ const docrContainerRef = useRef<HTMLDivElement>(null);
681
+
682
+ useEffect(() => {
683
+ const thumb = docrContainerRef.current?.querySelector<HTMLElement>('[role="slider"]');
684
+ thumb?.setAttribute('aria-label', t.asset.gridColumnsAria);
685
+ thumb?.setAttribute('aria-valuetext', valueLabel);
686
+ }, [t.asset.gridColumnsAria, valueLabel]);
687
+
688
+ return (
689
+ <fieldset
690
+ aria-label={t.asset.gridColumnsAria}
691
+ title={valueLabel}
692
+ 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
+ >
694
+ <Columns3 className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
695
+ <div ref={docrContainerRef} className="min-w-0 flex-1">
696
+ <Slider
697
+ min={MIN_GRID_COLUMNS}
698
+ max={MAX_GRID_COLUMNS}
699
+ step={1}
700
+ value={[value]}
701
+ onValueChange={(v) => onChange((Array.isArray(v) ? v[0] : v) ?? value)}
702
+ />
703
+ </div>
704
+ <output className="w-4 text-right font-mono text-[10.5px] tabular-nums text-muted-foreground">
705
+ {value}
706
+ </output>
707
+ </fieldset>
708
+ );
709
+ }
710
+
711
+ function hasFiles(e: React.DragEvent): boolean {
712
+ const types = e.dataTransfer?.types;
713
+ if (!types) return false;
714
+ for (let i = 0; i < types.length; i++) {
715
+ if (types[i] === 'Files') return true;
716
+ }
717
+ return false;
718
+ }
719
+
720
+ function AssetSortControl({
721
+ sort,
722
+ onKeyChange,
723
+ onToggleDirection,
724
+ }: {
725
+ sort: AssetSortOptions;
726
+ onKeyChange: (key: AssetSortKey) => void;
727
+ onToggleDirection: () => void;
728
+ }) {
729
+ const t = useLocale();
730
+ const labels: Record<AssetSortKey, string> = {
731
+ name: t.asset.nameColumn,
732
+ modified: t.asset.modifiedAt,
733
+ size: t.asset.sizeColumn,
734
+ type: t.asset.typeColumn,
735
+ };
736
+ const directionLabel = sort.direction === 'asc' ? t.asset.sortAscending : t.asset.sortDescending;
737
+ const DirectionIcon = sort.direction === 'asc' ? ArrowUp : ArrowDown;
738
+
739
+ return (
740
+ <div className="flex items-center gap-1">
741
+ <Select
742
+ items={labels}
743
+ value={sort.key}
744
+ onValueChange={(next) => onKeyChange(next as AssetSortKey)}
745
+ >
746
+ <SelectTrigger aria-label={t.asset.sortAria} className="h-8 min-w-[116px]">
747
+ <ArrowUpDown className="size-3.5" aria-hidden />
748
+ <SelectValue />
749
+ </SelectTrigger>
750
+ <SelectContent align="start">
751
+ {SORT_KEYS.map((key) => (
752
+ <SelectItem key={key} value={key}>
753
+ {labels[key]}
754
+ </SelectItem>
755
+ ))}
756
+ </SelectContent>
757
+ </Select>
758
+ <Button
759
+ type="button"
760
+ variant="outline"
761
+ size="icon"
762
+ onClick={onToggleDirection}
763
+ aria-label={directionLabel}
764
+ title={directionLabel}
765
+ className="size-8 bg-background"
766
+ >
767
+ <DirectionIcon className="size-3.5 text-muted-foreground" />
768
+ </Button>
769
+ </div>
770
+ );
771
+ }
772
+
773
+ function SortableColumnHeader({
774
+ label,
775
+ sortKey,
776
+ sort,
777
+ onSort,
778
+ className,
779
+ }: {
780
+ label: string;
781
+ sortKey: AssetSortKey;
782
+ sort: AssetSortOptions;
783
+ onSort: (key: AssetSortKey) => void;
784
+ className?: string;
785
+ }) {
786
+ const t = useLocale();
787
+ const active = sort.key === sortKey;
788
+ const directionLabel = sort.direction === 'asc' ? t.asset.sortAscending : t.asset.sortDescending;
789
+ const DirectionIcon = sort.direction === 'asc' ? ArrowUp : ArrowDown;
790
+ const sortLabel = format(t.asset.sortByColumn, { column: label });
791
+
792
+ return (
793
+ <button
794
+ type="button"
795
+ onClick={() => onSort(sortKey)}
796
+ aria-label={active ? `${sortLabel}, ${directionLabel}` : sortLabel}
797
+ title={active ? `${sortLabel} · ${directionLabel}` : sortLabel}
798
+ className={cn(
799
+ 'group flex h-5 items-center gap-1 rounded-[4px] text-left outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30',
800
+ active && 'text-foreground',
801
+ className,
802
+ )}
803
+ >
804
+ <span className="truncate">{label}</span>
805
+ {active ? (
806
+ <DirectionIcon className="size-2.5 shrink-0" aria-hidden />
807
+ ) : (
808
+ <ArrowUpDown
809
+ className="size-2.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-60 group-focus-visible:opacity-60"
810
+ aria-hidden
811
+ />
812
+ )}
813
+ </button>
814
+ );
815
+ }
816
+
817
+ function AssetListHeader({
818
+ sort,
819
+ onSort,
820
+ }: {
821
+ sort: AssetSortOptions;
822
+ onSort: (key: AssetSortKey) => void;
823
+ }) {
824
+ const t = useLocale();
825
+ return (
826
+ <div className="hidden items-center gap-3 px-2 pb-1 text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/70 sm:flex">
827
+ <SortableColumnHeader
828
+ label={t.asset.nameColumn}
829
+ sortKey="name"
830
+ sort={sort}
831
+ onSort={onSort}
832
+ className="min-w-0 flex-1"
833
+ />
834
+ <SortableColumnHeader
835
+ label={t.asset.typeColumn}
836
+ sortKey="type"
837
+ sort={sort}
838
+ onSort={onSort}
839
+ className="hidden w-40 shrink-0 md:flex"
840
+ />
841
+ <SortableColumnHeader
842
+ label={t.asset.modifiedAt}
843
+ sortKey="modified"
844
+ sort={sort}
845
+ onSort={onSort}
846
+ className="hidden w-28 shrink-0 lg:flex"
847
+ />
848
+ <SortableColumnHeader
849
+ label={t.asset.sizeColumn}
850
+ sortKey="size"
851
+ sort={sort}
852
+ onSort={onSort}
853
+ className="w-16 shrink-0"
854
+ />
855
+ <span className="w-16 shrink-0 text-right">{t.asset.statusColumn}</span>
856
+ <span className="size-6 shrink-0" aria-hidden />
857
+ </div>
858
+ );
859
+ }
860
+
861
+ function AssetCard({
862
+ asset,
863
+ onPreview,
864
+ onRename,
865
+ onDelete,
866
+ }: {
867
+ asset: AssetEntry;
868
+ onPreview: () => void;
869
+ onRename: () => void;
870
+ onDelete: () => void;
871
+ }) {
872
+ const isImage = asset.mime.startsWith('image/');
873
+ const t = useLocale();
874
+ 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">
876
+ <button
877
+ type="button"
878
+ onClick={onPreview}
879
+ aria-label={format(t.asset.previewAria, { name: asset.name })}
880
+ 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]"
881
+ >
882
+ {isImage ? (
883
+ <img
884
+ src={asset.url}
885
+ alt=""
886
+ className="size-full object-contain"
887
+ draggable={false}
888
+ onError={(e) => {
889
+ e.currentTarget.style.display = 'none';
890
+ }}
891
+ />
892
+ ) : (
893
+ <FileIcon className="size-9 text-muted-foreground" />
894
+ )}
895
+ </button>
896
+
897
+ <div className="flex items-center gap-1 px-2.5 py-2">
898
+ <div className="min-w-0 flex-1">
899
+ <div className="truncate text-[12.5px] font-medium" title={asset.name}>
900
+ {asset.name}
901
+ </div>
902
+ <div className="folio flex items-center gap-1.5">
903
+ <span className="truncate">{formatSize(asset.size)}</span>
904
+ <UsageBadge unused={asset.unused} />
905
+ </div>
906
+ <div
907
+ className="mt-0.5 flex min-w-0 items-center gap-1 text-[10.5px] leading-tight text-muted-foreground"
908
+ title={`${t.asset.modifiedAt}: ${formatAssetDate(asset.mtime, t.id, true)}`}
909
+ >
910
+ <span className="shrink-0">{t.asset.modifiedAt}</span>
911
+ <span className="opacity-40">·</span>
912
+ <time dateTime={assetDateTime(asset.mtime)} className="truncate font-mono">
913
+ {formatAssetDate(asset.mtime, t.id)}
914
+ </time>
915
+ </div>
916
+ </div>
917
+ <AssetActions asset={asset} onPreview={onPreview} onRename={onRename} onDelete={onDelete} />
918
+ </div>
919
+ </div>
920
+ );
921
+ }
922
+
923
+ function AssetListItem({
924
+ asset,
925
+ onPreview,
926
+ onRename,
927
+ onDelete,
928
+ }: {
929
+ asset: AssetEntry;
930
+ onPreview: () => void;
931
+ onRename: () => void;
932
+ onDelete: () => void;
933
+ }) {
934
+ const isImage = asset.mime.startsWith('image/');
935
+ const t = useLocale();
936
+ 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">
938
+ <button
939
+ type="button"
940
+ onClick={onPreview}
941
+ aria-label={format(t.asset.previewAria, { name: asset.name })}
942
+ className="relative flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-[4px] border border-hairline bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:10px_10px]"
943
+ >
944
+ {isImage ? (
945
+ <img
946
+ src={asset.url}
947
+ alt=""
948
+ className="size-full object-contain"
949
+ draggable={false}
950
+ onError={(event) => {
951
+ event.currentTarget.style.display = 'none';
952
+ }}
953
+ />
954
+ ) : (
955
+ <FileIcon className="size-5 text-muted-foreground" />
956
+ )}
957
+ </button>
958
+ <div className="min-w-0 flex-1">
959
+ <div className="truncate text-[12.5px] font-medium" title={asset.name}>
960
+ {asset.name}
961
+ </div>
962
+ <div className="folio mt-0.5 flex min-w-0 items-center gap-1.5 lg:hidden">
963
+ <span className="sm:hidden">{formatSize(asset.size)}</span>
964
+ <span className="opacity-40 sm:hidden">·</span>
965
+ <span className="truncate md:hidden">{asset.mime}</span>
966
+ <span className="opacity-40 md:hidden">·</span>
967
+ <span className="shrink-0">{t.asset.modifiedAt}</span>
968
+ <time dateTime={assetDateTime(asset.mtime)} className="truncate">
969
+ {formatAssetDate(asset.mtime, t.id)}
970
+ </time>
971
+ <span className="sm:hidden">
972
+ <UsageBadge unused={asset.unused} showUsed />
973
+ </span>
974
+ </div>
975
+ </div>
976
+ <span className="hidden w-40 shrink-0 truncate font-mono text-[11px] text-muted-foreground md:block">
977
+ {asset.mime}
978
+ </span>
979
+ <time
980
+ dateTime={assetDateTime(asset.mtime)}
981
+ title={`${t.asset.modifiedAt}: ${formatAssetDate(asset.mtime, t.id, true)}`}
982
+ className="hidden w-28 shrink-0 text-[11.5px] text-muted-foreground lg:block"
983
+ >
984
+ <span className="sr-only">{t.asset.modifiedAt}: </span>
985
+ {formatAssetDate(asset.mtime, t.id)}
986
+ </time>
987
+ <span className="folio hidden w-16 shrink-0 sm:block">{formatSize(asset.size)}</span>
988
+ <div className="hidden w-16 shrink-0 justify-end sm:flex">
989
+ <UsageBadge unused={asset.unused} showUsed />
990
+ </div>
991
+ <AssetActions asset={asset} onPreview={onPreview} onRename={onRename} onDelete={onDelete} />
992
+ </div>
993
+ );
994
+ }
995
+
996
+ function AssetActions({
997
+ asset,
998
+ onPreview,
999
+ onRename,
1000
+ onDelete,
1001
+ }: {
1002
+ asset: AssetEntry;
1003
+ onPreview: () => void;
1004
+ onRename: () => void;
1005
+ onDelete: () => void;
1006
+ }) {
1007
+ const t = useLocale();
1008
+ return (
1009
+ <DropdownMenu>
1010
+ <DropdownMenuTrigger
1011
+ type="button"
1012
+ aria-label={format(t.asset.actionsAria, { name: asset.name })}
1013
+ className={cn(
1014
+ buttonVariants({ variant: 'ghost', size: 'icon-xs' }),
1015
+ 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 aria-expanded:opacity-100',
1016
+ )}
1017
+ >
1018
+ <MoreVertical />
1019
+ </DropdownMenuTrigger>
1020
+ <DropdownMenuContent align="end" className="min-w-[160px]">
1021
+ <DropdownMenuItem onClick={onPreview}>
1022
+ <ImageIcon />
1023
+ {t.asset.previewMenuItem}
1024
+ </DropdownMenuItem>
1025
+ <DropdownMenuItem onClick={onRename}>
1026
+ <Pencil />
1027
+ {t.asset.renameMenuItem}
1028
+ </DropdownMenuItem>
1029
+ <DropdownMenuItem onClick={onDelete}>
1030
+ <Trash2 />
1031
+ {t.asset.deleteMenuItem}
1032
+ </DropdownMenuItem>
1033
+ </DropdownMenuContent>
1034
+ </DropdownMenu>
1035
+ );
1036
+ }
1037
+
1038
+ function UsageBadge({ unused, showUsed = false }: { unused: boolean; showUsed?: boolean }) {
1039
+ const t = useLocale();
1040
+ if (!unused && !showUsed) return null;
1041
+ return (
1042
+ <span
1043
+ className={cn(
1044
+ 'shrink-0 rounded-sm px-1 py-px text-[10px] font-medium leading-none',
1045
+ unused
1046
+ ? 'bg-muted text-muted-foreground'
1047
+ : 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
1048
+ )}
1049
+ >
1050
+ {unused ? t.asset.usageUnused : t.asset.usageUsed}
1051
+ </span>
1052
+ );
1053
+ }
1054
+
1055
+ function AssetTimestamp({
1056
+ label,
1057
+ timestamp,
1058
+ locale,
1059
+ }: {
1060
+ label: string;
1061
+ timestamp: number;
1062
+ locale: string;
1063
+ }) {
1064
+ return (
1065
+ <div className="bg-card px-3 py-2.5">
1066
+ <span className="eyebrow block text-[9px]">{label}</span>
1067
+ <time
1068
+ dateTime={assetDateTime(timestamp)}
1069
+ className="mt-1 block text-[12px] font-medium tabular-nums"
1070
+ >
1071
+ {formatAssetDate(timestamp, locale, true)}
1072
+ </time>
1073
+ </div>
1074
+ );
1075
+ }
1076
+
1077
+ function RenameAsset({
1078
+ asset,
1079
+ viewMode,
1080
+ onCancel,
1081
+ onSubmit,
1082
+ }: {
1083
+ asset: AssetEntry;
1084
+ viewMode: ViewMode;
1085
+ onCancel: () => void;
1086
+ onSubmit: (next: string) => Promise<void> | void;
1087
+ }) {
1088
+ const [value, setValue] = useState(asset.name);
1089
+ const [saving, setSaving] = useState(false);
1090
+ const inputRef = useRef<HTMLInputElement | null>(null);
1091
+ const t = useLocale();
1092
+
1093
+ useEffect(() => {
1094
+ queueMicrotask(() => {
1095
+ inputRef.current?.focus();
1096
+ const dot = asset.name.lastIndexOf('.');
1097
+ if (dot > 0) inputRef.current?.setSelectionRange(0, dot);
1098
+ else inputRef.current?.select();
1099
+ });
1100
+ }, [asset.name]);
1101
+
1102
+ const commit = async () => {
1103
+ const trimmed = value.trim();
1104
+ if (!trimmed) {
1105
+ onCancel();
1106
+ return;
1107
+ }
1108
+ setSaving(true);
1109
+ try {
1110
+ await onSubmit(trimmed);
1111
+ } finally {
1112
+ setSaving(false);
1113
+ }
1114
+ };
1115
+
1116
+ const isImage = asset.mime.startsWith('image/');
1117
+ if (viewMode === 'list') {
1118
+ return (
1119
+ <div className="flex min-h-14 items-center gap-3 rounded-[6px] border border-primary bg-card p-2 shadow-edge ring-2 ring-primary/15">
1120
+ <div className="relative flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-[4px] border border-hairline bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:10px_10px]">
1121
+ {isImage ? (
1122
+ <img src={asset.url} alt="" className="size-full object-contain" draggable={false} />
1123
+ ) : (
1124
+ <FileIcon className="size-5 text-muted-foreground" />
1125
+ )}
1126
+ </div>
1127
+ <input
1128
+ ref={inputRef}
1129
+ value={value}
1130
+ disabled={saving}
1131
+ onChange={(event) => setValue(event.target.value)}
1132
+ onBlur={() => {
1133
+ if (!saving) commit();
1134
+ }}
1135
+ onKeyDown={(event) => {
1136
+ if (event.nativeEvent.isComposing) return;
1137
+ if (event.key === 'Enter') {
1138
+ event.preventDefault();
1139
+ commit();
1140
+ } else if (event.key === 'Escape') {
1141
+ event.preventDefault();
1142
+ onCancel();
1143
+ }
1144
+ }}
1145
+ maxLength={120}
1146
+ className="h-8 min-w-0 flex-1 rounded-[5px] border bg-background px-2.5 text-[12.5px] outline-none ring-ring/40 focus:ring-2"
1147
+ />
1148
+ <span className="hidden w-40 shrink-0 truncate font-mono text-[11px] text-muted-foreground md:block">
1149
+ {asset.mime}
1150
+ </span>
1151
+ <time
1152
+ dateTime={assetDateTime(asset.mtime)}
1153
+ className="hidden w-28 shrink-0 text-[11.5px] text-muted-foreground lg:block"
1154
+ >
1155
+ <span className="sr-only">{t.asset.modifiedAt}: </span>
1156
+ {formatAssetDate(asset.mtime, t.id)}
1157
+ </time>
1158
+ <span className="folio hidden w-16 shrink-0 sm:block">{formatSize(asset.size)}</span>
1159
+ <div className="hidden w-16 shrink-0 justify-end sm:flex">
1160
+ <UsageBadge unused={asset.unused} showUsed />
1161
+ </div>
1162
+ <div className="size-6 shrink-0" />
1163
+ </div>
1164
+ );
1165
+ }
1166
+
1167
+ return (
1168
+ <div className="relative flex flex-col overflow-hidden rounded-[6px] border border-primary bg-card shadow-edge ring-2 ring-primary/15">
1169
+ <div className="relative flex aspect-square w-full items-center justify-center overflow-hidden bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:16px_16px]">
1170
+ {isImage ? (
1171
+ <img src={asset.url} alt="" className="size-full object-contain" draggable={false} />
1172
+ ) : (
1173
+ <FileIcon className="size-10 text-muted-foreground" />
1174
+ )}
1175
+ </div>
1176
+ <div className="border-t bg-card px-2 py-2">
1177
+ <input
1178
+ ref={inputRef}
1179
+ value={value}
1180
+ disabled={saving}
1181
+ onChange={(e) => setValue(e.target.value)}
1182
+ onBlur={() => {
1183
+ if (!saving) commit();
1184
+ }}
1185
+ onKeyDown={(e) => {
1186
+ if (e.nativeEvent.isComposing) return;
1187
+ if (e.key === 'Enter') {
1188
+ e.preventDefault();
1189
+ commit();
1190
+ } else if (e.key === 'Escape') {
1191
+ e.preventDefault();
1192
+ onCancel();
1193
+ }
1194
+ }}
1195
+ maxLength={120}
1196
+ className="w-full rounded-md border bg-background px-2 py-1 text-sm outline-none ring-ring/40 focus:ring-2"
1197
+ />
1198
+ </div>
1199
+ </div>
1200
+ );
1201
+ }
1202
+
1203
+ function ConflictDialog({
1204
+ file,
1205
+ onChoose,
1206
+ }: {
1207
+ file: File;
1208
+ onChoose: (decision: 'replace' | 'rename' | 'cancel') => void;
1209
+ }) {
1210
+ const t = useLocale();
1211
+ const [descPrefix, descSuffix] = t.asset.conflictDescription.split('{name}');
1212
+ return (
1213
+ <Dialog open onOpenChange={(open) => !open && onChoose('cancel')}>
1214
+ <DialogContent>
1215
+ <DialogHeader>
1216
+ <DialogTitle>{t.asset.conflictTitle}</DialogTitle>
1217
+ <DialogDescription>
1218
+ {descPrefix}
1219
+ <span className="font-mono">{file.name}</span>
1220
+ {descSuffix}
1221
+ </DialogDescription>
1222
+ </DialogHeader>
1223
+ <DialogFooter>
1224
+ <Button variant="outline" onClick={() => onChoose('cancel')}>
1225
+ {t.common.cancel}
1226
+ </Button>
1227
+ <Button variant="outline" onClick={() => onChoose('rename')}>
1228
+ {t.asset.conflictRenameCopy}
1229
+ </Button>
1230
+ <Button onClick={() => onChoose('replace')}>{t.asset.conflictReplace}</Button>
1231
+ </DialogFooter>
1232
+ </DialogContent>
1233
+ </Dialog>
1234
+ );
1235
+ }
1236
+
1237
+ function DeleteDialog({
1238
+ asset,
1239
+ usages,
1240
+ onCancel,
1241
+ onConfirm,
1242
+ }: {
1243
+ asset: AssetEntry;
1244
+ usages: AssetUsage[] | null;
1245
+ onCancel: () => void;
1246
+ onConfirm: () => void;
1247
+ }) {
1248
+ const t = useLocale();
1249
+ const inUse = (usages?.length ?? 0) > 0;
1250
+ const totalUses = usages?.reduce((acc, u) => acc + u.count, 0) ?? 0;
1251
+ const docCount = usages?.length ?? 0;
1252
+ const [descPrefix, descSuffix] = t.asset.deleteAssetDescription.split('{name}');
1253
+ return (
1254
+ <Dialog open onOpenChange={(open) => !open && onCancel()}>
1255
+ <DialogContent>
1256
+ <DialogHeader>
1257
+ <DialogTitle>{t.asset.deleteAssetTitle}</DialogTitle>
1258
+ <DialogDescription>
1259
+ {inUse ? (
1260
+ <>
1261
+ {format(t.asset.deleteAssetInUseDescription, {
1262
+ name: asset.name,
1263
+ count: totalUses,
1264
+ pages: docCount,
1265
+ })}{' '}
1266
+ {t.asset.deleteAssetInUseHint}
1267
+ </>
1268
+ ) : (
1269
+ <>
1270
+ {descPrefix}
1271
+ <span className="font-mono">{asset.name}</span>
1272
+ {descSuffix}
1273
+ </>
1274
+ )}
1275
+ </DialogDescription>
1276
+ </DialogHeader>
1277
+ {inUse && usages && (
1278
+ <ul className="max-h-40 overflow-y-auto rounded-[5px] border border-hairline bg-muted/40 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
1279
+ {usages.map((u) => (
1280
+ <li key={u.pageId} className="flex items-center justify-between gap-3">
1281
+ <span className="truncate">{u.pageId}</span>
1282
+ <span className="text-muted-foreground">×{u.count}</span>
1283
+ </li>
1284
+ ))}
1285
+ </ul>
1286
+ )}
1287
+ <DialogFooter>
1288
+ <Button variant="outline" onClick={onCancel}>
1289
+ {t.common.cancel}
1290
+ </Button>
1291
+ <Button variant="destructive" onClick={onConfirm} disabled={usages === null}>
1292
+ {inUse ? t.asset.deleteAndRevert : t.common.delete}
1293
+ </Button>
1294
+ </DialogFooter>
1295
+ </DialogContent>
1296
+ </Dialog>
1297
+ );
1298
+ }
1299
+
1300
+ function NoResultsMessage({ query, t }: { query: string; t: ReturnType<typeof useLocale> }) {
1301
+ const [prefix, suffix] = t.asset.logoSearchNoResults.split('{query}');
1302
+ return (
1303
+ <>
1304
+ {prefix}
1305
+ <span className="font-mono text-foreground">{query}</span>
1306
+ {suffix}
1307
+ </>
1308
+ );
1309
+ }
1310
+
1311
+ function PreviewDialog({
1312
+ asset,
1313
+ scope,
1314
+ onClose,
1315
+ }: {
1316
+ asset: AssetEntry;
1317
+ scope: Scope;
1318
+ onClose: () => void;
1319
+ }) {
1320
+ const isImage = asset.mime.startsWith('image/');
1321
+ const importPath = scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
1322
+ const t = useLocale();
1323
+ return (
1324
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
1325
+ <DialogContent className="sm:max-w-2xl">
1326
+ <DialogHeader>
1327
+ <DialogTitle className="font-mono text-base">{asset.name}</DialogTitle>
1328
+ <DialogDescription>
1329
+ {formatSize(asset.size)} · {asset.mime}
1330
+ </DialogDescription>
1331
+ </DialogHeader>
1332
+ {isImage ? (
1333
+ <div className="flex max-h-[60vh] items-center justify-center overflow-hidden rounded-md border bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:16px_16px]">
1334
+ <img
1335
+ src={asset.url}
1336
+ alt={asset.name}
1337
+ className="max-h-[60vh] max-w-full object-contain"
1338
+ />
1339
+ </div>
1340
+ ) : (
1341
+ <div className="flex items-center justify-center rounded-md border bg-muted/40 py-12 text-muted-foreground">
1342
+ <FileImage className="mr-2 size-5" />
1343
+ <span className="text-sm">{t.asset.noPreview}</span>
1344
+ </div>
1345
+ )}
1346
+ <div className="grid grid-cols-2 gap-px overflow-hidden rounded-[6px] border border-hairline bg-hairline">
1347
+ <AssetTimestamp label={t.asset.createdAt} timestamp={asset.createdAt} locale={t.id} />
1348
+ <AssetTimestamp label={t.asset.modifiedAt} timestamp={asset.mtime} locale={t.id} />
1349
+ </div>
1350
+ <div className="rounded-[5px] border border-hairline bg-muted/50 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
1351
+ <span className="text-muted-foreground">{t.asset.importHintComment}</span>
1352
+ <span className="text-brand">'{importPath}'</span>
1353
+ <span className="text-muted-foreground">{t.asset.importHintSemi}</span>
1354
+ </div>
1355
+ </DialogContent>
1356
+ </Dialog>
1357
+ );
1358
+ }
1359
+
1360
+ const SKELETON_SLOTS = ['s0', 's1', 's2', 's3', 's4', 's5'] as const;
1361
+
1362
+ function LogoSearchDialog({
1363
+ onClose,
1364
+ onPick,
1365
+ }: {
1366
+ onClose: () => void;
1367
+ onPick: (file: File) => Promise<void> | void;
1368
+ }) {
1369
+ const [query, setQuery] = useState('');
1370
+ const [results, setResults] = useState<SvglItem[] | null>(null);
1371
+ const [loading, setLoading] = useState(true);
1372
+ const [error, setError] = useState<string | null>(null);
1373
+ const [pending, setPending] = useState<Set<number>>(() => new Set());
1374
+ const [retryToken, setRetryToken] = useState(0);
1375
+ const inputRef = useRef<HTMLInputElement | null>(null);
1376
+ const t = useLocale();
1377
+
1378
+ useEffect(() => {
1379
+ queueMicrotask(() => inputRef.current?.focus());
1380
+ }, []);
1381
+
1382
+ // biome-ignore lint/correctness/useExhaustiveDependencies: retryToken is a bump-to-refetch trigger
1383
+ useEffect(() => {
1384
+ const ctrl = new AbortController();
1385
+ const timer = setTimeout(() => {
1386
+ setLoading(true);
1387
+ setError(null);
1388
+ searchSvgl(query, ctrl.signal)
1389
+ .then((next) => {
1390
+ setResults(next);
1391
+ setLoading(false);
1392
+ })
1393
+ .catch((err: unknown) => {
1394
+ if (ctrl.signal.aborted) return;
1395
+ setError(err instanceof Error ? err.message : t.asset.toastSearchFailed);
1396
+ setLoading(false);
1397
+ });
1398
+ }, 200);
1399
+ return () => {
1400
+ clearTimeout(timer);
1401
+ ctrl.abort();
1402
+ };
1403
+ }, [query, retryToken]);
1404
+
1405
+ return (
1406
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
1407
+ <DialogContent className="sm:max-w-2xl">
1408
+ <DialogHeader>
1409
+ <DialogTitle>{t.asset.logoSearchTitle}</DialogTitle>
1410
+ <DialogDescription>
1411
+ {t.asset.logoSearchPoweredByPrefix}
1412
+ <a
1413
+ href="https://svgl.app"
1414
+ target="_blank"
1415
+ rel="noopener noreferrer"
1416
+ className="underline underline-offset-2"
1417
+ >
1418
+ svgl.app
1419
+ </a>
1420
+ .
1421
+ </DialogDescription>
1422
+ </DialogHeader>
1423
+
1424
+ <div className="relative">
1425
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
1426
+ <input
1427
+ ref={inputRef}
1428
+ value={query}
1429
+ onChange={(e) => setQuery(e.target.value)}
1430
+ placeholder={t.asset.logoSearchPlaceholder}
1431
+ 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"
1432
+ />
1433
+ </div>
1434
+
1435
+ <div className="max-h-[60vh] min-h-[16rem] overflow-y-auto">
1436
+ {error ? (
1437
+ <div className="flex h-64 flex-col items-center justify-center gap-3 px-6 text-center">
1438
+ <div className="flex size-12 items-center justify-center rounded-full bg-muted">
1439
+ <CloudOff className="size-5 text-muted-foreground" />
1440
+ </div>
1441
+ <div>
1442
+ <p className="text-sm font-medium">{t.asset.logoSearchErrorTitle}</p>
1443
+ <p className="mt-1 text-xs text-muted-foreground">{t.asset.logoSearchErrorBody}</p>
1444
+ </div>
1445
+ <Button
1446
+ variant="outline"
1447
+ size="sm"
1448
+ onClick={() => setRetryToken((n) => n + 1)}
1449
+ className="gap-1.5"
1450
+ >
1451
+ <RotateCw className="size-3.5" />
1452
+ {t.common.tryAgain}
1453
+ </Button>
1454
+ </div>
1455
+ ) : loading && !results ? (
1456
+ <div className="grid grid-cols-3 gap-3">
1457
+ {SKELETON_SLOTS.map((slot) => (
1458
+ <div
1459
+ key={slot}
1460
+ className="aspect-square animate-pulse rounded-lg border bg-muted/40"
1461
+ />
1462
+ ))}
1463
+ </div>
1464
+ ) : results && results.length === 0 ? (
1465
+ <div className="flex h-64 flex-col items-center justify-center gap-3 px-6 text-center">
1466
+ <div className="flex size-12 items-center justify-center rounded-full bg-muted">
1467
+ <SearchX className="size-5 text-muted-foreground" />
1468
+ </div>
1469
+ <div>
1470
+ <p className="text-sm font-medium">
1471
+ {query.trim() ? (
1472
+ <NoResultsMessage query={query.trim()} t={t} />
1473
+ ) : (
1474
+ t.asset.logoSearchEmpty
1475
+ )}
1476
+ </p>
1477
+ <p className="mt-1 text-xs text-muted-foreground">
1478
+ {t.asset.logoSearchEmptyHintPrefix}
1479
+ <a
1480
+ href="https://svgl.app"
1481
+ target="_blank"
1482
+ rel="noopener noreferrer"
1483
+ className="underline underline-offset-2 hover:text-foreground"
1484
+ >
1485
+ svgl.app
1486
+ </a>
1487
+ {t.asset.logoSearchEmptyHintSuffix}
1488
+ </p>
1489
+ </div>
1490
+ </div>
1491
+ ) : (
1492
+ <div className="grid grid-cols-3 gap-3">
1493
+ {results?.map((item) => (
1494
+ <LogoResultCard
1495
+ key={item.id}
1496
+ item={item}
1497
+ pending={pending.has(item.id)}
1498
+ onAdd={async (file) => {
1499
+ setPending((prev) => {
1500
+ const next = new Set(prev);
1501
+ next.add(item.id);
1502
+ return next;
1503
+ });
1504
+ try {
1505
+ await onPick(file);
1506
+ } catch (err) {
1507
+ toast.error(err instanceof Error ? err.message : t.asset.toastDownloadFailed);
1508
+ } finally {
1509
+ setPending((prev) => {
1510
+ const next = new Set(prev);
1511
+ next.delete(item.id);
1512
+ return next;
1513
+ });
1514
+ }
1515
+ }}
1516
+ />
1517
+ ))}
1518
+ </div>
1519
+ )}
1520
+ </div>
1521
+
1522
+ <DialogFooter>
1523
+ <Button variant="outline" onClick={onClose}>
1524
+ {t.common.done}
1525
+ </Button>
1526
+ </DialogFooter>
1527
+ </DialogContent>
1528
+ </Dialog>
1529
+ );
1530
+ }
1531
+
1532
+ function LogoResultCard({
1533
+ item,
1534
+ pending,
1535
+ onAdd,
1536
+ }: {
1537
+ item: SvglItem;
1538
+ pending: boolean;
1539
+ onAdd: (file: File) => Promise<void> | void;
1540
+ }) {
1541
+ const hasVariants = typeof item.route === 'object' && item.route !== null;
1542
+ const [variant, setVariant] = useState<'light' | 'dark'>('light');
1543
+ const t = useLocale();
1544
+
1545
+ const previewUrl = useMemo(() => {
1546
+ if (typeof item.route === 'string') return item.route;
1547
+ return item.route[variant];
1548
+ }, [item.route, variant]);
1549
+
1550
+ const filename = useMemo(() => {
1551
+ const url = previewUrl;
1552
+ const fromUrl = basenameFromUrl(url);
1553
+ if (fromUrl) return fromUrl;
1554
+ const slug = slugify(item.title);
1555
+ return hasVariants ? `${slug}-${variant}.svg` : `${slug}.svg`;
1556
+ }, [previewUrl, item.title, hasVariants, variant]);
1557
+
1558
+ const category = Array.isArray(item.category) ? item.category.join(', ') : item.category;
1559
+
1560
+ return (
1561
+ <div className="group flex flex-col overflow-hidden rounded-lg border bg-card">
1562
+ <div
1563
+ className={cn(
1564
+ 'relative flex aspect-square w-full items-center justify-center overflow-hidden bg-[repeating-conic-gradient(theme(colors.muted)_0_25%,transparent_0_50%)] bg-[length:16px_16px]',
1565
+ variant === 'dark' && hasVariants && 'bg-neutral-900',
1566
+ )}
1567
+ >
1568
+ <img src={previewUrl} alt={item.title} className="size-3/4 object-contain" />
1569
+ </div>
1570
+ <div className="flex flex-col gap-1.5 border-t bg-card px-2.5 py-2">
1571
+ <div className="min-w-0">
1572
+ <div className="truncate text-xs font-medium" title={item.title}>
1573
+ {item.title}
1574
+ </div>
1575
+ <div className="truncate text-[10px] text-muted-foreground">{category}</div>
1576
+ </div>
1577
+ <div className="flex items-center gap-1.5">
1578
+ {hasVariants ? (
1579
+ <div className="flex overflow-hidden rounded-md border text-[10px]">
1580
+ <button
1581
+ type="button"
1582
+ onClick={() => setVariant('light')}
1583
+ className={cn(
1584
+ 'px-1.5 py-0.5 transition-colors',
1585
+ variant === 'light' ? 'bg-foreground text-background' : 'hover:bg-muted',
1586
+ )}
1587
+ >
1588
+ {t.asset.logoVariantLight}
1589
+ </button>
1590
+ <button
1591
+ type="button"
1592
+ onClick={() => setVariant('dark')}
1593
+ className={cn(
1594
+ 'border-l px-1.5 py-0.5 transition-colors',
1595
+ variant === 'dark' ? 'bg-foreground text-background' : 'hover:bg-muted',
1596
+ )}
1597
+ >
1598
+ {t.asset.logoVariantDark}
1599
+ </button>
1600
+ </div>
1601
+ ) : null}
1602
+ <Button
1603
+ size="sm"
1604
+ variant="outline"
1605
+ disabled={pending}
1606
+ onClick={async () => {
1607
+ try {
1608
+ const file = await fetchSvgAsFile(previewUrl, filename);
1609
+ await onAdd(file);
1610
+ } catch (err) {
1611
+ toast.error(err instanceof Error ? err.message : t.asset.toastDownloadFailed);
1612
+ }
1613
+ }}
1614
+ className="ml-auto h-6 px-2 text-[11px]"
1615
+ >
1616
+ {pending ? <Loader2 className="size-3 animate-spin" /> : t.common.add}
1617
+ </Button>
1618
+ </div>
1619
+ </div>
1620
+ </div>
1621
+ );
1622
+ }
1623
+
1624
+ function basenameFromUrl(u: string): string {
1625
+ try {
1626
+ return new URL(u).pathname.split('/').pop() || '';
1627
+ } catch {
1628
+ return '';
1629
+ }
1630
+ }
1631
+
1632
+ function slugify(s: string): string {
1633
+ return s
1634
+ .toLowerCase()
1635
+ .replace(/[^a-z0-9]+/g, '-')
1636
+ .replace(/^-|-$/g, '');
1637
+ }
1638
+
1639
+ const ASSET_DATE_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
1640
+
1641
+ function formatAssetDate(timestamp: number, locale: string, includeTime = false): string {
1642
+ const date = new Date(timestamp);
1643
+ if (Number.isNaN(date.getTime())) return '—';
1644
+ const key = `${locale}:${includeTime ? 'date-time' : 'date'}`;
1645
+ let formatter = ASSET_DATE_FORMATTERS.get(key);
1646
+ if (!formatter) {
1647
+ formatter = new Intl.DateTimeFormat(
1648
+ locale,
1649
+ includeTime ? { dateStyle: 'medium', timeStyle: 'short' } : { dateStyle: 'medium' },
1650
+ );
1651
+ ASSET_DATE_FORMATTERS.set(key, formatter);
1652
+ }
1653
+ return formatter.format(date);
1654
+ }
1655
+
1656
+ function assetDateTime(timestamp: number): string | undefined {
1657
+ const date = new Date(timestamp);
1658
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
1659
+ }
1660
+
1661
+ function formatSize(bytes: number): string {
1662
+ if (bytes < 1024) return `${bytes} B`;
1663
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1664
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1665
+ }