@kolkrabbi/kol-component 0.223.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.223.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'
@@ -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
+ }
@@ -32,6 +32,9 @@ import { formatLength } from '../molecules/AudioPreview.jsx'
32
32
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
33
33
  import { useModal } from '../molecules/Modal.jsx'
34
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'
35
38
  import ShellSearchOverlay from './ShellSearchOverlay.jsx'
36
39
  import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
37
40
  import SettingsPanel, { LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
@@ -74,9 +77,11 @@ import { nearestRatio } from '../utilities/ratios.js'
74
77
  * tags listed objects carry `tags: string[]`; `setTags(key, tags, bucket)` writes them —
75
78
  * preview-pane chips, "Tags…" / "Add tags to N…" in the menu, a Tags filter
76
79
  * group, a Tags column under Fields
77
- * text editing `readText(key, bucket) → { text, draft }` · `writeText(key, text, bucket)` ·
78
- * `saveDraft(key, text | null, bucket)`; listed objects carry `hasDraft` — Edit in
79
- * the pane and in Quick Look for markdown · json · yaml · text · code
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
80
85
  * settings `loadSettings(bucket)` · `saveSettings(bucket, settings | null)` (null = reset) —
81
86
  * used when the page is UNCONTROLLED; a `settings` prop still wins
82
87
  */
@@ -206,25 +211,37 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
206
211
  if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, next)).catch(() => {})
207
212
  onSettingsChange?.(next)
208
213
  }
