@open-slide/core 0.0.2 → 0.0.4

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 (35) hide show
  1. package/bin.js +2 -0
  2. package/dist/{build-DJGuOT6x.js → build-CuoESF2g.js} +1 -1
  3. package/dist/cli/bin.js +5 -5
  4. package/dist/config-DF58h0l4.js +641 -0
  5. package/dist/{dev-0SG0ArzD.js → dev-rlOZacWo.js} +1 -1
  6. package/dist/index.d.ts +7 -9
  7. package/dist/{preview-61Aawrlg.js → preview-DCrD9X36.js} +1 -1
  8. package/dist/vite/index.js +1 -1
  9. package/package.json +7 -4
  10. package/src/app/App.tsx +2 -2
  11. package/src/app/components/ClickNavZones.tsx +34 -0
  12. package/src/app/components/Player.tsx +26 -7
  13. package/src/app/components/ThumbnailRail.tsx +5 -5
  14. package/src/app/components/inspector/CommentPopover.tsx +3 -11
  15. package/src/app/components/inspector/InspectOverlay.tsx +15 -4
  16. package/src/app/components/inspector/InspectorProvider.tsx +12 -5
  17. package/src/app/components/sidebar/FolderItem.tsx +188 -0
  18. package/src/app/components/sidebar/IconPicker.tsx +59 -0
  19. package/src/app/components/sidebar/Sidebar.tsx +118 -0
  20. package/src/app/components/ui/dialog.tsx +141 -0
  21. package/src/app/components/ui/dropdown-menu.tsx +228 -0
  22. package/src/app/components/ui/popover.tsx +72 -0
  23. package/src/app/components/ui/tabs.tsx +79 -0
  24. package/src/app/lib/export-html.ts +313 -0
  25. package/src/app/lib/folders.ts +166 -0
  26. package/src/app/lib/inspector/fiber.ts +2 -2
  27. package/src/app/lib/inspector/useComments.ts +8 -8
  28. package/src/app/lib/sdk.ts +18 -5
  29. package/src/app/lib/slides.ts +8 -0
  30. package/src/app/routes/Home.tsx +540 -63
  31. package/src/app/routes/Slide.tsx +298 -0
  32. package/src/app/virtual.d.ts +4 -4
  33. package/dist/config-Opp2R1Jf.js +0 -335
  34. package/src/app/lib/decks.ts +0 -8
  35. package/src/app/routes/Deck.tsx +0 -185
@@ -1,34 +1,192 @@
1
- import { useEffect, useState } from 'react';
2
- import { Link } from 'react-router-dom';
3
- import { FolderPlus } from 'lucide-react';
4
- import { deckIds, loadDeck } from '../lib/decks';
5
- import type { DeckModule } from '../lib/sdk';
6
- import { SlideCanvas } from '../components/SlideCanvas';
1
+ import { FolderInput, FolderPlus, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Link, useSearchParams } from 'react-router-dom';
4
+ import { Button } from '@/components/ui/button';
7
5
  import { Card, CardContent } from '@/components/ui/card';
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogFooter,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from '@/components/ui/dialog';
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuTrigger,
19
+ } from '@/components/ui/dropdown-menu';
20
+ import { useFolders } from '@/lib/folders';
21
+ import { cn } from '@/lib/utils';
22
+ import { SlideCanvas } from '../components/SlideCanvas';
23
+ import { FolderIconChip, SLIDE_DND_MIME } from '../components/sidebar/FolderItem';
24
+ import { DRAFT_ID, Sidebar } from '../components/sidebar/Sidebar';
25
+ import type { Folder, FolderIcon, SlideModule } from '../lib/sdk';
26
+ import { loadSlide, slideIds } from '../lib/slides';
8
27
 
