@open-slide/core 1.2.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 (47) hide show
  1. package/dist/{build-6BeQ3cxb.js → build-_276DMmJ.js} +2 -2
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-AxZ5OE1u.js → config-BAwKWNtW.js} +215 -18
  4. package/dist/{config-CtT8K4VF.d.ts → config-D9cZ1A0X.d.ts} +2 -1
  5. package/dist/{dev-C9eLmUEq.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 +97 -333
  11. package/dist/{preview-Cunm-f4i.js → preview-BLPxspc9.js} +2 -2
  12. package/dist/sync-j9_QPovT.js +3 -0
  13. package/dist/{types-CRHIeoNq.d.ts → types-JYG1cmwC.d.ts} +31 -1
  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/slide-authoring/SKILL.md +11 -0
  20. package/src/app/app.tsx +11 -1
  21. package/src/app/components/asset-view.tsx +1 -13
  22. package/src/app/components/image-placeholder.tsx +123 -1
  23. package/src/app/components/inspector/inspector-panel.tsx +123 -10
  24. package/src/app/components/sidebar/folder-item.tsx +16 -5
  25. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  26. package/src/app/components/sidebar/sidebar.tsx +10 -0
  27. package/src/app/components/themes/theme-detail.tsx +300 -0
  28. package/src/app/components/themes/themes-gallery.tsx +146 -0
  29. package/src/app/components/thumbnail-rail.tsx +17 -5
  30. package/src/app/lib/assets.ts +55 -2
  31. package/src/app/lib/sdk.ts +1 -0
  32. package/src/app/lib/slides.ts +10 -1
  33. package/src/app/lib/themes.ts +22 -0
  34. package/src/app/lib/use-agent-socket.ts +18 -0
  35. package/src/app/routes/home-shell.tsx +173 -0
  36. package/src/app/routes/home.tsx +89 -207
  37. package/src/app/routes/slide.tsx +144 -14
  38. package/src/app/routes/themes.tsx +34 -0
  39. package/src/app/virtual.d.ts +20 -0
  40. package/src/locale/en.ts +35 -3
  41. package/src/locale/ja.ts +36 -3
  42. package/src/locale/types.ts +33 -1
  43. package/src/locale/zh-cn.ts +35 -3
  44. package/src/locale/zh-tw.ts +35 -3
  45. package/dist/sync-B4eLo2H6.js +0 -3
  46. /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
  47. /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
@@ -9,10 +9,12 @@ import { FolderIconChip, FolderItem } from './folder-item';
9
9
  import { IconPicker, PRESET_COLORS } from './icon-picker';
10
10
 
11
11
  export const DRAFT_ID = 'draft';
12
+ export const THEMES_ID = '__themes__';
12
13
 
13
14
  export function Sidebar({
14
15
  folders,
15
16
  countFor,
17
+ themesCount,
16
18
  selectedId,
17
19
  onSelect,
18
20
  onCreate,
@@ -24,6 +26,7 @@ export function Sidebar({
24
26
  }: {
25
27
  folders: Folder[];
26
28
  countFor: (folderId: string | null) => number;
29
+ themesCount: number;
27
30
  selectedId: string;
28
31
  onSelect: (id: string) => void;
29
32
  onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
@@ -111,6 +114,13 @@ export function Sidebar({
111
114
  onSelect={() => onSelect(DRAFT_ID)}
112
115
  onDropSlide={onDropToDraft}
113
116
  />
117
+ <FolderItem
118
+ row={{ kind: 'themes' }}
119
+ count={themesCount}
120
+ selected={selectedId === THEMES_ID}
121
+ onSelect={() => onSelect(THEMES_ID)}
122
+ onDropSlide={() => {}}
123
+ />
114
124
  </div>
115
125
 
116
126
  <div className="mt-5 flex items-center gap-2 px-4 pb-1.5">
@@ -0,0 +1,300 @@
1
+ import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
2
+ import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Link } from 'react-router-dom';
4
+ import { Button } from '@/components/ui/button';
5
+ import { format, useLocale } from '@/lib/use-locale';
6
+ import { cn } from '@/lib/utils';
7
+ import type { SlideModule } from '../../lib/sdk';
8
+ import { loadSlide, slidesByTheme } from '../../lib/slides';
9
+ import { loadThemeDemo, type ThemeDemoModule, themes } from '../../lib/themes';
10
+ import { SlideCanvas } from '../slide-canvas';
11
+
12
+ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () => void }) {
13
+ const t = useLocale();
14
+ const theme = useMemo(() => themes.find((th) => th.id === themeId), [themeId]);
15
+ const [demo, setDemo] = useState<ThemeDemoModule | null>(null);
16
+ const [pageIndex, setPageIndex] = useState(0);
17
+
18
+ useEffect(() => {
19
+ setPageIndex(0);
20
+ setDemo(null);
21
+ if (!theme?.hasDemo) return;
22
+ let cancelled = false;
23
+ loadThemeDemo(theme.id)
24
+ .then((mod) => {
25
+ if (!cancelled) setDemo(mod);
26
+ })
27
+ .catch(() => {});
28
+ return () => {
29
+ cancelled = true;
30
+ };
31
+ }, [theme]);
32
+
33
+ const pages = demo?.default ?? [];
34
+ const totalPages = pages.length;
35
+ const usedBySlideIds = useMemo(() => (theme ? slidesByTheme(theme.id) : []), [theme]);
36
+
37
+ const promptRef = useRef<HTMLPreElement>(null);
38
+ const [promptExpanded, setPromptExpanded] = useState(false);
39
+ const [promptOverflows, setPromptOverflows] = useState(false);
40
+
41
+ const themeBody = theme?.body;
42
+ useEffect(() => {
43
+ setPromptExpanded(false);
44
+ const el = promptRef.current;
45
+ if (!el || !themeBody) return;
46
+ setPromptOverflows(el.scrollHeight > PROMPT_COLLAPSED_PX + 8);
47
+ }, [themeBody]);
48
+
49
+ useEffect(() => {
50
+ if (totalPages <= 1) return;
51
+ const onKey = (e: KeyboardEvent) => {
52
+ const tag = (e.target as HTMLElement | null)?.tagName;
53
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
54
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
55
+ setPageIndex((i) => Math.min(totalPages - 1, i + 1));
56
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
57
+ setPageIndex((i) => Math.max(0, i - 1));
58
+ }
59
+ };
60
+ window.addEventListener('keydown', onKey);
61
+ return () => window.removeEventListener('keydown', onKey);
62
+ }, [totalPages]);
63
+
64
+ if (!theme) {
65
+ return (
66
+ <div className="px-8 py-12">
67
+ <Button variant="ghost" size="sm" onClick={onBack}>
68
+ <ChevronLeft className="size-4" />
69
+ {t.themes.backToGallery}
70
+ </Button>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ const Current = pages[pageIndex];
76
+
77
+ return (
78
+ <div className="flex flex-col gap-6 md:gap-8">
79
+ <div className="flex items-center gap-3">
80
+ <Button variant="ghost" size="sm" onClick={onBack} className="-ml-2">
81
+ <ChevronLeft className="size-4" />
82
+ {t.themes.backToGallery}
83
+ </Button>
84
+ </div>
85
+
86
+ <header className="flex flex-wrap items-baseline gap-3">
87
+ <h2 className="font-heading text-[26px] font-semibold leading-[1.05] tracking-[-0.025em] md:text-[32px]">
88
+ {theme.name}
89
+ </h2>
90
+ {theme.description ? (
91
+ <p className="basis-full text-[13px] leading-relaxed text-muted-foreground">
92
+ {theme.description}
93
+ </p>
94
+ ) : null}
95
+ </header>
96
+
97
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:gap-8">
98
+ <div className="flex min-w-0 flex-col gap-6">
99
+ <div className="flex flex-col gap-3">
100
+ <div className="relative aspect-video overflow-hidden rounded-[8px] border border-hairline bg-card shadow-edge ring-1 ring-foreground/[0.04]">
101
+ {!theme.hasDemo ? (
102
+ <NoDemoLargeState />
103
+ ) : !demo ? (
104
+ <div className="grid h-full w-full place-items-center text-[11px] tracking-[0.16em] uppercase text-muted-foreground/60">
105
+ {t.common.loading}
106
+ </div>
107
+ ) : Current ? (
108
+ <SlideCanvas flat freezeMotion design={demo.design}>
109
+ <Current />
110
+ </SlideCanvas>
111
+ ) : null}
112
+ </div>
113
+
114
+ {totalPages > 1 ? (
115
+ <div className="flex items-center justify-between gap-2">
116
+ <button
117
+ type="button"
118
+ aria-label={t.themes.prevPageAria}
119
+ disabled={pageIndex === 0}
120
+ onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
121
+ className="flex size-8 items-center justify-center rounded-[6px] border border-border bg-card text-foreground transition-colors hover:bg-muted disabled:opacity-40"
122
+ >
123
+ <ChevronLeft className="size-4" />
124
+ </button>
125
+ <span className="folio">
126
+ {format(t.themes.pageOf, { n: pageIndex + 1, total: totalPages })}
127
+ </span>
128
+ <button
129
+ type="button"
130
+ aria-label={t.themes.nextPageAria}
131
+ disabled={pageIndex === totalPages - 1}
132
+ onClick={() => setPageIndex((i) => Math.min(totalPages - 1, i + 1))}
133
+ className="flex size-8 items-center justify-center rounded-[6px] border border-border bg-card text-foreground transition-colors hover:bg-muted disabled:opacity-40"
134
+ >
135
+ <ChevronRight className="size-4" />
136
+ </button>
137
+ </div>
138
+ ) : null}
139
+ </div>
140
+
141
+ <div className="relative">
142
+ <pre
143
+ ref={promptRef}
144
+ style={
145
+ promptOverflows && !promptExpanded ? { maxHeight: PROMPT_COLLAPSED_PX } : undefined
146
+ }
147
+ className={cn(
148
+ 'w-full rounded-[8px] border border-hairline bg-card p-4 font-mono text-[11.5px] leading-relaxed text-foreground/90',
149
+ promptOverflows && !promptExpanded ? 'overflow-hidden' : 'overflow-auto',
150
+ )}
151
+ >
152
+ {renderBodyWithSwatches(theme.body)}
153
+ </pre>
154
+ {promptOverflows && !promptExpanded ? (
155
+ <button
156
+ type="button"
157
+ aria-label={t.themes.expandPromptAria}
158
+ onClick={() => setPromptExpanded(true)}
159
+ className="absolute inset-x-0 bottom-0 flex h-24 items-end justify-center rounded-b-[8px] bg-gradient-to-t from-card via-card/85 to-transparent pb-3 text-muted-foreground transition-colors hover:text-foreground"
160
+ >
161
+ <ChevronDown className="size-4" />
162
+ </button>
163
+ ) : null}
164
+ {promptOverflows && promptExpanded ? (
165
+ <div className="mt-2 flex justify-center">
166
+ <button
167
+ type="button"
168
+ aria-label={t.themes.collapsePromptAria}
169
+ onClick={() => setPromptExpanded(false)}
170
+ className="flex size-8 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
171
+ >
172
+ <ChevronDown className="size-4 rotate-180" />
173
+ </button>
174
+ </div>
175
+ ) : null}
176
+ </div>
177
+ </div>
178
+
179
+ <aside className="flex min-w-0 flex-col gap-4">
180
+ <div className="flex flex-wrap items-baseline gap-3">
181
+ <span className="eyebrow">{t.themes.usedBy}</span>
182
+ {usedBySlideIds.length > 0 ? (
183
+ <span className="folio">{usedBySlideIds.length.toString().padStart(2, '0')}</span>
184
+ ) : null}
185
+ </div>
186
+ {usedBySlideIds.length === 0 ? (
187
+ <p className="text-[12.5px] leading-relaxed text-muted-foreground">
188
+ {t.themes.usedByEmpty}
189
+ </p>
190
+ ) : (
191
+ <ul className="flex flex-col gap-5">
192
+ {usedBySlideIds.map((id) => (
193
+ <li key={id}>
194
+ <ThemeSlideCard id={id} />
195
+ </li>
196
+ ))}
197
+ </ul>
198
+ )}
199
+ </aside>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
204
+
205
+ function ThemeSlideCard({ id }: { id: string }) {
206
+ const t = useLocale();
207
+ const [slide, setSlide] = useState<SlideModule | null>(null);
208
+
209
+ useEffect(() => {
210
+ let cancelled = false;
211
+ loadSlide(id)
212
+ .then((mod) => {
213
+ if (!cancelled) setSlide(mod);
214
+ })
215
+ .catch(() => {});
216
+ return () => {
217
+ cancelled = true;
218
+ };
219
+ }, [id]);
220
+
221
+ const FirstPage = slide?.default[0];
222
+ const displayTitle = slide?.meta?.title ?? id;
223
+
224
+ return (
225
+ <Link to={`/s/${id}`} className="group block focus-visible:outline-none">
226
+ <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">
227
+ {FirstPage ? (
228
+ <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
229
+ <SlideCanvas flat freezeMotion design={slide?.design}>
230
+ <FirstPage />
231
+ </SlideCanvas>
232
+ </div>
233
+ ) : (
234
+ <div className="grid h-full w-full place-items-center text-[10px] tracking-[0.16em] uppercase text-muted-foreground/60">
235
+ {t.common.loading}
236
+ </div>
237
+ )}
238
+ </div>
239
+ <div className="mt-2.5">
240
+ <h3 className="min-w-0 truncate font-heading text-[13px] font-medium tracking-tight">
241
+ {displayTitle}
242
+ </h3>
243
+ <p className="mt-0.5 truncate font-mono text-[10.5px] text-muted-foreground/80">{id}</p>
244
+ </div>
245
+ </Link>
246
+ );
247
+ }
248
+
249
+ const PROMPT_COLLAPSED_PX = 320;
250
+
251
+ const HEX_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
252
+
253
+ function renderBodyWithSwatches(body: string): ReactNode[] {
254
+ const out: ReactNode[] = [];
255
+ let lastIndex = 0;
256
+ let match: RegExpExecArray | null = HEX_RE.exec(body);
257
+ let key = 0;
258
+ while (match !== null) {
259
+ if (match.index > lastIndex) {
260
+ out.push(<Fragment key={`t${key}`}>{body.slice(lastIndex, match.index)}</Fragment>);
261
+ }
262
+ const hex = match[0];
263
+ out.push(
264
+ <span
265
+ key={`s${key}`}
266
+ aria-hidden
267
+ className="mr-[0.25em] -translate-y-[0.1em] inline-block size-[0.85em] rounded-[2px] align-middle ring-1 ring-foreground/15"
268
+ style={{ background: hex }}
269
+ />,
270
+ );
271
+ out.push(<Fragment key={`h${key}`}>{hex}</Fragment>);
272
+ lastIndex = match.index + hex.length;
273
+ key += 1;
274
+ match = HEX_RE.exec(body);
275
+ }
276
+ if (lastIndex < body.length) {
277
+ out.push(<Fragment key={`t${key}`}>{body.slice(lastIndex)}</Fragment>);
278
+ }
279
+ return out;
280
+ }
281
+
282
+ function NoDemoLargeState() {
283
+ const t = useLocale();
284
+ return (
285
+ <div className="grid h-full w-full place-items-center bg-muted/40 px-8 text-center">
286
+ <div className="max-w-sm">
287
+ <p className="font-heading text-[15px] font-semibold tracking-tight">
288
+ {t.themes.noDemoYet}
289
+ </p>
290
+ <p className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
291
+ {t.themes.noDemoHintPrefix}
292
+ <code className="rounded-[4px] bg-card px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
293
+ /create-theme
294
+ </code>
295
+ {t.themes.noDemoHintSuffix}
296
+ </p>
297
+ </div>
298
+ </div>
299
+ );
300
+ }
@@ -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 VERTICAL_THUMB_WIDTH = 184;
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-all',
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 scale = VERTICAL_THUMB_WIDTH / CANVAS_WIDTH;
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-all',
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: VERTICAL_THUMB_WIDTH, height }}
271
+ style={{ width: thumbWidth, height }}
260
272
  >
261
273
  <SlideCanvas scale={scale} center={false} flat freezeMotion design={design}>
262
274
  <PageComp />
@@ -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;
@@ -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
+ }