@kolkrabbi/kol-component 0.222.0 → 0.224.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.222.0",
3
+ "version": "0.224.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -121,6 +121,7 @@ export { default as SpecList } from './molecules/SpecList.jsx'
121
121
  export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './organisms/SpectrumControls.jsx'
122
122
  export { default as SplitToolButton } from './molecules/SplitToolButton.jsx'
123
123
  export { default as ToolPalette } from './organisms/ToolPalette.jsx'
124
+ export { default as DocumentEditor } from './organisms/DocumentEditor.jsx'
124
125
  export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
125
126
  export { default as TabsRow } from './molecules/TabsRow.jsx'
126
127
 
@@ -187,7 +188,8 @@ export { default as RowMenuButton } from './molecules/RowMenuButton.jsx'
187
188
  export { default as DocPage } from './molecules/DocPage.jsx'
188
189
  export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
189
190
  export { default as ProfileCard } from './molecules/ProfileCard.jsx'
190
- export { parseFrontmatter } from './utilities/frontmatter.js'
191
+ export { parseFrontmatter, splitFrontmatter, joinFrontmatter } from './utilities/frontmatter.js'
192
+ export { readDraft, writeDraft, clearDraft, listDrafts, moveDrafts, DRAFTS_EVENT } from './utilities/localDrafts.js'
191
193
  export { readCover } from './utilities/id3.js'
192
194
  export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegment, groupSegments, groupVariants, posterFor, partition } from './utilities/mediaKinds.js'
193
195
  export { nearestRatio, RATIOS } from './utilities/ratios.js'
@@ -63,6 +63,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
63
63
  * @param {Function} formatDate (isoString) => string — the row/preview date, ISO date-only by default
64
64
  * @param {Function} partition (objects, level) => { folders: string[], files: object[] }
65
65
  * @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
66
+ * @param {Function} renderDetails (file) => ReactNode — rows under the preview column's facts (tags, an Edit action); absent, nothing is added
66
67
  * @param {number|string} height controlled height — px as a number, or ANY CSS length (`'100%'`, `'60vh'`,
67
68
  * `'calc(100dvh - 240px)'`) so the browser can fill the space it is given
68
69
  * (kol-client-olina 2026-09-22); a CSS length draws no height grabber;
@@ -260,10 +261,16 @@ function Row({
260
261
  onDragStart: drop ? (e) => { e.stopPropagation(); drop.onDragStart(e, drop.path) } : undefined,
261
262
  onDragOver: (e) => { if (takesFiles(e) || (drop && !isFileDrag(e) && drop.canDrop(drop.path))) { e.preventDefault(); setOver(true) } },
262
263
  onDragLeave: () => setOver(false),
264
+ /* A ROW THAT CANNOT TAKE THE DROP LETS IT BUBBLE (the file-onto-file bug, 2026-09-25): the
265
+ * enclosing column / list allows the drag, so the drop still fires HERE, and moving into a
266
+ * FILE's key renamed the dragged file. Unguarded, it skipped `canDrop`; now the container
267
+ * takes it — dropped on a file, it lands in that file's folder, as Finder does. */
263
268
  onDrop: (e) => {
264
- e.preventDefault(); e.stopPropagation(); setOver(false)
265
- if (takesFiles(e)) dropFiles(e.dataTransfer.files)
266
- else if (drop && !isFileDrag(e)) drop.onDrop(drop.path)
269
+ setOver(false)
270
+ if (takesFiles(e)) { e.preventDefault(); e.stopPropagation(); dropFiles(e.dataTransfer.files); return }
271
+ if (!drop || isFileDrag(e) || !drop.canDrop(drop.path)) return
272
+ e.preventDefault(); e.stopPropagation()
273
+ drop.onDrop(drop.path)
267
274
  },
268
275
  } : {}
269
276
  return (
@@ -350,7 +357,7 @@ function Row({
350
357
  )
351
358
  }
352
359
 
353
- function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, width }) {
360
+ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, renderDetails, width }) {
354
361
  // Pixel size and length come from the loaded media itself — the bucket stores
355
362
  // none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
356
363
  // `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
@@ -408,6 +415,14 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
408
415
  </div>
409
416
  ))}
410
417
  </dl>
418
+ {/* `renderDetails(o)` — the consumer's rows under the facts (the media D1 pass: tags, Edit) */}
419
+ {/* its own island: a click or a drag in here is not a click on the browser's background,
420
+ which deselects (a tag field would unpick the file it edits) */}
421
+ {renderDetails && (
422
+ <div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
423
+ {renderDetails(o)}
424
+ </div>
425
+ )}
411
426
  </div>
412
427
  )
413
428
  }
