@open-slide/core 0.0.1 → 0.0.3

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.
@@ -0,0 +1,87 @@
1
+ import * as React from "react"
2
+ import { Popover as PopoverPrimitive } from "radix-ui"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function Popover({
7
+ ...props
8
+ }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
9
+ return <PopoverPrimitive.Root data-slot="popover" {...props} />
10
+ }
11
+
12
+ function PopoverTrigger({
13
+ ...props
14
+ }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
15
+ return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
16
+ }
17
+
18
+ function PopoverContent({
19
+ className,
20
+ align = "center",
21
+ sideOffset = 4,
22
+ ...props
23
+ }: React.ComponentProps<typeof PopoverPrimitive.Content>) {
24
+ return (
25
+ <PopoverPrimitive.Portal>
26
+ <PopoverPrimitive.Content
27
+ data-slot="popover-content"
28
+ align={align}
29
+ sideOffset={sideOffset}
30
+ className={cn(
31
+ "z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
32
+ className
33
+ )}
34
+ {...props}
35
+ />
36
+ </PopoverPrimitive.Portal>
37
+ )
38
+ }
39
+
40
+ function PopoverAnchor({
41
+ ...props
42
+ }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
43
+ return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
44
+ }
45
+
46
+ function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
47
+ return (
48
+ <div
49
+ data-slot="popover-header"
50
+ className={cn("flex flex-col gap-1 text-sm", className)}
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
57
+ return (
58
+ <div
59
+ data-slot="popover-title"
60
+ className={cn("font-medium", className)}
61
+ {...props}
62
+ />
63
+ )
64
+ }
65
+
66
+ function PopoverDescription({
67
+ className,
68
+ ...props
69
+ }: React.ComponentProps<"p">) {
70
+ return (
71
+ <p
72
+ data-slot="popover-description"
73
+ className={cn("text-muted-foreground", className)}
74
+ {...props}
75
+ />
76
+ )
77
+ }
78
+
79
+ export {
80
+ Popover,
81
+ PopoverTrigger,
82
+ PopoverContent,
83
+ PopoverAnchor,
84
+ PopoverHeader,
85
+ PopoverTitle,
86
+ PopoverDescription,
87
+ }
@@ -0,0 +1,89 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { Tabs as TabsPrimitive } from "radix-ui"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ function Tabs({
8
+ className,
9
+ orientation = "horizontal",
10
+ ...props
11
+ }: React.ComponentProps<typeof TabsPrimitive.Root>) {
12
+ return (
13
+ <TabsPrimitive.Root
14
+ data-slot="tabs"
15
+ data-orientation={orientation}
16
+ orientation={orientation}
17
+ className={cn(
18
+ "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
19
+ className
20
+ )}
21
+ {...props}
22
+ />
23
+ )
24
+ }
25
+
26
+ const tabsListVariants = cva(
27
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
28
+ {
29
+ variants: {
30
+ variant: {
31
+ default: "bg-muted",
32
+ line: "gap-1 bg-transparent",
33
+ },
34
+ },
35
+ defaultVariants: {
36
+ variant: "default",
37
+ },
38
+ }
39
+ )
40
+
41
+ function TabsList({
42
+ className,
43
+ variant = "default",
44
+ ...props
45
+ }: React.ComponentProps<typeof TabsPrimitive.List> &
46
+ VariantProps<typeof tabsListVariants>) {
47
+ return (
48
+ <TabsPrimitive.List
49
+ data-slot="tabs-list"
50
+ data-variant={variant}
51
+ className={cn(tabsListVariants({ variant }), className)}
52
+ {...props}
53
+ />
54
+ )
55
+ }
56
+
57
+ function TabsTrigger({
58
+ className,
59
+ ...props
60
+ }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
61
+ return (
62
+ <TabsPrimitive.Trigger
63
+ data-slot="tabs-trigger"
64
+ className={cn(
65
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
66
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
67
+ "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
68
+ "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
69
+ className
70
+ )}
71
+ {...props}
72
+ />
73
+ )
74
+ }
75
+
76
+ function TabsContent({
77
+ className,
78
+ ...props
79
+ }: React.ComponentProps<typeof TabsPrimitive.Content>) {
80
+ return (
81
+ <TabsPrimitive.Content
82
+ data-slot="tabs-content"
83
+ className={cn("flex-1 outline-none", className)}
84
+ {...props}
85
+ />
86
+ )
87
+ }
88
+
89
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,130 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import type { Folder, FolderIcon, FoldersManifest } from './sdk';
3
+
4
+ const EMPTY: FoldersManifest = { folders: [], assignments: {} };
5
+
6
+ async function getManifest(): Promise<FoldersManifest> {
7
+ const res = await fetch('/__folders');
8
+ if (!res.ok) throw new Error(`GET /__folders ${res.status}`);
9
+ return (await res.json()) as FoldersManifest;
10
+ }
11
+
12
+ async function postFolder(name: string, icon: FolderIcon): Promise<Folder> {
13
+ const res = await fetch('/__folders', {
14
+ method: 'POST',
15
+ headers: { 'content-type': 'application/json' },
16
+ body: JSON.stringify({ name, icon }),
17
+ });
18
+ if (!res.ok) throw new Error(`POST /__folders ${res.status}`);
19
+ return (await res.json()) as Folder;
20
+ }
21
+
22
+ async function patchFolder(
23
+ id: string,
24
+ patch: { name?: string; icon?: FolderIcon },
25
+ ): Promise<Folder> {
26
+ const res = await fetch(`/__folders/${id}`, {
27
+ method: 'PATCH',
28
+ headers: { 'content-type': 'application/json' },
29
+ body: JSON.stringify(patch),
30
+ });
31
+ if (!res.ok) throw new Error(`PATCH /__folders/${id} ${res.status}`);
32
+ return (await res.json()) as Folder;
33
+ }
34
+
35
+ async function deleteFolder(id: string): Promise<void> {
36
+ const res = await fetch(`/__folders/${id}`, { method: 'DELETE' });
37
+ if (!res.ok) throw new Error(`DELETE /__folders/${id} ${res.status}`);
38
+ }
39
+
40
+ async function putAssign(slideId: string, folderId: string | null): Promise<void> {
41
+ const res = await fetch('/__folders/assign', {
42
+ method: 'PUT',
43
+ headers: { 'content-type': 'application/json' },
44
+ body: JSON.stringify({ slideId, folderId }),
45
+ });
46
+ if (!res.ok) throw new Error(`PUT /__folders/assign ${res.status}`);
47
+ }
48
+
49
+ export type UseFoldersResult = {
50
+ manifest: FoldersManifest;
51
+ loading: boolean;
52
+ create: (name: string, icon: FolderIcon) => Promise<Folder>;
53
+ update: (id: string, patch: { name?: string; icon?: FolderIcon }) => Promise<void>;
54
+ remove: (id: string) => Promise<void>;
55
+ assign: (slideId: string, folderId: string | null) => Promise<void>;
56
+ refresh: () => Promise<void>;
57
+ };
58
+
59
+ export function useFolders(): UseFoldersResult {
60
+ const [manifest, setManifest] = useState<FoldersManifest>(EMPTY);
61
+ const [loading, setLoading] = useState(true);
62
+
63
+ const refresh = useCallback(async () => {
64
+ const m = await getManifest();
65
+ setManifest(m);
66
+ }, []);
67
+
68
+ useEffect(() => {
69
+ let cancelled = false;
70
+ getManifest()
71
+ .then((m) => {
72
+ if (!cancelled) {
73
+ setManifest(m);
74
+ setLoading(false);
75
+ }
76
+ })
77
+ .catch(() => {
78
+ if (!cancelled) setLoading(false);
79
+ });
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, []);
84
+
85
+ useEffect(() => {
86
+ if (!import.meta.hot) return;
87
+ const handler = () => {
88
+ refresh().catch(() => {});
89
+ };
90
+ import.meta.hot.on('open-slide:folders-changed', handler);
91
+ return () => {
92
+ import.meta.hot?.off('open-slide:folders-changed', handler);
93
+ };
94
+ }, [refresh]);
95
+
96
+ const create = useCallback(
97
+ async (name: string, icon: FolderIcon) => {
98
+ const folder = await postFolder(name, icon);
99
+ await refresh();
100
+ return folder;
101
+ },
102
+ [refresh],
103
+ );
104
+
105
+ const update = useCallback(
106
+ async (id: string, patch: { name?: string; icon?: FolderIcon }) => {
107
+ await patchFolder(id, patch);
108
+ await refresh();
109
+ },
110
+ [refresh],
111
+ );
112
+
113
+ const remove = useCallback(
114
+ async (id: string) => {
115
+ await deleteFolder(id);
116
+ await refresh();
117
+ },
118
+ [refresh],
119
+ );
120
+
121
+ const assign = useCallback(
122
+ async (slideId: string, folderId: string | null) => {
123
+ await putAssign(slideId, folderId);
124
+ await refresh();
125
+ },
126
+ [refresh],
127
+ );
128
+
129
+ return { manifest, loading, create, update, remove, assign, refresh };
130
+ }
@@ -21,8 +21,8 @@ function getSource(fiber: FiberLike) {
21
21
  return fiber._debugSource ?? fiber.memoizedProps?.__source;
22
22
  }
23
23
 
24
- export function findSlideSource(el: HTMLElement, deckId: string): SlideSourceHit | null {
25
- const needle = `/slides/${deckId}/index.tsx`;
24
+ export function findSlideSource(el: HTMLElement, slideId: string): SlideSourceHit | null {
25
+ const needle = `/slides/${slideId}/index.tsx`;
26
26
  let fiber = getFiber(el);
27
27
  let anchor: HTMLElement = el;
28
28
  while (fiber) {
@@ -10,14 +10,14 @@ export type SlideComment = {
10
10
 
11
11
  type ListResponse = { comments: SlideComment[] };
12
12
 
13
- export function useComments(deckId: string) {
13
+ export function useComments(slideId: string) {
14
14
  const [comments, setComments] = useState<SlideComment[]>([]);
15
15
  const [error, setError] = useState<string | null>(null);
16
16
 
17
17
  const refetch = useCallback(async () => {
18
- if (!deckId) return;
18
+ if (!slideId) return;
19
19
  try {
20
- const res = await fetch(`/__comments?deckId=${encodeURIComponent(deckId)}`);
20
+ const res = await fetch(`/__comments?slideId=${encodeURIComponent(slideId)}`);
21
21
  if (!res.ok) {
22
22
  setError(`GET /__comments → ${res.status}`);
23
23
  return;
@@ -28,14 +28,14 @@ export function useComments(deckId: string) {
28
28
  } catch (e) {
29
29
  setError(String((e as Error).message ?? e));
30
30
  }
31
- }, [deckId]);
31
+ }, [slideId]);
32
32
 
33
33
  const add = useCallback(
34
34
  async (line: number, column: number, text: string) => {
35
35
  const res = await fetch('/__comments/add', {
36
36
  method: 'POST',
37
37
  headers: { 'content-type': 'application/json' },
38
- body: JSON.stringify({ deckId, line, column, text }),
38
+ body: JSON.stringify({ slideId, line, column, text }),
39
39
  });
40
40
  if (!res.ok) {
41
41
  const body = (await res.json().catch(() => ({}))) as { error?: string };
@@ -43,18 +43,18 @@ export function useComments(deckId: string) {
43
43
  }
44
44
  await refetch();
45
45
  },
46
- [deckId, refetch],
46
+ [slideId, refetch],
47
47
  );
48
48
 
49
49
  const remove = useCallback(
50
50
  async (id: string) => {
51
- const res = await fetch(`/__comments/${id}?deckId=${encodeURIComponent(deckId)}`, {
51
+ const res = await fetch(`/__comments/${id}?slideId=${encodeURIComponent(slideId)}`, {
52
52
  method: 'DELETE',
53
53
  });
54
54
  if (!res.ok) throw new Error(`DELETE /__comments/${id} → ${res.status}`);
55
55
  await refetch();
56
56
  },
57
- [deckId, refetch],
57
+ [slideId, refetch],
58
58
  );
59
59
 
60
60
  useEffect(() => {
@@ -1,15 +1,30 @@
1
1
  import type { ComponentType } from 'react';
2
2
 
3
- export type SlidePage = ComponentType;
3
+ export type Page = ComponentType;
4
4
 
5
- export type DeckMeta = {
5
+ export type SlideMeta = {
6
6
  title?: string;
7
7
  theme?: 'light' | 'dark';
8
8
  };
9
9
 
10
- export type DeckModule = {
11
- default: SlidePage[];
12
- meta?: DeckMeta;
10
+ export type SlideModule = {
11
+ default: Page[];
12
+ meta?: SlideMeta;
13
+ };
14
+
15
+ export type FolderIcon =
16
+ | { type: 'emoji'; value: string }
17
+ | { type: 'color'; value: string };
18
+
19
+ export type Folder = {
20
+ id: string;
21
+ name: string;
22
+ icon: FolderIcon;
23
+ };
24
+
25
+ export type FoldersManifest = {
26
+ folders: Folder[];
27
+ assignments: Record<string, string>;
13
28
  };
14
29
 
15
30
  export const CANVAS_WIDTH = 1920;
@@ -0,0 +1,8 @@
1
+ import type { SlideModule } from './sdk';
2
+ import { slideIds as ids, loadSlide as load } from 'virtual:open-slide/slides';
3
+
4
+ export const slideIds: string[] = ids;
5
+
6
+ export async function loadSlide(id: string): Promise<SlideModule> {
7
+ return load(id);
8
+ }
@@ -1,34 +1,114 @@
1
- import { useEffect, useState } from 'react';
2
- import { Link } from 'react-router-dom';
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { Link, useSearchParams } from 'react-router-dom';
3
3
  import { FolderPlus } from 'lucide-react';
4
- import { deckIds, loadDeck } from '../lib/decks';
5
- import type { DeckModule } from '../lib/sdk';
4
+ import { slideIds, loadSlide } from '../lib/slides';
5
+ import type { SlideModule } from '../lib/sdk';
6
6
  import { SlideCanvas } from '../components/SlideCanvas';
7
7
  import { Card, CardContent } from '@/components/ui/card';
8
+ import { useFolders } from '@/lib/folders';
9
+ import { Sidebar, DRAFT_ID } from '../components/sidebar/Sidebar';
10
+ import { FolderIconChip, SLIDE_DND_MIME } from '../components/sidebar/FolderItem';
8
11
 
9
12
  export function Home() {
13
+ const { manifest, create, update, remove, assign } = useFolders();
14
+ const [searchParams, setSearchParams] = useSearchParams();
15
+ const selectedId = searchParams.get('f') ?? DRAFT_ID;
16
+
17
+ const selectFolder = (id: string) => {
18
+ setSearchParams(
19
+ (prev) => {
20
+ const next = new URLSearchParams(prev);
21
+ if (id === DRAFT_ID) next.delete('f');
22
+ else next.set('f', id);
23
+ return next;
24
+ },
25
+ { replace: true },
26
+ );
27
+ };
28
+
29
+ const { draftSlides, slidesByFolder } = useMemo(() => {
30
+ const byFolder: Record<string, string[]> = {};
31
+ const draft: string[] = [];
32
+ const known = new Set(manifest.folders.map((f) => f.id));
33
+ for (const id of slideIds) {
34
+ const folderId = manifest.assignments[id];
35
+ if (folderId && known.has(folderId)) {
36
+ (byFolder[folderId] ??= []).push(id);
37
+ } else {
38
+ draft.push(id);
39
+ }
40
+ }
41
+ return { draftSlides: draft, slidesByFolder: byFolder };
42
+ }, [manifest]);
43
+
44
+ const countFor = (folderId: string | null) =>
45
+ folderId === null ? draftSlides.length : slidesByFolder[folderId]?.length ?? 0;
46
+
47
+ 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] ?? [];
51
+
52
+ const title = selectedFolder?.name ?? 'Draft';
53
+ const headerIcon = selectedFolder?.icon ?? { type: 'emoji' as const, value: '📝' };
54
+
10
55
  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>
56
+ <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
+
73
+ <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">
76
+ <FolderIconChip icon={headerIcon} className="size-6 text-xl" />
77
+ <h2 className="font-heading text-2xl font-bold tracking-tight">{title}</h2>
78
+ <span className="text-sm text-muted-foreground">
79
+ {visibleSlides.length} slide{visibleSlides.length === 1 ? '' : 's'}
80
+ </span>
81
+ </header>
82
+
83
+ {visibleSlides.length === 0 ? (
84
+ <EmptyState isDraft={selectedId === DRAFT_ID} folderName={selectedFolder?.name} />
85
+ ) : (
86
+ <ul className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-5">
87
+ {visibleSlides.map((id) => (
88
+ <li key={id}>
89
+ <SlideCard id={id} />
90
+ </li>
91
+ ))}
92
+ </ul>
93
+ )}
20
94
  </div>
21
- </header>
95
+ </div>
96
+ </div>
97
+ );
98
+ }
22
99
 
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>
100
+ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: string }) {
101
+ return (
102
+ <Card className="border-dashed">
103
+ <CardContent className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
104
+ <FolderPlus className="size-8 opacity-50" />
105
+ {isDraft ? (
106
+ <>
107
+ <p>No slides yet.</p>
28
108
  <p className="text-sm">
29
109
  Create{' '}
30
110
  <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
31
- slides/my-deck/index.tsx
111
+ slides/my-slide/index.tsx
32
112
  </code>{' '}
33
113
  with{' '}
34
114
  <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
@@ -36,28 +116,26 @@ export function Home() {
36
116
  </code>
37
117
  .
38
118
  </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>
119
+ </>
120
+ ) : (
121
+ <>
122
+ <p>No slides in {folderName ?? 'this folder'}.</p>
123
+ <p className="text-sm">Drag a slide from Draft into the sidebar folder.</p>
124
+ </>
125
+ )}
126
+ </CardContent>
127
+ </Card>
51
128
  );
52
129
  }
53
130
 
54
- function DeckCard({ id }: { id: string }) {
55
- const [deck, setDeck] = useState<DeckModule | null>(null);
131
+ function SlideCard({ id }: { id: string }) {
132
+ const [slide, setSlide] = useState<SlideModule | null>(null);
133
+ const [dragging, setDragging] = useState(false);
56
134
  useEffect(() => {
57
135
  let cancelled = false;
58
- loadDeck(id)
136
+ loadSlide(id)
59
137
  .then((mod) => {
60
- if (!cancelled) setDeck(mod);
138
+ if (!cancelled) setSlide(mod);
61
139
  })
62
140
  .catch(() => {});
63
141
  return () => {
@@ -65,34 +143,45 @@ function DeckCard({ id }: { id: string }) {
65
143
  };
66
144
  }, [id]);
67
145
 
68
- const FirstPage = deck?.default[0];
69
- const title = deck?.meta?.title ?? id;
70
- const pageCount = deck?.default.length ?? 0;
146
+ const FirstPage = slide?.default[0];
147
+ const title = slide?.meta?.title ?? id;
148
+ const pageCount = slide?.default.length ?? 0;
71
149
 
72
150
  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"
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' : ''}
76
160
  >
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
85
- </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
- )}
95
- </div>
96
- </Link>
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"
164
+ >
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
+ )}
175
+ </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
+ )}
183
+ </div>
184
+ </Link>
185
+ </div>
97
186
  );
98
187
  }