@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,148 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { toast } from 'sonner';
12
+ import { useHistory } from '@/components/history-provider';
13
+ import { type DesignSystem, defaultDesign, designToCssVars } from '../../lib/design';
14
+ import { shuffleDesign } from '../../lib/design-presets';
15
+ import { useDesign as useDesignFetch } from './use-design';
16
+
17
+ type DesignCtx = {
18
+ slideId: string;
19
+ loaded: boolean;
20
+ exists: boolean;
21
+ warning: string | null;
22
+ design: DesignSystem | null;
23
+ draft: DesignSystem | null;
24
+ dirty: boolean;
25
+ committing: boolean;
26
+ update: (mut: (next: DesignSystem) => void, coalesceKey?: string) => void;
27
+ commit: () => Promise<void>;
28
+ discard: () => void;
29
+ resetToDefaults: () => void;
30
+ shuffle: () => void;
31
+ };
32
+
33
+ const Ctx = createContext<DesignCtx | null>(null);
34
+
35
+ export function useDesignPanelState(): DesignCtx {
36
+ const v = useContext(Ctx);
37
+ if (!v) throw new Error('useDesignPanelState must be used inside <DesignProvider>');
38
+ return v;
39
+ }
40
+
41
+ function clone<T>(d: T): T {
42
+ return JSON.parse(JSON.stringify(d)) as T;
43
+ }
44
+
45
+ export function DesignProvider({ slideId, children }: { slideId: string; children: ReactNode }) {
46
+ const { design, exists, warning, loaded, save } = useDesignFetch(slideId);
47
+ const [draft, setDraft] = useState<DesignSystem | null>(null);
48
+ const [committing, setCommitting] = useState(false);
49
+ const history = useHistory();
50
+ const draftRef = useRef<DesignSystem | null>(null);
51
+ draftRef.current = draft;
52
+
53
+ useEffect(() => {
54
+ if (design) setDraft(clone(design));
55
+ }, [design]);
56
+
57
+ const dirty = useMemo(() => {
58
+ if (!draft || !design) return false;
59
+ return JSON.stringify(draft) !== JSON.stringify(design);
60
+ }, [draft, design]);
61
+
62
+ const update = useCallback(
63
+ (mut: (d: DesignSystem) => void, coalesceKey?: string) => {
64
+ const prev = draftRef.current;
65
+ if (!prev) return;
66
+ const next = clone(prev);
67
+ mut(next);
68
+ setDraft(next);
69
+ history.record({
70
+ coalesceKey,
71
+ undo: () => setDraft(prev),
72
+ redo: () => setDraft(next),
73
+ });
74
+ },
75
+ [history],
76
+ );
77
+
78
+ const commit = useCallback(async () => {
79
+ if (!draft) return;
80
+ setCommitting(true);
81
+ const r = await save(draft);
82
+ setCommitting(false);
83
+ if (!r.ok) toast.error(r.error ?? 'Failed to save');
84
+ history.clear();
85
+ }, [draft, save, history]);
86
+
87
+ const discard = useCallback(() => {
88
+ if (design) setDraft(clone(design));
89
+ history.clear();
90
+ }, [design, history]);
91
+
92
+ const resetToDefaults = useCallback(() => {
93
+ const prev = draftRef.current;
94
+ const next = clone(defaultDesign);
95
+ setDraft(next);
96
+ history.record({
97
+ coalesceKey: 'design:reset',
98
+ undo: () => setDraft(prev),
99
+ redo: () => setDraft(next),
100
+ });
101
+ }, [history]);
102
+
103
+ const shuffle = useCallback(() => {
104
+ const prev = draftRef.current;
105
+ const next = clone(shuffleDesign(prev));
106
+ setDraft(next);
107
+ history.record({
108
+ undo: () => setDraft(prev),
109
+ redo: () => setDraft(next),
110
+ });
111
+ }, [history]);
112
+
113
+ // SlideCanvas emits its design vars inline on the canvas root, so a draft
114
+ // overlay must use `!important` to outrank those inline styles.
115
+ const previewCss = useMemo(() => {
116
+ if (!dirty || !draft) return '';
117
+ const lines = Object.entries(designToCssVars(draft))
118
+ .map(([k, v]) => ` ${k}: ${v} !important;`)
119
+ .join('\n');
120
+ return `[data-osd-canvas] {\n${lines}\n}`;
121
+ }, [dirty, draft]);
122
+
123
+ const value: DesignCtx = {
124
+ slideId,
125
+ loaded,
126
+ exists,
127
+ warning,
128
+ design,
129
+ draft,
130
+ dirty,
131
+ committing,
132
+ update,
133
+ commit,
134
+ discard,
135
+ resetToDefaults,
136
+ shuffle,
137
+ };
138
+
139
+ return (
140
+ <Ctx.Provider value={value}>
141
+ {previewCss && (
142
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: trusted local css from draft state.
143
+ <style dangerouslySetInnerHTML={{ __html: previewCss }} />
144
+ )}
145
+ {children}
146
+ </Ctx.Provider>
147
+ );
148
+ }
@@ -0,0 +1,349 @@
1
+ import { Palette, Shuffle, X } from 'lucide-react';
2
+ import { useEffect, useState } from 'react';
3
+ import { Field, NumberField, Section } from '@/components/panel/panel-fields';
4
+ import { PanelShell, usePanelMount } from '@/components/panel/panel-shell';
5
+ import { useLocale } from '@/lib/use-locale';
6
+ import { Button } from '../ui/button';
7
+ import { Input } from '../ui/input';
8
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
9
+ import { Separator } from '../ui/separator';
10
+ import { Slider } from '../ui/slider';
11
+ import { useDesignPanelState } from './design-provider';
12
+
13
+ const FONT_PRESETS: Array<{ label: string; value: string }> = [
14
+ {
15
+ label: 'System sans',
16
+ value: '-apple-system, BlinkMacSystemFont, "Inter", system-ui, sans-serif',
17
+ },
18
+ { label: 'Inter', value: '"Inter", system-ui, sans-serif' },
19
+ { label: 'Helvetica', value: '"Helvetica Neue", Helvetica, Arial, sans-serif' },
20
+ { label: 'Georgia', value: 'Georgia, "Times New Roman", serif' },
21
+ { label: 'Times', value: '"Times New Roman", Times, serif' },
22
+ { label: 'SF Mono', value: '"SF Mono", "JetBrains Mono", Menlo, monospace' },
23
+ ];
24
+
25
+ type DesignPanelProps = {
26
+ open: boolean;
27
+ onClose: () => void;
28
+ };
29
+
30
+ export function DesignPanel({ open, onClose }: DesignPanelProps) {
31
+ const { draft, exists, warning, loaded, dirty, update, shuffle } = useDesignPanelState();
32
+ const { mounted, animVisible } = usePanelMount(open);
33
+ const t = useLocale();
34
+
35
+ if (!loaded) return null;
36
+ if (!mounted) return null;
37
+ if (!draft) return null;
38
+
39
+ return (
40
+ <PanelShell
41
+ uiAttr="design"
42
+ animVisible={animVisible}
43
+ header={
44
+ <>
45
+ <div className="flex min-w-0 items-center gap-2">
46
+ <Palette className="size-3.5 text-muted-foreground" />
47
+ <span className="font-heading text-[12px] font-semibold tracking-tight">
48
+ {t.stylePanel.designTokens}
49
+ </span>
50
+ {!exists && (
51
+ <span className="rounded-[3px] border border-hairline bg-muted/60 px-1.5 py-px font-mono text-[9.5px] uppercase tracking-[0.08em] text-muted-foreground">
52
+ {t.stylePanel.draftBadge}
53
+ </span>
54
+ )}
55
+ {dirty && (
56
+ <span
57
+ className="size-1.5 rounded-full bg-brand"
58
+ title={t.stylePanel.unsavedTitle}
59
+ aria-hidden
60
+ />
61
+ )}
62
+ </div>
63
+ <div className="flex items-center gap-0.5">
64
+ <Button
65
+ variant="ghost"
66
+ size="icon-sm"
67
+ className="text-muted-foreground hover:text-foreground"
68
+ onClick={shuffle}
69
+ aria-label={t.stylePanel.shuffleAria}
70
+ title={t.stylePanel.shuffleTitle}
71
+ >
72
+ <Shuffle className="size-3.5" />
73
+ </Button>
74
+ <Button
75
+ variant="ghost"
76
+ size="icon-sm"
77
+ className="text-muted-foreground hover:text-foreground"
78
+ onClick={onClose}
79
+ aria-label={t.stylePanel.closePanelAria}
80
+ >
81
+ <X className="size-3.5" />
82
+ </Button>
83
+ </div>
84
+ </>
85
+ }
86
+ banner={
87
+ warning && (
88
+ <div className="flex gap-2 border-b border-hairline bg-[oklch(0.97_0.04_85)] px-3 py-2 text-[11px] leading-relaxed text-[oklch(0.35_0.08_45)] dark:bg-[oklch(0.25_0.04_60)] dark:text-[oklch(0.85_0.08_85)]">
89
+ <span aria-hidden className="mt-0.5 size-1.5 shrink-0 rounded-full bg-brand" />
90
+ <span>{warning}</span>
91
+ </div>
92
+ )
93
+ }
94
+ >
95
+ <Section title={t.stylePanel.colorsSection}>
96
+ <ColorField
97
+ label={t.stylePanel.backgroundLabel}
98
+ value={draft.palette.bg}
99
+ onChange={(v) =>
100
+ update((d) => {
101
+ d.palette.bg = v;
102
+ }, 'design:palette.bg')
103
+ }
104
+ />
105
+ <ColorField
106
+ label={t.stylePanel.textLabel}
107
+ value={draft.palette.text}
108
+ onChange={(v) =>
109
+ update((d) => {
110
+ d.palette.text = v;
111
+ }, 'design:palette.text')
112
+ }
113
+ />
114
+ <ColorField
115
+ label={t.stylePanel.accentLabel}
116
+ value={draft.palette.accent}
117
+ onChange={(v) =>
118
+ update((d) => {
119
+ d.palette.accent = v;
120
+ }, 'design:palette.accent')
121
+ }
122
+ />
123
+ </Section>
124
+
125
+ <Separator />
126
+
127
+ <Section title={t.stylePanel.typographySection}>
128
+ <FontField
129
+ label={t.stylePanel.displayFontLabel}
130
+ value={draft.fonts.display}
131
+ onChange={(v) =>
132
+ update((d) => {
133
+ d.fonts.display = v;
134
+ }, 'design:fonts.display')
135
+ }
136
+ />
137
+ <FontField
138
+ label={t.stylePanel.bodyFontLabel}
139
+ value={draft.fonts.body}
140
+ onChange={(v) =>
141
+ update((d) => {
142
+ d.fonts.body = v;
143
+ }, 'design:fonts.body')
144
+ }
145
+ />
146
+ <SliderField
147
+ label={t.stylePanel.heroLabel}
148
+ value={draft.typeScale.hero}
149
+ min={48}
150
+ max={240}
151
+ step={2}
152
+ suffix="px"
153
+ onChange={(n) =>
154
+ update((d) => {
155
+ d.typeScale.hero = n;
156
+ }, 'design:typeScale.hero')
157
+ }
158
+ />
159
+ <SliderField
160
+ label={t.stylePanel.bodyLabel}
161
+ value={draft.typeScale.body}
162
+ min={16}
163
+ max={72}
164
+ step={1}
165
+ suffix="px"
166
+ onChange={(n) =>
167
+ update((d) => {
168
+ d.typeScale.body = n;
169
+ }, 'design:typeScale.body')
170
+ }
171
+ />
172
+ </Section>
173
+
174
+ <Separator />
175
+
176
+ <Section title={t.stylePanel.shapeSection}>
177
+ <SliderField
178
+ label={t.stylePanel.radiusLabel}
179
+ value={draft.radius}
180
+ min={0}
181
+ max={80}
182
+ step={1}
183
+ suffix="px"
184
+ onChange={(n) =>
185
+ update((d) => {
186
+ d.radius = n;
187
+ }, 'design:radius')
188
+ }
189
+ />
190
+ </Section>
191
+ </PanelShell>
192
+ );
193
+ }
194
+
195
+ export function DesignToggleButton({
196
+ active,
197
+ onToggle,
198
+ }: {
199
+ active: boolean;
200
+ onToggle: () => void;
201
+ }) {
202
+ const t = useLocale();
203
+ if (import.meta.env.PROD) return null;
204
+ return (
205
+ <Button
206
+ size="sm"
207
+ variant={active ? 'default' : 'ghost'}
208
+ onClick={onToggle}
209
+ data-design-ui
210
+ title={t.stylePanel.designToggleTitle}
211
+ >
212
+ <Palette className="size-3.5" />
213
+ <span className="hidden md:inline">{t.stylePanel.designToggle}</span>
214
+ <kbd className="ml-1 hidden rounded-[3px] bg-foreground/10 px-1 font-mono text-[9.5px] tracking-[0.04em] md:inline">
215
+ D
216
+ </kbd>
217
+ </Button>
218
+ );
219
+ }
220
+
221
+ function ColorField({
222
+ label,
223
+ value,
224
+ onChange,
225
+ }: {
226
+ label: string;
227
+ value: string;
228
+ onChange: (v: string) => void;
229
+ }) {
230
+ const [hexDraft, setHexDraft] = useState(value);
231
+ useEffect(() => setHexDraft(value), [value]);
232
+
233
+ return (
234
+ <Field label={label}>
235
+ <label className="relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md border bg-background shadow-xs">
236
+ <span className="size-5 rounded-sm" style={{ backgroundColor: value }} />
237
+ <input
238
+ type="color"
239
+ value={normalizeHex(value)}
240
+ onChange={(e) => onChange(e.target.value)}
241
+ className="absolute inset-0 cursor-pointer opacity-0"
242
+ />
243
+ </label>
244
+ <Input
245
+ type="text"
246
+ value={hexDraft}
247
+ onChange={(e) => {
248
+ const v = e.target.value;
249
+ setHexDraft(v);
250
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v);
251
+ }}
252
+ onBlur={() => {
253
+ if (!/^#[0-9a-fA-F]{6}$/.test(hexDraft)) setHexDraft(value);
254
+ }}
255
+ className="h-8 flex-1 font-mono text-[11px] uppercase"
256
+ spellCheck={false}
257
+ />
258
+ </Field>
259
+ );
260
+ }
261
+
262
+ function FontField({
263
+ label,
264
+ value,
265
+ onChange,
266
+ }: {
267
+ label: string;
268
+ value: string;
269
+ onChange: (v: string) => void;
270
+ }) {
271
+ const matched = FONT_PRESETS.find((p) => p.value === value);
272
+ const tFont = useLocale();
273
+ return (
274
+ <Field label={label}>
275
+ <Select
276
+ value={matched ? matched.value : '__custom__'}
277
+ onValueChange={(v) => {
278
+ if (v !== '__custom__') onChange(v);
279
+ }}
280
+ >
281
+ <SelectTrigger size="sm" className="h-8 flex-1 text-xs">
282
+ <SelectValue />
283
+ </SelectTrigger>
284
+ <SelectContent>
285
+ {FONT_PRESETS.map((p) => (
286
+ <SelectItem key={p.label} value={p.value} className="text-xs">
287
+ {p.label}
288
+ </SelectItem>
289
+ ))}
290
+ {!matched && (
291
+ <SelectItem value="__custom__" className="text-xs">
292
+ {tFont.stylePanel.fontPresetCustom}
293
+ </SelectItem>
294
+ )}
295
+ </SelectContent>
296
+ </Select>
297
+ </Field>
298
+ );
299
+ }
300
+
301
+ function SliderField({
302
+ label,
303
+ value,
304
+ min,
305
+ max,
306
+ step = 1,
307
+ suffix,
308
+ onChange,
309
+ }: {
310
+ label: string;
311
+ value: number;
312
+ min: number;
313
+ max: number;
314
+ step?: number;
315
+ suffix?: string;
316
+ onChange: (n: number) => void;
317
+ }) {
318
+ return (
319
+ <Field label={label}>
320
+ <Slider
321
+ min={min}
322
+ max={max}
323
+ step={step}
324
+ value={[value]}
325
+ onValueChange={([v]) => onChange(v ?? value)}
326
+ className="flex-1"
327
+ />
328
+ <NumberField
329
+ value={value}
330
+ onChange={onChange}
331
+ min={min}
332
+ max={max}
333
+ step={step}
334
+ suffix={suffix}
335
+ />
336
+ </Field>
337
+ );
338
+ }
339
+
340
+ function normalizeHex(value: string): string {
341
+ if (/^#[0-9a-fA-F]{6}$/.test(value)) return value;
342
+ if (/^#[0-9a-fA-F]{3}$/.test(value)) {
343
+ const r = value[1];
344
+ const g = value[2];
345
+ const b = value[3];
346
+ return `#${r}${r}${g}${g}${b}${b}`;
347
+ }
348
+ return '#000000';
349
+ }
@@ -0,0 +1,112 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { DesignSystem } from '../../lib/design';
3
+
4
+ type FetchedState = {
5
+ design: DesignSystem | null;
6
+ exists: boolean;
7
+ warning: string | null;
8
+ loaded: boolean;
9
+ };
10
+
11
+ export type UseDesignReturn = FetchedState & {
12
+ refresh: () => Promise<void>;
13
+ save: (patch: Partial<DesignSystem>) => Promise<{ ok: boolean; error?: string }>;
14
+ reset: () => Promise<{ ok: boolean; error?: string }>;
15
+ };
16
+
17
+ export function useDesign(slideId: string): UseDesignReturn {
18
+ const [state, setState] = useState<FetchedState>({
19
+ design: null,
20
+ exists: false,
21
+ warning: null,
22
+ loaded: false,
23
+ });
24
+ const slideIdRef = useRef(slideId);
25
+ slideIdRef.current = slideId;
26
+
27
+ const refresh = useCallback(async () => {
28
+ const id = slideIdRef.current;
29
+ if (!id) return;
30
+ try {
31
+ const res = await fetch(`/__design?slideId=${encodeURIComponent(id)}`);
32
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
33
+ const body = (await res.json()) as {
34
+ design: DesignSystem;
35
+ exists: boolean;
36
+ warning: string | null;
37
+ };
38
+ setState({
39
+ design: body.design,
40
+ exists: body.exists,
41
+ warning: body.warning,
42
+ loaded: true,
43
+ });
44
+ } catch (err) {
45
+ setState((s) => ({ ...s, warning: String((err as Error).message), loaded: true }));
46
+ }
47
+ }, []);
48
+
49
+ useEffect(() => {
50
+ setState({ design: null, exists: false, warning: null, loaded: false });
51
+ void refresh();
52
+ }, [refresh]);
53
+
54
+ const save = useCallback(async (patch: Partial<DesignSystem>) => {
55
+ const id = slideIdRef.current;
56
+ if (!id) return { ok: false, error: 'no slide id' };
57
+ try {
58
+ const res = await fetch(`/__design?slideId=${encodeURIComponent(id)}`, {
59
+ method: 'PUT',
60
+ headers: { 'content-type': 'application/json' },
61
+ body: JSON.stringify({ patch }),
62
+ });
63
+ const body = (await res.json()) as {
64
+ ok?: boolean;
65
+ error?: string;
66
+ design?: DesignSystem;
67
+ created?: boolean;
68
+ };
69
+ if (!res.ok || !body.ok) {
70
+ return { ok: false, error: body.error ?? `HTTP ${res.status}` };
71
+ }
72
+ if (body.design) {
73
+ setState((s) => ({
74
+ ...s,
75
+ design: body.design ?? s.design,
76
+ exists: true,
77
+ warning: null,
78
+ }));
79
+ }
80
+ return { ok: true };
81
+ } catch (err) {
82
+ return { ok: false, error: String((err as Error).message) };
83
+ }
84
+ }, []);
85
+
86
+ const reset = useCallback(async () => {
87
+ const id = slideIdRef.current;
88
+ if (!id) return { ok: false, error: 'no slide id' };
89
+ try {
90
+ const res = await fetch(`/__design/reset?slideId=${encodeURIComponent(id)}`, {
91
+ method: 'POST',
92
+ });
93
+ const body = (await res.json()) as { ok?: boolean; error?: string; design?: DesignSystem };
94
+ if (!res.ok || !body.ok) {
95
+ return { ok: false, error: body.error ?? `HTTP ${res.status}` };
96
+ }
97
+ if (body.design) {
98
+ setState((s) => ({
99
+ ...s,
100
+ design: body.design ?? s.design,
101
+ exists: true,
102
+ warning: null,
103
+ }));
104
+ }
105
+ return { ok: true };
106
+ } catch (err) {
107
+ return { ok: false, error: String((err as Error).message) };
108
+ }
109
+ }, []);
110
+
111
+ return { ...state, refresh, save, reset };
112
+ }
@@ -0,0 +1,59 @@
1
+ import { Monitor, Moon, Sun } from 'lucide-react';
2
+ import { useTheme } from 'next-themes';
3
+ import { useEffect, useState } from 'react';
4
+ import { buttonVariants } from '@/components/ui/button';
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuTrigger,
10
+ } from '@/components/ui/dropdown-menu';
11
+ import { useLocale } from '@/lib/use-locale';
12
+ import { cn } from '@/lib/utils';
13
+
14
+ export function ThemeToggle() {
15
+ const { theme, setTheme } = useTheme();
16
+ const [mounted, setMounted] = useState(false);
17
+ const t = useLocale();
18
+
19
+ useEffect(() => {
20
+ setMounted(true);
21
+ }, []);
22
+
23
+ return (
24
+ <DropdownMenu>
25
+ <DropdownMenuTrigger
26
+ type="button"
27
+ aria-label={t.themeToggle.toggleAria}
28
+ title={t.themeToggle.title}
29
+ className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'relative')}
30
+ >
31
+ <Sun className="size-3.5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
32
+ <Moon className="absolute size-3.5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
33
+ </DropdownMenuTrigger>
34
+ <DropdownMenuContent align="end" className="min-w-[140px]">
35
+ <DropdownMenuItem
36
+ onSelect={() => setTheme('light')}
37
+ data-active={mounted && theme === 'light'}
38
+ >
39
+ <Sun />
40
+ {t.themeToggle.light}
41
+ </DropdownMenuItem>
42
+ <DropdownMenuItem
43
+ onSelect={() => setTheme('dark')}
44
+ data-active={mounted && theme === 'dark'}
45
+ >
46
+ <Moon />
47
+ {t.themeToggle.dark}
48
+ </DropdownMenuItem>
49
+ <DropdownMenuItem
50
+ onSelect={() => setTheme('system')}
51
+ data-active={mounted && theme === 'system'}
52
+ >
53
+ <Monitor />
54
+ {t.themeToggle.system}
55
+ </DropdownMenuItem>
56
+ </DropdownMenuContent>
57
+ </DropdownMenu>
58
+ );
59
+ }