@@ -474,6 +489,9 @@ export default function ColumnBrowser({
474
489
  formatDate = defaultFormatDate,
475
490
  partition = defaultPartition,
476
491
  renderPreview,
492
+ /* `renderDetails(file)` — extra rows under the preview column's facts (tags, an Edit action);
493
+ * absent, the column is exactly what it was */
494
+ renderDetails,
477
495
  height,
478
496
  defaultHeight = 528,
479
497
  onHeightChange,
@@ -648,6 +666,9 @@ export default function ColumnBrowser({
648
666
 
649
667
  const shiftAnchor = useRef(null)
650
668
  const onKeyDown = (e) => {
669
+ /* typing in a field inside the browser (the preview's tag field) is not navigation */
670
+ const el = e.target
671
+ if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
651
672
  /* ⌘↑ / ⌘↓ (user 2026-09-23: *"all modes should 'command up down' to move up and down parent
652
673
  * child"*) — Finder's: ⌘↑ is ←, back to the enclosing folder; ⌘↓ opens what the cursor is on,
653
674
  * → for a folder and Quick Look for a file. */
@@ -1029,7 +1050,7 @@ export default function ColumnBrowser({
1029
1050
  </>
1030
1051
  ) : shown && (
1031
1052
  <>
1032
- <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
1053
+ <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} renderDetails={renderDetails} width={widthOf('preview')} />
1033
1054
  <ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
1034
1055
  </>
1035
1056
  )}
@@ -0,0 +1,279 @@
1
+ /* taxonomy-ok: organism — composes QuickLookFrame, FullscreenOverlay, Input, Textarea, ViewToggle, Button and KindPreview into one editing surface. */
2
+ import { useEffect, useMemo, useRef, useState } from 'react'
3
+ import Button from '../atoms/Button.jsx'
4
+ import Input from '../atoms/Input.jsx'
5
+ import Textarea from '../atoms/Textarea.jsx'
6
+ import ViewToggle from '../atoms/ViewToggle.jsx'
7
+ import QuickLookFrame from '../molecules/QuickLookFrame.jsx'
8
+ import KindPreview from '../molecules/KindPreview.jsx'
9
+ import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
10
+ import { splitFrontmatter, joinFrontmatter } from '../utilities/frontmatter.js'
11
+ import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
12
+
13
+ /**
14
+ * DocumentEditor — write a text file: open one, or make a new one (media D1 plan v2, 2026-09-26).
15
+ *
16
+ * The concept is kol-olina's brand notes page (NoteEdit): a title, a markdown body, Write / Preview,
17
+ * attachments from the bucket, a page for writing something NEW rather than a button hidden in a
18
+ * list. Here the document is a FILE in the bucket, so it takes any text kind, and a markdown file's
19
+ * frontmatter gets a form instead of a YAML fence to hand-edit.
20
+ *
21
+ * markdown a FIELDS form (title · description · date · tags suggested, any key allowed) over
22
+ * the body; Write · Split · Preview; Attach inserts a bucket file as a link / image
23
+ * svg text with a live picture beside it
24
+ * the rest text (json · yaml · csv · txt · code) with Split / Preview through KindPreview
25
+ *
26
+ * TWO TIERS, as NoteEdit had them: the DRAFT is browser memory (`localDrafts`, every pause, no
27
+ * network — the user's ruling), the SAVE is explicit (`onSave` / ⌘S). A draft is restored only if it
28
+ * is newer than the saved file (`savedAt`), so a file saved since is never overwritten by an old
29
+ * draft. Revert drops the draft.
30
+ *
31
+ * @param {'edit'|'new'} mode `new` shows the name + type row and calls `onCreate`
32
+ * @param {string} name the file's name (edit) or a starting name (new)
33
+ * @param {string} kind markdown · text · json · yaml · code · svg (edit); new picks it
34
+ * @param {string} text the saved text; `null` while it loads
35
+ * @param {number} savedAt epoch ms of the saved file — a draft older than this is ignored
36
+ * @param {Object} draft `{ bucket, key }` — where the draft lives; omit for no drafts
37
+ * @param {Function} onSave `(text) => Promise` — edit mode
38
+ * @param {Function} onCreate `({ name, text }) => Promise` — new mode; the name carries the extension
39
+ * @param {string} folder new mode: where the file will be made (shown, not edited)
40
+ * @param {Array} assets `[{ key, name, url, contentType }]` — the files markdown's Attach offers; a
41
+ * searchable list inside the editor inserts the pick at the caret
42
+ * @param {Function} onPickAsset `() => Promise<{ name, url, contentType } | null>` — a consumer's own picker instead
43
+ * @param {Function} onClose
44
+ */
45
+ const TYPES = [
46
+ { value: 'md', label: 'MD', kind: 'markdown' },
47
+ { value: 'txt', label: 'TXT', kind: 'text' },
48
+ { value: 'json', label: 'JSON', kind: 'json' },
49
+ { value: 'yaml', label: 'YAML', kind: 'yaml' },
50
+ { value: 'csv', label: 'CSV', kind: 'text' },
51
+ ]
52
+ const today = () => new Date().toISOString().slice(0, 10)
53
+ const STARTER = {
54
+ md: (title) => joinFrontmatter([['title', title], ['date', today()], ['tags', []]], `\n# ${title}\n\n`),
55
+ txt: () => '',
56
+ json: () => '{\n \n}\n',
57
+ yaml: () => '',
58
+ csv: () => 'name,value\n',
59
+ }
60
+ /* the keys a document usually carries, offered as one-click rows — any other key is typed */
61
+ const SUGGESTED = ['title', 'description', 'date', 'tags']
62
+ const DRAFT_PAUSE = 400 // browser memory is free — a short pause, not D1's write budget
63
+
64
+ export default function DocumentEditor({
65
+ mode = 'edit', name: nameProp = '', kind: kindProp, text, savedAt = 0, draft,
66
+ onSave, onCreate, folder = '', assets, onPickAsset, onClose,
67
+ }) {
68
+ const [picking, setPicking] = useState(false)
69
+ const [pickQuery, setPickQuery] = useState('')
70
+ const isNew = mode === 'new'
71
+ const [name, setName] = useState(isNew ? (nameProp || 'untitled') : nameProp)
72
+ const [type, setType] = useState('md')
73
+ const kind = isNew ? TYPES.find((t) => t.value === type).kind : kindProp
74
+ const [value, setValue] = useState(null)
75
+ const [base, setBase] = useState(null)
76
+ const [status, setStatus] = useState(isNew ? 'Not created yet' : 'Loading…')
77
+ const [view, setView] = useState(kind === 'markdown' || kind === 'svg' ? 'split' : 'write')
78
+ const timer = useRef(null)
79
+ const bodyRef = useRef(null)
80
+ const latest = useRef('')
81
+
82
+ /* load: the saved text, or a newer draft; a new document starts from its type's starter */
83
+ useEffect(() => {
84
+ if (isNew) {
85
+ const d = draft ? readDraft(draft.bucket, draft.key) : null
86
+ const start = d?.text ?? STARTER.md(name)
87
+ setValue(start); latest.current = start; setBase('')
88
+ if (d) setStatus('Draft restored')
89
+ return
90
+ }
91
+ if (text == null) return
92
+ const d = draft ? readDraft(draft.bucket, draft.key) : null
93
+ const useDraft = d && d.at > savedAt && d.text !== text
94
+ setBase(text)
95
+ setValue(useDraft ? d.text : text); latest.current = useDraft ? d.text : text
96
+ setStatus(useDraft ? 'Draft restored — not saved to the file' : 'Saved')
97
+ if (d && !useDraft && draft) clearDraft(draft.bucket, draft.key)
98
+ }, [text]) // eslint-disable-line react-hooks/exhaustive-deps
99
+
100
+ const flush = () => {
101
+ if (timer.current == null || !draft) return
102
+ clearTimeout(timer.current); timer.current = null
103
+ if (latest.current === base && !isNew) clearDraft(draft.bucket, draft.key)
104
+ else writeDraft(draft.bucket, draft.key, latest.current)
105
+ }
106
+ useEffect(() => () => flush(), []) // eslint-disable-line react-hooks/exhaustive-deps
107
+ const change = (next) => {
108
+ setValue(next); latest.current = next
109
+ setStatus(isNew ? 'Not created yet' : next === base ? 'Saved' : 'Edited — not saved')
110
+ clearTimeout(timer.current)
111
+ timer.current = setTimeout(flush, DRAFT_PAUSE)
112
+ }
113
+
114
+ const dirty = value != null && (isNew || value !== base)
115
+ const save = async () => {
116
+ if (!dirty) return
117
+ clearTimeout(timer.current); timer.current = null
118
+ setStatus(isNew ? 'Creating…' : 'Saving…')
119
+ try {
120
+ if (isNew) {
121
+ const file = name.trim().replace(/\.[^./]+$/, '') || 'untitled'
122
+ await onCreate?.({ name: `${file}.${type}`, text: latest.current })
123
+ } else {
124
+ await onSave?.(latest.current)
125
+ setBase(latest.current)
126
+ }
127
+ if (draft) clearDraft(draft.bucket, draft.key)
128
+ setStatus('Saved')
129
+ } catch (e) { setStatus(`Not saved: ${e.message}`) }
130
+ }
131
+ const revert = () => {
132
+ clearTimeout(timer.current); timer.current = null
133
+ if (draft) clearDraft(draft.bucket, draft.key)
134
+ const back = isNew ? STARTER[type](name) : base
135
+ setValue(back); latest.current = back; setStatus(isNew ? 'Not created yet' : 'Saved')
136
+ }
137
+ /* ⌘S anywhere while the editor is open */
138
+ const saveRef = useRef(save)
139
+ saveRef.current = save
140
+ useEffect(() => {
141
+ const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveRef.current() } }
142
+ window.addEventListener('keydown', onKey)
143
+ return () => window.removeEventListener('keydown', onKey)
144
+ }, [])
145
+
146
+ /* markdown: the fields form and the body are one text, split for editing and joined on change */
147
+ const md = kind === 'markdown'
148
+ const parts = useMemo(() => (md && value != null ? splitFrontmatter(value) : null), [md, value])
149
+ const setFields = (fields) => change(joinFrontmatter(fields, parts.body))
150
+ const setBody = (body) => change(parts.has || parts.fields.length ? joinFrontmatter(parts.fields, body) : body)
151
+
152
+ const insertAsset = (a) => {
153
+ const link = (a.contentType ?? '').startsWith('image/') ? `![${a.name}](${a.url})` : `[${a.name}](${a.url})`
154
+ const el = bodyRef.current?.querySelector('textarea')
155
+ const body = parts.body
156
+ const at = el ? el.selectionStart : body.length
157
+ setBody(`${body.slice(0, at)}${link}${body.slice(at)}`)
158
+ setPicking(false)
159
+ }
160
+ const attach = async () => {
161
+ if (!onPickAsset) { setPicking((p) => !p); return }
162
+ const a = await onPickAsset()
163
+ if (a) insertAsset(a)
164
+ }
165
+ /* images first — Attach is mostly "put this picture in the document" */
166
+ const pickList = (assets ?? [])
167
+ .filter((a) => !pickQuery || a.key.toLowerCase().includes(pickQuery.toLowerCase()))
168
+ .sort((a, b) => Number(!(a.contentType ?? '').startsWith('image/')) - Number(!(b.contentType ?? '').startsWith('image/')) || a.key.localeCompare(b.key))
169
+ .slice(0, 60)
170
+
171
+ /* THE NAME CARRIES THE TITLE while the title is still the starter's — the first thing typed names
172
+ * both, as NoteEdit's title named the note; once the title is edited it is left alone */
173
+ const rename = (next) => {
174
+ const was = name
175
+ setName(next)
176
+ if (!md || !parts) return
177
+ const title = parts.fields.find(([k]) => k.toLowerCase() === 'title')
178
+ if (!title || title[1] !== was) return
179
+ const heading = `# ${was}`
180
+ const body = parts.body.startsWith(`\n${heading}`) || parts.body.startsWith(heading) ? parts.body.replace(heading, `# ${next}`) : parts.body
181
+ change(joinFrontmatter(parts.fields.map(([k, v]) => (k === title[0] ? [k, next] : [k, v])), body))
182
+ }
183
+ const close = () => { flush(); onClose?.() }
184
+ const views = [{ value: 'write', label: 'Write' }, { value: 'split', label: 'Split' }, { value: 'preview', label: 'Preview' }]
185
+ const preview = value == null ? null : kind === 'svg'
186
+ ? <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(value)}`} alt="" className="max-w-full max-h-full object-contain m-auto" />
187
+ : <KindPreview o={{ key: name || 'document' }} text={value} kind={kind} />
188
+
189
+ return (
190
+ <FullscreenOverlay open onClose={close} closeButton={false} scrim>
191
+ <QuickLookFrame title={isNew ? `New document${folder ? ` in ${folder}` : ''}` : name} meta={status} onClose={close}
192
+ actions={(
193
+ <span className="flex items-center gap-2">
194
+ <ViewToggle variant="text" viewMode={view} onViewChange={setView} options={views} />
195
+ {md && (onPickAsset || assets?.length > 0) && <Button variant="nav" size="sm" iconLeft="image" onClick={attach} aria-pressed={picking}>Attach</Button>}
196
+ {!isNew && value !== base && value != null && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
197
+ <Button variant="secondary" size="sm" onClick={save} disabled={!dirty}>{isNew ? 'Create' : 'Save'}</Button>
198
+ </span>
199
+ )}>
200
+ {/* the window's own caps, inline — Tailwind does not generate arbitrary values from package source */}
201
+ <div className="kol-doc-editor flex flex-col" style={{ width: 'min(1200px, var(--kol-ql-max-w))', height: 'min(760px, var(--kol-ql-media-h))' }}>
202
+ {isNew && (
203
+ <div className="flex flex-wrap items-center gap-3 p-4 border-b" style={{ borderColor: 'var(--kol-oq-08)' }}>
204
+ <Input variant="filled" size="sm" value={name} aria-label="File name" placeholder="File name"
205
+ onChange={(e) => rename(e.target.value)} className="min-w-0 flex-1" />
206
+ <ViewToggle variant="text" viewMode={type} options={TYPES.map(({ value: v, label }) => ({ value: v, label }))}
207
+ onViewChange={(t) => { setType(t); const s = STARTER[t](name); setValue(s); latest.current = s; setView(t === 'md' ? 'split' : 'write') }} />
208
+ </div>
209
+ )}
210
+ <div className="flex flex-1 min-h-0">
211
+ {view !== 'preview' && (
212
+ <div ref={bodyRef} className="flex flex-col gap-4 p-4 min-w-0 overflow-y-auto" style={{ flex: '1 1 0' }}>
213
+ {md && parts && <FieldsForm fields={parts.fields} onChange={setFields} />}
214
+ {picking && (
215
+ <div className="flex flex-col gap-2 border rounded p-2" style={{ borderColor: 'var(--kol-oq-08)' }}>
216
+ <Input variant="filled" size="xs" value={pickQuery} placeholder="Find a file to attach" aria-label="Find a file to attach"
217
+ onChange={(e) => setPickQuery(e.target.value)} autoFocus />
218
+ <div className="flex flex-col overflow-y-auto" style={{ maxHeight: 200 }}>
219
+ {pickList.length === 0 && <span className="kol-mono-12 text-fg-48 p-2">No file matches.</span>}
220
+ {pickList.map((a) => (
221
+ <button key={a.key} type="button" onClick={() => insertAsset(a)}
222
+ className="kol-mono-12 text-left px-2 py-1 rounded text-fg-default hover:bg-oq-04 truncate">{a.key}</button>
223
+ ))}
224
+ </div>
225
+ </div>
226
+ )}
227
+ {value == null
228
+ ? <p className="kol-mono-12 text-fg-48">Loading…</p>
229
+ : <Textarea variant="filled" size="md" rows={18} axis="vertical" className="w-full flex-1 kol-mono-12"
230
+ value={md ? parts.body : value} onChange={(e) => (md ? setBody(e.target.value) : change(e.target.value))}
231
+ spellCheck={kind === 'markdown' || kind === 'text'} aria-label="Document text"
232
+ placeholder={md ? 'Write in markdown — # heading, **bold**, - list' : ''} />}
233
+ </div>
234
+ )}
235
+ {view !== 'write' && (
236
+ <div className="p-4 min-w-0 overflow-auto border-l" style={{ flex: '1 1 0', borderColor: 'var(--kol-oq-08)' }}>
237
+ {preview}
238
+ </div>
239
+ )}
240
+ </div>
241
+ </div>
242
+ </QuickLookFrame>
243
+ </FullscreenOverlay>
244
+ )
245
+ }
246
+
247
+ /* THE FRONTMATTER AS FIELDS. One row per key; a list value (tags) is typed comma-separated. The
248
+ * suggested keys a document has not got yet are one click away; any other key is typed. */
249
+ function FieldsForm({ fields, onChange }) {
250
+ const set = (i, key, v) => onChange(fields.map((f, j) => (j === i ? [key, v] : f)))
251
+ const missing = SUGGESTED.filter((s) => !fields.some(([k]) => k.toLowerCase() === s))
252
+ return (
253
+ <div className="flex flex-col gap-2">
254
+ <span className="kol-mono-12 text-fg-48">Fields</span>
255
+ {fields.map(([key, v], i) => (
256
+ <div key={i} className="flex items-center gap-2">
257
+ <Input variant="filled" size="xs" value={key} aria-label="Field name" className="w-28 shrink-0"
258
+ onChange={(e) => set(i, e.target.value, v)} />
259
+ {Array.isArray(v) || key.toLowerCase() === 'tags' ? (
260
+ /* a list commits on Enter / blur — re-deriving it per keystroke would eat the comma you just typed */
261
+ <Input variant="filled" size="xs" className="flex-1 min-w-0" aria-label={`${key || 'Field'} value`}
262
+ value={Array.isArray(v) ? v.join(', ') : v} placeholder="comma, separated"
263
+ onCommit={(raw) => set(i, key, raw.split(',').map((t) => t.trim()).filter(Boolean))} />
264
+ ) : (
265
+ <Input variant="filled" size="xs" className="flex-1 min-w-0" aria-label={`${key || 'Field'} value`}
266
+ value={v} onChange={(e) => set(i, key, e.target.value)} />
267
+ )}
268
+ <Button variant="nav" size="xs" iconOnly="x" aria-label={`Remove ${key}`} onClick={() => onChange(fields.filter((_, j) => j !== i))} />
269
+ </div>
270
+ ))}
271
+ <div className="flex flex-wrap items-center gap-1">
272
+ {missing.map((s) => (
273
+ <Button key={s} variant="nav" size="xs" iconLeft="plus" onClick={() => onChange([...fields, [s, s === 'tags' ? [] : s === 'date' ? today() : '']])}>{s}</Button>
274
+ ))}
275
+ <Button variant="nav" size="xs" iconLeft="plus" onClick={() => onChange([...fields, ['', '']])}>Field</Button>
276
+ </div>
277
+ </div>
278
+ )
279
+ }
@@ -4,6 +4,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
4
4
  import Button from '../atoms/Button.jsx'
5
5
  import Divider from '../atoms/Divider.jsx'
6
6
  import Input from '../atoms/Input.jsx'
7
+ import Tag from '../atoms/Tag.jsx'
7
8
  import IconFrame from '../atoms/IconFrame.jsx'
8
9
  import ActionButton from '../atoms/ActionButton.jsx'
9
10
  import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
@@ -31,6 +32,9 @@ import { formatLength } from '../molecules/AudioPreview.jsx'
31
32
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
32
33
  import { useModal } from '../molecules/Modal.jsx'
33
34
  import ContentFilters from './ContentFilters.jsx'
35
+ import DocumentEditor from './DocumentEditor.jsx'
36
+ import { listDrafts, moveDrafts, DRAFTS_EVENT } from '../utilities/localDrafts.js'
37
+ import { parseFrontmatter } from '../utilities/frontmatter.js'
34
38
  import ShellSearchOverlay from './ShellSearchOverlay.jsx'
35
39
  import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
36
40
  import SettingsPanel, { LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
@@ -66,6 +70,20 @@ import { nearestRatio } from '../utilities/ratios.js'
66
70
  * `client.buckets()` (kol-media-client ≥0.2.0); a client with none is one bucket
67
71
  * and no dropdown. Settings are kol-r2b2's per-bucket model (`SETTINGS_BASE`),
68
72
  * controlled through `settings` / `onSettingsChange` so a consumer persists them.
73
+ *
74
+ * WHAT A DATABASE BESIDE THE BUCKET ADDS (the media D1 pass, 2026-09-25 — kol-olina keeps these
75
+ * in Cloudflare D1). Every verb is optional; a client without one hides that feature and nothing
76
+ * else changes. Keys are the page's own (bucket keys), `bucket` is the bucket id.
77
+ * tags listed objects carry `tags: string[]`; `setTags(key, tags, bucket)` writes them —
78
+ * preview-pane chips, "Tags…" / "Add tags to N…" in the menu, a Tags filter
79
+ * group, a Tags column under Fields
80
+ * documents `readText(key, bucket) → { text }` · `writeText(key, text, bucket)` ·
81
+ * `createFile(key, bucket, text?)` — Edit in the pane and in Quick Look, New document
82
+ * in the menu (markdown gets a fields form over its frontmatter; svg a live picture).
83
+ * DRAFTS ARE NOT THE CLIENT'S: browser memory (`utilities/localDrafts`), user ruling
84
+ * 2026-09-26 — only what must outlive a device goes to the database
85
+ * settings `loadSettings(bucket)` · `saveSettings(bucket, settings | null)` (null = reset) —
86
+ * used when the page is UNCONTROLLED; a `settings` prop still wins
69
87
  */
70
88
 
71
89
  /* THE ROW VIEW'S COLUMNS (user 2026-09-22). OFF by default and `rowColumns` turns them on: with
@@ -165,32 +183,65 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
165
183
  const buckets = useMemo(() => client?.buckets?.() ?? [], [client])
166
184
  const bucketMeta = buckets.find((b) => b.id === bucket) ?? buckets[0] ?? { id: null, label: '', writable: false }
167
185
  const bucketId = bucketMeta.id
168
- const [own, setOwn] = useState(() => ({ ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) }))
186
+ const base = () => ({ ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) })
187
+ const [own, setOwn] = useState(base)
188
+ /* PERSONALISATION (the media D1 pass, 2026-09-25): an uncontrolled page whose client offers
189
+ * `loadSettings(bucket)` / `saveSettings(bucket, settings | null)` keeps each person's view
190
+ * settings THERE — kol-olina's D1 — instead of for this tab only. `null` is reset. A `settings`
191
+ * prop still wins: a consumer that owns them is left alone, exactly as before. */
192
+ const remote = !controlled && typeof client?.loadSettings === 'function'
193
+ useEffect(() => {
194
+ if (!remote) return undefined
195
+ let live = true
196
+ Promise.resolve(client.loadSettings(bucketId ?? undefined))
197
+ .then((got) => { if (live) setOwn({ ...base(), ...(got ?? {}) }) })
198
+ .catch(() => {})
199
+ return () => { live = false }
200
+ }, [remote, client, bucketId]) // eslint-disable-line react-hooks/exhaustive-deps
169
201
  const settings = controlled ?? own
170
202
  const setSettings = (next) => {
171
- if (next === null) { const back = { ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) }; if (!controlled) setOwn(back); onSettingsChange?.(back); return }
203
+ if (next === null) {
204
+ const back = base()
205
+ if (!controlled) setOwn(back)
206
+ if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, null)).then((got) => got && setOwn({ ...back, ...got })).catch(() => {})
207
+ onSettingsChange?.(back)
208
+ return
209
+ }
172
210
  if (!controlled) setOwn(next)
211
+ if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, next)).catch(() => {})
173
212
  onSettingsChange?.(next)
174
213
  }
175
- const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], error: null })
214
+ const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], folders: {}, error: null })
176
215
  const [cache, setCache] = useState({})
216
+ /* `reload()` — a write the PAGE made itself (a new document, a tag on a folder) re-lists without
217
+ * waiting for the consumer to bump `refreshKey` */
218
+ const [nonce, setNonce] = useState(0)
219
+ const loadKey = `${refreshKey}.${nonce}`
177
220
  useEffect(() => {
178
221
  if (!client) return undefined
179
222
  let cancelled = false
180
223
  const controller = new AbortController()
181
- client.listMedia('', { signal: controller.signal, bucket: bucketId ?? undefined })
182
- .then((objs) => { if (cancelled) return; setLoaded({ key: refreshKey, bucket: bucketId, objects: objs, error: null }); setCache((c) => ({ ...c, [bucketId]: objs })) })
183
- .catch((e) => { if (!cancelled && e.name !== 'AbortError') setLoaded({ key: refreshKey, bucket: bucketId, objects: [], error: e.message }) })
224
+ /* the folder rows (`folderInfo`, a D1 read) come beside the listing: tags and favourites a
225
+ * folder carries, which a key-prefix bucket has nowhere to keep */
226
+ Promise.all([
227
+ client.listMedia('', { signal: controller.signal, bucket: bucketId ?? undefined }),
228
+ Promise.resolve(client.folderInfo?.(bucketId ?? undefined)).catch(() => ({})),
229
+ ])
230
+ .then(([objs, folders]) => { if (cancelled) return; setLoaded({ key: loadKey, bucket: bucketId, objects: objs, folders: folders ?? {}, error: null }); setCache((c) => ({ ...c, [bucketId]: objs })) })
231
+ .catch((e) => { if (!cancelled && e.name !== 'AbortError') setLoaded({ key: loadKey, bucket: bucketId, objects: [], folders: {}, error: e.message }) })
184
232
  return () => { cancelled = true; controller.abort() }
185
- }, [client, refreshKey, bucketId])
186
- const ready = loaded.key === refreshKey && loaded.bucket === bucketId
233
+ }, [client, loadKey, bucketId]) // eslint-disable-line react-hooks/exhaustive-deps
234
+ const reload = () => setNonce((n) => n + 1)
235
+ const ready = loaded.key === loadKey && loaded.bucket === bucketId
187
236
  const objects = ready ? loaded.objects : (cache[bucketId] ?? [])
188
237
  const setObjects = (fn) => setLoaded((prev) => ({ ...prev, objects: fn(prev.objects) }))
238
+ const folderInfo = loaded.folders ?? {}
239
+ const setFolderInfo = (fn) => setLoaded((prev) => ({ ...prev, folders: fn(prev.folders ?? {}) }))
189
240
  const error = ready ? loaded.error : null
190
241
  const mediaUrl = (key) => client?.mediaUrl?.(key, bucketId ?? undefined) ?? key
191
242
  const downloadUrl = (key) => client?.downloadUrl?.(key, bucketId ?? undefined) ?? mediaUrl(key)
192
243
  const writable = !!bucketMeta.writable && !!(client?.deleteObject || client?.renameObject)
193
- return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, error, ready, mediaUrl, downloadUrl, writable }
244
+ return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, folderInfo, setFolderInfo, reload, error, ready, mediaUrl, downloadUrl, writable }
194
245
  }
195
246
 
196
247
  /* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
@@ -213,6 +264,37 @@ function ImageFrame({ src, vector }) {
213
264
  }
214
265
 
215
266
 
267
+ /* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
268
+ * one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
269
+ * without it, or on a read-only bucket, the chips show and nothing edits them. */
270
+ function TagEditor({ tags = [], onChange, suggestions = [] }) {
271
+ const listId = useMemo(() => `kol-tags-${Math.random().toString(36).slice(2, 8)}`, [])
272
+ const add = (raw) => {
273
+ const next = raw.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
274
+ if (next.length) onChange([...new Set([...tags, ...next])])
275
+ }
276
+ if (!onChange && !tags.length) return null
277
+ return (
278
+ <div className="flex flex-col gap-2">
279
+ <span className="kol-mono-12 text-fg-48">Tags</span>
280
+ {tags.length > 0 && (
281
+ <div className="flex flex-wrap gap-1">
282
+ {tags.map((t) => (
283
+ <Tag key={t} variant="secondary" size="sm" onRemove={onChange ? () => onChange(tags.filter((x) => x !== t)) : undefined}>{t}</Tag>
284
+ ))}
285
+ </div>
286
+ )}
287
+ {/* the bucket's own tags as suggestions — native autocomplete, so a typo does not fork the vocabulary */}
288
+ {onChange && <Input size="xs" value="" placeholder="Add a tag" aria-label="Add a tag" onCommit={add} list={listId} />}
289
+ {onChange && (
290
+ <datalist id={listId}>
291
+ {suggestions.filter((t) => !tags.includes(t)).map((t) => <option key={t} value={t} />)}
292
+ </datalist>
293
+ )}
294
+ </div>
295
+ )
296
+ }
297
+
216
298
  /* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
217
299
  * disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
218
300
  * that navigates — so nothing that already renders these moves. */
@@ -232,10 +314,16 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
232
314
  const dropProps = drag || dropFiles ? {
233
315
  onDragOver: (e) => { if (takesFiles(e) || (drag && !isFileDrag(e) && drag.canDrop(drag.path))) { e.preventDefault(); setOver(true) } },
234
316
  onDragLeave: () => setOver(false),
317
+ /* A ROW THAT CANNOT TAKE THE DROP LETS IT BUBBLE (the file-onto-file bug, 2026-09-25): the
318
+ * enclosing column / list allows the drag, so the drop still fires HERE, and moving into a
319
+ * FILE's key renamed the dragged file. Unguarded, it skipped `canDrop`; now the container
320
+ * takes it — dropped on a file, it lands in that file's folder, as Finder does. */
235
321
  onDrop: (e) => {
236
- e.preventDefault(); e.stopPropagation(); setOver(false)
237
- if (takesFiles(e)) dropFiles(e.dataTransfer.files)
238
- else if (drag && !isFileDrag(e)) drag.onDrop(drag.path)
322
+ setOver(false)
323
+ if (takesFiles(e)) { e.preventDefault(); e.stopPropagation(); dropFiles(e.dataTransfer.files); return }
324
+ if (!drag || isFileDrag(e) || !drag.canDrop(drag.path)) return
325
+ e.preventDefault(); e.stopPropagation()
326
+ drag.onDrop(drag.path)
239
327
  },
240
328
  } : {}
241
329
  return (
@@ -264,6 +352,7 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
264
352
  line up or the header above them is a lie. The trailing chevron went with them: it
265
353
  promised a click that opens, and a click now SELECTS (opening is a double-click). */}
266
354
  {cols && <>
355
+ {cols.tags != null && <span className="shrink-0" style={{ width: cols.tags }} />}
267
356
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{meta}</span>
268
357
  <span className="kol-mono-12 text-fg-24 shrink-0 truncate" style={{ width: cols.size }}>—</span>
269
358
  </>}
@@ -340,6 +429,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
340
429
  </span>
341
430
  <span className="flex-1" />
342
431
  {cols && <>
432
+ {cols.tags != null && <span className="kol-mono-12 text-fg-48 shrink-0 truncate" style={{ width: cols.tags }}>{(o.tags ?? []).map((t) => `#${t}`).join(' ')}</span>}
343
433
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
344
434
  <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
345
435
  </>}
@@ -352,7 +442,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
352
442
  * The columns never needed one — stepping into a folder IS its preview there — so this is the row
353
443
  * view's, and it is the page's rather than ColumnBrowser's because the counts come off the same
354
444
  * `objects` the page already holds. Same shell as the file preview, so the pane never shifts. */
355
- function FolderPreview({ path, objects, width, formatDate }) {
445
+ function FolderPreview({ path, objects, width, formatDate, details }) {
356
446
  const inside = objects.filter((o) => o.key.startsWith(path))
357
447
  const bytes = inside.reduce((n, o) => n + (o.size ?? 0), 0)
358
448
  const last = inside.reduce((t, o) => (o.uploaded && o.uploaded > t ? o.uploaded : t), '')
@@ -362,13 +452,13 @@ function FolderPreview({ path, objects, width, formatDate }) {
362
452
  ['Size', formatSize(bytes)],
363
453
  ...(last ? [['Newest', formatDate(last) || '—']] : []),
364
454
  ]
365
- return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} />
455
+ return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} details={details} />
366
456
  }