9
28
  export function Home() {
29
+ const { manifest, create, update, remove, assign, renameSlide, deleteSlide } = useFolders();
30
+ const [searchParams, setSearchParams] = useSearchParams();
31
+ const selectedId = searchParams.get('f') ?? DRAFT_ID;
32
+
33
+ const selectFolder = (id: string) => {
34
+ setSearchParams(
35
+ (prev) => {
36
+ const next = new URLSearchParams(prev);
37
+ if (id === DRAFT_ID) next.delete('f');
38
+ else next.set('f', id);
39
+ return next;
40
+ },
41
+ { replace: true },
42
+ );
43
+ };
44
+
45
+ const { draftSlides, slidesByFolder } = useMemo(() => {
46
+ const byFolder: Record<string, string[]> = {};
47
+ const draft: string[] = [];
48
+ const known = new Set(manifest.folders.map((f) => f.id));
49
+ for (const id of slideIds) {
50
+ const folderId = manifest.assignments[id];
51
+ if (folderId && known.has(folderId)) {
52
+ (byFolder[folderId] ??= []).push(id);
53
+ } else {
54
+ draft.push(id);
55
+ }
56
+ }
57
+ return { draftSlides: draft, slidesByFolder: byFolder };
58
+ }, [manifest]);
59
+
60
+ const countFor = (folderId: string | null) =>
61
+ folderId === null ? draftSlides.length : (slidesByFolder[folderId]?.length ?? 0);
62
+
63
+ const selectedFolder =
64
+ selectedId === DRAFT_ID ? null : (manifest.folders.find((f) => f.id === selectedId) ?? null);
65
+ const visibleSlides = selectedId === DRAFT_ID ? draftSlides : (slidesByFolder[selectedId] ?? []);
66
+
67
+ const title = selectedFolder?.name ?? 'Draft';
68
+ const headerIcon = selectedFolder?.icon ?? { type: 'emoji' as const, value: '📝' };
69
+
10
70
  return (
11
- <div className="mx-auto max-w-6xl px-8 py-16">
12
- <header className="mb-10 flex items-end justify-between gap-6">
13
- <div>
14
- <h1 className="font-heading text-3xl font-bold tracking-tight">open-slide</h1>
15
- <p className="mt-1 text-sm text-muted-foreground">
16
- {deckIds.length} deck{deckIds.length === 1 ? '' : 's'} · start with any agent using the{' '}
17
- <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">create-slide</code>{' '}
18
- skill
19
- </p>
71
+ <div className="flex h-screen overflow-hidden bg-background">
72
+ <div className="hidden md:block">
73
+ <Sidebar
74
+ folders={manifest.folders}
75
+ countFor={countFor}
76
+ selectedId={selectedId}
77
+ onSelect={selectFolder}
78
+ onCreate={(name, icon) => create(name, icon)}
79
+ onRename={(id, name) => update(id, { name })}
80
+ onChangeIcon={(id, icon) => update(id, { icon })}
81
+ onDelete={(id) => {
82
+ if (selectedId === id) selectFolder(DRAFT_ID);
83
+ remove(id);
84
+ }}
85
+ onDropToFolder={(folderId, slideId) => assign(slideId, folderId)}
86
+ onDropToDraft={(slideId) => assign(slideId, null)}
87
+ />
88
+ </div>
89
+
90
+ <div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
91
+ <div className="border-b bg-card/40 px-4 py-3 md:hidden">
92
+ <div className="mb-2 font-heading text-lg font-bold tracking-tight">open-slide</div>
93
+ <div className="flex gap-2 overflow-x-auto pb-1">
94
+ <MobileFolderPill
95
+ icon={{ type: 'emoji', value: '📝' }}
96
+ label="Draft"
97
+ count={countFor(null)}
98
+ active={selectedId === DRAFT_ID}
99
+ onClick={() => selectFolder(DRAFT_ID)}
100
+ />
101
+ {manifest.folders.map((f) => (
102
+ <MobileFolderPill
103
+ key={f.id}
104
+ icon={f.icon}
105
+ label={f.name}
106
+ count={countFor(f.id)}
107
+ active={selectedId === f.id}
108
+ onClick={() => selectFolder(f.id)}
109
+ />
110
+ ))}
111
+ </div>
112
+ </div>
113
+
114
+ <div className="mx-auto w-full max-w-6xl px-4 py-6 md:px-8 md:py-12">
115
+ <header className="mb-6 flex items-center gap-3 md:mb-8">
116
+ <FolderIconChip icon={headerIcon} className="size-6 text-xl" />
117
+ <h2 className="font-heading text-xl font-bold tracking-tight md:text-2xl">{title}</h2>
118
+ <span className="text-sm text-muted-foreground">
119
+ {visibleSlides.length} slide{visibleSlides.length === 1 ? '' : 's'}
120
+ </span>
121
+ </header>
122
+
123
+ {visibleSlides.length === 0 ? (
124
+ <EmptyState isDraft={selectedId === DRAFT_ID} folderName={selectedFolder?.name} />
125
+ ) : (
126
+ <ul className="grid grid-cols-[repeat(auto-fill,minmax(220px,1fr))] gap-4 md:grid-cols-[repeat(auto-fill,minmax(280px,1fr))] md:gap-5">
127
+ {visibleSlides.map((id) => (
128
+ <li key={id}>
129
+ <SlideCard
130
+ id={id}
131
+ folders={manifest.folders}
132
+ currentFolderId={manifest.assignments[id] ?? null}
133
+ onRename={(name) => renameSlide(id, name)}
134
+ onMove={(folderId) => assign(id, folderId)}
135
+ onDelete={() => deleteSlide(id)}
136
+ />
137
+ </li>
138
+ ))}
139
+ </ul>
140
+ )}
20
141
  </div>
21
- </header>
142
+ </div>
143
+ </div>
144
+ );
145
+ }
22
146
 