209
- const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], error: null })
214
+ const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], folders: {}, error: null })
210
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}`
211
220
  useEffect(() => {
212
221
  if (!client) return undefined
213
222
  let cancelled = false
214
223
  const controller = new AbortController()
215
- client.listMedia('', { signal: controller.signal, bucket: bucketId ?? undefined })
216
- .then((objs) => { if (cancelled) return; setLoaded({ key: refreshKey, bucket: bucketId, objects: objs, error: null }); setCache((c) => ({ ...c, [bucketId]: objs })) })
217
- .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 }) })
218
232
  return () => { cancelled = true; controller.abort() }
219
- }, [client, refreshKey, bucketId])
220
- 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
221
236
  const objects = ready ? loaded.objects : (cache[bucketId] ?? [])
222
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 ?? {}) }))
223
240
  const error = ready ? loaded.error : null
224
241
  const mediaUrl = (key) => client?.mediaUrl?.(key, bucketId ?? undefined) ?? key
225
242
  const downloadUrl = (key) => client?.downloadUrl?.(key, bucketId ?? undefined) ?? mediaUrl(key)
226
243
  const writable = !!bucketMeta.writable && !!(client?.deleteObject || client?.renameObject)
227
- 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 }
228
245
  }
229
246
 
230
247
  /* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
@@ -250,7 +267,8 @@ function ImageFrame({ src, vector }) {
250
267
  /* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
251
268
  * one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
252
269
  * without it, or on a read-only bucket, the chips show and nothing edits them. */
253
- function TagEditor({ tags = [], onChange }) {
270
+ function TagEditor({ tags = [], onChange, suggestions = [] }) {
271
+ const listId = useMemo(() => `kol-tags-${Math.random().toString(36).slice(2, 8)}`, [])
254
272
  const add = (raw) => {
255
273
  const next = raw.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
256
274
  if (next.length) onChange([...new Set([...tags, ...next])])
@@ -266,7 +284,13 @@ function TagEditor({ tags = [], onChange }) {
266
284
  ))}
267
285
  </div>
268
286
  )}
269
- {onChange && <Input size="xs" value="" placeholder="Add a tag" aria-label="Add a tag" onCommit={add} />}
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
+ )}
270
294
  </div>
271
295
  )
272
296
  }
@@ -418,7 +442,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
418
442
  * The columns never needed one — stepping into a folder IS its preview there — so this is the row
419
443
  * view's, and it is the page's rather than ColumnBrowser's because the counts come off the same
420
444
  * `objects` the page already holds. Same shell as the file preview, so the pane never shifts. */
421
- function FolderPreview({ path, objects, width, formatDate }) {
445
+ function FolderPreview({ path, objects, width, formatDate, details }) {
422
446
  const inside = objects.filter((o) => o.key.startsWith(path))
423
447
  const bytes = inside.reduce((n, o) => n + (o.size ?? 0), 0)
424
448
  const last = inside.reduce((t, o) => (o.uploaded && o.uploaded > t ? o.uploaded : t), '')
@@ -428,13 +452,13 @@ function FolderPreview({ path, objects, width, formatDate }) {
428
452
  ['Size', formatSize(bytes)],
429
453
  ...(last ? [['Newest', formatDate(last) || '—']] : []),
430
454
  ]
431
- 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} />
432
456
  }
433
457
 
434
458
  /* ONE PREVIEW FOR EVERYTHING THAT HOLDS THINGS — a folder, a bucket, the title (user 2026-09-25:
435
459
  * selecting a bucket or MEDIA left the pane empty). The glyph, the name, the facts; only the
436
460
  * glyph and the facts differ. */
437
- function ContainerPreview({ name, icon, facts, width }) {
461
+ function ContainerPreview({ name, icon, facts, width, details }) {
438
462
  return (
439
463
  <div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
440
464
  {/* the folder fills its box with the tile's inset (user 2026-09-24: a 64px glyph floated in
@@ -451,6 +475,8 @@ function ContainerPreview({ name, icon, facts, width }) {
451
475
  </div>
452
476
  ))}
453
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>}
454
480
  </div>
455
481
  )
456
482
  }
@@ -530,96 +556,27 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
530
556
  )
531
557
  }
532
558
 
533
- /* THE KINDS THAT OPEN AS TEXT — what `FileEditor` takes (the media D1 pass, 2026-09-25) */
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. */
534
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)
535
565
 
536
- /* FILE EDITING (the media D1 pass, 2026-09-25) — Quick Look's window with the text in it. Every
537
- * pause writes a DRAFT through `client.saveDraft` (kol-olina keeps it in D1, beside the file), so
538
- * closing the window or the tab loses nothing; ⌘S or Save writes the FILE (`client.writeText`) and
539
- * clears the draft; Revert drops the draft and goes back to the file. The file itself changes only
540
- * on Save — a draft is never what anyone else sees. */
541
- const DRAFT_PAUSE = 800
542
- function FileEditor({ o, client, bucket, onClose, onSaved, onDraft }) {
543
- const [base, setBase] = useState(null) // the saved file's text; null while loading
544
- const [text, setText] = useState('')
545
- const [status, setStatus] = useState('loading') // loading · saved · draft · editing · saving · error
546
- const timer = useRef(null)
547
- const latest = useRef('')
548
- const baseRef = useRef(null) // `base` for the unmount flush, which would otherwise see the first render's
549
- const keep = (b) => { baseRef.current = b; setBase(b) }
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)
550
570
  useEffect(() => {
551
571
  let live = true
552
- client.readText(o.key, bucket).then(({ text: file, draft }) => {
553
- if (!live) return
554
- keep(file ?? '')
555
- setText(draft ?? file ?? '')
556
- latest.current = draft ?? file ?? ''
557
- setStatus(draft != null ? 'draft' : 'saved')
558
- }).catch((e) => live && setStatus(`error: ${e.message}`))
572
+ client.readText(o.key, bucket).then((r) => live && setText(r?.text ?? '')).catch(() => live && setText(''))
559
573
  return () => { live = false }
560
574
  }, [o.key]) // eslint-disable-line react-hooks/exhaustive-deps
561
- const flushDraft = () => {
562
- if (timer.current == null) return undefined
563
- clearTimeout(timer.current); timer.current = null
564
- const t = latest.current
565
- const clean = t === baseRef.current
566
- return Promise.resolve(client.saveDraft?.(o.key, clean ? null : t, bucket))
567
- .then(() => { setStatus(clean ? 'saved' : 'draft'); onDraft?.(o.key, !clean) })
568
- .catch(() => {})
569
- }
570
- useEffect(() => () => { flushDraft() }, []) // eslint-disable-line react-hooks/exhaustive-deps
571
- const edit = (t) => {
572
- setText(t); latest.current = t; setStatus('editing')
573
- clearTimeout(timer.current)
574
- timer.current = setTimeout(flushDraft, DRAFT_PAUSE)
575
- }
576
- const save = async () => {
577
- clearTimeout(timer.current); timer.current = null
578
- setStatus('saving')
579
- try {
580
- const res = await client.writeText(o.key, latest.current, bucket)
581
- keep(latest.current); setStatus('saved')
582
- onSaved?.(o.key, res)
583
- } catch (e) { setStatus(`error: ${e.message}`) }
584
- }
585
- const revert = async () => {
586
- clearTimeout(timer.current); timer.current = null
587
- await client.saveDraft?.(o.key, null, bucket)
588
- setText(baseRef.current ?? ''); latest.current = baseRef.current ?? ''; setStatus('saved')
589
- onSaved?.(o.key, null)
590
- }
591
- const dirty = base != null && text !== base
592
- /* ⌘S anywhere while the editor is open — not only with the caret in the text */
593
- const saveRef = useRef(null)
594
- saveRef.current = () => { if (baseRef.current != null && latest.current !== baseRef.current) save() }
595
- useEffect(() => {
596
- const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveRef.current() } }
597
- window.addEventListener('keydown', onKey)
598
- return () => window.removeEventListener('keydown', onKey)
599
- }, [])
600
- const label = { loading: 'Loading…', saved: 'Saved', draft: 'Draft — not saved to the file', editing: 'Editing…', saving: 'Saving…' }[status] ?? status
601
575
  return (
602
- <FullscreenOverlay open onClose={() => { flushDraft(); onClose() }} closeButton={false} scrim>
603
- <QuickLookFrame title={o.displayKey ?? o.key.split('/').pop()} meta={label} onClose={() => { flushDraft(); onClose() }}
604
- actions={(
605
- <span className="flex items-center gap-2">
606
- {dirty && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
607
- <Button variant="secondary" size="sm" onClick={save} disabled={!dirty || status === 'saving'}>Save</Button>
608
- </span>
609
- )}>
610
- <textarea
611
- value={text}
612
- onChange={(e) => edit(e.target.value)}
613
- disabled={base == null}
614
- spellCheck={false}
615
- aria-label={`Edit ${o.key.split('/').pop()}`}
616
- className="kol-mono-12 block bg-transparent text-fg-default p-4 resize-none outline-none"
617
- /* inline: Tailwind does not generate arbitrary values from package source (see the SVG box
618
- in MediaInspector) — the window's own caps */
619
- style={{ width: 'min(900px, var(--kol-ql-max-w))', height: 'min(640px, var(--kol-ql-media-h))' }}
620
- />
621
- </QuickLookFrame>
622
- </FullscreenOverlay>
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} />
623
580
  )
624
581
  }
625
582
 
@@ -689,7 +646,7 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
689
646
  * the header; bare at rest with a wash on hover, `sm` so the glyphs sit with the 12px text. */
690
647
  actions: (
691
648
  <span className="flex items-center gap-1">
692
- {onEdit && TEXT_KINDS.has(kind) && (
649
+ {onEdit && editKindOf(o) && (o.size ?? 0) <= EDIT_CAP && (
693
650
  <Tooltip label="Edit">
694
651
  <Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
695
652
  </Tooltip>
@@ -1001,6 +958,9 @@ export function MediaLibraryBrowse({
1001
958
  * rendering what it rendered. */
1002
959
  view: viewProp, onViewChange,
1003
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,
1004
964
  /* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
1005
965
  * optional and each async; whatever is supplied becomes a right-click menu entry and
1006
966
  * anything absent simply is not offered. One object rather than four props because they
@@ -1148,49 +1108,151 @@ export function MediaLibraryBrowse({
1148
1108
  const press = useLongPress()
1149
1109
  const modal = useModal()
1150
1110
  const canWrite = !!fileActions && writable
1151
- /* TAGS ARE THE CLIENT'S (the media D1 pass, 2026-09-25) — `client.setTags(key, tags, bucket)`
1152
- * writes them, the listing carries them back as `o.tags`. The list is patched in place so a chip
1153
- * lands at once; the next listing confirms it. */
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)` */
1154
1125
  const canTag = writable && typeof client?.setTags === 'function'
