@kolkrabbi/kol-component 0.223.0 → 0.225.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.
@@ -1,4 +1,4 @@
1
- import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
1
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
2
2
  import formatSize from '../utilities/formatSize.js'
3
3
  import { Icon } from '@kolkrabbi/kol-icons'
4
4
  import Button from '../atoms/Button.jsx'
@@ -32,9 +32,13 @@ 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
- import SettingsPanel, { LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
40
+ import SettingsPanel, { SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
41
+ import SettingsSections from './SettingsSections.jsx'
38
42
  import { kindOf, extOf, KIND_LABEL, KINDS, DEFAULT_KINDS, isSystemFile, isSegment, groupSegments, groupVariants, posterFor, partition } from '../utilities/mediaKinds.js'
39
43
  import { nearestRatio } from '../utilities/ratios.js'
40
44
 
@@ -74,9 +78,11 @@ import { nearestRatio } from '../utilities/ratios.js'
74
78
  * tags listed objects carry `tags: string[]`; `setTags(key, tags, bucket)` writes them —
75
79
  * preview-pane chips, "Tags…" / "Add tags to N…" in the menu, a Tags filter
76
80
  * 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
81
+ * documents `readText(key, bucket) → { text }` · `writeText(key, text, bucket)` ·
82
+ * `createFile(key, bucket, text?)` — Edit in the pane and in Quick Look, New document
83
+ * in the menu (markdown gets a fields form over its frontmatter; svg a live picture).
84
+ * DRAFTS ARE NOT THE CLIENT'S: browser memory (`utilities/localDrafts`), user ruling
85
+ * 2026-09-26 — only what must outlive a device goes to the database
80
86
  * settings `loadSettings(bucket)` · `saveSettings(bucket, settings | null)` (null = reset) —
81
87
  * used when the page is UNCONTROLLED; a `settings` prop still wins
82
88
  */
@@ -103,7 +109,7 @@ export const SETTINGS_BASE = {
103
109
  folderView: 'columns', // 'rows' | 'columns'
104
110
  sortBy: 'name',
105
111
  sortDir: 'asc',
106
- columnHeight: 528, // px, or any CSS length — forwarded to ColumnBrowser `height` untouched
112
+ columnHeight: 528, // px, any CSS length (forwarded to ColumnBrowser `height` untouched), or 'fill' — what is left of the window
107
113
  columnWidths: {},
108
114
  rowPreview: true, // row view's preview pane beside the list (Finder's "Show preview")
109
115
  view: 'columns', // 'columns' | 'rows' | 'grid' | 'list' — the ONE view state (2026-09-22)
@@ -206,25 +212,37 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
206
212
  if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, next)).catch(() => {})
207
213
  onSettingsChange?.(next)
208
214
  }
209
- const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], error: null })
215
+ const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], folders: {}, error: null })
210
216
  const [cache, setCache] = useState({})
217
+ /* `reload()` — a write the PAGE made itself (a new document, a tag on a folder) re-lists without
218
+ * waiting for the consumer to bump `refreshKey` */
219
+ const [nonce, setNonce] = useState(0)
220
+ const loadKey = `${refreshKey}.${nonce}`
211
221
  useEffect(() => {
212
222
  if (!client) return undefined
213
223
  let cancelled = false
214
224
  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 }) })
225
+ /* the folder rows (`folderInfo`, a D1 read) come beside the listing: tags and favourites a
226
+ * folder carries, which a key-prefix bucket has nowhere to keep */
227
+ Promise.all([
228
+ client.listMedia('', { signal: controller.signal, bucket: bucketId ?? undefined }),
229
+ Promise.resolve(client.folderInfo?.(bucketId ?? undefined)).catch(() => ({})),
230
+ ])
231
+ .then(([objs, folders]) => { if (cancelled) return; setLoaded({ key: loadKey, bucket: bucketId, objects: objs, folders: folders ?? {}, error: null }); setCache((c) => ({ ...c, [bucketId]: objs })) })
232
+ .catch((e) => { if (!cancelled && e.name !== 'AbortError') setLoaded({ key: loadKey, bucket: bucketId, objects: [], folders: {}, error: e.message }) })
218
233
  return () => { cancelled = true; controller.abort() }
219
- }, [client, refreshKey, bucketId])
220
- const ready = loaded.key === refreshKey && loaded.bucket === bucketId
234
+ }, [client, loadKey, bucketId]) // eslint-disable-line react-hooks/exhaustive-deps
235
+ const reload = () => setNonce((n) => n + 1)
236
+ const ready = loaded.key === loadKey && loaded.bucket === bucketId
221
237
  const objects = ready ? loaded.objects : (cache[bucketId] ?? [])
222
238
  const setObjects = (fn) => setLoaded((prev) => ({ ...prev, objects: fn(prev.objects) }))
239
+ const folderInfo = loaded.folders ?? {}
240
+ const setFolderInfo = (fn) => setLoaded((prev) => ({ ...prev, folders: fn(prev.folders ?? {}) }))
223
241
  const error = ready ? loaded.error : null
224
242
  const mediaUrl = (key) => client?.mediaUrl?.(key, bucketId ?? undefined) ?? key
225
243
  const downloadUrl = (key) => client?.downloadUrl?.(key, bucketId ?? undefined) ?? mediaUrl(key)
226
244
  const writable = !!bucketMeta.writable && !!(client?.deleteObject || client?.renameObject)
227
- return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, error, ready, mediaUrl, downloadUrl, writable }
245
+ return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, folderInfo, setFolderInfo, reload, error, ready, mediaUrl, downloadUrl, writable }
228
246
  }
229
247
 
230
248
  /* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
@@ -250,7 +268,8 @@ function ImageFrame({ src, vector }) {
250
268
  /* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
251
269
  * one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
252
270
  * without it, or on a read-only bucket, the chips show and nothing edits them. */
253
- function TagEditor({ tags = [], onChange }) {
271
+ function TagEditor({ tags = [], onChange, suggestions = [] }) {
272
+ const listId = useMemo(() => `kol-tags-${Math.random().toString(36).slice(2, 8)}`, [])
254
273
  const add = (raw) => {
255
274
  const next = raw.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
256
275
  if (next.length) onChange([...new Set([...tags, ...next])])
@@ -266,7 +285,13 @@ function TagEditor({ tags = [], onChange }) {
266
285
  ))}
267
286
  </div>
268
287
  )}