367
457
 
368
458
  /* ONE PREVIEW FOR EVERYTHING THAT HOLDS THINGS — a folder, a bucket, the title (user 2026-09-25:
369
459
  * selecting a bucket or MEDIA left the pane empty). The glyph, the name, the facts; only the
370
460
  * glyph and the facts differ. */
371
- function ContainerPreview({ name, icon, facts, width }) {
461
+ function ContainerPreview({ name, icon, facts, width, details }) {
372
462
  return (
373
463
  <div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
374
464
  {/* the folder fills its box with the tile's inset (user 2026-09-24: a 64px glyph floated in
@@ -385,6 +475,8 @@ function ContainerPreview({ name, icon, facts, width }) {
385
475
  </div>
386
476
  ))}
387
477
  </dl>
478
+ {/* a folder's own rows — favourite, tags (media D1 plan v2) — an island, like the file pane's */}
479
+ {details && <div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>{details}</div>}
388
480
  </div>
389
481
  )
390
482
  }
@@ -451,12 +543,43 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
451
543
  <span className="w-4 shrink-0" aria-hidden="true" />
452
544
  <span className="w-8 shrink-0" aria-hidden="true" />
453
545
  <span className="flex-1 min-w-0">{label('name', 'Name')}</span>
546
+ {/* Tags does not sort — a file has several — so its header is a plain label */}
547
+ {cols.tags != null && (
548
+ <div className="relative shrink-0 border-l" style={{ width: cols.tags, borderColor: 'var(--kol-oq-08)' }}>
549
+ {handle('tags')}
550
+ <span className="kol-helper-12 text-fg-48">Tags</span>
551
+ </div>
552
+ )}
454
553
  {column('date', 'Date')}
455
554
  {column('size', 'Size')}
456
555
  </div>
457
556
  )
