@kolkrabbi/kol-component 0.216.0 → 0.218.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.
@@ -11,6 +11,7 @@ import ToggleCheckbox from '../atoms/ToggleCheckbox.jsx'
11
11
  import ViewToggle from '../atoms/ViewToggle.jsx'
12
12
  import Dropdown from '../molecules/Dropdown.jsx'
13
13
  import ContentCard from '../molecules/ContentCard.jsx'
14
+ import MediaTile from '../molecules/MediaTile.jsx'
14
15
  import ContentRow from '../molecules/ContentRow.jsx'
15
16
  import SortControls from '../molecules/SortControls.jsx'
16
17
  import SearchInput from '../molecules/SearchInput.jsx'
@@ -18,15 +19,19 @@ import MobileTabBar, { TABBAR_H } from '../molecules/MobileTabBar.jsx'
18
19
  import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
19
20
  import { Tooltip } from '../utilities/Popover.jsx'
20
21
  import ContextMenu, { useContextMenu } from '../utilities/ContextMenu.jsx'
22
+ import useMarquee from '../hooks/useMarquee.js'
21
23
  import KindPreview from '../molecules/KindPreview.jsx'
22
24
  import AudioSheet from '../molecules/AudioSheet.jsx'
23
25
  import VideoSheet from '../molecules/VideoSheet.jsx'
26
+ import QuickLookFrame from '../molecules/QuickLookFrame.jsx'
24
27
  import { formatLength } from '../molecules/AudioPreview.jsx'
25
28
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
29
+ import { useModal } from '../molecules/Modal.jsx'
26
30
  import ContentFilters from './ContentFilters.jsx'
27
- import ColumnBrowser from './ColumnBrowser.jsx'
31
+ import ShellSearchOverlay from './ShellSearchOverlay.jsx'
32
+ import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
28
33
  import SettingsPanel, { LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
29
- import { kindOf, KIND_LABEL, KINDS, DEFAULT_KINDS, isSystemFile, isSegment, groupSegments, groupVariants, posterFor, partition } from '../utilities/mediaKinds.js'
34
+ import { kindOf, extOf, KIND_LABEL, KINDS, DEFAULT_KINDS, isSystemFile, isSegment, groupSegments, groupVariants, posterFor, partition } from '../utilities/mediaKinds.js'
30
35
  import { nearestRatio } from '../utilities/ratios.js'
31
36
 
32
37
  /* taxonomy-ok: organism — the two media pages composed of DS parts. */
@@ -60,6 +65,15 @@ import { nearestRatio } from '../utilities/ratios.js'
60
65
  * controlled through `settings` / `onSettingsChange` so a consumer persists them.
61
66
  */
62
67
 
68
+ /* THE ROW VIEW'S COLUMNS (user 2026-09-22). OFF by default and `rowColumns` turns them on: with
69
+ * them off a row is a name, and every fact about it is one click away in the preview. On, the
70
+ * header and both row kinds read these same widths — drag a header divider and both move — and the
71
+ * values sit LEFT in their column, as the reference draws them. */
72
+ const ROW_COL_DEFAULTS = { date: 96, size: 80 }
73
+ const ROW_COL_MIN = 56
74
+ const ROW_COL_MAX = 240
75
+ const rowColWidths = (settings) => ({ ...ROW_COL_DEFAULTS, ...(settings.rowColumnWidths ?? {}) })
76
+
63
77
  /* ── the settings model — kol-r2b2's lib/settings.js BASE, verbatim ───────── */
64
78
  export const ALL_KINDS = [...KINDS, 'segments', 'system']
65
79
  export const SETTINGS_BASE = {
@@ -73,19 +87,34 @@ export const SETTINGS_BASE = {
73
87
  folderView: 'columns', // 'rows' | 'columns'
74
88
  sortBy: 'name',
75
89
  sortDir: 'asc',
76
- columnHeight: 528,
90
+ columnHeight: 528, // px, or any CSS length — forwarded to ColumnBrowser `height` untouched
77
91
  columnWidths: {},
92
+ rowPreview: true, // row view's preview pane beside the list (Finder's "Show preview")
93
+ view: 'columns', // 'columns' | 'rows' | 'grid' | 'list' — the ONE view state (2026-09-22)
94
+ filters: false, // the filter bar above the body; off until asked for
95
+ rowColumns: false, // row view's Date/Size columns + their header; off, a row is just its name
96
+ rowColumnWidths: { ...ROW_COL_DEFAULTS }, // px, dragged on the header's own dividers
78
97
  }
79
98
 
99
+ /* THE FOUR VIEWS (the merge, user 2026-09-22: "they both just display files"). Columns and rows
100
+ * walk the tree; grid and list are the same files as a wall. One switch, one surface, one listing —
101
+ * `BROWSE · FILES` was two pages pretending to be two products. */
102
+ /* THREE, NOT FOUR (user 2026-09-23: *"list view is the same as row view now..so redundant"*). It
103
+ * was: the wall's list is a row per file with a thumbnail, which is what the row view became the
104
+ * moment it took the pane, the columns and the preview. A stored `list` lands on `rows`. */
105
+ const VIEW_OPTIONS = [
106
+ { value: 'columns', label: 'Columns', icon: 'columns' },
107
+ /* `rows` in the set is a BRANCH glyph, not a row stack — the tree list takes `row` (stacked
108
+ * bands) and the file list the four rules of `view-list`. */
109
+ { value: 'rows', label: 'Rows', icon: 'row' },
110
+ { value: 'grid', label: 'Grid', icon: 'grid' },
111
+ ]
112
+
80
113
  const LAYOUT_OPTIONS = [
81
114
  { value: 'grid', label: 'Grid', icon: 'grid' },
82
115
  { value: 'list', label: 'List', icon: 'view-list' },
83
116
  { value: 'off', label: 'Off', icon: 'eye-off' },
84
117
  ]
85
- const FOLDER_VIEW_OPTIONS = [
86
- { value: 'rows', label: 'Rows', icon: 'view-list' },
87
- { value: 'columns', label: 'Columns', icon: 'columns' },
88
- ]
89
118
  /* the stack view the `···` switches (item 15) — the same two ColumnBrowser
90
119
  * carries below `md`, named as the references name them */
91
120
  const STACK_VIEW_OPTIONS = [
@@ -173,25 +202,36 @@ function ImageFrame({ src }) {
173
202
  )
174
203
  }
175
204
 
176
- /* `struck` = folder grouping is bypassed (flat mode) — still navigable, de-emphasised.
177
- *
178
- * `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
205
+
206
+ /* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
179
207
  * disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
180
208
  * that navigates — so nothing that already renders these moves. */
181
- function FolderRow({ name, onClick, struck = false, depth = 0, expanded, onToggle, meta, onContextMenu, drag, selected }) {
209
+ function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
182
210
  const [over, setOver] = useState(false)
183
211
  /* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
184
212
  * row highlights only while something is actually over it, so an accidental hover reads as
185
213
  * nothing. Without `drag` every handler below is undefined and the row is exactly what it was. */
186
- const dropProps = drag ? {
187
- draggable: true,
188
- onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, drag.path) },
189
- onDragOver: (e) => { if (drag.canDrop(drag.path)) { e.preventDefault(); setOver(true) } },
214
+ /* `dropFiles(files)` is the OS half: desktop files dropped here go to the consumer's upload. */
215
+ const takesFiles = (e) => dropFiles && isFileDrag(e)
216
+ /* A CLICK TAKES THE WHOLE ROW, A DRAG STARTS WHERE YOU PRESSED (user 2026-09-23: *"the example
217
+ * I used before … was more about the ability to make a marquee selection, not to make it difficult
218
+ * to select row items"*). Click, double-click and ⌘/⇧-click land anywhere on the row. The icon and
219
+ * name (`data-hit`) carry the drag-to-move and the pointer; a press-and-drag anywhere else on the
220
+ * row draws the marquee (`data-hit-zone`, `useMarquee`). The DROP stays on the whole row. */
221
+ const hitDrag = drag ? { draggable: true, onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, drag.path) } } : {}
222
+ const dropProps = drag || dropFiles ? {
223
+ onDragOver: (e) => { if (takesFiles(e) || (drag && !isFileDrag(e) && drag.canDrop(drag.path))) { e.preventDefault(); setOver(true) } },
190
224
  onDragLeave: () => setOver(false),
191
- onDrop: (e) => { e.preventDefault(); e.stopPropagation(); setOver(false); drag.onDrop(drag.path) },
225
+ onDrop: (e) => {
226
+ e.preventDefault(); e.stopPropagation(); setOver(false)
227
+ if (takesFiles(e)) dropFiles(e.dataTransfer.files)
228
+ else if (drag && !isFileDrag(e)) drag.onDrop(drag.path)
229
+ },
192
230
  } : {}
193
231
  return (
194
- <li onContextMenu={onContextMenu} {...dropProps} className={`flex items-center gap-3 py-2 border-b cursor-pointer transition-colors px-1 rounded ${over ? 'bg-fg-08' : selected ? 'bg-fg-04' : 'hover:bg-fg-04'}`} style={{ borderColor: 'var(--kol-fg-08)', paddingLeft: depth * 20 + 4 }} onClick={onClick}>
232
+ /* THE COLUMN BROWSER'S ROW (user 2026-09-22): `kol-column-browser-row` carries the 4px pill
233
+ * inset, the selected fill and the drop fill; no divider, as the columns have none. */
234
+ <li onContextMenu={onContextMenu} {...dropProps} data-marquee-key={markKey} data-hit-zone data-drop-over={over || undefined} className={`kol-column-browser-row flex items-center gap-3 py-2 px-3 transition-colors${selected ? ' is-selected' : ''}${current ? ' is-current' : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
195
235
  {onToggle ? (
196
236
  <button type="button" aria-label={expanded ? `Collapse ${name}` : `Expand ${name}`} aria-expanded={!!expanded}
197
237
  onClick={(e) => { e.stopPropagation(); onToggle() }}
@@ -200,10 +240,23 @@ function FolderRow({ name, onClick, struck = false, depth = 0, expanded, onToggl
200
240
  <Icon name="chevron-right" size={12} />
201
241
  </button>
202
242
  ) : <span className="w-4 shrink-0" />}
203
- <div className="w-8 h-8 shrink-0 flex items-center justify-center text-fg-48"><Icon name="folder" size={18} /></div>
204
- <span className={`kol-mono-12 flex-1 min-w-0 truncate ${struck ? 'line-through text-fg-48' : 'text-fg-default'}`}>{name}</span>
205
- {meta && <span className="kol-mono-12 text-fg-32 shrink-0">{meta}</span>}
206
- <Icon name="chevron-right" size={14} className="text-fg-32 shrink-0" />
243
+ {/* THE FOLDER GLYPH IS THE ONE ICON LEFT, so it fills its box (user 2026-09-23: *"only folder
244
+ should use icon, and it should be bigger"*). Every other kind now renders its own bytes. */}
245
+ <span data-hit {...hitDrag} className="kol-row-hit flex items-center gap-3 min-w-0">
246
+ <span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48"><Icon name="folder" size={26} /></span>
247
+ {/* NO TRAILING SLASH (user 2026-09-22). `partition` hands folders back as `audio/` because a
248
+ slash is what marks one in a flat key space; that is storage's spelling, not a label, and
249
+ `ColumnBrowser` has always stripped it. The glyph says "folder". */}
250
+ <span className="kol-mono-12 min-w-0 truncate text-fg-default">{name.replace(/\/$/, '')}</span>
251
+ </span>
252
+ <span className="flex-1" />
253
+ {/* THE FOLDER'S COUNT SITS IN THE DATE COLUMN and the size column takes a dash — the columns
254
+ line up or the header above them is a lie. The trailing chevron went with them: it
255
+ promised a click that opens, and a click now SELECTS (opening is a double-click). */}
256
+ {cols && <>
257
+ <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{meta}</span>
258
+ <span className="kol-mono-12 text-fg-24 shrink-0 truncate" style={{ width: cols.size }}>—</span>
259
+ </>}
207
260
  </li>
208
261
  )
209
262
  }
@@ -211,7 +264,7 @@ function FolderRow({ name, onClick, struck = false, depth = 0, expanded, onToggl
211
264
  /* One expanded level, drawn inline under its folder, recursing for anything expanded beneath it —
212
265
  * the disclosure behaviour a list view is expected to have. Depth only indents; the data comes from
213
266
  * the same `rowLevel` the top level uses, so an expanded branch cannot disagree with the columns. */
214
- function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder, onOpenFile, formatDate, folderMeta, thumbnailFor, flat, onRowContextMenu, dragFor }) {
267
+ function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder, onPickFolder, onOpenFile, onPickFile, formatDate, folderMeta, thumbnailFor, cols, onRowContextMenu, dragFor, dropFilesTo, pickedKey, currentKey, selectRow, order, selectedKeys }) {
215
268
  const { folders, files } = rowLevel(level)
216
269
  return (
217
270
  <>
@@ -221,26 +274,38 @@ function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder,
221
274
  return (
222
275
  <Fragment key={path}>
223
276
  <FolderRow
224
- name={f} depth={depth} struck={flat} expanded={open}
277
+ name={f} depth={depth} expanded={open}
278
+ markKey={path}
225
279
  meta={folderMeta?.(path) || undefined}
280
+ cols={cols}
226
281
  onToggle={() => onToggle(path)}
227
- onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path })}
282
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path, targets: selectedKeys?.has(path) ? [...selectedKeys] : [path] })}
228
283
  drag={dragFor?.(path)}
229
- onClick={() => onOpenFolder(path)}
284
+ dropFiles={dropFilesTo?.(path)}
285
+ selected={pickedKey === path || !!selectedKeys?.has(path)}
286
+ current={currentKey === path}
287
+ /* SELECTION IS THE SAME HERE AS AT THE ROOT (user 2026-09-23). These rows had no
288
+ * `selectRow` at all, so ⌘-click picked instead of adding — which is why a drag
289
+ * carried one item however many looked lit. */
290
+ onClick={(e) => { if (!selectRow?.(path, e, order)) onPickFolder(path) }}
291
+ onDoubleClick={() => onOpenFolder(path)}
230
292
  />
231
293
  {open && <RowSubtree
232
294
  level={path} depth={depth + 1} rowLevel={rowLevel} expanded={expanded} onToggle={onToggle}
233
- onOpenFolder={onOpenFolder} onOpenFile={onOpenFile} onRowContextMenu={onRowContextMenu} dragFor={dragFor}
234
- formatDate={formatDate} folderMeta={folderMeta} thumbnailFor={thumbnailFor} flat={flat}
295
+ onOpenFolder={onOpenFolder} onPickFolder={onPickFolder} onOpenFile={onOpenFile} onPickFile={onPickFile} onRowContextMenu={onRowContextMenu} dragFor={dragFor} dropFilesTo={dropFilesTo} pickedKey={pickedKey} currentKey={currentKey} cols={cols}
296
+ selectRow={selectRow} order={order} selectedKeys={selectedKeys}
297
+ formatDate={formatDate} folderMeta={folderMeta} thumbnailFor={thumbnailFor}
235
298
  />}
236
299
  </Fragment>
237
300
  )
238
301
  })}
239
302
  {files.map((o) => (
240
- <FileRow key={o.key} o={o} depth={depth} formatDate={formatDate} thumb={thumbnailFor?.(o)}
241
- onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
303
+ <FileRow key={o.key} o={o} depth={depth} formatDate={formatDate} thumb={thumbnailFor?.(o)} cols={cols}
304
+ selected={pickedKey === o.key || !!selectedKeys?.has(o.key)}
305
+ onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o, targets: selectedKeys?.has(o.key) ? [...selectedKeys] : [o.key] })}
242
306
  drag={dragFor?.(o.key)}
243
- onClick={() => onOpenFile(o)} />
307
+ onClick={(e) => { if (!selectRow?.(o.key, e, order)) onPickFile(o) }}
308
+ onDoubleClick={() => onOpenFile(o)} />
244
309
  ))}
245
310
  </>
246
311
  )
@@ -249,24 +314,130 @@ function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder,
249
314
  /* The file half of the row view. Row view listed FOLDERS ONLY and dropped `files` on the floor, so
250
315
  * `#img/` drew two rows where the column browser drew four — the two folders, the empty folder the
251
316
  * columns knew about, and a file. A list view that cannot show a file is not a list view. */
252
- function FileRow({ o, onClick, depth = 0, formatDate, thumb, onContextMenu, drag, selected }) {
317
+ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols, onContextMenu, drag, selected, markKey }) {
253
318
  const dragProps = drag ? { draggable: true, onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, o.key) } } : {}
254
319
  return (
255
- <li onContextMenu={onContextMenu} {...dragProps} className={`flex items-center gap-3 py-2 border-b cursor-pointer transition-colors px-1 rounded ${selected ? 'bg-fg-04' : 'hover:bg-fg-04'}`} style={{ borderColor: 'var(--kol-fg-08)', paddingLeft: depth * 20 + 4 }} onClick={onClick}>
320
+ <li onContextMenu={onContextMenu} data-marquee-key={markKey ?? o.key} data-hit-zone className={`kol-column-browser-row flex items-center gap-3 py-2 px-3 transition-colors${selected ? ' is-selected' : ''}`} style={{ paddingLeft: depth * 20 + 12 }} onClick={onClick} onDoubleClick={onDoubleClick}>
256
321
  <span className="w-4 shrink-0" />
257
- <div className="w-8 h-8 shrink-0 flex items-center justify-center text-fg-48 overflow-hidden rounded">
258
- {thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={18} />}
259
- </div>
260
- <span className="kol-mono-12 flex-1 min-w-0 truncate text-fg-default">{o.displayKey ?? o.key.split('/').pop()}</span>
261
- <span className="kol-mono-12 text-fg-32 shrink-0">{formatDate?.(o.uploaded)}</span>
262
- <span className="kol-mono-12 text-fg-32 shrink-0 w-20 text-right">{formatSize(o.size)}</span>
322
+ {/* the hit — icon and name, as in the folder row above */}
323
+ <span data-hit {...dragProps} className="kol-row-hit flex items-center gap-3 min-w-0">
324
+ <span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48 overflow-hidden rounded">
325
+ {thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={18} />}
326
+ </span>
327
+ <span className="kol-mono-12 min-w-0 truncate text-fg-default">{o.displayKey ?? o.key.split('/').pop()}</span>
328
+ </span>
329
+ <span className="flex-1" />
330
+ {cols && <>
331
+ <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
332
+ <span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
333
+ </>}
263
334
  </li>
264
335
  )
