@open-aippt/core 1.13.2

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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin.js +2 -0
  4. package/dist/build-DxTqmvsO.js +17 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +86 -0
  7. package/dist/config-CjzqjrEA.js +4280 -0
  8. package/dist/config-DIC-yVPp.d.ts +23 -0
  9. package/dist/design-cpzS8aud.js +35 -0
  10. package/dist/dev-BYuTeJbA.js +20 -0
  11. package/dist/format-BCeKbTOM.js +1605 -0
  12. package/dist/index.d.ts +134 -0
  13. package/dist/index.js +467 -0
  14. package/dist/locale/index.d.ts +24 -0
  15. package/dist/locale/index.js +3 -0
  16. package/dist/preview-DlQvnJPq.js +18 -0
  17. package/dist/sync-BPZ0m27m.js +139 -0
  18. package/dist/sync-EsYusbbL.js +3 -0
  19. package/dist/types-CHmFPIG_.d.ts +430 -0
  20. package/dist/vite/index.d.ts +14 -0
  21. package/dist/vite/index.js +4 -0
  22. package/env.d.ts +59 -0
  23. package/package.json +103 -0
  24. package/skills/apply-comments/SKILL.md +83 -0
  25. package/skills/create-slide/SKILL.md +91 -0
  26. package/skills/create-theme/SKILL.md +250 -0
  27. package/skills/current-slide/SKILL.md +110 -0
  28. package/skills/slide-authoring/SKILL.md +625 -0
  29. package/src/app/app.tsx +47 -0
  30. package/src/app/components/asset-view.tsx +966 -0
  31. package/src/app/components/history-provider.tsx +120 -0
  32. package/src/app/components/image-placeholder.tsx +243 -0
  33. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  34. package/src/app/components/inspector/comment-widget.tsx +93 -0
  35. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  36. package/src/app/components/inspector/inspect-overlay.tsx +387 -0
  37. package/src/app/components/inspector/inspector-panel.tsx +1115 -0
  38. package/src/app/components/inspector/inspector-provider.tsx +1218 -0
  39. package/src/app/components/inspector/save-bar.tsx +48 -0
  40. package/src/app/components/language-toggle.tsx +39 -0
  41. package/src/app/components/notes-drawer.tsx +120 -0
  42. package/src/app/components/overview-grid.tsx +363 -0
  43. package/src/app/components/panel/panel-fields.tsx +60 -0
  44. package/src/app/components/panel/panel-shell.tsx +80 -0
  45. package/src/app/components/panel/save-card.tsx +142 -0
  46. package/src/app/components/pdf-progress-toast.tsx +32 -0
  47. package/src/app/components/player.tsx +466 -0
  48. package/src/app/components/pptx-progress-toast.tsx +32 -0
  49. package/src/app/components/present/blackout-overlay.tsx +18 -0
  50. package/src/app/components/present/control-bar.tsx +315 -0
  51. package/src/app/components/present/help-overlay.tsx +57 -0
  52. package/src/app/components/present/jump-input.tsx +74 -0
  53. package/src/app/components/present/laser-pointer.tsx +39 -0
  54. package/src/app/components/present/progress-bar.tsx +26 -0
  55. package/src/app/components/present/use-idle.ts +46 -0
  56. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  57. package/src/app/components/present/use-presenter-channel.ts +66 -0
  58. package/src/app/components/present/use-touch-swipe.ts +66 -0
  59. package/src/app/components/shared-element.tsx +48 -0
  60. package/src/app/components/sidebar/folder-item.tsx +258 -0
  61. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  62. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  63. package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
  64. package/src/app/components/sidebar/sidebar.tsx +284 -0
  65. package/src/app/components/slide-canvas.tsx +102 -0
  66. package/src/app/components/slide-transition-layer.tsx +844 -0
  67. package/src/app/components/style-panel/design-provider.tsx +148 -0
  68. package/src/app/components/style-panel/style-panel.tsx +349 -0
  69. package/src/app/components/style-panel/use-design.ts +112 -0
  70. package/src/app/components/theme-toggle.tsx +59 -0
  71. package/src/app/components/themes/theme-detail.tsx +305 -0
  72. package/src/app/components/themes/themes-gallery.tsx +149 -0
  73. package/src/app/components/thumbnail-rail.tsx +805 -0
  74. package/src/app/components/ui/badge.tsx +45 -0
  75. package/src/app/components/ui/button.tsx +99 -0
  76. package/src/app/components/ui/card.tsx +92 -0
  77. package/src/app/components/ui/context-menu.tsx +237 -0
  78. package/src/app/components/ui/dialog.tsx +157 -0
  79. package/src/app/components/ui/dropdown-menu.tsx +245 -0
  80. package/src/app/components/ui/input.tsx +25 -0
  81. package/src/app/components/ui/label.tsx +24 -0
  82. package/src/app/components/ui/popover.tsx +75 -0
  83. package/src/app/components/ui/progress.tsx +31 -0
  84. package/src/app/components/ui/scroll-area.tsx +53 -0
  85. package/src/app/components/ui/select.tsx +196 -0
  86. package/src/app/components/ui/separator.tsx +28 -0
  87. package/src/app/components/ui/slider.tsx +61 -0
  88. package/src/app/components/ui/sonner.tsx +48 -0
  89. package/src/app/components/ui/tabs.tsx +79 -0
  90. package/src/app/components/ui/textarea.tsx +22 -0
  91. package/src/app/components/ui/toggle-group.tsx +83 -0
  92. package/src/app/components/ui/toggle.tsx +45 -0
  93. package/src/app/components/ui/tooltip.tsx +58 -0
  94. package/src/app/favicon.ico +0 -0
  95. package/src/app/index.html +13 -0
  96. package/src/app/lib/assets.ts +242 -0
  97. package/src/app/lib/design-presets.ts +94 -0
  98. package/src/app/lib/design.ts +58 -0
  99. package/src/app/lib/export-html.ts +326 -0
  100. package/src/app/lib/export-pdf.ts +298 -0
  101. package/src/app/lib/export-pptx.ts +284 -0
  102. package/src/app/lib/folders.ts +239 -0
  103. package/src/app/lib/inspector/fiber.test.ts +154 -0
  104. package/src/app/lib/inspector/fiber.ts +85 -0
  105. package/src/app/lib/inspector/use-comments.ts +74 -0
  106. package/src/app/lib/inspector/use-editor.ts +73 -0
  107. package/src/app/lib/inspector/use-notes.ts +134 -0
  108. package/src/app/lib/locale-store.ts +67 -0
  109. package/src/app/lib/page-context.tsx +38 -0
  110. package/src/app/lib/print-ready.test.ts +32 -0
  111. package/src/app/lib/print-ready.ts +51 -0
  112. package/src/app/lib/sdk.test.ts +13 -0
  113. package/src/app/lib/sdk.ts +37 -0
  114. package/src/app/lib/slides.ts +26 -0
  115. package/src/app/lib/step-context.tsx +261 -0
  116. package/src/app/lib/themes.ts +22 -0
  117. package/src/app/lib/transition.ts +30 -0
  118. package/src/app/lib/use-agent-socket.ts +18 -0
  119. package/src/app/lib/use-click-page-navigation.ts +60 -0
  120. package/src/app/lib/use-is-mobile.ts +21 -0
  121. package/src/app/lib/use-locale.ts +8 -0
  122. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  123. package/src/app/lib/use-slide-module.ts +48 -0
  124. package/src/app/lib/use-wheel-page-navigation.ts +99 -0
  125. package/src/app/lib/utils.test.ts +25 -0
  126. package/src/app/lib/utils.ts +6 -0
  127. package/src/app/main.tsx +14 -0
  128. package/src/app/routes/assets.tsx +9 -0
  129. package/src/app/routes/home-shell.tsx +213 -0
  130. package/src/app/routes/home.tsx +807 -0
  131. package/src/app/routes/presenter.tsx +418 -0
  132. package/src/app/routes/slide.tsx +1108 -0
  133. package/src/app/routes/themes.tsx +34 -0
  134. package/src/app/styles.css +429 -0
  135. package/src/app/virtual.d.ts +51 -0
  136. package/src/locale/en.ts +416 -0
  137. package/src/locale/format.ts +12 -0
  138. package/src/locale/index.ts +6 -0
  139. package/src/locale/ja.ts +422 -0
  140. package/src/locale/types.ts +443 -0
  141. package/src/locale/zh-cn.ts +414 -0
  142. package/src/locale/zh-tw.ts +414 -0