458
557
  }
459
558
 
559
+ /* THE KINDS THAT OPEN AS TEXT — what the document editor takes (media D1 plan v2, 2026-09-26).
560
+ * An SVG is text too: it opens with its picture beside it. Above EDIT_CAP a file is not loaded into
561
+ * an editor at all — a textarea holding megabytes is a frozen tab, not an edit. */
562
+ const TEXT_KINDS = new Set(['markdown', 'json', 'yaml', 'text', 'code'])
563
+ const EDIT_CAP = 1_048_576
564
+ const editKindOf = (o) => (o.contentType === 'image/svg+xml' || extOf(o.key) === 'svg' ? 'svg' : TEXT_KINDS.has(kindOf(o)) ? kindOf(o) : null)
565
+
566
+ /* The editor for a file that exists: reads its text through the client, saves through it. Drafts
567
+ * are the DocumentEditor's (browser memory), keyed by bucket + key. */
568
+ function FileEditorHost({ o, client, bucket, onClose, onSaved, assets }) {
569
+ const [text, setText] = useState(null)
570
+ useEffect(() => {
571
+ let live = true
572
+ client.readText(o.key, bucket).then((r) => live && setText(r?.text ?? '')).catch(() => live && setText(''))
573
+ return () => { live = false }
574
+ }, [o.key]) // eslint-disable-line react-hooks/exhaustive-deps
575
+ return (
576
+ <DocumentEditor name={o.displayKey ?? o.key.split('/').pop()} kind={editKindOf(o)} text={text}
577
+ savedAt={o.uploaded ? Date.parse(o.uploaded) : 0} draft={{ bucket, key: o.key }} assets={assets}
578
+ onSave={async (next) => { const r = await client.writeText(o.key, next, bucket); onSaved?.(o.key, r, next) }}
579
+ onClose={onClose} />
580
+ )
581
+ }
582
+
460
583
  /**
461
584
  * MediaInspector — the full-screen viewer for one file in a set: image, video
462
585
  * (scrubbable, audio on), audio, or a document page, in one `QuickLookFrame`, with
@@ -482,8 +605,9 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
482
605
  * @param {Function} downloadUrl - `(key) => url` for the download control
483
606
  * @param {Set|Object} keySet - The bucket's key set, used to find a video's poster
484
607
  * @param {boolean} showNav - ‹ › and n / N in the header (default on); off when not paging a selection
608
+ * @param {Function} onEdit - `(file) => void` — an Edit action in the header for a text file (markdown · json · yaml · text · code); absent, no Edit
485
609
  */