269
- {onChange && <Input size="xs" value="" placeholder="Add a tag" aria-label="Add a tag" onCommit={add} />}
288
+ {/* the bucket's own tags as suggestions — native autocomplete, so a typo does not fork the vocabulary */}
289
+ {onChange && <Input size="xs" value="" placeholder="Add a tag" aria-label="Add a tag" onCommit={add} list={listId} />}
290
+ {onChange && (
291
+ <datalist id={listId}>
292
+ {suggestions.filter((t) => !tags.includes(t)).map((t) => <option key={t} value={t} />)}
293
+ </datalist>
294
+ )}
270
295
  </div>
271
296
  )
272
297
  }
@@ -418,7 +443,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
418
443
  * The columns never needed one — stepping into a folder IS its preview there — so this is the row
419
444
  * view's, and it is the page's rather than ColumnBrowser's because the counts come off the same
420
445
  * `objects` the page already holds. Same shell as the file preview, so the pane never shifts. */
421
- function FolderPreview({ path, objects, width, formatDate }) {
446
+ function FolderPreview({ path, objects, width, formatDate, details }) {
422
447
  const inside = objects.filter((o) => o.key.startsWith(path))
423
448
  const bytes = inside.reduce((n, o) => n + (o.size ?? 0), 0)
424
449
  const last = inside.reduce((t, o) => (o.uploaded && o.uploaded > t ? o.uploaded : t), '')
@@ -428,13 +453,13 @@ function FolderPreview({ path, objects, width, formatDate }) {
428
453
  ['Size', formatSize(bytes)],
429
454
  ...(last ? [['Newest', formatDate(last) || '—']] : []),
430
455
  ]
431
- return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} />
456
+ return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} details={details} />
432
457
  }
433
458
 
434
459
  /* ONE PREVIEW FOR EVERYTHING THAT HOLDS THINGS — a folder, a bucket, the title (user 2026-09-25:
435
460
  * selecting a bucket or MEDIA left the pane empty). The glyph, the name, the facts; only the
436
461
  * glyph and the facts differ. */
437
- function ContainerPreview({ name, icon, facts, width }) {
462
+ function ContainerPreview({ name, icon, facts, width, details }) {
438
463
  return (
439
464
  <div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
440
465
  {/* the folder fills its box with the tile's inset (user 2026-09-24: a 64px glyph floated in
@@ -451,6 +476,8 @@ function ContainerPreview({ name, icon, facts, width }) {
451
476
  </div>
452
477
  ))}
453
478
  </dl>
479
+ {/* a folder's own rows — favourite, tags (media D1 plan v2) — an island, like the file pane's */}
480
+ {details && <div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>{details}</div>}
454
481
  </div>
455
482
  )
456
483
  }
@@ -530,96 +557,27 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
530
557
  )
531
558
  }
532
559
 
533
- /* THE KINDS THAT OPEN AS TEXT — what `FileEditor` takes (the media D1 pass, 2026-09-25) */
560
+ /* THE KINDS THAT OPEN AS TEXT — what the document editor takes (media D1 plan v2, 2026-09-26).
561
+ * An SVG is text too: it opens with its picture beside it. Above EDIT_CAP a file is not loaded into
562
+ * an editor at all — a textarea holding megabytes is a frozen tab, not an edit. */
534
563
  const TEXT_KINDS = new Set(['markdown', 'json', 'yaml', 'text', 'code'])