23
- {deckIds.length === 0 ? (
24
- <Card className="border-dashed">
25
- <CardContent className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
26
- <FolderPlus className="size-8 opacity-50" />
27
- <p>No decks yet.</p>
147
+ function MobileFolderPill({
148
+ icon,
149
+ label,
150
+ count,
151
+ active,
152
+ onClick,
153
+ }: {
154
+ icon: FolderIcon;
155
+ label: string;
156
+ count: number;
157
+ active: boolean;
158
+ onClick: () => void;
159
+ }) {
160
+ return (
161
+ <button
162
+ type="button"
163
+ onClick={onClick}
164
+ className={
165
+ 'flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ' +
166
+ (active
167
+ ? 'border-primary bg-primary/10 text-primary'
168
+ : 'border-border bg-background text-muted-foreground hover:text-foreground')
169
+ }
170
+ >
171
+ <FolderIconChip icon={icon} className="size-4 text-sm" />
172
+ <span className="truncate max-w-[8rem]">{label}</span>
173
+ <span className="tabular-nums opacity-70">{count}</span>
174
+ </button>
175
+ );
176
+ }
177
+
178
+ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: string }) {
179
+ return (
180
+ <Card className="border-dashed">
181
+ <CardContent className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
182
+ <FolderPlus className="size-8 opacity-50" />
183
+ {isDraft ? (
184
+ <>
185
+ <p>No slides yet.</p>
28
186
  <p className="text-sm">
29
187
  Create{' '}
30
188
  <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
31
- slides/my-deck/index.tsx
189
+ slides/my-slide/index.tsx
32
190
  </code>{' '}
33
191
  with{' '}
34
192
  <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
@@ -36,28 +194,44 @@ export function Home() {
36
194
  </code>
37
195
  .
38
196
  </p>
39
- </CardContent>
40
- </Card>
41
- ) : (
42
- <ul className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-5">
43
- {deckIds.map((id) => (
44
- <li key={id}>
45
- <DeckCard id={id} />
46
- </li>
47
- ))}
48
- </ul>
49
- )}
50
- </div>
197
+ </>
198
+ ) : (
199
+ <>
200
+ <p>No slides in {folderName ?? 'this folder'}.</p>
201
+ <p className="text-sm">Drag a slide from Draft into the sidebar folder.</p>
202
+ </>
203
+ )}
204
+ </CardContent>
205
+ </Card>
51
206
  );
52
207
  }
53
208
 