486
- export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true }) {
610
+ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true, onEdit }) {
487
611
  const o = files[index]
488
612
  const [dims, setDims] = useState(null)
489
613
  /* the window's size survives paging — Finder keeps it — so it lives here, not in the frame,
@@ -522,6 +646,11 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
522
646
  * the header; bare at rest with a wash on hover, `sm` so the glyphs sit with the 12px text. */
523
647
  actions: (
524
648
  <span className="flex items-center gap-1">
649
+ {onEdit && editKindOf(o) && (o.size ?? 0) <= EDIT_CAP && (
650
+ <Tooltip label="Edit">
651
+ <Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
652
+ </Tooltip>
653
+ )}
525
654
  <CopyAction label="Copy URL" text={mediaUrl(o.key)} />
526
655
  <Tooltip label="Download">
527
656
  <Button variant="nav" size="sm" iconOnly="download" href={downloadUrl(o.key)} download={o.key.split('/').pop()} aria-label="Download" />
@@ -829,6 +958,9 @@ export function MediaLibraryBrowse({
829
958
  * rendering what it rendered. */
830
959
  view: viewProp, onViewChange,
831
960
  folderTree, headerActions, headerTrailing, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '', banner,
961
+ /* `smartFolder` — a smart folder's id to open ON (a Home tile linking straight to one); the chips
962
+ * take over from there (media D1 plan v2) */
963
+ smartFolder,
832
964
  /* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
833
965
  * optional and each async; whatever is supplied becomes a right-click menu entry and
834
966
  * anything absent simply is not offered. One object rather than four props because they
@@ -976,6 +1108,151 @@ export function MediaLibraryBrowse({
976
1108
  const press = useLongPress()
977
1109
  const modal = useModal()
978
1110
  const canWrite = !!fileActions && writable
1111
+ /* ── WHAT THE DATABASE BESIDE THE BUCKET ADDS (media D1 plan v2, 2026-09-26) ──────────────────
1112
+ * Every verb optional; each feature below is off when its verb is missing. Writes patch the list
1113
+ * in place so the change lands at once; the next listing confirms it. A path ending in `/` is a
1114
+ * folder — its rows come from `folderInfo`, a file's ride the listing. */
1115
+ const bucketKey = bucketMeta.id ?? undefined
1116
+ const folderRows = lib.folderInfo
1117
+ const patchPath = (path, patch) => {
1118
+ if (path.endsWith('/')) lib.setFolderInfo((f) => ({ ...f, [path]: { tags: [], favourite: false, ...(f[path] ?? {}), ...patch } }))
1119
+ else {
1120
+ lib.setObjects((list) => list.map((o) => (o.key === path ? { ...o, ...patch } : o)))
1121
+ setPickedFile((f) => (f?.key === path ? { ...f, ...patch } : f))
1122
+ }
1123
+ }
1124
+ /* TAGS — `setTags(path, tags, bucket)` */
1125
+ const canTag = writable && typeof client?.setTags === 'function'
1126
+ const tagsOf = (path) => (path.endsWith('/') ? folderRows[path]?.tags : objects.find((o) => o.key === path)?.tags) ?? []
1127
+ /* every tag in the bucket, for the add field's suggestions — a typo forks the vocabulary */
1128
+ /* the rows view grows a Tags column when there is anything to show or set */
1129
+ const hasTags = canTag || objects.some((o) => o.tags?.length)
1130
+ const allTags = useMemo(() => [...new Set([...objects.flatMap((o) => o.tags ?? []), ...Object.values(folderRows).flatMap((f) => f.tags ?? [])])].sort(), [objects, folderRows])
1131
+ const saveTags = (path, tags) => runAction(async () => {
1132
+ const r = await client.setTags(path, tags, bucketKey)
1133
+ patchPath(path, { tags: r?.tags ?? tags })
1134
+ })
1135
+ /* FAVOURITES — `setFavourite(path, on, bucket)` */
1136
+ const canFavourite = writable && typeof client?.setFavourite === 'function'
1137
+ const isFavourite = (path) => !!(path.endsWith('/') ? folderRows[path]?.favourite : objects.find((o) => o.key === path)?.favourite)
1138
+ const toggleFavourite = (path) => runAction(async () => {
1139
+ const on = !isFavourite(path)
1140
+ await client.setFavourite(path, on, bucketKey)
1141
+ patchPath(path, { favourite: on })
1142
+ })
1143
+ /* THE EVENT LOG recents are read from — intentions only (opened, edited, created) */
1144
+ const logEvent = (kind, path) => { Promise.resolve(client?.logEvent?.(kind, path, bucketKey)).catch(() => {}) }
1145
+ /* DRAFTS are browser memory (the user's ruling) — read here only to mark a file that has one */
1146
+ const [draftKeys, setDraftKeys] = useState(() => new Set(listDrafts(bucketKey).map((d) => d.key)))
1147
+ useEffect(() => {
1148
+ const read = () => setDraftKeys(new Set(listDrafts(bucketKey).map((d) => d.key)))
1149
+ read()
1150
+ window.addEventListener(DRAFTS_EVENT, read)
1151
+ return () => window.removeEventListener(DRAFTS_EVENT, read)
1152
+ }, [bucketKey])
1153
+ /* DOCUMENTS — `readText` / `writeText` edit; a new one is the consumer's `createFile` + `writeText` */
1154
+ const canEdit = writable && typeof client?.readText === 'function' && typeof client?.writeText === 'function'
1155
+ const canNewDoc = canEdit && canWrite && !!fileActions.createFile
1156
+ const [editing, setEditing] = useState(null) // { mode: 'edit', o } | { mode: 'new', folder }
1157
+ const openEditor = (o) => { setEditing({ mode: 'edit', o }); logEvent('opened', o.key) }
1158
+ const newDocument = (folder) => setEditing({ mode: 'new', folder })
1159
+ /* A MARKDOWN FILE'S FRONTMATTER TAGS ARE ITS TAGS — merged into the database's on every save, so
1160
+ * a tag written in the document is a tag you can filter by. Merged, never replaced: a tag set in
1161
+ * the pane and not in the document stays. */
1162
+ const syncFrontmatterTags = async (key, text) => {
1163
+ if (!canTag || !/\.(md|markdown)$/i.test(key)) return
1164
+ const fm = parseFrontmatter(text ?? '').tags
1165
+ const typed = (Array.isArray(fm) ? fm : typeof fm === 'string' && fm ? fm.split(',') : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
1166
+ const current = objects.find((o) => o.key === key)?.tags ?? []
1167
+ const next = [...new Set([...current, ...typed])]
1168
+ if (next.length !== current.length) { await client.setTags(key, next, bucketKey); patchPath(key, { tags: next }) }
1169
+ }
1170
+ const afterEdit = (key, res, text) => {
1171
+ if (res?.size != null) lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, size: res.size, uploaded: new Date().toISOString() } : o)))
1172
+ syncFrontmatterTags(key, text).catch(() => {})
1173
+ }
1174
+ const createDocument = async ({ name, text }) => {
1175
+ const key = `${editing.folder}${name}`
1176
+ if (objects.some((o) => o.key === key)) throw new Error(`${name} already exists here`)
1177
+ await fileActions.createFile(key)
1178
+ await client.writeText(key, text, bucketKey)
1179
+ const fm = /\.(md|markdown)$/i.test(key) ? parseFrontmatter(text).tags : null
1180
+ const typed = (Array.isArray(fm) ? fm : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
1181
+ if (canTag && typed.length) await client.setTags(key, typed, bucketKey)
1182
+ lib.reload()
1183
+ setEditing(null)
1184
+ }
1185
+ /* what a markdown document can attach: the bucket's own files, linked by their public URL */
1186
+ const attachables = editing ? objects.filter((o) => !isSystemFile(o.key)).map((o) => ({ key: o.key, name: o.key.split('/').pop(), url: mediaUrl(o.key), contentType: o.contentType })) : []
1187
+ const editBlock = (o) => {
1188
+ const kind = editKindOf(o)
1189
+ if (!canEdit || !kind) return null
1190
+ const tooBig = (o.size ?? 0) > EDIT_CAP
1191
+ return (
1192
+ <div className="flex items-center gap-3">
1193
+ <Button variant="secondary" size="sm" iconLeft="edit" disabled={tooBig} onClick={() => openEditor(o)}>Edit</Button>
1194
+ {tooBig && <span className="kol-mono-12 text-fg-48">Over 1 MB — too large to edit here</span>}
1195
+ {!tooBig && draftKeys.has(o.key) && <span className="kol-mono-12 text-fg-48">Unsaved draft</span>}
1196
+ </div>
1197
+ )
1198
+ }
1199
+ /* what the preview pane adds under a file's or a folder's facts, in every view */
1200
+ const detailsFor = (o) => {
1201
+ const path = o.key ?? o
1202
+ const tags = tagsOf(path)
1203
+ const edit = typeof o === 'object' ? editBlock(o) : null
1204
+ if (!edit && !canTag && !canFavourite && !tags.length) return null
1205
+ return (
1206
+ <div className="flex flex-col gap-4">
1207
+ {(edit || canFavourite) && (
1208
+ <div className="flex items-center gap-3">
1209
+ {canFavourite && (
1210
+ <Tooltip label={isFavourite(path) ? 'Remove from favourites' : 'Add to favourites'}>
1211
+ <Button variant="nav" size="sm" iconOnly={isFavourite(path) ? 'star-solid' : 'star'} onClick={() => toggleFavourite(path)}
1212
+ aria-label={isFavourite(path) ? 'Remove from favourites' : 'Add to favourites'} aria-pressed={isFavourite(path)} />
1213
+ </Tooltip>
1214
+ )}
1215
+ {edit}
1216
+ </div>
1217
+ )}
1218
+ {(canTag || tags.length > 0) && <TagEditor tags={tags} suggestions={allTags} onChange={canTag ? (next) => saveTags(path, next) : undefined} />}
1219
+ </div>
1220
+ )
1221
+ }
1222
+ const doTags = async (keys) => {
1223
+ const one = keys.length === 1
1224
+ const current = one ? tagsOf(keys[0]).join(', ') : ''
1225
+ const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length} items, separated by commas:`, current, { okLabel: 'Save' })
1226
+ if (answer == null) return
1227
+ const typed = answer.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
1228
+ for (const key of keys) await saveTags(key, one ? typed : [...new Set([...tagsOf(key), ...typed])])
1229
+ }
1230
+ /* SMART FOLDERS — `smartFolders(bucket)` · `saveSmartFolder(sf, bucket)` · `deleteSmartFolder(id)` */
1231
+ const canSmart = typeof client?.smartFolders === 'function'
1232
+ const [smartFolders, setSmartFolders] = useState([])
1233
+ const [activeSmart, setActiveSmart] = useState(null)
1234
+ const loadSmart = () => { if (canSmart) Promise.resolve(client.smartFolders(bucketKey)).then((l) => setSmartFolders(l ?? [])).catch(() => {}) }
1235
+ useEffect(() => { setActiveSmart(null); loadSmart() }, [bucketKey]) // eslint-disable-line react-hooks/exhaustive-deps
1236
+ useEffect(() => { if (smartFolder && smartFolders.some((f) => f.id === smartFolder)) setActiveSmart(smartFolder) }, [smartFolder, smartFolders])
1237
+ const activeSmartFolder = smartFolders.find((f) => f.id === activeSmart) ?? null
1238
+ const saveSmart = async () => {
1239
+ const name = await modal.prompt('Smart folder name:', '', { okLabel: 'Next' })
1240
+ if (!name?.trim()) return
1241
+ const tags = await modal.prompt('Tags every file must carry, separated by commas (blank for any):', '', { okLabel: 'Next' })
1242
+ if (tags == null) return
1243
+ const kinds = await modal.prompt('Kinds, separated by commas — image, video, audio, markdown, text, json, pdf… (blank for all):', '', { okLabel: 'Save' })
1244
+ if (kinds == null) return
1245
+ const split = (v) => v.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
1246
+ runAction(async () => {
1247
+ const row = await client.saveSmartFolder({ name: name.trim(), query: { tags: split(tags), kinds: split(kinds), text: '' } }, bucketKey)
1248
+ loadSmart()
1249
+ if (row?.id) setActiveSmart(row.id)
1250
+ })
1251
+ }
1252
+ const deleteSmart = async (sf) => {
1253
+ if (!(await modal.confirm(`Delete the smart folder "${sf.name}"? The files stay where they are.`, { okLabel: 'Delete' }))) return
1254
+ runAction(async () => { await client.deleteSmartFolder(sf.id, bucketKey); if (activeSmart === sf.id) setActiveSmart(null); loadSmart() })
1255
+ }
979
1256
  const [busyAction, setBusyAction] = useState(false)
980
1257
 
981
1258
  const runAction = async (fn) => {
@@ -999,7 +1276,9 @@ export function MediaLibraryBrowse({
999
1276
  const name = await modal.prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current, { okLabel: 'Rename' })
1000
1277
  if (!name?.trim() || name === current) return
1001
1278
  const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
1002
- runAction(() => fileActions.rename(path, `${parent}${name.trim()}${isFolder ? '/' : ''}`))
1279
+ const to = `${parent}${name.trim()}${isFolder ? '/' : ''}`
1280
+ /* a draft is keyed by path — it follows the file (or everything under the folder) */
1281
+ runAction(async () => { await fileActions.rename(path, to); moveDrafts(bucketKey, path, to) })
1003
1282
  }
1004
1283
  const doMove = async (path) => {
1005
1284
  const dest = await modal.prompt(`Move into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
@@ -1012,6 +1291,7 @@ export function MediaLibraryBrowse({
1012
1291
  * view: expanded in place in the rows, navigated into in the columns and the grid. */
1013
1292
  const afterMove = (paths, dest) => {
1014
1293
  const moved = paths.map((p) => `${dest}${p.replace(/\/$/, '').split('/').pop()}${p.endsWith('/') ? '/' : ''}`)
1294
+ paths.forEach((p, i) => moveDrafts(bucketKey, p, moved[i]))
1015
1295
  setPickedFile(null); setPickedFolder(null)
1016
1296
  setRowSelection(new Set(moved))
1017
1297
  selectAnchorRef.current = moved[0] ?? null
@@ -1204,6 +1484,7 @@ export function MediaLibraryBrowse({
1204
1484
  const inView = q.files.filter((o) => rowSelection.has(o.key))
1205
1485
  const sel = inView.length === rowSelection.size ? inView : objects.filter((o) => rowSelection.has(o.key))
1206
1486
  const current = q.files[q.index]
1487
+ if (current?.key) logEvent('opened', current.key)
1207
1488
  if (sel.length > 1) {
1208
1489
  const index = Math.max(0, sel.findIndex((o) => o.key === current?.key))
1209
1490
  setQuickLook({ files: sel, index })
@@ -1437,7 +1718,7 @@ export function MediaLibraryBrowse({
1437
1718
  const showRowPreview = folderView === 'rows' && (settings.rowPreview ?? true)
1438
1719
  const showGridPreview = view === 'grid' && (settings.rowPreview ?? true)
1439
1720
  /* OFF by default: a row is a name, and the facts live in the preview (user 2026-09-22) */
1440
- const rowCols = settings.rowColumns ? rowColWidths(settings) : null
1721
+ const rowCols = settings.rowColumns ? { ...rowColWidths(settings), ...(hasTags && { tags: settings.rowColumnWidths?.tags ?? 140 }) } : null
1441
1722
  const previewWidth = settings.columnWidths?.preview ?? 320
1442
1723
  const previewFile = pickedFile && pickedFile.key.startsWith(prefix) ? pickedFile : null
1443
1724
  const previewFolder = pickedFolder && pickedFolder.startsWith(prefix) ? pickedFolder : null
@@ -1459,13 +1740,14 @@ export function MediaLibraryBrowse({
1459
1740
  ) : previewFile ? (
1460
1741
  <ColumnPreview key={previewFile.key} o={previewFile} urlOf={(o) => mediaUrl(o.key)} kindOf={kindOf} kindLabel={KIND_LABEL}
1461
1742
  formatSize={formatSize} formatDate={formatDate} width={previewWidth}
1743
+ renderDetails={detailsFor}
1462
1744
  renderPreview={(o) => {
1463
1745
  if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} vector={o.contentType === 'image/svg+xml'} />
1464
1746
  const poster = posterFor(o.key, keySet)
1465
1747
  return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
1466
1748
  }} />
1467
1749
  ) : previewFolder ? (
1468
- <FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} />
1750
+ <FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} details={detailsFor(previewFolder)} />
1469
1751
  ) : previewRoot ? (
1470
1752
  <ContainerPreview key={previewRoot.key} name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={previewWidth} />
1471
1753
  ) : (
@@ -1580,6 +1862,10 @@ export function MediaLibraryBrowse({
1580
1862
  * serves both: the wall renders it, the tree is scoped to the keys in it */
1581
1863
  const filterItems = isWall ? wallFiles : scoped.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o), displayKey: prefix ? o.key.slice(prefix.length) : o.key }))
1582
1864
  const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
1865
+ const filterTags = [...new Set(filterItems.flatMap((o) => o.tags ?? []))].sort()
1866
+ /* favourites as a one-chip group: ContentFilters matches a value, so a starred file carries one */
1867
+ const filterItemsFav = filterItems.map((o) => ({ ...o, starred: o.favourite ? 'favourite' : '' }))
1868
+ const anyStarred = filterItemsFav.some((o) => o.starred)
1583
1869
 
1584
1870
  /* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
1585
1871
  * `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
@@ -1652,6 +1938,7 @@ export function MediaLibraryBrowse({
1652
1938
  /* the PROP wins when a consumer passes one; otherwise the `···`
1653
1939
  owns it through settings (item 15) */
1654
1940
  stackView={stackView ?? settings.stackView ?? 'list'}
1941
+ renderDetails={(o) => detailsFor({ ...o, key: o.key.slice(VROOT.length) })}
1655
1942
  renderPreview={(o) => {
1656
1943
  const real = { ...o, key: o.key.slice(VROOT.length) }
1657
1944
  if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} vector={real.contentType === 'image/svg+xml'} />
@@ -1840,7 +2127,7 @@ export function MediaLibraryBrowse({
1840
2127
  /* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
1841
2128
  * 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
1842
2129
  * and no folders, as it was. */
1843
- folders={settings.flat ? [] : folders.map((f) => prefix + f)}
2130
+ folders={settings.flat || smartMatches ? [] : folders.map((f) => prefix + f)}
1844
2131
  onOpenFolder={goFolder}
1845
2132
  onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
1846
2133
  onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
@@ -1885,9 +2172,21 @@ export function MediaLibraryBrowse({
1885
2172
  pickFile(o)
1886
2173
  }
1887
2174
 
1888
- const body = (filtered) => (isWall
1889
- ? wallPane(filtered ?? wallFiles)
1890
- : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
2175
+ /* A SMART FOLDER IS A SAVED QUERY (media D1 plan v2) — tags it must carry, kinds it may be, text
2176
+ * in its path — shown flat, every match wherever it lives, as Finder's smart folders are. */
2177
+ const smartMatches = activeSmartFolder ? objects.filter((o) => {
2178
+ const q = activeSmartFolder.query ?? {}
2179
+ if (isSystemFile(o.key)) return false
2180
+ if ((q.tags ?? []).some((t) => !(o.tags ?? []).includes(t))) return false
2181
+ if ((q.kinds ?? []).length && !q.kinds.includes(kindOf(o))) return false
2182
+ if (q.text && !o.key.toLowerCase().includes(q.text.toLowerCase())) return false
2183
+ return true
2184
+ }).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key })) : null
2185
+ const body = (filtered) => (smartMatches
2186
+ ? wallPane(filtered ?? smartMatches)
2187
+ : isWall
2188
+ ? wallPane(filtered ?? wallFiles)
2189
+ : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
1891
2190
 
1892
2191
  return (
1893
2192
  <div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
@@ -2079,8 +2378,16 @@ export function MediaLibraryBrowse({
2079
2378
  )}
2080
2379
  {quickLook && (
2081
2380
  <MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
2381
+ onEdit={canEdit ? (f) => { closeQuickLook(); openEditor(f) } : undefined}
2082
2382
  onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
2083
2383
  )}
2384
+ {editing?.mode === 'edit' && (
2385
+ <FileEditorHost o={editing.o} client={client} bucket={bucketKey} onClose={() => setEditing(null)} onSaved={afterEdit} assets={attachables} />
2386
+ )}
2387
+ {editing?.mode === 'new' && (
2388
+ <DocumentEditor mode="new" folder={editing.folder} draft={{ bucket: bucketKey, key: `${editing.folder}.new-document` }} assets={attachables}
2389
+ onCreate={createDocument} onClose={() => setEditing(null)} />
2390
+ )}
2084
2391
  {settingsOpen && (
2085
2392
  <MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
2086
2393
  )}
@@ -2088,16 +2395,28 @@ export function MediaLibraryBrowse({
2088
2395
  <TrashPanel trash={trash} onClose={() => setTrashOpen(false)} run={runAction} confirm={modal.confirm} />
2089
2396
  )}
2090
2397
 
2398
+ {/* SMART FOLDERS — one chip each; a chip shows its matches flat in place of the body */}
2399
+ {canSmart && (smartFolders.length > 0 || canWrite) && !atTitleRoot && (
2400
+ <div className="flex flex-wrap items-center gap-2">
2401
+ <span className="kol-mono-12 text-fg-48">Smart folders</span>
2402
+ {smartFolders.map((sf) => (
2403
+ <Tag key={sf.id} variant="secondary" size="sm" hash={false} active={activeSmart === sf.id}
2404
+ onClick={() => setActiveSmart((a) => (a === sf.id ? null : sf.id))}
2405
+ onRemove={canWrite ? () => deleteSmart(sf) : undefined}>{sf.name}</Tag>
2406
+ ))}
2407
+ {canWrite && <Button variant="nav" size="xs" iconLeft="plus" onClick={saveSmart}>New smart folder</Button>}
2408
+ </div>
2409
+ )}
2091
2410
  {/* THE FILTER BAR, above whichever body is mounted (2026-09-22). It filters FILES, so the
2092
2411
  * tree is scoped to the keys it leaves and the wall renders them directly. Off by default
2093
2412
  * — the funnel in the crumb row turns it on. */}
2094
2413
  {filtersOn ? (
2095
2414
  <ContentFilters
2096
- items={filterItems}
2415
+ items={filterItemsFav}
2097
2416
  title="Files"
2098
2417
  totalCount={filterItems.length}
2099
2418
  searchKeys={['displayKey']}
2100
- filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }]}
2419
+ filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : []), ...(anyStarred ? [{ label: 'Favourites', key: 'starred', values: ['favourite'] }] : [])]}
2101
2420
  mutuallyExclusiveFilters={['kind']}
2102
2421
  /* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
2103
2422
  * its panel, and the bar's own funnel closes the bar — the same switch from either
@@ -2179,6 +2498,7 @@ export function MediaLibraryBrowse({
2179
2498
  <>
2180
2499
  <MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl(many)}>Copy {many.length} URLs</MenuDropdownItem>
2181
2500
  <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload(many)}>Download {many.length}</MenuDropdownItem>
2501
+ {canTag && <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags(many)}>Add tags to {many.length}…</MenuDropdownItem>}
2182
2502
  </>
2183
2503
  )}
2184
2504
  {fileActions.remove && (
@@ -2195,7 +2515,13 @@ export function MediaLibraryBrowse({
2195
2515
  New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
2196
2516
  </MenuDropdownItem>
2197
2517
  )}
2198
- {fileActions.createFile && (
2518
+ {/* NEW DOCUMENT is the writing page (a name, a type, fields for markdown); New file stays for
2519
+ a client that cannot write text — it makes the empty file and nothing else */}
2520
+ {canNewDoc ? (
2521
+ <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => newDocument(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2522
+ New document…
2523
+ </MenuDropdownItem>
2524
+ ) : fileActions.createFile && (
2199
2525
  <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2200
2526
  New file
2201
2527
  </MenuDropdownItem>
@@ -2222,6 +2548,17 @@ export function MediaLibraryBrowse({
2222
2548
  {target.type === 'file' && (
2223
2549
  <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
2224
2550
  )}
2551
+ {!isLevel && canTag && (
2552
+ <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags([target.path])}>Tags…</MenuDropdownItem>
2553
+ )}
2554
+ {!isLevel && canFavourite && (
2555
+ <MenuDropdownItem iconLeft={<Icon name={isFavourite(target.path) ? 'star-solid' : 'star'} size={14} />} onClick={() => toggleFavourite(target.path)}>
2556
+ {isFavourite(target.path) ? 'Remove from favourites' : 'Add to favourites'}
2557
+ </MenuDropdownItem>
2558
+ )}
2559
+ {target.type === 'file' && canEdit && editKindOf(target.o ?? { key: target.path }) && (
2560
+ <MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => openEditor(objects.find((o) => o.key === target.path) ?? { key: target.path })}>Edit</MenuDropdownItem>
2561
+ )}
2225
2562
  {!isLevel && fileActions.remove && (
2226
2563
  <>
2227
2564
  <MenuDropdownDivider />
@@ -27,3 +27,50 @@ export function parseFrontmatter(raw) {
27
27
  }
28
28
  return metadata
29
29
  }
30
+
31
+ /**
32
+ * splitFrontmatter / joinFrontmatter — the EDITOR's pair (DocumentEditor, 2026-09-26). Where
33
+ * `parseFrontmatter` reads for display (keys lowercased, lists flattened), these round-trip a file:
34
+ * keys keep their case and their order, a list stays a list, and the body is untouched — so opening
35
+ * and saving a document without touching the form changes nothing but what was edited.
36
+ *
37
+ * `fields` is `[[key, value]]`, value a string or an array of strings.
38
+ */
39
+ const unquote = (v) => {
40
+ if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) { try { return JSON.parse(v) } catch { return v.slice(1, -1) } }
41
+ if (v.length > 1 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'")
42
+ return v
43
+ }
44
+
45
+ export function splitFrontmatter(raw = '') {
46
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
47
+ if (!match) return { has: false, fields: [], body: raw }
48
+ const fields = []
49
+ const lines = match[1].split(/\r?\n/)
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const kv = lines[i].match(/^([A-Za-z_][\w -]*):\s*(.*)$/)
52
+ if (!kv) continue
53
+ let value = kv[2].trim()
54
+ if (!value) {
55
+ const items = []
56
+ while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) { i++; items.push(lines[i].replace(/^\s+-\s+/, '').trim()) }
57
+ value = items.length ? items : ''
58
+ } else if (value.startsWith('[') && value.endsWith(']')) {
59
+ value = value.slice(1, -1).split(',').map((t) => unquote(t.trim())).filter(Boolean)
60
+ } else {
61
+ value = unquote(value)
62
+ }
63
+ fields.push([kv[1], value])
64
+ }
65
+ return { has: true, fields, body: raw.slice(match[0].length) }
66
+ }
67
+
68
+ /* bare when YAML reads it back as the same string; quoted otherwise (a colon, a leading #, quotes) */
69
+ const yamlScalar = (v) => (/^[\w./@+-][\w ./@+,-]*$/.test(v) || v === '' ? v : JSON.stringify(v))
70
+
71
+ export function joinFrontmatter(fields, body = '') {
72
+ const rows = fields.filter(([key]) => String(key).trim())
73
+ if (!rows.length) return body
74
+ const yaml = rows.map(([key, v]) => (Array.isArray(v) ? `${key}: [${v.map(yamlScalar).join(', ')}]` : `${key}: ${yamlScalar(String(v ?? ''))}`)).join('\n')
75
+ return `---\n${yaml}\n---\n${body.startsWith('\n') ? body.slice(1) : body}`
76
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * localDrafts — a text file's unsaved edit, in BROWSER memory (media D1 plan v2, user ruling
3
+ * 2026-09-26: "draft saves can rely on browser memory … only what's needed goes to D1"). A draft is
4
+ * work in progress on one device; the database holds what must outlive the device. So the DS keeps
5
+ * drafts itself, keyed by bucket + file key, and no client needs a draft verb.
6
+ *
7
+ * Every write announces itself on `window` (`kol-media-drafts`), so a list elsewhere on the page
8
+ * (a Home shelf, the "unsaved draft" mark) re-reads without prop drilling. Storage can be missing
9
+ * or full (private mode, a quota) — every call degrades to "no draft", never to a throw.
10
+ */
11
+ const PREFIX = 'kol-media-draft:'
12
+ const EVENT = 'kol-media-drafts'
13
+ const store = () => { try { return globalThis.localStorage ?? null } catch { return null } }
14
+ const k = (bucket, key) => `${PREFIX}${bucket ?? ''}:${key}`
15
+ const announce = () => { try { globalThis.dispatchEvent?.(new Event(EVENT)) } catch { /* no window */ } }
16
+
17
+ export const DRAFTS_EVENT = EVENT
18
+
19
+ /** `{ text, at }` (at = epoch ms) or null */
20
+ export function readDraft(bucket, key) {
21
+ try { const raw = store()?.getItem(k(bucket, key)); return raw ? JSON.parse(raw) : null } catch { return null }
22
+ }
23
+ export function writeDraft(bucket, key, text) {
24
+ try { store()?.setItem(k(bucket, key), JSON.stringify({ text, at: Date.now() })); announce() } catch { /* quota */ }
25
+ }
26
+ export function clearDraft(bucket, key) {
27
+ try { store()?.removeItem(k(bucket, key)); announce() } catch { /* nothing to clear */ }
28
+ }
29
+ /** every draft in a bucket, newest first — `[{ key, at, size }]` */
30
+ export function listDrafts(bucket) {
31
+ const s = store()
32
+ if (!s) return []
33
+ const head = k(bucket, '')
34
+ const out = []
35
+ for (let i = 0; i < s.length; i++) {
36
+ const name = s.key(i)
37
+ if (!name?.startsWith(head)) continue
38
+ try { const d = JSON.parse(s.getItem(name)); out.push({ key: name.slice(head.length), at: d.at, size: d.text?.length ?? 0 }) } catch { /* a foreign value */ }
39
+ }
40
+ return out.sort((a, b) => b.at - a.at)
41
+ }
42
+ /** a file or a folder moved: its drafts follow (a folder is a path ending in `/`) */
43
+ export function moveDrafts(bucket, from, to) {
44
+ const moved = listDrafts(bucket).filter((d) => d.key === from || (from.endsWith('/') && d.key.startsWith(from)))
45
+ for (const d of moved) {
46
+ const draft = readDraft(bucket, d.key)
47
+ try { store()?.removeItem(k(bucket, d.key)); store()?.setItem(k(bucket, to + d.key.slice(from.length)), JSON.stringify(draft)) } catch { /* quota */ }
48
+ }
49
+ if (moved.length) announce()
50
+ }