265
336
  }
266
337
 
338
+ /* A FOLDER HAS A PREVIEW TOO (user 2026-09-22, against Finder: selecting a folder fills the pane).
339
+ * The columns never needed one — stepping into a folder IS its preview there — so this is the row
340
+ * view's, and it is the page's rather than ColumnBrowser's because the counts come off the same
341
+ * `objects` the page already holds. Same shell as the file preview, so the pane never shifts. */
342
+ function FolderPreview({ path, objects, width, formatDate }) {
343
+ const inside = objects.filter((o) => o.key.startsWith(path))
344
+ const bytes = inside.reduce((n, o) => n + (o.size ?? 0), 0)
345
+ const last = inside.reduce((t, o) => (o.uploaded && o.uploaded > t ? o.uploaded : t), '')
346
+ const facts = [
347
+ ['Kind', 'folder'],
348
+ ['Items', `${inside.length}`],
349
+ ['Size', formatSize(bytes)],
350
+ ...(last ? [['Newest', formatDate(last) || '—']] : []),
351
+ ]
352
+ return (
353
+ <div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
354
+ <div className="w-full aspect-square bg-oq-04 rounded flex items-center justify-center">
355
+ <Icon name="folder" size={64} className="text-oq-48" />
356
+ </div>
357
+ <p className="kol-mono-12 text-fg-default break-all">{path.replace(/\/$/, '').split('/').pop()}</p>
358
+ <dl className="flex flex-col gap-1">
359
+ {facts.map(([k, v]) => (
360
+ <div key={k} className="flex justify-between gap-4 kol-mono-12">
361
+ <dt className="text-fg-48">{k}</dt>
362
+ <dd className="text-fg-default text-right break-all">{v}</dd>
363
+ </div>
364
+ ))}
365
+ </dl>
366
+ </div>
367
+ )
368
+ }
369
+
370
+ /* THE COLUMN HEADER — Name · Date · Size, each one the sort control for its own column (user
371
+ * 2026-09-22). Unlabelled meta at the end of a row is a number nobody can read a column of; a
372
+ * header names them AND gives them the one behaviour a labelled column implies. Clicking the
373
+ * active column flips the direction, which is what every file manager does. It writes the SAME
374
+ * `sortBy`/`sortDir` the files wall's own SortControls write, so the two views cannot disagree.
375
+ *
376
+ * FINDER'S SHAPE, on the user's read of it (2026-09-22): the header sits ABOVE the pane rather
377
+ * than inside it — it labels the list, it is not a row of it — the labels are sentence case, and a
378
+ * hairline divides each column from the one before it. `PAD` is the rows' own inset (the pane's
379
+ * 1px border + the row pill's 4px margin + the row's 12px padding), so a label lands over the
380
+ * column it names; it is derived here rather than typed, because those three numbers are the ones
381
+ * that move. */
382
+ const ROW_PAD = 1 + 4 + 12
383
+
384
+ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onResize }) {
385
+ const label = (key, text) => {
386
+ const active = sortBy === key
387
+ return (
388
+ <button type="button" onClick={() => onSort(key, active && sortDir === 'asc' ? 'desc' : 'asc')}
389
+ aria-sort={active ? (sortDir === 'desc' ? 'descending' : 'ascending') : 'none'}
390
+ className={`kol-helper-12 cursor-pointer inline-flex items-center gap-1 w-full transition-colors ${active ? 'text-fg-default' : 'text-fg-48 hover:text-fg-default'}`}>
391
+ {text}
392
+ {active && <Icon name={sortDir === 'desc' ? 'arrow-up' : 'arrow-down'} size={9} />}
393
+ </button>
394
+ )
395
+ }
396
+ /* EACH DIVIDER RESIZES THE COLUMN TO ITS RIGHT — those are the fixed ones; Name takes whatever is
397
+ * left, so there is nothing to drag on its side. Pointer capture, no library, same gesture the
398
+ * column browser's own edges use. */
399
+ const handle = (key) => (
400
+ <span
401
+ role="separator" aria-orientation="vertical" aria-label={`Resize ${key} column`}
402
+ className="cursor-col-resize"
403
+ /* inline, not utilities: it must be OUT OF FLOW or it pushes the label 8px off the column
404
+ it names — which is exactly what it did */
405
+ style={{ position: 'absolute', top: 0, bottom: 0, left: -4, width: 8 }}
406
+ onPointerDown={(e) => {
407
+ e.preventDefault()
408
+ e.currentTarget.setPointerCapture(e.pointerId)
409
+ const startX = e.clientX
410
+ const base = cols[key]
411
+ const move = (ev) => onResize(key, Math.max(ROW_COL_MIN, Math.min(ROW_COL_MAX, Math.round(base - (ev.clientX - startX)))))
412
+ const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up) }
413
+ window.addEventListener('pointermove', move)
414
+ window.addEventListener('pointerup', up)
415
+ }}
416
+ />
417
+ )
418
+ const column = (key, text) => (
419
+ /* no left padding: the label shares its column's left edge with the values under it */
420
+ <div key={key} className="relative shrink-0 border-l" style={{ width: cols[key], borderColor: 'var(--kol-oq-08)' }}>
421
+ {handle(key)}
422
+ {label(key, text)}
423
+ </div>
424
+ )
425
+ return (
426
+ /* the right padding carries TWO extra pixels of borders — the pane's own and the cell's divider
427
+ — which is what a label sitting two pixels right of its column looks like */
428
+ <div className="flex items-center gap-3 shrink-0" style={{ width, paddingLeft: ROW_PAD, paddingRight: ROW_PAD + 2 }}>
429
+ <span className="w-4 shrink-0" aria-hidden="true" />
430
+ <span className="w-8 shrink-0" aria-hidden="true" />
431
+ <span className="flex-1 min-w-0">{label('name', 'Name')}</span>
432
+ {column('date', 'Date')}
433
+ {column('size', 'Size')}
434
+ </div>
435
+ )
436
+ }
437
+
267
438
  /**
268
439
  * MediaInspector — the full-screen viewer for one file in a set: image, video
269
- * (scrubbable, audio on), audio, or a document page, with the facts line and
440
+ * (scrubbable, audio on), audio, or a document page, in one `QuickLookFrame`, with
270
441
  * prev / next fixed at the viewport edges.
271
442
  *
272
443
  * PUBLIC SINCE 2026-09-04 (user ruling, off `ColumnBrowserMobileViews`: the
@@ -288,10 +459,14 @@ function FileRow({ o, onClick, depth = 0, formatDate, thumb, onContextMenu, drag
288
459
  * @param {Function} mediaUrl - `(key) => url` — the consumer's media resolver
289
460
  * @param {Function} downloadUrl - `(key) => url` for the download control
290
461
  * @param {Set|Object} keySet - The bucket's key set, used to find a video's poster
462
+ * @param {boolean} showNav - ‹ › and n / N in the header (default on); off when not paging a selection
291
463
  */
292
- export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet }) {
464
+ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true }) {
293
465
  const o = files[index]
294
466
  const [dims, setDims] = useState(null)
467
+ /* the window's size survives paging — Finder keeps it — so it lives here, not in the frame,
468
+ * which remounts whenever the kind changes */
469
+ const [size, setSize] = useState(null)
295
470
  const shownDims = dims && dims.key === o?.key ? dims : null
296
471
  useEffect(() => {
297
472
  const onKey = (e) => { if (e.key === 'ArrowLeft') onPrev(); if (e.key === 'ArrowRight') onNext() }
@@ -301,30 +476,53 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
301
476
  if (!o) return null
302
477
  const kind = kindOf(o)
303
478
  const poster = kind === 'video' ? posterFor(o.key, keySet) : null
304
- const ARROW = 'fixed top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded text-fg-48 hover:text-fg-default hover:bg-fg-ab-24 transition-colors'
479
+ /* ONE WINDOW FOR EVERY KIND (user 2026-09-23: *"put them in a container LIKE FINDER"*). The
480
+ * name, the facts and the download ride the window's header; the transport, when there is one,
481
+ * its footer. They used to be a caption line under whatever the kind drew. */
482
+ const facts = [
483
+ formatSize(o.size),
484
+ shownDims?.w && `${shownDims.w} × ${shownDims.h} px`,
485
+ shownDims?.len && formatLength(shownDims.len),
486
+ ].filter(Boolean).join(' · ')
487
+ const frame = {
488
+ title: o.displayKey ?? o.key.split('/').pop(),
489
+ meta: facts,
490
+ onClose,
491
+ /* ‹ › and n / N only when the window pages a SELECTION (user 2026-09-23: *"I only selected one
492
+ * file, why am I getting the chevrons"*). ← → still step, as Finder's do; the header does not
493
+ * advertise a set you did not make. `showNav` — the page passes whether several are selected. */
494
+ nav: showNav ? { index, total: files.length, onPrev, onNext } : undefined,
495
+ size,
496
+ onResize: setSize,
497
+ /* THE TWO READ VERBS (user 2026-09-23): Copy URL beside Download — the window is the one place
498
+ * besides the right-click menu that carries them */
499
+ /* THE CLOSE BUTTON'S IDIOM (user 2026-09-23): a filled container read as a second surface on
500
+ * the header; bare at rest with a wash on hover, `sm` so the glyphs sit with the 12px text. */
501
+ actions: (
502
+ <span className="flex items-center gap-1">
503
+ <CopyAction label="Copy URL" text={mediaUrl(o.key)} />
504
+ <Tooltip label="Download">
505
+ <Button variant="nav" size="sm" iconOnly="download" href={downloadUrl(o.key)} download={o.key.split('/').pop()} aria-label="Download" />
506
+ </Tooltip>
507
+ </span>
508
+ ),
509
+ }
305
510
  return (
306
- <FullscreenOverlay open onClose={onClose} closeButton={false}>
307
- <button type="button" className={`${ARROW} left-6`} onClick={onPrev} disabled={files.length <= 1} aria-label="Previous"><Icon name="chevron-left" size={22} /></button>
308
- <div className="max-w-[calc(100vw-10rem)] max-h-[85vh] flex flex-col items-center gap-3">
309
- {isImage(o.contentType) ? (
310
- <img src={mediaUrl(o.key)} alt={o.displayKey} className="max-w-full max-h-[78vh] object-contain rounded" onLoad={(e) => setDims({ key: o.key, w: e.target.naturalWidth, h: e.target.naturalHeight })} />
311
- ) : isVideo(o.contentType) ? (
312
- <VideoSheet src={mediaUrl(o.key)} poster={poster ? mediaUrl(poster) : undefined} onMeta={(m) => setDims({ key: o.key, ...m })} />
313
- ) : kind === 'audio' ? (
314
- <AudioSheet src={mediaUrl(o.key)} onDuration={(len) => setDims({ key: o.key, len })} />
315
- ) : (
316
- <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} />
317
- )}
318
- <div className="flex items-center gap-4">
319
- <span className="kol-mono-12 text-fg-48">{o.displayKey ?? o.key}</span>
320
- <span className="kol-mono-12 text-fg-32">{formatSize(o.size)}</span>
321
- {shownDims?.w && <span className="kol-mono-12 text-fg-32">{shownDims.w} × {shownDims.h} px</span>}
322
- {shownDims?.len && <span className="kol-mono-12 text-fg-32">{formatLength(shownDims.len)}</span>}
323
- <ActionButton chrome="inline" size="sm" icon="download" confirmIcon="check" label="Download" confirmLabel="Downloaded" href={downloadUrl(o.key)} />
324
- </div>
325
- <span className="kol-mono-10 text-fg-24">{index + 1} / {files.length}</span>
326
- </div>
327
- <button type="button" className={`${ARROW} right-6`} onClick={onNext} disabled={files.length <= 1} aria-label="Next"><Icon name="chevron-right" size={22} /></button>
511
+ /* QUICK LOOK DIMS, it does not black out — you keep the list you came from in view */
512
+ <FullscreenOverlay open onClose={onClose} closeButton={false} scrim>
513
+ {isImage(o.contentType) ? (
514
+ <QuickLookFrame {...frame}>
515
+ <img src={mediaUrl(o.key)} alt={o.displayKey} className="kol-quicklook-media" onLoad={(e) => setDims({ key: o.key, w: e.target.naturalWidth, h: e.target.naturalHeight })} />
516
+ </QuickLookFrame>
517
+ ) : isVideo(o.contentType) ? (
518
+ <VideoSheet key={o.key} src={mediaUrl(o.key)} poster={poster ? mediaUrl(poster) : undefined} onMeta={(m) => setDims({ key: o.key, ...m })} frame={frame} />
519
+ ) : kind === 'audio' ? (
520
+ <AudioSheet key={o.key} src={mediaUrl(o.key)} ext={extOf(o.key)} onDuration={(len) => setDims({ key: o.key, len })} frame={frame} />
521
+ ) : (
522
+ <QuickLookFrame {...frame}>
523
+ <KindPreview o={o} fit="sheet" urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} />
524
+ </QuickLookFrame>
525
+ )}
328
526
  </FullscreenOverlay>
329
527
  )
330
528
  }
@@ -338,11 +536,19 @@ function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profi
338
536
  const noVariants = profile.variantSets === 0
339
537
  const noSegments = profile.segments === 0
340
538
  return (
341
- <SettingsPanel variant="drawer" title="Display settings" onClose={onClose} footer={<SettingsFooter onReset={onReset}>{settingsFooter}</SettingsFooter>}>
539
+ <SettingsPanel variant="drawer" title="Display settings" onClose={onClose} footer={<SettingsFooter onReset={onReset} resetLabel="Reset preferences">{settingsFooter}</SettingsFooter>}>
342
540
  <LabeledControlSection label="Structure" rowGap={1} divided>
343
541
  <SettingsRow label="Columns" hint="Finder-style columns instead of folder rows">
344
542
  <SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} />
345
543
  </SettingsRow>
544
+ <SettingsRow label="Preview" hint="the picked file beside the rows">
545
+ <SettingsSwitch label="Preview" on={settings.rowPreview ?? true} onChange={(v) => set({ rowPreview: v })} />
546
+ </SettingsRow>
547
+ {/* NOT "Columns" — that switch three rows up means Finder's column browser. These are the
548
+ row view's date/size FIELDS, which is what the user calls them. */}
549
+ <SettingsRow label="Fields" hint="date and size beside each row, with a sort header">
550
+ <SettingsSwitch label="Fields" on={!!settings.rowColumns} onChange={(v) => set({ rowColumns: v })} />
551
+ </SettingsRow>
346
552
  <SettingsRow label="Flat" hint="ignore folders, show the whole subtree">
347
553
  <SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} />
348
554
  </SettingsRow>
@@ -398,8 +604,119 @@ function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profi
398
604
  * and pushed the gear off the right edge — they resolved it by SWAPPING a
399
605
  * control below `md` rather than adding one, which is the pattern to copy. If a
400
606
  * consumer needs more than one at this width, it belongs in a sheet, not here. */
