@open-slide/core 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/{build-6BeQ3cxb.js → build-_276DMmJ.js} +2 -2
  2. package/dist/cli/bin.js +5 -5
  3. package/dist/{config-AxZ5OE1u.js → config-BAwKWNtW.js} +215 -18
  4. package/dist/{config-CtT8K4VF.d.ts → config-D9cZ1A0X.d.ts} +2 -1
  5. package/dist/{dev-C9eLmUEq.js → dev-BoqeVXVq.js} +2 -2
  6. package/dist/en-CDKzoZvf.js +351 -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 +97 -333
  11. package/dist/{preview-Cunm-f4i.js → preview-BLPxspc9.js} +2 -2
  12. package/dist/sync-j9_QPovT.js +3 -0
  13. package/dist/{types-CRHIeoNq.d.ts → types-JYG1cmwC.d.ts} +31 -1
  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 +11 -0
  20. package/src/app/app.tsx +11 -1
  21. package/src/app/components/asset-view.tsx +1 -13
  22. package/src/app/components/image-placeholder.tsx +123 -1
  23. package/src/app/components/inspector/inspector-panel.tsx +123 -10
  24. package/src/app/components/sidebar/folder-item.tsx +16 -5
  25. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  26. package/src/app/components/sidebar/sidebar.tsx +10 -0
  27. package/src/app/components/themes/theme-detail.tsx +300 -0
  28. package/src/app/components/themes/themes-gallery.tsx +146 -0
  29. package/src/app/components/thumbnail-rail.tsx +17 -5
  30. package/src/app/lib/assets.ts +55 -2
  31. package/src/app/lib/sdk.ts +1 -0
  32. package/src/app/lib/slides.ts +10 -1
  33. package/src/app/lib/themes.ts +22 -0
  34. package/src/app/lib/use-agent-socket.ts +18 -0
  35. package/src/app/routes/home-shell.tsx +173 -0
  36. package/src/app/routes/home.tsx +89 -207
  37. package/src/app/routes/slide.tsx +144 -14
  38. package/src/app/routes/themes.tsx +34 -0
  39. package/src/app/virtual.d.ts +20 -0
  40. package/src/locale/en.ts +35 -3
  41. package/src/locale/ja.ts +36 -3
  42. package/src/locale/types.ts +33 -1
  43. package/src/locale/zh-cn.ts +35 -3
  44. package/src/locale/zh-tw.ts +35 -3
  45. package/dist/sync-B4eLo2H6.js +0 -3
  46. /package/dist/{design-C13iz9_4.js → design-cpzS8aud.js} +0 -0
  47. /package/dist/{sync-3oqN1WyK.js → sync-BCJDRIqo.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.2.0",
3
+ "version": "1.3.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
 
package/src/app/app.tsx CHANGED
@@ -3,14 +3,24 @@ 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
5
  import { Home } from './routes/home';
6
+ import { HomeShell } from './routes/home-shell';
6
7
  import { Presenter } from './routes/presenter';
7
8
  import { Slide } from './routes/slide';
9
+ import { ThemeDetailPage, ThemesGalleryPage } from './routes/themes';
8
10
 
9
11
  export function App() {
10
12
  return (
11
13
  <BrowserRouter>
12
14
  <Routes>
13
- <Route path="/" element={config.build.showSlideBrowser ? <Home /> : <NotFound />} />
15
+ {config.build.showSlideBrowser ? (
16
+ <Route element={<HomeShell />}>
17
+ <Route path="/" element={<Home />} />
18
+ <Route path="/themes" element={<ThemesGalleryPage />} />
19
+ <Route path="/themes/:themeId" element={<ThemeDetailPage />} />
20
+ </Route>
21
+ ) : (
22
+ <Route path="/" element={<NotFound />} />
23
+ )}
14
24
  <Route path="/s/:slideId" element={<Slide />} />
15
25
  <Route path="/s/:slideId/presenter" element={<Presenter />} />
16
26
  <Route path="*" element={<NotFound />} />
@@ -33,6 +33,7 @@ import {
33
33
  import {
34
34
  type AssetEntry,
35
35
  fetchSvgAsFile,
36
+ renamedCopy,
36
37
  type SvglItem,
37
38
  searchSvgl,
38
39
  useAssets,
@@ -315,19 +316,6 @@ function hasFiles(e: React.DragEvent): boolean {
315
316
  return false;
316
317
  }
317
318
 
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
319
  function AssetCard({
332
320
  asset,
333
321
  onPreview,
@@ -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
@@ -3,13 +3,17 @@ import {
3
3
  AlignJustify,
4
4
  AlignLeft,
5
5
  AlignRight,
6
+ ArrowDownToLine,
6
7
  Bold,
7
8
  Crop,
8
9
  ImageIcon,
9
10
  Italic,
11
+ Loader2,
12
+ Upload,
10
13
  X,
11
14
  } from 'lucide-react';
12
- import { useCallback, useEffect, useRef, useState } from 'react';
15
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
16
+ import { toast } from 'sonner';
13
17
  import { Field, NumberField, Section } from '@/components/panel/panel-fields';
14
18
  import { PANEL_TRANSITION_MS, PanelShell, useAnimatedOpen } from '@/components/panel/panel-shell';
15
19
  import { Button } from '@/components/ui/button';
@@ -34,10 +38,11 @@ import { Textarea } from '@/components/ui/textarea';
34
38
  import { Toggle } from '@/components/ui/toggle';
35
39
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
36
40
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
37
- import { type AssetEntry, useAssets } from '@/lib/assets';
41
+ import { type AssetEntry, uploadWithAutoRename, useAssets } from '@/lib/assets';
38
42
  import { findSlideSource } from '@/lib/inspector/fiber';
39
43
  import type { EditOp } from '@/lib/inspector/use-editor';
40
- import { useLocale } from '@/lib/use-locale';
44
+ import { useAgentSocketConnected } from '@/lib/use-agent-socket';
45
+ import { format, useLocale } from '@/lib/use-locale';
41
46
  import { cn } from '@/lib/utils';
42
47
  import type { Locale } from '../../../locale/types';
43
48
  import { type SelectedTarget, useInspector } from './inspector-provider';
@@ -746,11 +751,35 @@ function AssetPickerDialog({
746
751
  onClose: () => void;
747
752
  onPick: (asset: AssetEntry) => void;
748
753
  }) {
749
- const { assets, loading } = useAssets(slideId);
754
+ const { assets, loading, refresh } = useAssets(slideId);
750
755
  const images = assets.filter((a) => a.mime.startsWith('image/'));
751
756
  const t = useLocale();
752
757
  const path = `slides/${slideId}/assets/`;
753
758
  const [descPrefix, descSuffix] = t.inspector.replaceImageDescription.split('{path}');
759
+ const [uploading, setUploading] = useState(false);
760
+ const [dragActive, setDragActive] = useState(false);
761
+ const dragDepth = useRef(0);
762
+ const inputId = useId();
763
+
764
+ const handleFile = useCallback(
765
+ async (file: File) => {
766
+ if (!file.type.startsWith('image/')) return;
767
+ setUploading(true);
768
+ try {
769
+ const { ok, status, entry } = await uploadWithAutoRename(slideId, file);
770
+ if (!ok || !entry) {
771
+ toast.error(format(t.asset.toastUploadFailed, { status }));
772
+ return;
773
+ }
774
+ await refresh().catch(() => {});
775
+ onPick(entry);
776
+ } finally {
777
+ setUploading(false);
778
+ }
779
+ },
780
+ [slideId, refresh, onPick, t],
781
+ );
782
+
754
783
  return (
755
784
  <Dialog open onOpenChange={(o) => !o && onClose()}>
756
785
  <DialogContent className="sm:max-w-xl">
@@ -762,7 +791,60 @@ function AssetPickerDialog({
762
791
  {descSuffix}
763
792
  </DialogDescription>
764
793
  </DialogHeader>
765
- <div className="max-h-[60vh] overflow-y-auto">
794
+ <label
795
+ htmlFor={inputId}
796
+ className={cn(
797
+ 'absolute right-12 top-3.5 inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-[5px] border border-border bg-card px-2 text-[12px] font-medium transition-colors',
798
+ 'hover:bg-muted/60 hover:border-foreground/20 active:translate-y-px',
799
+ uploading && 'pointer-events-none opacity-60',
800
+ )}
801
+ >
802
+ {uploading ? (
803
+ <Loader2 className="size-3.5 animate-spin" />
804
+ ) : (
805
+ <Upload className="size-3.5" />
806
+ )}
807
+ <span>{t.asset.upload}</span>
808
+ </label>
809
+ <input
810
+ id={inputId}
811
+ type="file"
812
+ accept="image/*"
813
+ className="sr-only"
814
+ disabled={uploading}
815
+ onChange={(e) => {
816
+ const file = e.target.files?.[0];
817
+ e.target.value = '';
818
+ if (file) handleFile(file).catch(() => {});
819
+ }}
820
+ />
821
+ <section
822
+ aria-label={t.inspector.replaceImageDialogTitle}
823
+ className="relative max-h-[60vh] overflow-y-auto"
824
+ onDragEnter={(e) => {
825
+ if (uploading || !hasFiles(e)) return;
826
+ e.preventDefault();
827
+ dragDepth.current += 1;
828
+ setDragActive(true);
829
+ }}
830
+ onDragOver={(e) => {
831
+ if (uploading || !hasFiles(e)) return;
832
+ e.preventDefault();
833
+ e.dataTransfer.dropEffect = 'copy';
834
+ }}
835
+ onDragLeave={() => {
836
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
837
+ if (dragDepth.current === 0) setDragActive(false);
838
+ }}
839
+ onDrop={(e) => {
840
+ if (uploading || !hasFiles(e)) return;
841
+ e.preventDefault();
842
+ dragDepth.current = 0;
843
+ setDragActive(false);
844
+ const file = e.dataTransfer.files?.[0];
845
+ if (file) handleFile(file).catch(() => {});
846
+ }}
847
+ >
766
848
  {loading ? (
767
849
  <p className="px-1 py-6 text-center text-xs text-muted-foreground">
768
850
  {t.inspector.pickerLoading}
@@ -800,14 +882,39 @@ function AssetPickerDialog({
800
882
  ))}
801
883
  </div>
802
884
  )}
803
- </div>
885
+ {dragActive && (
886
+ <div
887
+ className="pointer-events-none absolute inset-0 z-10 animate-in fade-in-0 duration-200"
888
+ aria-hidden
889
+ >
890
+ <div className="absolute inset-0 bg-brand/5" />
891
+ <div className="absolute inset-1 rounded-[8px] border border-dashed border-brand/40" />
892
+ <div className="absolute inset-x-0 bottom-4 flex justify-center">
893
+ <div className="flex items-center gap-2 rounded-[6px] border border-border bg-card px-3 py-1.5 text-[12px] font-medium shadow-floating">
894
+ <ArrowDownToLine className="size-3.5 text-brand" />
895
+ <span>{t.asset.dropToUpload}</span>
896
+ </div>
897
+ </div>
898
+ </div>
899
+ )}
900
+ </section>
804
901
  </DialogContent>
805
902
  </Dialog>
806
903
  );
807
904
  }
808
905
 
906
+ function hasFiles(e: React.DragEvent): boolean {
907
+ const types = e.dataTransfer?.types;
908
+ if (!types) return false;
909
+ for (let i = 0; i < types.length; i++) {
910
+ if (types[i] === 'Files') return true;
911
+ }
912
+ return false;
913
+ }
914
+
809
915
  function AgentWatchingBadge() {
810
916
  const t = useLocale();
917
+ const connected = useAgentSocketConnected();
811
918
  return (
812
919
  <TooltipProvider delayDuration={200}>
813
920
  <Tooltip>
@@ -817,14 +924,20 @@ function AgentWatchingBadge() {
817
924
  className="flex shrink-0 cursor-help items-center gap-1.5 rounded-[3px] border border-hairline bg-card px-1.5 py-px text-[10.5px] text-foreground/85 outline-none focus-visible:ring-2 focus-visible:ring-ring/30"
818
925
  >
819
926
  <span aria-hidden className="relative flex size-1.5 items-center justify-center">
820
- <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
821
- <span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
927
+ {connected ? (
928
+ <>
929
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
930
+ <span className="relative inline-flex size-1.5 rounded-full bg-emerald-500" />
931
+ </>
932
+ ) : (
933
+ <span className="relative inline-flex size-1.5 rounded-full bg-rose-500" />
934
+ )}
822
935
  </span>
823
- {t.inspector.agentWatching}
936
+ {connected ? t.inspector.agentWatching : t.inspector.agentNotWatching}
824
937
  </button>
825
938
  </TooltipTrigger>
826
939
  <TooltipContent side="bottom" align="end" className="max-w-[260px] leading-relaxed">
827
- {t.inspector.agentWatchingTooltip}
940
+ {connected ? t.inspector.agentWatchingTooltip : t.inspector.agentNotWatchingTooltip}
828
941
  </TooltipContent>
829
942
  </Tooltip>
830
943
  </TooltipProvider>
@@ -67,6 +67,9 @@ type Row =
67
67
  }
68
68
  | {
69
69
  kind: 'draft';
70
+ }
71
+ | {
72
+ kind: 'themes';
70
73
  };
71
74
 
72
75
  export function FolderItem({
@@ -89,7 +92,9 @@ export function FolderItem({
89
92
  const slideDragActive = useSlideDragActive();
90
93
  const t = useLocale();
91
94
 
92
- const isSlideDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(SLIDE_DND_MIME);
95
+ const acceptsSlideDrop = row.kind !== 'themes';
96
+ const isSlideDrag = (e: React.DragEvent) =>
97
+ acceptsSlideDrop && e.dataTransfer.types.includes(SLIDE_DND_MIME);
93
98
  const handleDragEnter = (e: React.DragEvent) => {
94
99
  if (!isSlideDrag(e)) return;
95
100
  dragDepth.current += 1;
@@ -106,6 +111,7 @@ export function FolderItem({
106
111
  if (dragDepth.current === 0) setDragOver(false);
107
112
  };
108
113
  const handleDrop = (e: React.DragEvent) => {
114
+ if (!acceptsSlideDrop) return;
109
115
  const slideId = e.dataTransfer.getData(SLIDE_DND_MIME);
110
116
  dragDepth.current = 0;
111
117
  setDragOver(false);
@@ -114,9 +120,14 @@ export function FolderItem({
114
120
  onDropSlide(slideId);
115
121
  };
116
122
 
117
- const icon =
118
- row.kind === 'draft' ? ({ type: 'emoji', value: '📝' } satisfies FolderIcon) : row.folder.icon;
119
- const label = row.kind === 'draft' ? t.home.draft : row.folder.name;
123
+ const icon: FolderIcon =
124
+ row.kind === 'draft'
125
+ ? { type: 'emoji', value: '📝' }
126
+ : row.kind === 'themes'
127
+ ? { type: 'emoji', value: '🎨' }
128
+ : row.folder.icon;
129
+ const label =
130
+ row.kind === 'draft' ? t.home.draft : row.kind === 'themes' ? t.home.themes : row.folder.name;
120
131
 
121
132
  const commitRename = () => {
122
133
  if (row.kind !== 'folder') return;
@@ -133,7 +144,7 @@ export function FolderItem({
133
144
  selected
134
145
  ? 'bg-muted text-foreground before:absolute before:inset-y-1.5 before:-left-0.5 before:w-[2px] before:rounded-full before:bg-brand'
135
146
  : 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
136
- slideDragActive && !dragOver && 'ring-1 ring-foreground/10',
147
+ slideDragActive && acceptsSlideDrop && !dragOver && 'ring-1 ring-foreground/10',
137
148
  dragOver &&
138
149
  'bg-brand/10 text-foreground ring-1 ring-brand ring-offset-1 ring-offset-sidebar motion-safe:scale-[1.01] motion-safe:transition-transform',
139
150
  )}
@@ -0,0 +1,34 @@
1
+ import { cn } from '@/lib/utils';
2
+ import type { FolderIcon } from '../../lib/sdk';
3
+ import { FolderIconChip } from './folder-item';
4
+
5
+ export function MobileFolderPill({
6
+ icon,
7
+ label,
8
+ count,
9
+ active,
10
+ onClick,
11
+ }: {
12
+ icon: FolderIcon;
13
+ label: string;
14
+ count: number;
15
+ active: boolean;
16
+ onClick: () => void;
17
+ }) {
18
+ return (
19
+ <button
20
+ type="button"
21
+ onClick={onClick}
22
+ className={cn(
23
+ 'flex shrink-0 items-center gap-1.5 rounded-[5px] border px-2.5 py-1 text-[11.5px] font-medium transition-colors',
24
+ active
25
+ ? 'border-foreground/40 bg-foreground text-background'
26
+ : 'border-border bg-card text-muted-foreground hover:text-foreground',
27
+ )}
28
+ >
29
+ <FolderIconChip icon={icon} className="size-3.5 text-sm" />
30
+ <span className="max-w-[8rem] truncate">{label}</span>
31
+ <span className="folio nums">{count.toString().padStart(2, '0')}</span>
32
+ </button>
33
+ );
34
+ }