@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
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import "./design-
|
|
2
|
-
import { createViteConfig } from "./config-
|
|
1
|
+
import "./design-cpzS8aud.js";
|
|
2
|
+
import { createViteConfig } from "./config-XZJnC_fu.js";
|
|
3
3
|
import { mergeConfig, preview as preview$1 } from "vite";
|
|
4
4
|
|
|
5
5
|
//#region src/cli/preview.ts
|
|
@@ -38,6 +38,8 @@ type Locale = {
|
|
|
38
38
|
home: {
|
|
39
39
|
appTitle: string;
|
|
40
40
|
draft: string;
|
|
41
|
+
themes: string;
|
|
42
|
+
assets: string;
|
|
41
43
|
folders: string;
|
|
42
44
|
newFolder: string;
|
|
43
45
|
folderName: string;
|
|
@@ -52,7 +54,6 @@ type Locale = {
|
|
|
52
54
|
nothingMatchesSuffix: string;
|
|
53
55
|
noSlidesYet: string;
|
|
54
56
|
createSlideHintPrefix: string;
|
|
55
|
-
createSlideHintMid: string;
|
|
56
57
|
createSlideHintSuffix: string;
|
|
57
58
|
folderEmptyTitle: string;
|
|
58
59
|
folderEmptyHint: string;
|
|
@@ -87,11 +88,18 @@ type Locale = {
|
|
|
87
88
|
backToHome: string;
|
|
88
89
|
agentConnected: string;
|
|
89
90
|
agentConnectedTooltip: string;
|
|
91
|
+
agentDisconnected: string;
|
|
92
|
+
agentDisconnectedTooltip: string;
|
|
90
93
|
download: string;
|
|
91
94
|
exportAsHtml: string;
|
|
92
95
|
exportAsPdf: string;
|
|
93
96
|
pdfExportFailed: string;
|
|
97
|
+
pdfExportSafariUnsupported: string;
|
|
94
98
|
present: string;
|
|
99
|
+
presentMenuAria: string;
|
|
100
|
+
presentInWindow: string;
|
|
101
|
+
presentFullscreen: string;
|
|
102
|
+
presentPresenter: string;
|
|
95
103
|
slidesTab: string;
|
|
96
104
|
assetsTab: string;
|
|
97
105
|
renameSlide: string;
|
|
@@ -134,6 +142,8 @@ type Locale = {
|
|
|
134
142
|
whiteoutAria: string;
|
|
135
143
|
laserAria: string;
|
|
136
144
|
presenterAria: string;
|
|
145
|
+
enterFullscreenAria: string;
|
|
146
|
+
exitFullscreenAria: string;
|
|
137
147
|
helpAria: string;
|
|
138
148
|
exitAria: string;
|
|
139
149
|
elapsedTime: string;
|
|
@@ -161,6 +171,8 @@ type Locale = {
|
|
|
161
171
|
deselect: string;
|
|
162
172
|
agentWatching: string;
|
|
163
173
|
agentWatchingTooltip: string;
|
|
174
|
+
agentNotWatching: string;
|
|
175
|
+
agentNotWatchingTooltip: string;
|
|
164
176
|
contentSection: string;
|
|
165
177
|
typographySection: string;
|
|
166
178
|
colorSection: string;
|
|
@@ -241,6 +253,8 @@ type Locale = {
|
|
|
241
253
|
devOnlyMessage: string;
|
|
242
254
|
sectionAria: string;
|
|
243
255
|
eyebrow: string;
|
|
256
|
+
scopeSlide: string;
|
|
257
|
+
scopeGlobal: string;
|
|
244
258
|
/** templates: "{count} file" / "{count} files" */
|
|
245
259
|
fileCount: Plural;
|
|
246
260
|
searchLogos: string;
|
|
@@ -259,7 +273,7 @@ type Locale = {
|
|
|
259
273
|
renameMenuItem: string;
|
|
260
274
|
deleteMenuItem: string;
|
|
261
275
|
conflictTitle: string;
|
|
262
|
-
/** template: "{name} is already in
|
|
276
|
+
/** template: "{name} is already in the assets folder." */
|
|
263
277
|
conflictDescription: string;
|
|
264
278
|
conflictReplace: string;
|
|
265
279
|
conflictRenameCopy: string;
|
|
@@ -314,6 +328,7 @@ type Locale = {
|
|
|
314
328
|
toastDeleted: string;
|
|
315
329
|
toastDuplicateFailed: string;
|
|
316
330
|
toastDeleteFailed: string;
|
|
331
|
+
resizeRail: string;
|
|
317
332
|
};
|
|
318
333
|
pdfToast: {
|
|
319
334
|
title: string;
|
|
@@ -333,6 +348,11 @@ type Locale = {
|
|
|
333
348
|
prevAria: string;
|
|
334
349
|
nextAria: string;
|
|
335
350
|
};
|
|
351
|
+
imagePlaceholder: {
|
|
352
|
+
dropOverlay: string;
|
|
353
|
+
uploading: string;
|
|
354
|
+
uploadFailed: string;
|
|
355
|
+
};
|
|
336
356
|
notesDrawer: {
|
|
337
357
|
toggle: string;
|
|
338
358
|
/** template: "page {n}/{total}" */
|
|
@@ -343,5 +363,25 @@ type Locale = {
|
|
|
343
363
|
/** template: "Save failed: {msg}" */
|
|
344
364
|
statusError: string;
|
|
345
365
|
};
|
|
366
|
+
themes: {
|
|
367
|
+
title: string;
|
|
368
|
+
noThemesTitle: string;
|
|
369
|
+
noThemesHintPrefix: string;
|
|
370
|
+
noThemesHintSuffix: string;
|
|
371
|
+
noDemoYet: string;
|
|
372
|
+
noDemoHintPrefix: string;
|
|
373
|
+
noDemoHintSuffix: string;
|
|
374
|
+
backToGallery: string;
|
|
375
|
+
/** template: "page {n}/{total}" */
|
|
376
|
+
pageOf: string;
|
|
377
|
+
nextPageAria: string;
|
|
378
|
+
prevPageAria: string;
|
|
379
|
+
/** template: "Open theme {name}" */
|
|
380
|
+
openThemeAria: string;
|
|
381
|
+
usedBy: string;
|
|
382
|
+
usedByEmpty: string;
|
|
383
|
+
expandPromptAria: string;
|
|
384
|
+
collapsePromptAria: string;
|
|
385
|
+
};
|
|
346
386
|
}; //#endregion
|
|
347
387
|
export { Locale, Plural };
|
package/dist/vite/index.d.ts
CHANGED
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-slide/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -42,11 +42,19 @@
|
|
|
42
42
|
"vite"
|
|
43
43
|
],
|
|
44
44
|
"license": "MIT",
|
|
45
|
+
"author": {
|
|
46
|
+
"name": "1weiho",
|
|
47
|
+
"url": "https://github.com/1weiho"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://open-slide.dev",
|
|
45
50
|
"repository": {
|
|
46
51
|
"type": "git",
|
|
47
52
|
"url": "git+https://github.com/1weiho/open-slide.git",
|
|
48
53
|
"directory": "packages/core"
|
|
49
54
|
},
|
|
55
|
+
"bugs": {
|
|
56
|
+
"url": "https://github.com/1weiho/open-slide/issues"
|
|
57
|
+
},
|
|
50
58
|
"publishConfig": {
|
|
51
59
|
"access": "public"
|
|
52
60
|
},
|
|
@@ -13,7 +13,7 @@ You only write files under `slides/<id>/`. Never modify `package.json`, `open-sl
|
|
|
13
13
|
|
|
14
14
|
List files under `themes/`. If any theme markdown files exist (anything other than `README.md`), call `AskUserQuestion` with each theme id as an option plus a final **"no theme — design from scratch"** option.
|
|
15
15
|
|
|
16
|
-
- If the user picks a theme: read `themes/<id>.md` end-to-end. The theme's palette, typography, layout, and Title/Footer components are now authoritative — copy them directly into the slide. In Step 2, skip the **aesthetic direction** question (the theme already commits to one direction); you still need the topic itself, so confirm it before moving on. Page count, text density, and motion are independent of theme — ask those normally.
|
|
16
|
+
- If the user picks a theme: read `themes/<id>.md` end-to-end. The theme's palette, typography, layout, and Title/Footer components are now authoritative — copy them directly into the slide. **Also set `theme: '<theme-id>'` on the `meta` export in `index.tsx`** (e.g. `export const meta: SlideMeta = { title: '…', theme: '<theme-id>' };`) so the slide back-links to the theme (chip on the slide card + listing on `/themes/<id>`). In Step 2, skip the **aesthetic direction** question (the theme already commits to one direction); you still need the topic itself, so confirm it before moving on. Page count, text density, and motion are independent of theme — ask those normally.
|
|
17
17
|
- If the user picks "no theme", or `themes/` is empty (or contains only `README.md`): proceed to Step 2 unchanged.
|
|
18
18
|
|
|
19
19
|
If you skip the aesthetic question because a theme was picked, restate the theme name in Step 2 so the user can correct course before you start writing.
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: create-theme
|
|
3
|
-
description: Use this skill when the user wants to create, draft, author, or extract a slide theme in this open-slide repo. Triggers on phrases like "create a theme", "make a theme called X", "extract a theme from <slide>", "build a theme from these images". Produces
|
|
3
|
+
description: Use this skill when the user wants to create, draft, author, or extract a slide theme in this open-slide repo. Triggers on phrases like "create a theme", "make a theme called X", "extract a theme from <slide>", "build a theme from these images". Produces two paired files under `themes/`: `<id>.md` (palette, typography, layout, fixed Title/Footer components, motion) and `<id>.demo.tsx` (a runnable demo slide that the dev-UI Themes panel previews). Do NOT use for editing real slides — only for authoring the theme bundle.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Create a slide theme
|
|
7
7
|
|
|
8
|
-
This skill produces
|
|
8
|
+
This skill produces a **theme bundle** under `themes/`: two paired files that together describe a reusable visual identity.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
1. `themes/<id>.md` — agent-facing documentation: palette, typography, layout, fixed Title/Footer/Eyebrow components, motion. This is what `create-slide` reads when an author picks the theme.
|
|
11
|
+
2. `themes/<id>.demo.tsx` — a runnable mini-slide (a normal slide module: `export default Page[]`) that demonstrates the theme on 2–3 pages. The dev UI's **Themes panel** loads this file and renders it as the theme's live preview.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
Both files share the same stem so the runtime can pair them automatically.
|
|
14
|
+
|
|
15
|
+
A theme is **distinct from a slide's `design` const**. The theme markdown is authoring-time aesthetic direction (copied into a real slide's source by `create-slide`). The demo `.tsx` is a self-contained preview, not a real slide — it does not appear in the slides list. A per-slide `const design: DesignSystem = { … }` (declared at the top of `slides/<id>/index.tsx`) is the runtime tokens object the user can tweak from the Design panel. The markdown commits the *direction*; the per-slide `design` const makes the slide *tweakable*; the demo `.tsx` makes the theme *previewable*.
|
|
16
|
+
|
|
17
|
+
You only write files under `themes/<id>.md` and `themes/<id>.demo.tsx`. Never modify real slides or other configuration. The canvas / type-scale defaults that themes can override live in the **`slide-authoring`** skill — read it before writing the theme so your overrides are stated explicitly.
|
|
13
18
|
|
|
14
19
|
## Step 1 — Identify the input source
|
|
15
20
|
|
|
@@ -47,7 +52,6 @@ Produce a file with this exact section order. Section bodies adapt to the theme;
|
|
|
47
52
|
---
|
|
48
53
|
name: <Human title, e.g. "Editorial Noir">
|
|
49
54
|
description: <one-line elevator pitch>
|
|
50
|
-
mode: light | dark | system
|
|
51
55
|
---
|
|
52
56
|
|
|
53
57
|
# <Theme name>
|
|
@@ -163,6 +167,46 @@ const Cover: Page = () => (
|
|
|
163
167
|
```
|
|
164
168
|
````
|
|
165
169
|
|
|
170
|
+
## Step 4b — Write `themes/<id>.demo.tsx`
|
|
171
|
+
|
|
172
|
+
The demo is a normal slide module — same shape as `slides/<id>/index.tsx`, just sitting under `themes/` so the runtime knows it's preview-only. The dev-UI Themes panel imports it and renders it inside `SlideCanvas` (1920×1080).
|
|
173
|
+
|
|
174
|
+
Contract:
|
|
175
|
+
|
|
176
|
+
- `import type { Page } from '@open-slide/core';`
|
|
177
|
+
- Inline the **same** `Title`, `Footer`, `Eyebrow` components defined in the theme markdown — verbatim, no abstractions, no imports from elsewhere. The demo and the markdown must stay in lockstep so what the user sees in the panel matches what `create-slide` will paste into a real slide.
|
|
178
|
+
- Export 2–3 `Page` components and a default array. Aim for: a Cover (Eyebrow + Title + subtitle), one Content page exercising body type + accent, and a Closer or "End" card. The "Example usage" block at the bottom of the markdown is a good starting point — extend it.
|
|
179
|
+
- If the theme has runtime-tweakable tokens worth surfacing in the Design panel later, also `export const design: DesignSystem = {...}`.
|
|
180
|
+
- No external assets, no `import` from `@/`, no slides-only helpers (e.g. `WindowShell` from a real slide). Demo files must be self-contained.
|
|
181
|
+
|
|
182
|
+
Skeleton:
|
|
183
|
+
|
|
184
|
+
```tsx
|
|
185
|
+
import type { Page } from '@open-slide/core';
|
|
186
|
+
|
|
187
|
+
const Title = ({ children }: { children: React.ReactNode }) => (
|
|
188
|
+
// …same JSX as in themes/<id>.md
|
|
189
|
+
);
|
|
190
|
+
const Footer = ({ pageNum, total }: { pageNum: number; total: number }) => (
|
|
191
|
+
// …
|
|
192
|
+
);
|
|
193
|
+
const Eyebrow = ({ children }: { children: React.ReactNode }) => (
|
|
194
|
+
// …
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const Cover: Page = () => (
|
|
198
|
+
// …
|
|
199
|
+
);
|
|
200
|
+
const Content: Page = () => (
|
|
201
|
+
// …
|
|
202
|
+
);
|
|
203
|
+
const Closer: Page = () => (
|
|
204
|
+
// …
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
export default [Cover, Content, Closer];
|
|
208
|
+
```
|
|
209
|
+
|
|
166
210
|
## Step 5 — Self-review
|
|
167
211
|
|
|
168
212
|
Run this checklist before finishing:
|
|
@@ -172,23 +216,27 @@ Run this checklist before finishing:
|
|
|
172
216
|
- [ ] At least Title and Footer are defined as paste-ready React with concrete inline styles.
|
|
173
217
|
- [ ] Motion section commits to one of static / subtle / rich.
|
|
174
218
|
- [ ] Aesthetic paragraph names a single coherent direction.
|
|
175
|
-
- [ ]
|
|
176
|
-
- [ ]
|
|
219
|
+
- [ ] Both files written: `themes/<id>.md` and `themes/<id>.demo.tsx`. No slide changes, no config changes.
|
|
220
|
+
- [ ] Demo `.tsx` exports 2–3 pages and inlines the same Title/Footer/Eyebrow components defined in the markdown.
|
|
221
|
+
- [ ] Demo opens cleanly in the **Themes** panel of the dev UI — re-checked by you only by reading the file (do not start a server).
|
|
177
222
|
|
|
178
223
|
## Step 6 — Hand off
|
|
179
224
|
|
|
180
225
|
Tell the user:
|
|
181
226
|
|
|
182
|
-
- The theme id and file
|
|
183
|
-
- That
|
|
227
|
+
- The theme id and the two file paths (`themes/<id>.md` + `themes/<id>.demo.tsx`).
|
|
228
|
+
- That the demo will appear in the dev UI's **Themes** panel as a live card and detail view (HMR — no restart needed).
|
|
229
|
+
- That `/create-slide` will list the theme as a picker option on its next run.
|
|
184
230
|
- A one-line summary of the look (palette + aesthetic).
|
|
185
231
|
|
|
186
|
-
Do not run the dev server. Do not modify slides — even to demonstrate the theme;
|
|
232
|
+
Do not run the dev server. Do not modify real slides — even to demonstrate the theme; the demo `.tsx` is the demonstration.
|
|
187
233
|
|
|
188
234
|
## Anti-patterns
|
|
189
235
|
|
|
190
|
-
- ❌ Writing executable code in `themes/<id>.md` outside the labeled component snippets — the
|
|
191
|
-
- ❌ Producing
|
|
236
|
+
- ❌ Writing executable code in `themes/<id>.md` outside the labeled component snippets — the markdown is documentation.
|
|
237
|
+
- ❌ Producing only the markdown without the demo, or only the demo without the markdown. A theme is the **bundle** — both files, every time.
|
|
238
|
+
- ❌ Treating `themes/<id>.demo.tsx` as a real slide. It is preview-only and lives outside the slides list; never put it under `slides/`.
|
|
239
|
+
- ❌ Importing from `@/` or any slide-specific helper inside the demo. The demo is self-contained.
|
|
192
240
|
- ❌ Inventing palette / fonts when the user supplied images or an existing slide. Extract, don't fabricate.
|
|
193
241
|
- ❌ Editing `slides/`, `packages/`, `package.json`, or `open-slide.config.ts`.
|
|
194
242
|
- ❌ Skipping the Fixed components section. Title and Footer are the most common reuse target — they must be paste-ready.
|
|
@@ -38,6 +38,17 @@ export default [Cover, Body] satisfies Page[];
|
|
|
38
38
|
- `export default` is a **non-empty array of zero-prop React components**, one per page, in order.
|
|
39
39
|
- `meta.title` (optional) shows in the slide header. Default is the folder name.
|
|
40
40
|
- The slide id is the kebab-case folder name. Pick something short and descriptive (`q2-roadmap`, `team-offsite-2026`).
|
|
41
|
+
- `meta.theme` (optional) marks the slide as built from a theme under `themes/`. The id must match a `<id>.md` basename. Surfaces a back-link chip on the slide card and lists the slide on `/themes/<id>`. Omit if the slide isn't derived from a registered theme.
|
|
42
|
+
|
|
43
|
+
## Editing an existing slide
|
|
44
|
+
|
|
45
|
+
A finished slide commonly runs 1000–1800 lines. When you only need to touch one page, **don't read the whole file** — locate the page first, then read just that range:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
grep -n ": Page = " slides/<id>/index.tsx
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This lists every `const Foo: Page = …` declaration with its line number. Read the target page with `Read` using `offset` + `limit` (~150 lines is usually enough to capture one page plus its helper components). Read the whole file only when you need cross-page context (palette audit, reordering, design const tweaks).
|
|
41
52
|
|
|
42
53
|
## Canvas
|
|
43
54
|
|
|
@@ -231,7 +242,7 @@ export default [Cover, Content] satisfies Page[];
|
|
|
231
242
|
|
|
232
243
|
## Assets
|
|
233
244
|
|
|
234
|
-
|
|
245
|
+
**Slide-local assets** live under `slides/<id>/assets/` — anything one-off to a single slide. Import them as ES modules:
|
|
235
246
|
|
|
236
247
|
```tsx
|
|
237
248
|
import hero from './assets/hero.jpg';
|
|
@@ -245,6 +256,14 @@ For URL-only access:
|
|
|
245
256
|
const videoUrl = new URL('./assets/intro.mp4', import.meta.url).href;
|
|
246
257
|
```
|
|
247
258
|
|
|
259
|
+
**Global assets** — anything reused across decks or themes (company logos, presenter avatars, recurring icons) — live in the project root `assets/` folder. Import them via the `@assets` alias:
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
import logo from '@assets/logos/acme.svg';
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
A `themes/*.md` file may name an asset path in its prose (e.g. "use `@assets/logos/acme.svg` in the title slot"); the slide imports it explicitly.
|
|
266
|
+
|
|
248
267
|
Skip the `assets/` folder entirely for pure-text slides.
|
|
249
268
|
|
|
250
269
|
## Image placeholders
|
|
@@ -328,7 +347,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
|
|
|
328
347
|
- [ ] Slide declares a top-level `export const design: DesignSystem = { … }` and references the values via `var(--osd-X)` (use `design.X` only when you need a JS number for arithmetic). Only omit the `design` const for a one-off slide whose palette is intentionally locked.
|
|
329
348
|
- [ ] One idea per page.
|
|
330
349
|
- [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit `<Component />` instances, not via `array.map` over a data list.
|
|
331
|
-
- [ ] All imported assets exist on disk under `slides/<id>/assets
|
|
350
|
+
- [ ] All imported assets exist on disk — slide-local under `slides/<id>/assets/`, or global under `assets/` (imported via `@assets/...`).
|
|
332
351
|
- [ ] Every `<ImagePlaceholder>` corresponds to a real image the user must supply — not decorative filler. If it could be replaced by typography or layout, it should be.
|
|
333
352
|
- [ ] Nothing outside `slides/<id>/` was edited.
|
|
334
353
|
|
package/src/app/app.tsx
CHANGED
|
@@ -2,15 +2,27 @@ import config from 'virtual:open-slide/config';
|
|
|
2
2
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
|
3
3
|
import { Toaster } from './components/ui/sonner';
|
|
4
4
|
import { useLocale } from './lib/use-locale';
|
|
5
|
+
import { AssetsPage } from './routes/assets';
|
|
5
6
|
import { Home } from './routes/home';
|
|
7
|
+
import { HomeShell } from './routes/home-shell';
|
|
6
8
|
import { Presenter } from './routes/presenter';
|
|
7
9
|
import { Slide } from './routes/slide';
|
|
10
|
+
import { ThemeDetailPage, ThemesGalleryPage } from './routes/themes';
|
|
8
11
|
|
|
9
12
|
export function App() {
|
|
10
13
|
return (
|
|
11
14
|
<BrowserRouter>
|
|
12
15
|
<Routes>
|
|
13
|
-
|
|
16
|
+
{config.build.showSlideBrowser ? (
|
|
17
|
+
<Route element={<HomeShell />}>
|
|
18
|
+
<Route path="/" element={<Home />} />
|
|
19
|
+
<Route path="/themes" element={<ThemesGalleryPage />} />
|
|
20
|
+
<Route path="/themes/:themeId" element={<ThemeDetailPage />} />
|
|
21
|
+
<Route path="/assets" element={<AssetsPage />} />
|
|
22
|
+
</Route>
|
|
23
|
+
) : (
|
|
24
|
+
<Route path="/" element={<NotFound />} />
|
|
25
|
+
)}
|
|
14
26
|
<Route path="/s/:slideId" element={<Slide />} />
|
|
15
27
|
<Route path="/s/:slideId/presenter" element={<Presenter />} />
|
|
16
28
|
<Route path="*" element={<NotFound />} />
|
|
@@ -30,9 +30,11 @@ import {
|
|
|
30
30
|
DropdownMenuItem,
|
|
31
31
|
DropdownMenuTrigger,
|
|
32
32
|
} from '@/components/ui/dropdown-menu';
|
|
33
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
33
34
|
import {
|
|
34
35
|
type AssetEntry,
|
|
35
36
|
fetchSvgAsFile,
|
|
37
|
+
renamedCopy,
|
|
36
38
|
type SvglItem,
|
|
37
39
|
searchSvgl,
|
|
38
40
|
useAssets,
|
|
@@ -40,7 +42,11 @@ import {
|
|
|
40
42
|
import { format, useLocale } from '@/lib/use-locale';
|
|
41
43
|
import { cn } from '@/lib/utils';
|
|
42
44
|
|
|
43
|
-
type Props = { slideId: string };
|
|
45
|
+
type Props = { slideId: string | null };
|
|
46
|
+
|
|
47
|
+
type Scope = 'slide' | 'global';
|
|
48
|
+
|
|
49
|
+
const GLOBAL_SLIDE_ID = '@global';
|
|
44
50
|
|
|
45
51
|
type ConflictState = {
|
|
46
52
|
file: File;
|
|
@@ -48,7 +54,10 @@ type ConflictState = {
|
|
|
48
54
|
};
|
|
49
55
|
|
|
50
56
|
export function AssetView({ slideId }: Props) {
|
|
51
|
-
const
|
|
57
|
+
const lockedToGlobal = slideId === null;
|
|
58
|
+
const [scope, setScope] = useState<Scope>(lockedToGlobal ? 'global' : 'slide');
|
|
59
|
+
const effectiveSlideId = scope === 'global' || slideId === null ? GLOBAL_SLIDE_ID : slideId;
|
|
60
|
+
const { assets, loading, available, upload, rename, remove } = useAssets(effectiveSlideId);
|
|
52
61
|
const [dragActive, setDragActive] = useState(false);
|
|
53
62
|
const [conflict, setConflict] = useState<ConflictState | null>(null);
|
|
54
63
|
const [preview, setPreview] = useState<AssetEntry | null>(null);
|
|
@@ -132,10 +141,21 @@ export function AssetView({ slideId }: Props) {
|
|
|
132
141
|
}}
|
|
133
142
|
>
|
|
134
143
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline bg-sidebar px-6 py-3">
|
|
135
|
-
<div className="min-w-0">
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
144
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
145
|
+
{lockedToGlobal ? (
|
|
146
|
+
<span className="eyebrow">{t.asset.eyebrow}</span>
|
|
147
|
+
) : (
|
|
148
|
+
<Tabs value={scope} onValueChange={(next) => setScope(next as Scope)}>
|
|
149
|
+
<TabsList>
|
|
150
|
+
<TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
|
|
151
|
+
<TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
|
|
152
|
+
</TabsList>
|
|
153
|
+
</Tabs>
|
|
154
|
+
)}
|
|
155
|
+
<p className="min-w-0 truncate text-[12px] text-muted-foreground">
|
|
156
|
+
<span className="font-mono text-[11.5px]">
|
|
157
|
+
{scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`}
|
|
158
|
+
</span>
|
|
139
159
|
{!loading && (
|
|
140
160
|
<>
|
|
141
161
|
<span className="mx-2 opacity-50">·</span>
|
|
@@ -273,7 +293,7 @@ export function AssetView({ slideId }: Props) {
|
|
|
273
293
|
/>
|
|
274
294
|
)}
|
|
275
295
|
|
|
276
|
-
{preview && <PreviewDialog asset={preview} onClose={() => setPreview(null)} />}
|
|
296
|
+
{preview && <PreviewDialog asset={preview} scope={scope} onClose={() => setPreview(null)} />}
|
|
277
297
|
|
|
278
298
|
{logoSearchOpen && (
|
|
279
299
|
<LogoSearchDialog
|
|
@@ -315,19 +335,6 @@ function hasFiles(e: React.DragEvent): boolean {
|
|
|
315
335
|
return false;
|
|
316
336
|
}
|
|
317
337
|
|
|
318
|
-
function renamedCopy(file: File, taken: Set<string>): File {
|
|
319
|
-
const dot = file.name.lastIndexOf('.');
|
|
320
|
-
const stem = dot > 0 ? file.name.slice(0, dot) : file.name;
|
|
321
|
-
const ext = dot > 0 ? file.name.slice(dot) : '';
|
|
322
|
-
let i = 1;
|
|
323
|
-
let next = `${stem}-${i}${ext}`;
|
|
324
|
-
while (taken.has(next)) {
|
|
325
|
-
i += 1;
|
|
326
|
-
next = `${stem}-${i}${ext}`;
|
|
327
|
-
}
|
|
328
|
-
return new File([file], next, { type: file.type, lastModified: file.lastModified });
|
|
329
|
-
}
|
|
330
|
-
|
|
331
338
|
function AssetCard({
|
|
332
339
|
asset,
|
|
333
340
|
onPreview,
|
|
@@ -554,9 +561,17 @@ function NoResultsMessage({ query, t }: { query: string; t: ReturnType<typeof us
|
|
|
554
561
|
);
|
|
555
562
|
}
|
|
556
563
|
|
|
557
|
-
function PreviewDialog({
|
|
564
|
+
function PreviewDialog({
|
|
565
|
+
asset,
|
|
566
|
+
scope,
|
|
567
|
+
onClose,
|
|
568
|
+
}: {
|
|
569
|
+
asset: AssetEntry;
|
|
570
|
+
scope: Scope;
|
|
571
|
+
onClose: () => void;
|
|
572
|
+
}) {
|
|
558
573
|
const isImage = asset.mime.startsWith('image/');
|
|
559
|
-
const importPath = `./assets/${asset.name}`;
|
|
574
|
+
const importPath = scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
|
|
560
575
|
const t = useLocale();
|
|
561
576
|
return (
|
|
562
577
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CSSProperties, type HTMLAttributes, useRef, useState } from 'react';
|
|
2
|
+
import { toast } from 'sonner';
|
|
3
|
+
import { uploadWithAutoRename } from '@/lib/assets';
|
|
4
|
+
import { useLocale } from '@/lib/use-locale';
|
|
2
5
|
|
|
3
6
|
export type ImagePlaceholderProps = {
|
|
4
7
|
hint: string;
|
|
@@ -17,9 +20,56 @@ export function ImagePlaceholder({
|
|
|
17
20
|
...rest
|
|
18
21
|
}: ImagePlaceholderProps) {
|
|
19
22
|
const dims = width && height ? `${width} × ${height}` : null;
|
|
23
|
+
const [dragActive, setDragActive] = useState(false);
|
|
24
|
+
const [uploading, setUploading] = useState(false);
|
|
25
|
+
const dragDepth = useRef(0);
|
|
26
|
+
const t = useLocale();
|
|
27
|
+
|
|
28
|
+
const dndProps = import.meta.env.DEV
|
|
29
|
+
? {
|
|
30
|
+
onDragEnter: (e: React.DragEvent<HTMLDivElement>) => {
|
|
31
|
+
if (uploading || !hasImageFile(e)) return;
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
dragDepth.current += 1;
|
|
34
|
+
setDragActive(true);
|
|
35
|
+
},
|
|
36
|
+
onDragOver: (e: React.DragEvent<HTMLDivElement>) => {
|
|
37
|
+
if (uploading || !hasImageFile(e)) return;
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
e.dataTransfer.dropEffect = 'copy';
|
|
40
|
+
},
|
|
41
|
+
onDragLeave: () => {
|
|
42
|
+
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
43
|
+
if (dragDepth.current === 0) setDragActive(false);
|
|
44
|
+
},
|
|
45
|
+
onDrop: (e: React.DragEvent<HTMLDivElement>) => {
|
|
46
|
+
if (uploading || !hasImageFile(e)) return;
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
dragDepth.current = 0;
|
|
49
|
+
setDragActive(false);
|
|
50
|
+
const file = pickImageFile(e.dataTransfer.files);
|
|
51
|
+
if (!file) return;
|
|
52
|
+
const root = e.currentTarget;
|
|
53
|
+
const slideId = root.closest<HTMLElement>('[data-slide-id]')?.dataset.slideId;
|
|
54
|
+
const loc = root.dataset.slideLoc;
|
|
55
|
+
if (!slideId || !loc) return;
|
|
56
|
+
const idx = loc.indexOf(':');
|
|
57
|
+
if (idx <= 0) return;
|
|
58
|
+
const line = Number(loc.slice(0, idx));
|
|
59
|
+
const column = Number(loc.slice(idx + 1));
|
|
60
|
+
if (!Number.isFinite(line) || !Number.isFinite(column)) return;
|
|
61
|
+
setUploading(true);
|
|
62
|
+
handleDrop(slideId, file, line, column)
|
|
63
|
+
.catch(() => toast.error(t.imagePlaceholder.uploadFailed))
|
|
64
|
+
.finally(() => setUploading(false));
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
: null;
|
|
68
|
+
|
|
20
69
|
return (
|
|
21
70
|
<div
|
|
22
71
|
{...rest}
|
|
72
|
+
{...dndProps}
|
|
23
73
|
data-slide-placeholder={hint}
|
|
24
74
|
data-placeholder-w={width}
|
|
25
75
|
data-placeholder-h={height}
|
|
@@ -93,10 +143,82 @@ export function ImagePlaceholder({
|
|
|
93
143
|
</span>
|
|
94
144
|
)}
|
|
95
145
|
</div>
|
|
146
|
+
{import.meta.env.DEV && (dragActive || uploading) && (
|
|
147
|
+
<DropOverlay
|
|
148
|
+
label={uploading ? t.imagePlaceholder.uploading : t.imagePlaceholder.dropOverlay}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function DropOverlay({ label }: { label: string }) {
|
|
156
|
+
return (
|
|
157
|
+
<div
|
|
158
|
+
aria-hidden
|
|
159
|
+
style={{
|
|
160
|
+
position: 'absolute',
|
|
161
|
+
inset: 0,
|
|
162
|
+
pointerEvents: 'none',
|
|
163
|
+
borderRadius: 12,
|
|
164
|
+
border: '2px dashed oklch(0.62 0.18 250)',
|
|
165
|
+
background: 'oklch(0.62 0.18 250 / 0.08)',
|
|
166
|
+
display: 'flex',
|
|
167
|
+
alignItems: 'center',
|
|
168
|
+
justifyContent: 'center',
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<span
|
|
172
|
+
style={{
|
|
173
|
+
fontSize: 12,
|
|
174
|
+
fontWeight: 600,
|
|
175
|
+
letterSpacing: '0.02em',
|
|
176
|
+
color: 'oklch(0.45 0.16 250)',
|
|
177
|
+
background: 'rgba(255,255,255,0.92)',
|
|
178
|
+
padding: '6px 10px',
|
|
179
|
+
borderRadius: 6,
|
|
180
|
+
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
{label}
|
|
184
|
+
</span>
|
|
96
185
|
</div>
|
|
97
186
|
);
|
|
98
187
|
}
|
|
99
188
|
|
|
189
|
+
function hasImageFile(e: React.DragEvent): boolean {
|
|
190
|
+
const types = e.dataTransfer?.types;
|
|
191
|
+
if (!types) return false;
|
|
192
|
+
for (let i = 0; i < types.length; i++) {
|
|
193
|
+
if (types[i] === 'Files') return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function pickImageFile(files: FileList): File | null {
|
|
199
|
+
for (let i = 0; i < files.length; i++) {
|
|
200
|
+
const f = files[i];
|
|
201
|
+
if (f.type.startsWith('image/')) return f;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function handleDrop(slideId: string, file: File, line: number, column: number) {
|
|
207
|
+
const { ok, entry } = await uploadWithAutoRename(slideId, file);
|
|
208
|
+
if (!ok || !entry) throw new Error('upload failed');
|
|
209
|
+
const res = await fetch('/__edit', {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'content-type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({
|
|
213
|
+
slideId,
|
|
214
|
+
line,
|
|
215
|
+
column,
|
|
216
|
+
ops: [{ kind: 'replace-placeholder-with-image', assetPath: `./assets/${entry.name}` }],
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
if (!res.ok) throw new Error(`edit failed (${res.status})`);
|
|
220
|
+
}
|
|
221
|
+
|
|
100
222
|
function PlaceholderIcon() {
|
|
101
223
|
return (
|
|
102
224
|
<svg
|