1155
- const saveTags = (key, tags) => runAction(async () => {
1156
- await client.setTags(key, tags, bucketMeta.id ?? undefined)
1157
- lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, tags } : o)))
1158
- setPickedFile((f) => (f?.key === key ? { ...f, tags } : f))
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 })
1159
1142
  })
1160
- const tagsOf = (key) => objects.find((o) => o.key === key)?.tags ?? []
1161
- /* EDITING IS THE CLIENT'S TOO — `readText` / `writeText`, with `saveDraft` for the pauses */
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` */
1162
1154
  const canEdit = writable && typeof client?.readText === 'function' && typeof client?.writeText === 'function'
1163
- const [editing, setEditing] = useState(null)
1164
- const afterEdit = (key, res) => {
1165
- lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, hasDraft: false, ...(res?.size != null && { size: res.size, uploaded: new Date().toISOString() }) } : o)))
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 }) }
1166
1169
  }
1167
- const hasTags = canTag || objects.some((o) => o.tags?.length)
1168
- /* what the preview pane adds under a file's facts, in every view */
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 */
1169
1200
  const detailsFor = (o) => {
1170
- const tags = tagsOf(o.key)
1171
- const editable = canEdit && TEXT_KINDS.has(kindOf(o))
1172
- const hasDraft = objects.find((x) => x.key === o.key)?.hasDraft
1173
- if (!editable && !canTag && !tags.length) return null
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
1174
1205
  return (
1175
1206
  <div className="flex flex-col gap-4">
1176
- {editable && (
1207
+ {(edit || canFavourite) && (
1177
1208
  <div className="flex items-center gap-3">
1178
- <Button variant="secondary" size="sm" iconLeft="edit" onClick={() => setEditing(o)}>Edit</Button>
1179
- {hasDraft && <span className="kol-mono-12 text-fg-48">Unsaved draft</span>}
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}
1180
1216
  </div>
1181
1217
  )}
1182
- {(canTag || tags.length > 0) && <TagEditor tags={tags} onChange={canTag ? (next) => saveTags(o.key, next) : undefined} />}
1218
+ {(canTag || tags.length > 0) && <TagEditor tags={tags} suggestions={allTags} onChange={canTag ? (next) => saveTags(path, next) : undefined} />}
1183
1219
  </div>
1184
1220
  )
1185
1221
  }
1186
1222
  const doTags = async (keys) => {
1187
1223
  const one = keys.length === 1
1188
1224
  const current = one ? tagsOf(keys[0]).join(', ') : ''
1189
- const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length} files, separated by commas:`, current, { okLabel: 'Save' })
1225
+ const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length} items, separated by commas:`, current, { okLabel: 'Save' })
1190
1226
  if (answer == null) return
1191
1227
  const typed = answer.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
1192
1228
  for (const key of keys) await saveTags(key, one ? typed : [...new Set([...tagsOf(key), ...typed])])
1193
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
+ }
1194
1256
  const [busyAction, setBusyAction] = useState(false)
1195
1257
 
1196
1258
  const runAction = async (fn) => {
@@ -1214,7 +1276,9 @@ export function MediaLibraryBrowse({
1214
1276
  const name = await modal.prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current, { okLabel: 'Rename' })
1215
1277
  if (!name?.trim() || name === current) return
1216
1278
  const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
1217
- 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) })
1218
1282
  }
1219
1283
  const doMove = async (path) => {
1220
1284
  const dest = await modal.prompt(`Move into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
