@open-slide/core 0.0.9 → 0.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-pqF4W1Yi.js → build-DHiRlpjn.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-CtwxMYv9.js → config-LZM903FE.js} +377 -0
- package/dist/{dev-CJX97uiy.js → dev-B3JzCYn7.js} +1 -1
- package/dist/{preview-IuLPcL5y.js → preview-UikovHEt.js} +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/src/app/components/AssetView.tsx +846 -0
- package/src/app/components/ClickNavZones.tsx +2 -2
- package/src/app/components/ThumbnailRail.tsx +2 -2
- package/src/app/components/inspector/InspectorPanel.tsx +143 -0
- package/src/app/components/inspector/InspectorProvider.tsx +33 -3
- package/src/app/lib/assets.ts +166 -0
- package/src/app/lib/export-pdf.ts +1 -4
- package/src/app/lib/inspector/useEditor.ts +2 -1
- package/src/app/routes/Slide.tsx +96 -51
|
@@ -0,0 +1,846 @@
|
|
|
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 {
|
|
34
|
+
type AssetEntry,
|
|
35
|
+
fetchSvgAsFile,
|
|
36
|
+
type SvglItem,
|
|
37
|
+
searchSvgl,
|
|
38
|
+
useAssets,
|
|
39
|
+
} from '@/lib/assets';
|
|
40
|
+
import { cn } from '@/lib/utils';
|
|
41
|
+
|
|
42
|
+
type Props = { slideId: string };
|
|
43
|
+
|
|
44
|
+
type ConflictState = {
|
|
45
|
+
file: File;
|
|
46
|
+
resolve: (decision: 'replace' | 'rename' | 'cancel') => void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function AssetView({ slideId }: Props) {
|
|
50
|
+
const { assets, loading, available, upload, rename, remove } = useAssets(slideId);
|
|
51
|
+
const [dragActive, setDragActive] = useState(false);
|
|
52
|
+
const [conflict, setConflict] = useState<ConflictState | null>(null);
|
|
53
|
+
const [preview, setPreview] = useState<AssetEntry | null>(null);
|
|
54
|
+
const [confirmDelete, setConfirmDelete] = useState<AssetEntry | null>(null);
|
|
55
|
+
const [renaming, setRenaming] = useState<string | null>(null);
|
|
56
|
+
const [logoSearchOpen, setLogoSearchOpen] = useState(false);
|
|
57
|
+
const dragDepth = useRef(0);
|
|
58
|
+
const inputId = useId();
|
|
59
|
+
|
|
60
|
+
const existingNames = new Set(assets.map((a) => a.name));
|
|
61
|
+
|
|
62
|
+
async function handleFile(file: File) {
|
|
63
|
+
if (!available) return;
|
|
64
|
+
if (existingNames.has(file.name)) {
|
|
65
|
+
const decision = await new Promise<'replace' | 'rename' | 'cancel'>((resolve) => {
|
|
66
|
+
setConflict({ file, resolve });
|
|
67
|
+
});
|
|
68
|
+
if (decision === 'cancel') return;
|
|
69
|
+
if (decision === 'replace') {
|
|
70
|
+
const res = await upload(file, { overwrite: true });
|
|
71
|
+
if (!res.ok) toast.error(`Upload failed (${res.status})`);
|
|
72
|
+
else toast.success(`Replaced ${file.name}`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const next = renamedCopy(file, existingNames);
|
|
76
|
+
const res = await upload(next, { overwrite: false });
|
|
77
|
+
if (!res.ok) toast.error(`Upload failed (${res.status})`);
|
|
78
|
+
else toast.success(`Uploaded as ${next.name}`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const res = await upload(file);
|
|
82
|
+
if (!res.ok) toast.error(`Upload failed (${res.status})`);
|
|
83
|
+
else toast.success(`Uploaded ${file.name}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function handleFiles(files: FileList | File[]) {
|
|
87
|
+
const list = Array.from(files);
|
|
88
|
+
for (const f of list) {
|
|
89
|
+
// Sequential — keeps the conflict dialog UX coherent and avoids
|
|
90
|
+
// hammering the dev server's filesystem mutations in parallel.
|
|
91
|
+
await handleFile(f);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!available) {
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">
|
|
98
|
+
Asset management is only available in dev mode.
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<section
|
|
105
|
+
aria-label="Slide assets"
|
|
106
|
+
className={cn('relative flex h-full flex-col bg-background')}
|
|
107
|
+
onDragEnter={(e) => {
|
|
108
|
+
if (!hasFiles(e)) return;
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
dragDepth.current += 1;
|
|
111
|
+
setDragActive(true);
|
|
112
|
+
}}
|
|
113
|
+
onDragOver={(e) => {
|
|
114
|
+
if (!hasFiles(e)) return;
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
117
|
+
}}
|
|
118
|
+
onDragLeave={() => {
|
|
119
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
120
|
+
if (dragDepth.current === 0) setDragActive(false);
|
|
121
|
+
}}
|
|
122
|
+
onDrop={(e) => {
|
|
123
|
+
if (!hasFiles(e)) return;
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
dragDepth.current = 0;
|
|
126
|
+
setDragActive(false);
|
|
127
|
+
if (e.dataTransfer.files.length > 0) {
|
|
128
|
+
handleFiles(e.dataTransfer.files).catch(() => {});
|
|
129
|
+
}
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<div className="flex shrink-0 items-center justify-between gap-3 border-b bg-card px-6 py-3">
|
|
133
|
+
<div className="min-w-0">
|
|
134
|
+
<h2 className="truncate text-base font-semibold tracking-tight">Assets</h2>
|
|
135
|
+
<p className="truncate text-xs text-muted-foreground">
|
|
136
|
+
<span className="font-mono">slides/{slideId}/assets/</span>
|
|
137
|
+
{!loading && (
|
|
138
|
+
<>
|
|
139
|
+
<span className="mx-1.5">·</span>
|
|
140
|
+
<span>{assets.length === 1 ? '1 file' : `${assets.length} files`}</span>
|
|
141
|
+
</>
|
|
142
|
+
)}
|
|
143
|
+
</p>
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() => setLogoSearchOpen(true)}
|
|
149
|
+
className={cn(
|
|
150
|
+
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border bg-background px-3 text-sm font-medium transition-colors',
|
|
151
|
+
'hover:bg-muted active:translate-y-px',
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
<Search className="size-4" />
|
|
155
|
+
<span>Search logos</span>
|
|
156
|
+
</button>
|
|
157
|
+
<label
|
|
158
|
+
htmlFor={inputId}
|
|
159
|
+
className={cn(
|
|
160
|
+
'inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-opacity',
|
|
161
|
+
'hover:opacity-90 active:translate-y-px',
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
<Upload className="size-4" />
|
|
165
|
+
<span>Upload</span>
|
|
166
|
+
</label>
|
|
167
|
+
<input
|
|
168
|
+
id={inputId}
|
|
169
|
+
type="file"
|
|
170
|
+
multiple
|
|
171
|
+
className="sr-only"
|
|
172
|
+
onChange={(e) => {
|
|
173
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
174
|
+
handleFiles(e.target.files).catch(() => {});
|
|
175
|
+
e.target.value = '';
|
|
176
|
+
}
|
|
177
|
+
}}
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
183
|
+
{loading ? (
|
|
184
|
+
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
185
|
+
Loading…
|
|
186
|
+
</div>
|
|
187
|
+
) : assets.length === 0 ? (
|
|
188
|
+
<EmptyState />
|
|
189
|
+
) : (
|
|
190
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-4 p-6">
|
|
191
|
+
{assets.map((asset) =>
|
|
192
|
+
renaming === asset.name ? (
|
|
193
|
+
<RenameCard
|
|
194
|
+
key={asset.name}
|
|
195
|
+
asset={asset}
|
|
196
|
+
onCancel={() => setRenaming(null)}
|
|
197
|
+
onSubmit={async (next) => {
|
|
198
|
+
if (next === asset.name) {
|
|
199
|
+
setRenaming(null);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (existingNames.has(next)) {
|
|
203
|
+
toast.error('A file with that name already exists.');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const res = await rename(asset.name, next);
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
toast.error(`Rename failed (${res.status})`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
toast.success(`Renamed to ${next}`);
|
|
212
|
+
setRenaming(null);
|
|
213
|
+
}}
|
|
214
|
+
/>
|
|
215
|
+
) : (
|
|
216
|
+
<AssetCard
|
|
217
|
+
key={asset.name}
|
|
218
|
+
asset={asset}
|
|
219
|
+
onPreview={() => setPreview(asset)}
|
|
220
|
+
onRename={() => setRenaming(asset.name)}
|
|
221
|
+
onDelete={() => setConfirmDelete(asset)}
|
|
222
|
+
/>
|
|
223
|
+
),
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{dragActive && (
|
|
230
|
+
<div
|
|
231
|
+
className="pointer-events-none absolute inset-0 z-30 animate-in fade-in-0 duration-200"
|
|
232
|
+
aria-hidden="true"
|
|
233
|
+
>
|
|
234
|
+
<div className="absolute inset-0 bg-foreground/[0.03]" />
|
|
235
|
+
<div className="absolute inset-2 rounded-xl border border-dashed border-foreground/25" />
|
|
236
|
+
<div className="absolute inset-x-0 bottom-8 flex justify-center">
|
|
237
|
+
<div className="flex animate-in items-center gap-2 rounded-full border bg-background px-3.5 py-1.5 text-xs font-medium shadow-sm fade-in-0 slide-in-from-bottom-1 duration-300">
|
|
238
|
+
<ArrowDownToLine className="size-3.5 text-muted-foreground" />
|
|
239
|
+
<span>Drop to upload</span>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
{conflict && (
|
|
246
|
+
<ConflictDialog
|
|
247
|
+
file={conflict.file}
|
|
248
|
+
onChoose={(decision) => {
|
|
249
|
+
conflict.resolve(decision);
|
|
250
|
+
setConflict(null);
|
|
251
|
+
}}
|
|
252
|
+
/>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{confirmDelete && (
|
|
256
|
+
<DeleteDialog
|
|
257
|
+
asset={confirmDelete}
|
|
258
|
+
onCancel={() => setConfirmDelete(null)}
|
|
259
|
+
onConfirm={async () => {
|
|
260
|
+
const target = confirmDelete;
|
|
261
|
+
setConfirmDelete(null);
|
|
262
|
+
const res = await remove(target.name);
|
|
263
|
+
if (!res.ok) toast.error(`Delete failed (${res.status})`);
|
|
264
|
+
else toast.success(`Deleted ${target.name}`);
|
|
265
|
+
}}
|
|
266
|
+
/>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{preview && <PreviewDialog asset={preview} onClose={() => setPreview(null)} />}
|
|
270
|
+
|
|
271
|
+
{logoSearchOpen && (
|
|
272
|
+
<LogoSearchDialog
|
|
273
|
+
onClose={() => setLogoSearchOpen(false)}
|
|
274
|
+
onPick={(file) => handleFile(file)}
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
</section>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function EmptyState() {
|
|
282
|
+
return (
|
|
283
|
+
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
|
284
|
+
<div className="flex size-14 items-center justify-center rounded-full bg-muted">
|
|
285
|
+
<ImageIcon className="size-6 text-muted-foreground" />
|
|
286
|
+
</div>
|
|
287
|
+
<div>
|
|
288
|
+
<p className="text-sm font-medium">No assets yet</p>
|
|
289
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
290
|
+
Drop files anywhere here or click Upload to add them.
|
|
291
|
+
</p>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function hasFiles(e: React.DragEvent): boolean {
|
|
298
|
+
const types = e.dataTransfer?.types;
|
|
299
|
+
if (!types) return false;
|
|
300
|
+
for (let i = 0; i < types.length; i++) {
|
|
301
|
+
if (types[i] === 'Files') return true;
|
|
302
|
+
}
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renamedCopy(file: File, taken: Set<string>): File {
|
|
307
|
+
const dot = file.name.lastIndexOf('.');
|
|
308
|
+
const stem = dot > 0 ? file.name.slice(0, dot) : file.name;
|
|
309
|
+
const ext = dot > 0 ? file.name.slice(dot) : '';
|
|
310
|
+
let i = 1;
|
|
311
|
+
let next = `${stem}-${i}${ext}`;
|
|
312
|
+
while (taken.has(next)) {
|
|
313
|
+
i += 1;
|
|
314
|
+
next = `${stem}-${i}${ext}`;
|
|
315
|
+
}
|
|
316
|
+
return new File([file], next, { type: file.type, lastModified: file.lastModified });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function AssetCard({
|
|
320
|
+
asset,
|
|
321
|
+
onPreview,
|
|
322
|
+
onRename,
|
|
323
|
+
onDelete,
|
|
324
|
+
}: {
|
|
325
|
+
asset: AssetEntry;
|
|
326
|
+
onPreview: () => void;
|
|
327
|
+
onRename: () => void;
|
|
328
|
+
onDelete: () => void;
|
|
329
|
+
}) {
|
|
330
|
+
const isImage = asset.mime.startsWith('image/');
|
|
331
|
+
return (
|
|
332
|
+
<div className="group relative flex flex-col overflow-hidden rounded-xl border bg-card shadow-sm transition-shadow hover:shadow-md focus-within:ring-2 focus-within:ring-ring/50">
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
onClick={onPreview}
|
|
336
|
+
aria-label={`Preview ${asset.name}`}
|
|
337
|
+
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]"
|
|
338
|
+
>
|
|
339
|
+
{isImage ? (
|
|
340
|
+
<img
|
|
341
|
+
src={asset.url}
|
|
342
|
+
alt=""
|
|
343
|
+
className="size-full object-contain"
|
|
344
|
+
draggable={false}
|
|
345
|
+
onError={(e) => {
|
|
346
|
+
e.currentTarget.style.display = 'none';
|
|
347
|
+
}}
|
|
348
|
+
/>
|
|
349
|
+
) : (
|
|
350
|
+
<FileIcon className="size-10 text-muted-foreground" />
|
|
351
|
+
)}
|
|
352
|
+
</button>
|
|
353
|
+
|
|
354
|
+
<div className="flex items-center gap-1 border-t bg-card px-3 py-2">
|
|
355
|
+
<div className="min-w-0 flex-1">
|
|
356
|
+
<div className="truncate text-sm font-medium" title={asset.name}>
|
|
357
|
+
{asset.name}
|
|
358
|
+
</div>
|
|
359
|
+
<div className="truncate text-[11px] text-muted-foreground">{formatSize(asset.size)}</div>
|
|
360
|
+
</div>
|
|
361
|
+
<DropdownMenu>
|
|
362
|
+
<DropdownMenuTrigger
|
|
363
|
+
type="button"
|
|
364
|
+
aria-label={`Actions for ${asset.name}`}
|
|
365
|
+
className={cn(
|
|
366
|
+
buttonVariants({ variant: 'ghost', size: 'icon-xs' }),
|
|
367
|
+
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 aria-expanded:opacity-100',
|
|
368
|
+
)}
|
|
369
|
+
>
|
|
370
|
+
<MoreVertical />
|
|
371
|
+
</DropdownMenuTrigger>
|
|
372
|
+
<DropdownMenuContent align="end" className="min-w-[160px]">
|
|
373
|
+
<DropdownMenuItem onSelect={onPreview}>
|
|
374
|
+
<ImageIcon />
|
|
375
|
+
Preview
|
|
376
|
+
</DropdownMenuItem>
|
|
377
|
+
<DropdownMenuItem onSelect={onRename}>
|
|
378
|
+
<Pencil />
|
|
379
|
+
Rename
|
|
380
|
+
</DropdownMenuItem>
|
|
381
|
+
<DropdownMenuItem onSelect={onDelete}>
|
|
382
|
+
<Trash2 />
|
|
383
|
+
Delete
|
|
384
|
+
</DropdownMenuItem>
|
|
385
|
+
</DropdownMenuContent>
|
|
386
|
+
</DropdownMenu>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function RenameCard({
|
|
393
|
+
asset,
|
|
394
|
+
onCancel,
|
|
395
|
+
onSubmit,
|
|
396
|
+
}: {
|
|
397
|
+
asset: AssetEntry;
|
|
398
|
+
onCancel: () => void;
|
|
399
|
+
onSubmit: (next: string) => Promise<void> | void;
|
|
400
|
+
}) {
|
|
401
|
+
const [value, setValue] = useState(asset.name);
|
|
402
|
+
const [saving, setSaving] = useState(false);
|
|
403
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
404
|
+
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
queueMicrotask(() => {
|
|
407
|
+
inputRef.current?.focus();
|
|
408
|
+
const dot = asset.name.lastIndexOf('.');
|
|
409
|
+
if (dot > 0) inputRef.current?.setSelectionRange(0, dot);
|
|
410
|
+
else inputRef.current?.select();
|
|
411
|
+
});
|
|
412
|
+
}, [asset.name]);
|
|
413
|
+
|
|
414
|
+
const commit = async () => {
|
|
415
|
+
const trimmed = value.trim();
|
|
416
|
+
if (!trimmed) {
|
|
417
|
+
onCancel();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
setSaving(true);
|
|
421
|
+
try {
|
|
422
|
+
await onSubmit(trimmed);
|
|
423
|
+
} finally {
|
|
424
|
+
setSaving(false);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const isImage = asset.mime.startsWith('image/');
|
|
429
|
+
return (
|
|
430
|
+
<div className="relative flex flex-col overflow-hidden rounded-xl border-2 border-primary bg-card shadow-sm">
|
|
431
|
+
<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]">
|
|
432
|
+
{isImage ? (
|
|
433
|
+
<img src={asset.url} alt="" className="size-full object-contain" draggable={false} />
|
|
434
|
+
) : (
|
|
435
|
+
<FileIcon className="size-10 text-muted-foreground" />
|
|
436
|
+
)}
|
|
437
|
+
</div>
|
|
438
|
+
<div className="border-t bg-card px-2 py-2">
|
|
439
|
+
<input
|
|
440
|
+
ref={inputRef}
|
|
441
|
+
value={value}
|
|
442
|
+
disabled={saving}
|
|
443
|
+
onChange={(e) => setValue(e.target.value)}
|
|
444
|
+
onBlur={() => {
|
|
445
|
+
if (!saving) commit();
|
|
446
|
+
}}
|
|
447
|
+
onKeyDown={(e) => {
|
|
448
|
+
if (e.key === 'Enter') {
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
commit();
|
|
451
|
+
} else if (e.key === 'Escape') {
|
|
452
|
+
e.preventDefault();
|
|
453
|
+
onCancel();
|
|
454
|
+
}
|
|
455
|
+
}}
|
|
456
|
+
maxLength={120}
|
|
457
|
+
className="w-full rounded-md border bg-background px-2 py-1 text-sm outline-none ring-ring/40 focus:ring-2"
|
|
458
|
+
/>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function ConflictDialog({
|
|
465
|
+
file,
|
|
466
|
+
onChoose,
|
|
467
|
+
}: {
|
|
468
|
+
file: File;
|
|
469
|
+
onChoose: (decision: 'replace' | 'rename' | 'cancel') => void;
|
|
470
|
+
}) {
|
|
471
|
+
return (
|
|
472
|
+
<Dialog open onOpenChange={(open) => !open && onChoose('cancel')}>
|
|
473
|
+
<DialogContent>
|
|
474
|
+
<DialogHeader>
|
|
475
|
+
<DialogTitle>File already exists</DialogTitle>
|
|
476
|
+
<DialogDescription>
|
|
477
|
+
<span className="font-mono">{file.name}</span> is already in this slide's assets folder.
|
|
478
|
+
</DialogDescription>
|
|
479
|
+
</DialogHeader>
|
|
480
|
+
<DialogFooter>
|
|
481
|
+
<Button variant="outline" onClick={() => onChoose('cancel')}>
|
|
482
|
+
Cancel
|
|
483
|
+
</Button>
|
|
484
|
+
<Button variant="outline" onClick={() => onChoose('rename')}>
|
|
485
|
+
Rename copy
|
|
486
|
+
</Button>
|
|
487
|
+
<Button onClick={() => onChoose('replace')}>Replace</Button>
|
|
488
|
+
</DialogFooter>
|
|
489
|
+
</DialogContent>
|
|
490
|
+
</Dialog>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function DeleteDialog({
|
|
495
|
+
asset,
|
|
496
|
+
onCancel,
|
|
497
|
+
onConfirm,
|
|
498
|
+
}: {
|
|
499
|
+
asset: AssetEntry;
|
|
500
|
+
onCancel: () => void;
|
|
501
|
+
onConfirm: () => void;
|
|
502
|
+
}) {
|
|
503
|
+
return (
|
|
504
|
+
<Dialog open onOpenChange={(open) => !open && onCancel()}>
|
|
505
|
+
<DialogContent>
|
|
506
|
+
<DialogHeader>
|
|
507
|
+
<DialogTitle>Delete asset</DialogTitle>
|
|
508
|
+
<DialogDescription>
|
|
509
|
+
Delete <span className="font-mono">{asset.name}</span>? Imports referencing this file in
|
|
510
|
+
the slide will break.
|
|
511
|
+
</DialogDescription>
|
|
512
|
+
</DialogHeader>
|
|
513
|
+
<DialogFooter>
|
|
514
|
+
<Button variant="outline" onClick={onCancel}>
|
|
515
|
+
Cancel
|
|
516
|
+
</Button>
|
|
517
|
+
<Button variant="destructive" onClick={onConfirm}>
|
|
518
|
+
Delete
|
|
519
|
+
</Button>
|
|
520
|
+
</DialogFooter>
|
|
521
|
+
</DialogContent>
|
|
522
|
+
</Dialog>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function PreviewDialog({ asset, onClose }: { asset: AssetEntry; onClose: () => void }) {
|
|
527
|
+
const isImage = asset.mime.startsWith('image/');
|
|
528
|
+
const importPath = `./assets/${asset.name}`;
|
|
529
|
+
return (
|
|
530
|
+
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
531
|
+
<DialogContent className="sm:max-w-2xl">
|
|
532
|
+
<DialogHeader>
|
|
533
|
+
<DialogTitle className="font-mono text-base">{asset.name}</DialogTitle>
|
|
534
|
+
<DialogDescription>
|
|
535
|
+
{formatSize(asset.size)} · {asset.mime}
|
|
536
|
+
</DialogDescription>
|
|
537
|
+
</DialogHeader>
|
|
538
|
+
{isImage ? (
|
|
539
|
+
<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]">
|
|
540
|
+
<img
|
|
541
|
+
src={asset.url}
|
|
542
|
+
alt={asset.name}
|
|
543
|
+
className="max-h-[60vh] max-w-full object-contain"
|
|
544
|
+
/>
|
|
545
|
+
</div>
|
|
546
|
+
) : (
|
|
547
|
+
<div className="flex items-center justify-center rounded-md border bg-muted/40 py-12 text-muted-foreground">
|
|
548
|
+
<FileImage className="mr-2 size-5" />
|
|
549
|
+
<span className="text-sm">No preview available</span>
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
<div className="rounded-md border bg-muted/40 p-2 font-mono text-xs">
|
|
553
|
+
import asset from '<span className="font-semibold">{importPath}</span>';
|
|
554
|
+
</div>
|
|
555
|
+
</DialogContent>
|
|
556
|
+
</Dialog>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const SKELETON_SLOTS = ['s0', 's1', 's2', 's3', 's4', 's5'] as const;
|
|
561
|
+
|
|
562
|
+
function LogoSearchDialog({
|
|
563
|
+
onClose,
|
|
564
|
+
onPick,
|
|
565
|
+
}: {
|
|
566
|
+
onClose: () => void;
|
|
567
|
+
onPick: (file: File) => Promise<void> | void;
|
|
568
|
+
}) {
|
|
569
|
+
const [query, setQuery] = useState('');
|
|
570
|
+
const [results, setResults] = useState<SvglItem[] | null>(null);
|
|
571
|
+
const [loading, setLoading] = useState(true);
|
|
572
|
+
const [error, setError] = useState<string | null>(null);
|
|
573
|
+
const [pending, setPending] = useState<Set<number>>(() => new Set());
|
|
574
|
+
const [retryToken, setRetryToken] = useState(0);
|
|
575
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
576
|
+
|
|
577
|
+
useEffect(() => {
|
|
578
|
+
queueMicrotask(() => inputRef.current?.focus());
|
|
579
|
+
}, []);
|
|
580
|
+
|
|
581
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: retryToken is a bump-to-refetch trigger
|
|
582
|
+
useEffect(() => {
|
|
583
|
+
const ctrl = new AbortController();
|
|
584
|
+
const timer = setTimeout(() => {
|
|
585
|
+
setLoading(true);
|
|
586
|
+
setError(null);
|
|
587
|
+
searchSvgl(query, ctrl.signal)
|
|
588
|
+
.then((next) => {
|
|
589
|
+
setResults(next);
|
|
590
|
+
setLoading(false);
|
|
591
|
+
})
|
|
592
|
+
.catch((err: unknown) => {
|
|
593
|
+
if (ctrl.signal.aborted) return;
|
|
594
|
+
setError(err instanceof Error ? err.message : 'Search failed');
|
|
595
|
+
setLoading(false);
|
|
596
|
+
});
|
|
597
|
+
}, 200);
|
|
598
|
+
return () => {
|
|
599
|
+
clearTimeout(timer);
|
|
600
|
+
ctrl.abort();
|
|
601
|
+
};
|
|
602
|
+
}, [query, retryToken]);
|
|
603
|
+
|
|
604
|
+
return (
|
|
605
|
+
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
606
|
+
<DialogContent className="sm:max-w-2xl">
|
|
607
|
+
<DialogHeader>
|
|
608
|
+
<DialogTitle>Search logos</DialogTitle>
|
|
609
|
+
<DialogDescription>
|
|
610
|
+
Powered by{' '}
|
|
611
|
+
<a
|
|
612
|
+
href="https://svgl.app"
|
|
613
|
+
target="_blank"
|
|
614
|
+
rel="noopener noreferrer"
|
|
615
|
+
className="underline underline-offset-2"
|
|
616
|
+
>
|
|
617
|
+
svgl.app
|
|
618
|
+
</a>
|
|
619
|
+
.
|
|
620
|
+
</DialogDescription>
|
|
621
|
+
</DialogHeader>
|
|
622
|
+
|
|
623
|
+
<div className="relative">
|
|
624
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
625
|
+
<input
|
|
626
|
+
ref={inputRef}
|
|
627
|
+
value={query}
|
|
628
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
629
|
+
placeholder="Search by brand…"
|
|
630
|
+
className="w-full rounded-md border bg-background py-2 pl-8 pr-3 text-sm outline-none ring-ring/40 focus:ring-2"
|
|
631
|
+
/>
|
|
632
|
+
</div>
|
|
633
|
+
|
|
634
|
+
<div className="max-h-[60vh] min-h-[16rem] overflow-y-auto">
|
|
635
|
+
{error ? (
|
|
636
|
+
<div className="flex h-64 flex-col items-center justify-center gap-3 px-6 text-center">
|
|
637
|
+
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
|
638
|
+
<CloudOff className="size-5 text-muted-foreground" />
|
|
639
|
+
</div>
|
|
640
|
+
<div>
|
|
641
|
+
<p className="text-sm font-medium">Couldn't reach svgl</p>
|
|
642
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
643
|
+
Check your connection and try again.
|
|
644
|
+
</p>
|
|
645
|
+
</div>
|
|
646
|
+
<Button
|
|
647
|
+
variant="outline"
|
|
648
|
+
size="sm"
|
|
649
|
+
onClick={() => setRetryToken((n) => n + 1)}
|
|
650
|
+
className="gap-1.5"
|
|
651
|
+
>
|
|
652
|
+
<RotateCw className="size-3.5" />
|
|
653
|
+
Try again
|
|
654
|
+
</Button>
|
|
655
|
+
</div>
|
|
656
|
+
) : loading && !results ? (
|
|
657
|
+
<div className="grid grid-cols-3 gap-3">
|
|
658
|
+
{SKELETON_SLOTS.map((slot) => (
|
|
659
|
+
<div
|
|
660
|
+
key={slot}
|
|
661
|
+
className="aspect-square animate-pulse rounded-lg border bg-muted/40"
|
|
662
|
+
/>
|
|
663
|
+
))}
|
|
664
|
+
</div>
|
|
665
|
+
) : results && results.length === 0 ? (
|
|
666
|
+
<div className="flex h-64 flex-col items-center justify-center gap-3 px-6 text-center">
|
|
667
|
+
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
|
668
|
+
<SearchX className="size-5 text-muted-foreground" />
|
|
669
|
+
</div>
|
|
670
|
+
<div>
|
|
671
|
+
<p className="text-sm font-medium">
|
|
672
|
+
{query.trim() ? (
|
|
673
|
+
<>
|
|
674
|
+
No logos for{' '}
|
|
675
|
+
<span className="font-mono text-foreground">"{query.trim()}"</span>
|
|
676
|
+
</>
|
|
677
|
+
) : (
|
|
678
|
+
'No logos available'
|
|
679
|
+
)}
|
|
680
|
+
</p>
|
|
681
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
682
|
+
Try a different brand name, or browse the full catalog at{' '}
|
|
683
|
+
<a
|
|
684
|
+
href="https://svgl.app"
|
|
685
|
+
target="_blank"
|
|
686
|
+
rel="noopener noreferrer"
|
|
687
|
+
className="underline underline-offset-2 hover:text-foreground"
|
|
688
|
+
>
|
|
689
|
+
svgl.app
|
|
690
|
+
</a>
|
|
691
|
+
.
|
|
692
|
+
</p>
|
|
693
|
+
</div>
|
|
694
|
+
</div>
|
|
695
|
+
) : (
|
|
696
|
+
<div className="grid grid-cols-3 gap-3">
|
|
697
|
+
{results?.map((item) => (
|
|
698
|
+
<LogoResultCard
|
|
699
|
+
key={item.id}
|
|
700
|
+
item={item}
|
|
701
|
+
pending={pending.has(item.id)}
|
|
702
|
+
onAdd={async (file) => {
|
|
703
|
+
setPending((prev) => {
|
|
704
|
+
const next = new Set(prev);
|
|
705
|
+
next.add(item.id);
|
|
706
|
+
return next;
|
|
707
|
+
});
|
|
708
|
+
try {
|
|
709
|
+
await onPick(file);
|
|
710
|
+
} catch (err) {
|
|
711
|
+
toast.error(err instanceof Error ? err.message : 'Failed to download logo');
|
|
712
|
+
} finally {
|
|
713
|
+
setPending((prev) => {
|
|
714
|
+
const next = new Set(prev);
|
|
715
|
+
next.delete(item.id);
|
|
716
|
+
return next;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
}}
|
|
720
|
+
/>
|
|
721
|
+
))}
|
|
722
|
+
</div>
|
|
723
|
+
)}
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
<DialogFooter>
|
|
727
|
+
<Button variant="outline" onClick={onClose}>
|
|
728
|
+
Done
|
|
729
|
+
</Button>
|
|
730
|
+
</DialogFooter>
|
|
731
|
+
</DialogContent>
|
|
732
|
+
</Dialog>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function LogoResultCard({
|
|
737
|
+
item,
|
|
738
|
+
pending,
|
|
739
|
+
onAdd,
|
|
740
|
+
}: {
|
|
741
|
+
item: SvglItem;
|
|
742
|
+
pending: boolean;
|
|
743
|
+
onAdd: (file: File) => Promise<void> | void;
|
|
744
|
+
}) {
|
|
745
|
+
const hasVariants = typeof item.route === 'object' && item.route !== null;
|
|
746
|
+
const [variant, setVariant] = useState<'light' | 'dark'>('light');
|
|
747
|
+
|
|
748
|
+
const previewUrl = useMemo(() => {
|
|
749
|
+
if (typeof item.route === 'string') return item.route;
|
|
750
|
+
return item.route[variant];
|
|
751
|
+
}, [item.route, variant]);
|
|
752
|
+
|
|
753
|
+
const filename = useMemo(() => {
|
|
754
|
+
const url = previewUrl;
|
|
755
|
+
const fromUrl = basenameFromUrl(url);
|
|
756
|
+
if (fromUrl) return fromUrl;
|
|
757
|
+
const slug = slugify(item.title);
|
|
758
|
+
return hasVariants ? `${slug}-${variant}.svg` : `${slug}.svg`;
|
|
759
|
+
}, [previewUrl, item.title, hasVariants, variant]);
|
|
760
|
+
|
|
761
|
+
const category = Array.isArray(item.category) ? item.category.join(', ') : item.category;
|
|
762
|
+
|
|
763
|
+
return (
|
|
764
|
+
<div className="group flex flex-col overflow-hidden rounded-lg border bg-card">
|
|
765
|
+
<div
|
|
766
|
+
className={cn(
|
|
767
|
+
'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]',
|
|
768
|
+
variant === 'dark' && hasVariants && 'bg-neutral-900',
|
|
769
|
+
)}
|
|
770
|
+
>
|
|
771
|
+
<img src={previewUrl} alt={item.title} className="size-3/4 object-contain" />
|
|
772
|
+
</div>
|
|
773
|
+
<div className="flex flex-col gap-1.5 border-t bg-card px-2.5 py-2">
|
|
774
|
+
<div className="min-w-0">
|
|
775
|
+
<div className="truncate text-xs font-medium" title={item.title}>
|
|
776
|
+
{item.title}
|
|
777
|
+
</div>
|
|
778
|
+
<div className="truncate text-[10px] text-muted-foreground">{category}</div>
|
|
779
|
+
</div>
|
|
780
|
+
<div className="flex items-center gap-1.5">
|
|
781
|
+
{hasVariants ? (
|
|
782
|
+
<div className="flex overflow-hidden rounded-md border text-[10px]">
|
|
783
|
+
<button
|
|
784
|
+
type="button"
|
|
785
|
+
onClick={() => setVariant('light')}
|
|
786
|
+
className={cn(
|
|
787
|
+
'px-1.5 py-0.5 transition-colors',
|
|
788
|
+
variant === 'light' ? 'bg-foreground text-background' : 'hover:bg-muted',
|
|
789
|
+
)}
|
|
790
|
+
>
|
|
791
|
+
Light
|
|
792
|
+
</button>
|
|
793
|
+
<button
|
|
794
|
+
type="button"
|
|
795
|
+
onClick={() => setVariant('dark')}
|
|
796
|
+
className={cn(
|
|
797
|
+
'border-l px-1.5 py-0.5 transition-colors',
|
|
798
|
+
variant === 'dark' ? 'bg-foreground text-background' : 'hover:bg-muted',
|
|
799
|
+
)}
|
|
800
|
+
>
|
|
801
|
+
Dark
|
|
802
|
+
</button>
|
|
803
|
+
</div>
|
|
804
|
+
) : null}
|
|
805
|
+
<Button
|
|
806
|
+
size="sm"
|
|
807
|
+
variant="outline"
|
|
808
|
+
disabled={pending}
|
|
809
|
+
onClick={async () => {
|
|
810
|
+
try {
|
|
811
|
+
const file = await fetchSvgAsFile(previewUrl, filename);
|
|
812
|
+
await onAdd(file);
|
|
813
|
+
} catch (err) {
|
|
814
|
+
toast.error(err instanceof Error ? err.message : 'Failed to download logo');
|
|
815
|
+
}
|
|
816
|
+
}}
|
|
817
|
+
className="ml-auto h-6 px-2 text-[11px]"
|
|
818
|
+
>
|
|
819
|
+
{pending ? <Loader2 className="size-3 animate-spin" /> : 'Add'}
|
|
820
|
+
</Button>
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
</div>
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function basenameFromUrl(u: string): string {
|
|
828
|
+
try {
|
|
829
|
+
return new URL(u).pathname.split('/').pop() || '';
|
|
830
|
+
} catch {
|
|
831
|
+
return '';
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function slugify(s: string): string {
|
|
836
|
+
return s
|
|
837
|
+
.toLowerCase()
|
|
838
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
839
|
+
.replace(/^-|-$/g, '');
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function formatSize(bytes: number): string {
|
|
843
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
844
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
845
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
846
|
+
}
|