@open-slide/core 1.2.0 → 1.4.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-6BeQ3cxb.js → build-1Rqivz0d.js} +2 -2
- package/dist/cli/bin.js +5 -5
- package/dist/{config-AxZ5OE1u.js → config-XZJnC_fu.js} +735 -64
- package/dist/{config-CtT8K4VF.d.ts → config-s0YUbmUe.d.ts} +3 -1
- package/dist/{dev-C9eLmUEq.js → dev-0W8gYiSa.js} +2 -2
- package/dist/en-7GU-DHbJ.js +361 -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 +136 -342
- package/dist/{preview-Cunm-f4i.js → preview-DT9hJvzM.js} +2 -2
- package/dist/sync-j9_QPovT.js +3 -0
- package/dist/{types-CRHIeoNq.d.ts → types-QCpkHkiS.d.ts} +42 -2
- 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/slide-authoring/SKILL.md +21 -2
- package/src/app/app.tsx +13 -1
- package/src/app/components/asset-view.tsx +37 -22
- package/src/app/components/image-placeholder.tsx +123 -1
- package/src/app/components/inspector/inspect-overlay.tsx +49 -3
- package/src/app/components/inspector/inspector-panel.tsx +370 -30
- package/src/app/components/inspector/inspector-provider.tsx +390 -49
- package/src/app/components/player.tsx +25 -5
- package/src/app/components/present/control-bar.tsx +12 -0
- package/src/app/components/sidebar/folder-item.tsx +27 -5
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar.tsx +20 -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 +17 -5
- package/src/app/lib/assets.ts +55 -2
- package/src/app/lib/export-pdf.ts +6 -0
- package/src/app/lib/inspector/use-editor.ts +9 -1
- package/src/app/lib/sdk.ts +1 -0
- package/src/app/lib/slides.ts +17 -1
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +194 -0
- package/src/app/routes/home.tsx +89 -207
- package/src/app/routes/presenter.tsx +2 -20
- package/src/app/routes/slide.tsx +217 -54
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/virtual.d.ts +20 -0
- package/src/locale/en.ts +49 -7
- package/src/locale/ja.ts +50 -7
- package/src/locale/types.ts +44 -2
- package/src/locale/zh-cn.ts +49 -8
- package/src/locale/zh-tw.ts +49 -8
- 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,146 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
3
|
+
import { loadThemeDemo, type Theme, type ThemeDemoModule, themes } from '../../lib/themes';
|
|
4
|
+
import { SlideCanvas } from '../slide-canvas';
|
|
5
|
+
|
|
6
|
+
export function ThemesGallery({ onOpen }: { onOpen: (id: string) => void }) {
|
|
7
|
+
const t = useLocale();
|
|
8
|
+
|
|
9
|
+
if (themes.length === 0) {
|
|
10
|
+
return <ThemesEmptyState />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<ul className="grid grid-cols-[repeat(auto-fill,minmax(min(240px,100%),1fr))] gap-x-6 gap-y-9 md:grid-cols-[repeat(auto-fill,minmax(340px,1fr))]">
|
|
15
|
+
{themes.map((theme) => (
|
|
16
|
+
<li key={theme.id}>
|
|
17
|
+
<ThemeCard
|
|
18
|
+
theme={theme}
|
|
19
|
+
onOpen={() => onOpen(theme.id)}
|
|
20
|
+
ariaLabel={format(t.themes.openThemeAria, { name: theme.name })}
|
|
21
|
+
/>
|
|
22
|
+
</li>
|
|
23
|
+
))}
|
|
24
|
+
</ul>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function ThemeCard({
|
|
29
|
+
theme,
|
|
30
|
+
onOpen,
|
|
31
|
+
ariaLabel,
|
|
32
|
+
}: {
|
|
33
|
+
theme: Theme;
|
|
34
|
+
onOpen: () => void;
|
|
35
|
+
ariaLabel: string;
|
|
36
|
+
}) {
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={onOpen}
|
|
41
|
+
aria-label={ariaLabel}
|
|
42
|
+
className="group block w-full text-left focus-visible:outline-none"
|
|
43
|
+
>
|
|
44
|
+
<div className="relative aspect-video overflow-hidden rounded-[6px] border border-hairline bg-card shadow-edge ring-1 ring-foreground/[0.04] group-hover:shadow-floating group-hover:ring-foreground/20 motion-safe:transition-[box-shadow,--tw-ring-color] motion-safe:duration-200">
|
|
45
|
+
<ThemePreview theme={theme} />
|
|
46
|
+
</div>
|
|
47
|
+
<div className="mt-3">
|
|
48
|
+
<h3 className="min-w-0 truncate font-heading text-[14px] font-medium tracking-tight">
|
|
49
|
+
{theme.name}
|
|
50
|
+
</h3>
|
|
51
|
+
</div>
|
|
52
|
+
{theme.description ? (
|
|
53
|
+
<p className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
|
|
54
|
+
{theme.description}
|
|
55
|
+
</p>
|
|
56
|
+
) : null}
|
|
57
|
+
</button>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ThemePreview({ theme }: { theme: Theme }) {
|
|
62
|
+
const t = useLocale();
|
|
63
|
+
const demo = useThemeDemo(theme);
|
|
64
|
+
|
|
65
|
+
if (!theme.hasDemo) {
|
|
66
|
+
return <NoDemoState />;
|
|
67
|
+
}
|
|
68
|
+
if (!demo) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="grid h-full w-full place-items-center text-[10px] tracking-[0.16em] uppercase text-muted-foreground/60">
|
|
71
|
+
{t.common.loading}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const FirstPage = demo.default[0];
|
|
76
|
+
if (!FirstPage) return <NoDemoState />;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
|
|
80
|
+
<SlideCanvas flat freezeMotion design={demo.design}>
|
|
81
|
+
<FirstPage />
|
|
82
|
+
</SlideCanvas>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function NoDemoState() {
|
|
88
|
+
const t = useLocale();
|
|
89
|
+
return (
|
|
90
|
+
<div className="grid h-full w-full place-items-center bg-muted/40 px-6 text-center">
|
|
91
|
+
<div>
|
|
92
|
+
<p className="font-heading text-[12px] font-semibold tracking-tight text-foreground/80">
|
|
93
|
+
{t.themes.noDemoYet}
|
|
94
|
+
</p>
|
|
95
|
+
<p className="mt-1 text-[10.5px] leading-snug text-muted-foreground">
|
|
96
|
+
{t.themes.noDemoHintPrefix}
|
|
97
|
+
<code className="rounded-[3px] bg-card px-1 py-0.5 font-mono text-[10px] text-foreground">
|
|
98
|
+
/create-theme
|
|
99
|
+
</code>
|
|
100
|
+
{t.themes.noDemoHintSuffix}
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ThemesEmptyState() {
|
|
108
|
+
const t = useLocale();
|
|
109
|
+
return (
|
|
110
|
+
<div className="rounded-[10px] border border-dashed border-border bg-card/60 px-8 py-20">
|
|
111
|
+
<div className="mx-auto flex max-w-md flex-col items-center text-center">
|
|
112
|
+
<div className="text-2xl">🎨</div>
|
|
113
|
+
<p className="mt-3 font-heading text-[15px] font-semibold tracking-tight">
|
|
114
|
+
{t.themes.noThemesTitle}
|
|
115
|
+
</p>
|
|
116
|
+
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
117
|
+
{t.themes.noThemesHintPrefix}
|
|
118
|
+
<code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
|
|
119
|
+
/create-theme
|
|
120
|
+
</code>
|
|
121
|
+
{t.themes.noThemesHintSuffix}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function useThemeDemo(theme: Theme): ThemeDemoModule | null {
|
|
129
|
+
const [demo, setDemo] = useState<ThemeDemoModule | null>(null);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (!theme.hasDemo) {
|
|
132
|
+
setDemo(null);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let cancelled = false;
|
|
136
|
+
loadThemeDemo(theme.id)
|
|
137
|
+
.then((mod) => {
|
|
138
|
+
if (!cancelled) setDemo(mod);
|
|
139
|
+
})
|
|
140
|
+
.catch(() => {});
|
|
141
|
+
return () => {
|
|
142
|
+
cancelled = true;
|
|
143
|
+
};
|
|
144
|
+
}, [theme.id, theme.hasDemo]);
|
|
145
|
+
return demo;
|
|
146
|
+
}
|
|
@@ -47,9 +47,13 @@ type Props = {
|
|
|
47
47
|
onReorder?: (from: number, to: number) => void;
|
|
48
48
|
actions?: ThumbnailActions;
|
|
49
49
|
orientation?: Orientation;
|
|
50
|
+
/** Vertical-only: total rail width in px. Thumbnails scale to fit. */
|
|
51
|
+
width?: number;
|
|
50
52
|
};
|
|
51
53
|
|
|
52
|
-
const
|
|
54
|
+
const DEFAULT_VERTICAL_THUMB_WIDTH = 184;
|
|
55
|
+
const VERTICAL_RAIL_CHROME = 80;
|
|
56
|
+
const MIN_VERTICAL_THUMB_WIDTH = 120;
|
|
53
57
|
const HORIZONTAL_THUMB_HEIGHT = 64;
|
|
54
58
|
|
|
55
59
|
export function ThumbnailRail({
|
|
@@ -60,6 +64,7 @@ export function ThumbnailRail({
|
|
|
60
64
|
onReorder,
|
|
61
65
|
actions,
|
|
62
66
|
orientation = 'vertical',
|
|
67
|
+
width,
|
|
63
68
|
}: Props) {
|
|
64
69
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
65
70
|
const t = useLocale();
|
|
@@ -105,7 +110,7 @@ export function ThumbnailRail({
|
|
|
105
110
|
</span>
|
|
106
111
|
<div
|
|
107
112
|
className={cn(
|
|
108
|
-
'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-
|
|
113
|
+
'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-[border-color,box-shadow]',
|
|
109
114
|
active
|
|
110
115
|
? 'border-brand shadow-[0_0_0_1px_var(--brand)]'
|
|
111
116
|
: 'border-hairline group-hover/thumb:border-foreground/25',
|
|
@@ -138,7 +143,11 @@ export function ThumbnailRail({
|
|
|
138
143
|
);
|
|
139
144
|
}
|
|
140
145
|
|
|
141
|
-
const
|
|
146
|
+
const thumbWidth =
|
|
147
|
+
width != null
|
|
148
|
+
? Math.max(MIN_VERTICAL_THUMB_WIDTH, width - VERTICAL_RAIL_CHROME)
|
|
149
|
+
: DEFAULT_VERTICAL_THUMB_WIDTH;
|
|
150
|
+
const scale = thumbWidth / CANVAS_WIDTH;
|
|
142
151
|
const height = CANVAS_HEIGHT * scale;
|
|
143
152
|
|
|
144
153
|
const renderThumb = (PageComp: Page, i: number) => {
|
|
@@ -150,6 +159,7 @@ export function ThumbnailRail({
|
|
|
150
159
|
page={PageComp}
|
|
151
160
|
design={design}
|
|
152
161
|
scale={scale}
|
|
162
|
+
thumbWidth={thumbWidth}
|
|
153
163
|
height={height}
|
|
154
164
|
/>
|
|
155
165
|
);
|
|
@@ -230,6 +240,7 @@ function ThumbContents({
|
|
|
230
240
|
page: PageComp,
|
|
231
241
|
design,
|
|
232
242
|
scale,
|
|
243
|
+
thumbWidth,
|
|
233
244
|
height,
|
|
234
245
|
}: {
|
|
235
246
|
index: number;
|
|
@@ -237,6 +248,7 @@ function ThumbContents({
|
|
|
237
248
|
page: Page;
|
|
238
249
|
design?: DesignSystem;
|
|
239
250
|
scale: number;
|
|
251
|
+
thumbWidth: number;
|
|
240
252
|
height: number;
|
|
241
253
|
}) {
|
|
242
254
|
return (
|
|
@@ -251,12 +263,12 @@ function ThumbContents({
|
|
|
251
263
|
</span>
|
|
252
264
|
<div
|
|
253
265
|
className={cn(
|
|
254
|
-
'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-
|
|
266
|
+
'relative shrink-0 overflow-hidden rounded-[4px] border bg-card motion-safe:transition-[border-color,box-shadow]',
|
|
255
267
|
active
|
|
256
268
|
? 'border-brand shadow-[0_0_0_1px_var(--brand)]'
|
|
257
269
|
: 'border-hairline group-hover/thumb:border-foreground/25',
|
|
258
270
|
)}
|
|
259
|
-
style={{ width:
|
|
271
|
+
style={{ width: thumbWidth, height }}
|
|
260
272
|
>
|
|
261
273
|
<SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
|
|
262
274
|
<PageComp />
|
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;
|
|
@@ -65,6 +65,12 @@ const PRINT_STYLES = `
|
|
|
65
65
|
}
|
|
66
66
|
`;
|
|
67
67
|
|
|
68
|
+
export function isSafari(): boolean {
|
|
69
|
+
if (typeof navigator === 'undefined') return false;
|
|
70
|
+
const ua = navigator.userAgent;
|
|
71
|
+
return /Safari/.test(ua) && !/Chrome|Chromium|Edg|OPR|Firefox/.test(ua);
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
export type PdfExportProgress = {
|
|
69
75
|
phase: 'processing' | 'printing' | 'done';
|
|
70
76
|
/** Number of pages whose intro animations have finished (0..total). */
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
2
|
|
|
3
3
|
export type EditOp =
|
|
4
|
-
| { kind: 'set-style'; key: string; value: string | null }
|
|
4
|
+
| { kind: 'set-style'; key: string; value: string | null; prevText?: string }
|
|
5
5
|
| { kind: 'set-text'; value: string; prevText?: string }
|
|
6
|
+
| {
|
|
7
|
+
kind: 'set-text-range-style';
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
key: string;
|
|
11
|
+
value: string | null;
|
|
12
|
+
prevText?: string;
|
|
13
|
+
}
|
|
6
14
|
| { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string }
|
|
7
15
|
| { kind: 'replace-placeholder-with-image'; assetPath: string };
|
|
8
16
|
|
package/src/app/lib/sdk.ts
CHANGED
package/src/app/lib/slides.ts
CHANGED
|
@@ -1,8 +1,24 @@
|
|
|
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);
|
|
8
17
|
}
|
|
18
|
+
|
|
19
|
+
export function slideChangeIncludes(data: unknown, slideId: string): boolean {
|
|
20
|
+
if (!data || typeof data !== 'object') return false;
|
|
21
|
+
const payload = data as { slideId?: unknown; slideIds?: unknown };
|
|
22
|
+
if (payload.slideId === slideId) return true;
|
|
23
|
+
return Array.isArray(payload.slideIds) && payload.slideIds.includes(slideId);
|
|
24
|
+
}
|
|
@@ -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,48 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { SlideModule } from './sdk';
|
|
3
|
+
import { loadSlide, slideChangeIncludes } from './slides';
|
|
4
|
+
|
|
5
|
+
export function useSlideModule(slideId: string) {
|
|
6
|
+
const [slide, setSlide] = useState<SlideModule | null>(null);
|
|
7
|
+
const [error, setError] = useState<string | null>(null);
|
|
8
|
+
const loadSeqRef = useRef(0);
|
|
9
|
+
|
|
10
|
+
const reload = useCallback(
|
|
11
|
+
(reset: boolean) => {
|
|
12
|
+
const seq = ++loadSeqRef.current;
|
|
13
|
+
if (reset) setSlide(null);
|
|
14
|
+
setError(null);
|
|
15
|
+
loadSlide(slideId)
|
|
16
|
+
.then((mod) => {
|
|
17
|
+
if (seq === loadSeqRef.current) setSlide(mod);
|
|
18
|
+
})
|
|
19
|
+
.catch((e) => {
|
|
20
|
+
if (seq === loadSeqRef.current) setError(String(e?.message ?? e));
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
[slideId],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
reload(true);
|
|
28
|
+
}, [reload]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!import.meta.hot) return;
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
const handler = (data: unknown) => {
|
|
34
|
+
if (slideChangeIncludes(data, slideId)) {
|
|
35
|
+
queueMicrotask(() => {
|
|
36
|
+
if (!cancelled) reload(false);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
import.meta.hot.on('open-slide:slide-changed', handler);
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
import.meta.hot?.off('open-slide:slide-changed', handler);
|
|
44
|
+
};
|
|
45
|
+
}, [slideId, reload]);
|
|
46
|
+
|
|
47
|
+
return { slide, error, reload };
|
|
48
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { toast } from 'sonner';
|
|
4
|
+
import { useAssets } from '@/lib/assets';
|
|
5
|
+
import { useFolders } from '@/lib/folders';
|
|
6
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
import { MobileFolderPill } from '../components/sidebar/mobile-pill';
|
|
9
|
+
import { ASSETS_ID, DRAFT_ID, Sidebar, THEMES_ID } from '../components/sidebar/sidebar';
|
|
10
|
+
import type { FoldersManifest } from '../lib/sdk';
|
|
11
|
+
import { slideIds } from '../lib/slides';
|
|
12
|
+
import { themes as themeRegistry } from '../lib/themes';
|
|
13
|
+
|
|
14
|
+
export type HomeOutletContext = {
|
|
15
|
+
manifest: FoldersManifest;
|
|
16
|
+
loading: boolean;
|
|
17
|
+
draftSlides: string[];
|
|
18
|
+
slidesByFolder: Record<string, string[]>;
|
|
19
|
+
/** Selected folder id when on `/`; equals DRAFT_ID, a folder id, or THEMES_ID. */
|
|
20
|
+
selectedId: string;
|
|
21
|
+
reportTitle: (slideId: string, title: string) => void;
|
|
22
|
+
titleMap: Record<string, string>;
|
|
23
|
+
assign: (slideId: string, folderId: string | null) => Promise<void>;
|
|
24
|
+
renameSlide: (slideId: string, name: string) => Promise<void>;
|
|
25
|
+
deleteSlide: (slideId: string) => Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function pathToSelectedId(pathname: string, search: URLSearchParams): string {
|
|
29
|
+
if (pathname === '/themes' || pathname.startsWith('/themes/')) return THEMES_ID;
|
|
30
|
+
if (pathname === '/assets') return ASSETS_ID;
|
|
31
|
+
return search.get('f') ?? DRAFT_ID;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function HomeShell() {
|
|
35
|
+
const { manifest, loading, create, update, remove, assign, renameSlide, deleteSlide } =
|
|
36
|
+
useFolders();
|
|
37
|
+
const navigate = useNavigate();
|
|
38
|
+
const location = useLocation();
|
|
39
|
+
const [searchParams] = useSearchParams();
|
|
40
|
+
const t = useLocale();
|
|
41
|
+
|
|
42
|
+
const selectedId = pathToSelectedId(location.pathname, searchParams);
|
|
43
|
+
|
|
44
|
+
const [titleMap, setTitleMap] = useState<Record<string, string>>({});
|
|
45
|
+
const reportTitle = useCallback((slideId: string, slideTitle: string) => {
|
|
46
|
+
setTitleMap((prev) =>
|
|
47
|
+
prev[slideId] === slideTitle ? prev : { ...prev, [slideId]: slideTitle },
|
|
48
|
+
);
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const selectFolder = useCallback(
|
|
52
|
+
(id: string) => {
|
|
53
|
+
if (id === THEMES_ID) navigate('/themes', { replace: true });
|
|
54
|
+
else if (id === ASSETS_ID) navigate('/assets', { replace: true });
|
|
55
|
+
else if (id === DRAFT_ID) navigate('/', { replace: true });
|
|
56
|
+
else navigate(`/?f=${encodeURIComponent(id)}`, { replace: true });
|
|
57
|
+
},
|
|
58
|
+
[navigate],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const { assets: globalAssets } = useAssets('@global');
|
|
62
|
+
const isAssetsRoute = location.pathname === '/assets';
|
|
63
|
+
|
|
64
|
+
const { draftSlides, slidesByFolder } = useMemo(() => {
|
|
65
|
+
const byFolder: Record<string, string[]> = {};
|
|
66
|
+
const draft: string[] = [];
|
|
67
|
+
const known = new Set(manifest.folders.map((f) => f.id));
|
|
68
|
+
for (const id of slideIds) {
|
|
69
|
+
const folderId = manifest.assignments[id];
|
|
70
|
+
if (folderId && known.has(folderId)) {
|
|
71
|
+
byFolder[folderId] ??= [];
|
|
72
|
+
byFolder[folderId].push(id);
|
|
73
|
+
} else {
|
|
74
|
+
draft.push(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { draftSlides: draft, slidesByFolder: byFolder };
|
|
78
|
+
}, [manifest]);
|
|
79
|
+
|
|
80
|
+
const countFor = (folderId: string | null) =>
|
|
81
|
+
folderId === null ? draftSlides.length : (slidesByFolder[folderId]?.length ?? 0);
|
|
82
|
+
|
|
83
|
+
const moveSlideWithToast = useCallback(
|
|
84
|
+
async (slideId: string, folderId: string | null) => {
|
|
85
|
+
if (manifest.assignments[slideId] === (folderId ?? undefined)) return;
|
|
86
|
+
const slideName = titleMap[slideId] ?? slideId;
|
|
87
|
+
const folderName =
|
|
88
|
+
folderId === null
|
|
89
|
+
? t.home.draft
|
|
90
|
+
: (manifest.folders.find((f) => f.id === folderId)?.name ?? folderId);
|
|
91
|
+
try {
|
|
92
|
+
await assign(slideId, folderId);
|
|
93
|
+
toast.success(format(t.home.toastSlideMoved, { slide: slideName, folder: folderName }));
|
|
94
|
+
} catch {
|
|
95
|
+
toast.error(t.home.toastSlideMoveFailed);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[assign, manifest, titleMap, t],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const ctx: HomeOutletContext = {
|
|
102
|
+
manifest,
|
|
103
|
+
loading,
|
|
104
|
+
draftSlides,
|
|
105
|
+
slidesByFolder,
|
|
106
|
+
selectedId,
|
|
107
|
+
reportTitle,
|
|
108
|
+
titleMap,
|
|
109
|
+
assign,
|
|
110
|
+
renameSlide,
|
|
111
|
+
deleteSlide,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="flex h-dvh overflow-hidden bg-background text-foreground">
|
|
116
|
+
<div className="hidden md:block">
|
|
117
|
+
<Sidebar
|
|
118
|
+
folders={manifest.folders}
|
|
119
|
+
countFor={countFor}
|
|
120
|
+
themesCount={themeRegistry.length}
|
|
121
|
+
assetsCount={globalAssets.length}
|
|
122
|
+
selectedId={selectedId}
|
|
123
|
+
onSelect={selectFolder}
|
|
124
|
+
onCreate={(name, icon) => create(name, icon)}
|
|
125
|
+
onRename={(id, name) => update(id, { name })}
|
|
126
|
+
onChangeIcon={(id, icon) => update(id, { icon })}
|
|
127
|
+
onDelete={async (id) => {
|
|
128
|
+
const name = manifest.folders.find((f) => f.id === id)?.name ?? id;
|
|
129
|
+
if (selectedId === id) selectFolder(DRAFT_ID);
|
|
130
|
+
try {
|
|
131
|
+
await remove(id);
|
|
132
|
+
toast.success(format(t.home.toastFolderDeleted, { name }));
|
|
133
|
+
} catch {
|
|
134
|
+
toast.error(t.home.toastFolderDeleteFailed);
|
|
135
|
+
}
|
|
136
|
+
}}
|
|
137
|
+
onDropToFolder={(folderId, slideId) => moveSlideWithToast(slideId, folderId)}
|
|
138
|
+
onDropToDraft={(slideId) => moveSlideWithToast(slideId, null)}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<div className="paper relative flex min-w-0 flex-1 flex-col overflow-y-auto bg-canvas">
|
|
143
|
+
<div className="flex items-center justify-between border-b border-hairline bg-sidebar px-4 py-3 md:hidden">
|
|
144
|
+
<h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="border-b border-hairline bg-sidebar px-4 py-2 md:hidden">
|
|
147
|
+
<div className="flex gap-2 overflow-x-auto pb-1">
|
|
148
|
+
<MobileFolderPill
|
|
149
|
+
icon={{ type: 'emoji', value: '📝' }}
|
|
150
|
+
label={t.home.draft}
|
|
151
|
+
count={countFor(null)}
|
|
152
|
+
active={selectedId === DRAFT_ID}
|
|
153
|
+
onClick={() => selectFolder(DRAFT_ID)}
|
|
154
|
+
/>
|
|
155
|
+
<MobileFolderPill
|
|
156
|
+
icon={{ type: 'emoji', value: '🎨' }}
|
|
157
|
+
label={t.home.themes}
|
|
158
|
+
count={themeRegistry.length}
|
|
159
|
+
active={selectedId === THEMES_ID}
|
|
160
|
+
onClick={() => selectFolder(THEMES_ID)}
|
|
161
|
+
/>
|
|
162
|
+
<MobileFolderPill
|
|
163
|
+
icon={{ type: 'emoji', value: '🗂️' }}
|
|
164
|
+
label={t.home.assets}
|
|
165
|
+
count={globalAssets.length}
|
|
166
|
+
active={selectedId === ASSETS_ID}
|
|
167
|
+
onClick={() => selectFolder(ASSETS_ID)}
|
|
168
|
+
/>
|
|
169
|
+
{manifest.folders.map((f) => (
|
|
170
|
+
<MobileFolderPill
|
|
171
|
+
key={f.id}
|
|
172
|
+
icon={f.icon}
|
|
173
|
+
label={f.name}
|
|
174
|
+
count={countFor(f.id)}
|
|
175
|
+
active={selectedId === f.id}
|
|
176
|
+
onClick={() => selectFolder(f.id)}
|
|
177
|
+
/>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div
|
|
183
|
+
className={cn(
|
|
184
|
+
isAssetsRoute
|
|
185
|
+
? 'flex min-h-0 flex-1 flex-col'
|
|
186
|
+
: 'mx-auto w-full max-w-[1180px] px-5 py-8 md:px-10 md:py-12',
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
<Outlet context={ctx} />
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|