564
+ const EDIT_CAP = 1_048_576
565
+ const editKindOf = (o) => (o.contentType === 'image/svg+xml' || extOf(o.key) === 'svg' ? 'svg' : TEXT_KINDS.has(kindOf(o)) ? kindOf(o) : null)
535
566
 
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) }
567
+ /* The editor for a file that exists: reads its text through the client, saves through it. Drafts
568
+ * are the DocumentEditor's (browser memory), keyed by bucket + key. */
569
+ function FileEditorHost({ o, client, bucket, onClose, onSaved, assets }) {
570
+ const [text, setText] = useState(null)
550
571
  useEffect(() => {
551
572
  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}`))
573
+ client.readText(o.key, bucket).then((r) => live && setText(r?.text ?? '')).catch(() => live && setText(''))
559
574
  return () => { live = false }
560
575
  }, [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
576
  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>
577
+ <DocumentEditor name={o.displayKey ?? o.key.split('/').pop()} kind={editKindOf(o)} text={text}
578
+ savedAt={o.uploaded ? Date.parse(o.uploaded) : 0} draft={{ bucket, key: o.key }} assets={assets}
579
+ onSave={async (next) => { const r = await client.writeText(o.key, next, bucket); onSaved?.(o.key, r, next) }}
580
+ onClose={onClose} />
623
581
  )
624
582
  }
625
583
 
@@ -689,7 +647,7 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
689
647
  * the header; bare at rest with a wash on hover, `sm` so the glyphs sit with the 12px text. */
690
648
  actions: (
691
649
  <span className="flex items-center gap-1">
692
- {onEdit && TEXT_KINDS.has(kind) && (
650
+ {onEdit && editKindOf(o) && (o.size ?? 0) <= EDIT_CAP && (
693
651
  <Tooltip label="Edit">
694
652
  <Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
695
653
  </Tooltip>
@@ -726,61 +684,62 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
726
684
  )
727
685
  }
728
686
 
729
- /* Display settings for the ACTIVE bucket — kol-r2b2's drawer wiring on the DS
730
- * organism (the composition the user locked 2026-08-27). Every control sets a
731
- * default, never a gate. */
732
- function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profile, settingsFooter }) {
687
+ /* THE DISPLAY SETTINGS AS DATA (media-shell, user 2026-09-26: "missing settings sidebar? … or are
688
+ * we saying the shell has settings different from media?"). The rows were hard-wired JSX inside this
689
+ * drawer, so an app page could not show them and a second settings system grew beside it. Now ONE
690
+ * builder: this drawer renders it, and an app renders the same rows on its settings page and its own
691
+ * drawer (kol-shell `HubSettings sections`). `settings` is the page's per-bucket settings object,
692
+ * `onChange(next)` sets it, `profile` (what the bucket holds) drives the "nothing to fold" states —
693
+ * omit it where the listing is not at hand and those two switches stay enabled with plain hints.
694
+ * Every control sets a default, never a gate. */
695
+ export function mediaSettingsSections({ settings, onChange, profile }) {
733
696
  const set = (patch) => onChange({ ...settings, ...patch })
734
- const toggleKind = (k) => set({ kinds: settings.kinds.includes(k) ? settings.kinds.filter((x) => x !== k) : [...settings.kinds, k] })
735
- const noVariants = profile.variantSets === 0
736
- const noSegments = profile.segments === 0
697
+ const kinds = settings.kinds ?? []
698
+ const toggleKind = (k) => set({ kinds: kinds.includes(k) ? kinds.filter((x) => x !== k) : [...kinds, k] })
699
+ const noVariants = profile?.variantSets === 0
700
+ const noSegments = profile?.segments === 0
701
+ return [
702
+ { label: 'Structure', rowGap: 1, rows: [
703
+ { label: 'Columns', hint: 'Finder-style columns instead of folder rows',
704
+ render: () => <SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} /> },
705
+ { label: 'Preview', hint: 'the picked file beside the rows',
706
+ render: () => <SettingsSwitch label="Preview" on={settings.rowPreview ?? true} onChange={(v) => set({ rowPreview: v })} /> },
707
+ /* NOT "Columns" — that switch above means Finder's column browser. These are the row view's
708
+ * date/size FIELDS, which is what the user calls them. */
709
+ { label: 'Fields', hint: 'date and size beside each row, with a sort header',
710
+ render: () => <SettingsSwitch label="Fields" on={!!settings.rowColumns} onChange={(v) => set({ rowColumns: v })} /> },
711
+ { label: 'Flat', hint: 'ignore folders, show the whole subtree',
712
+ render: () => <SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} /> },
713
+ { label: 'Group resolutions', hint: noVariants ? 'no resolution sets in this bucket' : profile ? `${profile.variantSets} sets — previews the smallest file` : 'previews the smallest file of a set',
714
+ render: () => <SettingsSwitch label="Group resolutions" on={settings.groupVariants} onChange={(v) => set({ groupVariants: v })} disabled={noVariants} disabledHint="nothing to group here" /> },
715
+ { label: 'Fold HLS segments', hint: noSegments ? 'no segments in this bucket' : profile ? `${profile.segments} segments into stream rows` : 'segments into stream rows',
716
+ render: () => <SettingsSwitch label="Fold HLS segments" on={settings.foldSegments} onChange={(v) => set({ foldSegments: v })} disabled={noSegments} disabledHint="nothing to fold here" /> },
717
+ ] },
718
+ { label: 'Loading', rows: [
719
+ { label: 'Kinds', align: 'fill',
720
+ render: () => <SettingsMulti options={ALL_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] || k }))} selected={kinds} onToggle={toggleKind} noun="kinds" /> },
721
+ { label: 'Page size', hint: 'entries mounted at once', align: 'fill',
722
+ render: () => <SettingsChoice options={[{ value: 100, label: '100' }, { value: 200, label: '200' }, { value: 500, label: '500' }, { value: 0, label: 'All' }]} value={settings.pageSize} onChange={(v) => set({ pageSize: v })} /> },
723
+ { label: 'Video preview', hint: 'poster uses the sibling image; autoload fetches the file', align: 'fill',
724
+ render: () => <SettingsChoice options={[{ value: 'poster', label: 'Poster' }, { value: 'none', label: 'None' }, { value: 'autoload', label: 'Autoload' }]} value={settings.videoPreview} onChange={(v) => set({ videoPreview: v })} /> },
725
+ ] },
726
+ { label: 'Layout', rows: [
727
+ { label: 'View', align: 'fill',
728
+ render: () => <SettingsChoice options={[{ value: 'off', label: 'Off' }, { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} value={settings.layout} onChange={(v) => set({ layout: v })} /> },
729
+ { label: 'Sort', align: 'fill',
730
+ render: () => <SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} /> },
731
+ { label: 'Direction', align: 'fill',
732
+ render: () => <SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} /> },
733
+ ] },
734
+ ]
735
+ }
736
+
737
+ /* Display settings for the ACTIVE bucket — kol-r2b2's drawer wiring on the DS organism (the
738
+ * composition the user locked 2026-08-27), drawn from `mediaSettingsSections`. */
739
+ function MediaSettings({ settings, onChange, onReset, onClose, profile, settingsFooter }) {
737
740
  return (
738
741
  <SettingsPanel variant="drawer" title="Display settings" onClose={onClose} footer={<SettingsFooter onReset={onReset} resetLabel="Reset preferences">{settingsFooter}</SettingsFooter>}>
739
- <LabeledControlSection label="Structure" rowGap={1} divided>
740
- <SettingsRow label="Columns" hint="Finder-style columns instead of folder rows">
741
- <SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} />
742
- </SettingsRow>
743
- <SettingsRow label="Preview" hint="the picked file beside the rows">
744
- <SettingsSwitch label="Preview" on={settings.rowPreview ?? true} onChange={(v) => set({ rowPreview: v })} />
745
- </SettingsRow>
746
- {/* NOT "Columns" — that switch three rows up means Finder's column browser. These are the
747
- row view's date/size FIELDS, which is what the user calls them. */}
748
- <SettingsRow label="Fields" hint="date and size beside each row, with a sort header">
749
- <SettingsSwitch label="Fields" on={!!settings.rowColumns} onChange={(v) => set({ rowColumns: v })} />
750
- </SettingsRow>
751
- <SettingsRow label="Flat" hint="ignore folders, show the whole subtree">
752
- <SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} />
753
- </SettingsRow>
754
- <SettingsRow label="Group resolutions" hint={noVariants ? 'no resolution sets in this bucket' : `${profile.variantSets} sets — previews the smallest file`}>
755
- <SettingsSwitch label="Group resolutions" on={settings.groupVariants} onChange={(v) => set({ groupVariants: v })} disabled={noVariants} disabledHint="nothing to group here" />
756
- </SettingsRow>
757
- <SettingsRow label="Fold HLS segments" hint={noSegments ? 'no segments in this bucket' : `${profile.segments} segments into stream rows`}>
758
- <SettingsSwitch label="Fold HLS segments" on={settings.foldSegments} onChange={(v) => set({ foldSegments: v })} disabled={noSegments} disabledHint="nothing to fold here" />
759
- </SettingsRow>
760
- </LabeledControlSection>
761
- <LabeledControlSection label="Loading" divided>
762
- <SettingsRow label="Kinds" align="fill">
763
- <SettingsMulti options={ALL_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] || k }))} selected={settings.kinds} onToggle={toggleKind} noun="kinds" />
764
- </SettingsRow>
765
- <SettingsRow label="Page size" hint="entries mounted at once" align="fill">
766
- <SettingsChoice options={[{ value: 100, label: '100' }, { value: 200, label: '200' }, { value: 500, label: '500' }, { value: 0, label: 'All' }]} value={settings.pageSize} onChange={(v) => set({ pageSize: v })} />
767
- </SettingsRow>
768
- <SettingsRow label="Video preview" hint="poster uses the sibling image; autoload fetches the file" align="fill">
769
- <SettingsChoice options={[{ value: 'poster', label: 'Poster' }, { value: 'none', label: 'None' }, { value: 'autoload', label: 'Autoload' }]} value={settings.videoPreview} onChange={(v) => set({ videoPreview: v })} />
770
- </SettingsRow>
771
- </LabeledControlSection>
772
- <LabeledControlSection label="Layout" divided>
773
- <SettingsRow label="View" align="fill">
774
- <SettingsChoice options={[{ value: 'off', label: 'Off' }, { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} value={settings.layout} onChange={(v) => set({ layout: v })} />
775
- </SettingsRow>
776
- <SettingsRow label="Sort" align="fill">
777
- <SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} />
778
- </SettingsRow>
779
- <SettingsRow label="Direction" align="fill">
780
- <SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} />
781
- </SettingsRow>
782
- </LabeledControlSection>
783
- {void bucketMeta}
742
+ <SettingsSections sections={mediaSettingsSections({ settings, onChange, profile })} divided />
784
743
  </SettingsPanel>
785
744
  )
786
745
  }
@@ -959,6 +918,44 @@ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta
959
918
  )
960
919
  }
961
920
 
921
+ /* `columnHeight: 'fill'` — THE BROWSER TAKES WHAT IS LEFT OF THE WINDOW (media-shell, user 2026-09-26:
922
+ * "why isnt the browser in media shell using available height like media does?"). apps/media had it
923
+ * as a hand-measured `calc(100dvh - 212px)` — its header, gaps and padding, counted once — so the same
924
+ * tool one shell over got the 528 base. Measured instead: the window, less the view's own top, less
925
+ * what the page draws under it (the count line), less the bottom padding / border / margin of every
926
+ * box around it. Follows a resize and any change above it. Floor 240 so a short window still browses. */
927
+ /* `view` is the ELEMENT (state, via a callback ref), not a ref object: the view mounts after the data
928
+ * loads, and in a fixed-height page (kol-shell PageShell) nothing else resizes to wake the observer —
929
+ * a ref read once at mount stayed null and the browser sat at the 528 base. */
930
+ function useFillHeight(on, view, pageEl) {
931
+ const [h, setH] = useState(null)
932
+ useLayoutEffect(() => {
933
+ if (!on || !view) return undefined
934
+ const measure = () => {
935
+ const page = pageEl.current
936
+ if (!page) return
937
+ const r = view.getBoundingClientRect()
938
+ /* what the page DRAWS under the view — its content's bottom, not its box's: a page stretched to
939
+ * fill its container (flex-1) would count its own empty space as "below" and freeze the height */
940
+ const contentBottom = Math.max(r.bottom, ...[...page.children].map((c) => c.getBoundingClientRect().bottom))
941
+ let below = contentBottom - r.bottom
942
+ for (let el = page; el && el !== document.body; el = el.parentElement) {
943
+ const cs = getComputedStyle(el)
944
+ below += parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth) + (el === page ? 0 : parseFloat(cs.marginBottom))
945
+ }
946
+ const next = Math.max(240, Math.floor(window.innerHeight - r.top - below))
947
+ setH((p) => (p === next ? p : next))
948
+ }
949
+ measure()
950
+ const ro = new ResizeObserver(measure)
951
+ ro.observe(document.body)
952
+ if (pageEl.current) ro.observe(pageEl.current)
953
+ window.addEventListener('resize', measure)
954
+ return () => { ro.disconnect(); window.removeEventListener('resize', measure) }
955
+ }, [on, view]) // eslint-disable-line react-hooks/exhaustive-deps
956
+ return h
957
+ }
958
+
962
959
  /* the profile — what the bucket holds; drives the chips and the settings' "nothing to fold" states */
963
960
  const profileOf = (objects, rawFiles, systemCount) => ({
964
961
  variantSets: groupVariants(objects.map((o) => ({ ...o, displayKey: o.key }))).filter((g) => g.variants).length,
@@ -1001,6 +998,9 @@ export function MediaLibraryBrowse({
1001
998
  * rendering what it rendered. */
1002
999
  view: viewProp, onViewChange,
1003
1000
  folderTree, headerActions, headerTrailing, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '', banner,
1001
+ /* `smartFolder` — a smart folder's id to open ON (a Home tile linking straight to one); the chips
1002
+ * take over from there (media D1 plan v2) */
1003
+ smartFolder,
1004
1004
  /* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
1005
1005
  * optional and each async; whatever is supplied becomes a right-click menu entry and
1006
1006
  * anything absent simply is not offered. One object rather than four props because they
@@ -1148,49 +1148,151 @@ export function MediaLibraryBrowse({
1148
1148
  const press = useLongPress()
1149
1149
  const modal = useModal()
1150
1150
  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. */
1151
+ /* ── WHAT THE DATABASE BESIDE THE BUCKET ADDS (media D1 plan v2, 2026-09-26) ──────────────────
1152
+ * Every verb optional; each feature below is off when its verb is missing. Writes patch the list
1153
+ * in place so the change lands at once; the next listing confirms it. A path ending in `/` is a
1154
+ * folder — its rows come from `folderInfo`, a file's ride the listing. */
1155
+ const bucketKey = bucketMeta.id ?? undefined
1156
+ const folderRows = lib.folderInfo
1157
+ const patchPath = (path, patch) => {
1158
+ if (path.endsWith('/')) lib.setFolderInfo((f) => ({ ...f, [path]: { tags: [], favourite: false, ...(f[path] ?? {}), ...patch } }))
1159
+ else {
1160
+ lib.setObjects((list) => list.map((o) => (o.key === path ? { ...o, ...patch } : o)))
1161
+ setPickedFile((f) => (f?.key === path ? { ...f, ...patch } : f))
1162
+ }
1163
+ }
1164
+ /* TAGS — `setTags(path, tags, bucket)` */
1154
1165
  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))
1166
+ const tagsOf = (path) => (path.endsWith('/') ? folderRows[path]?.tags : objects.find((o) => o.key === path)?.tags) ?? []
1167
+ /* every tag in the bucket, for the add field's suggestions — a typo forks the vocabulary */
1168
+ /* the rows view grows a Tags column when there is anything to show or set */
1169
+ const hasTags = canTag || objects.some((o) => o.tags?.length)
1170
+ const allTags = useMemo(() => [...new Set([...objects.flatMap((o) => o.tags ?? []), ...Object.values(folderRows).flatMap((f) => f.tags ?? [])])].sort(), [objects, folderRows])
1171
+ const saveTags = (path, tags) => runAction(async () => {
1172
+ const r = await client.setTags(path, tags, bucketKey)
1173
+ patchPath(path, { tags: r?.tags ?? tags })
1159
1174
  })
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 */
1175
+ /* FAVOURITES — `setFavourite(path, on, bucket)` */
1176
+ const canFavourite = writable && typeof client?.setFavourite === 'function'
1177
+ const isFavourite = (path) => !!(path.endsWith('/') ? folderRows[path]?.favourite : objects.find((o) => o.key === path)?.favourite)
1178
+ const toggleFavourite = (path) => runAction(async () => {
1179
+ const on = !isFavourite(path)
1180
+ await client.setFavourite(path, on, bucketKey)
1181
+ patchPath(path, { favourite: on })
1182
+ })
1183
+ /* THE EVENT LOG recents are read from — intentions only (opened, edited, created) */
1184
+ const logEvent = (kind, path) => { Promise.resolve(client?.logEvent?.(kind, path, bucketKey)).catch(() => {}) }
1185
+ /* DRAFTS are browser memory (the user's ruling) — read here only to mark a file that has one */
1186
+ const [draftKeys, setDraftKeys] = useState(() => new Set(listDrafts(bucketKey).map((d) => d.key)))
1187
+ useEffect(() => {
1188
+ const read = () => setDraftKeys(new Set(listDrafts(bucketKey).map((d) => d.key)))
1189
+ read()
1190
+ window.addEventListener(DRAFTS_EVENT, read)
1191
+ return () => window.removeEventListener(DRAFTS_EVENT, read)
1192
+ }, [bucketKey])
1193
+ /* DOCUMENTS — `readText` / `writeText` edit; a new one is the consumer's `createFile` + `writeText` */
1162
1194
  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)))
1195
+ const canNewDoc = canEdit && canWrite && !!fileActions.createFile
1196
+ const [editing, setEditing] = useState(null) // { mode: 'edit', o } | { mode: 'new', folder }
1197
+ const openEditor = (o) => { setEditing({ mode: 'edit', o }); logEvent('opened', o.key) }
1198
+ const newDocument = (folder) => setEditing({ mode: 'new', folder })
1199
+ /* A MARKDOWN FILE'S FRONTMATTER TAGS ARE ITS TAGS — merged into the database's on every save, so
1200
+ * a tag written in the document is a tag you can filter by. Merged, never replaced: a tag set in
1201
+ * the pane and not in the document stays. */
1202
+ const syncFrontmatterTags = async (key, text) => {
1203
+ if (!canTag || !/\.(md|markdown)$/i.test(key)) return
1204
+ const fm = parseFrontmatter(text ?? '').tags
1205
+ const typed = (Array.isArray(fm) ? fm : typeof fm === 'string' && fm ? fm.split(',') : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
1206
+ const current = objects.find((o) => o.key === key)?.tags ?? []
1207
+ const next = [...new Set([...current, ...typed])]
1208
+ if (next.length !== current.length) { await client.setTags(key, next, bucketKey); patchPath(key, { tags: next }) }
1166
1209
  }
1167
- const hasTags = canTag || objects.some((o) => o.tags?.length)
1168
- /* what the preview pane adds under a file's facts, in every view */
1210
+ const afterEdit = (key, res, text) => {
1211
+ if (res?.size != null) lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, size: res.size, uploaded: new Date().toISOString() } : o)))
1212
+ syncFrontmatterTags(key, text).catch(() => {})
1213
+ }
1214
+ const createDocument = async ({ name, text }) => {
1215
+ const key = `${editing.folder}${name}`
1216
+ if (objects.some((o) => o.key === key)) throw new Error(`${name} already exists here`)
1217
+ await fileActions.createFile(key)
1218
+ await client.writeText(key, text, bucketKey)
1219
+ const fm = /\.(md|markdown)$/i.test(key) ? parseFrontmatter(text).tags : null
1220
+ const typed = (Array.isArray(fm) ? fm : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
1221
+ if (canTag && typed.length) await client.setTags(key, typed, bucketKey)
1222
+ lib.reload()
1223
+ setEditing(null)
1224
+ }
1225
+ /* what a markdown document can attach: the bucket's own files, linked by their public URL */
1226
+ 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 })) : []
1227
+ const editBlock = (o) => {
1228
+ const kind = editKindOf(o)
1229
+ if (!canEdit || !kind) return null
1230
+ const tooBig = (o.size ?? 0) > EDIT_CAP
1231
+ return (
1232
+ <div className="flex items-center gap-3">
1233
+ <Button variant="secondary" size="sm" iconLeft="edit" disabled={tooBig} onClick={() => openEditor(o)}>Edit</Button>
1234
+ {tooBig && <span className="kol-mono-12 text-fg-48">Over 1 MB — too large to edit here</span>}
1235
+ {!tooBig && draftKeys.has(o.key) && <span className="kol-mono-12 text-fg-48">Unsaved draft</span>}
1236
+ </div>
1237
+ )
1238
+ }
1239
+ /* what the preview pane adds under a file's or a folder's facts, in every view */
1169
1240
  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
1241
+ const path = o.key ?? o
1242
+ const tags = tagsOf(path)
1243
+ const edit = typeof o === 'object' ? editBlock(o) : null
1244
+ if (!edit && !canTag && !canFavourite && !tags.length) return null
1174
1245
  return (
1175
1246
  <div className="flex flex-col gap-4">
1176
- {editable && (
1247
+ {(edit || canFavourite) && (
1177
1248
  <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>}
1249
+ {canFavourite && (
1250
+ <Tooltip label={isFavourite(path) ? 'Remove from favourites' : 'Add to favourites'}>
1251
+ <Button variant="nav" size="sm" iconOnly={isFavourite(path) ? 'star-solid' : 'star'} onClick={() => toggleFavourite(path)}
1252
+ aria-label={isFavourite(path) ? 'Remove from favourites' : 'Add to favourites'} aria-pressed={isFavourite(path)} />
1253
+ </Tooltip>
1254
+ )}
1255
+ {edit}
1180
1256
  </div>
1181
1257
  )}
1182
- {(canTag || tags.length > 0) && <TagEditor tags={tags} onChange={canTag ? (next) => saveTags(o.key, next) : undefined} />}
1258
+ {(canTag || tags.length > 0) && <TagEditor tags={tags} suggestions={allTags} onChange={canTag ? (next) => saveTags(path, next) : undefined} />}
1183
1259
  </div>
1184
1260
  )
1185
1261
  }
1186
1262
  const doTags = async (keys) => {
1187
1263
  const one = keys.length === 1
1188
1264
  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' })
1265
+ const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length} items, separated by commas:`, current, { okLabel: 'Save' })
1190
1266
  if (answer == null) return
1191
1267
  const typed = answer.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
1192
1268
  for (const key of keys) await saveTags(key, one ? typed : [...new Set([...tagsOf(key), ...typed])])
1193
1269
  }
