@marver-design/marver 0.20.0 → 0.21.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/CHANGELOG.md +26 -0
- package/dist/{bake-tAb0D6Rc.mjs → bake-BID6mo-N.mjs} +1 -1
- package/dist/{boards-BmxcT3Lc.mjs → boards-BwiDAmPf.mjs} +95 -48
- package/dist/{boards-PuVzw5Wp.mjs → boards-DnLewfj8.mjs} +20 -11
- package/dist/{build-DwpNPl6Z.mjs → build-C7MqQ7hq.mjs} +18 -8
- package/dist/cli.mjs +4 -4
- package/dist/{daemon-DbHvLQUL.mjs → daemon-CRZFpl6K.mjs} +1 -1
- package/dist/{dev-BjdDP69b.mjs → dev-BNZF4Mup.mjs} +5 -5
- package/dist/{init-DKxuRxBr.mjs → init-C34BY3R4.mjs} +1 -1
- package/dist/{manifest-B01PSyDc.mjs → manifest-mMfUhPtL.mjs} +8 -5
- package/dist/{plugin-y4Ch7o_A.mjs → plugin-omHLCn91.mjs} +34 -26
- package/dist/{poster-FCv_nXyP.mjs → poster-BvxiAzy1.mjs} +1 -1
- package/dist/{publish-bakes-XH6Bac58.mjs → publish-bakes-BqzAAa3w.mjs} +2 -2
- package/dist/{shot-iw3SEcpn.mjs → shot-DswS4iRK.mjs} +2 -2
- package/package.json +1 -1
- package/src/client/shell/BoardList.tsx +111 -52
- package/src/client/shell/store.ts +5 -5
- package/src/client/shell/styles.css +10 -9
- package/src/shared/board-tree.ts +271 -140
- package/templates/AGENTS-embedded.md +3 -3
- package/templates/AGENTS-studio.md +3 -3
- package/templates/instructions/boards.md +47 -22
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capture, t as findChrome } from "./shot-
|
|
1
|
+
import { capture, t as findChrome } from "./shot-DswS4iRK.mjs";
|
|
2
2
|
import { constants, copyFileSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, sep } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { o as slideSize } from "./cli.mjs";
|
|
2
|
-
import { planShot, t as findChrome } from "./shot-
|
|
3
|
-
import { ASK_MAX, bakeBatch } from "./bake-
|
|
2
|
+
import { planShot, t as findChrome } from "./shot-DswS4iRK.mjs";
|
|
3
|
+
import { ASK_MAX, bakeBatch } from "./bake-BID6mo-N.mjs";
|
|
4
4
|
import { MIME } from "./serve-z5qtj_wJ.mjs";
|
|
5
5
|
import { copyFileSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -337,8 +337,8 @@ async function shootFrame(opts) {
|
|
|
337
337
|
};
|
|
338
338
|
const plan = planShot(frame, viewports, size);
|
|
339
339
|
if (frame.kind !== "html") try {
|
|
340
|
-
const { scanAssetRefs } = await import("./build-
|
|
341
|
-
const { ensurePoster } = await import("./poster-
|
|
340
|
+
const { scanAssetRefs } = await import("./build-C7MqQ7hq.mjs");
|
|
341
|
+
const { ensurePoster } = await import("./poster-BvxiAzy1.mjs");
|
|
342
342
|
const refs = scanAssetRefs(readFileSync(join(root, frame.file), "utf8"), frame.file);
|
|
343
343
|
for (const r of refs) if (r.endsWith(".poster.png")) await ensurePoster(join(root, "design", "assets"), r.slice(0, -11));
|
|
344
344
|
} catch {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marver-design/marver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "The agent-native design canvas. A design/ folder, one command, a canvas of live frames built from your repo's real components - comment @marver and your own coding agent does the work. The tool ships no AI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
|
2
2
|
import { useStore, HAS_ALL_SCENES, PUBLISHED, fetchBoardTree, rememberTitles, type TreeBase } from './store.ts'
|
|
3
3
|
import { canvasCtl } from './canvas/Canvas.tsx'
|
|
4
4
|
import { Tip } from './Tip.tsx'
|
|
5
5
|
import { copyToClipboard, type MenuItem, type MenuOpener } from './ContextMenu.tsx'
|
|
6
6
|
import { ArrowLineUpIcon, CardsIcon, CardsThreeIcon, FolderIcon, FolderMinusIcon, FolderOpenIcon, FolderPlusIcon, PencilSimpleIcon, SignpostIcon } from './icons.tsx'
|
|
7
7
|
import {
|
|
8
|
-
applyDrop, boardsIn, createFolder, deleteFolder, folderIn, folderOf, foldersIn, humanize, isOwnSlot, labelOf,
|
|
8
|
+
applyDrop, boardsIn, createFolder, deleteFolder, depthOf, folderEntries, folderIn, folderOf, foldersIn, holdsFolders, humanize, INDENT, isOwnSlot, labelOf, listIn, moveBoard, moveFolderToRoot, newFolderSlot, parentOf, readTitle,
|
|
9
|
+
resolveDrop, retitleFolder, rootIndex, slugFor,
|
|
9
10
|
type Drag, type Drop, type Folder, type Row, type TreeItem,
|
|
10
11
|
} from '../../shared/board-tree.ts'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* Boards live at the top of the sidebar - always visible, one click to switch - in
|
|
14
|
-
* of folders. The tree (shared/board-tree.ts) comes from the board files' `order`/`folder`
|
|
14
|
+
* Boards live at the top of the sidebar - always visible, one click to switch - in up to TWO
|
|
15
|
+
* levels of folders (a folder holds boards and folders; a sub-folder holds boards). The tree (shared/board-tree.ts) comes from the board files' `order`/`folder`
|
|
15
16
|
* fields plus the `_folders.json` registry; every mutation here (drag, new/rename/delete
|
|
16
17
|
* folder, move) is one optimistic tree write through `arrangeBoards`, replayed once on a
|
|
17
18
|
* 409 (someone else wrote first). The list refreshes on mount, window focus, a slow poll and
|
|
@@ -23,8 +24,9 @@ const CLOSED_KEY = 'mv-folders-closed' // collapsed folders are a viewer prefe
|
|
|
23
24
|
const readClosed = (): Record<string, true> => { try { return JSON.parse(localStorage.getItem(CLOSED_KEY) ?? '{}') } catch { return {} } }
|
|
24
25
|
|
|
25
26
|
/** The inline input: renaming a board or a folder, or naming a NEW folder that does not exist
|
|
26
|
-
* yet - drawn at
|
|
27
|
-
|
|
27
|
+
* yet - drawn at `index` in `parent`'s items (null = the root), optionally with `board`
|
|
28
|
+
* already inside it. */
|
|
29
|
+
type Naming = { kind: 'board' | 'folder'; name: string } | { kind: 'new'; index: number; board?: string; parent: string | null }
|
|
28
30
|
/** A mutation as intent: applied to whichever tree is current, so a 409 can replay it. */
|
|
29
31
|
type Intent = (tree: TreeItem[]) => TreeItem[] | null
|
|
30
32
|
|
|
@@ -185,7 +187,7 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
185
187
|
refresh()
|
|
186
188
|
return
|
|
187
189
|
}
|
|
188
|
-
const folderLabels = tree.
|
|
190
|
+
const folderLabels = folderEntries(tree).map((e) => e.folder).filter((f) => n.kind !== 'folder' || f.name !== n.name).map((f) => labelOf(f.name, f.title))
|
|
189
191
|
if (n.kind === 'folder') {
|
|
190
192
|
const current = folderIn(tree, n.name)
|
|
191
193
|
if (!current || title === labelOf(n.name, current.title)) { setNaming(null); return }
|
|
@@ -196,11 +198,11 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
196
198
|
}
|
|
197
199
|
if (n.kind !== 'new') return
|
|
198
200
|
if (folderLabels.includes(title)) { taken('folder'); return }
|
|
199
|
-
const { index, board: withBoard } = n
|
|
201
|
+
const { index, board: withBoard, parent } = n
|
|
200
202
|
const slug = slugFor(title, foldersIn(tree)) // "Old stuff" → old-stuff (-2 past a namesake); "🚀" alone → folder
|
|
201
203
|
setNaming(null)
|
|
202
204
|
setOpen(slug, true) // a new folder opens, whatever an old namesake left behind
|
|
203
|
-
if (!mutate((t) => createFolder(t, slug, index, withBoard, stored(slug) || undefined))) useStore.getState().toast('that board is gone - nothing changed')
|
|
205
|
+
if (!mutate((t) => createFolder(t, slug, index, withBoard, stored(slug) || undefined, parent))) useStore.getState().toast(withBoard ? 'that board is gone - nothing changed' : 'that folder is gone - nothing changed')
|
|
204
206
|
} finally { commitBusy.current = false }
|
|
205
207
|
}
|
|
206
208
|
|
|
@@ -210,7 +212,10 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
210
212
|
const measure = (): Row[] => Array.from(rootRef.current?.querySelectorAll<HTMLElement>('[data-board-row],[data-folder-row]') ?? []).map((el) => {
|
|
211
213
|
const r = el.getBoundingClientRect()
|
|
212
214
|
const folder = el.hasAttribute('data-folder-row')
|
|
213
|
-
return {
|
|
215
|
+
return {
|
|
216
|
+
kind: folder ? 'folder' : 'board', name: el.dataset.folderRow ?? el.dataset.board ?? '', parent: (folder ? el.dataset.parent : el.dataset.folder) ?? null,
|
|
217
|
+
depth: Number(el.dataset.depth ?? 0), open: el.dataset.open === '1', top: r.top, bottom: r.bottom, left: r.left,
|
|
218
|
+
}
|
|
214
219
|
})
|
|
215
220
|
/** The drop target for the pointer at (x, y), or null: outside the panel (a release there
|
|
216
221
|
* cancels), or a slot that would change nothing. Inside the panel there is always one - the
|
|
@@ -289,22 +294,36 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
289
294
|
}, [])
|
|
290
295
|
|
|
291
296
|
// ---- menus (flat lists, no submenus) ----
|
|
292
|
-
const newFolderAt = (index: number, withBoard?: string
|
|
297
|
+
const newFolderAt = (index: number, withBoard?: string, parent: string | null = null) => {
|
|
298
|
+
if (parent) setOpen(parent, true) // the new folder is drawn inside its parent: show it
|
|
299
|
+
setNaming({ kind: 'new', index, board: withBoard, parent })
|
|
300
|
+
}
|
|
293
301
|
const boardMenu = (n: string, parent: string | null): MenuItem[] => {
|
|
294
302
|
const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`board: ${n}`, 'path copied') }]
|
|
295
303
|
if (PUBLISHED || n === 'all-scenes') return items
|
|
296
304
|
items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'board', name: n }) })
|
|
297
|
-
// the new folder takes the board's own slot
|
|
298
|
-
|
|
305
|
+
// the new folder takes the board's own slot at its own level (a sub-folder inside a top-level
|
|
306
|
+
// folder; right after its sub-folder when it sits in one - that level holds no folders)
|
|
307
|
+
items.push({ label: 'Move to new folder', icon: <FolderPlusIcon size={15} />, onClick: () => { const at = newFolderSlot(treeRef.current, n); newFolderAt(at.index, n, at.parent) } })
|
|
299
308
|
// moving into an EXISTING folder is a drag, not a menu - the list stays short
|
|
300
|
-
if (parent) items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => {
|
|
309
|
+
if (parent) items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => {
|
|
310
|
+
const p = folderOf(t, n)
|
|
311
|
+
const top = p ? parentOf(t, p) ?? p : null // lands right after the top-level folder it was in
|
|
312
|
+
return moveBoard(t, n, null, top ? rootIndex(t, 'folder', top) + 1 : undefined)
|
|
313
|
+
}) })
|
|
301
314
|
return items
|
|
302
315
|
}
|
|
303
|
-
const folderMenu = (f: string, boards: string[]): MenuItem[] => {
|
|
304
|
-
const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`folder: ${f} (boards: ${boards.join(', ') || 'none'})`, 'path copied') }]
|
|
316
|
+
const folderMenu = (f: string, boards: string[], parent: string | null): MenuItem[] => {
|
|
317
|
+
const items: MenuItem[] = [{ label: 'Copy path', icon: <SignpostIcon size={15} />, onClick: () => copyToClipboard(`folder: ${f}${parent ? ` (in ${parent})` : ''} (boards: ${boards.join(', ') || 'none'})`, 'path copied') }]
|
|
305
318
|
if (PUBLISHED) return items
|
|
306
319
|
items.push({ label: 'Rename', icon: <PencilSimpleIcon size={15} />, onClick: () => setNaming({ kind: 'folder', name: f }) })
|
|
307
|
-
//
|
|
320
|
+
// two levels: a top-level folder can hold folders; a sub-folder can leave its parent
|
|
321
|
+
if (!parent) items.push({ label: 'New folder inside', icon: <FolderPlusIcon size={15} />, onClick: () => newFolderAt(listIn(treeRef.current, f)?.length ?? 0, undefined, f) })
|
|
322
|
+
else items.push({ label: 'Move to top level', icon: <ArrowLineUpIcon size={15} />, onClick: () => mutate((t) => {
|
|
323
|
+
const p = parentOf(t, f)
|
|
324
|
+
return moveFolderToRoot(t, f, p ? rootIndex(t, 'folder', p) + 1 : undefined)
|
|
325
|
+
}) })
|
|
326
|
+
// folders organise, never own: deleting one moves what it holds up one level, into its place, in order
|
|
308
327
|
items.push({ label: 'Delete folder', icon: <FolderMinusIcon size={15} />, onClick: () => { setOpen(f, true); mutate((t) => deleteFolder(t, f)) } })
|
|
309
328
|
return items
|
|
310
329
|
}
|
|
@@ -327,6 +346,15 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
327
346
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
328
347
|
}, [])
|
|
329
348
|
|
|
349
|
+
// a draft new folder whose parent vanished or got nested mid-naming is cancelled, said once
|
|
350
|
+
const orphanDraft = naming?.kind === 'new' && !holdsFolders(tree, naming.parent)
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!orphanDraft) return
|
|
353
|
+
setNaming(null)
|
|
354
|
+
useStore.getState().toast('that folder changed - the new folder was not made')
|
|
355
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
356
|
+
}, [orphanDraft])
|
|
357
|
+
|
|
330
358
|
// ---- rows ----
|
|
331
359
|
const input = (defaultValue: string, placeholder?: string) => (
|
|
332
360
|
<input autoFocus defaultValue={defaultValue} placeholder={placeholder} spellCheck={false}
|
|
@@ -337,26 +365,51 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
337
365
|
}}
|
|
338
366
|
onBlur={(e) => { if (namingRef.current) void commit(e.currentTarget.value) }} />
|
|
339
367
|
)
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
368
|
+
// a new folder being named is drawn at its future slot, its board (if any) already inside;
|
|
369
|
+
// that board leaves its usual row for the duration. A draft whose parent vanished or can no
|
|
370
|
+
// longer hold folders (an agent deleted or nested it mid-naming) draws nothing and hides
|
|
371
|
+
// nothing - the effect below cancels it, so the board it held shows where it now is
|
|
372
|
+
const draftLive = naming?.kind === 'new' && holdsFolders(tree, naming.parent)
|
|
373
|
+
const draft = naming?.kind === 'new' && draftLive ? (naming.board && !boardsIn(tree).includes(naming.board) ? { ...naming, board: undefined } : naming) : null // a board deleted mid-naming leaves the draft
|
|
374
|
+
const visible = (items: TreeItem[]) => (draft?.board ? items.filter((k) => !(k.kind === 'board' && k.name === draft.board)) : items)
|
|
375
|
+
const key = (it: TreeItem) => `${it.kind === 'board' ? 'b' : 'f'}:${it.name}`
|
|
376
|
+
/** The last row an item draws: a board's own, a closed or empty folder's header, else its last child's last row. */
|
|
377
|
+
const lastRow = (it: TreeItem): string => {
|
|
378
|
+
if (it.kind === 'board') return key(it)
|
|
379
|
+
const kids = visible(it.items)
|
|
380
|
+
return !closed[it.name] && kids.length ? lastRow(kids[kids.length - 1]!) : key(it)
|
|
347
381
|
}
|
|
348
|
-
|
|
382
|
+
// ONE seam per gap, overlaid (::after, no layout shift) on the row the gap touches: before the
|
|
383
|
+
// first row of the item AT the index, or after the last row of a list's last item at its end.
|
|
384
|
+
// Its left edge follows the indent of the list it lands in, so "inside" and "outside" never
|
|
385
|
+
// draw alike. The root end draws before the pinned all-scenes row. An open EMPTY folder has no
|
|
386
|
+
// row to draw on: its header draws the indented seam under itself (drop-in).
|
|
387
|
+
const seam = ((): { row: string; where: 'before' | 'after'; depth: number } | null => {
|
|
388
|
+
if (!drag || !drop || 'into' in drop) return null
|
|
389
|
+
const items = listIn(tree, drop.list)
|
|
390
|
+
if (!items) return null
|
|
391
|
+
const depth = depthOf(tree, drop.list)
|
|
392
|
+
if (drop.index < items.length) return { row: key(items[drop.index]!), where: 'before', depth }
|
|
393
|
+
if (drop.list === null && HAS_ALL_SCENES) return { row: 'b:all-scenes', where: 'before', depth: 0 }
|
|
394
|
+
return items.length ? { row: lastRow(items[items.length - 1]!), where: 'after', depth } : null
|
|
395
|
+
})()
|
|
396
|
+
const seamLeft = (depth: number): CSSProperties => ({ ['--seam-left' as string]: `${depth ? depth * INDENT : 6}px` })
|
|
397
|
+
const seamOf = (row: string): { cls: string; style?: CSSProperties } =>
|
|
398
|
+
seam && seam.row === row ? { cls: ` drop-${seam.where}`, style: seamLeft(seam.depth) } : { cls: '' }
|
|
399
|
+
const indent = (depth: number) => (depth >= 2 ? ' in-folder in-sub' : depth === 1 ? ' in-folder' : '')
|
|
400
|
+
|
|
401
|
+
const boardRow = (n: string, parent: string | null, depth: number) => {
|
|
349
402
|
const dragging = drag?.kind === 'board' && drag.name === n
|
|
350
|
-
const
|
|
403
|
+
const s = dragging ? { cls: '' } : seamOf(`b:${n}`)
|
|
351
404
|
// the row being renamed is still a row: measured by a drag (its slot exists) and it draws its seam
|
|
352
405
|
if (naming?.kind === 'board' && naming.name === n) return (
|
|
353
|
-
<div key={n} data-board-row data-board={n} data-folder={parent ?? undefined} className={`it board editing${
|
|
406
|
+
<div key={`b:${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-depth={depth} className={`it board editing${indent(depth)}${s.cls}`} style={s.style}><CardsIcon size={14} />{input(label(n))}</div>
|
|
354
407
|
)
|
|
355
408
|
const canDrag = !PUBLISHED && n !== 'all-scenes'
|
|
356
409
|
const item: Drag = { kind: 'board', name: n }
|
|
357
410
|
return (
|
|
358
|
-
<button key={
|
|
359
|
-
className={`it board${
|
|
411
|
+
<button key={`b:${n}`} data-board-row data-board={n} data-folder={parent ?? undefined} data-depth={depth} data-reorderable={canDrag || undefined}
|
|
412
|
+
className={`it board${indent(depth)}${n === board ? ' cur' : ''}${dragging ? ' dragging' : ''}${s.cls}`} style={s.style}
|
|
360
413
|
// draggable rows switch on the pointer tap (onPointerUp), so their trailing mouse
|
|
361
414
|
// click (detail >= 1) must be ignored to avoid a double switch; keyboard clicks
|
|
362
415
|
// (detail === 0) and non-draggable rows (all-scenes, published) switch here as normal
|
|
@@ -372,23 +425,42 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
372
425
|
</button>
|
|
373
426
|
)
|
|
374
427
|
}
|
|
375
|
-
const
|
|
428
|
+
const draftRows = (depth: number): ReactNode[] => !draft ? [] : [
|
|
429
|
+
<div key="f:new" className={`it folder editing${indent(depth)}`}><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
|
|
430
|
+
...(draft.board ? [<div key="new/board" className={`it board draft${indent(depth + 1)}${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{label(draft.board)}</span></div>] : []),
|
|
431
|
+
]
|
|
432
|
+
/** A list's rows - the root's, a folder's, a sub-folder's - with a draft new folder at its slot. */
|
|
433
|
+
const listRows = (items: TreeItem[], parent: string | null, depth: number): ReactNode[] => {
|
|
434
|
+
const out: ReactNode[] = []
|
|
435
|
+
const here = draft && draft.parent === parent ? draft : null
|
|
436
|
+
items.forEach((it, i) => {
|
|
437
|
+
if (here && here.index === i) out.push(...draftRows(depth))
|
|
438
|
+
if (it.kind === 'board') { if (draft?.board !== it.name) out.push(boardRow(it.name, parent, depth)) }
|
|
439
|
+
else out.push(...folderRows(it, parent, depth))
|
|
440
|
+
})
|
|
441
|
+
if (here && here.index >= items.length) out.push(...draftRows(depth))
|
|
442
|
+
return out
|
|
443
|
+
}
|
|
444
|
+
const folderRows = (it: Folder, parent: string | null, depth: number): ReactNode[] => {
|
|
376
445
|
const f = it.name
|
|
377
446
|
const open = !closed[f]
|
|
447
|
+
const kids = visible(it.items)
|
|
448
|
+
const boards = boardsIn(kids)
|
|
449
|
+
const s = seamOf(`f:${f}`)
|
|
378
450
|
const rows: ReactNode[] = []
|
|
379
451
|
if (naming?.kind === 'folder' && naming.name === f) {
|
|
380
|
-
rows.push(<div key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} className={`it folder editing${
|
|
452
|
+
rows.push(<div key={`f:${f}`} data-folder-row={f} data-parent={parent ?? undefined} data-depth={depth} data-open={open ? '1' : '0'} className={`it folder editing${indent(depth)}${s.cls}`} style={s.style}>{open ? <FolderOpenIcon size={14} /> : <FolderIcon size={14} />}{input(labelOf(f, it.title))}</div>)
|
|
381
453
|
} else {
|
|
382
454
|
const item: Drag = { kind: 'folder', name: f }
|
|
383
455
|
const dragging = drag?.kind === 'folder' && drag.name === f
|
|
384
456
|
const into = !!drag && !!drop && 'into' in drop && drop.into === f
|
|
385
|
-
|
|
386
|
-
const
|
|
457
|
+
const inSeam = !!drag && !!drop && !('into' in drop) && drop.list === f && open && kids.length === 0
|
|
458
|
+
const sc = dragging ? { cls: '' } : inSeam ? { cls: ' drop-in', style: seamLeft(depth + 1) } : s
|
|
387
459
|
rows.push(
|
|
388
|
-
<button key={`f:${f}`} data-folder-row={f} data-open={open ? '1' : '0'} data-reorderable={!PUBLISHED || undefined}
|
|
389
|
-
className={`it folder${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${
|
|
460
|
+
<button key={`f:${f}`} data-folder-row={f} data-parent={parent ?? undefined} data-depth={depth} data-open={open ? '1' : '0'} data-reorderable={!PUBLISHED || undefined}
|
|
461
|
+
className={`it folder${indent(depth)}${boards.includes(board) ? ' held' : ''}${dragging ? ' dragging' : ''}${into ? ' drop-into' : ''}${sc.cls}`} style={sc.style}
|
|
390
462
|
onClick={(e) => { if (PUBLISHED || e.detail === 0) setOpen(f, !open) }}
|
|
391
|
-
onContextMenu={(e) => onMenu(e, folderMenu(f, boards))}
|
|
463
|
+
onContextMenu={(e) => onMenu(e, folderMenu(f, boards, parent))}
|
|
392
464
|
onPointerDown={!PUBLISHED ? (e) => onPointerDown(e, item) : undefined}
|
|
393
465
|
onPointerMove={!PUBLISHED ? onPointerMove : undefined}
|
|
394
466
|
onPointerUp={!PUBLISHED ? onPointerUp : undefined}
|
|
@@ -400,25 +472,12 @@ export function BoardList({ onMenu }: { onMenu: MenuOpener }) {
|
|
|
400
472
|
</button>,
|
|
401
473
|
)
|
|
402
474
|
}
|
|
403
|
-
if (open)
|
|
475
|
+
if (open) rows.push(...listRows(it.items, f, depth + 1))
|
|
404
476
|
return rows
|
|
405
477
|
}
|
|
406
478
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const draft = naming?.kind === 'new' ? (naming.board && !boardsIn(tree).includes(naming.board) ? { ...naming, board: undefined } : naming) : null // a board deleted mid-naming leaves the draft
|
|
410
|
-
const rows: ReactNode[] = []
|
|
411
|
-
const draftRows = draft ? [
|
|
412
|
-
<div key="f:new" className="it folder editing"><FolderOpenIcon size={14} />{input('', 'Folder name')}</div>,
|
|
413
|
-
...(draft.board ? [<div key="new/board" className={`it board in-folder draft${draft.board === board ? ' cur' : ''}`}><CardsIcon size={14} /><span>{label(draft.board)}</span></div>] : []),
|
|
414
|
-
] : []
|
|
415
|
-
tree.forEach((it, i) => {
|
|
416
|
-
if (draft && draft.index === i) rows.push(...draftRows)
|
|
417
|
-
if (it.kind === 'board') { if (draft?.board !== it.name) rows.push(boardRow(it.name, null, i, false)) }
|
|
418
|
-
else rows.push(...folderRow(it, draft?.board ? it.boards.filter((b) => b !== draft.board) : it.boards, i))
|
|
419
|
-
})
|
|
420
|
-
if (draft && draft.index >= tree.length) rows.push(...draftRows)
|
|
421
|
-
if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, -1, false)) // a published bundle without it shows none
|
|
479
|
+
const rows: ReactNode[] = listRows(tree, null, 0)
|
|
480
|
+
if (HAS_ALL_SCENES) rows.push(boardRow('all-scenes', null, 0)) // a published bundle without it shows none
|
|
422
481
|
return (
|
|
423
482
|
<div className="sh-boards" ref={rootRef} onContextMenu={(e: ReactMouseEvent) => blankMenu(e)}>
|
|
424
483
|
<div className="hd">
|
|
@@ -138,15 +138,15 @@ export const modeAllowed = (board: string, mode: 'present' | 'focus' | 'slides')
|
|
|
138
138
|
export { cap, humanize } from './labels.ts'
|
|
139
139
|
import { cap, humanize } from './labels.ts'
|
|
140
140
|
import { canAutoReload } from './canvas/ready-watch.ts'
|
|
141
|
-
import { buildTree, flatten, labelOf, toWire, type TreeItem } from '../../shared/board-tree.ts'
|
|
141
|
+
import { buildTree, flatten, labelOf, toWire, TREE_PROTOCOL, type TreeItem } from '../../shared/board-tree.ts'
|
|
142
142
|
|
|
143
143
|
/** The CAS tokens a tree write echoes: the sha256 of every board file as last seen, and of
|
|
144
144
|
* the folder registry (null = there was no file). */
|
|
145
145
|
export interface TreeBase { boards: Record<string, string>; folders: string | null }
|
|
146
146
|
export interface TreeSnapshot { tree: TreeItem[]; base: TreeBase; titles: Record<string, string> }
|
|
147
147
|
|
|
148
|
-
/** The sidebar tree: root boards and folders in rank order, each folder's boards
|
|
149
|
-
* (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
|
|
148
|
+
/** The sidebar tree: root boards and folders in rank order, each folder's boards and
|
|
149
|
+
* sub-folders inside (shared/board-tree.ts), plus the hashes it was built from. `all-scenes` is not in it - it
|
|
150
150
|
* is pinned last by the callers. Throws on transport failure and on a malformed registry
|
|
151
151
|
* (the server's 422 message) - callers keep their last known tree. */
|
|
152
152
|
export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
@@ -154,7 +154,7 @@ export async function fetchBoardTree(): Promise<TreeSnapshot> {
|
|
|
154
154
|
const [boards, reg] = await Promise.all([
|
|
155
155
|
fetch(`${ROUTE}/api/boards`).then((r) => r.json()) as Promise<{ name: string; sha256: string; order?: number; folder?: string; title?: string }[]>,
|
|
156
156
|
fetch(`${ROUTE}/api/folders`).then(async (r) => {
|
|
157
|
-
const j = await r.json() as { folders?: { name: string; order?: number; title?: string }[]; sha256?: string | null; error?: string }
|
|
157
|
+
const j = await r.json() as { folders?: { name: string; order?: number; parent?: string; title?: string }[]; sha256?: string | null; error?: string }
|
|
158
158
|
if (!r.ok) throw new Error(j?.error ?? `folders ${r.status}`)
|
|
159
159
|
return j
|
|
160
160
|
}),
|
|
@@ -795,7 +795,7 @@ export const useStore = create<State>((set, get) => {
|
|
|
795
795
|
const active = get().board
|
|
796
796
|
const boards = { ...base.boards, ...(get().boardHash && base.boards[active] !== undefined ? { [active]: get().boardHash } : {}) }
|
|
797
797
|
let res: Response
|
|
798
|
-
try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders } }) }
|
|
798
|
+
try { res = await postOwner('boards/reorder', { tree: toWire(tree), base: { boards, folders: base.folders }, protocol: TREE_PROTOCOL }) }
|
|
799
799
|
catch { return { ok: false as const, stale: false, error: 'could not reach the dev server' } }
|
|
800
800
|
const body = await res.json().catch(() => ({} as { error?: string; sha256?: { boards?: Record<string, string> } }))
|
|
801
801
|
if (!res.ok) return { ok: false as const, stale: res.status === 409, error: body?.error }
|
|
@@ -484,7 +484,7 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
484
484
|
#0088ff - never the interact-mode purple that --glass-accent shifts to. */
|
|
485
485
|
.sh-panel .it.board.drop-before, .sh-panel .it.board.drop-after { position: relative }
|
|
486
486
|
.sh-panel .it.board.drop-before::after, .sh-panel .it.board.drop-after::after {
|
|
487
|
-
content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
|
|
487
|
+
content: ''; position: absolute; left: var(--seam-left, 6px); right: 6px; height: 3px; border-radius: 3px;
|
|
488
488
|
background: #0088ff; pointer-events: none }
|
|
489
489
|
.sh-panel .it.board.drop-before::after { top: -1px }
|
|
490
490
|
.sh-panel .it.board.drop-after::after { bottom: -1px }
|
|
@@ -493,28 +493,29 @@ body.sh-space .sh-node { pointer-events: none } /* space-drag
|
|
|
493
493
|
.sh-panel .it.editing input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none;
|
|
494
494
|
color: var(--glass-ink); font: inherit }
|
|
495
495
|
.sh-panel .it.editing input::placeholder { color: var(--glass-ink-3) }
|
|
496
|
-
/* folders:
|
|
496
|
+
/* folders: two levels, same row grammar as boards (idle icon tertiary, count tertiary). A folder
|
|
497
497
|
holding the current board wears the ancestor wash so the active board's home survives a
|
|
498
|
-
collapse;
|
|
498
|
+
collapse; what it holds indents one step - a sub-folder's header one step, its boards two. Folder rows share the board seam rules; dropping INTO a
|
|
499
499
|
folder lights the whole row (wash + brand-blue ring) instead of a seam. */
|
|
500
500
|
.sh-panel .it.folder svg { color: var(--glass-ink-3); flex: none; margin: 0 1px; transition: color .2s ease }
|
|
501
501
|
.sh-panel .it.folder[data-reorderable]:active { cursor: grabbing }
|
|
502
502
|
.sh-panel .it.folder.dragging { opacity: .4 }
|
|
503
503
|
.sh-panel .it.folder.drop-before, .sh-panel .it.folder.drop-after { position: relative }
|
|
504
504
|
.sh-panel .it.folder.drop-before::after, .sh-panel .it.folder.drop-after::after {
|
|
505
|
-
content: ''; position: absolute; left: 6px; right: 6px; height: 3px; border-radius: 3px;
|
|
505
|
+
content: ''; position: absolute; left: var(--seam-left, 6px); right: 6px; height: 3px; border-radius: 3px;
|
|
506
506
|
background: #0088ff; pointer-events: none }
|
|
507
507
|
.sh-panel .it.folder.drop-before::after { top: -1px }
|
|
508
508
|
.sh-panel .it.folder.drop-after::after { bottom: -1px }
|
|
509
509
|
.sh-panel .it.folder.drop-into { background: var(--accent-wash); box-shadow: inset 0 0 0 1.5px #0088ff }
|
|
510
510
|
.sh-panel .it.folder.drop-into svg { color: #0088ff }
|
|
511
|
-
.sh-panel .it.board.in-folder { padding-left: 28px }
|
|
511
|
+
.sh-panel .it.board.in-folder, .sh-panel .it.folder.in-folder { padding-left: 28px }
|
|
512
|
+
.sh-panel .it.board.in-sub { padding-left: 56px }
|
|
512
513
|
.sh-panel .it.board.draft { cursor: default; opacity: .6 }
|
|
513
|
-
/* seams
|
|
514
|
-
|
|
515
|
-
|
|
514
|
+
/* seams are indented with the list they land in (--seam-left, set on the row that draws them),
|
|
515
|
+
so "inside", "inside the sub-folder" and "at the root" never draw alike; an open EMPTY folder
|
|
516
|
+
draws that indented seam under its own header (drop-in) */
|
|
516
517
|
.sh-panel .it.folder.drop-in { position: relative }
|
|
517
|
-
.sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: 28px; right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
|
|
518
|
+
.sh-panel .it.folder.drop-in::after { content: ''; position: absolute; left: var(--seam-left, 28px); right: 6px; bottom: -1px; height: 3px; border-radius: 3px;
|
|
518
519
|
background: #0088ff; pointer-events: none }
|
|
519
520
|
/* the Boards header carries a quiet folder-plus: tertiary, shows on hover of the header row */
|
|
520
521
|
.sh-panel .sh-boards .hd { display: flex; align-items: center; margin-right: 6px }
|