@open-slide/core 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-DSqSio-T.js → build-_276DMmJ.js} +2 -2
- package/dist/cli/bin.js +5 -5
- package/dist/{config-KdiYeWtK.js → config-BAwKWNtW.js} +888 -229
- package/dist/{config-C7vMYzFD.d.ts → config-D9cZ1A0X.d.ts} +2 -1
- package/dist/{dev-B_GVbr11.js → dev-BoqeVXVq.js} +2 -2
- package/dist/en-CDKzoZvf.js +351 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +229 -39
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +166 -326
- package/dist/{preview-D_mxhj7w.js → preview-BLPxspc9.js} +2 -2
- package/dist/sync-j9_QPovT.js +3 -0
- package/dist/{types-DYgVpIGo.d.ts → types-JYG1cmwC.d.ts} +59 -5
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +2 -2
- package/package.json +9 -1
- package/skills/create-slide/SKILL.md +1 -1
- package/skills/create-theme/SKILL.md +60 -12
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +59 -1
- package/src/app/app.tsx +11 -1
- package/src/app/components/asset-view.tsx +1 -13
- package/src/app/components/image-placeholder.tsx +123 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +64 -20
- package/src/app/components/inspector/inspector-panel.tsx +163 -19
- package/src/app/components/inspector/inspector-provider.tsx +60 -7
- package/src/app/components/notes-drawer.tsx +117 -0
- package/src/app/components/player.tsx +11 -7
- package/src/app/components/present/overview-grid.tsx +2 -2
- package/src/app/components/sidebar/folder-item.tsx +16 -5
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar.tsx +10 -0
- package/src/app/components/themes/theme-detail.tsx +300 -0
- package/src/app/components/themes/themes-gallery.tsx +146 -0
- package/src/app/components/thumbnail-rail.tsx +136 -29
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/lib/assets.ts +55 -2
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/sdk.ts +1 -0
- package/src/app/lib/slides.ts +10 -1
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/routes/home-shell.tsx +173 -0
- package/src/app/routes/home.tsx +108 -204
- package/src/app/routes/slide.tsx +333 -68
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/virtual.d.ts +20 -0
- package/src/locale/en.ts +61 -7
- package/src/locale/ja.ts +62 -7
- package/src/locale/types.ts +62 -5
- package/src/locale/zh-cn.ts +61 -7
- package/src/locale/zh-tw.ts +61 -7
- package/dist/sync-B4eLo2H6.js +0 -3
- /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
- /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
|
5
|
+
import { ContextMenu as ContextMenuPrimitive } from 'radix-ui';
|
|
6
|
+
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
|
|
9
|
+
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
|
10
|
+
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ContextMenuTrigger({
|
|
14
|
+
...props
|
|
15
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
|
16
|
+
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
|
20
|
+
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
|
24
|
+
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
|
28
|
+
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ContextMenuRadioGroup({
|
|
32
|
+
...props
|
|
33
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
|
34
|
+
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ContextMenuSubTrigger({
|
|
38
|
+
className,
|
|
39
|
+
inset,
|
|
40
|
+
children,
|
|
41
|
+
...props
|
|
42
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
|
43
|
+
inset?: boolean;
|
|
44
|
+
}) {
|
|
45
|
+
return (
|
|
46
|
+
<ContextMenuPrimitive.SubTrigger
|
|
47
|
+
data-slot="context-menu-sub-trigger"
|
|
48
|
+
data-inset={inset}
|
|
49
|
+
className={cn(
|
|
50
|
+
'flex cursor-default items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12.5px] outline-hidden select-none focus:bg-foreground focus:text-background data-[inset]:pl-8 data-[state=open]:bg-muted',
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
<ChevronRightIcon className="ml-auto size-3.5 opacity-60" />
|
|
57
|
+
</ContextMenuPrimitive.SubTrigger>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ContextMenuSubContent({
|
|
62
|
+
className,
|
|
63
|
+
...props
|
|
64
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
|
65
|
+
return (
|
|
66
|
+
<ContextMenuPrimitive.SubContent
|
|
67
|
+
data-slot="context-menu-sub-content"
|
|
68
|
+
className={cn(
|
|
69
|
+
'z-50 min-w-[9rem] overflow-hidden rounded-[8px] border border-border bg-popover p-1 text-popover-foreground shadow-floating',
|
|
70
|
+
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
|
71
|
+
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ContextMenuContent({
|
|
80
|
+
className,
|
|
81
|
+
...props
|
|
82
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
|
83
|
+
return (
|
|
84
|
+
<ContextMenuPrimitive.Portal>
|
|
85
|
+
<ContextMenuPrimitive.Content
|
|
86
|
+
data-slot="context-menu-content"
|
|
87
|
+
className={cn(
|
|
88
|
+
'z-50 max-h-(--radix-context-menu-content-available-height) min-w-[9rem] origin-(--radix-context-menu-content-transform-origin)',
|
|
89
|
+
'overflow-x-hidden overflow-y-auto rounded-[8px] border border-border bg-popover p-1 text-popover-foreground shadow-floating',
|
|
90
|
+
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
|
91
|
+
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
94
|
+
{...props}
|
|
95
|
+
/>
|
|
96
|
+
</ContextMenuPrimitive.Portal>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ContextMenuItem({
|
|
101
|
+
className,
|
|
102
|
+
inset,
|
|
103
|
+
variant = 'default',
|
|
104
|
+
...props
|
|
105
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
|
106
|
+
inset?: boolean;
|
|
107
|
+
variant?: 'default' | 'destructive';
|
|
108
|
+
}) {
|
|
109
|
+
return (
|
|
110
|
+
<ContextMenuPrimitive.Item
|
|
111
|
+
data-slot="context-menu-item"
|
|
112
|
+
data-inset={inset}
|
|
113
|
+
data-variant={variant}
|
|
114
|
+
className={cn(
|
|
115
|
+
'relative flex cursor-default items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12.5px] outline-hidden select-none transition-colors',
|
|
116
|
+
'focus:bg-foreground focus:text-background',
|
|
117
|
+
'data-[active=true]:bg-muted data-[active=true]:text-foreground',
|
|
118
|
+
'data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[inset]:pl-8',
|
|
119
|
+
'data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive data-[variant=destructive]:focus:text-white',
|
|
120
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-current [&_svg]:opacity-80",
|
|
121
|
+
className,
|
|
122
|
+
)}
|
|
123
|
+
{...props}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function ContextMenuCheckboxItem({
|
|
129
|
+
className,
|
|
130
|
+
children,
|
|
131
|
+
checked,
|
|
132
|
+
...props
|
|
133
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
|
134
|
+
return (
|
|
135
|
+
<ContextMenuPrimitive.CheckboxItem
|
|
136
|
+
data-slot="context-menu-checkbox-item"
|
|
137
|
+
className={cn(
|
|
138
|
+
'relative flex cursor-default items-center gap-2 rounded-[5px] py-1.5 pr-2 pl-8 text-[12.5px] outline-hidden select-none focus:bg-foreground focus:text-background data-[disabled]:pointer-events-none data-[disabled]:opacity-45',
|
|
139
|
+
className,
|
|
140
|
+
)}
|
|
141
|
+
checked={checked}
|
|
142
|
+
{...props}
|
|
143
|
+
>
|
|
144
|
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
145
|
+
<ContextMenuPrimitive.ItemIndicator>
|
|
146
|
+
<CheckIcon className="size-3.5" />
|
|
147
|
+
</ContextMenuPrimitive.ItemIndicator>
|
|
148
|
+
</span>
|
|
149
|
+
{children}
|
|
150
|
+
</ContextMenuPrimitive.CheckboxItem>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function ContextMenuRadioItem({
|
|
155
|
+
className,
|
|
156
|
+
children,
|
|
157
|
+
...props
|
|
158
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
|
159
|
+
return (
|
|
160
|
+
<ContextMenuPrimitive.RadioItem
|
|
161
|
+
data-slot="context-menu-radio-item"
|
|
162
|
+
className={cn(
|
|
163
|
+
'relative flex cursor-default items-center gap-2 rounded-[5px] py-1.5 pr-2 pl-8 text-[12.5px] outline-hidden select-none focus:bg-foreground focus:text-background data-[disabled]:pointer-events-none data-[disabled]:opacity-45',
|
|
164
|
+
className,
|
|
165
|
+
)}
|
|
166
|
+
{...props}
|
|
167
|
+
>
|
|
168
|
+
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
169
|
+
<ContextMenuPrimitive.ItemIndicator>
|
|
170
|
+
<CircleIcon className="size-2 fill-current" />
|
|
171
|
+
</ContextMenuPrimitive.ItemIndicator>
|
|
172
|
+
</span>
|
|
173
|
+
{children}
|
|
174
|
+
</ContextMenuPrimitive.RadioItem>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function ContextMenuLabel({
|
|
179
|
+
className,
|
|
180
|
+
inset,
|
|
181
|
+
...props
|
|
182
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
|
183
|
+
inset?: boolean;
|
|
184
|
+
}) {
|
|
185
|
+
return (
|
|
186
|
+
<ContextMenuPrimitive.Label
|
|
187
|
+
data-slot="context-menu-label"
|
|
188
|
+
data-inset={inset}
|
|
189
|
+
className={cn('eyebrow px-2 py-1.5 data-[inset]:pl-8', className)}
|
|
190
|
+
{...props}
|
|
191
|
+
/>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function ContextMenuSeparator({
|
|
196
|
+
className,
|
|
197
|
+
...props
|
|
198
|
+
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
|
199
|
+
return (
|
|
200
|
+
<ContextMenuPrimitive.Separator
|
|
201
|
+
data-slot="context-menu-separator"
|
|
202
|
+
className={cn('-mx-1 my-1 h-px bg-hairline', className)}
|
|
203
|
+
{...props}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
|
209
|
+
return (
|
|
210
|
+
<span
|
|
211
|
+
data-slot="context-menu-shortcut"
|
|
212
|
+
className={cn(
|
|
213
|
+
'ml-auto font-mono text-[10.5px] tracking-[0.06em] text-muted-foreground/80',
|
|
214
|
+
className,
|
|
215
|
+
)}
|
|
216
|
+
{...props}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export {
|
|
222
|
+
ContextMenu,
|
|
223
|
+
ContextMenuTrigger,
|
|
224
|
+
ContextMenuContent,
|
|
225
|
+
ContextMenuItem,
|
|
226
|
+
ContextMenuCheckboxItem,
|
|
227
|
+
ContextMenuRadioItem,
|
|
228
|
+
ContextMenuLabel,
|
|
229
|
+
ContextMenuSeparator,
|
|
230
|
+
ContextMenuShortcut,
|
|
231
|
+
ContextMenuGroup,
|
|
232
|
+
ContextMenuPortal,
|
|
233
|
+
ContextMenuSub,
|
|
234
|
+
ContextMenuSubContent,
|
|
235
|
+
ContextMenuSubTrigger,
|
|
236
|
+
ContextMenuRadioGroup,
|
|
237
|
+
};
|
package/src/app/lib/assets.ts
CHANGED
|
@@ -10,14 +10,14 @@ export type AssetEntry = {
|
|
|
10
10
|
|
|
11
11
|
export type UploadOptions = { overwrite?: boolean };
|
|
12
12
|
|
|
13
|
-
async function listAssets(slideId: string): Promise<AssetEntry[]> {
|
|
13
|
+
export async function listAssets(slideId: string): Promise<AssetEntry[]> {
|
|
14
14
|
const res = await fetch(`/__assets/${slideId}`);
|
|
15
15
|
if (!res.ok) throw new Error(`GET /__assets/${slideId} ${res.status}`);
|
|
16
16
|
const data = (await res.json()) as { assets?: AssetEntry[] };
|
|
17
17
|
return data.assets ?? [];
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
async function uploadAsset(
|
|
20
|
+
export async function uploadAsset(
|
|
21
21
|
slideId: string,
|
|
22
22
|
file: File,
|
|
23
23
|
opts: UploadOptions = {},
|
|
@@ -45,6 +45,59 @@ async function deleteAsset(slideId: string, name: string): Promise<Response> {
|
|
|
45
45
|
return fetch(`/__assets/${slideId}/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export async function uploadWithAutoRename(
|
|
49
|
+
slideId: string,
|
|
50
|
+
file: File,
|
|
51
|
+
): Promise<{ ok: boolean; status: number; entry: AssetEntry | null }> {
|
|
52
|
+
// Vite's default `assetsInclude` matches asset extensions case-sensitively,
|
|
53
|
+
// so `<img src="./assets/foo.JPG" />` (which the placeholder edit rewrites
|
|
54
|
+
// into a real `import`) fails to parse. Lowercase the extension so the
|
|
55
|
+
// import path is always one Vite recognizes.
|
|
56
|
+
let uploaded = lowercaseExtension(file);
|
|
57
|
+
let res = await uploadAsset(slideId, uploaded);
|
|
58
|
+
if (res.status === 409) {
|
|
59
|
+
const list = await listAssets(slideId);
|
|
60
|
+
const taken = new Set(list.map((a) => a.name));
|
|
61
|
+
uploaded = renamedCopy(uploaded, taken);
|
|
62
|
+
res = await uploadAsset(slideId, uploaded);
|
|
63
|
+
}
|
|
64
|
+
if (!res.ok) return { ok: false, status: res.status, entry: null };
|
|
65
|
+
const body = (await res.json().catch(() => null)) as Partial<AssetEntry> | null;
|
|
66
|
+
const entry: AssetEntry = {
|
|
67
|
+
name: body?.name ?? uploaded.name,
|
|
68
|
+
size: body?.size ?? uploaded.size,
|
|
69
|
+
mtime: body?.mtime ?? Date.now(),
|
|
70
|
+
mime: body?.mime ?? uploaded.type ?? 'application/octet-stream',
|
|
71
|
+
url: body?.url ?? `/__assets/${slideId}/${encodeURIComponent(uploaded.name)}`,
|
|
72
|
+
};
|
|
73
|
+
return { ok: true, status: res.status, entry };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function lowercaseExtension(file: File): File {
|
|
77
|
+
const dot = file.name.lastIndexOf('.');
|
|
78
|
+
if (dot <= 0) return file;
|
|
79
|
+
const ext = file.name.slice(dot);
|
|
80
|
+
const lower = ext.toLowerCase();
|
|
81
|
+
if (ext === lower) return file;
|
|
82
|
+
return new File([file], file.name.slice(0, dot) + lower, {
|
|
83
|
+
type: file.type,
|
|
84
|
+
lastModified: file.lastModified,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function renamedCopy(file: File, taken: Set<string>): File {
|
|
89
|
+
const dot = file.name.lastIndexOf('.');
|
|
90
|
+
const stem = dot > 0 ? file.name.slice(0, dot) : file.name;
|
|
91
|
+
const ext = dot > 0 ? file.name.slice(dot) : '';
|
|
92
|
+
let i = 1;
|
|
93
|
+
let next = `${stem}-${i}${ext}`;
|
|
94
|
+
while (taken.has(next)) {
|
|
95
|
+
i += 1;
|
|
96
|
+
next = `${stem}-${i}${ext}`;
|
|
97
|
+
}
|
|
98
|
+
return new File([file], next, { type: file.type, lastModified: file.lastModified });
|
|
99
|
+
}
|
|
100
|
+
|
|
48
101
|
export type SvglItem = {
|
|
49
102
|
id: number;
|
|
50
103
|
title: string;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export type NoteSaveStatus =
|
|
4
|
+
| { kind: 'idle' }
|
|
5
|
+
| { kind: 'saving' }
|
|
6
|
+
| { kind: 'saved' }
|
|
7
|
+
| { kind: 'error'; message: string };
|
|
8
|
+
|
|
9
|
+
const DEBOUNCE_MS = 600;
|
|
10
|
+
|
|
11
|
+
type Target = { slideId: string; index: number };
|
|
12
|
+
|
|
13
|
+
// HMR is suppressed for our writes, so the cached slide module's `notes`
|
|
14
|
+
// stays stale across navigation. Cache last-saved text per target so
|
|
15
|
+
// switching slides and back doesn't surface the old value.
|
|
16
|
+
const sessionCache = new Map<string, string>();
|
|
17
|
+
const cacheKey = (slideId: string, index: number) => `${slideId}:${index}`;
|
|
18
|
+
|
|
19
|
+
// Remap the per-target cache after a reorder. `order[i]` is the original
|
|
20
|
+
// page index that lands at new position `i`, matching the contract used by
|
|
21
|
+
// the `/__slides/:id/reorder` endpoint.
|
|
22
|
+
export function remapNotesSessionCacheAfterReorder(slideId: string, order: number[]): void {
|
|
23
|
+
const prev = new Map<number, string>();
|
|
24
|
+
for (let i = 0; i < order.length; i++) {
|
|
25
|
+
const cached = sessionCache.get(cacheKey(slideId, i));
|
|
26
|
+
if (cached !== undefined) prev.set(i, cached);
|
|
27
|
+
sessionCache.delete(cacheKey(slideId, i));
|
|
28
|
+
}
|
|
29
|
+
for (let newIdx = 0; newIdx < order.length; newIdx++) {
|
|
30
|
+
const oldIdx = order[newIdx];
|
|
31
|
+
const text = prev.get(oldIdx);
|
|
32
|
+
if (text !== undefined) sessionCache.set(cacheKey(slideId, newIdx), text);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function useNotes(slideId: string, index: number, initial: string | undefined) {
|
|
37
|
+
const initialText = sessionCache.get(cacheKey(slideId, index)) ?? initial ?? '';
|
|
38
|
+
const [value, setValueState] = useState(initialText);
|
|
39
|
+
const [status, setStatus] = useState<NoteSaveStatus>({ kind: 'idle' });
|
|
40
|
+
|
|
41
|
+
const lastSavedRef = useRef(initialText);
|
|
42
|
+
const dirtyRef = useRef(false);
|
|
43
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
const inflightRef = useRef<AbortController | null>(null);
|
|
45
|
+
const targetRef = useRef<Target>({ slideId, index });
|
|
46
|
+
const valueRef = useRef(value);
|
|
47
|
+
valueRef.current = value;
|
|
48
|
+
|
|
49
|
+
const cancelTimer = useCallback(() => {
|
|
50
|
+
if (timerRef.current != null) {
|
|
51
|
+
clearTimeout(timerRef.current);
|
|
52
|
+
timerRef.current = null;
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
const persist = useCallback(async (target: Target, text: string) => {
|
|
57
|
+
inflightRef.current?.abort();
|
|
58
|
+
const ctl = new AbortController();
|
|
59
|
+
inflightRef.current = ctl;
|
|
60
|
+
setStatus({ kind: 'saving' });
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch('/__notes', {
|
|
63
|
+
method: 'PUT',
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
body: JSON.stringify({ slideId: target.slideId, index: target.index, text }),
|
|
66
|
+
signal: ctl.signal,
|
|
67
|
+
});
|
|
68
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
69
|
+
if (!res.ok) throw new Error(body.error ?? `PUT /__notes → ${res.status}`);
|
|
70
|
+
sessionCache.set(cacheKey(target.slideId, target.index), text);
|
|
71
|
+
if (inflightRef.current !== ctl) return;
|
|
72
|
+
lastSavedRef.current = text;
|
|
73
|
+
dirtyRef.current = false;
|
|
74
|
+
setStatus({ kind: 'saved' });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if ((err as { name?: string }).name === 'AbortError') return;
|
|
77
|
+
setStatus({ kind: 'error', message: String((err as Error).message ?? err) });
|
|
78
|
+
} finally {
|
|
79
|
+
if (inflightRef.current === ctl) inflightRef.current = null;
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const flush = useCallback(async () => {
|
|
84
|
+
cancelTimer();
|
|
85
|
+
if (!dirtyRef.current) return;
|
|
86
|
+
const target = targetRef.current;
|
|
87
|
+
await persist(target, valueRef.current);
|
|
88
|
+
}, [cancelTimer, persist]);
|
|
89
|
+
|
|
90
|
+
// When the (slideId, index) target changes, flush pending edits for the
|
|
91
|
+
// previous target before adopting the new initial text.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const prev = targetRef.current;
|
|
94
|
+
const targetChanged = prev.slideId !== slideId || prev.index !== index;
|
|
95
|
+
if (targetChanged && dirtyRef.current) {
|
|
96
|
+
cancelTimer();
|
|
97
|
+
const pending = valueRef.current;
|
|
98
|
+
if (lastSavedRef.current !== pending) void persist(prev, pending);
|
|
99
|
+
}
|
|
100
|
+
targetRef.current = { slideId, index };
|
|
101
|
+
cancelTimer();
|
|
102
|
+
setValueState(initialText);
|
|
103
|
+
lastSavedRef.current = initialText;
|
|
104
|
+
dirtyRef.current = false;
|
|
105
|
+
setStatus({ kind: 'idle' });
|
|
106
|
+
}, [slideId, index, initialText, persist, cancelTimer]);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
return () => {
|
|
110
|
+
cancelTimer();
|
|
111
|
+
inflightRef.current?.abort();
|
|
112
|
+
};
|
|
113
|
+
}, [cancelTimer]);
|
|
114
|
+
|
|
115
|
+
const setValue = useCallback(
|
|
116
|
+
(next: string) => {
|
|
117
|
+
setValueState(next);
|
|
118
|
+
dirtyRef.current = next !== lastSavedRef.current;
|
|
119
|
+
cancelTimer();
|
|
120
|
+
if (!dirtyRef.current) {
|
|
121
|
+
setStatus({ kind: 'idle' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const target = targetRef.current;
|
|
125
|
+
timerRef.current = setTimeout(() => {
|
|
126
|
+
timerRef.current = null;
|
|
127
|
+
void persist(target, next);
|
|
128
|
+
}, DEBOUNCE_MS);
|
|
129
|
+
},
|
|
130
|
+
[persist, cancelTimer],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return { value, setValue, status, flush };
|
|
134
|
+
}
|
package/src/app/lib/sdk.ts
CHANGED
package/src/app/lib/slides.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
slideIds as ids,
|
|
3
|
+
loadSlide as load,
|
|
4
|
+
slideThemes as themes,
|
|
5
|
+
} from 'virtual:open-slide/slides';
|
|
2
6
|
import type { SlideModule } from './sdk';
|
|
3
7
|
|
|
4
8
|
export const slideIds: string[] = ids;
|
|
9
|
+
export const slideThemes: Record<string, string> = themes;
|
|
10
|
+
|
|
11
|
+
export function slidesByTheme(themeId: string): string[] {
|
|
12
|
+
return slideIds.filter((id) => slideThemes[id] === themeId);
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
export async function loadSlide(id: string): Promise<SlideModule> {
|
|
7
16
|
return load(id);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { loadThemeDemo as load, themes as raw } from 'virtual:open-slide/themes';
|
|
2
|
+
import type { DesignSystem } from './design';
|
|
3
|
+
import type { Page } from './sdk';
|
|
4
|
+
|
|
5
|
+
export type Theme = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
body: string;
|
|
10
|
+
hasDemo: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ThemeDemoModule = {
|
|
14
|
+
default: Page[];
|
|
15
|
+
design?: DesignSystem;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const themes: Theme[] = raw;
|
|
19
|
+
|
|
20
|
+
export async function loadThemeDemo(id: string): Promise<ThemeDemoModule> {
|
|
21
|
+
return load(id);
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useAgentSocketConnected() {
|
|
4
|
+
const [connected, setConnected] = useState(true);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const hot = import.meta.hot;
|
|
7
|
+
if (!hot) return;
|
|
8
|
+
const onConnect = () => setConnected(true);
|
|
9
|
+
const onDisconnect = () => setConnected(false);
|
|
10
|
+
hot.on('vite:ws:connect', onConnect);
|
|
11
|
+
hot.on('vite:ws:disconnect', onDisconnect);
|
|
12
|
+
return () => {
|
|
13
|
+
hot.off('vite:ws:connect', onConnect);
|
|
14
|
+
hot.off('vite:ws:disconnect', onDisconnect);
|
|
15
|
+
};
|
|
16
|
+
}, []);
|
|
17
|
+
return connected;
|
|
18
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { toast } from 'sonner';
|
|
4
|
+
import { useFolders } from '@/lib/folders';
|
|
5
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
6
|
+
import { MobileFolderPill } from '../components/sidebar/mobile-pill';
|
|
7
|
+
import { DRAFT_ID, Sidebar, THEMES_ID } from '../components/sidebar/sidebar';
|
|
8
|
+
import type { FoldersManifest } from '../lib/sdk';
|
|
9
|
+
import { slideIds } from '../lib/slides';
|
|
10
|
+
import { themes as themeRegistry } from '../lib/themes';
|
|
11
|
+
|
|
12
|
+
export type HomeOutletContext = {
|
|
13
|
+
manifest: FoldersManifest;
|
|
14
|
+
loading: boolean;
|
|
15
|
+
draftSlides: string[];
|
|
16
|
+
slidesByFolder: Record<string, string[]>;
|
|
17
|
+
/** Selected folder id when on `/`; equals DRAFT_ID, a folder id, or THEMES_ID. */
|
|
18
|
+
selectedId: string;
|
|
19
|
+
reportTitle: (slideId: string, title: string) => void;
|
|
20
|
+
titleMap: Record<string, string>;
|
|
21
|
+
assign: (slideId: string, folderId: string | null) => Promise<void>;
|
|
22
|
+
renameSlide: (slideId: string, name: string) => Promise<void>;
|
|
23
|
+
deleteSlide: (slideId: string) => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function pathToSelectedId(pathname: string, search: URLSearchParams): string {
|
|
27
|
+
if (pathname === '/themes' || pathname.startsWith('/themes/')) return THEMES_ID;
|
|
28
|
+
return search.get('f') ?? DRAFT_ID;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function HomeShell() {
|
|
32
|
+
const { manifest, loading, create, update, remove, assign, renameSlide, deleteSlide } =
|
|
33
|
+
useFolders();
|
|
34
|
+
const navigate = useNavigate();
|
|
35
|
+
const location = useLocation();
|
|
36
|
+
const [searchParams] = useSearchParams();
|
|
37
|
+
const t = useLocale();
|
|
38
|
+
|
|
39
|
+
const selectedId = pathToSelectedId(location.pathname, searchParams);
|
|
40
|
+
|
|
41
|
+
const [titleMap, setTitleMap] = useState<Record<string, string>>({});
|
|
42
|
+
const reportTitle = useCallback((slideId: string, slideTitle: string) => {
|
|
43
|
+
setTitleMap((prev) =>
|
|
44
|
+
prev[slideId] === slideTitle ? prev : { ...prev, [slideId]: slideTitle },
|
|
45
|
+
);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const selectFolder = useCallback(
|
|
49
|
+
(id: string) => {
|
|
50
|
+
if (id === THEMES_ID) navigate('/themes', { replace: true });
|
|
51
|
+
else if (id === DRAFT_ID) navigate('/', { replace: true });
|
|
52
|
+
else navigate(`/?f=${encodeURIComponent(id)}`, { replace: true });
|
|
53
|
+
},
|
|
54
|
+
[navigate],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const { draftSlides, slidesByFolder } = useMemo(() => {
|
|
58
|
+
const byFolder: Record<string, string[]> = {};
|
|
59
|
+
const draft: string[] = [];
|
|
60
|
+
const known = new Set(manifest.folders.map((f) => f.id));
|
|
61
|
+
for (const id of slideIds) {
|
|
62
|
+
const folderId = manifest.assignments[id];
|
|
63
|
+
if (folderId && known.has(folderId)) {
|
|
64
|
+
byFolder[folderId] ??= [];
|
|
65
|
+
byFolder[folderId].push(id);
|
|
66
|
+
} else {
|
|
67
|
+
draft.push(id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { draftSlides: draft, slidesByFolder: byFolder };
|
|
71
|
+
}, [manifest]);
|
|
72
|
+
|
|
73
|
+
const countFor = (folderId: string | null) =>
|
|
74
|
+
folderId === null ? draftSlides.length : (slidesByFolder[folderId]?.length ?? 0);
|
|
75
|
+
|
|
76
|
+
const moveSlideWithToast = useCallback(
|
|
77
|
+
async (slideId: string, folderId: string | null) => {
|
|
78
|
+
if (manifest.assignments[slideId] === (folderId ?? undefined)) return;
|
|
79
|
+
const slideName = titleMap[slideId] ?? slideId;
|
|
80
|
+
const folderName =
|
|
81
|
+
folderId === null
|
|
82
|
+
? t.home.draft
|
|
83
|
+
: (manifest.folders.find((f) => f.id === folderId)?.name ?? folderId);
|
|
84
|
+
try {
|
|
85
|
+
await assign(slideId, folderId);
|
|
86
|
+
toast.success(format(t.home.toastSlideMoved, { slide: slideName, folder: folderName }));
|
|
87
|
+
} catch {
|
|
88
|
+
toast.error(t.home.toastSlideMoveFailed);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
[assign, manifest, titleMap, t],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const ctx: HomeOutletContext = {
|
|
95
|
+
manifest,
|
|
96
|
+
loading,
|
|
97
|
+
draftSlides,
|
|
98
|
+
slidesByFolder,
|
|
99
|
+
selectedId,
|
|
100
|
+
reportTitle,
|
|
101
|
+
titleMap,
|
|
102
|
+
assign,
|
|
103
|
+
renameSlide,
|
|
104
|
+
deleteSlide,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div className="flex h-dvh overflow-hidden bg-background text-foreground">
|
|
109
|
+
<div className="hidden md:block">
|
|
110
|
+
<Sidebar
|
|
111
|
+
folders={manifest.folders}
|
|
112
|
+
countFor={countFor}
|
|
113
|
+
themesCount={themeRegistry.length}
|
|
114
|
+
selectedId={selectedId}
|
|
115
|
+
onSelect={selectFolder}
|
|
116
|
+
onCreate={(name, icon) => create(name, icon)}
|
|
117
|
+
onRename={(id, name) => update(id, { name })}
|
|
118
|
+
onChangeIcon={(id, icon) => update(id, { icon })}
|
|
119
|
+
onDelete={async (id) => {
|
|
120
|
+
const name = manifest.folders.find((f) => f.id === id)?.name ?? id;
|
|
121
|
+
if (selectedId === id) selectFolder(DRAFT_ID);
|
|
122
|
+
try {
|
|
123
|
+
await remove(id);
|
|
124
|
+
toast.success(format(t.home.toastFolderDeleted, { name }));
|
|
125
|
+
} catch {
|
|
126
|
+
toast.error(t.home.toastFolderDeleteFailed);
|
|
127
|
+
}
|
|
128
|
+
}}
|
|
129
|
+
onDropToFolder={(folderId, slideId) => moveSlideWithToast(slideId, folderId)}
|
|
130
|
+
onDropToDraft={(slideId) => moveSlideWithToast(slideId, null)}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div className="paper relative flex min-w-0 flex-1 flex-col overflow-y-auto bg-canvas">
|
|
135
|
+
<div className="flex items-center justify-between border-b border-hairline bg-sidebar px-4 py-3 md:hidden">
|
|
136
|
+
<h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
|
|
137
|
+
</div>
|
|
138
|
+
<div className="border-b border-hairline bg-sidebar px-4 py-2 md:hidden">
|
|
139
|
+
<div className="flex gap-2 overflow-x-auto pb-1">
|
|
140
|
+
<MobileFolderPill
|
|
141
|
+
icon={{ type: 'emoji', value: '📝' }}
|
|
142
|
+
label={t.home.draft}
|
|
143
|
+
count={countFor(null)}
|
|
144
|
+
active={selectedId === DRAFT_ID}
|
|
145
|
+
onClick={() => selectFolder(DRAFT_ID)}
|
|
146
|
+
/>
|
|
147
|
+
<MobileFolderPill
|
|
148
|
+
icon={{ type: 'emoji', value: '🎨' }}
|
|
149
|
+
label={t.home.themes}
|
|
150
|
+
count={themeRegistry.length}
|
|
151
|
+
active={selectedId === THEMES_ID}
|
|
152
|
+
onClick={() => selectFolder(THEMES_ID)}
|
|
153
|
+
/>
|
|
154
|
+
{manifest.folders.map((f) => (
|
|
155
|
+
<MobileFolderPill
|
|
156
|
+
key={f.id}
|
|
157
|
+
icon={f.icon}
|
|
158
|
+
label={f.name}
|
|
159
|
+
count={countFor(f.id)}
|
|
160
|
+
active={selectedId === f.id}
|
|
161
|
+
onClick={() => selectFolder(f.id)}
|
|
162
|
+
/>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<div className="mx-auto w-full max-w-[1180px] px-5 py-8 md:px-10 md:py-12">
|
|
168
|
+
<Outlet context={ctx} />
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|