@open-slide/core 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-6BeQ3cxb.js → build-1Rqivz0d.js} +2 -2
- package/dist/cli/bin.js +5 -5
- package/dist/{config-AxZ5OE1u.js → config-XZJnC_fu.js} +735 -64
- package/dist/{config-CtT8K4VF.d.ts → config-s0YUbmUe.d.ts} +3 -1
- package/dist/{dev-C9eLmUEq.js → dev-0W8gYiSa.js} +2 -2
- package/dist/en-7GU-DHbJ.js +361 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +229 -39
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +136 -342
- package/dist/{preview-Cunm-f4i.js → preview-DT9hJvzM.js} +2 -2
- package/dist/sync-j9_QPovT.js +3 -0
- package/dist/{types-CRHIeoNq.d.ts → types-QCpkHkiS.d.ts} +42 -2
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +2 -2
- package/package.json +9 -1
- package/skills/create-slide/SKILL.md +1 -1
- package/skills/create-theme/SKILL.md +60 -12
- package/skills/slide-authoring/SKILL.md +21 -2
- package/src/app/app.tsx +13 -1
- package/src/app/components/asset-view.tsx +37 -22
- package/src/app/components/image-placeholder.tsx +123 -1
- package/src/app/components/inspector/inspect-overlay.tsx +49 -3
- package/src/app/components/inspector/inspector-panel.tsx +370 -30
- package/src/app/components/inspector/inspector-provider.tsx +390 -49
- package/src/app/components/player.tsx +25 -5
- package/src/app/components/present/control-bar.tsx +12 -0
- package/src/app/components/sidebar/folder-item.tsx +27 -5
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar.tsx +20 -0
- package/src/app/components/themes/theme-detail.tsx +300 -0
- package/src/app/components/themes/themes-gallery.tsx +146 -0
- package/src/app/components/thumbnail-rail.tsx +17 -5
- package/src/app/lib/assets.ts +55 -2
- package/src/app/lib/export-pdf.ts +6 -0
- package/src/app/lib/inspector/use-editor.ts +9 -1
- package/src/app/lib/sdk.ts +1 -0
- package/src/app/lib/slides.ts +17 -1
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +194 -0
- package/src/app/routes/home.tsx +89 -207
- package/src/app/routes/presenter.tsx +2 -20
- package/src/app/routes/slide.tsx +217 -54
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/virtual.d.ts +20 -0
- package/src/locale/en.ts +49 -7
- package/src/locale/ja.ts +50 -7
- package/src/locale/types.ts +44 -2
- package/src/locale/zh-cn.ts +49 -8
- package/src/locale/zh-tw.ts +49 -8
- package/dist/sync-B4eLo2H6.js +0 -3
- /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
- /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
|
@@ -32,6 +32,12 @@ type Props = {
|
|
|
32
32
|
allowExit?: boolean;
|
|
33
33
|
controls?: boolean;
|
|
34
34
|
slideId?: string;
|
|
35
|
+
/**
|
|
36
|
+
* When true, the Player enters the browser Fullscreen API on mount.
|
|
37
|
+
* When false, it renders as a window-sized overlay (viewport-filling)
|
|
38
|
+
* without entering fullscreen. Defaults to true for back-compat.
|
|
39
|
+
*/
|
|
40
|
+
fullscreen?: boolean;
|
|
35
41
|
};
|
|
36
42
|
|
|
37
43
|
export function Player({
|
|
@@ -43,8 +49,9 @@ export function Player({
|
|
|
43
49
|
allowExit = true,
|
|
44
50
|
controls = false,
|
|
45
51
|
slideId,
|
|
52
|
+
fullscreen = true,
|
|
46
53
|
}: Props) {
|
|
47
|
-
const rootRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
48
55
|
// Mirrored as state so descendants portaling *into* the player subtree
|
|
49
56
|
// (tooltips, popovers — the body is outside the fullscreen tree) re-render
|
|
50
57
|
// once the node mounts.
|
|
@@ -60,6 +67,12 @@ export function Player({
|
|
|
60
67
|
const [laser, setLaser] = useState(false);
|
|
61
68
|
const [keyboardDriven, setKeyboardDriven] = useState(false);
|
|
62
69
|
const [startedAt] = useState(() => Date.now());
|
|
70
|
+
const [windowed, setWindowed] = useState(!fullscreen);
|
|
71
|
+
// Mirror windowed into a ref so the fullscreenchange listener can read the
|
|
72
|
+
// latest value without re-binding — exits from window mode must not call
|
|
73
|
+
// onExit, but exits initiated by the browser (Esc in fullscreen) must.
|
|
74
|
+
const windowedRef = useRef(windowed);
|
|
75
|
+
windowedRef.current = windowed;
|
|
63
76
|
|
|
64
77
|
const canPrev = index > 0;
|
|
65
78
|
const canNext = index < pages.length - 1;
|
|
@@ -90,6 +103,7 @@ export function Player({
|
|
|
90
103
|
});
|
|
91
104
|
|
|
92
105
|
useEffect(() => {
|
|
106
|
+
if (windowed) return;
|
|
93
107
|
const el = rootRef.current;
|
|
94
108
|
if (!el) return;
|
|
95
109
|
if (document.fullscreenElement !== el) {
|
|
@@ -98,17 +112,21 @@ export function Player({
|
|
|
98
112
|
return () => {
|
|
99
113
|
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
|
100
114
|
};
|
|
101
|
-
}, []);
|
|
115
|
+
}, [windowed]);
|
|
102
116
|
|
|
103
117
|
useEffect(() => {
|
|
104
118
|
if (!allowExit) return;
|
|
105
119
|
const onFsChange = () => {
|
|
106
|
-
if (!document.fullscreenElement) onExit();
|
|
120
|
+
if (!document.fullscreenElement && !windowedRef.current) onExit();
|
|
107
121
|
};
|
|
108
122
|
document.addEventListener('fullscreenchange', onFsChange);
|
|
109
123
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
110
124
|
}, [onExit, allowExit]);
|
|
111
125
|
|
|
126
|
+
const toggleFullscreen = useCallback(() => {
|
|
127
|
+
setWindowed((w) => !w);
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
112
130
|
// Player is the source of truth: it re-publishes state on every change
|
|
113
131
|
// and answers `request-state` pings so newly opened presenter windows
|
|
114
132
|
// hydrate immediately.
|
|
@@ -271,7 +289,7 @@ export function Player({
|
|
|
271
289
|
<div
|
|
272
290
|
ref={setRoot}
|
|
273
291
|
className={cn(
|
|
274
|
-
'
|
|
292
|
+
'fixed inset-0 flex items-center justify-center overflow-hidden bg-black',
|
|
275
293
|
controls && 'select-none',
|
|
276
294
|
controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
|
|
277
295
|
)}
|
|
@@ -310,12 +328,14 @@ export function Player({
|
|
|
310
328
|
blackout={blackout}
|
|
311
329
|
laser={laser}
|
|
312
330
|
allowExit={allowExit}
|
|
331
|
+
windowed={windowed}
|
|
313
332
|
onPrev={goPrev}
|
|
314
333
|
onNext={goNext}
|
|
315
334
|
onOverview={() => setOverviewOpen(true)}
|
|
316
335
|
onBlackout={(mode) => setBlackout((c) => (c === mode ? null : mode))}
|
|
317
336
|
onLaser={() => setLaser((v) => !v)}
|
|
318
337
|
onPresenter={() => slideId && openPresenterWindow(slideId)}
|
|
338
|
+
onToggleFullscreen={toggleFullscreen}
|
|
319
339
|
onHelp={() => setHelpOpen(true)}
|
|
320
340
|
onExit={onExit}
|
|
321
341
|
/>
|
|
@@ -334,7 +354,7 @@ export function Player({
|
|
|
334
354
|
);
|
|
335
355
|
}
|
|
336
356
|
|
|
337
|
-
function openPresenterWindow(slideId: string) {
|
|
357
|
+
export function openPresenterWindow(slideId: string) {
|
|
338
358
|
if (typeof window === 'undefined') return;
|
|
339
359
|
const url = `/s/${encodeURIComponent(slideId)}/presenter`;
|
|
340
360
|
window.open(url, `open-slide-presenter-${slideId}`, 'popup,width=1280,height=800');
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
Grid2x2,
|
|
6
6
|
Keyboard,
|
|
7
7
|
LogOut,
|
|
8
|
+
Maximize,
|
|
9
|
+
Minimize,
|
|
8
10
|
MonitorSpeaker,
|
|
9
11
|
Square,
|
|
10
12
|
Sun,
|
|
@@ -24,12 +26,14 @@ type Props = {
|
|
|
24
26
|
blackout: 'black' | 'white' | null;
|
|
25
27
|
laser: boolean;
|
|
26
28
|
allowExit: boolean;
|
|
29
|
+
windowed: boolean;
|
|
27
30
|
onPrev: () => void;
|
|
28
31
|
onNext: () => void;
|
|
29
32
|
onOverview: () => void;
|
|
30
33
|
onBlackout: (mode: 'black' | 'white') => void;
|
|
31
34
|
onLaser: () => void;
|
|
32
35
|
onPresenter: () => void;
|
|
36
|
+
onToggleFullscreen: () => void;
|
|
33
37
|
onHelp: () => void;
|
|
34
38
|
onExit: () => void;
|
|
35
39
|
/**
|
|
@@ -48,12 +52,14 @@ export function PresentControlBar({
|
|
|
48
52
|
blackout,
|
|
49
53
|
laser,
|
|
50
54
|
allowExit,
|
|
55
|
+
windowed,
|
|
51
56
|
onPrev,
|
|
52
57
|
onNext,
|
|
53
58
|
onOverview,
|
|
54
59
|
onBlackout,
|
|
55
60
|
onLaser,
|
|
56
61
|
onPresenter,
|
|
62
|
+
onToggleFullscreen,
|
|
57
63
|
onHelp,
|
|
58
64
|
onExit,
|
|
59
65
|
tooltipContainer,
|
|
@@ -123,6 +129,12 @@ export function PresentControlBar({
|
|
|
123
129
|
<BarButton label={t.present.presenterAria} onClick={onPresenter}>
|
|
124
130
|
<MonitorSpeaker className="size-4" />
|
|
125
131
|
</BarButton>
|
|
132
|
+
<BarButton
|
|
133
|
+
label={windowed ? t.present.enterFullscreenAria : t.present.exitFullscreenAria}
|
|
134
|
+
onClick={onToggleFullscreen}
|
|
135
|
+
>
|
|
136
|
+
{windowed ? <Maximize className="size-4" /> : <Minimize className="size-4" />}
|
|
137
|
+
</BarButton>
|
|
126
138
|
<BarButton label={t.present.helpAria} onClick={onHelp}>
|
|
127
139
|
<Keyboard className="size-4" />
|
|
128
140
|
</BarButton>
|
|
@@ -67,6 +67,12 @@ type Row =
|
|
|
67
67
|
}
|
|
68
68
|
| {
|
|
69
69
|
kind: 'draft';
|
|
70
|
+
}
|
|
71
|
+
| {
|
|
72
|
+
kind: 'themes';
|
|
73
|
+
}
|
|
74
|
+
| {
|
|
75
|
+
kind: 'assets';
|
|
70
76
|
};
|
|
71
77
|
|
|
72
78
|
export function FolderItem({
|
|
@@ -89,7 +95,9 @@ export function FolderItem({
|
|
|
89
95
|
const slideDragActive = useSlideDragActive();
|
|
90
96
|
const t = useLocale();
|
|
91
97
|
|
|
92
|
-
const
|
|
98
|
+
const acceptsSlideDrop = row.kind !== 'themes' && row.kind !== 'assets';
|
|
99
|
+
const isSlideDrag = (e: React.DragEvent) =>
|
|
100
|
+
acceptsSlideDrop && e.dataTransfer.types.includes(SLIDE_DND_MIME);
|
|
93
101
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
94
102
|
if (!isSlideDrag(e)) return;
|
|
95
103
|
dragDepth.current += 1;
|
|
@@ -106,6 +114,7 @@ export function FolderItem({
|
|
|
106
114
|
if (dragDepth.current === 0) setDragOver(false);
|
|
107
115
|
};
|
|
108
116
|
const handleDrop = (e: React.DragEvent) => {
|
|
117
|
+
if (!acceptsSlideDrop) return;
|
|
109
118
|
const slideId = e.dataTransfer.getData(SLIDE_DND_MIME);
|
|
110
119
|
dragDepth.current = 0;
|
|
111
120
|
setDragOver(false);
|
|
@@ -114,9 +123,22 @@ export function FolderItem({
|
|
|
114
123
|
onDropSlide(slideId);
|
|
115
124
|
};
|
|
116
125
|
|
|
117
|
-
const icon =
|
|
118
|
-
row.kind === 'draft'
|
|
119
|
-
|
|
126
|
+
const icon: FolderIcon =
|
|
127
|
+
row.kind === 'draft'
|
|
128
|
+
? { type: 'emoji', value: '📝' }
|
|
129
|
+
: row.kind === 'themes'
|
|
130
|
+
? { type: 'emoji', value: '🎨' }
|
|
131
|
+
: row.kind === 'assets'
|
|
132
|
+
? { type: 'emoji', value: '🗂️' }
|
|
133
|
+
: row.folder.icon;
|
|
134
|
+
const label =
|
|
135
|
+
row.kind === 'draft'
|
|
136
|
+
? t.home.draft
|
|
137
|
+
: row.kind === 'themes'
|
|
138
|
+
? t.home.themes
|
|
139
|
+
: row.kind === 'assets'
|
|
140
|
+
? t.home.assets
|
|
141
|
+
: row.folder.name;
|
|
120
142
|
|
|
121
143
|
const commitRename = () => {
|
|
122
144
|
if (row.kind !== 'folder') return;
|
|
@@ -133,7 +155,7 @@ export function FolderItem({
|
|
|
133
155
|
selected
|
|
134
156
|
? '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
157
|
: 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
|
|
136
|
-
slideDragActive && !dragOver && 'ring-1 ring-foreground/10',
|
|
158
|
+
slideDragActive && acceptsSlideDrop && !dragOver && 'ring-1 ring-foreground/10',
|
|
137
159
|
dragOver &&
|
|
138
160
|
'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
161
|
)}
|
|
@@ -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,14 @@ 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__';
|
|
13
|
+
export const ASSETS_ID = '__assets__';
|
|
12
14
|
|
|
13
15
|
export function Sidebar({
|
|
14
16
|
folders,
|
|
15
17
|
countFor,
|
|
18
|
+
themesCount,
|
|
19
|
+
assetsCount,
|
|
16
20
|
selectedId,
|
|
17
21
|
onSelect,
|
|
18
22
|
onCreate,
|
|
@@ -24,6 +28,8 @@ export function Sidebar({
|
|
|
24
28
|
}: {
|
|
25
29
|
folders: Folder[];
|
|
26
30
|
countFor: (folderId: string | null) => number;
|
|
31
|
+
themesCount: number;
|
|
32
|
+
assetsCount: number;
|
|
27
33
|
selectedId: string;
|
|
28
34
|
onSelect: (id: string) => void;
|
|
29
35
|
onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
|
|
@@ -111,6 +117,20 @@ export function Sidebar({
|
|
|
111
117
|
onSelect={() => onSelect(DRAFT_ID)}
|
|
112
118
|
onDropSlide={onDropToDraft}
|
|
113
119
|
/>
|
|
120
|
+
<FolderItem
|
|
121
|
+
row={{ kind: 'themes' }}
|
|
122
|
+
count={themesCount}
|
|
123
|
+
selected={selectedId === THEMES_ID}
|
|
124
|
+
onSelect={() => onSelect(THEMES_ID)}
|
|
125
|
+
onDropSlide={() => {}}
|
|
126
|
+
/>
|
|
127
|
+
<FolderItem
|
|
128
|
+
row={{ kind: 'assets' }}
|
|
129
|
+
count={assetsCount}
|
|
130
|
+
selected={selectedId === ASSETS_ID}
|
|
131
|
+
onSelect={() => onSelect(ASSETS_ID)}
|
|
132
|
+
onDropSlide={() => {}}
|
|
133
|
+
/>
|
|
114
134
|
</div>
|
|
115
135
|
|
|
116
136
|
<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
|
+
}
|