@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.
@@ -1,16 +1,32 @@
1
- import { useEffect, useMemo, useState } from 'react';
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 { FolderPlus } from 'lucide-react';
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 { Sidebar, DRAFT_ID } from '../components/sidebar/Sidebar';
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
- <Sidebar
58
- folders={manifest.folders}
59
- countFor={countFor}
60
- selectedId={selectedId}
61
- onSelect={selectFolder}
62
- onCreate={(name, icon) => create(name, icon)}
63
- onRename={(id, name) => update(id, { name })}
64
- onChangeIcon={(id, icon) => update(id, { icon })}
65
- onDelete={(id) => {
66
- if (selectedId === id) selectFolder(DRAFT_ID);
67
- remove(id);
68
- }}
69
- onDropToFolder={(folderId, slideId) => assign(slideId, folderId)}
70
- onDropToDraft={(slideId) => assign(slideId, null)}
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="mx-auto w-full max-w-6xl px-8 py-12">
75
- <header className="mb-8 flex items-center gap-3">
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-2xl font-bold tracking-tight">{title}</h2>
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 id={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
+ />
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
- function SlideCard({ id }: { id: string }) {
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 title = slide?.meta?.title ?? id;
243
+ const displayTitle = slide?.meta?.title ?? id;
148
244
  const pageCount = slide?.default.length ?? 0;
149
245
 
150
246
  return (
151
- <div
152
- draggable
153
- onDragStart={(e) => {
154
- e.dataTransfer.setData(SLIDE_DND_MIME, id);
155
- e.dataTransfer.effectAllowed = 'move';
156
- setDragging(true);
157
- }}
158
- onDragEnd={() => setDragging(false)}
159
- className={dragging ? 'opacity-50' : ''}
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
- <div className="relative aspect-video overflow-hidden bg-gradient-to-br from-indigo-50 to-violet-50">
166
- {FirstPage ? (
167
- <SlideCanvas flat>
168
- <FirstPage />
169
- </SlideCanvas>
170
- ) : (
171
- <div className="grid h-full w-full place-items-center text-xs tracking-widest uppercase text-muted-foreground/60">
172
- Loading
173
- </div>
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
- <div className="flex items-baseline justify-between gap-3 px-4 py-3">
177
- <span className="truncate text-sm font-medium">{title}</span>
178
- {pageCount > 0 && (
179
- <span className="shrink-0 text-xs text-muted-foreground tabular-nums">
180
- {pageCount} page{pageCount === 1 ? '' : 's'}
181
- </span>
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
- </Link>
185
- </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>
186
574
  );
187
575
  }