401
- function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings, onHome, headerTrailing }) {
402
- const options = buckets.length ? [{ value: 'all', label: `${title} · all` }, ...buckets.map((b) => ({ value: b.id, label: b.label }))] : []
607
+ /* A copy control in the header's idiom (the close button's: bare, a wash on hover); the tooltip says it worked. */
608
+ function CopyAction({ label, text }) {
609
+ const [copied, setCopied] = useState(false)
610
+ useEffect(() => {
611
+ if (!copied) return undefined
612
+ const t = setTimeout(() => setCopied(false), 1600)
613
+ return () => clearTimeout(t)
614
+ }, [copied])
615
+ return (
616
+ <Tooltip label={copied ? 'Copied' : label}>
617
+ <Button variant="nav" size="sm" iconOnly={copied ? 'check' : 'copy'} aria-label={label}
618
+ onClick={() => { navigator.clipboard?.writeText(text); setCopied(true) }} />
619
+ </Tooltip>
620
+ )
621
+ }
622
+
623
+ /* THE CURRENT CRUMB COPIES THE PATH (user 2026-09-23: *"could we make a hover click to copy path?
624
+ * … just click and it copies to clipboard, maybe popover tooltip appears 'path copied'"*). It was
625
+ * a button navigating to where you already were; now it is the one crumb that does something new.
626
+ * The other crumbs still navigate. */
627
+ function CopyCrumb({ label, path, className }) {
628
+ const [copied, setCopied] = useState(false)
629
+ useEffect(() => {
630
+ if (!copied) return undefined
631
+ const t = setTimeout(() => setCopied(false), 1600)
632
+ return () => clearTimeout(t)
633
+ }, [copied])
634
+ return (
635
+ <Tooltip label={copied ? 'Path copied' : 'Copy path'}>
636
+ <button type="button" className={className} onClick={() => { navigator.clipboard?.writeText(path); setCopied(true) }}>{label}</button>
637
+ </Tooltip>
638
+ )
639
+ }
640
+
641
+ /* THE TRASH — what delete moved aside, newest first. BUILT FROM PARTS THAT EXIST (user 2026-09-23:
642
+ * *"follow an example, dont reinvent the wheel"*): the settings drawer's title, a
643
+ * list in the column browser's own rows, and `SettingsFooter` for Empty trash.
644
+ *
645
+ * NO BUTTONS ON THE ROWS (user 2026-09-23: *"remove both x and restore and use context menu for
646
+ * those items, as single or multiple selection"*). The items select the way files do — click, ⌘ to
647
+ * add, ⇧ for a run — and right-click offers Restore and Delete forever for the row, or for the
648
+ * whole selection when the row is in it. Delete forever asks first: it is the one verb here that
649
+ * cannot be taken back. */
650
+ function TrashPanel({ trash, onClose, run, confirm }) {
651
+ const items = trash.items ?? []
652
+ const [sel, setSel] = useState(() => new Set())
653
+ const anchor = useRef(null)
654
+ const menu = useContextMenu()
655
+ const pick = (e, id) => {
656
+ const ids = items.map((t) => t.id)
657
+ if (e.shiftKey && anchor.current) {
658
+ const a = ids.indexOf(anchor.current)
659
+ const b = ids.indexOf(id)
660
+ setSel(new Set(ids.slice(Math.min(a, b), Math.max(a, b) + 1)))
661
+ return
662
+ }
663
+ anchor.current = id
664
+ if (e.metaKey || e.ctrlKey) setSel((prev) => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
665
+ else setSel(new Set([id]))
666
+ }
667
+ const targetsFor = (id) => (sel.has(id) ? [...sel] : [id])
668
+ const restore = (ids) => run(async () => { for (const id of ids) await trash.restore(id); setSel(new Set()) })
669
+ const purge = async (ids) => {
670
+ const n = ids.length
671
+ if (confirm && !(await confirm(`Delete ${n === 1 ? 'this item' : `${n} items`} forever? This cannot be undone.`, { okLabel: 'Delete forever' }))) return
672
+ run(async () => { for (const id of ids) await trash.purge(id); setSel(new Set()) })
673
+ }
674
+ return (
675
+ <SettingsPanel variant="drawer" title="Trash" onClose={onClose}
676
+ footer={items.length > 0 && trash.empty ? (
677
+ <SettingsFooter>
678
+ <Button variant="primary" size="sm" onClick={async () => {
679
+ if (confirm && !(await confirm(`Empty the trash? ${items.length} item${items.length === 1 ? '' : 's'} will be gone for good.`, { okLabel: 'Empty trash' }))) return
680
+ run(async () => { await trash.empty(); setSel(new Set()) })
681
+ }}>Empty trash</Button>
682
+ </SettingsFooter>
683
+ ) : undefined}>
684
+ {/* THE COLUMN BROWSER'S ROWS, as they are (user 2026-09-23: *"just USE WHAT COLUMN browser
685
+ * uses. its literally a solved problem"*) — icon, name as spelled, the same selection fill. No
686
+ * heading, no count: it is the trash, and the files are the content. */}
687
+ <ul className="kol-column-browser-column flex flex-col">
688
+ {items.map((t) => {
689
+ const name = t.path.replace(/\/$/, '').split('/').pop()
690
+ const icon = t.isFolder ? 'folder' : ({ image: 'image', video: 'video' })[kindOf({ key: t.path })] ?? 'file'
691
+ return (
692
+ <li key={t.id} className={`kol-column-browser-row flex items-center gap-3 py-2 px-3 cursor-pointer transition-colors${sel.has(t.id) ? ' is-selected' : ''}`}
693
+ onClick={(e) => pick(e, t.id)}
694
+ onContextMenu={(e) => { if (!sel.has(t.id)) { anchor.current = t.id; setSel(new Set([t.id])) } menu.openAt(e, { ids: targetsFor(t.id) }) }}>
695
+ <span className="w-5 shrink-0 flex items-center justify-center text-oq-48"><Icon name={icon} size={icon === 'folder' ? 18 : 14} /></span>
696
+ <span className="kol-mono-12 min-w-0 truncate">{name}</span>
697
+ </li>
698
+ )
699
+ })}
700
+ </ul>
701
+ <ContextMenu menu={menu}>
702
+ {(target) => {
703
+ if (!target) return null
704
+ const n = target.ids.length
705
+ return (
706
+ <>
707
+ {trash.restore && <MenuDropdownItem iconLeft={<Icon name="refresh" size={14} />} onClick={() => restore(target.ids)}>{n > 1 ? `Restore ${n}` : 'Restore'}</MenuDropdownItem>}
708
+ {trash.purge && <MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => purge(target.ids)}>{n > 1 ? `Delete ${n} forever` : 'Delete forever'}</MenuDropdownItem>}
709
+ </>
710
+ )
711
+ }}
712
+ </ContextMenu>
713
+ </SettingsPanel>
714
+ )
715
+ }
716
+
717
+ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings, onTrash, onHome, headerTrailing }) {
718
+ /* ONE BUCKET, NO DROPDOWN (user 2026-09-22) — a picker with one thing in it chooses nothing. */
719
+ const options = buckets.length > 1 ? [{ value: 'all', label: `${title} · all` }, ...buckets.map((b) => ({ value: b.id, label: b.label }))] : []
403
720
  /* THE WORDMARK IS THE HOME BUTTON when `onHome` is given (user 2026-09-21: "it should reload,
404
721
  * like a home button"). The crumb row's root already did exactly this and the wordmark above it
405
722
  * did nothing — the one element every file manager makes clickable was the one that was inert.
@@ -424,6 +741,13 @@ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta
424
741
  <IconFrame name="lock" variant="primary" size="sm" aria-label="Read-only" />
425
742
  </Tooltip>
426
743
  )}
744
+ {/* THE TRASH (user 2026-09-23) — the failsafe for every delete. Search moved back to the crumb
745
+ row, into the well beside the view switch. */}
746
+ {onTrash && (
747
+ <Tooltip label="Trash">
748
+ <IconFrame name="trash" variant="primary" size="sm" onClick={onTrash} aria-label="Trash" />
749
+ </Tooltip>
750
+ )}
427
751
  <Tooltip label="Display settings">
428
752
  <IconFrame name="settings-01" variant="primary" size="sm" onClick={onSettings} aria-label="Display settings" />
429
753
  </Tooltip>