1270
+ /* SMART FOLDERS — `smartFolders(bucket)` · `saveSmartFolder(sf, bucket)` · `deleteSmartFolder(id)` */
1271
+ const canSmart = typeof client?.smartFolders === 'function'
1272
+ const [smartFolders, setSmartFolders] = useState([])
1273
+ const [activeSmart, setActiveSmart] = useState(null)
1274
+ const loadSmart = () => { if (canSmart) Promise.resolve(client.smartFolders(bucketKey)).then((l) => setSmartFolders(l ?? [])).catch(() => {}) }
1275
+ useEffect(() => { setActiveSmart(null); loadSmart() }, [bucketKey]) // eslint-disable-line react-hooks/exhaustive-deps
1276
+ useEffect(() => { if (smartFolder && smartFolders.some((f) => f.id === smartFolder)) setActiveSmart(smartFolder) }, [smartFolder, smartFolders])
1277
+ const activeSmartFolder = smartFolders.find((f) => f.id === activeSmart) ?? null
1278
+ const saveSmart = async () => {
1279
+ const name = await modal.prompt('Smart folder name:', '', { okLabel: 'Next' })
1280
+ if (!name?.trim()) return
1281
+ const tags = await modal.prompt('Tags every file must carry, separated by commas (blank for any):', '', { okLabel: 'Next' })
1282
+ if (tags == null) return
1283
+ const kinds = await modal.prompt('Kinds, separated by commas — image, video, audio, markdown, text, json, pdf… (blank for all):', '', { okLabel: 'Save' })
1284
+ if (kinds == null) return
1285
+ const split = (v) => v.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
1286
+ runAction(async () => {
1287
+ const row = await client.saveSmartFolder({ name: name.trim(), query: { tags: split(tags), kinds: split(kinds), text: '' } }, bucketKey)
1288
+ loadSmart()
1289
+ if (row?.id) setActiveSmart(row.id)
1290
+ })
1291
+ }
1292
+ const deleteSmart = async (sf) => {
1293
+ if (!(await modal.confirm(`Delete the smart folder "${sf.name}"? The files stay where they are.`, { okLabel: 'Delete' }))) return
1294
+ runAction(async () => { await client.deleteSmartFolder(sf.id, bucketKey); if (activeSmart === sf.id) setActiveSmart(null); loadSmart() })
1295
+ }
1194
1296
  const [busyAction, setBusyAction] = useState(false)
