@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.
Files changed (56) hide show
  1. package/dist/{build-6BeQ3cxb.js → build-1Rqivz0d.js} +2 -2
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-AxZ5OE1u.js → config-XZJnC_fu.js} +735 -64
  4. package/dist/{config-CtT8K4VF.d.ts → config-s0YUbmUe.d.ts} +3 -1
  5. package/dist/{dev-C9eLmUEq.js → dev-0W8gYiSa.js} +2 -2
  6. package/dist/en-7GU-DHbJ.js +361 -0
  7. package/dist/index.d.ts +4 -3
  8. package/dist/index.js +229 -39
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +136 -342
  11. package/dist/{preview-Cunm-f4i.js → preview-DT9hJvzM.js} +2 -2
  12. package/dist/sync-j9_QPovT.js +3 -0
  13. package/dist/{types-CRHIeoNq.d.ts → types-QCpkHkiS.d.ts} +42 -2
  14. package/dist/vite/index.d.ts +2 -2
  15. package/dist/vite/index.js +2 -2
  16. package/package.json +9 -1
  17. package/skills/create-slide/SKILL.md +1 -1
  18. package/skills/create-theme/SKILL.md +60 -12
  19. package/skills/slide-authoring/SKILL.md +21 -2
  20. package/src/app/app.tsx +13 -1
  21. package/src/app/components/asset-view.tsx +37 -22
  22. package/src/app/components/image-placeholder.tsx +123 -1
  23. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  24. package/src/app/components/inspector/inspector-panel.tsx +370 -30
  25. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  26. package/src/app/components/player.tsx +25 -5
  27. package/src/app/components/present/control-bar.tsx +12 -0
  28. package/src/app/components/sidebar/folder-item.tsx +27 -5
  29. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  30. package/src/app/components/sidebar/sidebar.tsx +20 -0
  31. package/src/app/components/themes/theme-detail.tsx +300 -0
  32. package/src/app/components/themes/themes-gallery.tsx +146 -0
  33. package/src/app/components/thumbnail-rail.tsx +17 -5
  34. package/src/app/lib/assets.ts +55 -2
  35. package/src/app/lib/export-pdf.ts +6 -0
  36. package/src/app/lib/inspector/use-editor.ts +9 -1
  37. package/src/app/lib/sdk.ts +1 -0
  38. package/src/app/lib/slides.ts +17 -1
  39. package/src/app/lib/themes.ts +22 -0
  40. package/src/app/lib/use-agent-socket.ts +18 -0
  41. package/src/app/lib/use-slide-module.ts +48 -0
  42. package/src/app/routes/assets.tsx +9 -0
  43. package/src/app/routes/home-shell.tsx +194 -0
  44. package/src/app/routes/home.tsx +89 -207
  45. package/src/app/routes/presenter.tsx +2 -20
  46. package/src/app/routes/slide.tsx +217 -54
  47. package/src/app/routes/themes.tsx +34 -0
  48. package/src/app/virtual.d.ts +20 -0
  49. package/src/locale/en.ts +49 -7
  50. package/src/locale/ja.ts +50 -7
  51. package/src/locale/types.ts +44 -2
  52. package/src/locale/zh-cn.ts +49 -8
  53. package/src/locale/zh-tw.ts +49 -8
  54. package/dist/sync-B4eLo2H6.js +0 -3
  55. /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
  56. /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
@@ -1,5 +1,5 @@
1
- import "./design-C13iz9_4.js";
2
- import { createViteConfig } from "./config-AxZ5OE1u.js";
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
@@ -0,0 +1,3 @@
1
+ import { detectSkillsDrift, syncSkills } from "./sync-BCJDRIqo.js";
2
+
3
+ export { syncSkills };
@@ -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 this slide's assets folder." */
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 };
@@ -1,5 +1,5 @@
1
- import "../types-CRHIeoNq.js";
2
- import { OpenSlideConfig } from "../config-CtT8K4VF.js";
1
+ import "../types-QCpkHkiS.js";
2
+ import { OpenSlideConfig } from "../config-s0YUbmUe.js";
3
3
  import { InlineConfig } from "vite";
4
4
 
5
5
  //#region src/vite/config.d.ts
@@ -1,4 +1,4 @@
1
- import "../design-C13iz9_4.js";
2
- import { createViteConfig } from "../config-AxZ5OE1u.js";
1
+ import "../design-cpzS8aud.js";
2
+ import { createViteConfig } from "../config-XZJnC_fu.js";
3
3
 
4
4
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.2.0",
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 a single markdown file under `themes/<id>.md` describing palette, typography, layout, fixed Title/Footer components, and motion philosophy. Do NOT use for editing slides themselves — only for authoring `themes/<id>.md`.
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 one markdown file under `themes/<id>.md` that describes a reusable visual identity for slides — palette, typography, layout, fixed Title/Footer/Eyebrow components, motion. Themes are agent-facing documentation, not executable runtime: a slide author reads the theme markdown and applies it when writing `slides/<id>/index.tsx`.
8
+ This skill produces a **theme bundle** under `themes/`: two paired files that together describe a reusable visual identity.
9
9
 
10
- A theme is **distinct from a slide's `design` const**. The theme markdown is authoring-time aesthetic direction (read by `create-slide`, copied into slide source). 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 in the dev UI. Both can coexist: the markdown theme commits the *direction*, the per-slide `design` const makes the slide *tweakable*. This skill only writes the markdown.
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
- You only write a single file under `themes/`. Never modify 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
+ 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
- - [ ] File lives at `themes/<id>.md` and only that file was created — no slide changes, no config changes.
176
- - [ ] Frontmatter `mode` is one of `light`, `dark`, `system`.
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 path.
183
- - That `/create-slide` will list it as a picker option on its next run.
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; that is the user's next move.
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 file is documentation.
191
- - ❌ Producing more than one file. One theme = one `themes/<id>.md`.
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
- Place files under `slides/<id>/assets/`. Import them as ES modules:
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
- <Route path="/" element={config.build.showSlideBrowser ? <Home /> : <NotFound />} />
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 { assets, loading, available, upload, rename, remove } = useAssets(slideId);
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
- <span className="eyebrow">{t.asset.eyebrow}</span>
137
- <p className="mt-0.5 truncate text-[12px] text-muted-foreground">
138
- <span className="font-mono text-[11.5px]">slides/{slideId}/assets/</span>
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({ asset, onClose }: { asset: AssetEntry; onClose: () => void }) {
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 { CSSProperties, HTMLAttributes } from 'react';
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