@@ -473,6 +797,10 @@ function sortObjects(objects, sortBy = 'name', sortDir = 'asc') {
473
797
 
474
798
  export function MediaLibraryBrowse({
475
799
  client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
800
+ /* THE ONE VIEW (2026-09-22): 'columns' | 'rows' | 'grid' | 'list'. Controlled or from settings;
801
+ * `folderView` and `layout` are kept in step beneath it so a consumer still on those keeps
802
+ * rendering what it rendered. */
803
+ view: viewProp, onViewChange,
476
804
  folderTree, headerActions, headerTrailing, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '', banner,
477
805
  /* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
478
806
  * optional and each async; whatever is supplied becomes a right-click menu entry and
@@ -480,8 +808,25 @@ export function MediaLibraryBrowse({
480
808
  * arrive together — a consumer with a writable store has all of them, one with a read-only
481
809
  * bucket has none — and because the page must never have to know WHICH verbs exist to
482
810
  * decide whether to draw a menu at all. The consumer re-lists after a mutation by bumping
483
- * `refreshKey`; the page does not own the data. */
811
+ * `refreshKey`; the page does not own the data.
812
+ *
813
+ * `fileActions.items` — THE CONSUMER'S OWN VERBS (kol-client-olina 2026-09-22: Duplicate was
814
+ * a DS publish away, and so would every verb after it be). `[{ label, icon?, when?, run }]`;
815
+ * each gets the menu's payload `{ type, path, o?, targets? }`, `when` filters per target
816
+ * (absent = always), and `run` goes through the same busy/error path as the built-ins. They
817
+ * render AFTER the built-in verbs. Without `items` the menu is exactly what it was. */
484
818
  fileActions,
819
+ /* `onDropFiles(files, folderPath)` — desktop files dropped on a folder row or a level
820
+ * (kol-client-olina 2026-09-22). The page hands over the `FileList` and the bucket-relative
821
+ * folder and NEVER uploads: every consumer's pipeline differs. The consumer re-lists through
822
+ * `refreshKey`, the same contract as `fileActions`. Writable buckets only; absent, nothing. */
823
+ onDropFiles,
824
+ /* `trash` — THE FAILSAFE (user 2026-09-23). `{ items, restore(id), purge(id), empty() }`, the
825
+ * consumer's: `items` is `[{ id, path, deletedAt, isFolder?, count?, size?, expiresAt? }]`, and
826
+ * `fileActions.remove` is expected to move into it. Given, delete stops asking "this cannot be
827
+ * undone" — it can — and a trash button opens the list with Restore and Delete forever. The
828
+ * consumer re-lists through `refreshKey`. Absent, delete is what it was. */
829
+ trash,
485
830
  /* PASS-THROUGHS TO `ColumnBrowser`. A documented prop this page does not
486
831
  * forward is a prop no consumer of the PAGE can reach, which makes the seam
487
832
  * fictional — `thumbnailFor` and `folderMeta` shipped, were announced, and
@@ -499,6 +844,9 @@ export function MediaLibraryBrowse({
499
844
  * the consumer's — a repo whose surfaces are routes wires its router here.
500
845
  * No tabs, no pill, and the page is exactly what it was. */
501
846
  tabs, activeTab, onTabChange,
847
+ /* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
848
+ * Absent, a one-bucket consumer collapses the level as ruled 2026-09-03. */
849
+ bucketLevel = false,
502
850
  }) {
503
851
  const [ownPrefix, setOwnPrefix] = useState('')
504
852
  const prefix = prefixProp ?? ownPrefix
@@ -510,50 +858,122 @@ export function MediaLibraryBrowse({
510
858
  const { buckets, bucketMeta, settings, setSettings, objects, error, mediaUrl, downloadUrl, writable } = lib
511
859
  const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
512
860
  const [settingsOpen, setSettingsOpen] = useState(false)
861
+ const [trashOpen, setTrashOpen] = useState(false)
513
862
  const [pickedFile, setPickedFile] = useState(null)
863
+ /* ROW VIEW PICKS, and only one at a time: a click selects and fills the preview, a DOUBLE click
864
+ * opens (user 2026-09-22, Finder's behaviour — "clicking shouldnt automatically open it"). The
865
+ * chevron still expands in place, which is the third thing and always was. */
866
+ const [pickedFolder, setPickedFolder] = useState(null)
867
+ const pickFile = (o) => { setPickedFolder(null); setPickedFile(o) }
868
+ const pickFolder = (path) => { setPickedFile(null); setPickedFolder(path) }
869
+ /* IN THE ROWS, WHAT YOU PICK SETS WHERE YOU ARE (user 2026-09-23: *"that files folder becomes the
870
+ * default NOT the previous folder"*). The list is rooted at the top, so a pick can sit in any open
871
+ * folder; the current folder follows it to the pick's parent. Whatever the old path had open
872
+ * STAYS open — moving where you are must not fold up what you were looking at. */
873
+ const dirOf = (path) => { const t = path.replace(/\/$/, ''); return t.slice(0, t.lastIndexOf('/') + 1) }
874
+ const moveHereKeepingOpen = (to) => {
875
+ if (to === prefix) return
876
+ setExpandedRows((prev) => new Set([...prev, ...openChain(prefix)]))
877
+ setPrefix(to)
878
+ }
879
+ const rowPickFile = (o) => { pickFile(o); moveHereKeepingOpen(dirOf(o.key)) }
880
+ const rowPickFolder = (path) => { pickFolder(path); moveHereKeepingOpen(dirOf(path)) }
514
881
  /* Row view's inline disclosure set — folder paths currently expanded. Kept here rather than in
515
882
  * settings: it is a transient reading state, not a preference, and Finder does not persist it
516
883
  * across a relaunch either. */
517
884
  const [expandedRows, setExpandedRows] = useState(() => new Set())
885
+ /* THE ROW LIST IS ALWAYS BASED AT THE ROOT (user 2026-09-23: *"row view should not open a path
886
+ * of the folder its highlighted … rather expand the folders to that location, still showing the
887
+ * root"*). It used to re-base on `prefix`, so arriving from the columns three folders deep threw
888
+ * the tree away and showed one level. The path now seeds the EXPANSION instead: every ancestor
889
+ * opens, the folder you are in reads selected, and the root is still the first row.
890
+ *
891
+ * Collapsing a folder ON that path is how you leave it — the chevron cannot fight `prefix` and
892
+ * win, so it moves `prefix` up instead of flickering back open. */
893
+ const openChain = (p) => {
894
+ const out = []
895
+ let cur = ''
896
+ for (const seg of p.replace(/\/$/, '').split('/').filter(Boolean)) { cur += `${seg}/`; out.push(cur) }
897
+ return out
898
+ }
899
+ const rowsExpanded = new Set([...expandedRows, ...openChain(prefix)])
900
+ /* THE VISIBLE ORDER, flattened in render order, so ⇧-click ranges across expanded folders and
901
+ * not just the level you happen to be on. Built from the same `rowLevel` + expansion the list
902
+ * draws from, so it cannot disagree with what is on screen. */
903
+ const visibleRowOrder = (list) => {
904
+ const out = []
905
+ const walk = (level) => {
906
+ const { folders, files } = rowLevel(level, list)
907
+ for (const f of folders) {
908
+ const path = level + f
909
+ out.push(path)
910
+ if (rowsExpanded.has(path)) walk(path)
911
+ }
912
+ for (const o of files) out.push(o.key)
913
+ }
914
+ walk('')
915
+ return out
916
+ }
917
+ const toggleRow = (path) => {
918
+ if (openChain(prefix).includes(path)) { setPrefix(path.replace(/[^/]+\/$/, '')); return }
919
+ setExpandedRows((prev) => {
920
+ const next = new Set(prev)
921
+ next.has(path) ? next.delete(path) : next.add(path)
922
+ return next
923
+ })
924
+ }
518
925
 
519
926
  /* ── THE FILE VERBS ────────────────────────────────────────────────────────────────────────
520
927
  * Right-click is the surface; `fileActions` is the implementation. The page owns neither the
521
928
  * data nor the store — it collects an intent, asks for the one piece of text it needs, calls
522
- * the consumer's verb and lets the consumer re-list. `prompt`/`confirm` are deliberate: this
523
- * file already uses both for batch move and delete, and inventing a dialog here would be a
524
- * second modal idiom in one component. */
929
+ * the consumer's verb and lets the consumer re-list. The asking goes through `useModal()` —
930
+ * the DS's one dialog idiom — so a consumer that mounts `ModalProvider` gets DS dialogs and one
931
+ * that does not gets the native ones, exactly as before (kol-client-olina 2026-09-22: the
932
+ * browser's own grey prompt box was the last non-DS surface on media.olina-productions.com). */
525
933
  const menu = useContextMenu()
934
+ const modal = useModal()
526
935
  const canWrite = !!fileActions && writable
527
936
  const [busyAction, setBusyAction] = useState(false)
528
937
 
529
938
  const runAction = async (fn) => {
530
939
  if (!fn) return
531
940
  setBusyAction(true)
532
- try { await fn() } catch (e) { alert(e.message) } finally { setBusyAction(false) }
941
+ try { await fn() } catch (e) { await modal.alert(e.message) } finally { setBusyAction(false) }
533
942
  }
534
943
 
535
- const doCreateFolder = (parent) => {
536
- const name = prompt(`New folder in ${parent || 'the bucket root'}:`)
944
+ const doCreateFolder = async (parent) => {
945
+ const name = await modal.prompt(`New folder in ${parent || 'the bucket root'}:`, '', { okLabel: 'Create' })
537
946
  if (!name?.trim()) return
538
947
  runAction(() => fileActions.createFolder(`${parent}${name.trim().replace(/^\/+|\/+$/g, '')}/`))
539
948
  }
540
- const doCreateFile = (parent) => {
541
- const name = prompt(`New file in ${parent || 'the bucket root'}:`, 'untitled.txt')
949
+ const doCreateFile = async (parent) => {
950
+ const name = await modal.prompt(`New file in ${parent || 'the bucket root'}:`, 'untitled.txt', { okLabel: 'Create' })
542
951
  if (!name?.trim()) return
543
952
  runAction(() => fileActions.createFile(`${parent}${name.trim().replace(/^\/+/, '')}`))
544
953
  }
545
- const doRename = (path, isFolder) => {
954
+ const doRename = async (path, isFolder) => {
546
955
  const current = path.replace(/\/$/, '').split('/').pop()
547
- const name = prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current)
956
+ const name = await modal.prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current, { okLabel: 'Rename' })
548
957
  if (!name?.trim() || name === current) return
549
958
  const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
550
959
  runAction(() => fileActions.rename(path, `${parent}${name.trim()}${isFolder ? '/' : ''}`))
551
960
  }
552
- const doMove = (path) => {
553
- const dest = prompt(`Move into which folder? (blank = bucket root)`, prefix)
554
- if (dest === null) return
961
+ const doMove = async (path) => {
962
+ const dest = await modal.prompt(`Move into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
963
+ if (dest == null) return
555
964
  const clean = dest ? `${dest.replace(/^\/+|\/+$/g, '')}/` : ''
556
- runAction(() => fileActions.move(path, clean))
965
+ runAction(async () => { await fileActions.move(path, clean); afterMove([path], clean) })
966
+ }
967
+ /* A MOVE KEEPS WHAT YOU MOVED IN HAND (user 2026-09-23: *"they should remain selected and new
968
+ * location shown"*). The set is re-keyed to where the items now live, and the target comes into
969
+ * view: expanded in place in the rows, navigated into in the columns and the grid. */
970
+ const afterMove = (paths, dest) => {
971
+ const moved = paths.map((p) => `${dest}${p.replace(/\/$/, '').split('/').pop()}${p.endsWith('/') ? '/' : ''}`)
972
+ setPickedFile(null); setPickedFolder(null)
973
+ setRowSelection(new Set(moved))
974
+ selectAnchorRef.current = moved[0] ?? null
975
+ if (view === 'rows') setExpandedRows((prev) => new Set([...prev, ...openChain(dest)]))
976
+ else setPrefix(dest)
557
977
  }
558
978
  /* DRAG TO MOVE. HTML5 drag-and-drop, not a pointer-tracking implementation: the rows are a list,
559
979
  * the targets are folders, and the browser already does the hit-testing and the cursor. The
@@ -567,11 +987,23 @@ export function MediaLibraryBrowse({
567
987
  * tree you are still walking. The menu acts on the whole set when the clicked row is in it. */
568
988
  const [rowSelection, setRowSelection] = useState(() => new Set())
569
989
  const selectAnchorRef = useRef(null)
990
+ /* where the keyboard IS, apart from where a ⇧ run began — null means "at the anchor" */
991
+ const selectCursorRef = useRef(null)
992
+ /* DRAG A BAND ACROSS THE LIST (user 2026-09-22). The same set the ⌘/⇧ clicks build, so the menu
993
+ * acts on a dragged selection exactly as it does on a clicked one. */
994
+ const marqueeSelect = (keys, additive) => setRowSelection((prev) => (additive ? new Set([...prev, ...keys]) : new Set(keys)))
995
+ const rowsMarquee = useMarquee({ onSelect: marqueeSelect })
996
+ const gridMarquee = useMarquee({ onSelect: marqueeSelect })
570
997
 
571
998
  const selectRow = (path, e, orderedPaths) => {
999
+ selectCursorRef.current = null
572
1000
  const additive = e.metaKey || e.ctrlKey
573
1001
  const ranged = e.shiftKey && selectAnchorRef.current
574
- if (!additive && !ranged) { setRowSelection(new Set()); selectAnchorRef.current = path; return false }
1002
+ /* A PLAIN CLICK IS A SELECTION OF ONE (user 2026-09-23). It used to CLEAR the set and only
1003
+ * "pick" the row for the preview, so a row could look selected while the set was empty — and
1004
+ * a drag, which carries the set, then took one fewer item than the screen showed. The pick and
1005
+ * the selection are the same act; `false` still tells the caller to preview it. */
1006
+ if (!additive && !ranged) { setRowSelection(new Set([path])); selectAnchorRef.current = path; return false }
575
1007
  e.preventDefault()
576
1008
  setRowSelection((prev) => {
577
1009
  const next = new Set(prev)
@@ -593,13 +1025,13 @@ export function MediaLibraryBrowse({
593
1025
  * unrelated row from silently deleting five things. */
594
1026
  const targetsFor = (path) => (rowSelection.has(path) ? [...rowSelection] : [path])
595
1027
 
596
- const doBatch = (paths, verb, label) => {
1028
+ const doBatch = async (paths, verb, label) => {
597
1029
  if (paths.length === 1) return null
598
- if (label === 'Delete' && !confirm(`Delete ${paths.length} items? This cannot be undone.`)) return null
1030
+ if (label === 'Delete' && !trash && !(await modal.confirm(`Delete ${paths.length} items? This cannot be undone.`, { okLabel: 'Delete' }))) return null
599
1031
  let dest = null
600
1032
  if (label === 'Move') {
601
- dest = prompt(`Move ${paths.length} items into which folder? (blank = bucket root)`, prefix)
602
- if (dest === null) return null
1033
+ dest = await modal.prompt(`Move ${paths.length} items into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
1034
+ if (dest == null) return null
603
1035
  dest = dest ? `${dest.replace(/^\/+|\/+$/g, '')}/` : ''
604
1036
  }
605
1037
  runAction(async () => {
@@ -607,40 +1039,312 @@ export function MediaLibraryBrowse({
607
1039
  if (label === 'Delete') await fileActions.remove(p)
608
1040
  else await fileActions.move(p, dest)
609
1041
  }
610
- setRowSelection(new Set())
1042
+ if (label === 'Delete') setRowSelection(new Set())
1043
+ else afterMove(paths, dest)
611
1044
  })
612
1045
  void verb
613
1046
  return true
614
1047
  }
615
1048
 
1049
+ /* THE DRAG CARRIES THE SELECTION (user 2026-09-23: "if 3 are selected, drag and drop still only
1050
+ * grabs one"). Dragging a row that is IN the selection drags the whole set — which is what the
1051
+ * right-click menu already did, and the two must not disagree about what "the targets" means.
1052
+ * Dragging a row outside the selection is that row alone, as every file manager does. */
616
1053
  const draggingRef = useRef(null)
617
1054
  const dragFor = canWrite && fileActions.move ? (path) => ({
618
1055
  path,
619
- onDragStart: (e, from) => { draggingRef.current = from; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', from) },
1056
+ onDragStart: (e, from) => {
1057
+ const set = rowSelection.has(from) ? [...rowSelection] : [from]
1058
+ draggingRef.current = set
1059
+ e.dataTransfer.effectAllowed = 'move'
1060
+ e.dataTransfer.setData('text/plain', set.join('\n'))
1061
+ },
1062
+ /* every dragged path has to be legal, or the drop would half-happen */
620
1063
  canDrop: (dest) => {
621
- const from = draggingRef.current
622
- if (!from || from === dest) return false
623
- if (from.endsWith('/') && dest.startsWith(from)) return false // into itself
624
- const parent = from.replace(/\/$/, '').split('/').slice(0, -1).join('/')
625
- return dest !== (parent ? `${parent}/` : '') // already there
1064
+ const set = draggingRef.current
1065
+ if (!set?.length) return false
1066
+ /* ONLY A FOLDER TAKES A DROP. A file row is not a destination: moving `a.jpg` "into"
1067
+ * `b.json` renamed it to `b.jsona.jpg`-shaped keys (the file-onto-file bug, 2026-09-22). */
1068
+ if (dest && !dest.endsWith('/')) return false
1069
+ return set.every((from) => {
1070
+ if (from === dest) return false
1071
+ if (from.endsWith('/') && dest.startsWith(from)) return false // into itself
1072
+ const parent = from.replace(/\/$/, '').split('/').slice(0, -1).join('/')
1073
+ return dest !== (parent ? `${parent}/` : '') // already there
1074
+ })
626
1075
  },
627
1076
  onDrop: (dest) => {
628
- const from = draggingRef.current
1077
+ const set = draggingRef.current
629
1078
  draggingRef.current = null
630
- if (!from) return
631
- const name = from.replace(/\/$/, '').split('/').pop()
632
- runAction(() => fileActions.move(from, dest))
633
- void name
1079
+ if (!set?.length) return
1080
+ runAction(async () => {
1081
+ for (const from of set) await fileActions.move(from, dest)
1082
+ afterMove(set, dest)
1083
+ })
634
1084
  },
635
1085
  }) : null
636
1086
 
637
- const doDelete = (path, isFolder) => {
638
- if (!confirm(`Delete ${isFolder ? `"${path}" and everything in it` : `"${path}"`}? This cannot be undone.`)) return
1087
+ /* the OS-drop target for a bucket-relative folder path, or null where nothing may land */
1088
+ const dropFilesTo = onDropFiles && writable ? (path) => (files) => onDropFiles(files, path) : null
1089
+
1090
+ /* UPLOAD FROM THE MENU (user 2026-09-22). The drop zone asked where files should go and never
1091
+ * said; right-click names the folder. The picker is the browser's own and the files go through
1092
+ * `onDropFiles`, the same seam a drag uses — the page still never uploads. */
1093
+ const doUpload = (path) => {
1094
+ const input = document.createElement('input')
1095
+ input.type = 'file'
1096
+ input.multiple = true
1097
+ input.onchange = () => { if (input.files?.length) runAction(() => onDropFiles(input.files, path)) }
1098
+ input.click()
1099
+ }
1100
+
1101
+ /* The wall's two read verbs, in the tree's menu too (user 2026-09-22: "in right click dropdown we
1102
+ * need copy url and download options, they are currently only availble in files mode"). Both are
1103
+ * the client's URLs, so neither needs a write seam — they are offered wherever a FILE is. */
1104
+ const doCopyUrl = (paths) => navigator.clipboard?.writeText(paths.map((p) => mediaUrl(p)).join('\n'))
1105
+ const doDownload = (paths) => {
1106
+ for (const p of paths) {
1107
+ const a = document.createElement('a')
1108
+ a.href = downloadUrl(p)
1109
+ a.download = p.split('/').pop()
1110
+ document.body.appendChild(a)
1111
+ a.click()
1112
+ a.remove()
1113
+ }
1114
+ }
1115
+
1116
+ const doDelete = async (path, isFolder) => {
1117
+ if (!trash && !(await modal.confirm(`Delete ${isFolder ? `"${path}" and everything in it` : `"${path}"`}? This cannot be undone.`, { okLabel: 'Delete' }))) return
639
1118
  runAction(() => fileActions.remove(path))
640
1119
  }
1120
+ /* stepping into a folder drops the pick — the preview would otherwise describe something no
1121
+ * longer in the list */
1122
+ const goFolder = (path) => { setPickedFile(null); setPickedFolder(null); setPrefix(path) }
1123
+ /* SPACE IS QUICK LOOK IN EVERY VIEW (user 2026-09-23: *"spacebar preview only works in column,
1124
+ * fix that. CONSISTENCY"*). `ColumnBrowser` has owned that key since it shipped, so the columns
1125
+ * had it and the two views built later did not. The target is whatever is picked — the previewed
1126
+ * file, or a selection of exactly one — and the set it pages through is the list you are looking
1127
+ * at: the folder's files in rows, the wall's own list in grid. */
1128
+ /* QUICK LOOK PAGES THROUGH THE SELECTION (user 2026-09-23: *"multiple files selected should
1129
+ * show '1/2 <>' … because two files are highlighted"*). With more than one file selected the set
1130
+ * is the selection, in list order, starting on the file asked for; otherwise it is the list the
1131
+ * caller handed over. Every open goes through here so no view pages a different set. */
1132
+ const openQuickLook = (q) => {
1133
+ if (!q) { setQuickLook(null); return }
1134
+ /* in the order on screen when the caller's list holds the whole selection, else bucket order */
1135
+ const inView = q.files.filter((o) => rowSelection.has(o.key))
1136
+ const sel = inView.length === rowSelection.size ? inView : objects.filter((o) => rowSelection.has(o.key))
1137
+ const current = q.files[q.index]
1138
+ if (sel.length > 1) {
1139
+ const index = Math.max(0, sel.findIndex((o) => o.key === current?.key))
1140
+ setQuickLook({ files: sel, index })
1141
+ return
1142
+ }
1143
+ setQuickLook(q)
1144
+ }
1145
+ /* QUICK LOOK STEPS THE SELECTION TOO (user 2026-09-23: *"when quicklook preview is open on
1146
+ * selected item and arrow right focuses new item, that item is being selected"*). Paging a folder,
1147
+ * the file you land on becomes the selection and the pick — so closing the window leaves you on
1148
+ * it. Paging a multi-selection, the set is what you are looking through and stays whole. */
1149
+ /* closing hands focus to the SELECTION, not back to the tile that opened the window — after
1150
+ * stepping they are different tiles, and the overlay's own focus-return would light the old one */
1151
+ const closeQuickLook = () => {
1152
+ setQuickLook(null)
1153
+ requestAnimationFrame(() => {
1154
+ const key = selectAnchorRef.current
1155
+ const el = key && document.querySelector(`[data-marquee-key="${CSS.escape(key)}"]`)
1156
+ if (el?.tabIndex >= 0) el.focus({ preventScroll: true })
1157
+ })
1158
+ }
1159
+ const stepQuickLook = (dir) => {
1160
+ if (!quickLook) return
1161
+ const { files } = quickLook
1162
+ const index = (quickLook.index + dir + files.length) % files.length
1163
+ setQuickLook({ ...quickLook, index })
1164
+ const o = files[index]
1165
+ if (o && rowSelection.size <= 1) {
1166
+ setRowSelection(new Set([o.key])); selectAnchorRef.current = o.key
1167
+ setPickedFolder(null); setPickedFile(o)
1168
+ }
1169
+ }
1170
+ const spaceQuickLook = () => {
1171
+ const key = pickedFile?.key ?? pickedFolder ?? (rowSelection.size === 1 ? [...rowSelection][0] : null)
1172
+ if (!key) return
1173
+ /* A FOLDER QUICK-LOOKS TOO (user 2026-09-23: *"anything can be quick viewed, also folders"*) —
1174
+ * the same pane the preview column draws, at overlay size. */
1175
+ if (key.endsWith('/')) { setQuickLookFolder(key); return }
1176
+ const files = isWall
1177
+ ? wallFiles
1178
+ : rowLevel(key.slice(0, key.lastIndexOf('/') + 1), sortedObjects).files
1179
+ const index = files.findIndex((f) => f.key === key)
1180
+ if (index === -1) return
1181
+ openQuickLook({ files, index })
1182
+ }
1183
+
1184
+ /* SEARCH IS THE DS'S OWN PALETTE (2026-09-22). `/` is the file-manager habit and ⌘K the app one,
1185
+ * so both open it; it searches the bucket's keys and JUMPS — it does not filter the list under
1186
+ * you, which is what the filter bar is for. */
1187
+ const [searchOpen, setSearchOpen] = useState(false)
1188
+ const [searchQuery, setSearchQuery] = useState('')
1189
+ /* THE ARROWS IN THE GRID AND THE ROWS (user 2026-09-23: *"arrow navigation doesnt work in grid
1190
+ * mode? nor in row mode"*). The columns have always had theirs (`ColumnBrowser`); these are the
1191
+ * same keys for the other two views, and every one is listed in the shortcuts sheet.
1192
+ * grid ←→ one tile, ↑↓ one row of tiles (the grid's live column count)
1193
+ * rows ↑↓ the visible order · → expands a folder, again steps into it · ← collapses, or goes
1194
+ * to the parent folder
1195
+ * both ⌘↑ the enclosing folder · ⌘↓ opens (a folder in the rows, Quick Look on a file) */
1196
+ const gridOrderRef = useRef([])
1197
+ const arrowNav = (e) => {
1198
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return false
1199
+ if (view === 'columns' || quickLook || quickLookFolder || searchOpen) return false
1200
+ const cur = selectCursorRef.current ?? selectAnchorRef.current ?? [...rowSelection].pop() ?? null
1201
+ /* ⇧ + ARROW EXTENDS (user 2026-09-23: *"shift up down or left right … should also make
1202
+ * selection"*): the anchor stays where the run began, the cursor moves, and the selection is
1203
+ * everything between them in the order on screen. */
1204
+ const extend = (order, key) => {
1205
+ const anchor = selectAnchorRef.current ?? cur ?? key
1206
+ const a = order.indexOf(anchor)
1207
+ const b = order.indexOf(key)
1208
+ if (a === -1 || b === -1) return false
1209
+ setRowSelection(new Set(order.slice(Math.min(a, b), Math.max(a, b) + 1)))
1210
+ selectAnchorRef.current = anchor
1211
+ selectCursorRef.current = key
1212
+ return true
1213
+ }
1214
+ /* FOCUS FOLLOWS THE SELECTION (user 2026-09-23: *"why are we supporting multiple selected/highlight
1215
+ * states?"*). The clicked tile kept keyboard focus — and its focus ring — while the arrows moved
1216
+ * the selection on, so two tiles looked current. The one the arrows land on takes focus too. */
1217
+ const reveal = (box, key) => {
1218
+ const el = box?.querySelector(`[data-marquee-key="${CSS.escape(key)}"]`)
1219
+ el?.scrollIntoView({ block: 'nearest' })
1220
+ if (el?.tabIndex >= 0) el.focus({ preventScroll: true })
1221
+ }
1222
+ if (view === 'grid') {
1223
+ const list = gridOrderRef.current
1224
+ if (!list.length) return true
1225
+ if (e.metaKey && e.key === 'ArrowUp') { if (prefix) goFolder(dirOf(prefix)); return true }
1226
+ const i = list.findIndex((f) => f.key === cur)
1227
+ if (e.metaKey && e.key === 'ArrowDown') { if (i !== -1) openQuickLook({ files: list, index: i }); return true }
1228
+ const box = gridMarquee.ref.current
1229
+ const cols = box ? getComputedStyle(box).gridTemplateColumns.split(' ').filter(Boolean).length : 1
1230
+ const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key]
1231
+ const o = list[i === -1 ? 0 : Math.max(0, Math.min(list.length - 1, i + step))]
1232
+ if (e.shiftKey && extend(list.map((f) => f.key), o.key)) { reveal(box, o.key); return true }
1233
+ setRowSelection(new Set([o.key])); selectAnchorRef.current = o.key; selectCursorRef.current = null
1234
+ setPickedFolder(null); setPickedFile(o)
1235
+ reveal(box, o.key)
1236
+ return true
1237
+ }
1238
+ if (view === 'rows') {
1239
+ const order = visibleRowOrder(sortedObjects)
1240
+ if (!order.length) return true
1241
+ const select = (key) => {
1242
+ setRowSelection(new Set([key])); selectAnchorRef.current = key; selectCursorRef.current = null
1243
+ if (key.endsWith('/')) rowPickFolder(key)
1244
+ else { const o = objects.find((x) => x.key === key); if (o) rowPickFile(o) }
1245
+ reveal(rowsMarquee.ref.current, key)
1246
+ }
1247
+ const isFolder = cur?.endsWith('/')
1248
+ const open = isFolder && rowsExpanded.has(cur)
1249
+ if (e.metaKey && e.key === 'ArrowUp') { const up = cur ? dirOf(cur) : dirOf(prefix); if (up) select(up); return true }
1250
+ if (e.metaKey && e.key === 'ArrowDown') {
1251
+ if (!cur) return true
1252
+ if (isFolder) goFolder(cur)
1253
+ else { const lvl = rowLevel(dirOf(cur), sortedObjects).files; openQuickLook({ files: lvl, index: Math.max(0, lvl.findIndex((f) => f.key === cur)) }) }
1254
+ return true
1255
+ }
1256
+ const i = order.indexOf(cur)
1257
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1258
+ const next = order[i === -1 ? 0 : Math.max(0, Math.min(order.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]
1259
+ if (e.shiftKey && extend(order, next)) { reveal(rowsMarquee.ref.current, next); return true }
1260
+ select(next); return true
1261
+ }
1262
+ if (e.key === 'ArrowRight' && isFolder) {
1263
+ if (!open) setExpandedRows((prev) => new Set([...prev, cur]))
1264
+ else if (order[i + 1]?.startsWith(cur)) select(order[i + 1])
1265
+ return true
1266
+ }
1267
+ if (e.key === 'ArrowLeft' && cur) {
1268
+ if (open) toggleRow(cur)
1269
+ else if (dirOf(cur)) select(dirOf(cur))
1270
+ return true
1271
+ }
1272
+ return true
1273
+ }
1274
+ return false
1275
+ }
1276
+ useEffect(() => {
1277
+ const onKey = (e) => {
1278
+ const el = e.target
1279
+ if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
1280
+ if (e.key === '/' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); setSearchOpen(true) }
1281
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setSearchOpen(true) }
1282
+ if (arrowNav(e)) { e.preventDefault(); return }
1283
+ /* the columns keep their own space handler — theirs knows the column's cursor */
1284
+ if (e.key === ' ' && view !== 'columns' && !searchOpen) {
1285
+ e.preventDefault()
1286
+ if (quickLook) closeQuickLook()
1287
+ else if (quickLookFolder) setQuickLookFolder(null)
1288
+ else spaceQuickLook()
1289
+ }
1290
+ }
1291
+ window.addEventListener('keydown', onKey)
1292
+ return () => window.removeEventListener('keydown', onKey)
1293
+ })
1294
+
641
1295
  const [quickLook, setQuickLook] = useState(null)
1296
+ const [quickLookFolder, setQuickLookFolder] = useState(null)
642
1297
  const columnsRef = useRef(null)
643
- const { folderView = 'columns', flat } = settings
1298
+ /* `flat` IS THE WALL'S, NOT THIS PAGE'S (user 2026-09-22: "there is no option to switch to flat
1299
+ * mode, bc its not in files view. this is row mode.. this makes no sense"). It reached here from
1300
+ * kol-r2b2's single surface and did two things that cannot be defended in a TREE: it struck every
1301
+ * folder name through, and it counted the whole subtree in the level's own count line — with the
1302
+ * only control on the other view's filter bar. A tree browses folders; flat browses files. */
1303
+ /* `view` IS THE STATE; `folderView` is what it used to be called for the two tree views and is
1304
+ * kept in step so an older consumer (kol-r2b2, kol-olina) is unaffected. */
1305
+ const stored = viewProp ?? settings.view ?? (settings.folderView === 'rows' ? 'rows' : 'columns')
1306
+ const view = stored === 'list' ? 'rows' : stored
1307
+ const isWall = view === 'grid'
1308
+ const folderView = view === 'rows' ? 'rows' : 'columns'
1309
+ const setView = (v) => {
1310
+ onViewChange?.(v)
1311
+ setSettings({
1312
+ ...settings,
1313
+ view: v,
1314
+ folderView: v === 'rows' ? 'rows' : v === 'columns' ? 'columns' : settings.folderView,
1315
+ layout: v === 'grid' ? 'grid' : settings.layout,
1316
+ })
1317
+ }
1318
+ /* ROW VIEW'S PREVIEW PANE. Reserved whenever the setting is on, empty until something is picked
1319
+ * — Finder keeps the space rather than reflowing the list on every selection. */
1320
+ const showRowPreview = folderView === 'rows' && (settings.rowPreview ?? true)
1321
+ const showGridPreview = view === 'grid' && (settings.rowPreview ?? true)
1322
+ /* OFF by default: a row is a name, and the facts live in the preview (user 2026-09-22) */
1323
+ const rowCols = settings.rowColumns ? rowColWidths(settings) : null
1324
+ const previewWidth = settings.columnWidths?.preview ?? 320
1325
+ const previewFile = pickedFile && pickedFile.key.startsWith(prefix) ? pickedFile : null
1326
+ const previewFolder = pickedFolder && pickedFolder.startsWith(prefix) ? pickedFolder : null
1327
+ /* THE PREVIEW PANE, ONE FUNCTION FOR THE ROWS AND THE GRID (user 2026-09-23: *"why did you not
1328
+ * treat this like column and row, with container padding + preview pane? CONSISTENCY"*). Several
1329
+ * selected → the stack; a file → its preview; a folder → its summary; nothing → the reserved
1330
+ * space, which Finder keeps rather than reflowing the list on every pick. */
1331
+ const previewPane = () => (
1332
+ rowSelection.size > 1 ? (
1333
+ <SelectionPreview keys={[...rowSelection]} objects={objects} urlOf={(o) => mediaUrl(o.key)} kindOf={kindOf} formatSize={formatSize} formatDate={formatDate} width={previewWidth} />
1334
+ ) : previewFile ? (
1335
+ <ColumnPreview key={previewFile.key} o={previewFile} urlOf={(o) => mediaUrl(o.key)} kindOf={kindOf} kindLabel={KIND_LABEL}
1336
+ formatSize={formatSize} formatDate={formatDate} width={previewWidth}
1337
+ renderPreview={(o) => {
1338
+ if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} />
1339
+ const poster = posterFor(o.key, keySet)
1340
+ return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
1341
+ }} />
1342
+ ) : previewFolder ? (
1343
+ <FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} />
1344
+ ) : (
1345
+ <div className="kol-column-browser-preview shrink-0" style={{ width: previewWidth }} />
1346
+ )
1347
+ )
644
1348
  /* PINNED SEARCH + `···` (item 15), below `md` only. Item 7 of the previous
645
1349
  * ticket hid the desktop control cluster and put nothing in its place, so
646
1350
  * SORT became unreachable on a phone; both references carry exactly these two
@@ -653,7 +1357,12 @@ export function MediaLibraryBrowse({
653
1357
  * rest. One view, rather than a flat results list nobody asked for. Sort
654
1358
  * orders the files the way the wall already sorts its own. */
655
1359
  const q = query.trim().toLowerCase()
656
- const searched = useMemo(() => (q ? objects.filter((o) => o.key.toLowerCase().includes(q)) : objects), [objects, q])
1360
+ /* SEGMENTS HIDE WHEN FOLDED, in every view (user 2026-09-23: *"we can just by default hide the
1361
+ * segments, if they serve no practical purpose"*). A `.ts` chunk cannot play on its own — its
1362
+ * stream's `.m3u8` beside it is the thing you open. The fold used to reach only the grid, so the
1363
+ * columns and rows listed every chunk. */
1364
+ const listed = useMemo(() => (settings.foldSegments ? objects.filter((o) => !isSegment(o.key)) : objects), [objects, settings.foldSegments])
1365
+ const searched = useMemo(() => (q ? listed.filter((o) => o.key.toLowerCase().includes(q)) : listed), [listed, q])
657
1366
  const sortedObjects = useMemo(
658
1367
  () => sortObjects(searched, settings.sortBy, settings.sortDir),
659
1368
  [searched, settings.sortBy, settings.sortDir],
@@ -671,7 +1380,7 @@ export function MediaLibraryBrowse({
671
1380
  * merged set now, so the tally is taken from the same place. */
672
1381
  const folders = [...new Set([...treeFolders(prefix), ...keyFolders])].sort()
673
1382
  const keySet = new Set(objects.map((o) => o.key))
674
- const levelFiles = flat ? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key })) : dirFiles
1383
+ const levelFiles = dirFiles
675
1384
  const systemCount = levelFiles.filter((o) => isSystemFile(o.key)).length
676
1385
  const rawFiles = levelFiles.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o) }))
677
1386
  const profile = profileOf(objects, rawFiles, systemCount)
@@ -689,7 +1398,7 @@ export function MediaLibraryBrowse({
689
1398
  * to reach a folder. The virtual root collapses to the empty string, which
690
1399
  * makes every `slice(VROOT.length)` below a no-op and leaves column 0 as the
691
1400
  * bucket's own folders. Multi-bucket browse is untouched. */
692
- const single = buckets.length <= 1
1401
+ const single = !bucketLevel && buckets.length <= 1
693
1402
  const ROOT = title
694
1403
  const label = bucketMeta.label || 'bucket'
695
1404
  const VROOT = single ? '' : `${ROOT}/${label}/`
@@ -699,11 +1408,11 @@ export function MediaLibraryBrowse({
699
1408
  * columns about what a folder even is. This is that merge, lifted so both callers share it —
700
1409
  * folders from `folderTree` (real nodes, empty ones included) unioned with the ones derived from
701
1410
  * keys, plus the level's own files, which the row view never rendered at all. */
702
- const rowLevel = (level) => {
1411
+ const rowLevel = (level, list = objects) => {
703
1412
  /* `partition` slices every key by `prefix` WITHOUT checking it matches, so it must be handed an
704
1413
  * already-scoped list. Given the whole bucket it cut four characters off every root key and
705
1414
  * drew `readme.md` as `me.md` and `video/` as `o/`. Scope first, always. */
706
- const inLevel = level ? objects.filter((o) => o.key.startsWith(level)) : objects
1415
+ const inLevel = level ? list.filter((o) => o.key.startsWith(level)) : list
707
1416
  const live = partition(inLevel, level)
708
1417
  const baked = treeFolders(level)
709
1418
  return {
@@ -721,11 +1430,277 @@ export function MediaLibraryBrowse({
721
1430
  }, [folderView, prefix, appRoot, pickedFile, bucketMeta.id])
722
1431
 
723
1432
  if (error) return <p className="kol-mono-12 text-ui-error">Error: {error}</p>
1433
+ /* NOT SELECTABLE (user 2026-09-23, superseding 2026-09-22's selectable crumbs: *"instead of this
1434
+ * highlight, could we make a hover click to copy path?"*). A drag across the line painted the
1435
+ * browser's selection block over it; the path is one click on the current crumb now. */
724
1436
  const crumbCls = (active) => `cursor-pointer select-none transition-colors ${active ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`
1437
+ const fullPath = (tail = '') => [ROOT, ...(appRoot ? [] : [label, ...crumbs]), ...(tail ? [tail] : [])].join('/')
1438
+
1439
+ /* ── THE WALL'S LIST, on the same surface (2026-09-22) ──────────────────────────────────────
1440
+ * `flat` belongs to the FILE views and not to the tree (that ruling stands): a wall shows the
1441
+ * folder's own files, or the whole subtree when flat is on. Grouping and segment-folding are the
1442
+ * settings the wall always had. */
1443
+ const wallKinds = new Set(settings.kinds ?? SETTINGS_BASE.kinds)
1444
+ const wallSource = settings.flat
1445
+ ? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key }))
1446
+ : dirFiles
1447
+ const wallVisible = wallKinds.has('system') ? wallSource : wallSource.filter((o) => !isSystemFile(o.key))
1448
+ const wallGrouped = settings.groupVariants ? groupVariants(wallVisible) : wallVisible
1449
+ const wallFiles = (settings.foldSegments ? groupSegments(wallGrouped) : wallGrouped)
1450
+ .map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet) }))
1451
+ const filtersOn = !!settings.filters
1452
+ /* the bar filters FILES, and a tree is drawn from the files under it — so one filtered list
1453
+ * serves both: the wall renders it, the tree is scoped to the keys in it */
1454
+ 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 }))
1455
+ const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
1456
+
1457
+ /* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
1458
+ * `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
1459
+ * key space, or the subset the filter bar left standing. `wallPane` is the same two file views
1460
+ * the Files page rendered, in a pane of the browser's own height so the page does not jump
1461
+ * between views. */
1462
+ const treeBody = (keep) => {
1463
+ const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
1464
+ return (
1465
+ folderView === 'columns' ? (
1466
+ <div ref={columnsRef} className="relative">
1467
+ <ColumnBrowser
1468
+ autoFocus={autoFocus}
1469
+ className={appRoot ? 'is-root' : ''}
1470
+ height={settings.columnHeight}
1471
+ onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
1472
+ columnWidths={settings.columnWidths}
1473
+ onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
1474
+ objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
1475
+ prefix={single ? prefix : (appRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
1476
+ onPrefix={(v) => {
1477
+ /* single: the browser's path IS the bucket path, no segment to strip */
1478
+ if (single) { setAppRoot(false); setPrefix(v); return }
1479
+ const [, seg, ...rest] = v.split('/')
1480
+ const target = buckets.find((b) => b.label === seg)
1481
+ if (!target && buckets.length) { setAppRoot(true); setPickedFile(null); setPrefix(''); return }
1482
+ setAppRoot(false)
1483
+ const real = rest.join('/')
1484
+ if (target && target.id !== bucketMeta.id) switchBucket(target.id, real); else setPrefix(real)
1485
+ }}
1486
+ onPick={(o) => setPickedFile(o ? { ...o, key: o.key.slice(VROOT.length) } : null)}
1487
+ quickLook={quickLook && { ...quickLook, files: quickLook.files.map((o) => ({ ...o, key: `${VROOT}${o.key}` })) }}
1488
+ onQuickLook={(q) => openQuickLook(q && { ...q, files: q.files.map((o) => ({ ...o, key: o.key.slice(VROOT.length) })) })}
1489
+ urlOf={(o) => mediaUrl(o.key.slice(VROOT.length))}
1490
+ kindOf={kindOf}
1491
+ kindLabel={KIND_LABEL}
1492
+ formatSize={formatSize}
1493
+ formatDate={formatDate}
1494
+ thumbnailFor={thumbnailFor}
1495
+ folderMeta={folderMeta}
1496
+ /* the PROP wins when a consumer passes one; otherwise the `···`
1497
+ owns it through settings (item 15) */
1498
+ stackView={stackView ?? settings.stackView ?? 'list'}
1499
+ renderPreview={(o) => {
1500
+ const real = { ...o, key: o.key.slice(VROOT.length) }
1501
+ if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
1502
+ const poster = posterFor(real.key, keySet)
1503
+ return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
1504
+ }}
1505
+ /* THE COLUMNS SPEAK IN VIRTUAL PATHS. With more than one bucket every level is
1506
+ * prefixed `<title>/<bucket>/` so the stores share one tree — so a path coming OUT
1507
+ * of the browser must be un-rooted before it reaches a file verb, or the store is
1508
+ * asked to move `MEDIA/R2 · kol-media/img/x.jpg`, which it has never heard of. The
1509
+ * row view needs none of this; it is already bucket-relative. */
1510
+ onRowContextMenu={(e, payload) => menu.openAt(e, { ...payload, path: unroot(payload.path), ...(payload.o && { o: { ...payload.o, key: unroot(payload.o.key) } }) })}
1511
+ dragFor={dragFor ? (path) => dragFor(unroot(path)) : undefined}
1512
+ /* virtual paths again: only a level inside THIS bucket's root can take a file */
1513
+ onDropFiles={dropFilesTo ? (files, path) => { if (!VROOT || path.startsWith(VROOT)) dropFilesTo(unroot(path))(files) } : undefined}
1514
+ /* the band's keys come back virtual-rooted; the page keeps ONE selection, bucket-relative */
1515
+ selectedKeys={VROOT ? new Set([...rowSelection].map((k) => `${VROOT}${k}`)) : rowSelection}
1516
+ onSelectKeys={(keys, additive) => { marqueeSelect(keys.map(unroot), additive); if (!additive && keys.length === 1) selectAnchorRef.current = unroot(keys[0]) }}
1517
+ onSelectClick={(key, e, keys) => selectRow(unroot(key), e, keys.map(unroot))}
1518
+ /* the bucket rows under the title are SOURCES — `database`, not `folder` */
1519
+ folderIcon={(path) => (!single && path.startsWith(`${ROOT}/`) && path.slice(ROOT.length + 1).split('/').filter(Boolean).length === 1 ? 'database' : 'folder')}
1520
+ partition={(objs, level) => {
1521
+ if (!single) {
1522
+ if (level === '') return { folders: [`${ROOT}/`], files: [] }
1523
+ if (level === `${ROOT}/`) return { folders: (buckets.length ? buckets : [bucketMeta]).map((b) => `${b.label || 'bucket'}/`), files: [] }
1524
+ }
1525
+ const live = partition(objs, level)
1526
+ const baked = treeFolders(level.slice(VROOT.length))
1527
+ return { folders: [...new Set([...baked, ...live.folders])].sort(), files: live.files }
1528
+ }}
1529
+ />
1530
+ </div>
1531
+ ) : (
1532
+ /* ROW VIEW — the same level the column view draws, not a different one. It used to render
1533
+ * `partition(scoped, prefix).folders` and nothing else, which lost two things: an EMPTY
1534
+ * folder (partition derives folders from file keys, so a folder with no files under it
1535
+ * does not exist to it — `folderTree` is where real nodes live, and the column view was
1536
+ * already merging the two) and every FILE at the level. `#img/` drew 2 rows against the
1537
+ * columns' 4. Folders first, then files, each expandable in place like a list view. */
1538
+ /* SAME AREA AS THE COLUMNS. The row view used to be content-height, so the two folder
1539
+ * views made the page jump between them and the blank space below the last row — where
1540
+ * you right-click for "new folder" and drop to file something here — did not exist. It
1541
+ * takes the column browser's own height now, from the same setting.
1542
+ *
1543
+ * The list IS the current folder, so it is a drop target for `prefix`, matching a column
1544
+ * being a target for its level. Highlight via a data attribute for the same reason.
1545
+ *
1546
+ * THE COLUMN BROWSER'S PANE (user 2026-09-22): same border, radius and height, the list a
1547
+ * `kol-column-browser-column` (its 4px block inset, its selected rules), and beside it the
1548
+ * same preview the columns open — Finder's "Show preview", on by default (`rowPreview`). */
1549
+ /* THE WRAPPER OWNS THE HEIGHT, not the pane (2026-09-22). Both folder views have to end on
1550
+ * the same line — the count line under them jumps otherwise, and a consumer's fill height
1551
+ * is ONE value for both — so the header comes out of this view's budget rather than being
1552
+ * added to it. The pane takes what is left. */
1553
+ <div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
1554
+ {/* the header labels the LIST, so it spans the list and stops where the preview starts */}
1555
+ {rowCols && (
1556
+ <RowHeader sortBy={settings.sortBy} sortDir={settings.sortDir} width={showRowPreview ? `calc(100% - ${previewWidth}px)` : '100%'}
1557
+ cols={rowCols}
1558
+ onResize={(key, px) => setSettings({ ...settings, rowColumnWidths: { ...rowColWidths(settings), [key]: px } })}
1559
+ onSort={(sortBy, sortDir) => setSettings({ ...settings, sortBy, sortDir })} />
1560
+ )}
1561
+ <div className="relative border rounded flex overflow-hidden flex-1 min-h-0"
1562
+ style={{ borderColor: 'var(--kol-oq-08)' }}>
1563
+ <div className={`flex-1 min-w-0 flex flex-col${showRowPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}>
1564
+ <ul ref={rowsMarquee.ref} {...rowsMarquee.props}
1565
+ /* the background deselects, here as in the columns */
1566
+ onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}
1567
+ className="relative kol-column-browser-column kol-row-browser flex-1 min-w-0 flex flex-col overflow-y-auto"
1568
+ onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
1569
+ onDragOver={(e) => {
1570
+ const files = dropFilesTo && isFileDrag(e)
1571
+ const d = !isFileDrag(e) && dragFor?.(prefix)
1572
+ if (!files && !d?.canDrop(prefix)) return
1573
+ e.preventDefault()
1574
+ e.currentTarget.dataset.dropOver = '1'
1575
+ }}
1576
+ /* THE HIGHLIGHT CLEARS HOWEVER THE DRAG ENDS (user 2026-09-22 — it stuck as a border).
1577
+ * A drop on a folder row stops propagation, so this list's own onDrop never ran; the
1578
+ * capture phase runs first. Leaving through a child fires on the child, so leave is
1579
+ * "the pointer is no longer inside", not "the event target was the list". */
1580
+ onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) delete e.currentTarget.dataset.dropOver }}
1581
+ onDropCapture={(e) => { delete e.currentTarget.dataset.dropOver }}
1582
+ onDrop={(e) => {
1583
+ if (dropFilesTo && isFileDrag(e)) { e.preventDefault(); dropFilesTo(prefix)(e.dataTransfer.files); return }
1584
+ const d = !isFileDrag(e) && dragFor?.(prefix)
1585
+ if (!d?.canDrop(prefix)) return
1586
+ e.preventDefault()
1587
+ d.onDrop(prefix)
1588
+ }}>
1589
+ {rowsMarquee.rect && <div className="kol-marquee" style={rowsMarquee.rect} />}
1590
+ {rowLevel('', treeObjects).folders.map((f) => {
1591
+ const path = f
1592
+ const open = rowsExpanded.has(path)
1593
+ const order = visibleRowOrder(treeObjects)
1594
+ return (
1595
+ <Fragment key={f}>
1596
+ <FolderRow
1597
+ name={f} expanded={open}
1598
+ markKey={path}
1599
+ /* THE FOLDER YOU ARE IN IS NOT A SELECTION (user 2026-09-23) — it wears the trail
1600
+ * mark, the columns' parent-folder tone, never the selected fill */
1601
+ selected={rowSelection.has(path) || pickedFolder === path}
1602
+ current={prefix === path}
1603
+ onContextMenu={(e) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
1604
+ drag={dragFor?.(path)}
1605
+ dropFiles={dropFilesTo?.(path)}
1606
+ meta={folderMeta?.(path) || undefined}
1607
+ cols={rowCols}
1608
+ onToggle={() => toggleRow(path)}
1609
+ onClick={(e) => { if (!selectRow(path, e, order)) rowPickFolder(path) }}
1610
+ onDoubleClick={() => goFolder(path)}
1611
+ />
1612
+ {open && <RowSubtree
1613
+ level={path} depth={1} rowLevel={(lvl) => rowLevel(lvl, treeObjects)} expanded={rowsExpanded}
1614
+ onToggle={toggleRow}
1615
+ onOpenFolder={goFolder} onPickFolder={rowPickFolder} onPickFile={rowPickFile}
1616
+ onOpenFile={(o) => { const lvl = rowLevel(o.key.slice(0, o.key.lastIndexOf('/') + 1), treeObjects).files; openQuickLook({ files: lvl, index: Math.max(0, lvl.findIndex((f) => f.key === o.key)) }) }}
1617
+ selectRow={selectRow} order={order} selectedKeys={rowSelection}
1618
+ onRowContextMenu={(e, payload) => menu.openAt(e, payload)} dragFor={dragFor} dropFilesTo={dropFilesTo}
1619
+ formatDate={formatDate} folderMeta={folderMeta} thumbnailFor={thumbnailFor} cols={rowCols}
1620
+ pickedKey={pickedFolder ?? pickedFile?.key ?? null} currentKey={prefix}
1621
+ />}
1622
+ </Fragment>
1623
+ )
1624
+ })}
1625
+ {rowLevel('', treeObjects).files.map((o) => (
1626
+ <FileRow key={o.key} o={o} formatDate={formatDate} thumb={thumbnailFor?.(o)} cols={rowCols}
1627
+ selected={rowSelection.has(o.key) || pickedFile?.key === o.key}
1628
+ onDoubleClick={() => { const lvl = rowLevel('', treeObjects).files; openQuickLook({ files: lvl, index: Math.max(0, lvl.findIndex((f) => f.key === o.key)) }) }}
1629
+ onContextMenu={(e) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })}
1630
+ drag={dragFor?.(o.key)}
1631
+ onClick={(e) => {
1632
+ if (!selectRow(o.key, e, visibleRowOrder(treeObjects))) rowPickFile(o)
1633
+ }} />
1634
+ ))}
1635
+ </ul>
1636
+ </div>
1637
+ {showRowPreview && previewPane()}
1638
+ </div>
1639
+ </div>
1640
+ )
1641
+ )
1642
+ }
1643
+
1644
+ /* THE GRID IS THE THIRD VIEW OF ONE SURFACE (user 2026-09-23, reversing the same day's "no pane,
1645
+ * no fill height" for the wall: *"why did you not treat this like column and row, with container
1646
+ * padding + preview pane? CONSISTENCY"*). Same bordered frame, same fill height, the tiles scroll
1647
+ * inside it, the same preview pane beside them, the same count line under it — with the size
1648
+ * slider at its right end, Finder's. */
1649
+ const tileSize = settings.tileSize ?? 180
1650
+ const wallPane = (files) => (
1651
+ <div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
1652
+ <div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
1653
+ <div className={`flex-1 min-w-0 overflow-y-auto p-4${showGridPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}
1654
+ onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}>
1655
+ <WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} marquee={gridMarquee} onSorted={(list) => { gridOrderRef.current = list }}
1656
+ onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) }}
1657
+ sortBy={settings.sortBy} sortDir={settings.sortDir}
1658
+ pageSize={settings.pageSize} listId={`${prefix}|${view}|${settings.flat}|${refreshKey}|${bucketMeta.id}`}
1659
+ mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} videoPreview={settings.videoPreview} formatDate={formatDate}
1660
+ selected={rowSelection} onPick={(o, e, keys) => {
1661
+ /* ⇧/⌘ ADD TO THE SELECTION HERE TOO (user 2026-09-23) — the same `selectRow` the rows use;
1662
+ * a plain click is still a selection of one and still previews */
1663
+ if (selectRow(o.key, e, keys)) return
1664
+ setPickedFolder(null); setPickedFile(o)
1665
+ }}
1666
+ onOpen={(o, files) => openQuickLook({ files, index: Math.max(0, files.findIndex((f) => f.key === o.key)) })}
1667
+ /* COPY AND DOWNLOAD LIVE IN THE MENU AND QUICK LOOK (user 2026-09-23) — as in the rows and the
1668
+ * columns. The tile carries no buttons. */
1669
+ onContextMenu={(e, o) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })} />
1670
+ </div>
1671
+ {showGridPreview && previewPane()}
1672
+ </div>
1673
+ </div>
1674
+ )
1675
+
1676
+ /* The palette's own ruling (2026-08-01) is that Enter COMMITS the query rather than navigating
1677
+ * to whatever happened to be first — so `onExpand` is where a committed query lands, and here
1678
+ * that means the top hit. Arrow-then-Enter and a click go through `onSelect`, same jump. */
1679
+ const searchResults = (searchQuery.trim()
1680
+ ? objects.filter((o) => o.key.toLowerCase().includes(searchQuery.trim().toLowerCase()))
1681
+ : objects
1682
+ ).slice(0, 50).map((o) => ({
1683
+ id: o.key,
1684
+ label: o.key.split('/').pop(),
1685
+ hint: o.key.slice(0, o.key.lastIndexOf('/') + 1) || 'the bucket root',
1686
+ group: KIND_LABEL[kindOf(o)] || 'File',
1687
+ }))
1688
+ const jumpTo = (key) => {
1689
+ const o = objects.find((x) => x.key === key)
1690
+ setSearchOpen(false); setSearchQuery('')
1691
+ if (!o) return
1692
+ setAppRoot(false)
1693
+ setPrefix(o.key.slice(0, o.key.lastIndexOf('/') + 1))
1694
+ pickFile(o)
1695
+ }
1696
+
1697
+ const body = (filtered) => (isWall
1698
+ ? wallPane(filtered ?? wallFiles)
1699
+ : treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
725
1700
 
726
1701
  return (
727
1702
  <div className={`flex flex-col gap-6 ${className}`.trim()}>
728
- <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing}
1703
+ <LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
729
1704
  onHome={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}
730
1705
  onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
731
1706
  onSettings={() => setSettingsOpen(true)} />
@@ -773,38 +1748,47 @@ export function MediaLibraryBrowse({
773
1748
  <button className={crumbCls(appRoot)} onClick={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}>{ROOT}</button>
774
1749
  {!appRoot && bucketMeta.id && (
775
1750
  <span className="flex items-center gap-2">
776
- <span className="text-oq-32">/</span>
777
- <button className={crumbCls(crumbs.length === 0 && !(folderView === 'columns' && pickedFile))} onClick={() => setPrefix('')}>{label.toUpperCase()}</button>
1751
+ <span className="text-oq-32 select-none">/</span>
1752
+ {crumbs.length === 0 && !(folderView === 'columns' && pickedFile)
1753
+ ? <CopyCrumb className={crumbCls(true)} label={label.toUpperCase()} path={fullPath()} />
1754
+ : <button className={crumbCls(false)} onClick={() => setPrefix('')}>{label.toUpperCase()}</button>}
778
1755
  </span>
779
1756
  )}
780
1757
  {!appRoot && crumbs.map((seg, i) => {
781
1758
  const to = crumbs.slice(0, i + 1).join('/') + '/'
782
1759
  const last = i === crumbs.length - 1
1760
+ const current = last && !(folderView === 'columns' && pickedFile)
783
1761
  return (
784
1762
  <span key={to} className="flex items-center gap-2">
785
- <span className="text-oq-32">/</span>
786
- <button className={crumbCls(last && !(folderView === 'columns' && pickedFile))} onClick={() => setPrefix(to)}>{seg.toUpperCase()}</button>
1763
+ <span className="text-oq-32 select-none">/</span>
1764
+ {current
1765
+ ? <CopyCrumb className={crumbCls(true)} label={seg.toUpperCase()} path={fullPath()} />
1766
+ : <button className={crumbCls(false)} onClick={() => setPrefix(to)}>{seg.toUpperCase()}</button>}
787
1767
  </span>
788
1768
  )
789
1769
  })}
790
1770
  {folderView === 'columns' && pickedFile && pickedFile.key.startsWith(prefix) && (
791
- <span className="flex items-center gap-2"><span className="text-oq-32">/</span><span className="text-oq-96">{pickedFile.key.slice(prefix.length).toUpperCase()}</span></span>
1771
+ <span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
1772
+ <CopyCrumb className={crumbCls(true)} label={pickedFile.key.slice(prefix.length).toUpperCase()} path={fullPath(pickedFile.key.slice(prefix.length))} />
1773
+ </span>
792
1774
  )}
793
1775
  </div>
794
- {/* the control cluster is desktop-only — below `md` these two toggles
795
- * are the same setting the drawer already carries, and nothing in
796
- * this row survives 390 */}
797
- <div className="hidden md:flex items-center gap-6">
798
- <ViewToggle viewMode={folderView} onViewChange={(v) => setSettings({ ...settings, folderView: v })} variant="icon" options={FOLDER_VIEW_OPTIONS} />
1776
+ {/* the control cluster is desktop-only — below `md` this is the same
1777
+ * setting the drawer already carries, and nothing in this row survives 390.
1778
+ *
1779
+ * ONE SWITCH FOR FOUR VIEWS (the merge, 2026-09-22). Two views were a pair of words;
1780
+ * four are an icon strip — the same reason Finder draws four glyphs and no labels.
1781
+ * Beside it: the filter bar's funnel and search, which belong to every view now. */}
1782
+ <div className="hidden md:flex items-center gap-4">
1783
+ {/* FILTER AND SEARCH, ONE WELL (user 2026-09-23): the same chip and height as the view
1784
+ * switch beside them — the bare filter frame sat shorter than the well, and search had
1785
+ * wandered up to the header. Tooltips are the word, not a sentence. */}
1786
+ <ViewToggle variant="icon" viewMode={null} onViewChange={() => {}} options={[
1787
+ { value: 'filter', label: 'Filter', icon: 'filter', pressed: filtersOn, onClick: () => setSettings({ ...settings, filters: !filtersOn }) },
1788
+ { value: 'search', label: 'Search', icon: 'search', dividerBefore: true, onClick: () => setSearchOpen(true) },
1789
+ ]} />
799
1790
  <Divider variant="vertical" />
800
- <div className="flex items-center gap-4">
801
- {[{ value: 'rows', label: 'ROW' }, { value: 'columns', label: 'COLUMN' }].map((opt) => (
802
- <span key={opt.value} role="button" onClick={() => setSettings({ ...settings, folderView: opt.value })}
803
- className={`kol-helper-14 cursor-pointer select-none ${folderView === opt.value ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`} style={{ letterSpacing: 1 }}>
804
- {opt.label}
805
- </span>
806
- ))}
807
- </div>
1791
+ <ViewToggle viewMode={view} onViewChange={setView} variant="icon" options={VIEW_OPTIONS} />
808
1792
  </div>
809
1793
  </div>
810
1794
 
@@ -868,160 +1852,83 @@ export function MediaLibraryBrowse({
868
1852
  </MenuItem>
869
1853
  </div>
870
1854
 
1855
+ {searchOpen && (
1856
+ <ShellSearchOverlay
1857
+ open
1858
+ onClose={() => { setSearchOpen(false); setSearchQuery('') }}
1859
+ query={searchQuery}
1860
+ onQueryChange={setSearchQuery}
1861
+ placeholder="Search this bucket"
1862
+ results={searchResults}
1863
+ onExpand={() => searchResults[0] && jumpTo(searchResults[0].id)}
1864
+ onSelect={(item) => jumpTo(item.id)}
1865
+ />
1866
+ )}
1867
+ {quickLookFolder && (
1868
+ <FullscreenOverlay open onClose={() => setQuickLookFolder(null)} scrim>
1869
+ <FolderPreview path={quickLookFolder} objects={objects} width={420} formatDate={formatDate} />
1870
+ </FullscreenOverlay>
1871
+ )}
871
1872
  {quickLook && (
872
- <MediaInspector files={quickLook.files} index={quickLook.index} onClose={() => setQuickLook(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
873
- onPrev={() => setQuickLook((q) => ({ ...q, index: (q.index - 1 + q.files.length) % q.files.length }))}
874
- onNext={() => setQuickLook((q) => ({ ...q, index: (q.index + 1) % q.files.length }))} />
1873
+ <MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
1874
+ onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
875
1875
  )}
876
1876
  {settingsOpen && (
877
1877
  <MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
878
1878
  )}
879
-
880
- {folderView === 'columns' ? (
881
- <div ref={columnsRef} className="relative">
882
- <ColumnBrowser
883
- autoFocus={autoFocus}
884
- className={appRoot ? 'is-root' : ''}
885
- height={settings.columnHeight}
886
- onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
887
- columnWidths={settings.columnWidths}
888
- onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
889
- objects={sortedObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
890
- prefix={single ? prefix : (appRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
891
- onPrefix={(v) => {
892
- /* single: the browser's path IS the bucket path, no segment to strip */
893
- if (single) { setAppRoot(false); setPrefix(v); return }
894
- const [, seg, ...rest] = v.split('/')
895
- const target = buckets.find((b) => b.label === seg)
896
- if (!target && buckets.length) { setAppRoot(true); setPickedFile(null); setPrefix(''); return }
897
- setAppRoot(false)
898
- const real = rest.join('/')
899
- if (target && target.id !== bucketMeta.id) switchBucket(target.id, real); else setPrefix(real)
900
- }}
901
- onPick={(o) => setPickedFile(o ? { ...o, key: o.key.slice(VROOT.length) } : null)}
902
- quickLook={quickLook && { ...quickLook, files: quickLook.files.map((o) => ({ ...o, key: `${VROOT}${o.key}` })) }}
903
- onQuickLook={(q) => setQuickLook(q && { ...q, files: q.files.map((o) => ({ ...o, key: o.key.slice(VROOT.length) })) })}
904
- urlOf={(o) => mediaUrl(o.key.slice(VROOT.length))}
905
- kindOf={kindOf}
906
- kindLabel={KIND_LABEL}
907
- formatSize={formatSize}
908
- formatDate={formatDate}
909
- thumbnailFor={thumbnailFor}
910
- folderMeta={folderMeta}
911
- /* the PROP wins when a consumer passes one; otherwise the `···`
912
- owns it through settings (item 15) */
913
- stackView={stackView ?? settings.stackView ?? 'list'}
914
- renderPreview={(o) => {
915
- const real = { ...o, key: o.key.slice(VROOT.length) }
916
- if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
917
- const poster = posterFor(real.key, keySet)
918
- return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
919
- }}
920
- /* THE COLUMNS SPEAK IN VIRTUAL PATHS. With more than one bucket every level is
921
- * prefixed `<title>/<bucket>/` so the stores share one tree — so a path coming OUT
922
- * of the browser must be un-rooted before it reaches a file verb, or the store is
923
- * asked to move `MEDIA/R2 · kol-media/img/x.jpg`, which it has never heard of. The
924
- * row view needs none of this; it is already bucket-relative. */
925
- onRowContextMenu={(e, payload) => menu.openAt(e, { ...payload, path: unroot(payload.path) })}
926
- dragFor={dragFor ? (path) => dragFor(unroot(path)) : undefined}
927
- partition={(objs, level) => {
928
- if (!single) {
929
- if (level === '') return { folders: [`${ROOT}/`], files: [] }
930
- if (level === `${ROOT}/`) return { folders: (buckets.length ? buckets : [bucketMeta]).map((b) => `${b.label || 'bucket'}/`), files: [] }
931
- }
932
- const live = partition(objs, level)
933
- const baked = treeFolders(level.slice(VROOT.length))
934
- return { folders: [...new Set([...baked, ...live.folders])].sort(), files: live.files }
935
- }}
936
- />
937
- </div>
938
- ) : (
939
- /* ROW VIEW — the same level the column view draws, not a different one. It used to render
940
- * `partition(scoped, prefix).folders` and nothing else, which lost two things: an EMPTY
941
- * folder (partition derives folders from file keys, so a folder with no files under it
942
- * does not exist to it — `folderTree` is where real nodes live, and the column view was
943
- * already merging the two) and every FILE at the level. `#img/` drew 2 rows against the
944
- * columns' 4. Folders first, then files, each expandable in place like a list view. */
945
- /* SAME AREA AS THE COLUMNS. The row view used to be content-height, so the two folder
946
- * views made the page jump between them and the blank space below the last row — where
947
- * you right-click for "new folder" and drop to file something here — did not exist. It
948
- * takes the column browser's own height now, from the same setting.
949
- *
950
- * The list IS the current folder, so it is a drop target for `prefix`, matching a column
951
- * being a target for its level. Highlight via a data attribute for the same reason. */
952
- <ul className="flex flex-col overflow-y-auto kol-row-browser"
953
- style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}
954
- onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
955
- onDragOver={(e) => {
956
- const d = dragFor?.(prefix)
957
- if (!d?.canDrop(prefix)) return
958
- e.preventDefault()
959
- e.currentTarget.dataset.dropOver = '1'
960
- }}
961
- onDragLeave={(e) => { if (e.currentTarget === e.target) delete e.currentTarget.dataset.dropOver }}
962
- onDrop={(e) => {
963
- delete e.currentTarget.dataset.dropOver
964
- const d = dragFor?.(prefix)
965
- if (!d?.canDrop(prefix)) return
966
- e.preventDefault()
967
- d.onDrop(prefix)
968
- }}>
969
- {rowLevel(prefix).folders.map((f) => {
970
- const path = prefix + f
971
- const open = expandedRows.has(path)
972
- const order = [...rowLevel(prefix).folders.map((x) => prefix + x), ...rowLevel(prefix).files.map((x) => x.key)]
973
- return (
974
- <Fragment key={f}>
975
- <FolderRow
976
- name={f} struck={flat} expanded={open}
977
- selected={rowSelection.has(path)}
978
- onContextMenu={(e) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
979
- drag={dragFor?.(path)}
980
- meta={folderMeta?.(path) || undefined}
981
- onToggle={() => setExpandedRows((prev) => {
982
- const next = new Set(prev)
983
- next.has(path) ? next.delete(path) : next.add(path)
984
- return next
985
- })}
986
- onClick={(e) => { if (!selectRow(path, e, order)) setPrefix(path) }}
987
- />
988
- {open && <RowSubtree
989
- level={path} depth={1} rowLevel={rowLevel} expanded={expandedRows}
990
- onToggle={(p) => setExpandedRows((prev) => { const n = new Set(prev); n.has(p) ? n.delete(p) : n.add(p); return n })}
991
- onOpenFolder={setPrefix} onOpenFile={(o) => setPickedFile(o)}
992
- onRowContextMenu={(e, payload) => menu.openAt(e, payload)} dragFor={dragFor}
993
- formatDate={formatDate} folderMeta={folderMeta} thumbnailFor={thumbnailFor} flat={flat}
994
- />}
995
- </Fragment>
996
- )
997
- })}
998
- {rowLevel(prefix).files.map((o) => (
999
- <FileRow key={o.key} o={o} formatDate={formatDate} thumb={thumbnailFor?.(o)}
1000
- selected={rowSelection.has(o.key)}
1001
- onContextMenu={(e) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })}
1002
- drag={dragFor?.(o.key)}
1003
- onClick={(e) => {
1004
- const order = [...rowLevel(prefix).folders.map((x) => prefix + x), ...rowLevel(prefix).files.map((x) => x.key)]
1005
- if (!selectRow(o.key, e, order)) setPickedFile(o)
1006
- }} />
1007
- ))}
1008
- </ul>
1879
+ {trashOpen && trash && (
1880
+ <TrashPanel trash={trash} onClose={() => setTrashOpen(false)} run={runAction} confirm={modal.confirm} />
1009
1881
  )}
1010
1882
 
1011
- {/* `mt-2` BALANCES THE INK, not the boxes (user 2026-09-21: "what was unclear about matching
1012
- * the gap above to below"). The column pane, the crumb row above it and this line share one
1013
- * `gap-3`, so box-to-box both gaps are 12px — but the crumb row's box runs 8px lower than
1014
- * its own text, because the ROW·COLUMN icon buttons are taller than the crumbs. Measured
1015
- * against the pane's BORDER: 20px above, 12px below. The 8 puts the text gaps level. */}
1016
- {appRoot && folderView === 'columns' && folderTree ? (
1017
- <p className="kol-mono-12 text-fg-48 mt-2">
1883
+ {/* THE FILTER BAR, above whichever body is mounted (2026-09-22). It filters FILES, so the
1884
+ * tree is scoped to the keys it leaves and the wall renders them directly. Off by default
1885
+ * — the funnel in the crumb row turns it on. */}
1886
+ {filtersOn ? (
1887
+ <ContentFilters
1888
+ items={filterItems}
1889
+ title="Files"
1890
+ totalCount={filterItems.length}
1891
+ searchKeys={['displayKey']}
1892
+ filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }]}
1893
+ mutuallyExclusiveFilters={['kind']}
1894
+ /* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
1895
+ * its panel, and the bar's own funnel closes the bar — the same switch from either
1896
+ * end, instead of two controls that each do half of it. */
1897
+ filtersOpen
1898
+ onFiltersOpenChange={(open) => { if (!open) setSettings({ ...settings, filters: false }) }}
1899
+ renderItem={(filtered) => body(filtered)}
1900
+ />
1901
+ ) : body(null)}
1902
+
1903
+ {/* THE GAPS MATCH ABOVE AND BELOW (user 2026-09-21: "what was unclear about matching the
1904
+ * gap above to below"). This carried `mt-2` while the crumb row's icon toggle made that
1905
+ * row 8px taller than its text; the toggle was cut 2026-09-22, the crumb row is text
1906
+ * height again, and one shared `gap-3` puts both gaps level on its own. */}
1907
+ {/* THE COUNT LINE IS EVERY VIEW'S (user 2026-09-23) — and in the grid it carries the tile-size
1908
+ * slider at its right end, bare: no glyph, no readout (Finder's). */}
1909
+ {isWall ? (
1910
+ /* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
1911
+ * this row 8px taller than the fill budget, so the page scrolled by that much */
1912
+ <div className="flex items-center justify-between gap-4 h-4">
1913
+ <p className="kol-mono-12 text-fg-48">
1914
+ {wallFiles.length} {wallFiles.length === 1 ? 'file' : 'files'} · {formatSize(wallFiles.reduce((n, o) => n + (o.size ?? 0), 0))}
1915
+ </p>
1916
+ <input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
1917
+ onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
1918
+ </div>
1919
+ ) : appRoot && folderView === 'columns' && folderTree ? (
1920
+ <p className="kol-mono-12 text-fg-48">
1018
1921
  {buckets.length} buckets · {Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · {formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}
1019
1922
  </p>
1020
1923
  ) : (
1021
- <p className="kol-mono-12 text-fg-48 mt-2">
1924
+ <p className="kol-mono-12 text-fg-48">
1022
1925
  {folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
1023
1926
  {rawFiles.length} {rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
1024
- {(!prefix || flat) && rawFiles.length !== bucketFiles && <span className="text-fg-32">{' · bucket: '}{bucketFiles} files · {formatSize(bucketBytes)}</span>}
1927
+ {/* THE GREY TAIL TOTALS WHERE YOU STAND (kol-client-olina 2026-09-23): the bucket at the
1928
+ * root, the current folder recursively below it. Hidden when it would repeat the
1929
+ * bright part — a leaf has nothing beneath it to add. */}
1930
+ {!prefix && rawFiles.length !== bucketFiles && <span className="text-fg-32">{' · bucket: '}{bucketFiles} files · {formatSize(bucketBytes)}</span>}
1931
+ {prefix && scoped.length !== levelFiles.length && <span className="text-fg-32">{` · in ${crumbs.at(-1)}: `}{scoped.length} files · {formatSize(scoped.reduce((n, o) => n + (o.size || 0), 0))}</span>}
1025
1932
  {systemCount > 0 && <span className="text-fg-32">{' · '}{systemCount} system files hidden</span>}
1026
1933
  </p>
1027
1934
  )}
@@ -1040,6 +1947,15 @@ export function MediaLibraryBrowse({
1040
1947
  * not one of them — renaming five things to one name is not a thing — so it stays
1041
1948
  * single and disappears from a multi-selection rather than lying about what it does. */
1042
1949
  const many = (target.targets ?? []).length > 1 ? target.targets : null
1950
+ const custom = (fileActions.items ?? []).filter((it) => !it.when || it.when(target))
1951
+ const customItems = custom.length > 0 && (
1952
+ <>
1953
+ <MenuDropdownDivider />
1954
+ {custom.map((it) => (
1955
+ <MenuDropdownItem key={it.label} iconLeft={it.icon ? <Icon name={it.icon} size={14} /> : undefined} onClick={() => runAction(() => it.run(target))}>{it.label}</MenuDropdownItem>
1956
+ ))}
1957
+ </>
1958
+ )
1043
1959
  if (many) {
1044
1960
  return (
1045
1961
  <>
@@ -1048,9 +1964,16 @@ export function MediaLibraryBrowse({
1048
1964
  {fileActions.move && (
1049
1965
  <MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
1050
1966
  )}
1967
+ {many.every((p) => !p.endsWith('/')) && (
1968
+ <>
1969
+ <MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl(many)}>Copy {many.length} URLs</MenuDropdownItem>
1970
+ <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload(many)}>Download {many.length}</MenuDropdownItem>
1971
+ </>
1972
+ )}
1051
1973
  {fileActions.remove && (
1052
1974
  <MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doBatch(many, 'remove', 'Delete')}>Delete {many.length}</MenuDropdownItem>
1053
1975
  )}
1976
+ {customItems}
1054
1977
  </>
1055
1978
  )
1056
1979
  }
@@ -1066,6 +1989,11 @@ export function MediaLibraryBrowse({
1066
1989
  New file
1067
1990
  </MenuDropdownItem>
1068
1991
  )}
1992
+ {dropFilesTo && !(target.type === 'file') && (
1993
+ <MenuDropdownItem iconLeft={<Icon name="upload" size={14} />} onClick={() => doUpload(target.path)}>
1994
+ {isFolder ? `Upload into ${target.path.replace(/\/$/, '').split('/').pop()}…` : 'Upload here…'}
1995
+ </MenuDropdownItem>
1996
+ )}
1069
1997
  {!isLevel && <MenuDropdownDivider />}
1070
1998
  {!isLevel && fileActions.rename && (
1071
1999
  <MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => doRename(target.path, isFolder)}>Rename</MenuDropdownItem>
@@ -1073,12 +2001,23 @@ export function MediaLibraryBrowse({
1073
2001
  {!isLevel && fileActions.move && (
1074
2002
  <MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doMove(target.path)}>Move to…</MenuDropdownItem>
1075
2003
  )}
2004
+ {/* A FOLDER GETS COPY URL TOO (user 2026-09-23) — through the SAME `mediaUrl`
2005
+ seam a file goes through, so what a folder URL means is the consumer's call
2006
+ (a CDN prefix, a listing, a signed path) and never this page's guess. No
2007
+ Download: a folder is not an object to fetch. */}
2008
+ {!isLevel && (
2009
+ <MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl([target.path])}>Copy URL</MenuDropdownItem>
2010
+ )}
2011
+ {target.type === 'file' && (
2012
+ <MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
2013
+ )}
1076
2014
  {!isLevel && fileActions.remove && (
1077
2015
  <>
1078
2016
  <MenuDropdownDivider />
1079
2017
  <MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doDelete(target.path, isFolder)}>Delete</MenuDropdownItem>
1080
2018
  </>
1081
2019
  )}
2020
+ {customItems}
1082
2021
  </>
1083
2022
  )
1084
2023
  }}
@@ -1108,6 +2047,172 @@ export function MediaLibraryBrowse({
1108
2047
  )
1109
2048
  }
1110
2049
 
2050
+
2051
+ /* ── THE WALL, as a body ────────────────────────────────────────────────────
2052
+ * The grid / list of files, its paging and its inspector, lifted out of
2053
+ * `MediaLibraryLibrary` so ONE surface can render it beside the folder views
2054
+ * (the merge, user 2026-09-22: "they both just display files"). The page above
2055
+ * it owns the listing, the filtering and the selection; this owns the tiles.
2056
+ *
2057
+ * `renderName` / `renderActions` are seams because the standalone wall edits a
2058
+ * name in place and the merged surface does not — the markup is the same either
2059
+ * way, which is the whole reason this is one component and not two.
2060
+ */
2061
+ /* The kinds that ARE a document — the ones whose page is worth zooming into a tile. */
2062
+ const DOC_KINDS = new Set(['markdown', 'json', 'yaml', 'text', 'code'])
2063
+
2064
+ /* THE TILE MEASURES ITSELF (user 2026-09-23: *"is nothing about the thumbnails responsive?"*).
2065
+ * A document in a tile is zoomed, and the zoom was a fixed step — so the same file read one size
2066
+ * in a 260px card and another in a 400px one, because `zoom` reflows the page: at zoom z a
2067
+ * `width: 100%` page lays out at box/z CSS pixels. Solving for a CONSTANT page width keeps the
2068
+ * text the same physical size at every tile size: z = box / PAGE. That number cannot be derived
2069
+ * in CSS from the box, which is why this measures.
2070
+ *
2071
+ * `PAGE` is the width the page is laid out AT, not a paper size: 720 is where the doc page's
2072
+ * 24px padding, prose measure and code blocks read as a page rather than as a column of wrap. */
2073
+ /* CODE IS NOT PROSE, so it is not laid out on a prose page (user 2026-09-23: *"code is small here
2074
+ * in the container"*). A narrower target page means a bigger zoom for the same box, which is what
2075
+ * makes 60-column code readable in a tile where a 720px page turned it into texture. */
2076
+ const DOC_PAGE_W = 720
2077
+ const CODE_PAGE_W = 460
2078
+ const pageWidthFor = (kind) => (kind === 'markdown' || kind === 'text' ? DOC_PAGE_W : CODE_PAGE_W)
2079
+ const DOC_ZOOM_MIN = 0.18
2080
+ const DOC_ZOOM_MAX = 0.6
2081
+
2082
+ function DocThumb({ children, className, onClick, pageWidth = DOC_PAGE_W }) {
2083
+ const box = useRef(null)
2084
+ const [zoom, setZoom] = useState(null)
2085
+ useEffect(() => {
2086
+ const el = box.current
2087
+ if (!el || typeof ResizeObserver === 'undefined') return undefined
2088
+ const measure = () => {
2089
+ const w = el.getBoundingClientRect().width
2090
+ if (!w) return
2091
+ setZoom(Math.min(DOC_ZOOM_MAX, Math.max(DOC_ZOOM_MIN, Math.round((w / pageWidth) * 100) / 100)))
2092
+ }
2093
+ measure()
2094
+ const ro = new ResizeObserver(measure)
2095
+ ro.observe(el)
2096
+ return () => ro.disconnect()
2097
+ }, [pageWidth])
2098
+ return (
2099
+ <div ref={box} className={className} onClick={onClick} style={zoom ? { '--kol-thumb-zoom': zoom } : undefined}>
2100
+ {children}
2101
+ </div>
2102
+ )
2103
+ }
2104
+
2105
+ export function WallBody({
2106
+ files, layout = 'list', sortBy = 'name', sortDir = 'asc', pageSize = 200, listId = '', cardMin = 260,
2107
+ mediaUrl, downloadUrl, keySet, videoPreview, formatDate = defaultFormatDate,
2108
+ selected, selectMode = false, onToggleSelect, onSorted, onContextMenu, onPick,
2109
+ /* ONE INSPECTOR (user 2026-09-23). Given `onOpen`, the wall hands the open UP and keeps no
2110
+ * lightbox of its own — otherwise a page that also opens one (space, a row double-click) ends up
2111
+ * with two stacked overlays of the same file. The standalone wall passes nothing and keeps its
2112
+ * own, exactly as it was. */
2113
+ onOpen,
2114
+ renderName = (o) => o.displayKey?.split('/').pop() ?? o.key.split('/').pop(),
2115
+ renderActions,
2116
+ /* `tiles="media"` (2026-09-23) — the file manager's grid: `MediaTile`, preview over name, no card
2117
+ * and no buttons (the verbs live in the right-click menu and Quick Look). `card` is the site
2118
+ * wall's `ContentCard` and stays the default, so an embedded wall renders exactly as before. */
2119
+ tiles = 'card',
2120
+ /* the grid's drag-select band and its empty-space click — both the page's, like the rows' */
2121
+ marquee, onBackgroundClick,
2122
+ }) {
2123
+ const PAGE = pageSize || Infinity
2124
+ const [visible, setVisible] = useState(PAGE)
2125
+ const [lastListId, setLastListId] = useState(listId)
2126
+ const [lightboxIndex, setLightboxIndex] = useState(null)
2127
+ if (listId !== lastListId) { setLastListId(listId); setVisible(PAGE) }
2128
+ if (layout === 'off') return null
2129
+
2130
+ /* ONE PREVIEWER — `KindPreview`, as the column pane uses. An image is the caller's <img>, a
2131
+ * video keeps its sibling poster when the bucket has one, and every other kind falls through. */
2132
+ const renderThumb = (o, onClick) => {
2133
+ const kind = kindOf(o)
2134
+ const poster = (isVideo(o.contentType) || kind === 'playlist') ? posterFor(o.key, keySet) : null
2135
+ const imgSrc = isImage(o.contentType) ? mediaUrl(o.key) : poster && videoPreview !== 'none' ? mediaUrl(poster) : null
2136
+ if (imgSrc) return <img src={imgSrc} alt="" loading="lazy" className={onClick ? 'cursor-zoom-in' : undefined} onClick={onClick || undefined} />
2137
+ if (isVideo(o.contentType) && videoPreview === 'none') {
2138
+ return (
2139
+ <div className={`w-full h-full flex items-center justify-center bg-oq-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`} onClick={onClick || undefined}>
2140
+ <span className="kol-mono-12 text-fg-48">video</span>
2141
+ </div>
2142
+ )
2143
+ }
2144
+ /* ONE LADDER (user 2026-09-23). Every kind that is not the caller's own image goes through
2145
+ * `KindPreview` at `fit="tile"` — audio gets the same tile the pane shows, a PDF its first
2146
+ * page, a document its zoomed page. The glyph branch that stood here was a second ladder, and
2147
+ * a second ladder is the thing this pass exists to delete: a glyph is for a FOLDER. */
2148
+ const cls = `kol-media-thumb w-full h-full flex items-center justify-center bg-oq-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`
2149
+ const preview = <KindPreview o={o} fit="tile" urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
2150
+ if (DOC_KINDS.has(kind)) {
2151
+ return <DocThumb className={cls} pageWidth={pageWidthFor(kind)} onClick={onClick || undefined}>{preview}</DocThumb>
2152
+ }
2153
+ return <div className={cls} onClick={onClick || undefined}>{preview}</div>
2154
+ }
2155
+
2156
+ const sorted = sortFiles(files, sortBy, sortDir)
2157
+ onSorted?.(sorted)
2158
+ const shown = sorted.slice(0, visible)
2159
+ const more = sorted.length - shown.length
2160
+ const isSelected = (key) => !!selected?.has(key)
2161
+ const openAt = (o, idx) => (onOpen ? onOpen(o, sorted) : setLightboxIndex(idx))
2162
+ return (
2163
+ <div className="flex flex-col gap-3">
2164
+ {sorted.length === 0 ? null : layout !== 'grid' ? (
2165
+ <div className="flex flex-col">
2166
+ {shown.map((o, idx) => (
2167
+ <ContentRow key={o.key} variant="default" media={renderThumb(o, null)} title={renderName(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
2168
+ actions={renderActions?.(o, true)} selected={isSelected(o.key)}
2169
+ onContextMenu={onContextMenu ? (e) => onContextMenu(e, o) : undefined}
2170
+ onDoubleClick={() => openAt(o, idx)}
2171
+ onClick={selectMode ? (e) => onToggleSelect?.(idx, o.key, e.shiftKey) : onPick ? (e) => onPick(o, e, sorted.map((f) => f.key)) : undefined} />
2172
+ ))}
2173
+ </div>
2174
+ ) : tiles === 'media' ? (
2175
+ <div ref={marquee?.ref} {...marquee?.props} className="relative grid gap-x-3 gap-y-5"
2176
+ style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
2177
+ onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) onBackgroundClick?.() }}>
2178
+ {marquee?.rect && <div className="kol-marquee" style={marquee.rect} />}
2179
+ {shown.map((o, idx) => (
2180
+ <MediaTile key={o.key} markKey={o.key} preview={renderThumb(o, null)} name={renderName(o)} selected={isSelected(o.key)}
2181
+ onContextMenu={onContextMenu ? (e) => onContextMenu(e, o) : undefined}
2182
+ onDoubleClick={() => openAt(o, idx)}
2183
+ onClick={onPick ? (e) => onPick(o, e, sorted.map((f) => f.key)) : undefined} />
2184
+ ))}
2185
+ </div>
2186
+ ) : (
2187
+ <div className="grid gap-3" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}>
2188
+ {shown.map((o, idx) => (
2189
+ <ContentCard key={o.key} variant="default" media={renderThumb(o, null)}
2190
+ control={<ActionButton chrome="media" icon="download" confirmIcon="check" label="Download" confirmLabel="Downloaded" href={downloadUrl(o.key)} />}
2191
+ controlStart={selectMode ? <ToggleCheckbox variant="media" checked={isSelected(o.key)} onChange={() => onToggleSelect?.(idx, o.key, false)} onClick={(e) => e.stopPropagation()} aria-label={`Select ${o.key}`} /> : undefined}
2192
+ title={renderName(o)} date={formatDate(o.uploaded)} size={<SizeOrDownload href={downloadUrl(o.key)}>{formatSize(o.size)}</SizeOrDownload>}
2193
+ actions={renderActions?.(o)} selected={isSelected(o.key)}
2194
+ onContextMenu={onContextMenu ? (e) => onContextMenu(e, o) : undefined}
2195
+ /* ONE GESTURE SET ACROSS THE THREE VIEWS (2026-09-23): click selects, double-click
2196
+ * opens, right-click is the menu. The media used to open on a single click, which
2197
+ * made the grid the one view where a click did something else. */
2198
+ onDoubleClick={() => openAt(o, idx)}
2199
+ onClick={selectMode ? (e) => onToggleSelect?.(idx, o.key, e.shiftKey) : onPick ? (e) => onPick(o, e, sorted.map((f) => f.key)) : undefined} />
2200
+ ))}
2201
+ </div>
2202
+ )}
2203
+ {more > 0 && (
2204
+ <button type="button" onClick={() => setVisible((v) => v + PAGE)} className="kol-mono-12 text-fg-48 hover:text-fg-default transition-colors self-start py-2">
2205
+ Show {Math.min(more, PAGE)} more · {more} remaining
2206
+ </button>
2207
+ )}
2208
+ {lightboxIndex !== null && !onOpen && (
2209
+ <MediaInspector files={sorted} index={lightboxIndex} onClose={() => setLightboxIndex(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
2210
+ onPrev={() => setLightboxIndex((i) => (i - 1 + sorted.length) % sorted.length)} onNext={() => setLightboxIndex((i) => (i + 1) % sorted.length)} />
2211
+ )}
2212
+ </div>
2213
+ )
2214
+ }
2215
+
1111
2216
  /* ══ LIBRARY — the content-filters wall ═══════════════════════════════════ */
1112
2217
  export function MediaLibraryLibrary({
1113
2218
  client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
@@ -1135,8 +2240,9 @@ export function MediaLibraryLibrary({
1135
2240
  const bucketId = bucket ?? ownBucket
1136
2241
  const lib = useBucketLibrary({ client, bucket: bucketId, defaults, settings: settingsProp, onSettingsChange, refreshKey })
1137
2242
  const { buckets, bucketMeta, settings, setSettings, objects, setObjects, error, mediaUrl, downloadUrl, writable } = lib
2243
+ /* the same one dialog idiom as the browse page — see its note on `useModal()` */
2244
+ const modal = useModal()
1138
2245
  const [settingsOpen, setSettingsOpen] = useState(false)
1139
- const [lightboxIndex, setLightboxIndex] = useState(null)
1140
2246
  const [editingKey, setEditingKey] = useState(null)
1141
2247
  const [editingValue, setEditingValue] = useState('')
1142
2248
  const [renaming, setRenaming] = useState(false)
@@ -1150,11 +2256,7 @@ export function MediaLibraryLibrary({
1150
2256
  const kinds = new Set(settings.kinds)
1151
2257
  const setFlat = (v) => setSettings({ ...settings, flat: v })
1152
2258
  const setLayout = (v) => setSettings({ ...settings, layout: v })
1153
- const PAGE = pageSize || Infinity
1154
- const [visible, setVisible] = useState(PAGE)
1155
2259
  const listId = `${prefix}|${flat}|${[...kinds].sort().join(',')}|${pageSize}|${refreshKey}|${bucketMeta.id}`
1156
- const [lastListId, setLastListId] = useState(listId)
1157
- if (listId !== lastListId) { setLastListId(listId); setVisible(PAGE) }
1158
2260
  const handleSort = (field) => setSettings(sortBy === field ? { ...settings, sortDir: sortDir === 'asc' ? 'desc' : 'asc' } : { ...settings, sortBy: field, sortDir: 'asc' })
1159
2261
 
1160
2262
  useEffect(() => {
@@ -1183,8 +2285,8 @@ export function MediaLibraryLibrary({
1183
2285
 
1184
2286
  const handleCopy = (key) => navigator.clipboard.writeText(mediaUrl(key))
1185
2287
  const handleDelete = async (key) => {
1186
- if (!confirm(`Delete "${key}"? This cannot be undone.`)) return
1187
- try { await client.deleteObject(key); setObjects((prev) => prev.filter((o) => o.key !== key)) } catch (e) { alert(`Delete failed: ${e.message}`) }
2288
+ if (!(await modal.confirm(`Delete "${key}"? This cannot be undone.`, { okLabel: 'Delete' }))) return
2289
+ try { await client.deleteObject(key); setObjects((prev) => prev.filter((o) => o.key !== key)) } catch (e) { await modal.alert(`Delete failed: ${e.message}`) }
1188
2290
  }
1189
2291
  const startRename = (key) => { setEditingKey(key); setEditingValue(key) }
1190
2292
  const cancelRename = () => { setEditingKey(null); setEditingValue('') }
@@ -1193,7 +2295,7 @@ export function MediaLibraryLibrary({
1193
2295
  if (!from || !to || from === to) { cancelRename(); return }
1194
2296
  setRenaming(true)
1195
2297
  try { const r = await client.renameObject(from, to); const newKey = r?.to || to; setObjects((prev) => prev.map((o) => (o.key === from ? { ...o, key: newKey } : o))); setEditingKey(null); setEditingValue('') }
1196
- catch (e) { alert(`Rename failed: ${e.message}`) } finally { setRenaming(false) }
2298
+ catch (e) { await modal.alert(`Rename failed: ${e.message}`) } finally { setRenaming(false) }
1197
2299
  }
1198
2300
  const toggleSelect = (idx, key, shift) => {
1199
2301
  setSelected((prev) => {
@@ -1208,8 +2310,8 @@ export function MediaLibraryLibrary({
1208
2310
  const selectAll = () => setSelected(new Set(sortedRef.current.map((o) => o.key)))
1209
2311
  const exitSelect = () => { setSelectMode(false); setSelected(new Set()); setLastIdx(null) }
1210
2312
  const batchMove = async () => {
1211
- const folder = prompt(`Move ${selected.size} file(s) into folder (under ${prefix || 'root'}):`)
1212
- if (folder === null) return
2313
+ const folder = await modal.prompt(`Move ${selected.size} file(s) into folder (under ${prefix || 'root'}):`, '', { okLabel: 'Move' })
2314
+ if (folder == null) return
1213
2315
  const clean = folder.replace(/^\/+|\/+$/g, '').trim()
1214
2316
  if (!clean) return
1215
2317
  setBusy(true)
@@ -1221,16 +2323,16 @@ export function MediaLibraryLibrary({
1221
2323
  }
1222
2324
  setObjects((prev) => prev.map((o) => (remap.has(o.key) ? { ...o, key: remap.get(o.key) } : o)))
1223
2325
  setBusy(false); exitSelect()
1224
- if (failures.length) alert(`Moved ${remap.size}. ${failures.length} failed:\n${failures.join('\n')}`)
2326
+ if (failures.length) await modal.alert(`Moved ${remap.size}. ${failures.length} failed:\n${failures.join('\n')}`)
1225
2327
  }
1226
2328
  const batchDelete = async () => {
1227
- if (!confirm(`Delete ${selected.size} file(s)? This cannot be undone.`)) return
2329
+ if (!(await modal.confirm(`Delete ${selected.size} file(s)? This cannot be undone.`, { okLabel: 'Delete' }))) return
1228
2330
  setBusy(true)
1229
2331
  const done = []; const failures = []
1230
2332
  for (const key of selected) { try { await client.deleteObject(key); done.push(key) } catch (e) { failures.push(`${key.split('/').pop()}: ${e.message}`) } }
1231
2333
  setObjects((prev) => prev.filter((o) => !done.includes(o.key)))
1232
2334
  setBusy(false); exitSelect()
1233
- if (failures.length) alert(`Deleted ${done.length}. ${failures.length} failed:\n${failures.join('\n')}`)
2335
+ if (failures.length) await modal.alert(`Deleted ${done.length}. ${failures.length} failed:\n${failures.join('\n')}`)
1234
2336
  }
1235
2337
  const batchDownload = () => {
1236
2338
  for (const key of selected) { const a = document.createElement('a'); a.href = downloadUrl(key); a.download = key.split('/').pop(); document.body.appendChild(a); a.click(); a.remove() }
@@ -1268,36 +2370,6 @@ export function MediaLibraryLibrary({
1268
2370
  }
1269
2371
  return o.displayKey.split('/').pop()
1270
2372
  }
1271
- /* ONE PREVIEWER (2026-09-21). This was a hand-rolled ladder that knew image, a sibling poster
1272
- * and an autoloaded video, and printed the literal word "video" or "markdown" for everything
1273
- * else — while the column preview eight hundred lines up already called `KindPreview`, which
1274
- * renders markdown, code, json, yaml, audio and HLS for real. That is why every kind previewed
1275
- * in the column pane and nothing previewed in the grid or the list: two previewers for one
1276
- * object, and the wall's predates the component.
1277
- *
1278
- * Images stay the caller's, as they are in the column pane and as `KindPreview` documents —
1279
- * it has no image branch on purpose, because in a frame the frame draws its own. A video keeps
1280
- * its poster when the bucket has a sibling still, because a poster is cheaper than the file and
1281
- * `videoPreview` is the consumer's setting about exactly that. Everything else falls through. */
1282
- const renderThumb = (o, onClick) => {
1283
- const kind = kindOf(o)
1284
- const poster = (isVideo(o.contentType) || kind === 'playlist') ? posterFor(o.key, keySet) : null
1285
- const imgSrc = isImage(o.contentType) ? mediaUrl(o.key) : poster && videoPreview !== 'none' ? mediaUrl(poster) : null
1286
- if (imgSrc) return <img src={imgSrc} alt="" loading="lazy" className={onClick ? 'cursor-zoom-in' : undefined} onClick={onClick || undefined} />
1287
- if (isVideo(o.contentType) && videoPreview === 'none') {
1288
- return (
1289
- <div className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`} onClick={onClick || undefined}>
1290
- <span className="kol-mono-12 text-fg-48">video</span>
1291
- </div>
1292
- )
1293
- }
1294
- return (
1295
- <div className={`kol-media-thumb w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`} onClick={onClick || undefined}>
1296
- <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
1297
- </div>
1298
- )
1299
- }
1300
-
1301
2373
  const layoutOptions = [
1302
2374
  ...(writable ? [{ value: 'grid', label: selectMode ? 'CANCEL' : 'SELECT', active: !selectMode, title: 'Select multiple files', onClick: () => (selectMode ? exitSelect() : setSelectMode(true)) }] : []),
1303
2375
  { value: 'list', label: flat ? 'TREE' : 'FLAT', active: flat, title: 'Show all files recursively', onClick: () => setFlat(!flat) },
@@ -1350,44 +2422,13 @@ export function MediaLibraryLibrary({
1350
2422
  onLayoutChange={setLayout}
1351
2423
  trailingActions={<div className="flex items-center gap-6"><ViewToggle viewMode={layout} onViewChange={setLayout} variant="icon" options={LAYOUT_OPTIONS} /></div>}
1352
2424
  belowActions={layout === 'off' ? null : <div className="h-8 flex items-center"><SortControls options={SORT_OPTIONS} sortBy={sortBy} sortDir={sortDir} onSort={handleSort} /></div>}
1353
- renderItem={(filtered) => {
1354
- if (layout === 'off') return null
1355
- const sorted = sortFiles(filtered, sortBy, sortDir)
1356
- sortedRef.current = sorted
1357
- const shown = sorted.slice(0, visible)
1358
- const more = sorted.length - shown.length
1359
- return (
1360
- <div className="flex flex-col gap-3">
1361
- {sorted.length === 0 ? null : layout !== 'grid' ? (
1362
- <div className="flex flex-col">
1363
- {shown.map((o, idx) => (
1364
- <ContentRow key={o.key} variant="default" media={renderThumb(o, selectMode ? null : () => setLightboxIndex(idx))} title={renderNameCell(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
1365
- actions={renderActions(o, true)} selected={selected.has(o.key)} onClick={selectMode ? (e) => toggleSelect(idx, o.key, e.shiftKey) : undefined} />
1366
- ))}
1367
- </div>
1368
- ) : (
1369
- <div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
1370
- {shown.map((o, idx) => (
1371
- <ContentCard key={o.key} variant="default" media={renderThumb(o, selectMode ? null : () => setLightboxIndex(idx))}
1372
- control={<ActionButton chrome="media" icon="download" confirmIcon="check" label="Download" confirmLabel="Downloaded" href={downloadUrl(o.key)} />}
1373
- controlStart={selectMode ? <ToggleCheckbox variant="media" checked={selected.has(o.key)} onChange={() => toggleSelect(idx, o.key, false)} onClick={(e) => e.stopPropagation()} aria-label={`Select ${o.key}`} /> : undefined}
1374
- title={renderNameCell(o)} date={formatDate(o.uploaded)} size={<SizeOrDownload href={downloadUrl(o.key)}>{formatSize(o.size)}</SizeOrDownload>}
1375
- actions={renderActions(o)} selected={selected.has(o.key)} onClick={selectMode ? (e) => toggleSelect(idx, o.key, e.shiftKey) : undefined} />
1376
- ))}
1377
- </div>
1378
- )}
1379
- {more > 0 && (
1380
- <button type="button" onClick={() => setVisible((v) => v + PAGE)} className="kol-mono-12 text-fg-48 hover:text-fg-default transition-colors self-start py-2">
1381
- Show {Math.min(more, PAGE)} more · {more} remaining
1382
- </button>
1383
- )}
1384
- {lightboxIndex !== null && (
1385
- <MediaInspector files={sorted} index={lightboxIndex} onClose={() => setLightboxIndex(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
1386
- onPrev={() => setLightboxIndex((i) => (i - 1 + sorted.length) % sorted.length)} onNext={() => setLightboxIndex((i) => (i + 1) % sorted.length)} />
1387
- )}
1388
- </div>
1389
- )
1390
- }}
2425
+ /* ONE WALL BODY, shared with the merged surface (2026-09-22) */
2426
+ renderItem={(filtered) => (
2427
+ <WallBody files={filtered} layout={layout} sortBy={sortBy} sortDir={sortDir} pageSize={pageSize} listId={listId}
2428
+ mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} videoPreview={videoPreview} formatDate={formatDate}
2429
+ selected={selected} selectMode={selectMode} onToggleSelect={toggleSelect} onSorted={(list) => { sortedRef.current = list }}
2430
+ renderName={renderNameCell} renderActions={renderActions} />
2431
+ )}
1391
2432
  />
1392
2433
 
1393
2434
  {/* the pill again — the wall is one of the surfaces it switches, so it