1195
1297
 
1196
1298
  const runAction = async (fn) => {
@@ -1214,7 +1316,9 @@ export function MediaLibraryBrowse({
1214
1316
  const name = await modal.prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current, { okLabel: 'Rename' })
1215
1317
  if (!name?.trim() || name === current) return
1216
1318
  const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
1217
- runAction(() => fileActions.rename(path, `${parent}${name.trim()}${isFolder ? '/' : ''}`))
1319
+ const to = `${parent}${name.trim()}${isFolder ? '/' : ''}`
1320
+ /* a draft is keyed by path — it follows the file (or everything under the folder) */
1321
+ runAction(async () => { await fileActions.rename(path, to); moveDrafts(bucketKey, path, to) })
1218
1322
  }
1219
1323
  const doMove = async (path) => {
1220
1324
  const dest = await modal.prompt(`Move into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
@@ -1227,6 +1331,7 @@ export function MediaLibraryBrowse({
1227
1331
  * view: expanded in place in the rows, navigated into in the columns and the grid. */
1228
1332
  const afterMove = (paths, dest) => {
1229
1333
  const moved = paths.map((p) => `${dest}${p.replace(/\/$/, '').split('/').pop()}${p.endsWith('/') ? '/' : ''}`)
1334
+ paths.forEach((p, i) => moveDrafts(bucketKey, p, moved[i]))
1230
1335
  setPickedFile(null); setPickedFolder(null)
1231
1336
  setRowSelection(new Set(moved))
1232
1337
  selectAnchorRef.current = moved[0] ?? null
@@ -1419,6 +1524,7 @@ export function MediaLibraryBrowse({
1419
1524
  const inView = q.files.filter((o) => rowSelection.has(o.key))
1420
1525
  const sel = inView.length === rowSelection.size ? inView : objects.filter((o) => rowSelection.has(o.key))
1421
1526
  const current = q.files[q.index]
1527
+ if (current?.key) logEvent('opened', current.key)
1422
1528
  if (sel.length > 1) {
1423
1529
  const index = Math.max(0, sel.findIndex((o) => o.key === current?.key))
1424
1530
  setQuickLook({ files: sel, index })
@@ -1623,6 +1729,17 @@ export function MediaLibraryBrowse({
1623
1729
  setQuickLook({ files, index: at === -1 ? 0 : at })
1624
1730
  }, [rowSelection]) // eslint-disable-line react-hooks/exhaustive-deps
1625
1731
  const columnsRef = useRef(null)
1732
+ /* the view box (whichever view is mounted) and the page root — what `fill` measures */
1733
+ const [viewEl, viewRef] = useState(null)
1734
+ /* stable, so the columns box is not re-attached (null, then itself) on every render */
1735
+ const columnsBox = useCallback((el) => { columnsRef.current = el; viewRef(el) }, [])
1736
+ const pageRef = useRef(null)
1737
+ const fill = settings.columnHeight === 'fill'
1738
+ const fillH = useFillHeight(fill, viewEl, pageRef)
1739
+ /* a drag in fill mode is the SESSION's, never saved — one height on every reload (apps/media's ruling) */
1740
+ const [dragH, setDragH] = useState(null)
1741
+ const colH = fill ? (dragH ?? fillH ?? SETTINGS_BASE.columnHeight) : (settings.columnHeight ?? SETTINGS_BASE.columnHeight)
1742
+ const onColH = (px) => (fill ? setDragH(px) : setSettings({ ...settings, columnHeight: px }))
1626
1743
  /* `flat` IS THE WALL'S, NOT THIS PAGE'S (user 2026-09-22: "there is no option to switch to flat
1627
1744
  * mode, bc its not in files view. this is row mode.. this makes no sense"). It reached here from
1628
1745
  * kol-r2b2's single surface and did two things that cannot be defended in a TREE: it struck every
@@ -1681,7 +1798,7 @@ export function MediaLibraryBrowse({
1681
1798
  return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
1682
1799
  }} />
1683
1800
  ) : previewFolder ? (
1684
- <FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} />
1801
+ <FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} details={detailsFor(previewFolder)} />
1685
1802
  ) : previewRoot ? (
1686
1803
  <ContainerPreview key={previewRoot.key} name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={previewWidth} />
1687
1804
  ) : (
@@ -1797,6 +1914,9 @@ export function MediaLibraryBrowse({
1797
1914
  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
1915
  const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
1799
1916
  const filterTags = [...new Set(filterItems.flatMap((o) => o.tags ?? []))].sort()
1917
+ /* favourites as a one-chip group: ContentFilters matches a value, so a starred file carries one */
1918
+ const filterItemsFav = filterItems.map((o) => ({ ...o, starred: o.favourite ? 'favourite' : '' }))
1919
+ const anyStarred = filterItemsFav.some((o) => o.starred)
1800
1920
 
1801
1921
  /* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
1802
1922
  * `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
@@ -1835,12 +1955,12 @@ export function MediaLibraryBrowse({
1835
1955
  const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
1836
1956
  return (
1837
1957
  folderView === 'columns' || phone ? (
1838
- <div ref={columnsRef} className="relative">
1958
+ <div ref={columnsBox} className="relative">
1839
1959
  <ColumnBrowser
1840
1960
  autoFocus={autoFocus}
1841
1961
  className={atTitleRoot ? 'is-root' : ''}
1842
- height={settings.columnHeight}
1843
- onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
1962
+ height={colH}
1963
+ onHeightChange={onColH}
1844
1964
  columnWidths={settings.columnWidths}
1845
1965
  onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
1846
1966
  objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
@@ -1924,7 +2044,7 @@ export function MediaLibraryBrowse({
1924
2044
  * the same line — the count line under them jumps otherwise, and a consumer's fill height
1925
2045
  * is ONE value for both — so the header comes out of this view's budget rather than being
1926
2046
  * added to it. The pane takes what is left. */
1927
- <div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
2047
+ <div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
1928
2048
  {/* the header labels the LIST, so it spans the list and stops where the preview starts */}
1929
2049
  {rowCols && (
1930
2050
  <RowHeader sortBy={settings.sortBy} sortDir={settings.sortDir} width={showRowPreview ? `calc(100% - ${previewWidth}px)` : '100%'}
@@ -2034,7 +2154,7 @@ export function MediaLibraryBrowse({
2034
2154
  onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
2035
2155
  )
2036
2156
  const wallPane = (files) => (
2037
- <div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
2157
+ <div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
2038
2158
  <div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
2039
2159
  {/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
2040
2160
  * there were tiles — the grid is as tall as its tiles, the pane is the whole frame). The rows'
@@ -2058,7 +2178,7 @@ export function MediaLibraryBrowse({
2058
2178
  /* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
2059
2179
  * 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
2060
2180
  * and no folders, as it was. */
2061
- folders={settings.flat ? [] : folders.map((f) => prefix + f)}
2181
+ folders={settings.flat || smartMatches ? [] : folders.map((f) => prefix + f)}
2062
2182
  onOpenFolder={goFolder}
2063
2183
  onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
2064
2184
  onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
@@ -2103,12 +2223,24 @@ export function MediaLibraryBrowse({
2103
2223
  pickFile(o)
2104
2224
  }
2105
2225
 
2106
- const body = (filtered) => (isWall
2107
- ? wallPane(filtered ?? wallFiles)
2108
- : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
2226
+ /* A SMART FOLDER IS A SAVED QUERY (media D1 plan v2) — tags it must carry, kinds it may be, text
2227
+ * in its path — shown flat, every match wherever it lives, as Finder's smart folders are. */
2228
+ const smartMatches = activeSmartFolder ? objects.filter((o) => {
2229
+ const q = activeSmartFolder.query ?? {}
2230
+ if (isSystemFile(o.key)) return false
2231
+ if ((q.tags ?? []).some((t) => !(o.tags ?? []).includes(t))) return false
2232
+ if ((q.kinds ?? []).length && !q.kinds.includes(kindOf(o))) return false
2233
+ if (q.text && !o.key.toLowerCase().includes(q.text.toLowerCase())) return false
2234
+ return true
2235
+ }).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key })) : null
2236
+ const body = (filtered) => (smartMatches
2237
+ ? wallPane(filtered ?? smartMatches)
2238
+ : isWall
2239
+ ? wallPane(filtered ?? wallFiles)
2240
+ : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
2109
2241
 
2110
2242
  return (
2111
- <div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
2243
+ <div ref={pageRef} {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
2112
2244
  <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
2113
2245
  onHome={goTitleRoot}
2114
2246
  onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
@@ -2297,12 +2429,15 @@ export function MediaLibraryBrowse({
2297
2429
  )}
2298
2430
  {quickLook && (
2299
2431
  <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}
2432
+ onEdit={canEdit ? (f) => { closeQuickLook(); openEditor(f) } : undefined}
2301
2433
  onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
2302
2434
  )}
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)))} />
2435
+ {editing?.mode === 'edit' && (
2436
+ <FileEditorHost o={editing.o} client={client} bucket={bucketKey} onClose={() => setEditing(null)} onSaved={afterEdit} assets={attachables} />
2437
+ )}
2438
+ {editing?.mode === 'new' && (
2439
+ <DocumentEditor mode="new" folder={editing.folder} draft={{ bucket: bucketKey, key: `${editing.folder}.new-document` }} assets={attachables}
2440
+ onCreate={createDocument} onClose={() => setEditing(null)} />
2306
2441
  )}
2307
2442
  {settingsOpen && (
2308
2443
  <MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
@@ -2311,16 +2446,28 @@ export function MediaLibraryBrowse({
2311
2446
  <TrashPanel trash={trash} onClose={() => setTrashOpen(false)} run={runAction} confirm={modal.confirm} />
2312
2447
  )}
2313
2448
 
2449
+ {/* SMART FOLDERS — one chip each; a chip shows its matches flat in place of the body */}
2450
+ {canSmart && (smartFolders.length > 0 || canWrite) && !atTitleRoot && (
2451
+ <div className="flex flex-wrap items-center gap-2">
2452
+ <span className="kol-mono-12 text-fg-48">Smart folders</span>
2453
+ {smartFolders.map((sf) => (
2454
+ <Tag key={sf.id} variant="secondary" size="sm" hash={false} active={activeSmart === sf.id}
2455
+ onClick={() => setActiveSmart((a) => (a === sf.id ? null : sf.id))}
2456
+ onRemove={canWrite ? () => deleteSmart(sf) : undefined}>{sf.name}</Tag>
2457
+ ))}
2458
+ {canWrite && <Button variant="nav" size="xs" iconLeft="plus" onClick={saveSmart}>New smart folder</Button>}
2459
+ </div>
2460
+ )}
2314
2461
  {/* THE FILTER BAR, above whichever body is mounted (2026-09-22). It filters FILES, so the
2315
2462
  * tree is scoped to the keys it leaves and the wall renders them directly. Off by default
2316
2463
  * — the funnel in the crumb row turns it on. */}
2317
2464
  {filtersOn ? (
2318
2465
  <ContentFilters
2319
- items={filterItems}
2466
+ items={filterItemsFav}
2320
2467
  title="Files"
2321
2468
  totalCount={filterItems.length}
2322
2469
  searchKeys={['displayKey']}
2323
- filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : [])]}
2470
+ filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : []), ...(anyStarred ? [{ label: 'Favourites', key: 'starred', values: ['favourite'] }] : [])]}
2324
2471
  mutuallyExclusiveFilters={['kind']}
2325
2472
  /* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
2326
2473
  * its panel, and the bar's own funnel closes the bar — the same switch from either
@@ -2419,7 +2566,13 @@ export function MediaLibraryBrowse({
2419
2566
  New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
2420
2567
  </MenuDropdownItem>
2421
2568
  )}
2422
- {fileActions.createFile && (
2569
+ {/* NEW DOCUMENT is the writing page (a name, a type, fields for markdown); New file stays for
2570
+ a client that cannot write text — it makes the empty file and nothing else */}
2571
+ {canNewDoc ? (
2572
+ <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => newDocument(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2573
+ New document…
2574
+ </MenuDropdownItem>
2575
+ ) : fileActions.createFile && (
2423
2576
  <MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
2424
2577
  New file
2425
2578
  </MenuDropdownItem>
@@ -2446,9 +2599,17 @@ export function MediaLibraryBrowse({
2446
2599
  {target.type === 'file' && (
2447
2600
  <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
2448
2601
  )}
2449
- {target.type === 'file' && canTag && (
2602
+ {!isLevel && canTag && (
2450
2603
  <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags([target.path])}>Tags…</MenuDropdownItem>
2451
2604
  )}
2605
+ {!isLevel && canFavourite && (
2606
+ <MenuDropdownItem iconLeft={<Icon name={isFavourite(target.path) ? 'star-solid' : 'star'} size={14} />} onClick={() => toggleFavourite(target.path)}>
2607
+ {isFavourite(target.path) ? 'Remove from favourites' : 'Add to favourites'}
2608
+ </MenuDropdownItem>
2609
+ )}
2610
+ {target.type === 'file' && canEdit && editKindOf(target.o ?? { key: target.path }) && (
2611
+ <MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => openEditor(objects.find((o) => o.key === target.path) ?? { key: target.path })}>Edit</MenuDropdownItem>
2612
+ )}
2452
2613
  {!isLevel && fileActions.remove && (
2453
2614
  <>
2454
2615
  <MenuDropdownDivider />