@open-slide/core 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-DSqSio-T.js → build-_276DMmJ.js} +2 -2
- package/dist/cli/bin.js +5 -5
- package/dist/{config-KdiYeWtK.js → config-BAwKWNtW.js} +888 -229
- package/dist/{config-C7vMYzFD.d.ts → config-D9cZ1A0X.d.ts} +2 -1
- package/dist/{dev-B_GVbr11.js → dev-BoqeVXVq.js} +2 -2
- package/dist/en-CDKzoZvf.js +351 -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 +166 -326
- package/dist/{preview-D_mxhj7w.js → preview-BLPxspc9.js} +2 -2
- package/dist/sync-j9_QPovT.js +3 -0
- package/dist/{types-DYgVpIGo.d.ts → types-JYG1cmwC.d.ts} +59 -5
- 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/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +59 -1
- package/src/app/app.tsx +11 -1
- package/src/app/components/asset-view.tsx +1 -13
- package/src/app/components/image-placeholder.tsx +123 -1
- package/src/app/components/inspector/image-crop-dialog.tsx +64 -20
- package/src/app/components/inspector/inspector-panel.tsx +163 -19
- package/src/app/components/inspector/inspector-provider.tsx +60 -7
- package/src/app/components/notes-drawer.tsx +117 -0
- package/src/app/components/player.tsx +11 -7
- package/src/app/components/present/overview-grid.tsx +2 -2
- package/src/app/components/sidebar/folder-item.tsx +16 -5
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar.tsx +10 -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 +136 -29
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/lib/assets.ts +55 -2
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/sdk.ts +1 -0
- package/src/app/lib/slides.ts +10 -1
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/routes/home-shell.tsx +173 -0
- package/src/app/routes/home.tsx +108 -204
- package/src/app/routes/slide.tsx +333 -68
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/virtual.d.ts +20 -0
- package/src/locale/en.ts +61 -7
- package/src/locale/ja.ts +62 -7
- package/src/locale/types.ts +62 -5
- package/src/locale/zh-cn.ts +61 -7
- package/src/locale/zh-tw.ts +61 -7
- 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,117 @@
|
|
|
1
|
+
import { ChevronDown, ChevronUp, NotebookPen } from 'lucide-react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { PANEL_TRANSITION_MS, usePanelMount } from '@/components/panel/panel-shell';
|
|
4
|
+
import { useNotes } from '@/lib/inspector/use-notes';
|
|
5
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'open-slide:notes-drawer-open';
|
|
9
|
+
const DRAWER_CONTENT_H = 166;
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
slideId: string;
|
|
13
|
+
index: number;
|
|
14
|
+
total: number;
|
|
15
|
+
initial: string | undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function NotesDrawer({ slideId, index, total, initial }: Props) {
|
|
19
|
+
const t = useLocale();
|
|
20
|
+
const [open, setOpen] = useState(() => {
|
|
21
|
+
if (typeof window === 'undefined') return false;
|
|
22
|
+
return window.localStorage.getItem(STORAGE_KEY) === '1';
|
|
23
|
+
});
|
|
24
|
+
const { value, setValue, status, flush } = useNotes(slideId, index, initial);
|
|
25
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
26
|
+
const { mounted, animVisible } = usePanelMount(open);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (typeof window === 'undefined') return;
|
|
30
|
+
window.localStorage.setItem(STORAGE_KEY, open ? '1' : '0');
|
|
31
|
+
}, [open]);
|
|
32
|
+
|
|
33
|
+
const statusLabel = (() => {
|
|
34
|
+
switch (status.kind) {
|
|
35
|
+
case 'saving':
|
|
36
|
+
return t.notesDrawer.statusSaving;
|
|
37
|
+
case 'saved':
|
|
38
|
+
return t.notesDrawer.statusSaved;
|
|
39
|
+
case 'error':
|
|
40
|
+
return format(t.notesDrawer.statusError, { msg: status.message });
|
|
41
|
+
default:
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<aside
|
|
48
|
+
data-notes-drawer
|
|
49
|
+
className="hidden shrink-0 border-t border-hairline bg-sidebar/85 backdrop-blur md:block"
|
|
50
|
+
>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={() => {
|
|
54
|
+
setOpen((o) => {
|
|
55
|
+
if (o) void flush();
|
|
56
|
+
return !o;
|
|
57
|
+
});
|
|
58
|
+
}}
|
|
59
|
+
className="flex h-9 w-full items-center gap-2 px-3 text-[12px] text-foreground/80 hover:bg-muted/40"
|
|
60
|
+
aria-expanded={open}
|
|
61
|
+
>
|
|
62
|
+
<NotebookPen className="size-3.5 text-muted-foreground" />
|
|
63
|
+
<span className="font-medium">{t.notesDrawer.toggle}</span>
|
|
64
|
+
<span className="font-mono text-[11px] text-muted-foreground">
|
|
65
|
+
{format(t.notesDrawer.pageLabel, { n: index + 1, total })}
|
|
66
|
+
</span>
|
|
67
|
+
<span
|
|
68
|
+
className={cn(
|
|
69
|
+
'ml-auto truncate text-[11px]',
|
|
70
|
+
status.kind === 'error' ? 'text-destructive' : 'text-muted-foreground',
|
|
71
|
+
)}
|
|
72
|
+
aria-live="polite"
|
|
73
|
+
>
|
|
74
|
+
{statusLabel}
|
|
75
|
+
</span>
|
|
76
|
+
{open ? (
|
|
77
|
+
<ChevronDown className="size-3.5 text-muted-foreground" />
|
|
78
|
+
) : (
|
|
79
|
+
<ChevronUp className="size-3.5 text-muted-foreground" />
|
|
80
|
+
)}
|
|
81
|
+
</button>
|
|
82
|
+
{mounted && (
|
|
83
|
+
<div
|
|
84
|
+
className="overflow-hidden border-t border-hairline transition-[height] ease-out"
|
|
85
|
+
style={{
|
|
86
|
+
height: animVisible ? DRAWER_CONTENT_H : 0,
|
|
87
|
+
transitionDuration: `${PANEL_TRANSITION_MS}ms`,
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<div className="px-3 py-2">
|
|
91
|
+
<textarea
|
|
92
|
+
ref={textareaRef}
|
|
93
|
+
value={value}
|
|
94
|
+
onChange={(e) => setValue(e.target.value)}
|
|
95
|
+
onBlur={() => {
|
|
96
|
+
void flush();
|
|
97
|
+
}}
|
|
98
|
+
onKeyDown={(e) => {
|
|
99
|
+
if (e.key === 'Escape') {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
textareaRef.current?.blur();
|
|
102
|
+
} else if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
void flush();
|
|
105
|
+
}
|
|
106
|
+
}}
|
|
107
|
+
placeholder={t.notesDrawer.placeholder}
|
|
108
|
+
rows={6}
|
|
109
|
+
spellCheck
|
|
110
|
+
className="block h-[150px] w-full resize-none rounded-[6px] border border-border bg-card px-3 py-2 text-[13px] leading-relaxed text-card-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
</aside>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -61,6 +61,9 @@ export function Player({
|
|
|
61
61
|
const [keyboardDriven, setKeyboardDriven] = useState(false);
|
|
62
62
|
const [startedAt] = useState(() => Date.now());
|
|
63
63
|
|
|
64
|
+
const canPrev = index > 0;
|
|
65
|
+
const canNext = index < pages.length - 1;
|
|
66
|
+
|
|
64
67
|
const goPrev = useCallback(() => {
|
|
65
68
|
if (index > 0) onIndexChange(index - 1);
|
|
66
69
|
}, [index, onIndexChange]);
|
|
@@ -73,8 +76,8 @@ export function Player({
|
|
|
73
76
|
useWheelPageNavigation({
|
|
74
77
|
ref: rootRef,
|
|
75
78
|
enabled: !overlayActive,
|
|
76
|
-
canPrev
|
|
77
|
-
canNext
|
|
79
|
+
canPrev,
|
|
80
|
+
canNext,
|
|
78
81
|
onPrev: goPrev,
|
|
79
82
|
onNext: goNext,
|
|
80
83
|
});
|
|
@@ -269,7 +272,8 @@ export function Player({
|
|
|
269
272
|
ref={setRoot}
|
|
270
273
|
className={cn(
|
|
271
274
|
'relative flex h-dvh w-screen items-center justify-center bg-black',
|
|
272
|
-
|
|
275
|
+
controls && 'select-none',
|
|
276
|
+
controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
|
|
273
277
|
)}
|
|
274
278
|
>
|
|
275
279
|
<SlideCanvas flat design={design}>
|
|
@@ -280,15 +284,15 @@ export function Player({
|
|
|
280
284
|
type="button"
|
|
281
285
|
aria-label="Previous page"
|
|
282
286
|
onClick={goPrev}
|
|
283
|
-
disabled={
|
|
284
|
-
className=
|
|
287
|
+
disabled={!canPrev}
|
|
288
|
+
className={cn('absolute inset-y-0 left-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
|
|
285
289
|
/>
|
|
286
290
|
<button
|
|
287
291
|
type="button"
|
|
288
292
|
aria-label="Next page"
|
|
289
293
|
onClick={goNext}
|
|
290
|
-
disabled={
|
|
291
|
-
className=
|
|
294
|
+
disabled={!canNext}
|
|
295
|
+
className={cn('absolute inset-y-0 right-0 z-10 w-[30%]', hideCursor && 'cursor-none')}
|
|
292
296
|
/>
|
|
293
297
|
|
|
294
298
|
{controls && (
|
|
@@ -96,9 +96,9 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
96
96
|
</div>
|
|
97
97
|
<div ref={gridRef} className="min-h-0 flex-1 overflow-auto px-8 pb-8">
|
|
98
98
|
<div
|
|
99
|
-
className="grid gap-5"
|
|
99
|
+
className="grid justify-center gap-5"
|
|
100
100
|
style={{
|
|
101
|
-
gridTemplateColumns: `repeat(auto-fill,
|
|
101
|
+
gridTemplateColumns: `repeat(auto-fill, ${THUMB_W}px)`,
|
|
102
102
|
}}
|
|
103
103
|
>
|
|
104
104
|
{pages.map((PageComp, i) => {
|
|
@@ -67,6 +67,9 @@ type Row =
|
|
|
67
67
|
}
|
|
68
68
|
| {
|
|
69
69
|
kind: 'draft';
|
|
70
|
+
}
|
|
71
|
+
| {
|
|
72
|
+
kind: 'themes';
|
|
70
73
|
};
|
|
71
74
|
|
|
72
75
|
export function FolderItem({
|
|
@@ -89,7 +92,9 @@ export function FolderItem({
|
|
|
89
92
|
const slideDragActive = useSlideDragActive();
|
|
90
93
|
const t = useLocale();
|
|
91
94
|
|
|
92
|
-
const
|
|
95
|
+
const acceptsSlideDrop = row.kind !== 'themes';
|
|
96
|
+
const isSlideDrag = (e: React.DragEvent) =>
|
|
97
|
+
acceptsSlideDrop && e.dataTransfer.types.includes(SLIDE_DND_MIME);
|
|
93
98
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
94
99
|
if (!isSlideDrag(e)) return;
|
|
95
100
|
dragDepth.current += 1;
|
|
@@ -106,6 +111,7 @@ export function FolderItem({
|
|
|
106
111
|
if (dragDepth.current === 0) setDragOver(false);
|
|
107
112
|
};
|
|
108
113
|
const handleDrop = (e: React.DragEvent) => {
|
|
114
|
+
if (!acceptsSlideDrop) return;
|
|
109
115
|
const slideId = e.dataTransfer.getData(SLIDE_DND_MIME);
|
|
110
116
|
dragDepth.current = 0;
|
|
111
117
|
setDragOver(false);
|
|
@@ -114,9 +120,14 @@ export function FolderItem({
|
|
|
114
120
|
onDropSlide(slideId);
|
|
115
121
|
};
|
|
116
122
|
|
|
117
|
-
const icon =
|
|
118
|
-
row.kind === 'draft'
|
|
119
|
-
|
|
123
|
+
const icon: FolderIcon =
|
|
124
|
+
row.kind === 'draft'
|
|
125
|
+
? { type: 'emoji', value: '📝' }
|
|
126
|
+
: row.kind === 'themes'
|
|
127
|
+
? { type: 'emoji', value: '🎨' }
|
|
128
|
+
: row.folder.icon;
|
|
129
|
+
const label =
|
|
130
|
+
row.kind === 'draft' ? t.home.draft : row.kind === 'themes' ? t.home.themes : row.folder.name;
|
|
120
131
|
|
|
121
132
|
const commitRename = () => {
|
|
122
133
|
if (row.kind !== 'folder') return;
|
|
@@ -133,7 +144,7 @@ export function FolderItem({
|
|
|
133
144
|
selected
|
|
134
145
|
? 'bg-muted text-foreground before:absolute before:inset-y-1.5 before:-left-0.5 before:w-[2px] before:rounded-full before:bg-brand'
|
|
135
146
|
: 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
|
|
136
|
-
slideDragActive && !dragOver && 'ring-1 ring-foreground/10',
|
|
147
|
+
slideDragActive && acceptsSlideDrop && !dragOver && 'ring-1 ring-foreground/10',
|
|
137
148
|
dragOver &&
|
|
138
149
|
'bg-brand/10 text-foreground ring-1 ring-brand ring-offset-1 ring-offset-sidebar motion-safe:scale-[1.01] motion-safe:transition-transform',
|
|
139
150
|
)}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
import type { FolderIcon } from '../../lib/sdk';
|
|
3
|
+
import { FolderIconChip } from './folder-item';
|
|
4
|
+
|
|
5
|
+
export function MobileFolderPill({
|
|
6
|
+
icon,
|
|
7
|
+
label,
|
|
8
|
+
count,
|
|
9
|
+
active,
|
|
10
|
+
onClick,
|
|
11
|
+
}: {
|
|
12
|
+
icon: FolderIcon;
|
|
13
|
+
label: string;
|
|
14
|
+
count: number;
|
|
15
|
+
active: boolean;
|
|
16
|
+
onClick: () => void;
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
onClick={onClick}
|
|
22
|
+
className={cn(
|
|
23
|
+
'flex shrink-0 items-center gap-1.5 rounded-[5px] border px-2.5 py-1 text-[11.5px] font-medium transition-colors',
|
|
24
|
+
active
|
|
25
|
+
? 'border-foreground/40 bg-foreground text-background'
|
|
26
|
+
: 'border-border bg-card text-muted-foreground hover:text-foreground',
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
<FolderIconChip icon={icon} className="size-3.5 text-sm" />
|
|
30
|
+
<span className="max-w-[8rem] truncate">{label}</span>
|
|
31
|
+
<span className="folio nums">{count.toString().padStart(2, '0')}</span>
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -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
|
+
}
|