@open-aippt/core 1.13.2

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 (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin.js +2 -0
  4. package/dist/build-DxTqmvsO.js +17 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +86 -0
  7. package/dist/config-CjzqjrEA.js +4280 -0
  8. package/dist/config-DIC-yVPp.d.ts +23 -0
  9. package/dist/design-cpzS8aud.js +35 -0
  10. package/dist/dev-BYuTeJbA.js +20 -0
  11. package/dist/format-BCeKbTOM.js +1605 -0
  12. package/dist/index.d.ts +134 -0
  13. package/dist/index.js +467 -0
  14. package/dist/locale/index.d.ts +24 -0
  15. package/dist/locale/index.js +3 -0
  16. package/dist/preview-DlQvnJPq.js +18 -0
  17. package/dist/sync-BPZ0m27m.js +139 -0
  18. package/dist/sync-EsYusbbL.js +3 -0
  19. package/dist/types-CHmFPIG_.d.ts +430 -0
  20. package/dist/vite/index.d.ts +14 -0
  21. package/dist/vite/index.js +4 -0
  22. package/env.d.ts +59 -0
  23. package/package.json +103 -0
  24. package/skills/apply-comments/SKILL.md +83 -0
  25. package/skills/create-slide/SKILL.md +91 -0
  26. package/skills/create-theme/SKILL.md +250 -0
  27. package/skills/current-slide/SKILL.md +110 -0
  28. package/skills/slide-authoring/SKILL.md +625 -0
  29. package/src/app/app.tsx +47 -0
  30. package/src/app/components/asset-view.tsx +966 -0
  31. package/src/app/components/history-provider.tsx +120 -0
  32. package/src/app/components/image-placeholder.tsx +243 -0
  33. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  34. package/src/app/components/inspector/comment-widget.tsx +93 -0
  35. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  36. package/src/app/components/inspector/inspect-overlay.tsx +387 -0
  37. package/src/app/components/inspector/inspector-panel.tsx +1115 -0
  38. package/src/app/components/inspector/inspector-provider.tsx +1218 -0
  39. package/src/app/components/inspector/save-bar.tsx +48 -0
  40. package/src/app/components/language-toggle.tsx +39 -0
  41. package/src/app/components/notes-drawer.tsx +120 -0
  42. package/src/app/components/overview-grid.tsx +363 -0
  43. package/src/app/components/panel/panel-fields.tsx +60 -0
  44. package/src/app/components/panel/panel-shell.tsx +80 -0
  45. package/src/app/components/panel/save-card.tsx +142 -0
  46. package/src/app/components/pdf-progress-toast.tsx +32 -0
  47. package/src/app/components/player.tsx +466 -0
  48. package/src/app/components/pptx-progress-toast.tsx +32 -0
  49. package/src/app/components/present/blackout-overlay.tsx +18 -0
  50. package/src/app/components/present/control-bar.tsx +315 -0
  51. package/src/app/components/present/help-overlay.tsx +57 -0
  52. package/src/app/components/present/jump-input.tsx +74 -0
  53. package/src/app/components/present/laser-pointer.tsx +39 -0
  54. package/src/app/components/present/progress-bar.tsx +26 -0
  55. package/src/app/components/present/use-idle.ts +46 -0
  56. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  57. package/src/app/components/present/use-presenter-channel.ts +66 -0
  58. package/src/app/components/present/use-touch-swipe.ts +66 -0
  59. package/src/app/components/shared-element.tsx +48 -0
  60. package/src/app/components/sidebar/folder-item.tsx +258 -0
  61. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  62. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  63. package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
  64. package/src/app/components/sidebar/sidebar.tsx +284 -0
  65. package/src/app/components/slide-canvas.tsx +102 -0
  66. package/src/app/components/slide-transition-layer.tsx +844 -0
  67. package/src/app/components/style-panel/design-provider.tsx +148 -0
  68. package/src/app/components/style-panel/style-panel.tsx +349 -0
  69. package/src/app/components/style-panel/use-design.ts +112 -0
  70. package/src/app/components/theme-toggle.tsx +59 -0
  71. package/src/app/components/themes/theme-detail.tsx +305 -0
  72. package/src/app/components/themes/themes-gallery.tsx +149 -0
  73. package/src/app/components/thumbnail-rail.tsx +805 -0
  74. package/src/app/components/ui/badge.tsx +45 -0
  75. package/src/app/components/ui/button.tsx +99 -0
  76. package/src/app/components/ui/card.tsx +92 -0
  77. package/src/app/components/ui/context-menu.tsx +237 -0
  78. package/src/app/components/ui/dialog.tsx +157 -0
  79. package/src/app/components/ui/dropdown-menu.tsx +245 -0
  80. package/src/app/components/ui/input.tsx +25 -0
  81. package/src/app/components/ui/label.tsx +24 -0
  82. package/src/app/components/ui/popover.tsx +75 -0
  83. package/src/app/components/ui/progress.tsx +31 -0
  84. package/src/app/components/ui/scroll-area.tsx +53 -0
  85. package/src/app/components/ui/select.tsx +196 -0
  86. package/src/app/components/ui/separator.tsx +28 -0
  87. package/src/app/components/ui/slider.tsx +61 -0
  88. package/src/app/components/ui/sonner.tsx +48 -0
  89. package/src/app/components/ui/tabs.tsx +79 -0
  90. package/src/app/components/ui/textarea.tsx +22 -0
  91. package/src/app/components/ui/toggle-group.tsx +83 -0
  92. package/src/app/components/ui/toggle.tsx +45 -0
  93. package/src/app/components/ui/tooltip.tsx +58 -0
  94. package/src/app/favicon.ico +0 -0
  95. package/src/app/index.html +13 -0
  96. package/src/app/lib/assets.ts +242 -0
  97. package/src/app/lib/design-presets.ts +94 -0
  98. package/src/app/lib/design.ts +58 -0
  99. package/src/app/lib/export-html.ts +326 -0
  100. package/src/app/lib/export-pdf.ts +298 -0
  101. package/src/app/lib/export-pptx.ts +284 -0
  102. package/src/app/lib/folders.ts +239 -0
  103. package/src/app/lib/inspector/fiber.test.ts +154 -0
  104. package/src/app/lib/inspector/fiber.ts +85 -0
  105. package/src/app/lib/inspector/use-comments.ts +74 -0
  106. package/src/app/lib/inspector/use-editor.ts +73 -0
  107. package/src/app/lib/inspector/use-notes.ts +134 -0
  108. package/src/app/lib/locale-store.ts +67 -0
  109. package/src/app/lib/page-context.tsx +38 -0
  110. package/src/app/lib/print-ready.test.ts +32 -0
  111. package/src/app/lib/print-ready.ts +51 -0
  112. package/src/app/lib/sdk.test.ts +13 -0
  113. package/src/app/lib/sdk.ts +37 -0
  114. package/src/app/lib/slides.ts +26 -0
  115. package/src/app/lib/step-context.tsx +261 -0
  116. package/src/app/lib/themes.ts +22 -0
  117. package/src/app/lib/transition.ts +30 -0
  118. package/src/app/lib/use-agent-socket.ts +18 -0
  119. package/src/app/lib/use-click-page-navigation.ts +60 -0
  120. package/src/app/lib/use-is-mobile.ts +21 -0
  121. package/src/app/lib/use-locale.ts +8 -0
  122. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  123. package/src/app/lib/use-slide-module.ts +48 -0
  124. package/src/app/lib/use-wheel-page-navigation.ts +99 -0
  125. package/src/app/lib/utils.test.ts +25 -0
  126. package/src/app/lib/utils.ts +6 -0
  127. package/src/app/main.tsx +14 -0
  128. package/src/app/routes/assets.tsx +9 -0
  129. package/src/app/routes/home-shell.tsx +213 -0
  130. package/src/app/routes/home.tsx +807 -0
  131. package/src/app/routes/presenter.tsx +418 -0
  132. package/src/app/routes/slide.tsx +1108 -0
  133. package/src/app/routes/themes.tsx +34 -0
  134. package/src/app/styles.css +429 -0
  135. package/src/app/virtual.d.ts +51 -0
  136. package/src/locale/en.ts +416 -0
  137. package/src/locale/format.ts +12 -0
  138. package/src/locale/index.ts +6 -0
  139. package/src/locale/ja.ts +422 -0
  140. package/src/locale/types.ts +443 -0
  141. package/src/locale/zh-cn.ts +414 -0
  142. package/src/locale/zh-tw.ts +414 -0
@@ -0,0 +1,305 @@
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 { SlidePageProvider } from '../../lib/page-context';
8
+ import type { SlideModule } from '../../lib/sdk';
9
+ import { loadSlide, slidesByTheme } from '../../lib/slides';
10
+ import { loadThemeDemo, type ThemeDemoModule, themes } from '../../lib/themes';
11
+ import { SlideCanvas } from '../slide-canvas';
12
+
13
+ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () => void }) {
14
+ const t = useLocale();
15
+ const theme = useMemo(() => themes.find((th) => th.id === themeId), [themeId]);
16
+ const [demo, setDemo] = useState<ThemeDemoModule | null>(null);
17
+ const [pageIndex, setPageIndex] = useState(0);
18
+
19
+ useEffect(() => {
20
+ setPageIndex(0);
21
+ setDemo(null);
22
+ if (!theme?.hasDemo) return;
23
+ let cancelled = false;
24
+ loadThemeDemo(theme.id)
25
+ .then((mod) => {
26
+ if (!cancelled) setDemo(mod);
27
+ })
28
+ .catch(() => {});
29
+ return () => {
30
+ cancelled = true;
31
+ };
32
+ }, [theme]);
33
+
34
+ const pages = demo?.default ?? [];
35
+ const totalPages = pages.length;
36
+ const usedBySlideIds = useMemo(() => (theme ? slidesByTheme(theme.id) : []), [theme]);
37
+
38
+ const promptRef = useRef<HTMLPreElement>(null);
39
+ const [promptExpanded, setPromptExpanded] = useState(false);
40
+ const [promptOverflows, setPromptOverflows] = useState(false);
41
+
42
+ const themeBody = theme?.body;
43
+ useEffect(() => {
44
+ setPromptExpanded(false);
45
+ const el = promptRef.current;
46
+ if (!el || !themeBody) return;
47
+ setPromptOverflows(el.scrollHeight > PROMPT_COLLAPSED_PX + 8);
48
+ }, [themeBody]);
49
+
50
+ useEffect(() => {
51
+ if (totalPages <= 1) return;
52
+ const onKey = (e: KeyboardEvent) => {
53
+ const tag = (e.target as HTMLElement | null)?.tagName;
54
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
55
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
56
+ setPageIndex((i) => Math.min(totalPages - 1, i + 1));
57
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
58
+ setPageIndex((i) => Math.max(0, i - 1));
59
+ }
60
+ };
61
+ window.addEventListener('keydown', onKey);
62
+ return () => window.removeEventListener('keydown', onKey);
63
+ }, [totalPages]);
64
+
65
+ if (!theme) {
66
+ return (
67
+ <div className="px-8 py-12">
68
+ <Button variant="ghost" size="sm" onClick={onBack}>
69
+ <ChevronLeft className="size-4" />
70
+ {t.themes.backToGallery}
71
+ </Button>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ const Current = pages[pageIndex];
77
+
78
+ return (
79
+ <div className="flex flex-col gap-6 md:gap-8">
80
+ <div className="flex items-center gap-3">
81
+ <Button variant="ghost" size="sm" onClick={onBack} className="-ml-2">
82
+ <ChevronLeft className="size-4" />
83
+ {t.themes.backToGallery}
84
+ </Button>
85
+ </div>
86
+
87
+ <header className="flex flex-wrap items-baseline gap-3">
88
+ <h2 className="font-heading text-[26px] font-semibold leading-[1.05] tracking-[-0.025em] md:text-[32px]">
89
+ {theme.name}
90
+ </h2>
91
+ {theme.description ? (
92
+ <p className="basis-full text-[13px] leading-relaxed text-muted-foreground">
93
+ {theme.description}
94
+ </p>
95
+ ) : null}
96
+ </header>
97
+
98
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:gap-8">
99
+ <div className="flex min-w-0 flex-col gap-6">
100
+ <div className="flex flex-col gap-3">
101
+ <div className="relative aspect-video overflow-hidden rounded-[8px] border border-hairline bg-card shadow-edge ring-1 ring-foreground/[0.04]">
102
+ {!theme.hasDemo ? (
103
+ <NoDemoLargeState />
104
+ ) : !demo ? (
105
+ <div className="grid h-full w-full place-items-center text-[11px] tracking-[0.16em] uppercase text-muted-foreground/60">
106
+ {t.common.loading}
107
+ </div>
108
+ ) : Current ? (
109
+ <SlideCanvas flat freezeMotion design={demo.design}>
110
+ <SlidePageProvider index={pageIndex} total={totalPages}>
111
+ <Current />
112
+ </SlidePageProvider>
113
+ </SlideCanvas>
114
+ ) : null}
115
+ </div>
116
+
117
+ {totalPages > 1 ? (
118
+ <div className="flex items-center justify-between gap-2">
119
+ <button
120
+ type="button"
121
+ aria-label={t.themes.prevPageAria}
122
+ disabled={pageIndex === 0}
123
+ onClick={() => setPageIndex((i) => Math.max(0, i - 1))}
124
+ 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"
125
+ >
126
+ <ChevronLeft className="size-4" />
127
+ </button>
128
+ <span className="folio">
129
+ {format(t.themes.pageOf, { n: pageIndex + 1, total: totalPages })}
130
+ </span>
131
+ <button
132
+ type="button"
133
+ aria-label={t.themes.nextPageAria}
134
+ disabled={pageIndex === totalPages - 1}
135
+ onClick={() => setPageIndex((i) => Math.min(totalPages - 1, i + 1))}
136
+ 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"
137
+ >
138
+ <ChevronRight className="size-4" />
139
+ </button>
140
+ </div>
141
+ ) : null}
142
+ </div>
143
+
144
+ <div className="relative">
145
+ <pre
146
+ ref={promptRef}
147
+ style={
148
+ promptOverflows && !promptExpanded ? { maxHeight: PROMPT_COLLAPSED_PX } : undefined
149
+ }
150
+ className={cn(
151
+ 'w-full rounded-[8px] border border-hairline bg-card p-4 font-mono text-[11.5px] leading-relaxed text-foreground/90',
152
+ promptOverflows && !promptExpanded ? 'overflow-hidden' : 'overflow-auto',
153
+ )}
154
+ >
155
+ {renderBodyWithSwatches(theme.body)}
156
+ </pre>
157
+ {promptOverflows && !promptExpanded ? (
158
+ <button
159
+ type="button"
160
+ aria-label={t.themes.expandPromptAria}
161
+ onClick={() => setPromptExpanded(true)}
162
+ 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"
163
+ >
164
+ <ChevronDown className="size-4" />
165
+ </button>
166
+ ) : null}
167
+ {promptOverflows && promptExpanded ? (
168
+ <div className="mt-2 flex justify-center">
169
+ <button
170
+ type="button"
171
+ aria-label={t.themes.collapsePromptAria}
172
+ onClick={() => setPromptExpanded(false)}
173
+ className="flex size-8 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
174
+ >
175
+ <ChevronDown className="size-4 rotate-180" />
176
+ </button>
177
+ </div>
178
+ ) : null}
179
+ </div>
180
+ </div>
181
+
182
+ <aside className="flex min-w-0 flex-col gap-4">
183
+ <div className="flex flex-wrap items-baseline gap-3">
184
+ <span className="eyebrow">{t.themes.usedBy}</span>
185
+ {usedBySlideIds.length > 0 ? (
186
+ <span className="folio">{usedBySlideIds.length.toString().padStart(2, '0')}</span>
187
+ ) : null}
188
+ </div>
189
+ {usedBySlideIds.length === 0 ? (
190
+ <p className="text-[12.5px] leading-relaxed text-muted-foreground">
191
+ {t.themes.usedByEmpty}
192
+ </p>
193
+ ) : (
194
+ <ul className="flex flex-col gap-5">
195
+ {usedBySlideIds.map((id) => (
196
+ <li key={id}>
197
+ <ThemeSlideCard id={id} />
198
+ </li>
199
+ ))}
200
+ </ul>
201
+ )}
202
+ </aside>
203
+ </div>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ function ThemeSlideCard({ id }: { id: string }) {
209
+ const t = useLocale();
210
+ const [slide, setSlide] = useState<SlideModule | null>(null);
211
+
212
+ useEffect(() => {
213
+ let cancelled = false;
214
+ loadSlide(id)
215
+ .then((mod) => {
216
+ if (!cancelled) setSlide(mod);
217
+ })
218
+ .catch(() => {});
219
+ return () => {
220
+ cancelled = true;
221
+ };
222
+ }, [id]);
223
+
224
+ const FirstPage = slide?.default[0];
225
+ const displayTitle = slide?.meta?.title ?? id;
226
+
227
+ return (
228
+ <Link to={`/s/${id}`} className="group block focus-visible:outline-none">
229
+ <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">
230
+ {FirstPage ? (
231
+ <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
232
+ <SlideCanvas flat freezeMotion design={slide?.design}>
233
+ <SlidePageProvider index={0} total={slide?.default.length ?? 1}>
234
+ <FirstPage />
235
+ </SlidePageProvider>
236
+ </SlideCanvas>
237
+ </div>
238
+ ) : (
239
+ <div className="grid h-full w-full place-items-center text-[10px] tracking-[0.16em] uppercase text-muted-foreground/60">
240
+ {t.common.loading}
241
+ </div>
242
+ )}
243
+ </div>
244
+ <div className="mt-2.5">
245
+ <h3 className="min-w-0 truncate font-heading text-[13px] font-medium tracking-tight">
246
+ {displayTitle}
247
+ </h3>
248
+ <p className="mt-0.5 truncate font-mono text-[10.5px] text-muted-foreground/80">{id}</p>
249
+ </div>
250
+ </Link>
251
+ );
252
+ }
253
+
254
+ const PROMPT_COLLAPSED_PX = 320;
255
+
256
+ 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;
257
+
258
+ function renderBodyWithSwatches(body: string): ReactNode[] {
259
+ const out: ReactNode[] = [];
260
+ let lastIndex = 0;
261
+ let match: RegExpExecArray | null = HEX_RE.exec(body);
262
+ let key = 0;
263
+ while (match !== null) {
264
+ if (match.index > lastIndex) {
265
+ out.push(<Fragment key={`t${key}`}>{body.slice(lastIndex, match.index)}</Fragment>);
266
+ }
267
+ const hex = match[0];
268
+ out.push(
269
+ <span
270
+ key={`s${key}`}
271
+ aria-hidden
272
+ className="mr-[0.25em] -translate-y-[0.1em] inline-block size-[0.85em] rounded-[2px] align-middle ring-1 ring-foreground/15"
273
+ style={{ background: hex }}
274
+ />,
275
+ );
276
+ out.push(<Fragment key={`h${key}`}>{hex}</Fragment>);
277
+ lastIndex = match.index + hex.length;
278
+ key += 1;
279
+ match = HEX_RE.exec(body);
280
+ }
281
+ if (lastIndex < body.length) {
282
+ out.push(<Fragment key={`t${key}`}>{body.slice(lastIndex)}</Fragment>);
283
+ }
284
+ return out;
285
+ }
286
+
287
+ function NoDemoLargeState() {
288
+ const t = useLocale();
289
+ return (
290
+ <div className="grid h-full w-full place-items-center bg-muted/40 px-8 text-center">
291
+ <div className="max-w-sm">
292
+ <p className="font-heading text-[15px] font-semibold tracking-tight">
293
+ {t.themes.noDemoYet}
294
+ </p>
295
+ <p className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
296
+ {t.themes.noDemoHintPrefix}
297
+ <code className="rounded-[4px] bg-card px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
298
+ /create-theme
299
+ </code>
300
+ {t.themes.noDemoHintSuffix}
301
+ </p>
302
+ </div>
303
+ </div>
304
+ );
305
+ }
@@ -0,0 +1,149 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { format, useLocale } from '@/lib/use-locale';
3
+ import { SlidePageProvider } from '../../lib/page-context';
4
+ import { loadThemeDemo, type Theme, type ThemeDemoModule, themes } from '../../lib/themes';
5
+ import { SlideCanvas } from '../slide-canvas';
6
+
7
+ export function ThemesGallery({ onOpen }: { onOpen: (id: string) => void }) {
8
+ const t = useLocale();
9
+
10
+ if (themes.length === 0) {
11
+ return <ThemesEmptyState />;
12
+ }
13
+
14
+ return (
15
+ <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))]">
16
+ {themes.map((theme) => (
17
+ <li key={theme.id}>
18
+ <ThemeCard
19
+ theme={theme}
20
+ onOpen={() => onOpen(theme.id)}
21
+ ariaLabel={format(t.themes.openThemeAria, { name: theme.name })}
22
+ />
23
+ </li>
24
+ ))}
25
+ </ul>
26
+ );
27
+ }
28
+
29
+ function ThemeCard({
30
+ theme,
31
+ onOpen,
32
+ ariaLabel,
33
+ }: {
34
+ theme: Theme;
35
+ onOpen: () => void;
36
+ ariaLabel: string;
37
+ }) {
38
+ return (
39
+ <button
40
+ type="button"
41
+ onClick={onOpen}
42
+ aria-label={ariaLabel}
43
+ className="group block w-full text-left focus-visible:outline-none"
44
+ >
45
+ <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">
46
+ <ThemePreview theme={theme} />
47
+ </div>
48
+ <div className="mt-3">
49
+ <h3 className="min-w-0 truncate font-heading text-[14px] font-medium tracking-tight">
50
+ {theme.name}
51
+ </h3>
52
+ </div>
53
+ {theme.description ? (
54
+ <p className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
55
+ {theme.description}
56
+ </p>
57
+ ) : null}
58
+ </button>
59
+ );
60
+ }
61
+
62
+ function ThemePreview({ theme }: { theme: Theme }) {
63
+ const t = useLocale();
64
+ const demo = useThemeDemo(theme);
65
+
66
+ if (!theme.hasDemo) {
67
+ return <NoDemoState />;
68
+ }
69
+ if (!demo) {
70
+ return (
71
+ <div className="grid h-full w-full place-items-center text-[10px] tracking-[0.16em] uppercase text-muted-foreground/60">
72
+ {t.common.loading}
73
+ </div>
74
+ );
75
+ }
76
+ const FirstPage = demo.default[0];
77
+ if (!FirstPage) return <NoDemoState />;
78
+
79
+ return (
80
+ <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
81
+ <SlideCanvas flat freezeMotion design={demo.design}>
82
+ <SlidePageProvider index={0} total={demo.default.length}>
83
+ <FirstPage />
84
+ </SlidePageProvider>
85
+ </SlideCanvas>
86
+ </div>
87
+ );
88
+ }
89
+
90
+ function NoDemoState() {
91
+ const t = useLocale();
92
+ return (
93
+ <div className="grid h-full w-full place-items-center bg-muted/40 px-6 text-center">
94
+ <div>
95
+ <p className="font-heading text-[12px] font-semibold tracking-tight text-foreground/80">
96
+ {t.themes.noDemoYet}
97
+ </p>
98
+ <p className="mt-1 text-[10.5px] leading-snug text-muted-foreground">
99
+ {t.themes.noDemoHintPrefix}
100
+ <code className="rounded-[3px] bg-card px-1 py-0.5 font-mono text-[10px] text-foreground">
101
+ /create-theme
102
+ </code>
103
+ {t.themes.noDemoHintSuffix}
104
+ </p>
105
+ </div>
106
+ </div>
107
+ );
108
+ }
109
+
110
+ function ThemesEmptyState() {
111
+ const t = useLocale();
112
+ return (
113
+ <div className="rounded-[10px] border border-dashed border-border bg-card/60 px-8 py-20">
114
+ <div className="mx-auto flex max-w-md flex-col items-center text-center">
115
+ <div className="text-2xl">🎨</div>
116
+ <p className="mt-3 font-heading text-[15px] font-semibold tracking-tight">
117
+ {t.themes.noThemesTitle}
118
+ </p>
119
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
120
+ {t.themes.noThemesHintPrefix}
121
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
122
+ /create-theme
123
+ </code>
124
+ {t.themes.noThemesHintSuffix}
125
+ </p>
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ function useThemeDemo(theme: Theme): ThemeDemoModule | null {
132
+ const [demo, setDemo] = useState<ThemeDemoModule | null>(null);
133
+ useEffect(() => {
134
+ if (!theme.hasDemo) {
135
+ setDemo(null);
136
+ return;
137
+ }
138
+ let cancelled = false;
139
+ loadThemeDemo(theme.id)
140
+ .then((mod) => {
141
+ if (!cancelled) setDemo(mod);
142
+ })
143
+ .catch(() => {});
144
+ return () => {
145
+ cancelled = true;
146
+ };
147
+ }, [theme.id, theme.hasDemo]);
148
+ return demo;
149
+ }