@open-slide/core 0.0.3 → 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.
- package/dist/{build-Cav2jYyI.js → build-CuoESF2g.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-g-uy_P5U.js → config-DF58h0l4.js} +132 -5
- package/dist/{dev-CFmlBbLh.js → dev-rlOZacWo.js} +1 -1
- package/dist/{preview-CotwHU_d.js → preview-DCrD9X36.js} +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +3 -2
- package/src/app/components/ClickNavZones.tsx +34 -0
- package/src/app/components/Player.tsx +22 -3
- package/src/app/components/inspector/CommentPopover.tsx +3 -11
- package/src/app/components/inspector/InspectOverlay.tsx +13 -2
- package/src/app/components/inspector/InspectorProvider.tsx +8 -1
- package/src/app/components/sidebar/FolderItem.tsx +1 -4
- package/src/app/components/ui/dialog.tsx +141 -0
- package/src/app/components/ui/dropdown-menu.tsx +41 -70
- package/src/app/components/ui/popover.tsx +22 -37
- package/src/app/components/ui/tabs.tsx +26 -36
- package/src/app/lib/export-html.ts +313 -0
- package/src/app/lib/folders.ts +40 -4
- package/src/app/lib/sdk.ts +1 -3
- package/src/app/routes/Home.tsx +453 -65
- package/src/app/routes/Slide.tsx +151 -38
package/src/app/routes/Home.tsx
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FolderInput, FolderPlus, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
3
|
import { Link, useSearchParams } from 'react-router-dom';
|
|
3
|
-
import {
|
|
4
|
-
import { slideIds, loadSlide } from '../lib/slides';
|
|
5
|
-
import type { SlideModule } from '../lib/sdk';
|
|
6
|
-
import { SlideCanvas } from '../components/SlideCanvas';
|
|
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';
|
|
8
20
|
import { useFolders } from '@/lib/folders';
|
|
9
|
-
import {
|
|
21
|
+
import { cn } from '@/lib/utils';
|
|
22
|
+
import { SlideCanvas } from '../components/SlideCanvas';
|
|
10
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';
|
|
11
27
|
|
|
12
28
|
export function Home() {
|
|
13
|
-
const { manifest, create, update, remove, assign } = useFolders();
|
|
29
|
+
const { manifest, create, update, remove, assign, renameSlide, deleteSlide } = useFolders();
|
|
14
30
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
15
31
|
const selectedId = searchParams.get('f') ?? DRAFT_ID;
|
|
16
32
|
|
|
@@ -42,39 +58,63 @@ export function Home() {
|
|
|
42
58
|
}, [manifest]);
|
|
43
59
|
|
|
44
60
|
const countFor = (folderId: string | null) =>
|
|
45
|
-
folderId === null ? draftSlides.length : slidesByFolder[folderId]?.length ?? 0;
|
|
61
|
+
folderId === null ? draftSlides.length : (slidesByFolder[folderId]?.length ?? 0);
|
|
46
62
|
|
|
47
63
|
const selectedFolder =
|
|
48
|
-
selectedId === DRAFT_ID ? null : manifest.folders.find((f) => f.id === selectedId) ?? null;
|
|
49
|
-
const visibleSlides =
|
|
50
|
-
selectedId === DRAFT_ID ? draftSlides : slidesByFolder[selectedId] ?? [];
|
|
64
|
+
selectedId === DRAFT_ID ? null : (manifest.folders.find((f) => f.id === selectedId) ?? null);
|
|
65
|
+
const visibleSlides = selectedId === DRAFT_ID ? draftSlides : (slidesByFolder[selectedId] ?? []);
|
|
51
66
|
|
|
52
67
|
const title = selectedFolder?.name ?? 'Draft';
|
|
53
68
|
const headerIcon = selectedFolder?.icon ?? { type: 'emoji' as const, value: '📝' };
|
|
54
69
|
|
|
55
70
|
return (
|
|
56
71
|
<div className="flex h-screen overflow-hidden bg-background">
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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>
|
|
72
89
|
|
|
73
90
|
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
|
|
74
|
-
<div className="
|
|
75
|
-
<
|
|
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">
|
|
76
116
|
<FolderIconChip icon={headerIcon} className="size-6 text-xl" />
|
|
77
|
-
<h2 className="font-heading text-
|
|
117
|
+
<h2 className="font-heading text-xl font-bold tracking-tight md:text-2xl">{title}</h2>
|
|
78
118
|
<span className="text-sm text-muted-foreground">
|
|
79
119
|
{visibleSlides.length} slide{visibleSlides.length === 1 ? '' : 's'}
|
|
80
120
|
</span>
|
|
@@ -83,10 +123,17 @@ export function Home() {
|
|
|
83
123
|
{visibleSlides.length === 0 ? (
|
|
84
124
|
<EmptyState isDraft={selectedId === DRAFT_ID} folderName={selectedFolder?.name} />
|
|
85
125
|
) : (
|
|
86
|
-
<ul className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-5">
|
|
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">
|
|
87
127
|
{visibleSlides.map((id) => (
|
|
88
128
|
<li key={id}>
|
|
89
|
-
<SlideCard
|
|
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
|
+
/>
|
|
90
137
|
</li>
|
|
91
138
|
))}
|
|
92
139
|
</ul>
|
|
@@ -97,6 +144,37 @@ export function Home() {
|
|
|
97
144
|
);
|
|
98
145
|
}
|
|
99
146
|
|
|
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
|
+
|
|
100
178
|
function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: string }) {
|
|
101
179
|
return (
|
|
102
180
|
<Card className="border-dashed">
|
|
@@ -128,9 +206,27 @@ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: st
|
|
|
128
206
|
);
|
|
129
207
|
}
|
|
130
208
|
|
|
131
|
-
|
|
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
|
+
}) {
|
|
132
226
|
const [slide, setSlide] = useState<SlideModule | null>(null);
|
|
133
227
|
const [dragging, setDragging] = useState(false);
|
|
228
|
+
const [dialog, setDialog] = useState<DialogKind>(null);
|
|
229
|
+
|
|
134
230
|
useEffect(() => {
|
|
135
231
|
let cancelled = false;
|
|
136
232
|
loadSlide(id)
|
|
@@ -144,44 +240,336 @@ function SlideCard({ id }: { id: string }) {
|
|
|
144
240
|
}, [id]);
|
|
145
241
|
|
|
146
242
|
const FirstPage = slide?.default[0];
|
|
147
|
-
const
|
|
243
|
+
const displayTitle = slide?.meta?.title ?? id;
|
|
148
244
|
const pageCount = slide?.default.length ?? 0;
|
|
149
245
|
|
|
150
246
|
return (
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
e
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
<Link
|
|
162
|
-
to={`/s/${id}`}
|
|
163
|
-
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"
|
|
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')}
|
|
164
257
|
>
|
|
165
|
-
<
|
|
166
|
-
{
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
)}
|
|
272
|
+
</div>
|
|
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>
|
|
175
313
|
</div>
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
314
|
+
</div>
|
|
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
|
+
))}
|
|
183
486
|
</div>
|
|
184
|
-
|
|
185
|
-
|
|
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>
|
|
186
574
|
);
|
|
187
575
|
}
|