54
- function DeckCard({ id }: { id: string }) {
55
- const [deck, setDeck] = useState<DeckModule | null>(null);
209
+ type DialogKind = null | 'rename' | 'move' | 'delete';
210
+
211
+ function SlideCard({
212
+ id,
213
+ folders,
214
+ currentFolderId,
215
+ onRename,
216
+ onMove,
217
+ onDelete,
218
+ }: {
219
+ id: string;
220
+ folders: Folder[];
221
+ currentFolderId: string | null;
222
+ onRename: (name: string) => Promise<void> | void;
223
+ onMove: (folderId: string | null) => Promise<void> | void;
224
+ onDelete: () => Promise<void> | void;
225
+ }) {
226
+ const [slide, setSlide] = useState<SlideModule | null>(null);
227
+ const [dragging, setDragging] = useState(false);
228
+ const [dialog, setDialog] = useState<DialogKind>(null);
229
+
56
230
  useEffect(() => {
57
231
  let cancelled = false;
58
- loadDeck(id)
232
+ loadSlide(id)
59
233
  .then((mod) => {
60
- if (!cancelled) setDeck(mod);
234
+ if (!cancelled) setSlide(mod);
61
235
  })
62
236
  .catch(() => {});
63
237
  return () => {
@@ -65,34 +239,337 @@ function DeckCard({ id }: { id: string }) {
65
239
  };
66
240
  }, [id]);
67
241
 
68
- const FirstPage = deck?.default[0];
69
- const title = deck?.meta?.title ?? id;
70
- const pageCount = deck?.default.length ?? 0;
242
+ const FirstPage = slide?.default[0];
243
+ const displayTitle = slide?.meta?.title ?? id;
244
+ const pageCount = slide?.default.length ?? 0;
71
245
 
72
246
  return (
73
- <Link
74
- to={`/d/${id}`}
75
- className="group block overflow-hidden rounded-xl bg-card text-card-foreground ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:ring-foreground/20 hover:shadow-lg"
76
- >
77
- <div className="relative aspect-video overflow-hidden bg-gradient-to-br from-indigo-50 to-violet-50">
78
- {FirstPage ? (
79
- <SlideCanvas flat>
80
- <FirstPage />
81
- </SlideCanvas>
82
- ) : (
83
- <div className="grid h-full w-full place-items-center text-xs tracking-widest uppercase text-muted-foreground/60">
84
- Loading
247
+ <>
248
+ <div
249
+ draggable
250
+ onDragStart={(e) => {
251
+ e.dataTransfer.setData(SLIDE_DND_MIME, id);
252
+ e.dataTransfer.effectAllowed = 'move';
253
+ setDragging(true);
254
+ }}
255
+ onDragEnd={() => setDragging(false)}
256
+ className={cn('group relative', dragging && 'opacity-50')}
257
+ >
258
+ <Link
259
+ to={`/s/${id}`}
260
+ className="block overflow-hidden rounded-xl bg-card text-card-foreground ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:ring-foreground/20 hover:shadow-lg"
261
+ >
262
+ <div className="relative aspect-video overflow-hidden bg-gradient-to-br from-indigo-50 to-violet-50">
263
+ {FirstPage ? (
264
+ <SlideCanvas flat>
265
+ <FirstPage />
266
+ </SlideCanvas>
267
+ ) : (
268
+ <div className="grid h-full w-full place-items-center text-xs tracking-widest uppercase text-muted-foreground/60">
269
+ Loading
270
+ </div>
271
+ )}
85
272
  </div>
86
- )}
87
- </div>
88
- <div className="flex items-baseline justify-between gap-3 px-4 py-3">
89
- <span className="truncate text-sm font-medium">{title}</span>
90
- {pageCount > 0 && (
91
- <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
92
- {pageCount} page{pageCount === 1 ? '' : 's'}
93
- </span>
94
- )}
273
+ <div className="flex items-baseline justify-between gap-3 px-4 py-3">
274
+ <span className="truncate text-sm font-medium">{displayTitle}</span>
275
+ {pageCount > 0 && (
276
+ <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
277
+ {pageCount} page{pageCount === 1 ? '' : 's'}
278
+ </span>
279
+ )}
280
+ </div>
281
+ </Link>
282
+
283
+ <div className="absolute right-2 top-2">
284
+ <DropdownMenu>
285
+ <DropdownMenuTrigger asChild>
286
+ <button
287
+ type="button"
288
+ onClick={(e) => {
289
+ e.stopPropagation();
290
+ e.preventDefault();
291
+ }}
292
+ className="flex size-7 items-center justify-center rounded-md bg-background/80 text-foreground shadow-sm ring-1 ring-foreground/10 opacity-0 backdrop-blur transition-opacity hover:bg-background group-hover:opacity-100 aria-expanded:opacity-100"
293
+ aria-label="Slide actions"
294
+ >
295
+ <MoreHorizontal className="size-4" />
296
+ </button>
297
+ </DropdownMenuTrigger>
298
+ <DropdownMenuContent align="end" className="min-w-[160px]">
299
+ <DropdownMenuItem onSelect={() => setDialog('rename')}>
300
+ <Pencil />
301
+ Rename
302
+ </DropdownMenuItem>
303
+ <DropdownMenuItem onSelect={() => setDialog('move')}>
304
+ <FolderInput />
305
+ Move to folder
306
+ </DropdownMenuItem>
307
+ <DropdownMenuItem variant="destructive" onSelect={() => setDialog('delete')}>
308
+ <Trash2 />
309
+ Delete
310
+ </DropdownMenuItem>
311
+ </DropdownMenuContent>
312
+ </DropdownMenu>
313
+ </div>
95
314
  </div>
96
- </Link>
315
+
316
+ <RenameDialog
317
+ open={dialog === 'rename'}
318
+ initialName={displayTitle}
319
+ onOpenChange={(v) => setDialog(v ? 'rename' : null)}
320
+ onSubmit={async (name) => {
321
+ await onRename(name);
322
+ setDialog(null);
323
+ }}
324
+ />
325
+ <MoveDialog
326
+ open={dialog === 'move'}
327
+ slideName={displayTitle}
328
+ folders={folders}
329
+ currentFolderId={currentFolderId}
330
+ onOpenChange={(v) => setDialog(v ? 'move' : null)}
331
+ onSubmit={async (folderId) => {
332
+ await onMove(folderId);
333
+ setDialog(null);
334
+ }}
335
+ />
336
+ <DeleteDialog
337
+ open={dialog === 'delete'}
338
+ slideName={displayTitle}
339
+ onOpenChange={(v) => setDialog(v ? 'delete' : null)}
340
+ onConfirm={async () => {
341
+ await onDelete();
342
+ setDialog(null);
343
+ }}
344
+ />
345
+ </>
346
+ );
347
+ }
348
+
349
+ function RenameDialog({
350
+ open,
351
+ initialName,
352
+ onOpenChange,
353
+ onSubmit,
354
+ }: {
355
+ open: boolean;
356
+ initialName: string;
357
+ onOpenChange: (open: boolean) => void;
358
+ onSubmit: (name: string) => Promise<void> | void;
359
+ }) {
360
+ const [value, setValue] = useState(initialName);
361
+ const [submitting, setSubmitting] = useState(false);
362
+ const inputRef = useRef<HTMLInputElement | null>(null);
363
+
364
+ useEffect(() => {
365
+ if (open) {
366
+ setValue(initialName);
367
+ setSubmitting(false);
368
+ queueMicrotask(() => {
369
+ inputRef.current?.focus();
370
+ inputRef.current?.select();
371
+ });
372
+ }
373
+ }, [open, initialName]);
374
+
375
+ const submit = async () => {
376
+ const trimmed = value.trim();
377
+ if (!trimmed || trimmed === initialName) {
378
+ onOpenChange(false);
379
+ return;
380
+ }
381
+ setSubmitting(true);
382
+ try {
383
+ await onSubmit(trimmed);
384
+ } finally {
385
+ setSubmitting(false);
386
+ }
387
+ };
388
+
389
+ return (
390
+ <Dialog open={open} onOpenChange={onOpenChange}>
391
+ <DialogContent>
392
+ <DialogHeader>
393
+ <DialogTitle>Rename slide</DialogTitle>
394
+ <DialogDescription>Give this slide a new display name.</DialogDescription>
395
+ </DialogHeader>
396
+ <input
397
+ ref={inputRef}
398
+ value={value}
399
+ onChange={(e) => setValue(e.target.value)}
400
+ onKeyDown={(e) => {
401
+ if (e.key === 'Enter') {
402
+ e.preventDefault();
403
+ submit();
404
+ }
405
+ }}
406
+ maxLength={80}
407
+ placeholder="Slide name"
408
+ className="h-9 w-full rounded-md border bg-background px-3 text-sm outline-none ring-ring/40 focus:ring-2"
409
+ />
410
+ <DialogFooter>
411
+ <Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
412
+ Cancel
413
+ </Button>
414
+ <Button size="sm" disabled={submitting} onClick={submit}>
415
+ Save
416
+ </Button>
417
+ </DialogFooter>
418
+ </DialogContent>
419
+ </Dialog>
420
+ );
421
+ }
422
+
423
+ function MoveDialog({
424
+ open,
425
+ slideName,
426
+ folders,
427
+ currentFolderId,
428
+ onOpenChange,
429
+ onSubmit,
430
+ }: {
431
+ open: boolean;
432
+ slideName: string;
433
+ folders: Folder[];
434
+ currentFolderId: string | null;
435
+ onOpenChange: (open: boolean) => void;
436
+ onSubmit: (folderId: string | null) => Promise<void> | void;
437
+ }) {
438
+ const [selected, setSelected] = useState<string | null>(currentFolderId);
439
+ const [submitting, setSubmitting] = useState(false);
440
+
441
+ useEffect(() => {
442
+ if (open) {
443
+ setSelected(currentFolderId);
444
+ setSubmitting(false);
445
+ }
446
+ }, [open, currentFolderId]);
447
+
448
+ const submit = async () => {
449
+ if (selected === currentFolderId) {
450
+ onOpenChange(false);
451
+ return;
452
+ }
453
+ setSubmitting(true);
454
+ try {
455
+ await onSubmit(selected);
456
+ } finally {
457
+ setSubmitting(false);
458
+ }
459
+ };
460
+
461
+ return (
462
+ <Dialog open={open} onOpenChange={onOpenChange}>
463
+ <DialogContent>
464
+ <DialogHeader>
465
+ <DialogTitle>Move slide</DialogTitle>
466
+ <DialogDescription>
467
+ Choose a folder for <span className="font-medium text-foreground">{slideName}</span>.
468
+ </DialogDescription>
469
+ </DialogHeader>
470
+ <div className="max-h-[320px] overflow-y-auto rounded-md border">
471
+ <FolderOption
472
+ icon={{ type: 'emoji', value: '📝' }}
473
+ label="Draft"
474
+ active={selected === null}
475
+ onClick={() => setSelected(null)}
476
+ />
477
+ {folders.map((f) => (
478
+ <FolderOption
479
+ key={f.id}
480
+ icon={f.icon}
481
+ label={f.name}
482
+ active={selected === f.id}
483
+ onClick={() => setSelected(f.id)}
484
+ />
485
+ ))}
486
+ </div>
487
+ <DialogFooter>
488
+ <Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
489
+ Cancel
490
+ </Button>
491
+ <Button size="sm" disabled={submitting || selected === currentFolderId} onClick={submit}>
492
+ Move
493
+ </Button>
494
+ </DialogFooter>
495
+ </DialogContent>
496
+ </Dialog>
497
+ );
498
+ }
499
+
500
+ function FolderOption({
501
+ icon,
502
+ label,
503
+ active,
504
+ onClick,
505
+ }: {
506
+ icon: FolderIcon;
507
+ label: string;
508
+ active: boolean;
509
+ onClick: () => void;
510
+ }) {
511
+ return (
512
+ <button
513
+ type="button"
514
+ onClick={onClick}
515
+ className={cn(
516
+ 'flex w-full items-center gap-2 border-b px-3 py-2 text-left text-sm last:border-b-0 transition-colors',
517
+ active ? 'bg-primary/10 text-primary' : 'hover:bg-muted/60',
518
+ )}
519
+ >
520
+ <FolderIconChip icon={icon} />
521
+ <span className="truncate">{label}</span>
522
+ {active && <span className="ml-auto text-xs tracking-wide opacity-70">Selected</span>}
523
+ </button>
524
+ );
525
+ }
526
+
527
+ function DeleteDialog({
528
+ open,
529
+ slideName,
530
+ onOpenChange,
531
+ onConfirm,
532
+ }: {
533
+ open: boolean;
534
+ slideName: string;
535
+ onOpenChange: (open: boolean) => void;
536
+ onConfirm: () => Promise<void> | void;
537
+ }) {
538
+ const [submitting, setSubmitting] = useState(false);
539
+
540
+ useEffect(() => {
541
+ if (open) setSubmitting(false);
542
+ }, [open]);
543
+
544
+ const confirm = async () => {
545
+ setSubmitting(true);
546
+ try {
547
+ await onConfirm();
548
+ } finally {
549
+ setSubmitting(false);
550
+ }
551
+ };
552
+
553
+ return (
554
+ <Dialog open={open} onOpenChange={onOpenChange}>
555
+ <DialogContent>
556
+ <DialogHeader>
557
+ <DialogTitle>Delete slide?</DialogTitle>
558
+ <DialogDescription>
559
+ This permanently removes{' '}
560
+ <span className="font-medium text-foreground">{slideName}</span> and its files from
561
+ disk. This action cannot be undone.
562
+ </DialogDescription>
563
+ </DialogHeader>
564
+ <DialogFooter>
565
+ <Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
566
+ Cancel
567
+ </Button>
568
+ <Button variant="destructive" size="sm" disabled={submitting} onClick={confirm}>
569
+ Delete
570
+ </Button>
571
+ </DialogFooter>
572
+ </DialogContent>
573
+ </Dialog>
97
574
  );
98
575
  }