@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.
Files changed (55) hide show
  1. package/dist/{build-DSqSio-T.js → build-_276DMmJ.js} +2 -2
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-KdiYeWtK.js → config-BAwKWNtW.js} +888 -229
  4. package/dist/{config-C7vMYzFD.d.ts → config-D9cZ1A0X.d.ts} +2 -1
  5. package/dist/{dev-B_GVbr11.js → dev-BoqeVXVq.js} +2 -2
  6. package/dist/en-CDKzoZvf.js +351 -0
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +229 -39
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +166 -326
  11. package/dist/{preview-D_mxhj7w.js → preview-BLPxspc9.js} +2 -2
  12. package/dist/sync-j9_QPovT.js +3 -0
  13. package/dist/{types-DYgVpIGo.d.ts → types-JYG1cmwC.d.ts} +59 -5
  14. package/dist/vite/index.d.ts +2 -2
  15. package/dist/vite/index.js +2 -2
  16. package/package.json +9 -1
  17. package/skills/create-slide/SKILL.md +1 -1
  18. package/skills/create-theme/SKILL.md +60 -12
  19. package/skills/current-slide/SKILL.md +110 -0
  20. package/skills/slide-authoring/SKILL.md +59 -1
  21. package/src/app/app.tsx +11 -1
  22. package/src/app/components/asset-view.tsx +1 -13
  23. package/src/app/components/image-placeholder.tsx +123 -1
  24. package/src/app/components/inspector/image-crop-dialog.tsx +64 -20
  25. package/src/app/components/inspector/inspector-panel.tsx +163 -19
  26. package/src/app/components/inspector/inspector-provider.tsx +60 -7
  27. package/src/app/components/notes-drawer.tsx +117 -0
  28. package/src/app/components/player.tsx +11 -7
  29. package/src/app/components/present/overview-grid.tsx +2 -2
  30. package/src/app/components/sidebar/folder-item.tsx +16 -5
  31. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  32. package/src/app/components/sidebar/sidebar.tsx +10 -0
  33. package/src/app/components/themes/theme-detail.tsx +300 -0
  34. package/src/app/components/themes/themes-gallery.tsx +146 -0
  35. package/src/app/components/thumbnail-rail.tsx +136 -29
  36. package/src/app/components/ui/context-menu.tsx +237 -0
  37. package/src/app/lib/assets.ts +55 -2
  38. package/src/app/lib/inspector/use-notes.ts +134 -0
  39. package/src/app/lib/sdk.ts +1 -0
  40. package/src/app/lib/slides.ts +10 -1
  41. package/src/app/lib/themes.ts +22 -0
  42. package/src/app/lib/use-agent-socket.ts +18 -0
  43. package/src/app/routes/home-shell.tsx +173 -0
  44. package/src/app/routes/home.tsx +108 -204
  45. package/src/app/routes/slide.tsx +333 -68
  46. package/src/app/routes/themes.tsx +34 -0
  47. package/src/app/virtual.d.ts +20 -0
  48. package/src/locale/en.ts +61 -7
  49. package/src/locale/ja.ts +62 -7
  50. package/src/locale/types.ts +62 -5
  51. package/src/locale/zh-cn.ts +61 -7
  52. package/src/locale/zh-tw.ts +61 -7
  53. package/dist/sync-B4eLo2H6.js +0 -3
  54. /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
  55. /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
+ };
@@ -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
+ }
@@ -5,6 +5,7 @@ export type Page = ComponentType;
5
5
 
6
6
  export type SlideMeta = {
7
7
  title?: string;
8
+ theme?: string;
8
9
  };
9
10
 
10
11
  export type SlideModule = {
@@ -1,7 +1,16 @@
1
- import { slideIds as ids, loadSlide as load } from 'virtual:open-slide/slides';
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
+ }