@@ -0,0 +1,966 @@
1
+ import {
2
+ ArrowDownToLine,
3
+ CloudOff,
4
+ File as FileIcon,
5
+ FileImage,
6
+ ImageIcon,
7
+ Loader2,
8
+ MoreVertical,
9
+ Pencil,
10
+ RotateCw,
11
+ Search,
12
+ SearchX,
13
+ Trash2,
14
+ Upload,
15
+ } from 'lucide-react';
16
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
17
+ import { toast } from 'sonner';
18
+ import { Button, buttonVariants } from '@/components/ui/button';
19
+ import {
20
+ Dialog,
21
+ DialogContent,
22
+ DialogDescription,
23
+ DialogFooter,
24
+ DialogHeader,
25
+ DialogTitle,
26
+ } from '@/components/ui/dialog';
27
+ import {
28
+ DropdownMenu,
29
+ DropdownMenuContent,
30
+ DropdownMenuItem,
31
+ DropdownMenuTrigger,
32
+ } from '@/components/ui/dropdown-menu';
33
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
34
+ import {
35
+ type AssetEntry,
36
+ type AssetUsage,
37
+ fetchSvgAsFile,
38
+ listAssetUsages,
39
+ renamedCopy,
40
+ revertAssetUsage,
41
+ type SvglItem,
42
+ searchSvgl,
43
+ useAssets,
44
+ } from '@/lib/assets';
45
+ import { format, useLocale } from '@/lib/use-locale';
46
+ import { cn } from '@/lib/utils';
47
+
48
+ type Props = { slideId: string | null };
49
+
50
+ type Scope = 'slide' | 'global';
51
+
52
+ const GLOBAL_SLIDE_ID = '@global';
53
+
54
+ type ConflictState = {
55
+ file: File;
56
+ resolve: (decision: 'replace' | 'rename' | 'cancel') => void;
57
+ };
58
+
59
+ export function AssetView({ slideId }: Props) {
60
+ const lockedToGlobal = slideId === null;
61
+ const [scope, setScope] = useState<Scope>(lockedToGlobal ? 'global' : 'slide');
62
+ const effectiveSlideId = scope === 'global' || slideId === null ? GLOBAL_SLIDE_ID : slideId;
63
+ const { assets, loading, available, upload, rename, remove } = useAssets(effectiveSlideId);
64
+ const [dragActive, setDragActive] = useState(false);
65
+ const [conflict, setConflict] = useState<ConflictState | null>(null);
66
+ const [preview, setPreview] = useState<AssetEntry | null>(null);
67
+ const [confirmDelete, setConfirmDelete] = useState<AssetEntry | null>(null);
68
+ const [confirmDeleteUsages, setConfirmDeleteUsages] = useState<AssetUsage[] | null>(null);
69
+ const [renaming, setRenaming] = useState<string | null>(null);
70
+ const [logoSearchOpen, setLogoSearchOpen] = useState(false);
71
+ const dragDepth = useRef(0);
72
+ const inputId = useId();
73
+ const t = useLocale();
74
+
75
+ const existingNames = new Set(assets.map((a) => a.name));
76
+
77
+ async function handleFile(file: File) {
78
+ if (!available) return;
79
+ if (existingNames.has(file.name)) {
80
+ const decision = await new Promise<'replace' | 'rename' | 'cancel'>((resolve) => {
81
+ setConflict({ file, resolve });
82
+ });
83
+ if (decision === 'cancel') return;
84
+ if (decision === 'replace') {
85
+ const res = await upload(file, { overwrite: true });
86
+ if (!res.ok) toast.error(format(t.asset.toastUploadFailed, { status: res.status }));
87
+ else toast.success(format(t.asset.toastReplaced, { name: file.name }));
88
+ return;
89
+ }
90
+ const next = renamedCopy(file, existingNames);
91
+ const res = await upload(next, { overwrite: false });
92
+ if (!res.ok) toast.error(format(t.asset.toastUploadFailed, { status: res.status }));
93
+ else toast.success(format(t.asset.toastUploadedAs, { name: next.name }));
94
+ return;
95
+ }
96
+ const res = await upload(file);
97
+ if (!res.ok) toast.error(format(t.asset.toastUploadFailed, { status: res.status }));
98
+ else toast.success(format(t.asset.toastUploaded, { name: file.name }));
99
+ }
100
+
101
+ async function handleFiles(files: FileList | File[]) {
102
+ const list = Array.from(files);
103
+ for (const f of list) {
104
+ // Sequential — keeps the conflict dialog UX coherent and avoids
105
+ // hammering the dev server's filesystem mutations in parallel.
106
+ await handleFile(f);
107
+ }
108
+ }
109
+
110
+ if (!available) {
111
+ return (
112
+ <div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">
113
+ {t.asset.devOnlyMessage}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ return (
119
+ <section
120
+ aria-label={t.asset.sectionAria}
121
+ className={cn('relative flex h-full flex-col bg-background')}
122
+ onDragEnter={(e) => {
123
+ if (!hasFiles(e)) return;
124
+ e.preventDefault();
125
+ dragDepth.current += 1;
126
+ setDragActive(true);
127
+ }}
128
+ onDragOver={(e) => {
129
+ if (!hasFiles(e)) return;
130
+ e.preventDefault();
131
+ e.dataTransfer.dropEffect = 'copy';
132
+ }}
133
+ onDragLeave={() => {
134
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
135
+ if (dragDepth.current === 0) setDragActive(false);
136
+ }}
137
+ onDrop={(e) => {
138
+ if (!hasFiles(e)) return;
139
+ e.preventDefault();
140
+ dragDepth.current = 0;
141
+ setDragActive(false);
142
+ if (e.dataTransfer.files.length > 0) {
143
+ handleFiles(e.dataTransfer.files).catch(() => {});
144
+ }
145
+ }}
146
+ >
147
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline bg-sidebar px-6 py-3">
148
+ <div className="flex min-w-0 items-center gap-3">
149
+ {lockedToGlobal ? (
150
+ <span className="eyebrow">{t.asset.eyebrow}</span>
151
+ ) : (
152
+ <Tabs value={scope} onValueChange={(next) => setScope(next as Scope)}>
153
+ <TabsList>
154
+ <TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
155
+ <TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
156
+ </TabsList>
157
+ </Tabs>
158
+ )}
159
+ <p className="min-w-0 truncate text-[12px] text-muted-foreground">
160
+ <span className="font-mono text-[11.5px]">
161
+ {scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`}
162
+ </span>
163
+ {!loading && (
164
+ <>
165
+ <span className="mx-2 opacity-50">·</span>
166
+ <span className="folio">
167
+ {format(assets.length === 1 ? t.asset.fileCount.one : t.asset.fileCount.other, {
168
+ count: assets.length.toString().padStart(2, '0'),
169
+ })}
170
+ </span>
171
+ </>
172
+ )}
173
+ </p>
174
+ </div>
175
+ <div className="flex shrink-0 items-center gap-1.5">
176
+ <button
177
+ type="button"
178
+ onClick={() => setLogoSearchOpen(true)}
179
+ className={cn(
180
+ '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',
181
+ 'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
182
+ )}
183
+ >
184
+ <Search className="size-3.5" />
185
+ <span>{t.asset.searchLogos}</span>
186
+ </button>
187
+ <label
188
+ htmlFor={inputId}
189
+ className={cn(
190
+ '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',
191
+ 'shadow-[inset_0_1px_0_oklch(1_0_0/0.12),0_1px_0_oklch(0_0_0/0.12)]',
192
+ 'hover:bg-foreground/90 active:translate-y-px',
193
+ )}
194
+ >
195
+ <Upload className="size-3.5" />
196
+ <span>{t.asset.upload}</span>
197
+ </label>
198
+ <input
199
+ id={inputId}
200
+ type="file"
201
+ multiple
202
+ className="sr-only"
203
+ onChange={(e) => {
204
+ if (e.target.files && e.target.files.length > 0) {
205
+ handleFiles(e.target.files).catch(() => {});
206
+ e.target.value = '';
207
+ }
208
+ }}
209
+ />
210
+ </div>
211
+ </div>
212
+
213
+ <div className="min-h-0 flex-1 overflow-y-auto">
214
+ {loading ? (
215
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
216
+ {t.asset.loading}
217
+ </div>
218
+ ) : assets.length === 0 ? (
219
+ <EmptyState />
220
+ ) : (
221
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4 p-6">
222
+ {assets.map((asset) =>
223
+ renaming === asset.name ? (
224
+ <RenameCard
225
+ key={asset.name}
226
+ asset={asset}
227
+ onCancel={() => setRenaming(null)}
228
+ onSubmit={async (next) => {
229
+ if (next === asset.name) {
230
+ setRenaming(null);
231
+ return;
232
+ }
233
+ if (existingNames.has(next)) {
234
+ toast.error(t.asset.nameAlreadyExists);
235
+ return;
236
+ }
237
+ const res = await rename(asset.name, next);
238
+ if (!res.ok) {
239
+ toast.error(format(t.asset.toastRenameFailed, { status: res.status }));
240
+ return;
241
+ }
242
+ toast.success(format(t.asset.toastRenamed, { name: next }));
243
+ setRenaming(null);
244
+ }}
245
+ />
246
+ ) : (
247
+ <AssetCard
248
+ key={asset.name}
249
+ asset={asset}
250
+ onPreview={() => setPreview(asset)}
251
+ onRename={() => setRenaming(asset.name)}
252
+ onDelete={() => {
253
+ setConfirmDelete(asset);
254
+ setConfirmDeleteUsages(null);
255
+ listAssetUsages(effectiveSlideId, asset.name)
256
+ .then((u) => setConfirmDeleteUsages(u))
257
+ .catch(() => setConfirmDeleteUsages([]));
258
+ }}
259
+ />
260
+ ),
261
+ )}
262
+ </div>
263
+ )}
264
+ </div>
265
+
266
+ {dragActive && (
267
+ <div
268
+ className="pointer-events-none absolute inset-0 z-30 animate-in fade-in-0 duration-200"
269
+ aria-hidden="true"
270
+ >
271
+ <div className="absolute inset-0 bg-brand/5" />
272
+ <div className="absolute inset-2 rounded-[10px] border border-dashed border-brand/40" />
273
+ <div className="absolute inset-x-0 bottom-8 flex justify-center">
274
+ <div className="flex animate-in items-center gap-2 rounded-[6px] border border-border bg-card px-3 py-1.5 text-[12px] font-medium shadow-floating fade-in-0 slide-in-from-bottom-1 duration-300">
275
+ <ArrowDownToLine className="size-3.5 text-brand" />
276
+ <span>{t.asset.dropToUpload}</span>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ )}
281
+
282
+ {conflict && (
283
+ <ConflictDialog
284
+ file={conflict.file}
285
+ onChoose={(decision) => {
286
+ conflict.resolve(decision);
287
+ setConflict(null);
288
+ }}
289
+ />
290
+ )}
291
+
292
+ {confirmDelete && (
293
+ <DeleteDialog
294
+ asset={confirmDelete}
295
+ usages={confirmDeleteUsages}
296
+ onCancel={() => {
297
+ setConfirmDelete(null);
298
+ setConfirmDeleteUsages(null);
299
+ }}
300
+ onConfirm={async () => {
301
+ const target = confirmDelete;
302
+ const usages = confirmDeleteUsages ?? [];
303
+ setConfirmDelete(null);
304
+ setConfirmDeleteUsages(null);
305
+ const assetPath =
306
+ scope === 'global' ? `@assets/${target.name}` : `./assets/${target.name}`;
307
+ for (const u of usages) {
308
+ const rev = await revertAssetUsage(u.slideId, assetPath);
309
+ if (!rev.ok) {
310
+ toast.error(format(t.asset.toastRevertFailed, { slideId: u.slideId }));
311
+ return;
312
+ }
313
+ }
314
+ const res = await remove(target.name);
315
+ if (!res.ok) {
316
+ toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
317
+ return;
318
+ }
319
+ const totalUsages = usages.reduce((acc, u) => acc + u.count, 0);
320
+ if (totalUsages > 0) {
321
+ toast.success(
322
+ format(t.asset.toastDeletedWithRevert, {
323
+ name: target.name,
324
+ count: totalUsages,
325
+ }),
326
+ );
327
+ } else {
328
+ toast.success(format(t.asset.toastDeleted, { name: target.name }));
329
+ }
330
+ }}
331
+ />
332
+ )}
333
+
334
+ {preview && <PreviewDialog asset={preview} scope={scope} onClose={() => setPreview(null)} />}
335
+
336
+ {logoSearchOpen && (
337
+ <LogoSearchDialog
338
+ onClose={() => setLogoSearchOpen(false)}
339
+ onPick={(file) => handleFile(file)}
340
+ />
341
+ )}
342
+ </section>
343
+ );
344
+ }
345
+
346
+ function EmptyState() {
347
+ const t = useLocale();
348
+ return (
349
+ <div className="flex h-full flex-col items-center justify-center gap-4 px-6 py-16 text-center">
350
+ <div className="flex size-12 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground">
351
+ <ImageIcon className="size-5" />
352
+ </div>
353
+ <div>
354
+ <p className="font-heading text-[14px] font-semibold tracking-tight">
355
+ {t.asset.noAssetsYet}
356
+ </p>
357
+ <p className="mt-1 max-w-xs text-[12.5px] leading-relaxed text-muted-foreground">
358
+ {t.asset.noAssetsHintPrefix}
359
+ <span className="font-mono text-foreground">{t.asset.upload}</span>
360
+ {t.asset.noAssetsHintSuffix}
361
+ </p>
362
+ </div>
363
+ </div>
364
+ );
365
+ }
366
+
367
+ function hasFiles(e: React.DragEvent): boolean {
368
+ const types = e.dataTransfer?.types;
369
+ if (!types) return false;
370
+ for (let i = 0; i < types.length; i++) {
371
+ if (types[i] === 'Files') return true;
372
+ }
373
+ return false;
374
+ }
375
+
376
+ function AssetCard({
377
+ asset,
378
+ onPreview,
379
+ onRename,
380
+ onDelete,
381
+ }: {
382
+ asset: AssetEntry;
383
+ onPreview: () => void;
384
+ onRename: () => void;
385
+ onDelete: () => void;
386
+ }) {
387
+ const isImage = asset.mime.startsWith('image/');
388
+ const t = useLocale();
389
+ return (
390
+ <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">
391
+ <button
392
+ type="button"
393
+ onClick={onPreview}
394
+ aria-label={format(t.asset.previewAria, { name: asset.name })}
395
+ 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]"
396
+ >
397
+ {isImage ? (
398
+ <img
399
+ src={asset.url}
400
+ alt=""
401
+ className="size-full object-contain"
402
+ draggable={false}
403
+ onError={(e) => {
404
+ e.currentTarget.style.display = 'none';
405
+ }}
406
+ />
407
+ ) : (
408
+ <FileIcon className="size-9 text-muted-foreground" />
409
+ )}
410
+ </button>
411
+
412
+ <div className="flex items-center gap-1 px-2.5 py-2">
413
+ <div className="min-w-0 flex-1">
414
+ <div className="truncate text-[12.5px] font-medium" title={asset.name}>
415
+ {asset.name}
416
+ </div>
417
+ <div className="folio flex items-center gap-1.5">
418
+ <span className="truncate">{formatSize(asset.size)}</span>
419
+ {asset.unused ? (
420
+ <span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[10px] font-medium text-muted-foreground leading-none">
421
+ {t.asset.usageUnused}
422
+ </span>
423
+ ) : null}
424
+ </div>
425
+ </div>
426
+ <DropdownMenu>
427
+ <DropdownMenuTrigger
428
+ type="button"
429
+ aria-label={format(t.asset.actionsAria, { name: asset.name })}
430
+ className={cn(
431
+ buttonVariants({ variant: 'ghost', size: 'icon-xs' }),
432
+ 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 aria-expanded:opacity-100',
433
+ )}
434
+ >
435
+ <MoreVertical />
436
+ </DropdownMenuTrigger>
437
+ <DropdownMenuContent align="end" className="min-w-[160px]">
438
+ <DropdownMenuItem onSelect={onPreview}>
439
+ <ImageIcon />
440
+ {t.asset.previewMenuItem}
441
+ </DropdownMenuItem>
442
+ <DropdownMenuItem onSelect={onRename}>
443
+ <Pencil />
444
+ {t.asset.renameMenuItem}
445
+ </DropdownMenuItem>
446
+ <DropdownMenuItem onSelect={onDelete}>
447
+ <Trash2 />
448
+ {t.asset.deleteMenuItem}
449
+ </DropdownMenuItem>
450
+ </DropdownMenuContent>
451
+ </DropdownMenu>
452
+ </div>
453
+ </div>
454
+ );
455
+ }
456
+
457
+ function RenameCard({
458
+ asset,
459
+ onCancel,
460
+ onSubmit,
461
+ }: {
462
+ asset: AssetEntry;
463
+ onCancel: () => void;
464
+ onSubmit: (next: string) => Promise<void> | void;
465
+ }) {
466
+ const [value, setValue] = useState(asset.name);
467
+ const [saving, setSaving] = useState(false);
468
+ const inputRef = useRef<HTMLInputElement | null>(null);
469
+
470
+ useEffect(() => {
471
+ queueMicrotask(() => {
472
+ inputRef.current?.focus();
473
+ const dot = asset.name.lastIndexOf('.');
474
+ if (dot > 0) inputRef.current?.setSelectionRange(0, dot);
475
+ else inputRef.current?.select();
476
+ });
477
+ }, [asset.name]);
478
+
479
+ const commit = async () => {
480
+ const trimmed = value.trim();
481
+ if (!trimmed) {
482
+ onCancel();
483
+ return;
484
+ }
485
+ setSaving(true);
486
+ try {
487
+ await onSubmit(trimmed);
488
+ } finally {
489
+ setSaving(false);
490
+ }
491
+ };
492
+
493
+ const isImage = asset.mime.startsWith('image/');
494
+ return (
495
+ <div className="relative flex flex-col overflow-hidden rounded-xl border-2 border-primary bg-card shadow-sm">
496
+ <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]">
497
+ {isImage ? (
498
+ <img src={asset.url} alt="" className="size-full object-contain" draggable={false} />
499
+ ) : (
500
+ <FileIcon className="size-10 text-muted-foreground" />
501
+ )}
502
+ </div>
503
+ <div className="border-t bg-card px-2 py-2">
504
+ <input
505
+ ref={inputRef}
506
+ value={value}
507
+ disabled={saving}
508
+ onChange={(e) => setValue(e.target.value)}
509
+ onBlur={() => {
510
+ if (!saving) commit();
511
+ }}
512
+ onKeyDown={(e) => {
513
+ if (e.nativeEvent.isComposing) return;
514
+ if (e.key === 'Enter') {
515
+ e.preventDefault();
516
+ commit();
517
+ } else if (e.key === 'Escape') {
518
+ e.preventDefault();
519
+ onCancel();
520
+ }
521
+ }}
522
+ maxLength={120}
523
+ className="w-full rounded-md border bg-background px-2 py-1 text-sm outline-none ring-ring/40 focus:ring-2"
524
+ />
525
+ </div>
526
+ </div>
527
+ );
528
+ }
529
+
530
+ function ConflictDialog({
531
+ file,
532
+ onChoose,
533
+ }: {
534
+ file: File;
535
+ onChoose: (decision: 'replace' | 'rename' | 'cancel') => void;
536
+ }) {
537
+ const t = useLocale();
538
+ const [descPrefix, descSuffix] = t.asset.conflictDescription.split('{name}');
539
+ return (
540
+ <Dialog open onOpenChange={(open) => !open && onChoose('cancel')}>
541
+ <DialogContent>
542
+ <DialogHeader>
543
+ <DialogTitle>{t.asset.conflictTitle}</DialogTitle>
544
+ <DialogDescription>
545
+ {descPrefix}
546
+ <span className="font-mono">{file.name}</span>
547
+ {descSuffix}
548
+ </DialogDescription>
549
+ </DialogHeader>
550
+ <DialogFooter>
551
+ <Button variant="outline" onClick={() => onChoose('cancel')}>
552
+ {t.common.cancel}
553
+ </Button>
554
+ <Button variant="outline" onClick={() => onChoose('rename')}>
555
+ {t.asset.conflictRenameCopy}
556
+ </Button>
557
+ <Button onClick={() => onChoose('replace')}>{t.asset.conflictReplace}</Button>
558
+ </DialogFooter>
559
+ </DialogContent>
560
+ </Dialog>
561
+ );
562
+ }
563
+
564
+ function DeleteDialog({
565
+ asset,
566
+ usages,
567
+ onCancel,
568
+ onConfirm,
569
+ }: {
570
+ asset: AssetEntry;
571
+ usages: AssetUsage[] | null;
572
+ onCancel: () => void;
573
+ onConfirm: () => void;
574
+ }) {
575
+ const t = useLocale();
576
+ const inUse = (usages?.length ?? 0) > 0;
577
+ const totalUses = usages?.reduce((acc, u) => acc + u.count, 0) ?? 0;
578
+ const slideCount = usages?.length ?? 0;
579
+ const [descPrefix, descSuffix] = t.asset.deleteAssetDescription.split('{name}');
580
+ return (
581
+ <Dialog open onOpenChange={(open) => !open && onCancel()}>
582
+ <DialogContent>
583
+ <DialogHeader>
584
+ <DialogTitle>{t.asset.deleteAssetTitle}</DialogTitle>
585
+ <DialogDescription>
586
+ {inUse ? (
587
+ <>
588
+ {format(t.asset.deleteAssetInUseDescription, {
589
+ name: asset.name,
590
+ count: totalUses,
591
+ slides: slideCount,
592
+ })}{' '}
593
+ {t.asset.deleteAssetInUseHint}
594
+ </>
595
+ ) : (
596
+ <>
597
+ {descPrefix}
598
+ <span className="font-mono">{asset.name}</span>
599
+ {descSuffix}
600
+ </>
601
+ )}
602
+ </DialogDescription>
603
+ </DialogHeader>
604
+ {inUse && usages && (
605
+ <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">
606
+ {usages.map((u) => (
607
+ <li key={u.slideId} className="flex items-center justify-between gap-3">
608
+ <span className="truncate">{u.slideId}</span>
609
+ <span className="text-muted-foreground">×{u.count}</span>
610
+ </li>
611
+ ))}
612
+ </ul>
613
+ )}
614
+ <DialogFooter>
615
+ <Button variant="outline" onClick={onCancel}>
616
+ {t.common.cancel}
617
+ </Button>
618
+ <Button variant="destructive" onClick={onConfirm} disabled={usages === null}>
619
+ {inUse ? t.asset.deleteAndRevert : t.common.delete}
620
+ </Button>
621
+ </DialogFooter>
622
+ </DialogContent>
623
+ </Dialog>
624
+ );
625
+ }
626
+
627
+ function NoResultsMessage({ query, t }: { query: string; t: ReturnType<typeof useLocale> }) {
628
+ const [prefix, suffix] = t.asset.logoSearchNoResults.split('{query}');
629
+ return (
630
+ <>
631
+ {prefix}
632
+ <span className="font-mono text-foreground">{query}</span>
633
+ {suffix}
634
+ </>
635
+ );
636
+ }
637
+
638
+ function PreviewDialog({
639
+ asset,
640
+ scope,
641
+ onClose,
642
+ }: {
643
+ asset: AssetEntry;
644
+ scope: Scope;
645
+ onClose: () => void;
646
+ }) {
647
+ const isImage = asset.mime.startsWith('image/');
648
+ const importPath = scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
649
+ const t = useLocale();
650
+ return (
651
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
652
+ <DialogContent className="sm:max-w-2xl">
653
+ <DialogHeader>
654
+ <DialogTitle className="font-mono text-base">{asset.name}</DialogTitle>
655
+ <DialogDescription>
656
+ {formatSize(asset.size)} · {asset.mime}
657
+ </DialogDescription>
658
+ </DialogHeader>
659
+ {isImage ? (
660
+ <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]">
661
+ <img
662
+ src={asset.url}
663
+ alt={asset.name}
664
+ className="max-h-[60vh] max-w-full object-contain"
665
+ />
666
+ </div>
667
+ ) : (
668
+ <div className="flex items-center justify-center rounded-md border bg-muted/40 py-12 text-muted-foreground">
669
+ <FileImage className="mr-2 size-5" />
670
+ <span className="text-sm">{t.asset.noPreview}</span>
671
+ </div>
672
+ )}
673
+ <div className="rounded-[5px] border border-hairline bg-muted/50 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
674
+ <span className="text-muted-foreground">{t.asset.importHintComment}</span>
675
+ <span className="text-brand">'{importPath}'</span>
676
+ <span className="text-muted-foreground">{t.asset.importHintSemi}</span>
677
+ </div>
678
+ </DialogContent>
679
+ </Dialog>
680
+ );
681
+ }
682
+
683
+ const SKELETON_SLOTS = ['s0', 's1', 's2', 's3', 's4', 's5'] as const;
684
+
685
+ function LogoSearchDialog({
686
+ onClose,
687
+ onPick,
688
+ }: {
689
+ onClose: () => void;
690
+ onPick: (file: File) => Promise<void> | void;
691
+ }) {
692
+ const [query, setQuery] = useState('');
693
+ const [results, setResults] = useState<SvglItem[] | null>(null);
694
+ const [loading, setLoading] = useState(true);
695
+ const [error, setError] = useState<string | null>(null);
696
+ const [pending, setPending] = useState<Set<number>>(() => new Set());
697
+ const [retryToken, setRetryToken] = useState(0);
698
+ const inputRef = useRef<HTMLInputElement | null>(null);
699
+ const t = useLocale();
700
+
701
+ useEffect(() => {
702
+ queueMicrotask(() => inputRef.current?.focus());
703
+ }, []);
704
+
705
+ // biome-ignore lint/correctness/useExhaustiveDependencies: retryToken is a bump-to-refetch trigger
706
+ useEffect(() => {
707
+ const ctrl = new AbortController();
708
+ const timer = setTimeout(() => {
709
+ setLoading(true);
710
+ setError(null);
711
+ searchSvgl(query, ctrl.signal)
712
+ .then((next) => {
713
+ setResults(next);
714
+ setLoading(false);
715
+ })
716
+ .catch((err: unknown) => {
717
+ if (ctrl.signal.aborted) return;
718
+ setError(err instanceof Error ? err.message : t.asset.toastSearchFailed);
719
+ setLoading(false);
720
+ });
721
+ }, 200);
722
+ return () => {
723
+ clearTimeout(timer);
724
+ ctrl.abort();
725
+ };
726
+ }, [query, retryToken]);
727
+
728
+ return (
729
+ <Dialog open onOpenChange={(open) => !open && onClose()}>
730
+ <DialogContent className="sm:max-w-2xl">
731
+ <DialogHeader>
732
+ <DialogTitle>{t.asset.logoSearchTitle}</DialogTitle>
733
+ <DialogDescription>
734
+ {t.asset.logoSearchPoweredByPrefix}
735
+ <a
736
+ href="https://svgl.app"
737
+ target="_blank"
738
+ rel="noopener noreferrer"
739
+ className="underline underline-offset-2"
740
+ >
741
+ svgl.app
742
+ </a>
743
+ .
744
+ </DialogDescription>
745
+ </DialogHeader>
746
+
747
+ <div className="relative">
748
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
749
+ <input
750
+ ref={inputRef}
751
+ value={query}
752
+ onChange={(e) => setQuery(e.target.value)}
753
+ placeholder={t.asset.logoSearchPlaceholder}
754
+ 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"
755
+ />
756
+ </div>
757
+
758
+ <div className="max-h-[60vh] min-h-[16rem] overflow-y-auto">
759
+ {error ? (
760
+ <div className="flex h-64 flex-col items-center justify-center gap-3 px-6 text-center">
761
+ <div className="flex size-12 items-center justify-center rounded-full bg-muted">
762
+ <CloudOff className="size-5 text-muted-foreground" />
763
+ </div>
764
+ <div>
765
+ <p className="text-sm font-medium">{t.asset.logoSearchErrorTitle}</p>
766
+ <p className="mt-1 text-xs text-muted-foreground">{t.asset.logoSearchErrorBody}</p>
767
+ </div>
768
+ <Button
769
+ variant="outline"
770
+ size="sm"
771
+ onClick={() => setRetryToken((n) => n + 1)}
772
+ className="gap-1.5"
773
+ >
774
+ <RotateCw className="size-3.5" />
775
+ {t.common.tryAgain}
776
+ </Button>
777
+ </div>
778
+ ) : loading && !results ? (
779
+ <div className="grid grid-cols-3 gap-3">
780
+ {SKELETON_SLOTS.map((slot) => (
781
+ <div
782
+ key={slot}
783
+ className="aspect-square animate-pulse rounded-lg border bg-muted/40"
784
+ />
785
+ ))}
786
+ </div>
787
+ ) : results && results.length === 0 ? (
788
+ <div className="flex h-64 flex-col items-center justify-center gap-3 px-6 text-center">
789
+ <div className="flex size-12 items-center justify-center rounded-full bg-muted">
790
+ <SearchX className="size-5 text-muted-foreground" />
791
+ </div>
792
+ <div>
793
+ <p className="text-sm font-medium">
794
+ {query.trim() ? (
795
+ <NoResultsMessage query={query.trim()} t={t} />
796
+ ) : (
797
+ t.asset.logoSearchEmpty
798
+ )}
799
+ </p>
800
+ <p className="mt-1 text-xs text-muted-foreground">
801
+ {t.asset.logoSearchEmptyHintPrefix}
802
+ <a
803
+ href="https://svgl.app"
804
+ target="_blank"
805
+ rel="noopener noreferrer"
806
+ className="underline underline-offset-2 hover:text-foreground"
807
+ >
808
+ svgl.app
809
+ </a>
810
+ {t.asset.logoSearchEmptyHintSuffix}
811
+ </p>
812
+ </div>
813
+ </div>
814
+ ) : (
815
+ <div className="grid grid-cols-3 gap-3">
816
+ {results?.map((item) => (
817
+ <LogoResultCard
818
+ key={item.id}
819
+ item={item}
820
+ pending={pending.has(item.id)}
821
+ onAdd={async (file) => {
822
+ setPending((prev) => {
823
+ const next = new Set(prev);
824
+ next.add(item.id);
825
+ return next;
826
+ });
827
+ try {
828
+ await onPick(file);
829
+ } catch (err) {
830
+ toast.error(err instanceof Error ? err.message : t.asset.toastDownloadFailed);
831
+ } finally {
832
+ setPending((prev) => {
833
+ const next = new Set(prev);
834
+ next.delete(item.id);
835
+ return next;
836
+ });
837
+ }
838
+ }}
839
+ />
840
+ ))}
841
+ </div>
842
+ )}
843
+ </div>
844
+
845
+ <DialogFooter>
846
+ <Button variant="outline" onClick={onClose}>
847
+ {t.common.done}
848
+ </Button>
849
+ </DialogFooter>
850
+ </DialogContent>
851
+ </Dialog>
852
+ );
853
+ }
854
+
855
+ function LogoResultCard({
856
+ item,
857
+ pending,
858
+ onAdd,
859
+ }: {
860
+ item: SvglItem;
861
+ pending: boolean;
862
+ onAdd: (file: File) => Promise<void> | void;
863
+ }) {
864
+ const hasVariants = typeof item.route === 'object' && item.route !== null;
865
+ const [variant, setVariant] = useState<'light' | 'dark'>('light');
866
+ const t = useLocale();
867
+
868
+ const previewUrl = useMemo(() => {
869
+ if (typeof item.route === 'string') return item.route;
870
+ return item.route[variant];
871
+ }, [item.route, variant]);
872
+
873
+ const filename = useMemo(() => {
874
+ const url = previewUrl;
875
+ const fromUrl = basenameFromUrl(url);
876
+ if (fromUrl) return fromUrl;
877
+ const slug = slugify(item.title);
878
+ return hasVariants ? `${slug}-${variant}.svg` : `${slug}.svg`;
879
+ }, [previewUrl, item.title, hasVariants, variant]);
880
+
881
+ const category = Array.isArray(item.category) ? item.category.join(', ') : item.category;
882
+
883
+ return (
884
+ <div className="group flex flex-col overflow-hidden rounded-lg border bg-card">
885
+ <div
886
+ className={cn(
887
+ '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]',
888
+ variant === 'dark' && hasVariants && 'bg-neutral-900',
889
+ )}
890
+ >
891
+ <img src={previewUrl} alt={item.title} className="size-3/4 object-contain" />
892
+ </div>
893
+ <div className="flex flex-col gap-1.5 border-t bg-card px-2.5 py-2">
894
+ <div className="min-w-0">
895
+ <div className="truncate text-xs font-medium" title={item.title}>
896
+ {item.title}
897
+ </div>
898
+ <div className="truncate text-[10px] text-muted-foreground">{category}</div>
899
+ </div>
900
+ <div className="flex items-center gap-1.5">
901
+ {hasVariants ? (
902
+ <div className="flex overflow-hidden rounded-md border text-[10px]">
903
+ <button
904
+ type="button"
905
+ onClick={() => setVariant('light')}
906
+ className={cn(
907
+ 'px-1.5 py-0.5 transition-colors',
908
+ variant === 'light' ? 'bg-foreground text-background' : 'hover:bg-muted',
909
+ )}
910
+ >
911
+ {t.asset.logoVariantLight}
912
+ </button>
913
+ <button
914
+ type="button"
915
+ onClick={() => setVariant('dark')}
916
+ className={cn(
917
+ 'border-l px-1.5 py-0.5 transition-colors',
918
+ variant === 'dark' ? 'bg-foreground text-background' : 'hover:bg-muted',
919
+ )}
920
+ >
921
+ {t.asset.logoVariantDark}
922
+ </button>
923
+ </div>
924
+ ) : null}
925
+ <Button
926
+ size="sm"
927
+ variant="outline"
928
+ disabled={pending}
929
+ onClick={async () => {
930
+ try {
931
+ const file = await fetchSvgAsFile(previewUrl, filename);
932
+ await onAdd(file);
933
+ } catch (err) {
934
+ toast.error(err instanceof Error ? err.message : t.asset.toastDownloadFailed);
935
+ }
936
+ }}
937
+ className="ml-auto h-6 px-2 text-[11px]"
938
+ >
939
+ {pending ? <Loader2 className="size-3 animate-spin" /> : t.common.add}
940
+ </Button>
941
+ </div>
942
+ </div>
943
+ </div>
944
+ );
945
+ }
946
+
947
+ function basenameFromUrl(u: string): string {
948
+ try {
949
+ return new URL(u).pathname.split('/').pop() || '';
950
+ } catch {
951
+ return '';
952
+ }
953
+ }
954
+
955
+ function slugify(s: string): string {
956
+ return s
957
+ .toLowerCase()
958
+ .replace(/[^a-z0-9]+/g, '-')
959
+ .replace(/^-|-$/g, '');
960
+ }
961
+
962
+ function formatSize(bytes: number): string {
963
+ if (bytes < 1024) return `${bytes} B`;
964
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
965
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
966
+ }