@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.
- package/bin.js +2 -0
- package/dist/{build-DJGuOT6x.js → build-CuoESF2g.js} +1 -1
- package/dist/cli/bin.js +5 -5
- package/dist/config-DF58h0l4.js +641 -0
- package/dist/{dev-0SG0ArzD.js → dev-rlOZacWo.js} +1 -1
- package/dist/index.d.ts +7 -9
- package/dist/{preview-61Aawrlg.js → preview-DCrD9X36.js} +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +7 -4
- package/src/app/App.tsx +2 -2
- package/src/app/components/ClickNavZones.tsx +34 -0
- package/src/app/components/Player.tsx +26 -7
- package/src/app/components/ThumbnailRail.tsx +5 -5
- package/src/app/components/inspector/CommentPopover.tsx +3 -11
- package/src/app/components/inspector/InspectOverlay.tsx +15 -4
- package/src/app/components/inspector/InspectorProvider.tsx +12 -5
- package/src/app/components/sidebar/FolderItem.tsx +188 -0
- package/src/app/components/sidebar/IconPicker.tsx +59 -0
- package/src/app/components/sidebar/Sidebar.tsx +118 -0
- package/src/app/components/ui/dialog.tsx +141 -0
- package/src/app/components/ui/dropdown-menu.tsx +228 -0
- package/src/app/components/ui/popover.tsx +72 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/lib/export-html.ts +313 -0
- package/src/app/lib/folders.ts +166 -0
- package/src/app/lib/inspector/fiber.ts +2 -2
- package/src/app/lib/inspector/useComments.ts +8 -8
- package/src/app/lib/sdk.ts +18 -5
- package/src/app/lib/slides.ts +8 -0
- package/src/app/routes/Home.tsx +540 -63
- package/src/app/routes/Slide.tsx +298 -0
- package/src/app/virtual.d.ts +4 -4
- package/dist/config-Opp2R1Jf.js +0 -335
- package/src/app/lib/decks.ts +0 -8
- package/src/app/routes/Deck.tsx +0 -185
package/src/app/routes/Home.tsx
CHANGED
|
@@ -1,34 +1,192 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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="
|
|
12
|
-
<
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
</
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
22
146
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
232
|
+
loadSlide(id)
|
|
59
233
|
.then((mod) => {
|
|
60
|
-
if (!cancelled)
|
|
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 =
|
|
69
|
-
const
|
|
70
|
-
const pageCount =
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
</
|
|
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
|
-
|
|
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
|
}
|