@@ -1227,6 +1291,7 @@ export function MediaLibraryBrowse({
1227
1291
  * view: expanded in place in the rows, navigated into in the columns and the grid. */
1228
1292
  const afterMove = (paths, dest) => {
1229
1293
  const moved = paths.map((p) => `${dest}${p.replace(/\/$/, '').split('/').pop()}${p.endsWith('/') ? '/' : ''}`)
1294
+ paths.forEach((p, i) => moveDrafts(bucketKey, p, moved[i]))
1230
1295
  setPickedFile(null); setPickedFolder(null)
1231
1296
  setRowSelection(new Set(moved))
1232
1297
  selectAnchorRef.current = moved[0] ?? null
@@ -1419,6 +1484,7 @@ export function MediaLibraryBrowse({
1419
1484
  const inView = q.files.filter((o) => rowSelection.has(o.key))
1420
1485
  const sel = inView.length === rowSelection.size ? inView : objects.filter((o) => rowSelection.has(o.key))
1421
1486
  const current = q.files[q.index]
1487
+ if (current?.key) logEvent('opened', current.key)
1422
1488
  if (sel.length > 1) {
1423
1489
  const index = Math.max(0, sel.findIndex((o) => o.key === current?.key))
1424
1490
  setQuickLook({ files: sel, index })
@@ -1681,7 +1747,7 @@ export function MediaLibraryBrowse({
1681
1747
  return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
1682
1748
  }} />
1683
1749
  ) : previewFolder ? (
1684
- <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)} />
1685
1751
  ) : previewRoot ? (
1686
1752
  <ContainerPreview key={previewRoot.key} name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={previewWidth} />
1687
1753
  ) : (
@@ -1797,6 +1863,9 @@ export function MediaLibraryBrowse({
1797
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 }))
1798
1864
  const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
1799
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)
1800
1869
 
1801
1870
  /* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
1802
1871
  * `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
@@ -2058,7 +2127,7 @@ export function MediaLibraryBrowse({
2058
2127
  /* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
2059
2128
  * 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
2060
2129
  * and no folders, as it was. */
2061
- folders={settings.flat ? [] : folders.map((f) => prefix + f)}
2130
+ folders={settings.flat || smartMatches ? [] : folders.map((f) => prefix + f)}
2062
2131
  onOpenFolder={goFolder}
2063
2132
  onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
2064
2133
  onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
@@ -2103,9 +2172,21 @@ export function MediaLibraryBrowse({
2103
2172
  pickFile(o)
2104
2173
  }
2105
2174
 
2106
- const body = (filtered) => (isWall
2107
- ? wallPane(filtered ?? wallFiles)
2108
- : 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))
2109
2190
 
2110
2191
  return (
2111
2192
  <div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
@@ -2297,12 +2378,15 @@ export function MediaLibraryBrowse({
2297
2378
  )}
2298
2379
  {quickLook && (
2299
2380
  <MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
2300
- onEdit={canEdit ? (f) => { closeQuickLook(); setEditing(f) } : undefined}
2381
+ onEdit={canEdit ? (f) => { closeQuickLook(); openEditor(f) } : undefined}
2301
2382
  onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
2302
2383
  )}
2303
- {editing && (
2304
- <FileEditor o={editing} client={client} bucket={bucketMeta.id ?? undefined} onClose={() => setEditing(null)} onSaved={afterEdit}
2305
- onDraft={(key, has) => lib.setObjects((list) => list.map((x) => (x.key === key ? { ...x, hasDraft: has } : x)))} />
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)} />
2306
2390
  )}
2307
2391
  {settingsOpen && (
2308
2392
  <MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
@@ -2311,16 +2395,28 @@ export function MediaLibraryBrowse({
2311
2395
  <TrashPanel trash={trash} onClose={() => setTrashOpen(false)} run={runAction} confirm={modal.confirm} />
2312
2396
  )}
2313
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
+ )}
2314
2410
  {/* THE FILTER BAR, above whichever body is mounted (2026-09-22). It filters FILES, so the
2315
2411
  * tree is scoped to the keys it leaves and the wall renders them directly. Off by default
2316
2412
  * — the funnel in the crumb row turns it on. */}
2317
2413
  {filtersOn ? (
2318
2414
  <ContentFilters
2319
- items={filterItems}
2415
+ items={filterItemsFav}
2320
2416
  title="Files"
2321
2417
  totalCount={filterItems.length}
2322
2418
  searchKeys={['displayKey']}
2323
- filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : [])]}
2419
+ filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : []), ...(anyStarred ? [{ label: 'Favourites', key: 'starred', values: ['favourite'] }] : [])]}
2324
2420
  mutuallyExclusiveFilters={['kind']}
2325
2421
  /* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
2326
2422
  * its panel, and the bar's own funnel closes the bar — the same switch from either
@@ -2419,7 +2515,13 @@ export function MediaLibraryBrowse({
2419
2515
  New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
2420
2516
  </MenuDropdownItem>
2421
2517
  )}
2422
- {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 && (
2423
2525
  <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2424
2526
  New file
2425
2527
  </MenuDropdownItem>
@@ -2446,9 +2548,17 @@ export function MediaLibraryBrowse({
2446
2548
  {target.type === 'file' && (
2447
2549
  <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
2448
2550
  )}
2449
- {target.type === 'file' && canTag && (
2551
+ {!isLevel && canTag && (
2450
2552
  <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags([target.path])}>Tags…</MenuDropdownItem>
2451
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
+ )}
2452
2562
  {!isLevel && fileActions.remove && (
2453
2563
  <>
2454
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
+ }