@open-slide/core 0.0.2 → 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.
- package/bin.js +2 -0
- package/dist/{build-DJGuOT6x.js → build-Cav2jYyI.js} +1 -1
- package/dist/cli/bin.js +5 -5
- package/dist/{config-Opp2R1Jf.js → config-g-uy_P5U.js} +218 -39
- package/dist/{dev-0SG0ArzD.js → dev-CFmlBbLh.js} +1 -1
- package/dist/index.d.ts +7 -9
- package/dist/{preview-61Aawrlg.js → preview-CotwHU_d.js} +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +5 -3
- package/src/app/App.tsx +2 -2
- package/src/app/components/Player.tsx +4 -4
- package/src/app/components/ThumbnailRail.tsx +5 -5
- package/src/app/components/inspector/InspectOverlay.tsx +4 -4
- package/src/app/components/inspector/InspectorProvider.tsx +5 -5
- package/src/app/components/sidebar/FolderItem.tsx +191 -0
- package/src/app/components/sidebar/IconPicker.tsx +59 -0
- package/src/app/components/sidebar/Sidebar.tsx +118 -0
- package/src/app/components/ui/dropdown-menu.tsx +257 -0
- package/src/app/components/ui/popover.tsx +87 -0
- package/src/app/components/ui/tabs.tsx +89 -0
- package/src/app/lib/folders.ts +130 -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 +20 -5
- package/src/app/lib/slides.ts +8 -0
- package/src/app/routes/Home.tsx +151 -62
- package/src/app/routes/{Deck.tsx → Slide.tsx} +17 -17
- package/src/app/virtual.d.ts +4 -4
- package/src/app/lib/decks.ts +0 -8
|
@@ -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,
|
|
25
|
-
const needle = `/slides/${
|
|
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(
|
|
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 (!
|
|
18
|
+
if (!slideId) return;
|
|
19
19
|
try {
|
|
20
|
-
const res = await fetch(`/__comments?
|
|
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
|
-
}, [
|
|
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({
|
|
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
|
-
[
|
|
46
|
+
[slideId, refetch],
|
|
47
47
|
);
|
|
48
48
|
|
|
49
49
|
const remove = useCallback(
|
|
50
50
|
async (id: string) => {
|
|
51
|
-
const res = await fetch(`/__comments/${id}?
|
|
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
|
-
[
|
|
57
|
+
[slideId, refetch],
|
|
58
58
|
);
|
|
59
59
|
|
|
60
60
|
useEffect(() => {
|
package/src/app/lib/sdk.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
2
|
|
|
3
|
-
export type
|
|
3
|
+
export type Page = ComponentType;
|
|
4
4
|
|
|
5
|
-
export type
|
|
5
|
+
export type SlideMeta = {
|
|
6
6
|
title?: string;
|
|
7
7
|
theme?: 'light' | 'dark';
|
|
8
8
|
};
|
|
9
9
|
|
|
10
|
-
export type
|
|
11
|
-
default:
|
|
12
|
-
meta?:
|
|
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;
|
package/src/app/routes/Home.tsx
CHANGED
|
@@ -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 {
|
|
5
|
-
import type {
|
|
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="
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
</
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
22
99
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
55
|
-
const [
|
|
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
|
-
|
|
136
|
+
loadSlide(id)
|
|
59
137
|
.then((mod) => {
|
|
60
|
-
if (!cancelled)
|
|
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 =
|
|
69
|
-
const title =
|
|
70
|
-
const pageCount =
|
|
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
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
<
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
</span>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
}
|