@kolkrabbi/kol-component 0.215.0 → 0.217.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/atoms/ActionButton.jsx +10 -9
- package/src/atoms/FileIcon.jsx +31 -0
- package/src/atoms/ViewToggle.jsx +35 -17
- package/src/hooks/useMarquee.js +93 -0
- package/src/index.js +5 -0
- package/src/molecules/AudioPreview.jsx +4 -2
- package/src/molecules/AudioSheet.jsx +22 -36
- package/src/molecules/CodeBlock.jsx +4 -2
- package/src/molecules/ContentCard.jsx +13 -0
- package/src/molecules/ContentRow.jsx +9 -0
- package/src/molecules/DocFrontmatter.jsx +1 -1
- package/src/molecules/KindPreview.jsx +91 -5
- package/src/molecules/MediaTile.jsx +29 -0
- package/src/molecules/Modal.jsx +25 -12
- package/src/molecules/PdfPage.jsx +110 -0
- package/src/molecules/PlaybackBar.jsx +16 -8
- package/src/molecules/QuickLookFrame.jsx +88 -0
- package/src/molecules/ShellDrawer.jsx +10 -4
- package/src/molecules/Stepper.jsx +2 -2
- package/src/molecules/VideoSheet.jsx +12 -11
- package/src/organisms/ColumnBrowser.jsx +236 -27
- package/src/organisms/ContentFilters.jsx +29 -13
- package/src/organisms/MediaLibrary.jsx +25 -12
- package/src/organisms/MediaLibraryExplorer.jsx +31 -0
- package/src/organisms/MediaLibraryPages.jsx +1662 -198
- package/src/organisms/SettingsPanel.jsx +16 -6
- package/src/utilities/ContextMenu.jsx +80 -0
- package/src/utilities/FullscreenOverlay.jsx +17 -3
- package/src/utilities/Popover.jsx +11 -4
- package/src/utilities/id3.js +3 -0
- package/src/utilities/layerStack.js +18 -0
- package/src/utilities/mediaKinds.js +10 -4
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import formatSize from '../utilities/formatSize.js'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
@@ -11,20 +11,27 @@ 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'
|
|
17
18
|
import MobileTabBar, { TABBAR_H } from '../molecules/MobileTabBar.jsx'
|
|
18
19
|
import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
|
|
20
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
21
|
+
import ContextMenu, { useContextMenu } from '../utilities/ContextMenu.jsx'
|
|
22
|
+
import useMarquee from '../hooks/useMarquee.js'
|
|
19
23
|
import KindPreview from '../molecules/KindPreview.jsx'
|
|
20
24
|
import AudioSheet from '../molecules/AudioSheet.jsx'
|
|
21
25
|
import VideoSheet from '../molecules/VideoSheet.jsx'
|
|
26
|
+
import QuickLookFrame from '../molecules/QuickLookFrame.jsx'
|
|
22
27
|
import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
23
28
|
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
29
|
+
import { useModal } from '../molecules/Modal.jsx'
|
|
24
30
|
import ContentFilters from './ContentFilters.jsx'
|
|
25
|
-
import
|
|
31
|
+
import ShellSearchOverlay from './ShellSearchOverlay.jsx'
|
|
32
|
+
import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
|
|
26
33
|
import SettingsPanel, { LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
|
|
27
|
-
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'
|
|
28
35
|
import { nearestRatio } from '../utilities/ratios.js'
|
|
29
36
|
|
|
30
37
|
/* taxonomy-ok: organism — the two media pages composed of DS parts. */
|
|
@@ -58,6 +65,15 @@ import { nearestRatio } from '../utilities/ratios.js'
|
|
|
58
65
|
* controlled through `settings` / `onSettingsChange` so a consumer persists them.
|
|
59
66
|
*/
|
|
60
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
|
+
|
|
61
77
|
/* ── the settings model — kol-r2b2's lib/settings.js BASE, verbatim ───────── */
|
|
62
78
|
export const ALL_KINDS = [...KINDS, 'segments', 'system']
|
|
63
79
|
export const SETTINGS_BASE = {
|
|
@@ -71,19 +87,34 @@ export const SETTINGS_BASE = {
|
|
|
71
87
|
folderView: 'columns', // 'rows' | 'columns'
|
|
72
88
|
sortBy: 'name',
|
|
73
89
|
sortDir: 'asc',
|
|
74
|
-
columnHeight: 528,
|
|
90
|
+
columnHeight: 528, // px, or any CSS length — forwarded to ColumnBrowser `height` untouched
|
|
75
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
|
|
76
97
|
}
|
|
77
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
|
+
|
|
78
113
|
const LAYOUT_OPTIONS = [
|
|
79
114
|
{ value: 'grid', label: 'Grid', icon: 'grid' },
|
|
80
115
|
{ value: 'list', label: 'List', icon: 'view-list' },
|
|
81
116
|
{ value: 'off', label: 'Off', icon: 'eye-off' },
|
|
82
117
|
]
|
|
83
|
-
const FOLDER_VIEW_OPTIONS = [
|
|
84
|
-
{ value: 'rows', label: 'Rows', icon: 'view-list' },
|
|
85
|
-
{ value: 'columns', label: 'Columns', icon: 'columns' },
|
|
86
|
-
]
|
|
87
118
|
/* the stack view the `···` switches (item 15) — the same two ColumnBrowser
|
|
88
119
|
* carries below `md`, named as the references name them */
|
|
89
120
|
const STACK_VIEW_OPTIONS = [
|
|
@@ -171,20 +202,242 @@ function ImageFrame({ src }) {
|
|
|
171
202
|
)
|
|
172
203
|
}
|
|
173
204
|
|
|
174
|
-
|
|
175
|
-
|
|
205
|
+
|
|
206
|
+
/* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
|
|
207
|
+
* disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
|
|
208
|
+
* that navigates — so nothing that already renders these moves. */
|
|
209
|
+
function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
|
|
210
|
+
const [over, setOver] = useState(false)
|
|
211
|
+
/* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
|
|
212
|
+
* row highlights only while something is actually over it, so an accidental hover reads as
|
|
213
|
+
* nothing. Without `drag` every handler below is undefined and the row is exactly what it was. */
|
|
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) } },
|
|
224
|
+
onDragLeave: () => setOver(false),
|
|
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
|
+
},
|
|
230
|
+
} : {}
|
|
231
|
+
return (
|
|
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}>
|
|
235
|
+
{onToggle ? (
|
|
236
|
+
<button type="button" aria-label={expanded ? `Collapse ${name}` : `Expand ${name}`} aria-expanded={!!expanded}
|
|
237
|
+
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
|
238
|
+
className="w-4 h-4 shrink-0 flex items-center justify-center text-fg-32 hover:text-fg-default transition-transform"
|
|
239
|
+
style={{ transform: expanded ? 'rotate(90deg)' : 'none' }}>
|
|
240
|
+
<Icon name="chevron-right" size={12} />
|
|
241
|
+
</button>
|
|
242
|
+
) : <span className="w-4 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
|
+
</>}
|
|
260
|
+
</li>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* One expanded level, drawn inline under its folder, recursing for anything expanded beneath it —
|
|
265
|
+
* the disclosure behaviour a list view is expected to have. Depth only indents; the data comes from
|
|
266
|
+
* the same `rowLevel` the top level uses, so an expanded branch cannot disagree with the columns. */
|
|
267
|
+
function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder, onPickFolder, onOpenFile, onPickFile, formatDate, folderMeta, thumbnailFor, cols, onRowContextMenu, dragFor, dropFilesTo, pickedKey, currentKey, selectRow, order, selectedKeys }) {
|
|
268
|
+
const { folders, files } = rowLevel(level)
|
|
269
|
+
return (
|
|
270
|
+
<>
|
|
271
|
+
{folders.map((f) => {
|
|
272
|
+
const path = level + f
|
|
273
|
+
const open = expanded.has(path)
|
|
274
|
+
return (
|
|
275
|
+
<Fragment key={path}>
|
|
276
|
+
<FolderRow
|
|
277
|
+
name={f} depth={depth} expanded={open}
|
|
278
|
+
markKey={path}
|
|
279
|
+
meta={folderMeta?.(path) || undefined}
|
|
280
|
+
cols={cols}
|
|
281
|
+
onToggle={() => onToggle(path)}
|
|
282
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path, targets: selectedKeys?.has(path) ? [...selectedKeys] : [path] })}
|
|
283
|
+
drag={dragFor?.(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)}
|
|
292
|
+
/>
|
|
293
|
+
{open && <RowSubtree
|
|
294
|
+
level={path} depth={depth + 1} rowLevel={rowLevel} expanded={expanded} onToggle={onToggle}
|
|
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}
|
|
298
|
+
/>}
|
|
299
|
+
</Fragment>
|
|
300
|
+
)
|
|
301
|
+
})}
|
|
302
|
+
{files.map((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] })}
|
|
306
|
+
drag={dragFor?.(o.key)}
|
|
307
|
+
onClick={(e) => { if (!selectRow?.(o.key, e, order)) onPickFile(o) }}
|
|
308
|
+
onDoubleClick={() => onOpenFile(o)} />
|
|
309
|
+
))}
|
|
310
|
+
</>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* The file half of the row view. Row view listed FOLDERS ONLY and dropped `files` on the floor, so
|
|
315
|
+
* `#img/` drew two rows where the column browser drew four — the two folders, the empty folder the
|
|
316
|
+
* columns knew about, and a file. A list view that cannot show a file is not a list view. */
|
|
317
|
+
function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols, onContextMenu, drag, selected, markKey }) {
|
|
318
|
+
const dragProps = drag ? { draggable: true, onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, o.key) } } : {}
|
|
176
319
|
return (
|
|
177
|
-
<li className=
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
<
|
|
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}>
|
|
321
|
+
<span className="w-4 shrink-0" />
|
|
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
|
+
</>}
|
|
181
334
|
</li>
|
|
182
335
|
)
|
|
183
336
|
}
|
|
184
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
|
+
|
|
185
438
|
/**
|
|
186
439
|
* MediaInspector — the full-screen viewer for one file in a set: image, video
|
|
187
|
-
* (scrubbable, audio on), audio, or a document page,
|
|
440
|
+
* (scrubbable, audio on), audio, or a document page, in one `QuickLookFrame`, with
|
|
188
441
|
* prev / next fixed at the viewport edges.
|
|
189
442
|
*
|
|
190
443
|
* PUBLIC SINCE 2026-09-04 (user ruling, off `ColumnBrowserMobileViews`: the
|
|
@@ -206,10 +459,14 @@ function FolderRow({ name, onClick, struck = false }) {
|
|
|
206
459
|
* @param {Function} mediaUrl - `(key) => url` — the consumer's media resolver
|
|
207
460
|
* @param {Function} downloadUrl - `(key) => url` for the download control
|
|
208
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
|
|
209
463
|
*/
|
|
210
|
-
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 }) {
|
|
211
465
|
const o = files[index]
|
|
212
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)
|
|
213
470
|
const shownDims = dims && dims.key === o?.key ? dims : null
|
|
214
471
|
useEffect(() => {
|
|
215
472
|
const onKey = (e) => { if (e.key === 'ArrowLeft') onPrev(); if (e.key === 'ArrowRight') onNext() }
|
|
@@ -219,30 +476,53 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
219
476
|
if (!o) return null
|
|
220
477
|
const kind = kindOf(o)
|
|
221
478
|
const poster = kind === 'video' ? posterFor(o.key, keySet) : null
|
|
222
|
-
|
|
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
|
+
}
|
|
223
510
|
return (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
{
|
|
228
|
-
<img src={mediaUrl(o.key)} alt={o.displayKey} className="
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
) :
|
|
232
|
-
|
|
233
|
-
) :
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
{shownDims?.w && <span className="kol-mono-12 text-fg-32">{shownDims.w} × {shownDims.h} px</span>}
|
|
240
|
-
{shownDims?.len && <span className="kol-mono-12 text-fg-32">{formatLength(shownDims.len)}</span>}
|
|
241
|
-
<ActionButton chrome="inline" size="sm" icon="download" confirmIcon="check" label="Download" confirmLabel="Downloaded" href={downloadUrl(o.key)} />
|
|
242
|
-
</div>
|
|
243
|
-
<span className="kol-mono-10 text-fg-24">{index + 1} / {files.length}</span>
|
|
244
|
-
</div>
|
|
245
|
-
<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
|
+
)}
|
|
246
526
|
</FullscreenOverlay>
|
|
247
527
|
)
|
|
248
528
|
}
|
|
@@ -256,11 +536,19 @@ function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profi
|
|
|
256
536
|
const noVariants = profile.variantSets === 0
|
|
257
537
|
const noSegments = profile.segments === 0
|
|
258
538
|
return (
|
|
259
|
-
<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>}>
|
|
260
540
|
<LabeledControlSection label="Structure" rowGap={1} divided>
|
|
261
541
|
<SettingsRow label="Columns" hint="Finder-style columns instead of folder rows">
|
|
262
542
|
<SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} />
|
|
263
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>
|
|
264
552
|
<SettingsRow label="Flat" hint="ignore folders, show the whole subtree">
|
|
265
553
|
<SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} />
|
|
266
554
|
</SettingsRow>
|
|
@@ -316,18 +604,157 @@ function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profi
|
|
|
316
604
|
* and pushed the gear off the right edge — they resolved it by SWAPPING a
|
|
317
605
|
* control below `md` rather than adding one, which is the pattern to copy. If a
|
|
318
606
|
* consumer needs more than one at this width, it belongs in a sheet, not here. */
|
|
319
|
-
|
|
320
|
-
|
|
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 }))] : []
|
|
720
|
+
/* THE WORDMARK IS THE HOME BUTTON when `onHome` is given (user 2026-09-21: "it should reload,
|
|
721
|
+
* like a home button"). The crumb row's root already did exactly this and the wordmark above it
|
|
722
|
+
* did nothing — the one element every file manager makes clickable was the one that was inert.
|
|
723
|
+
* Without the prop it stays a plain <h1>, so nothing existing changes. */
|
|
321
724
|
return (
|
|
322
725
|
<header className="flex items-baseline justify-between gap-4">
|
|
323
|
-
|
|
726
|
+
{onHome ? (
|
|
727
|
+
<button type="button" onClick={onHome}
|
|
728
|
+
className="kol-sans-display-03 min-w-0 truncate text-left cursor-pointer transition-opacity hover:opacity-64"
|
|
729
|
+
aria-label={`${title} — back to the top`}>{title}</button>
|
|
730
|
+
) : (
|
|
731
|
+
<h1 className="kol-sans-display-03 min-w-0 truncate">{title}</h1>
|
|
732
|
+
)}
|
|
324
733
|
<div className="flex items-center gap-2 min-w-0">
|
|
325
734
|
{options.length > 0 && <Dropdown className="min-w-0 max-w-[45vw] md:w-48 md:max-w-none" value={appRoot ? 'all' : bucketId} onChange={onBucket} options={options} />}
|
|
326
735
|
{headerActions}
|
|
736
|
+
{/* The DS's own `Tooltip` (utilities/Popover → `.kol-tooltip`), not a `title`
|
|
737
|
+
attribute. These two were the only header chrome still handing the label to
|
|
738
|
+
the browser, which drew its native box beside DS-styled ones. */}
|
|
327
739
|
{!writable && bucketMeta.id && (
|
|
328
|
-
<
|
|
740
|
+
<Tooltip label={`${bucketMeta.label} is read-only here`}>
|
|
741
|
+
<IconFrame name="lock" variant="primary" size="sm" aria-label="Read-only" />
|
|
742
|
+
</Tooltip>
|
|
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>
|
|
329
750
|
)}
|
|
330
|
-
<
|
|
751
|
+
<Tooltip label="Display settings">
|
|
752
|
+
<IconFrame name="settings-01" variant="primary" size="sm" onClick={onSettings} aria-label="Display settings" />
|
|
753
|
+
</Tooltip>
|
|
754
|
+
{/* AFTER the icons, because the crumb row below reads `[icons] │ ROW COLUMN` and a text
|
|
755
|
+
* switch in this surface sits to the RIGHT of its divider. Put before them it mirrored
|
|
756
|
+
* the row directly beneath it. */}
|
|
757
|
+
{headerTrailing}
|
|
331
758
|
</div>
|
|
332
759
|
</header>
|
|
333
760
|
)
|
|
@@ -370,7 +797,36 @@ function sortObjects(objects, sortBy = 'name', sortDir = 'asc') {
|
|
|
370
797
|
|
|
371
798
|
export function MediaLibraryBrowse({
|
|
372
799
|
client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
|
|
373
|
-
|
|
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,
|
|
804
|
+
folderTree, headerActions, headerTrailing, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '', banner,
|
|
805
|
+
/* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
|
|
806
|
+
* optional and each async; whatever is supplied becomes a right-click menu entry and
|
|
807
|
+
* anything absent simply is not offered. One object rather than four props because they
|
|
808
|
+
* arrive together — a consumer with a writable store has all of them, one with a read-only
|
|
809
|
+
* bucket has none — and because the page must never have to know WHICH verbs exist to
|
|
810
|
+
* decide whether to draw a menu at all. The consumer re-lists after a mutation by bumping
|
|
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. */
|
|
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,
|
|
374
830
|
/* PASS-THROUGHS TO `ColumnBrowser`. A documented prop this page does not
|
|
375
831
|
* forward is a prop no consumer of the PAGE can reach, which makes the seam
|
|
376
832
|
* fictional — `thumbnailFor` and `folderMeta` shipped, were announced, and
|
|
@@ -399,10 +855,493 @@ export function MediaLibraryBrowse({
|
|
|
399
855
|
const { buckets, bucketMeta, settings, setSettings, objects, error, mediaUrl, downloadUrl, writable } = lib
|
|
400
856
|
const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
|
|
401
857
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
858
|
+
const [trashOpen, setTrashOpen] = useState(false)
|
|
402
859
|
const [pickedFile, setPickedFile] = useState(null)
|
|
860
|
+
/* ROW VIEW PICKS, and only one at a time: a click selects and fills the preview, a DOUBLE click
|
|
861
|
+
* opens (user 2026-09-22, Finder's behaviour — "clicking shouldnt automatically open it"). The
|
|
862
|
+
* chevron still expands in place, which is the third thing and always was. */
|
|
863
|
+
const [pickedFolder, setPickedFolder] = useState(null)
|
|
864
|
+
const pickFile = (o) => { setPickedFolder(null); setPickedFile(o) }
|
|
865
|
+
const pickFolder = (path) => { setPickedFile(null); setPickedFolder(path) }
|
|
866
|
+
/* IN THE ROWS, WHAT YOU PICK SETS WHERE YOU ARE (user 2026-09-23: *"that files folder becomes the
|
|
867
|
+
* default NOT the previous folder"*). The list is rooted at the top, so a pick can sit in any open
|
|
868
|
+
* folder; the current folder follows it to the pick's parent. Whatever the old path had open
|
|
869
|
+
* STAYS open — moving where you are must not fold up what you were looking at. */
|
|
870
|
+
const dirOf = (path) => { const t = path.replace(/\/$/, ''); return t.slice(0, t.lastIndexOf('/') + 1) }
|
|
871
|
+
const moveHereKeepingOpen = (to) => {
|
|
872
|
+
if (to === prefix) return
|
|
873
|
+
setExpandedRows((prev) => new Set([...prev, ...openChain(prefix)]))
|
|
874
|
+
setPrefix(to)
|
|
875
|
+
}
|
|
876
|
+
const rowPickFile = (o) => { pickFile(o); moveHereKeepingOpen(dirOf(o.key)) }
|
|
877
|
+
const rowPickFolder = (path) => { pickFolder(path); moveHereKeepingOpen(dirOf(path)) }
|
|
878
|
+
/* Row view's inline disclosure set — folder paths currently expanded. Kept here rather than in
|
|
879
|
+
* settings: it is a transient reading state, not a preference, and Finder does not persist it
|
|
880
|
+
* across a relaunch either. */
|
|
881
|
+
const [expandedRows, setExpandedRows] = useState(() => new Set())
|
|
882
|
+
/* THE ROW LIST IS ALWAYS BASED AT THE ROOT (user 2026-09-23: *"row view should not open a path
|
|
883
|
+
* of the folder its highlighted … rather expand the folders to that location, still showing the
|
|
884
|
+
* root"*). It used to re-base on `prefix`, so arriving from the columns three folders deep threw
|
|
885
|
+
* the tree away and showed one level. The path now seeds the EXPANSION instead: every ancestor
|
|
886
|
+
* opens, the folder you are in reads selected, and the root is still the first row.
|
|
887
|
+
*
|
|
888
|
+
* Collapsing a folder ON that path is how you leave it — the chevron cannot fight `prefix` and
|
|
889
|
+
* win, so it moves `prefix` up instead of flickering back open. */
|
|
890
|
+
const openChain = (p) => {
|
|
891
|
+
const out = []
|
|
892
|
+
let cur = ''
|
|
893
|
+
for (const seg of p.replace(/\/$/, '').split('/').filter(Boolean)) { cur += `${seg}/`; out.push(cur) }
|
|
894
|
+
return out
|
|
895
|
+
}
|
|
896
|
+
const rowsExpanded = new Set([...expandedRows, ...openChain(prefix)])
|
|
897
|
+
/* THE VISIBLE ORDER, flattened in render order, so ⇧-click ranges across expanded folders and
|
|
898
|
+
* not just the level you happen to be on. Built from the same `rowLevel` + expansion the list
|
|
899
|
+
* draws from, so it cannot disagree with what is on screen. */
|
|
900
|
+
const visibleRowOrder = (list) => {
|
|
901
|
+
const out = []
|
|
902
|
+
const walk = (level) => {
|
|
903
|
+
const { folders, files } = rowLevel(level, list)
|
|
904
|
+
for (const f of folders) {
|
|
905
|
+
const path = level + f
|
|
906
|
+
out.push(path)
|
|
907
|
+
if (rowsExpanded.has(path)) walk(path)
|
|
908
|
+
}
|
|
909
|
+
for (const o of files) out.push(o.key)
|
|
910
|
+
}
|
|
911
|
+
walk('')
|
|
912
|
+
return out
|
|
913
|
+
}
|
|
914
|
+
const toggleRow = (path) => {
|
|
915
|
+
if (openChain(prefix).includes(path)) { setPrefix(path.replace(/[^/]+\/$/, '')); return }
|
|
916
|
+
setExpandedRows((prev) => {
|
|
917
|
+
const next = new Set(prev)
|
|
918
|
+
next.has(path) ? next.delete(path) : next.add(path)
|
|
919
|
+
return next
|
|
920
|
+
})
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/* ── THE FILE VERBS ────────────────────────────────────────────────────────────────────────
|
|
924
|
+
* Right-click is the surface; `fileActions` is the implementation. The page owns neither the
|
|
925
|
+
* data nor the store — it collects an intent, asks for the one piece of text it needs, calls
|
|
926
|
+
* the consumer's verb and lets the consumer re-list. The asking goes through `useModal()` —
|
|
927
|
+
* the DS's one dialog idiom — so a consumer that mounts `ModalProvider` gets DS dialogs and one
|
|
928
|
+
* that does not gets the native ones, exactly as before (kol-client-olina 2026-09-22: the
|
|
929
|
+
* browser's own grey prompt box was the last non-DS surface on media.olina-productions.com). */
|
|
930
|
+
const menu = useContextMenu()
|
|
931
|
+
const modal = useModal()
|
|
932
|
+
const canWrite = !!fileActions && writable
|
|
933
|
+
const [busyAction, setBusyAction] = useState(false)
|
|
934
|
+
|
|
935
|
+
const runAction = async (fn) => {
|
|
936
|
+
if (!fn) return
|
|
937
|
+
setBusyAction(true)
|
|
938
|
+
try { await fn() } catch (e) { await modal.alert(e.message) } finally { setBusyAction(false) }
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const doCreateFolder = async (parent) => {
|
|
942
|
+
const name = await modal.prompt(`New folder in ${parent || 'the bucket root'}:`, '', { okLabel: 'Create' })
|
|
943
|
+
if (!name?.trim()) return
|
|
944
|
+
runAction(() => fileActions.createFolder(`${parent}${name.trim().replace(/^\/+|\/+$/g, '')}/`))
|
|
945
|
+
}
|
|
946
|
+
const doCreateFile = async (parent) => {
|
|
947
|
+
const name = await modal.prompt(`New file in ${parent || 'the bucket root'}:`, 'untitled.txt', { okLabel: 'Create' })
|
|
948
|
+
if (!name?.trim()) return
|
|
949
|
+
runAction(() => fileActions.createFile(`${parent}${name.trim().replace(/^\/+/, '')}`))
|
|
950
|
+
}
|
|
951
|
+
const doRename = async (path, isFolder) => {
|
|
952
|
+
const current = path.replace(/\/$/, '').split('/').pop()
|
|
953
|
+
const name = await modal.prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current, { okLabel: 'Rename' })
|
|
954
|
+
if (!name?.trim() || name === current) return
|
|
955
|
+
const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
|
|
956
|
+
runAction(() => fileActions.rename(path, `${parent}${name.trim()}${isFolder ? '/' : ''}`))
|
|
957
|
+
}
|
|
958
|
+
const doMove = async (path) => {
|
|
959
|
+
const dest = await modal.prompt(`Move into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
|
|
960
|
+
if (dest == null) return
|
|
961
|
+
const clean = dest ? `${dest.replace(/^\/+|\/+$/g, '')}/` : ''
|
|
962
|
+
runAction(async () => { await fileActions.move(path, clean); afterMove([path], clean) })
|
|
963
|
+
}
|
|
964
|
+
/* A MOVE KEEPS WHAT YOU MOVED IN HAND (user 2026-09-23: *"they should remain selected and new
|
|
965
|
+
* location shown"*). The set is re-keyed to where the items now live, and the target comes into
|
|
966
|
+
* view: expanded in place in the rows, navigated into in the columns and the grid. */
|
|
967
|
+
const afterMove = (paths, dest) => {
|
|
968
|
+
const moved = paths.map((p) => `${dest}${p.replace(/\/$/, '').split('/').pop()}${p.endsWith('/') ? '/' : ''}`)
|
|
969
|
+
setPickedFile(null); setPickedFolder(null)
|
|
970
|
+
setRowSelection(new Set(moved))
|
|
971
|
+
selectAnchorRef.current = moved[0] ?? null
|
|
972
|
+
if (view === 'rows') setExpandedRows((prev) => new Set([...prev, ...openChain(dest)]))
|
|
973
|
+
else setPrefix(dest)
|
|
974
|
+
}
|
|
975
|
+
/* DRAG TO MOVE. HTML5 drag-and-drop, not a pointer-tracking implementation: the rows are a list,
|
|
976
|
+
* the targets are folders, and the browser already does the hit-testing and the cursor. The
|
|
977
|
+
* dragged path lives in a ref rather than dataTransfer alone so `canDrop` can answer without
|
|
978
|
+
* reading the event — a folder may not be dropped into itself or into its own subtree, which is
|
|
979
|
+
* the one rule that makes this safe to wire straight to `move`. */
|
|
980
|
+
/* MULTI-SELECT in the browse views. ⌘/Ctrl-click adds one, ⇧-click takes the run between the
|
|
981
|
+
* anchor and the click, a plain click clears back to navigation. The wall has had its own SELECT
|
|
982
|
+
* mode since 0.119.0; this is the browse half, and it deliberately does NOT reuse that mode's
|
|
983
|
+
* chrome — there the wall IS the surface and a mode bar fits, here selection is a modifier on a
|
|
984
|
+
* tree you are still walking. The menu acts on the whole set when the clicked row is in it. */
|
|
985
|
+
const [rowSelection, setRowSelection] = useState(() => new Set())
|
|
986
|
+
const selectAnchorRef = useRef(null)
|
|
987
|
+
/* where the keyboard IS, apart from where a ⇧ run began — null means "at the anchor" */
|
|
988
|
+
const selectCursorRef = useRef(null)
|
|
989
|
+
/* DRAG A BAND ACROSS THE LIST (user 2026-09-22). The same set the ⌘/⇧ clicks build, so the menu
|
|
990
|
+
* acts on a dragged selection exactly as it does on a clicked one. */
|
|
991
|
+
const marqueeSelect = (keys, additive) => setRowSelection((prev) => (additive ? new Set([...prev, ...keys]) : new Set(keys)))
|
|
992
|
+
const rowsMarquee = useMarquee({ onSelect: marqueeSelect })
|
|
993
|
+
const gridMarquee = useMarquee({ onSelect: marqueeSelect })
|
|
994
|
+
|
|
995
|
+
const selectRow = (path, e, orderedPaths) => {
|
|
996
|
+
selectCursorRef.current = null
|
|
997
|
+
const additive = e.metaKey || e.ctrlKey
|
|
998
|
+
const ranged = e.shiftKey && selectAnchorRef.current
|
|
999
|
+
/* A PLAIN CLICK IS A SELECTION OF ONE (user 2026-09-23). It used to CLEAR the set and only
|
|
1000
|
+
* "pick" the row for the preview, so a row could look selected while the set was empty — and
|
|
1001
|
+
* a drag, which carries the set, then took one fewer item than the screen showed. The pick and
|
|
1002
|
+
* the selection are the same act; `false` still tells the caller to preview it. */
|
|
1003
|
+
if (!additive && !ranged) { setRowSelection(new Set([path])); selectAnchorRef.current = path; return false }
|
|
1004
|
+
e.preventDefault()
|
|
1005
|
+
setRowSelection((prev) => {
|
|
1006
|
+
const next = new Set(prev)
|
|
1007
|
+
if (ranged) {
|
|
1008
|
+
const a = orderedPaths.indexOf(selectAnchorRef.current)
|
|
1009
|
+
const b = orderedPaths.indexOf(path)
|
|
1010
|
+
if (a !== -1 && b !== -1) for (let i = Math.min(a, b); i <= Math.max(a, b); i++) next.add(orderedPaths[i])
|
|
1011
|
+
} else {
|
|
1012
|
+
next.has(path) ? next.delete(path) : next.add(path)
|
|
1013
|
+
selectAnchorRef.current = path
|
|
1014
|
+
}
|
|
1015
|
+
return next
|
|
1016
|
+
})
|
|
1017
|
+
return true
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/* The set the menu acts on: the selection when you right-clicked something inside it, otherwise
|
|
1021
|
+
* just the row you hit — which is what every file manager does and what stops a right-click on an
|
|
1022
|
+
* unrelated row from silently deleting five things. */
|
|
1023
|
+
const targetsFor = (path) => (rowSelection.has(path) ? [...rowSelection] : [path])
|
|
1024
|
+
|
|
1025
|
+
const doBatch = async (paths, verb, label) => {
|
|
1026
|
+
if (paths.length === 1) return null
|
|
1027
|
+
if (label === 'Delete' && !trash && !(await modal.confirm(`Delete ${paths.length} items? This cannot be undone.`, { okLabel: 'Delete' }))) return null
|
|
1028
|
+
let dest = null
|
|
1029
|
+
if (label === 'Move') {
|
|
1030
|
+
dest = await modal.prompt(`Move ${paths.length} items into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
|
|
1031
|
+
if (dest == null) return null
|
|
1032
|
+
dest = dest ? `${dest.replace(/^\/+|\/+$/g, '')}/` : ''
|
|
1033
|
+
}
|
|
1034
|
+
runAction(async () => {
|
|
1035
|
+
for (const p of paths) {
|
|
1036
|
+
if (label === 'Delete') await fileActions.remove(p)
|
|
1037
|
+
else await fileActions.move(p, dest)
|
|
1038
|
+
}
|
|
1039
|
+
if (label === 'Delete') setRowSelection(new Set())
|
|
1040
|
+
else afterMove(paths, dest)
|
|
1041
|
+
})
|
|
1042
|
+
void verb
|
|
1043
|
+
return true
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/* THE DRAG CARRIES THE SELECTION (user 2026-09-23: "if 3 are selected, drag and drop still only
|
|
1047
|
+
* grabs one"). Dragging a row that is IN the selection drags the whole set — which is what the
|
|
1048
|
+
* right-click menu already did, and the two must not disagree about what "the targets" means.
|
|
1049
|
+
* Dragging a row outside the selection is that row alone, as every file manager does. */
|
|
1050
|
+
const draggingRef = useRef(null)
|
|
1051
|
+
const dragFor = canWrite && fileActions.move ? (path) => ({
|
|
1052
|
+
path,
|
|
1053
|
+
onDragStart: (e, from) => {
|
|
1054
|
+
const set = rowSelection.has(from) ? [...rowSelection] : [from]
|
|
1055
|
+
draggingRef.current = set
|
|
1056
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
1057
|
+
e.dataTransfer.setData('text/plain', set.join('\n'))
|
|
1058
|
+
},
|
|
1059
|
+
/* every dragged path has to be legal, or the drop would half-happen */
|
|
1060
|
+
canDrop: (dest) => {
|
|
1061
|
+
const set = draggingRef.current
|
|
1062
|
+
if (!set?.length) return false
|
|
1063
|
+
/* ONLY A FOLDER TAKES A DROP. A file row is not a destination: moving `a.jpg` "into"
|
|
1064
|
+
* `b.json` renamed it to `b.jsona.jpg`-shaped keys (the file-onto-file bug, 2026-09-22). */
|
|
1065
|
+
if (dest && !dest.endsWith('/')) return false
|
|
1066
|
+
return set.every((from) => {
|
|
1067
|
+
if (from === dest) return false
|
|
1068
|
+
if (from.endsWith('/') && dest.startsWith(from)) return false // into itself
|
|
1069
|
+
const parent = from.replace(/\/$/, '').split('/').slice(0, -1).join('/')
|
|
1070
|
+
return dest !== (parent ? `${parent}/` : '') // already there
|
|
1071
|
+
})
|
|
1072
|
+
},
|
|
1073
|
+
onDrop: (dest) => {
|
|
1074
|
+
const set = draggingRef.current
|
|
1075
|
+
draggingRef.current = null
|
|
1076
|
+
if (!set?.length) return
|
|
1077
|
+
runAction(async () => {
|
|
1078
|
+
for (const from of set) await fileActions.move(from, dest)
|
|
1079
|
+
afterMove(set, dest)
|
|
1080
|
+
})
|
|
1081
|
+
},
|
|
1082
|
+
}) : null
|
|
1083
|
+
|
|
1084
|
+
/* the OS-drop target for a bucket-relative folder path, or null where nothing may land */
|
|
1085
|
+
const dropFilesTo = onDropFiles && writable ? (path) => (files) => onDropFiles(files, path) : null
|
|
1086
|
+
|
|
1087
|
+
/* UPLOAD FROM THE MENU (user 2026-09-22). The drop zone asked where files should go and never
|
|
1088
|
+
* said; right-click names the folder. The picker is the browser's own and the files go through
|
|
1089
|
+
* `onDropFiles`, the same seam a drag uses — the page still never uploads. */
|
|
1090
|
+
const doUpload = (path) => {
|
|
1091
|
+
const input = document.createElement('input')
|
|
1092
|
+
input.type = 'file'
|
|
1093
|
+
input.multiple = true
|
|
1094
|
+
input.onchange = () => { if (input.files?.length) runAction(() => onDropFiles(input.files, path)) }
|
|
1095
|
+
input.click()
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/* The wall's two read verbs, in the tree's menu too (user 2026-09-22: "in right click dropdown we
|
|
1099
|
+
* need copy url and download options, they are currently only availble in files mode"). Both are
|
|
1100
|
+
* the client's URLs, so neither needs a write seam — they are offered wherever a FILE is. */
|
|
1101
|
+
const doCopyUrl = (paths) => navigator.clipboard?.writeText(paths.map((p) => mediaUrl(p)).join('\n'))
|
|
1102
|
+
const doDownload = (paths) => {
|
|
1103
|
+
for (const p of paths) {
|
|
1104
|
+
const a = document.createElement('a')
|
|
1105
|
+
a.href = downloadUrl(p)
|
|
1106
|
+
a.download = p.split('/').pop()
|
|
1107
|
+
document.body.appendChild(a)
|
|
1108
|
+
a.click()
|
|
1109
|
+
a.remove()
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const doDelete = async (path, isFolder) => {
|
|
1114
|
+
if (!trash && !(await modal.confirm(`Delete ${isFolder ? `"${path}" and everything in it` : `"${path}"`}? This cannot be undone.`, { okLabel: 'Delete' }))) return
|
|
1115
|
+
runAction(() => fileActions.remove(path))
|
|
1116
|
+
}
|
|
1117
|
+
/* stepping into a folder drops the pick — the preview would otherwise describe something no
|
|
1118
|
+
* longer in the list */
|
|
1119
|
+
const goFolder = (path) => { setPickedFile(null); setPickedFolder(null); setPrefix(path) }
|
|
1120
|
+
/* SPACE IS QUICK LOOK IN EVERY VIEW (user 2026-09-23: *"spacebar preview only works in column,
|
|
1121
|
+
* fix that. CONSISTENCY"*). `ColumnBrowser` has owned that key since it shipped, so the columns
|
|
1122
|
+
* had it and the two views built later did not. The target is whatever is picked — the previewed
|
|
1123
|
+
* file, or a selection of exactly one — and the set it pages through is the list you are looking
|
|
1124
|
+
* at: the folder's files in rows, the wall's own list in grid. */
|
|
1125
|
+
/* QUICK LOOK PAGES THROUGH THE SELECTION (user 2026-09-23: *"multiple files selected should
|
|
1126
|
+
* show '1/2 <>' … because two files are highlighted"*). With more than one file selected the set
|
|
1127
|
+
* is the selection, in list order, starting on the file asked for; otherwise it is the list the
|
|
1128
|
+
* caller handed over. Every open goes through here so no view pages a different set. */
|
|
1129
|
+
const openQuickLook = (q) => {
|
|
1130
|
+
if (!q) { setQuickLook(null); return }
|
|
1131
|
+
/* in the order on screen when the caller's list holds the whole selection, else bucket order */
|
|
1132
|
+
const inView = q.files.filter((o) => rowSelection.has(o.key))
|
|
1133
|
+
const sel = inView.length === rowSelection.size ? inView : objects.filter((o) => rowSelection.has(o.key))
|
|
1134
|
+
const current = q.files[q.index]
|
|
1135
|
+
if (sel.length > 1) {
|
|
1136
|
+
const index = Math.max(0, sel.findIndex((o) => o.key === current?.key))
|
|
1137
|
+
setQuickLook({ files: sel, index })
|
|
1138
|
+
return
|
|
1139
|
+
}
|
|
1140
|
+
setQuickLook(q)
|
|
1141
|
+
}
|
|
1142
|
+
/* QUICK LOOK STEPS THE SELECTION TOO (user 2026-09-23: *"when quicklook preview is open on
|
|
1143
|
+
* selected item and arrow right focuses new item, that item is being selected"*). Paging a folder,
|
|
1144
|
+
* the file you land on becomes the selection and the pick — so closing the window leaves you on
|
|
1145
|
+
* it. Paging a multi-selection, the set is what you are looking through and stays whole. */
|
|
1146
|
+
/* closing hands focus to the SELECTION, not back to the tile that opened the window — after
|
|
1147
|
+
* stepping they are different tiles, and the overlay's own focus-return would light the old one */
|
|
1148
|
+
const closeQuickLook = () => {
|
|
1149
|
+
setQuickLook(null)
|
|
1150
|
+
requestAnimationFrame(() => {
|
|
1151
|
+
const key = selectAnchorRef.current
|
|
1152
|
+
const el = key && document.querySelector(`[data-marquee-key="${CSS.escape(key)}"]`)
|
|
1153
|
+
if (el?.tabIndex >= 0) el.focus({ preventScroll: true })
|
|
1154
|
+
})
|
|
1155
|
+
}
|
|
1156
|
+
const stepQuickLook = (dir) => {
|
|
1157
|
+
if (!quickLook) return
|
|
1158
|
+
const { files } = quickLook
|
|
1159
|
+
const index = (quickLook.index + dir + files.length) % files.length
|
|
1160
|
+
setQuickLook({ ...quickLook, index })
|
|
1161
|
+
const o = files[index]
|
|
1162
|
+
if (o && rowSelection.size <= 1) {
|
|
1163
|
+
setRowSelection(new Set([o.key])); selectAnchorRef.current = o.key
|
|
1164
|
+
setPickedFolder(null); setPickedFile(o)
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const spaceQuickLook = () => {
|
|
1168
|
+
const key = pickedFile?.key ?? pickedFolder ?? (rowSelection.size === 1 ? [...rowSelection][0] : null)
|
|
1169
|
+
if (!key) return
|
|
1170
|
+
/* A FOLDER QUICK-LOOKS TOO (user 2026-09-23: *"anything can be quick viewed, also folders"*) —
|
|
1171
|
+
* the same pane the preview column draws, at overlay size. */
|
|
1172
|
+
if (key.endsWith('/')) { setQuickLookFolder(key); return }
|
|
1173
|
+
const files = isWall
|
|
1174
|
+
? wallFiles
|
|
1175
|
+
: rowLevel(key.slice(0, key.lastIndexOf('/') + 1), sortedObjects).files
|
|
1176
|
+
const index = files.findIndex((f) => f.key === key)
|
|
1177
|
+
if (index === -1) return
|
|
1178
|
+
openQuickLook({ files, index })
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/* SEARCH IS THE DS'S OWN PALETTE (2026-09-22). `/` is the file-manager habit and ⌘K the app one,
|
|
1182
|
+
* so both open it; it searches the bucket's keys and JUMPS — it does not filter the list under
|
|
1183
|
+
* you, which is what the filter bar is for. */
|
|
1184
|
+
const [searchOpen, setSearchOpen] = useState(false)
|
|
1185
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
1186
|
+
/* THE ARROWS IN THE GRID AND THE ROWS (user 2026-09-23: *"arrow navigation doesnt work in grid
|
|
1187
|
+
* mode? nor in row mode"*). The columns have always had theirs (`ColumnBrowser`); these are the
|
|
1188
|
+
* same keys for the other two views, and every one is listed in the shortcuts sheet.
|
|
1189
|
+
* grid ←→ one tile, ↑↓ one row of tiles (the grid's live column count)
|
|
1190
|
+
* rows ↑↓ the visible order · → expands a folder, again steps into it · ← collapses, or goes
|
|
1191
|
+
* to the parent folder
|
|
1192
|
+
* both ⌘↑ the enclosing folder · ⌘↓ opens (a folder in the rows, Quick Look on a file) */
|
|
1193
|
+
const gridOrderRef = useRef([])
|
|
1194
|
+
const arrowNav = (e) => {
|
|
1195
|
+
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return false
|
|
1196
|
+
if (view === 'columns' || quickLook || quickLookFolder || searchOpen) return false
|
|
1197
|
+
const cur = selectCursorRef.current ?? selectAnchorRef.current ?? [...rowSelection].pop() ?? null
|
|
1198
|
+
/* ⇧ + ARROW EXTENDS (user 2026-09-23: *"shift up down or left right … should also make
|
|
1199
|
+
* selection"*): the anchor stays where the run began, the cursor moves, and the selection is
|
|
1200
|
+
* everything between them in the order on screen. */
|
|
1201
|
+
const extend = (order, key) => {
|
|
1202
|
+
const anchor = selectAnchorRef.current ?? cur ?? key
|
|
1203
|
+
const a = order.indexOf(anchor)
|
|
1204
|
+
const b = order.indexOf(key)
|
|
1205
|
+
if (a === -1 || b === -1) return false
|
|
1206
|
+
setRowSelection(new Set(order.slice(Math.min(a, b), Math.max(a, b) + 1)))
|
|
1207
|
+
selectAnchorRef.current = anchor
|
|
1208
|
+
selectCursorRef.current = key
|
|
1209
|
+
return true
|
|
1210
|
+
}
|
|
1211
|
+
/* FOCUS FOLLOWS THE SELECTION (user 2026-09-23: *"why are we supporting multiple selected/highlight
|
|
1212
|
+
* states?"*). The clicked tile kept keyboard focus — and its focus ring — while the arrows moved
|
|
1213
|
+
* the selection on, so two tiles looked current. The one the arrows land on takes focus too. */
|
|
1214
|
+
const reveal = (box, key) => {
|
|
1215
|
+
const el = box?.querySelector(`[data-marquee-key="${CSS.escape(key)}"]`)
|
|
1216
|
+
el?.scrollIntoView({ block: 'nearest' })
|
|
1217
|
+
if (el?.tabIndex >= 0) el.focus({ preventScroll: true })
|
|
1218
|
+
}
|
|
1219
|
+
if (view === 'grid') {
|
|
1220
|
+
const list = gridOrderRef.current
|
|
1221
|
+
if (!list.length) return true
|
|
1222
|
+
if (e.metaKey && e.key === 'ArrowUp') { if (prefix) goFolder(dirOf(prefix)); return true }
|
|
1223
|
+
const i = list.findIndex((f) => f.key === cur)
|
|
1224
|
+
if (e.metaKey && e.key === 'ArrowDown') { if (i !== -1) openQuickLook({ files: list, index: i }); return true }
|
|
1225
|
+
const box = gridMarquee.ref.current
|
|
1226
|
+
const cols = box ? getComputedStyle(box).gridTemplateColumns.split(' ').filter(Boolean).length : 1
|
|
1227
|
+
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key]
|
|
1228
|
+
const o = list[i === -1 ? 0 : Math.max(0, Math.min(list.length - 1, i + step))]
|
|
1229
|
+
if (e.shiftKey && extend(list.map((f) => f.key), o.key)) { reveal(box, o.key); return true }
|
|
1230
|
+
setRowSelection(new Set([o.key])); selectAnchorRef.current = o.key; selectCursorRef.current = null
|
|
1231
|
+
setPickedFolder(null); setPickedFile(o)
|
|
1232
|
+
reveal(box, o.key)
|
|
1233
|
+
return true
|
|
1234
|
+
}
|
|
1235
|
+
if (view === 'rows') {
|
|
1236
|
+
const order = visibleRowOrder(sortedObjects)
|
|
1237
|
+
if (!order.length) return true
|
|
1238
|
+
const select = (key) => {
|
|
1239
|
+
setRowSelection(new Set([key])); selectAnchorRef.current = key; selectCursorRef.current = null
|
|
1240
|
+
if (key.endsWith('/')) rowPickFolder(key)
|
|
1241
|
+
else { const o = objects.find((x) => x.key === key); if (o) rowPickFile(o) }
|
|
1242
|
+
reveal(rowsMarquee.ref.current, key)
|
|
1243
|
+
}
|
|
1244
|
+
const isFolder = cur?.endsWith('/')
|
|
1245
|
+
const open = isFolder && rowsExpanded.has(cur)
|
|
1246
|
+
if (e.metaKey && e.key === 'ArrowUp') { const up = cur ? dirOf(cur) : dirOf(prefix); if (up) select(up); return true }
|
|
1247
|
+
if (e.metaKey && e.key === 'ArrowDown') {
|
|
1248
|
+
if (!cur) return true
|
|
1249
|
+
if (isFolder) goFolder(cur)
|
|
1250
|
+
else { const lvl = rowLevel(dirOf(cur), sortedObjects).files; openQuickLook({ files: lvl, index: Math.max(0, lvl.findIndex((f) => f.key === cur)) }) }
|
|
1251
|
+
return true
|
|
1252
|
+
}
|
|
1253
|
+
const i = order.indexOf(cur)
|
|
1254
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
1255
|
+
const next = order[i === -1 ? 0 : Math.max(0, Math.min(order.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))]
|
|
1256
|
+
if (e.shiftKey && extend(order, next)) { reveal(rowsMarquee.ref.current, next); return true }
|
|
1257
|
+
select(next); return true
|
|
1258
|
+
}
|
|
1259
|
+
if (e.key === 'ArrowRight' && isFolder) {
|
|
1260
|
+
if (!open) setExpandedRows((prev) => new Set([...prev, cur]))
|
|
1261
|
+
else if (order[i + 1]?.startsWith(cur)) select(order[i + 1])
|
|
1262
|
+
return true
|
|
1263
|
+
}
|
|
1264
|
+
if (e.key === 'ArrowLeft' && cur) {
|
|
1265
|
+
if (open) toggleRow(cur)
|
|
1266
|
+
else if (dirOf(cur)) select(dirOf(cur))
|
|
1267
|
+
return true
|
|
1268
|
+
}
|
|
1269
|
+
return true
|
|
1270
|
+
}
|
|
1271
|
+
return false
|
|
1272
|
+
}
|
|
1273
|
+
useEffect(() => {
|
|
1274
|
+
const onKey = (e) => {
|
|
1275
|
+
const el = e.target
|
|
1276
|
+
if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
|
|
1277
|
+
if (e.key === '/' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); setSearchOpen(true) }
|
|
1278
|
+
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setSearchOpen(true) }
|
|
1279
|
+
if (arrowNav(e)) { e.preventDefault(); return }
|
|
1280
|
+
/* the columns keep their own space handler — theirs knows the column's cursor */
|
|
1281
|
+
if (e.key === ' ' && view !== 'columns' && !searchOpen) {
|
|
1282
|
+
e.preventDefault()
|
|
1283
|
+
if (quickLook) closeQuickLook()
|
|
1284
|
+
else if (quickLookFolder) setQuickLookFolder(null)
|
|
1285
|
+
else spaceQuickLook()
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
window.addEventListener('keydown', onKey)
|
|
1289
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
1290
|
+
})
|
|
1291
|
+
|
|
403
1292
|
const [quickLook, setQuickLook] = useState(null)
|
|
1293
|
+
const [quickLookFolder, setQuickLookFolder] = useState(null)
|
|
404
1294
|
const columnsRef = useRef(null)
|
|
405
|
-
|
|
1295
|
+
/* `flat` IS THE WALL'S, NOT THIS PAGE'S (user 2026-09-22: "there is no option to switch to flat
|
|
1296
|
+
* mode, bc its not in files view. this is row mode.. this makes no sense"). It reached here from
|
|
1297
|
+
* kol-r2b2's single surface and did two things that cannot be defended in a TREE: it struck every
|
|
1298
|
+
* folder name through, and it counted the whole subtree in the level's own count line — with the
|
|
1299
|
+
* only control on the other view's filter bar. A tree browses folders; flat browses files. */
|
|
1300
|
+
/* `view` IS THE STATE; `folderView` is what it used to be called for the two tree views and is
|
|
1301
|
+
* kept in step so an older consumer (kol-r2b2, kol-olina) is unaffected. */
|
|
1302
|
+
const stored = viewProp ?? settings.view ?? (settings.folderView === 'rows' ? 'rows' : 'columns')
|
|
1303
|
+
const view = stored === 'list' ? 'rows' : stored
|
|
1304
|
+
const isWall = view === 'grid'
|
|
1305
|
+
const folderView = view === 'rows' ? 'rows' : 'columns'
|
|
1306
|
+
const setView = (v) => {
|
|
1307
|
+
onViewChange?.(v)
|
|
1308
|
+
setSettings({
|
|
1309
|
+
...settings,
|
|
1310
|
+
view: v,
|
|
1311
|
+
folderView: v === 'rows' ? 'rows' : v === 'columns' ? 'columns' : settings.folderView,
|
|
1312
|
+
layout: v === 'grid' ? 'grid' : settings.layout,
|
|
1313
|
+
})
|
|
1314
|
+
}
|
|
1315
|
+
/* ROW VIEW'S PREVIEW PANE. Reserved whenever the setting is on, empty until something is picked
|
|
1316
|
+
* — Finder keeps the space rather than reflowing the list on every selection. */
|
|
1317
|
+
const showRowPreview = folderView === 'rows' && (settings.rowPreview ?? true)
|
|
1318
|
+
const showGridPreview = view === 'grid' && (settings.rowPreview ?? true)
|
|
1319
|
+
/* OFF by default: a row is a name, and the facts live in the preview (user 2026-09-22) */
|
|
1320
|
+
const rowCols = settings.rowColumns ? rowColWidths(settings) : null
|
|
1321
|
+
const previewWidth = settings.columnWidths?.preview ?? 320
|
|
1322
|
+
const previewFile = pickedFile && pickedFile.key.startsWith(prefix) ? pickedFile : null
|
|
1323
|
+
const previewFolder = pickedFolder && pickedFolder.startsWith(prefix) ? pickedFolder : null
|
|
1324
|
+
/* THE PREVIEW PANE, ONE FUNCTION FOR THE ROWS AND THE GRID (user 2026-09-23: *"why did you not
|
|
1325
|
+
* treat this like column and row, with container padding + preview pane? CONSISTENCY"*). Several
|
|
1326
|
+
* selected → the stack; a file → its preview; a folder → its summary; nothing → the reserved
|
|
1327
|
+
* space, which Finder keeps rather than reflowing the list on every pick. */
|
|
1328
|
+
const previewPane = () => (
|
|
1329
|
+
rowSelection.size > 1 ? (
|
|
1330
|
+
<SelectionPreview keys={[...rowSelection]} objects={objects} urlOf={(o) => mediaUrl(o.key)} kindOf={kindOf} formatSize={formatSize} formatDate={formatDate} width={previewWidth} />
|
|
1331
|
+
) : previewFile ? (
|
|
1332
|
+
<ColumnPreview key={previewFile.key} o={previewFile} urlOf={(o) => mediaUrl(o.key)} kindOf={kindOf} kindLabel={KIND_LABEL}
|
|
1333
|
+
formatSize={formatSize} formatDate={formatDate} width={previewWidth}
|
|
1334
|
+
renderPreview={(o) => {
|
|
1335
|
+
if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} />
|
|
1336
|
+
const poster = posterFor(o.key, keySet)
|
|
1337
|
+
return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
1338
|
+
}} />
|
|
1339
|
+
) : previewFolder ? (
|
|
1340
|
+
<FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} />
|
|
1341
|
+
) : (
|
|
1342
|
+
<div className="kol-column-browser-preview shrink-0" style={{ width: previewWidth }} />
|
|
1343
|
+
)
|
|
1344
|
+
)
|
|
406
1345
|
/* PINNED SEARCH + `···` (item 15), below `md` only. Item 7 of the previous
|
|
407
1346
|
* ticket hid the desktop control cluster and put nothing in its place, so
|
|
408
1347
|
* SORT became unreachable on a phone; both references carry exactly these two
|
|
@@ -415,16 +1354,30 @@ export function MediaLibraryBrowse({
|
|
|
415
1354
|
* rest. One view, rather than a flat results list nobody asked for. Sort
|
|
416
1355
|
* orders the files the way the wall already sorts its own. */
|
|
417
1356
|
const q = query.trim().toLowerCase()
|
|
418
|
-
|
|
1357
|
+
/* SEGMENTS HIDE WHEN FOLDED, in every view (user 2026-09-23: *"we can just by default hide the
|
|
1358
|
+
* segments, if they serve no practical purpose"*). A `.ts` chunk cannot play on its own — its
|
|
1359
|
+
* stream's `.m3u8` beside it is the thing you open. The fold used to reach only the grid, so the
|
|
1360
|
+
* columns and rows listed every chunk. */
|
|
1361
|
+
const listed = useMemo(() => (settings.foldSegments ? objects.filter((o) => !isSegment(o.key)) : objects), [objects, settings.foldSegments])
|
|
1362
|
+
const searched = useMemo(() => (q ? listed.filter((o) => o.key.toLowerCase().includes(q)) : listed), [listed, q])
|
|
419
1363
|
const sortedObjects = useMemo(
|
|
420
1364
|
() => sortObjects(searched, settings.sortBy, settings.sortDir),
|
|
421
1365
|
[searched, settings.sortBy, settings.sortDir],
|
|
422
1366
|
)
|
|
423
1367
|
|
|
1368
|
+
const treeFolders = (level) => (folderTree?.[bucketMeta.id]?.folders ?? [])
|
|
1369
|
+
.filter((p) => p.startsWith(level) && p.length > level.length && !p.slice(level.length, -1).includes('/'))
|
|
1370
|
+
.map((p) => p.slice(level.length))
|
|
1371
|
+
|
|
424
1372
|
const scoped = prefix ? sortedObjects.filter((o) => o.key.startsWith(prefix)) : sortedObjects
|
|
425
|
-
const { folders, files: dirFiles } = partition(scoped, prefix)
|
|
1373
|
+
const { folders: keyFolders, files: dirFiles } = partition(scoped, prefix)
|
|
1374
|
+
/* THE COUNT COUNTS WHAT IS ON SCREEN. `partition` derives folders from file keys, so an EMPTY
|
|
1375
|
+
* folder is invisible to it — the stats line said "2 folders" under a list showing three, the
|
|
1376
|
+
* third being `03-scratch/`, which exists in `folderTree` and nowhere else. Both views draw the
|
|
1377
|
+
* merged set now, so the tally is taken from the same place. */
|
|
1378
|
+
const folders = [...new Set([...treeFolders(prefix), ...keyFolders])].sort()
|
|
426
1379
|
const keySet = new Set(objects.map((o) => o.key))
|
|
427
|
-
const levelFiles =
|
|
1380
|
+
const levelFiles = dirFiles
|
|
428
1381
|
const systemCount = levelFiles.filter((o) => isSystemFile(o.key)).length
|
|
429
1382
|
const rawFiles = levelFiles.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o) }))
|
|
430
1383
|
const profile = profileOf(objects, rawFiles, systemCount)
|
|
@@ -446,9 +1399,24 @@ export function MediaLibraryBrowse({
|
|
|
446
1399
|
const ROOT = title
|
|
447
1400
|
const label = bucketMeta.label || 'bucket'
|
|
448
1401
|
const VROOT = single ? '' : `${ROOT}/${label}/`
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
1402
|
+
const unroot = (p) => (VROOT && p.startsWith(VROOT) ? p.slice(VROOT.length) : p)
|
|
1403
|
+
/* ONE LEVEL, ONE ANSWER. The column view merges the baked tree with the live partition inside its
|
|
1404
|
+
* `partition` prop; the row view used the live partition alone and therefore disagreed with the
|
|
1405
|
+
* columns about what a folder even is. This is that merge, lifted so both callers share it —
|
|
1406
|
+
* folders from `folderTree` (real nodes, empty ones included) unioned with the ones derived from
|
|
1407
|
+
* keys, plus the level's own files, which the row view never rendered at all. */
|
|
1408
|
+
const rowLevel = (level, list = objects) => {
|
|
1409
|
+
/* `partition` slices every key by `prefix` WITHOUT checking it matches, so it must be handed an
|
|
1410
|
+
* already-scoped list. Given the whole bucket it cut four characters off every root key and
|
|
1411
|
+
* drew `readme.md` as `me.md` and `video/` as `o/`. Scope first, always. */
|
|
1412
|
+
const inLevel = level ? list.filter((o) => o.key.startsWith(level)) : list
|
|
1413
|
+
const live = partition(inLevel, level)
|
|
1414
|
+
const baked = treeFolders(level)
|
|
1415
|
+
return {
|
|
1416
|
+
folders: [...new Set([...baked, ...live.folders])].sort(),
|
|
1417
|
+
files: sortObjects(live.files.filter((o) => !isSystemFile(o.key)), settings.sortBy, settings.sortDir),
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
452
1420
|
|
|
453
1421
|
useEffect(() => {
|
|
454
1422
|
if (folderView !== 'columns') return undefined
|
|
@@ -459,14 +1427,289 @@ export function MediaLibraryBrowse({
|
|
|
459
1427
|
}, [folderView, prefix, appRoot, pickedFile, bucketMeta.id])
|
|
460
1428
|
|
|
461
1429
|
if (error) return <p className="kol-mono-12 text-ui-error">Error: {error}</p>
|
|
1430
|
+
/* NOT SELECTABLE (user 2026-09-23, superseding 2026-09-22's selectable crumbs: *"instead of this
|
|
1431
|
+
* highlight, could we make a hover click to copy path?"*). A drag across the line painted the
|
|
1432
|
+
* browser's selection block over it; the path is one click on the current crumb now. */
|
|
462
1433
|
const crumbCls = (active) => `cursor-pointer select-none transition-colors ${active ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`
|
|
1434
|
+
const fullPath = (tail = '') => [ROOT, ...(appRoot ? [] : [label, ...crumbs]), ...(tail ? [tail] : [])].join('/')
|
|
1435
|
+
|
|
1436
|
+
/* ── THE WALL'S LIST, on the same surface (2026-09-22) ──────────────────────────────────────
|
|
1437
|
+
* `flat` belongs to the FILE views and not to the tree (that ruling stands): a wall shows the
|
|
1438
|
+
* folder's own files, or the whole subtree when flat is on. Grouping and segment-folding are the
|
|
1439
|
+
* settings the wall always had. */
|
|
1440
|
+
const wallKinds = new Set(settings.kinds ?? SETTINGS_BASE.kinds)
|
|
1441
|
+
const wallSource = settings.flat
|
|
1442
|
+
? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key }))
|
|
1443
|
+
: dirFiles
|
|
1444
|
+
const wallVisible = wallKinds.has('system') ? wallSource : wallSource.filter((o) => !isSystemFile(o.key))
|
|
1445
|
+
const wallGrouped = settings.groupVariants ? groupVariants(wallVisible) : wallVisible
|
|
1446
|
+
const wallFiles = (settings.foldSegments ? groupSegments(wallGrouped) : wallGrouped)
|
|
1447
|
+
.map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet) }))
|
|
1448
|
+
const filtersOn = !!settings.filters
|
|
1449
|
+
/* the bar filters FILES, and a tree is drawn from the files under it — so one filtered list
|
|
1450
|
+
* serves both: the wall renders it, the tree is scoped to the keys in it */
|
|
1451
|
+
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 }))
|
|
1452
|
+
const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
|
|
1453
|
+
|
|
1454
|
+
/* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
|
|
1455
|
+
* `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
|
|
1456
|
+
* key space, or the subset the filter bar left standing. `wallPane` is the same two file views
|
|
1457
|
+
* the Files page rendered, in a pane of the browser's own height so the page does not jump
|
|
1458
|
+
* between views. */
|
|
1459
|
+
const treeBody = (keep) => {
|
|
1460
|
+
const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
|
|
1461
|
+
return (
|
|
1462
|
+
folderView === 'columns' ? (
|
|
1463
|
+
<div ref={columnsRef} className="relative">
|
|
1464
|
+
<ColumnBrowser
|
|
1465
|
+
autoFocus={autoFocus}
|
|
1466
|
+
className={appRoot ? 'is-root' : ''}
|
|
1467
|
+
height={settings.columnHeight}
|
|
1468
|
+
onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
|
|
1469
|
+
columnWidths={settings.columnWidths}
|
|
1470
|
+
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
1471
|
+
objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
1472
|
+
prefix={single ? prefix : (appRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
|
|
1473
|
+
onPrefix={(v) => {
|
|
1474
|
+
/* single: the browser's path IS the bucket path, no segment to strip */
|
|
1475
|
+
if (single) { setAppRoot(false); setPrefix(v); return }
|
|
1476
|
+
const [, seg, ...rest] = v.split('/')
|
|
1477
|
+
const target = buckets.find((b) => b.label === seg)
|
|
1478
|
+
if (!target && buckets.length) { setAppRoot(true); setPickedFile(null); setPrefix(''); return }
|
|
1479
|
+
setAppRoot(false)
|
|
1480
|
+
const real = rest.join('/')
|
|
1481
|
+
if (target && target.id !== bucketMeta.id) switchBucket(target.id, real); else setPrefix(real)
|
|
1482
|
+
}}
|
|
1483
|
+
onPick={(o) => setPickedFile(o ? { ...o, key: o.key.slice(VROOT.length) } : null)}
|
|
1484
|
+
quickLook={quickLook && { ...quickLook, files: quickLook.files.map((o) => ({ ...o, key: `${VROOT}${o.key}` })) }}
|
|
1485
|
+
onQuickLook={(q) => openQuickLook(q && { ...q, files: q.files.map((o) => ({ ...o, key: o.key.slice(VROOT.length) })) })}
|
|
1486
|
+
urlOf={(o) => mediaUrl(o.key.slice(VROOT.length))}
|
|
1487
|
+
kindOf={kindOf}
|
|
1488
|
+
kindLabel={KIND_LABEL}
|
|
1489
|
+
formatSize={formatSize}
|
|
1490
|
+
formatDate={formatDate}
|
|
1491
|
+
thumbnailFor={thumbnailFor}
|
|
1492
|
+
folderMeta={folderMeta}
|
|
1493
|
+
/* the PROP wins when a consumer passes one; otherwise the `···`
|
|
1494
|
+
owns it through settings (item 15) */
|
|
1495
|
+
stackView={stackView ?? settings.stackView ?? 'list'}
|
|
1496
|
+
renderPreview={(o) => {
|
|
1497
|
+
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
1498
|
+
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
|
|
1499
|
+
const poster = posterFor(real.key, keySet)
|
|
1500
|
+
return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
1501
|
+
}}
|
|
1502
|
+
/* THE COLUMNS SPEAK IN VIRTUAL PATHS. With more than one bucket every level is
|
|
1503
|
+
* prefixed `<title>/<bucket>/` so the stores share one tree — so a path coming OUT
|
|
1504
|
+
* of the browser must be un-rooted before it reaches a file verb, or the store is
|
|
1505
|
+
* asked to move `MEDIA/R2 · kol-media/img/x.jpg`, which it has never heard of. The
|
|
1506
|
+
* row view needs none of this; it is already bucket-relative. */
|
|
1507
|
+
onRowContextMenu={(e, payload) => menu.openAt(e, { ...payload, path: unroot(payload.path), ...(payload.o && { o: { ...payload.o, key: unroot(payload.o.key) } }) })}
|
|
1508
|
+
dragFor={dragFor ? (path) => dragFor(unroot(path)) : undefined}
|
|
1509
|
+
/* virtual paths again: only a level inside THIS bucket's root can take a file */
|
|
1510
|
+
onDropFiles={dropFilesTo ? (files, path) => { if (!VROOT || path.startsWith(VROOT)) dropFilesTo(unroot(path))(files) } : undefined}
|
|
1511
|
+
/* the band's keys come back virtual-rooted; the page keeps ONE selection, bucket-relative */
|
|
1512
|
+
selectedKeys={VROOT ? new Set([...rowSelection].map((k) => `${VROOT}${k}`)) : rowSelection}
|
|
1513
|
+
onSelectKeys={(keys, additive) => { marqueeSelect(keys.map(unroot), additive); if (!additive && keys.length === 1) selectAnchorRef.current = unroot(keys[0]) }}
|
|
1514
|
+
onSelectClick={(key, e, keys) => selectRow(unroot(key), e, keys.map(unroot))}
|
|
1515
|
+
/* the bucket rows under the title are SOURCES — `database`, not `folder` */
|
|
1516
|
+
folderIcon={(path) => (!single && path.startsWith(`${ROOT}/`) && path.slice(ROOT.length + 1).split('/').filter(Boolean).length === 1 ? 'database' : 'folder')}
|
|
1517
|
+
partition={(objs, level) => {
|
|
1518
|
+
if (!single) {
|
|
1519
|
+
if (level === '') return { folders: [`${ROOT}/`], files: [] }
|
|
1520
|
+
if (level === `${ROOT}/`) return { folders: (buckets.length ? buckets : [bucketMeta]).map((b) => `${b.label || 'bucket'}/`), files: [] }
|
|
1521
|
+
}
|
|
1522
|
+
const live = partition(objs, level)
|
|
1523
|
+
const baked = treeFolders(level.slice(VROOT.length))
|
|
1524
|
+
return { folders: [...new Set([...baked, ...live.folders])].sort(), files: live.files }
|
|
1525
|
+
}}
|
|
1526
|
+
/>
|
|
1527
|
+
</div>
|
|
1528
|
+
) : (
|
|
1529
|
+
/* ROW VIEW — the same level the column view draws, not a different one. It used to render
|
|
1530
|
+
* `partition(scoped, prefix).folders` and nothing else, which lost two things: an EMPTY
|
|
1531
|
+
* folder (partition derives folders from file keys, so a folder with no files under it
|
|
1532
|
+
* does not exist to it — `folderTree` is where real nodes live, and the column view was
|
|
1533
|
+
* already merging the two) and every FILE at the level. `#img/` drew 2 rows against the
|
|
1534
|
+
* columns' 4. Folders first, then files, each expandable in place like a list view. */
|
|
1535
|
+
/* SAME AREA AS THE COLUMNS. The row view used to be content-height, so the two folder
|
|
1536
|
+
* views made the page jump between them and the blank space below the last row — where
|
|
1537
|
+
* you right-click for "new folder" and drop to file something here — did not exist. It
|
|
1538
|
+
* takes the column browser's own height now, from the same setting.
|
|
1539
|
+
*
|
|
1540
|
+
* The list IS the current folder, so it is a drop target for `prefix`, matching a column
|
|
1541
|
+
* being a target for its level. Highlight via a data attribute for the same reason.
|
|
1542
|
+
*
|
|
1543
|
+
* THE COLUMN BROWSER'S PANE (user 2026-09-22): same border, radius and height, the list a
|
|
1544
|
+
* `kol-column-browser-column` (its 4px block inset, its selected rules), and beside it the
|
|
1545
|
+
* same preview the columns open — Finder's "Show preview", on by default (`rowPreview`). */
|
|
1546
|
+
/* THE WRAPPER OWNS THE HEIGHT, not the pane (2026-09-22). Both folder views have to end on
|
|
1547
|
+
* the same line — the count line under them jumps otherwise, and a consumer's fill height
|
|
1548
|
+
* is ONE value for both — so the header comes out of this view's budget rather than being
|
|
1549
|
+
* added to it. The pane takes what is left. */
|
|
1550
|
+
<div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
|
|
1551
|
+
{/* the header labels the LIST, so it spans the list and stops where the preview starts */}
|
|
1552
|
+
{rowCols && (
|
|
1553
|
+
<RowHeader sortBy={settings.sortBy} sortDir={settings.sortDir} width={showRowPreview ? `calc(100% - ${previewWidth}px)` : '100%'}
|
|
1554
|
+
cols={rowCols}
|
|
1555
|
+
onResize={(key, px) => setSettings({ ...settings, rowColumnWidths: { ...rowColWidths(settings), [key]: px } })}
|
|
1556
|
+
onSort={(sortBy, sortDir) => setSettings({ ...settings, sortBy, sortDir })} />
|
|
1557
|
+
)}
|
|
1558
|
+
<div className="relative border rounded flex overflow-hidden flex-1 min-h-0"
|
|
1559
|
+
style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1560
|
+
<div className={`flex-1 min-w-0 flex flex-col${showRowPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1561
|
+
<ul ref={rowsMarquee.ref} {...rowsMarquee.props}
|
|
1562
|
+
/* the background deselects, here as in the columns */
|
|
1563
|
+
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}
|
|
1564
|
+
className="relative kol-column-browser-column kol-row-browser flex-1 min-w-0 flex flex-col overflow-y-auto"
|
|
1565
|
+
onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
1566
|
+
onDragOver={(e) => {
|
|
1567
|
+
const files = dropFilesTo && isFileDrag(e)
|
|
1568
|
+
const d = !isFileDrag(e) && dragFor?.(prefix)
|
|
1569
|
+
if (!files && !d?.canDrop(prefix)) return
|
|
1570
|
+
e.preventDefault()
|
|
1571
|
+
e.currentTarget.dataset.dropOver = '1'
|
|
1572
|
+
}}
|
|
1573
|
+
/* THE HIGHLIGHT CLEARS HOWEVER THE DRAG ENDS (user 2026-09-22 — it stuck as a border).
|
|
1574
|
+
* A drop on a folder row stops propagation, so this list's own onDrop never ran; the
|
|
1575
|
+
* capture phase runs first. Leaving through a child fires on the child, so leave is
|
|
1576
|
+
* "the pointer is no longer inside", not "the event target was the list". */
|
|
1577
|
+
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) delete e.currentTarget.dataset.dropOver }}
|
|
1578
|
+
onDropCapture={(e) => { delete e.currentTarget.dataset.dropOver }}
|
|
1579
|
+
onDrop={(e) => {
|
|
1580
|
+
if (dropFilesTo && isFileDrag(e)) { e.preventDefault(); dropFilesTo(prefix)(e.dataTransfer.files); return }
|
|
1581
|
+
const d = !isFileDrag(e) && dragFor?.(prefix)
|
|
1582
|
+
if (!d?.canDrop(prefix)) return
|
|
1583
|
+
e.preventDefault()
|
|
1584
|
+
d.onDrop(prefix)
|
|
1585
|
+
}}>
|
|
1586
|
+
{rowsMarquee.rect && <div className="kol-marquee" style={rowsMarquee.rect} />}
|
|
1587
|
+
{rowLevel('', treeObjects).folders.map((f) => {
|
|
1588
|
+
const path = f
|
|
1589
|
+
const open = rowsExpanded.has(path)
|
|
1590
|
+
const order = visibleRowOrder(treeObjects)
|
|
1591
|
+
return (
|
|
1592
|
+
<Fragment key={f}>
|
|
1593
|
+
<FolderRow
|
|
1594
|
+
name={f} expanded={open}
|
|
1595
|
+
markKey={path}
|
|
1596
|
+
/* THE FOLDER YOU ARE IN IS NOT A SELECTION (user 2026-09-23) — it wears the trail
|
|
1597
|
+
* mark, the columns' parent-folder tone, never the selected fill */
|
|
1598
|
+
selected={rowSelection.has(path) || pickedFolder === path}
|
|
1599
|
+
current={prefix === path}
|
|
1600
|
+
onContextMenu={(e) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
|
|
1601
|
+
drag={dragFor?.(path)}
|
|
1602
|
+
dropFiles={dropFilesTo?.(path)}
|
|
1603
|
+
meta={folderMeta?.(path) || undefined}
|
|
1604
|
+
cols={rowCols}
|
|
1605
|
+
onToggle={() => toggleRow(path)}
|
|
1606
|
+
onClick={(e) => { if (!selectRow(path, e, order)) rowPickFolder(path) }}
|
|
1607
|
+
onDoubleClick={() => goFolder(path)}
|
|
1608
|
+
/>
|
|
1609
|
+
{open && <RowSubtree
|
|
1610
|
+
level={path} depth={1} rowLevel={(lvl) => rowLevel(lvl, treeObjects)} expanded={rowsExpanded}
|
|
1611
|
+
onToggle={toggleRow}
|
|
1612
|
+
onOpenFolder={goFolder} onPickFolder={rowPickFolder} onPickFile={rowPickFile}
|
|
1613
|
+
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)) }) }}
|
|
1614
|
+
selectRow={selectRow} order={order} selectedKeys={rowSelection}
|
|
1615
|
+
onRowContextMenu={(e, payload) => menu.openAt(e, payload)} dragFor={dragFor} dropFilesTo={dropFilesTo}
|
|
1616
|
+
formatDate={formatDate} folderMeta={folderMeta} thumbnailFor={thumbnailFor} cols={rowCols}
|
|
1617
|
+
pickedKey={pickedFolder ?? pickedFile?.key ?? null} currentKey={prefix}
|
|
1618
|
+
/>}
|
|
1619
|
+
</Fragment>
|
|
1620
|
+
)
|
|
1621
|
+
})}
|
|
1622
|
+
{rowLevel('', treeObjects).files.map((o) => (
|
|
1623
|
+
<FileRow key={o.key} o={o} formatDate={formatDate} thumb={thumbnailFor?.(o)} cols={rowCols}
|
|
1624
|
+
selected={rowSelection.has(o.key) || pickedFile?.key === o.key}
|
|
1625
|
+
onDoubleClick={() => { const lvl = rowLevel('', treeObjects).files; openQuickLook({ files: lvl, index: Math.max(0, lvl.findIndex((f) => f.key === o.key)) }) }}
|
|
1626
|
+
onContextMenu={(e) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })}
|
|
1627
|
+
drag={dragFor?.(o.key)}
|
|
1628
|
+
onClick={(e) => {
|
|
1629
|
+
if (!selectRow(o.key, e, visibleRowOrder(treeObjects))) rowPickFile(o)
|
|
1630
|
+
}} />
|
|
1631
|
+
))}
|
|
1632
|
+
</ul>
|
|
1633
|
+
</div>
|
|
1634
|
+
{showRowPreview && previewPane()}
|
|
1635
|
+
</div>
|
|
1636
|
+
</div>
|
|
1637
|
+
)
|
|
1638
|
+
)
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/* THE GRID IS THE THIRD VIEW OF ONE SURFACE (user 2026-09-23, reversing the same day's "no pane,
|
|
1642
|
+
* no fill height" for the wall: *"why did you not treat this like column and row, with container
|
|
1643
|
+
* padding + preview pane? CONSISTENCY"*). Same bordered frame, same fill height, the tiles scroll
|
|
1644
|
+
* inside it, the same preview pane beside them, the same count line under it — with the size
|
|
1645
|
+
* slider at its right end, Finder's. */
|
|
1646
|
+
const tileSize = settings.tileSize ?? 180
|
|
1647
|
+
const wallPane = (files) => (
|
|
1648
|
+
<div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
|
|
1649
|
+
<div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1650
|
+
<div className={`flex-1 min-w-0 overflow-y-auto p-4${showGridPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
1651
|
+
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}>
|
|
1652
|
+
<WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} marquee={gridMarquee} onSorted={(list) => { gridOrderRef.current = list }}
|
|
1653
|
+
onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) }}
|
|
1654
|
+
sortBy={settings.sortBy} sortDir={settings.sortDir}
|
|
1655
|
+
pageSize={settings.pageSize} listId={`${prefix}|${view}|${settings.flat}|${refreshKey}|${bucketMeta.id}`}
|
|
1656
|
+
mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} videoPreview={settings.videoPreview} formatDate={formatDate}
|
|
1657
|
+
selected={rowSelection} onPick={(o, e, keys) => {
|
|
1658
|
+
/* ⇧/⌘ ADD TO THE SELECTION HERE TOO (user 2026-09-23) — the same `selectRow` the rows use;
|
|
1659
|
+
* a plain click is still a selection of one and still previews */
|
|
1660
|
+
if (selectRow(o.key, e, keys)) return
|
|
1661
|
+
setPickedFolder(null); setPickedFile(o)
|
|
1662
|
+
}}
|
|
1663
|
+
onOpen={(o, files) => openQuickLook({ files, index: Math.max(0, files.findIndex((f) => f.key === o.key)) })}
|
|
1664
|
+
/* COPY AND DOWNLOAD LIVE IN THE MENU AND QUICK LOOK (user 2026-09-23) — as in the rows and the
|
|
1665
|
+
* columns. The tile carries no buttons. */
|
|
1666
|
+
onContextMenu={(e, o) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })} />
|
|
1667
|
+
</div>
|
|
1668
|
+
{showGridPreview && previewPane()}
|
|
1669
|
+
</div>
|
|
1670
|
+
</div>
|
|
1671
|
+
)
|
|
1672
|
+
|
|
1673
|
+
/* The palette's own ruling (2026-08-01) is that Enter COMMITS the query rather than navigating
|
|
1674
|
+
* to whatever happened to be first — so `onExpand` is where a committed query lands, and here
|
|
1675
|
+
* that means the top hit. Arrow-then-Enter and a click go through `onSelect`, same jump. */
|
|
1676
|
+
const searchResults = (searchQuery.trim()
|
|
1677
|
+
? objects.filter((o) => o.key.toLowerCase().includes(searchQuery.trim().toLowerCase()))
|
|
1678
|
+
: objects
|
|
1679
|
+
).slice(0, 50).map((o) => ({
|
|
1680
|
+
id: o.key,
|
|
1681
|
+
label: o.key.split('/').pop(),
|
|
1682
|
+
hint: o.key.slice(0, o.key.lastIndexOf('/') + 1) || 'the bucket root',
|
|
1683
|
+
group: KIND_LABEL[kindOf(o)] || 'File',
|
|
1684
|
+
}))
|
|
1685
|
+
const jumpTo = (key) => {
|
|
1686
|
+
const o = objects.find((x) => x.key === key)
|
|
1687
|
+
setSearchOpen(false); setSearchQuery('')
|
|
1688
|
+
if (!o) return
|
|
1689
|
+
setAppRoot(false)
|
|
1690
|
+
setPrefix(o.key.slice(0, o.key.lastIndexOf('/') + 1))
|
|
1691
|
+
pickFile(o)
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
const body = (filtered) => (isWall
|
|
1695
|
+
? wallPane(filtered ?? wallFiles)
|
|
1696
|
+
: treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
|
|
463
1697
|
|
|
464
1698
|
return (
|
|
465
1699
|
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
466
|
-
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions}
|
|
1700
|
+
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
|
|
1701
|
+
onHome={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}
|
|
467
1702
|
onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
|
|
468
1703
|
onSettings={() => setSettingsOpen(true)} />
|
|
469
1704
|
|
|
1705
|
+
{/* THE BANNER SLOT — anything that must sit directly under the header and above the body.
|
|
1706
|
+
* The upload drop zone is why it exists: a consumer rendering it AFTER the page put it
|
|
1707
|
+
* below an 800px column browser, so pressing Upload scrolled you past the whole browser to
|
|
1708
|
+
* reach the target you had just asked for. A slot is the fix; telling the consumer to
|
|
1709
|
+
* re-order is not, because the header is ours and the body is ours and there was no
|
|
1710
|
+
* between. */}
|
|
1711
|
+
{banner}
|
|
1712
|
+
|
|
470
1713
|
<div className="flex flex-col gap-3">
|
|
471
1714
|
{/* Breadcrumb (uppercase, active segment at full ink) · folder-view toggle
|
|
472
1715
|
*
|
|
@@ -502,38 +1745,47 @@ export function MediaLibraryBrowse({
|
|
|
502
1745
|
<button className={crumbCls(appRoot)} onClick={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}>{ROOT}</button>
|
|
503
1746
|
{!appRoot && bucketMeta.id && (
|
|
504
1747
|
<span className="flex items-center gap-2">
|
|
505
|
-
<span className="text-oq-32">/</span>
|
|
506
|
-
|
|
1748
|
+
<span className="text-oq-32 select-none">/</span>
|
|
1749
|
+
{crumbs.length === 0 && !(folderView === 'columns' && pickedFile)
|
|
1750
|
+
? <CopyCrumb className={crumbCls(true)} label={label.toUpperCase()} path={fullPath()} />
|
|
1751
|
+
: <button className={crumbCls(false)} onClick={() => setPrefix('')}>{label.toUpperCase()}</button>}
|
|
507
1752
|
</span>
|
|
508
1753
|
)}
|
|
509
1754
|
{!appRoot && crumbs.map((seg, i) => {
|
|
510
1755
|
const to = crumbs.slice(0, i + 1).join('/') + '/'
|
|
511
1756
|
const last = i === crumbs.length - 1
|
|
1757
|
+
const current = last && !(folderView === 'columns' && pickedFile)
|
|
512
1758
|
return (
|
|
513
1759
|
<span key={to} className="flex items-center gap-2">
|
|
514
|
-
<span className="text-oq-32">/</span>
|
|
515
|
-
|
|
1760
|
+
<span className="text-oq-32 select-none">/</span>
|
|
1761
|
+
{current
|
|
1762
|
+
? <CopyCrumb className={crumbCls(true)} label={seg.toUpperCase()} path={fullPath()} />
|
|
1763
|
+
: <button className={crumbCls(false)} onClick={() => setPrefix(to)}>{seg.toUpperCase()}</button>}
|
|
516
1764
|
</span>
|
|
517
1765
|
)
|
|
518
1766
|
})}
|
|
519
1767
|
{folderView === 'columns' && pickedFile && pickedFile.key.startsWith(prefix) && (
|
|
520
|
-
<span className="flex items-center gap-2"><span className="text-oq-32
|
|
1768
|
+
<span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
|
|
1769
|
+
<CopyCrumb className={crumbCls(true)} label={pickedFile.key.slice(prefix.length).toUpperCase()} path={fullPath(pickedFile.key.slice(prefix.length))} />
|
|
1770
|
+
</span>
|
|
521
1771
|
)}
|
|
522
1772
|
</div>
|
|
523
|
-
{/* the control cluster is desktop-only — below `md`
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
|
|
527
|
-
|
|
1773
|
+
{/* the control cluster is desktop-only — below `md` this is the same
|
|
1774
|
+
* setting the drawer already carries, and nothing in this row survives 390.
|
|
1775
|
+
*
|
|
1776
|
+
* ONE SWITCH FOR FOUR VIEWS (the merge, 2026-09-22). Two views were a pair of words;
|
|
1777
|
+
* four are an icon strip — the same reason Finder draws four glyphs and no labels.
|
|
1778
|
+
* Beside it: the filter bar's funnel and search, which belong to every view now. */}
|
|
1779
|
+
<div className="hidden md:flex items-center gap-4">
|
|
1780
|
+
{/* FILTER AND SEARCH, ONE WELL (user 2026-09-23): the same chip and height as the view
|
|
1781
|
+
* switch beside them — the bare filter frame sat shorter than the well, and search had
|
|
1782
|
+
* wandered up to the header. Tooltips are the word, not a sentence. */}
|
|
1783
|
+
<ViewToggle variant="icon" viewMode={null} onViewChange={() => {}} options={[
|
|
1784
|
+
{ value: 'filter', label: 'Filter', icon: 'filter', pressed: filtersOn, onClick: () => setSettings({ ...settings, filters: !filtersOn }) },
|
|
1785
|
+
{ value: 'search', label: 'Search', icon: 'search', dividerBefore: true, onClick: () => setSearchOpen(true) },
|
|
1786
|
+
]} />
|
|
528
1787
|
<Divider variant="vertical" />
|
|
529
|
-
<
|
|
530
|
-
{[{ value: 'rows', label: 'ROW' }, { value: 'columns', label: 'COLUMN' }].map((opt) => (
|
|
531
|
-
<span key={opt.value} role="button" onClick={() => setSettings({ ...settings, folderView: opt.value })}
|
|
532
|
-
className={`kol-helper-14 cursor-pointer select-none ${folderView === opt.value ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`} style={{ letterSpacing: 1 }}>
|
|
533
|
-
{opt.label}
|
|
534
|
-
</span>
|
|
535
|
-
))}
|
|
536
|
-
</div>
|
|
1788
|
+
<ViewToggle viewMode={view} onViewChange={setView} variant="icon" options={VIEW_OPTIONS} />
|
|
537
1789
|
</div>
|
|
538
1790
|
</div>
|
|
539
1791
|
|
|
@@ -597,71 +1849,71 @@ export function MediaLibraryBrowse({
|
|
|
597
1849
|
</MenuItem>
|
|
598
1850
|
</div>
|
|
599
1851
|
|
|
1852
|
+
{searchOpen && (
|
|
1853
|
+
<ShellSearchOverlay
|
|
1854
|
+
open
|
|
1855
|
+
onClose={() => { setSearchOpen(false); setSearchQuery('') }}
|
|
1856
|
+
query={searchQuery}
|
|
1857
|
+
onQueryChange={setSearchQuery}
|
|
1858
|
+
placeholder="Search this bucket"
|
|
1859
|
+
results={searchResults}
|
|
1860
|
+
onExpand={() => searchResults[0] && jumpTo(searchResults[0].id)}
|
|
1861
|
+
onSelect={(item) => jumpTo(item.id)}
|
|
1862
|
+
/>
|
|
1863
|
+
)}
|
|
1864
|
+
{quickLookFolder && (
|
|
1865
|
+
<FullscreenOverlay open onClose={() => setQuickLookFolder(null)} scrim>
|
|
1866
|
+
<FolderPreview path={quickLookFolder} objects={objects} width={420} formatDate={formatDate} />
|
|
1867
|
+
</FullscreenOverlay>
|
|
1868
|
+
)}
|
|
600
1869
|
{quickLook && (
|
|
601
|
-
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={
|
|
602
|
-
onPrev={() =>
|
|
603
|
-
onNext={() => setQuickLook((q) => ({ ...q, index: (q.index + 1) % q.files.length }))} />
|
|
1870
|
+
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
|
|
1871
|
+
onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
|
|
604
1872
|
)}
|
|
605
1873
|
{settingsOpen && (
|
|
606
1874
|
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
607
1875
|
)}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
<div ref={columnsRef} className="relative">
|
|
611
|
-
<ColumnBrowser
|
|
612
|
-
autoFocus={autoFocus}
|
|
613
|
-
className={appRoot ? 'is-root' : ''}
|
|
614
|
-
height={settings.columnHeight}
|
|
615
|
-
onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
|
|
616
|
-
columnWidths={settings.columnWidths}
|
|
617
|
-
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
618
|
-
objects={sortedObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
619
|
-
prefix={single ? prefix : (appRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
|
|
620
|
-
onPrefix={(v) => {
|
|
621
|
-
/* single: the browser's path IS the bucket path, no segment to strip */
|
|
622
|
-
if (single) { setAppRoot(false); setPrefix(v); return }
|
|
623
|
-
const [, seg, ...rest] = v.split('/')
|
|
624
|
-
const target = buckets.find((b) => b.label === seg)
|
|
625
|
-
if (!target && buckets.length) { setAppRoot(true); setPickedFile(null); setPrefix(''); return }
|
|
626
|
-
setAppRoot(false)
|
|
627
|
-
const real = rest.join('/')
|
|
628
|
-
if (target && target.id !== bucketMeta.id) switchBucket(target.id, real); else setPrefix(real)
|
|
629
|
-
}}
|
|
630
|
-
onPick={(o) => setPickedFile(o ? { ...o, key: o.key.slice(VROOT.length) } : null)}
|
|
631
|
-
quickLook={quickLook && { ...quickLook, files: quickLook.files.map((o) => ({ ...o, key: `${VROOT}${o.key}` })) }}
|
|
632
|
-
onQuickLook={(q) => setQuickLook(q && { ...q, files: q.files.map((o) => ({ ...o, key: o.key.slice(VROOT.length) })) })}
|
|
633
|
-
urlOf={(o) => mediaUrl(o.key.slice(VROOT.length))}
|
|
634
|
-
kindOf={kindOf}
|
|
635
|
-
kindLabel={KIND_LABEL}
|
|
636
|
-
formatSize={formatSize}
|
|
637
|
-
formatDate={formatDate}
|
|
638
|
-
thumbnailFor={thumbnailFor}
|
|
639
|
-
folderMeta={folderMeta}
|
|
640
|
-
/* the PROP wins when a consumer passes one; otherwise the `···`
|
|
641
|
-
owns it through settings (item 15) */
|
|
642
|
-
stackView={stackView ?? settings.stackView ?? 'list'}
|
|
643
|
-
renderPreview={(o) => {
|
|
644
|
-
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
645
|
-
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
|
|
646
|
-
const poster = posterFor(real.key, keySet)
|
|
647
|
-
return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
648
|
-
}}
|
|
649
|
-
partition={(objs, level) => {
|
|
650
|
-
if (!single) {
|
|
651
|
-
if (level === '') return { folders: [`${ROOT}/`], files: [] }
|
|
652
|
-
if (level === `${ROOT}/`) return { folders: (buckets.length ? buckets : [bucketMeta]).map((b) => `${b.label || 'bucket'}/`), files: [] }
|
|
653
|
-
}
|
|
654
|
-
const live = partition(objs, level)
|
|
655
|
-
const baked = treeFolders(level.slice(VROOT.length))
|
|
656
|
-
return { folders: [...new Set([...baked, ...live.folders])].sort(), files: live.files }
|
|
657
|
-
}}
|
|
658
|
-
/>
|
|
659
|
-
</div>
|
|
660
|
-
) : folders.length > 0 && (
|
|
661
|
-
<ul className="flex flex-col">{folders.map((f) => <FolderRow key={f} name={f} onClick={() => setPrefix(prefix + f)} struck={flat} />)}</ul>
|
|
1876
|
+
{trashOpen && trash && (
|
|
1877
|
+
<TrashPanel trash={trash} onClose={() => setTrashOpen(false)} run={runAction} confirm={modal.confirm} />
|
|
662
1878
|
)}
|
|
663
1879
|
|
|
664
|
-
{
|
|
1880
|
+
{/* THE FILTER BAR, above whichever body is mounted (2026-09-22). It filters FILES, so the
|
|
1881
|
+
* tree is scoped to the keys it leaves and the wall renders them directly. Off by default
|
|
1882
|
+
* — the funnel in the crumb row turns it on. */}
|
|
1883
|
+
{filtersOn ? (
|
|
1884
|
+
<ContentFilters
|
|
1885
|
+
items={filterItems}
|
|
1886
|
+
title="Files"
|
|
1887
|
+
totalCount={filterItems.length}
|
|
1888
|
+
searchKeys={['displayKey']}
|
|
1889
|
+
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }]}
|
|
1890
|
+
mutuallyExclusiveFilters={['kind']}
|
|
1891
|
+
/* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
|
|
1892
|
+
* its panel, and the bar's own funnel closes the bar — the same switch from either
|
|
1893
|
+
* end, instead of two controls that each do half of it. */
|
|
1894
|
+
filtersOpen
|
|
1895
|
+
onFiltersOpenChange={(open) => { if (!open) setSettings({ ...settings, filters: false }) }}
|
|
1896
|
+
renderItem={(filtered) => body(filtered)}
|
|
1897
|
+
/>
|
|
1898
|
+
) : body(null)}
|
|
1899
|
+
|
|
1900
|
+
{/* THE GAPS MATCH ABOVE AND BELOW (user 2026-09-21: "what was unclear about matching the
|
|
1901
|
+
* gap above to below"). This carried `mt-2` while the crumb row's icon toggle made that
|
|
1902
|
+
* row 8px taller than its text; the toggle was cut 2026-09-22, the crumb row is text
|
|
1903
|
+
* height again, and one shared `gap-3` puts both gaps level on its own. */}
|
|
1904
|
+
{/* THE COUNT LINE IS EVERY VIEW'S (user 2026-09-23) — and in the grid it carries the tile-size
|
|
1905
|
+
* slider at its right end, bare: no glyph, no readout (Finder's). */}
|
|
1906
|
+
{isWall ? (
|
|
1907
|
+
/* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
|
|
1908
|
+
* this row 8px taller than the fill budget, so the page scrolled by that much */
|
|
1909
|
+
<div className="flex items-center justify-between gap-4 h-4">
|
|
1910
|
+
<p className="kol-mono-12 text-fg-48">
|
|
1911
|
+
{wallFiles.length} {wallFiles.length === 1 ? 'file' : 'files'} · {formatSize(wallFiles.reduce((n, o) => n + (o.size ?? 0), 0))}
|
|
1912
|
+
</p>
|
|
1913
|
+
<input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
|
|
1914
|
+
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
1915
|
+
</div>
|
|
1916
|
+
) : appRoot && folderView === 'columns' && folderTree ? (
|
|
665
1917
|
<p className="kol-mono-12 text-fg-48">
|
|
666
1918
|
{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))}
|
|
667
1919
|
</p>
|
|
@@ -669,12 +1921,103 @@ export function MediaLibraryBrowse({
|
|
|
669
1921
|
<p className="kol-mono-12 text-fg-48">
|
|
670
1922
|
{folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
|
|
671
1923
|
{rawFiles.length} {rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
|
|
672
|
-
{
|
|
1924
|
+
{!prefix && rawFiles.length !== bucketFiles && <span className="text-fg-32">{' · bucket: '}{bucketFiles} files · {formatSize(bucketBytes)}</span>}
|
|
673
1925
|
{systemCount > 0 && <span className="text-fg-32">{' · '}{systemCount} system files hidden</span>}
|
|
674
1926
|
</p>
|
|
675
1927
|
)}
|
|
676
1928
|
{void onOpen}
|
|
677
1929
|
|
|
1930
|
+
{/* ONE MENU FOR EVERY ROW. `useContextMenu` carries the payload from whichever row opened
|
|
1931
|
+
* it, so this is a single instance rather than one per row, and the entries are built from
|
|
1932
|
+
* what `fileActions` actually supplies — a read-only bucket gets no menu at all. */}
|
|
1933
|
+
{canWrite && (
|
|
1934
|
+
<ContextMenu menu={menu}>
|
|
1935
|
+
{(target) => {
|
|
1936
|
+
if (!target) return null
|
|
1937
|
+
const isFolder = target.type === 'folder'
|
|
1938
|
+
const isLevel = target.type === 'level'
|
|
1939
|
+
/* A SELECTION OF MORE THAN ONE takes over the verbs that can act on a set. Rename is
|
|
1940
|
+
* not one of them — renaming five things to one name is not a thing — so it stays
|
|
1941
|
+
* single and disappears from a multi-selection rather than lying about what it does. */
|
|
1942
|
+
const many = (target.targets ?? []).length > 1 ? target.targets : null
|
|
1943
|
+
const custom = (fileActions.items ?? []).filter((it) => !it.when || it.when(target))
|
|
1944
|
+
const customItems = custom.length > 0 && (
|
|
1945
|
+
<>
|
|
1946
|
+
<MenuDropdownDivider />
|
|
1947
|
+
{custom.map((it) => (
|
|
1948
|
+
<MenuDropdownItem key={it.label} iconLeft={it.icon ? <Icon name={it.icon} size={14} /> : undefined} onClick={() => runAction(() => it.run(target))}>{it.label}</MenuDropdownItem>
|
|
1949
|
+
))}
|
|
1950
|
+
</>
|
|
1951
|
+
)
|
|
1952
|
+
if (many) {
|
|
1953
|
+
return (
|
|
1954
|
+
<>
|
|
1955
|
+
<MenuDropdownItem disabled>{many.length} selected</MenuDropdownItem>
|
|
1956
|
+
<MenuDropdownDivider />
|
|
1957
|
+
{fileActions.move && (
|
|
1958
|
+
<MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
|
|
1959
|
+
)}
|
|
1960
|
+
{many.every((p) => !p.endsWith('/')) && (
|
|
1961
|
+
<>
|
|
1962
|
+
<MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl(many)}>Copy {many.length} URLs</MenuDropdownItem>
|
|
1963
|
+
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload(many)}>Download {many.length}</MenuDropdownItem>
|
|
1964
|
+
</>
|
|
1965
|
+
)}
|
|
1966
|
+
{fileActions.remove && (
|
|
1967
|
+
<MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doBatch(many, 'remove', 'Delete')}>Delete {many.length}</MenuDropdownItem>
|
|
1968
|
+
)}
|
|
1969
|
+
{customItems}
|
|
1970
|
+
</>
|
|
1971
|
+
)
|
|
1972
|
+
}
|
|
1973
|
+
return (
|
|
1974
|
+
<>
|
|
1975
|
+
{fileActions.createFolder && (
|
|
1976
|
+
<MenuDropdownItem iconLeft={<Icon name="folder" size={14} />} onClick={() => doCreateFolder(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
1977
|
+
New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
|
|
1978
|
+
</MenuDropdownItem>
|
|
1979
|
+
)}
|
|
1980
|
+
{fileActions.createFile && (
|
|
1981
|
+
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
1982
|
+
New file
|
|
1983
|
+
</MenuDropdownItem>
|
|
1984
|
+
)}
|
|
1985
|
+
{dropFilesTo && !(target.type === 'file') && (
|
|
1986
|
+
<MenuDropdownItem iconLeft={<Icon name="upload" size={14} />} onClick={() => doUpload(target.path)}>
|
|
1987
|
+
{isFolder ? `Upload into ${target.path.replace(/\/$/, '').split('/').pop()}…` : 'Upload here…'}
|
|
1988
|
+
</MenuDropdownItem>
|
|
1989
|
+
)}
|
|
1990
|
+
{!isLevel && <MenuDropdownDivider />}
|
|
1991
|
+
{!isLevel && fileActions.rename && (
|
|
1992
|
+
<MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => doRename(target.path, isFolder)}>Rename</MenuDropdownItem>
|
|
1993
|
+
)}
|
|
1994
|
+
{!isLevel && fileActions.move && (
|
|
1995
|
+
<MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doMove(target.path)}>Move to…</MenuDropdownItem>
|
|
1996
|
+
)}
|
|
1997
|
+
{/* A FOLDER GETS COPY URL TOO (user 2026-09-23) — through the SAME `mediaUrl`
|
|
1998
|
+
seam a file goes through, so what a folder URL means is the consumer's call
|
|
1999
|
+
(a CDN prefix, a listing, a signed path) and never this page's guess. No
|
|
2000
|
+
Download: a folder is not an object to fetch. */}
|
|
2001
|
+
{!isLevel && (
|
|
2002
|
+
<MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl([target.path])}>Copy URL</MenuDropdownItem>
|
|
2003
|
+
)}
|
|
2004
|
+
{target.type === 'file' && (
|
|
2005
|
+
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
|
|
2006
|
+
)}
|
|
2007
|
+
{!isLevel && fileActions.remove && (
|
|
2008
|
+
<>
|
|
2009
|
+
<MenuDropdownDivider />
|
|
2010
|
+
<MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doDelete(target.path, isFolder)}>Delete</MenuDropdownItem>
|
|
2011
|
+
</>
|
|
2012
|
+
)}
|
|
2013
|
+
{customItems}
|
|
2014
|
+
</>
|
|
2015
|
+
)
|
|
2016
|
+
}}
|
|
2017
|
+
</ContextMenu>
|
|
2018
|
+
)}
|
|
2019
|
+
{busyAction && <span className="sr-only" role="status">Working…</span>}
|
|
2020
|
+
|
|
678
2021
|
{/* THE TAB PILL (item 16) — floats over the list, so the list owes it
|
|
679
2022
|
room or its last row sits under the bar forever.
|
|
680
2023
|
THE SPACER GOES LAST, AND THAT IS THE WHOLE POINT
|
|
@@ -697,10 +2040,176 @@ export function MediaLibraryBrowse({
|
|
|
697
2040
|
)
|
|
698
2041
|
}
|
|
699
2042
|
|
|
2043
|
+
|
|
2044
|
+
/* ── THE WALL, as a body ────────────────────────────────────────────────────
|
|
2045
|
+
* The grid / list of files, its paging and its inspector, lifted out of
|
|
2046
|
+
* `MediaLibraryLibrary` so ONE surface can render it beside the folder views
|
|
2047
|
+
* (the merge, user 2026-09-22: "they both just display files"). The page above
|
|
2048
|
+
* it owns the listing, the filtering and the selection; this owns the tiles.
|
|
2049
|
+
*
|
|
2050
|
+
* `renderName` / `renderActions` are seams because the standalone wall edits a
|
|
2051
|
+
* name in place and the merged surface does not — the markup is the same either
|
|
2052
|
+
* way, which is the whole reason this is one component and not two.
|
|
2053
|
+
*/
|
|
2054
|
+
/* The kinds that ARE a document — the ones whose page is worth zooming into a tile. */
|
|
2055
|
+
const DOC_KINDS = new Set(['markdown', 'json', 'yaml', 'text', 'code'])
|
|
2056
|
+
|
|
2057
|
+
/* THE TILE MEASURES ITSELF (user 2026-09-23: *"is nothing about the thumbnails responsive?"*).
|
|
2058
|
+
* A document in a tile is zoomed, and the zoom was a fixed step — so the same file read one size
|
|
2059
|
+
* in a 260px card and another in a 400px one, because `zoom` reflows the page: at zoom z a
|
|
2060
|
+
* `width: 100%` page lays out at box/z CSS pixels. Solving for a CONSTANT page width keeps the
|
|
2061
|
+
* text the same physical size at every tile size: z = box / PAGE. That number cannot be derived
|
|
2062
|
+
* in CSS from the box, which is why this measures.
|
|
2063
|
+
*
|
|
2064
|
+
* `PAGE` is the width the page is laid out AT, not a paper size: 720 is where the doc page's
|
|
2065
|
+
* 24px padding, prose measure and code blocks read as a page rather than as a column of wrap. */
|
|
2066
|
+
/* CODE IS NOT PROSE, so it is not laid out on a prose page (user 2026-09-23: *"code is small here
|
|
2067
|
+
* in the container"*). A narrower target page means a bigger zoom for the same box, which is what
|
|
2068
|
+
* makes 60-column code readable in a tile where a 720px page turned it into texture. */
|
|
2069
|
+
const DOC_PAGE_W = 720
|
|
2070
|
+
const CODE_PAGE_W = 460
|
|
2071
|
+
const pageWidthFor = (kind) => (kind === 'markdown' || kind === 'text' ? DOC_PAGE_W : CODE_PAGE_W)
|
|
2072
|
+
const DOC_ZOOM_MIN = 0.18
|
|
2073
|
+
const DOC_ZOOM_MAX = 0.6
|
|
2074
|
+
|
|
2075
|
+
function DocThumb({ children, className, onClick, pageWidth = DOC_PAGE_W }) {
|
|
2076
|
+
const box = useRef(null)
|
|
2077
|
+
const [zoom, setZoom] = useState(null)
|
|
2078
|
+
useEffect(() => {
|
|
2079
|
+
const el = box.current
|
|
2080
|
+
if (!el || typeof ResizeObserver === 'undefined') return undefined
|
|
2081
|
+
const measure = () => {
|
|
2082
|
+
const w = el.getBoundingClientRect().width
|
|
2083
|
+
if (!w) return
|
|
2084
|
+
setZoom(Math.min(DOC_ZOOM_MAX, Math.max(DOC_ZOOM_MIN, Math.round((w / pageWidth) * 100) / 100)))
|
|
2085
|
+
}
|
|
2086
|
+
measure()
|
|
2087
|
+
const ro = new ResizeObserver(measure)
|
|
2088
|
+
ro.observe(el)
|
|
2089
|
+
return () => ro.disconnect()
|
|
2090
|
+
}, [pageWidth])
|
|
2091
|
+
return (
|
|
2092
|
+
<div ref={box} className={className} onClick={onClick} style={zoom ? { '--kol-thumb-zoom': zoom } : undefined}>
|
|
2093
|
+
{children}
|
|
2094
|
+
</div>
|
|
2095
|
+
)
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
export function WallBody({
|
|
2099
|
+
files, layout = 'list', sortBy = 'name', sortDir = 'asc', pageSize = 200, listId = '', cardMin = 260,
|
|
2100
|
+
mediaUrl, downloadUrl, keySet, videoPreview, formatDate = defaultFormatDate,
|
|
2101
|
+
selected, selectMode = false, onToggleSelect, onSorted, onContextMenu, onPick,
|
|
2102
|
+
/* ONE INSPECTOR (user 2026-09-23). Given `onOpen`, the wall hands the open UP and keeps no
|
|
2103
|
+
* lightbox of its own — otherwise a page that also opens one (space, a row double-click) ends up
|
|
2104
|
+
* with two stacked overlays of the same file. The standalone wall passes nothing and keeps its
|
|
2105
|
+
* own, exactly as it was. */
|
|
2106
|
+
onOpen,
|
|
2107
|
+
renderName = (o) => o.displayKey?.split('/').pop() ?? o.key.split('/').pop(),
|
|
2108
|
+
renderActions,
|
|
2109
|
+
/* `tiles="media"` (2026-09-23) — the file manager's grid: `MediaTile`, preview over name, no card
|
|
2110
|
+
* and no buttons (the verbs live in the right-click menu and Quick Look). `card` is the site
|
|
2111
|
+
* wall's `ContentCard` and stays the default, so an embedded wall renders exactly as before. */
|
|
2112
|
+
tiles = 'card',
|
|
2113
|
+
/* the grid's drag-select band and its empty-space click — both the page's, like the rows' */
|
|
2114
|
+
marquee, onBackgroundClick,
|
|
2115
|
+
}) {
|
|
2116
|
+
const PAGE = pageSize || Infinity
|
|
2117
|
+
const [visible, setVisible] = useState(PAGE)
|
|
2118
|
+
const [lastListId, setLastListId] = useState(listId)
|
|
2119
|
+
const [lightboxIndex, setLightboxIndex] = useState(null)
|
|
2120
|
+
if (listId !== lastListId) { setLastListId(listId); setVisible(PAGE) }
|
|
2121
|
+
if (layout === 'off') return null
|
|
2122
|
+
|
|
2123
|
+
/* ONE PREVIEWER — `KindPreview`, as the column pane uses. An image is the caller's <img>, a
|
|
2124
|
+
* video keeps its sibling poster when the bucket has one, and every other kind falls through. */
|
|
2125
|
+
const renderThumb = (o, onClick) => {
|
|
2126
|
+
const kind = kindOf(o)
|
|
2127
|
+
const poster = (isVideo(o.contentType) || kind === 'playlist') ? posterFor(o.key, keySet) : null
|
|
2128
|
+
const imgSrc = isImage(o.contentType) ? mediaUrl(o.key) : poster && videoPreview !== 'none' ? mediaUrl(poster) : null
|
|
2129
|
+
if (imgSrc) return <img src={imgSrc} alt="" loading="lazy" className={onClick ? 'cursor-zoom-in' : undefined} onClick={onClick || undefined} />
|
|
2130
|
+
if (isVideo(o.contentType) && videoPreview === 'none') {
|
|
2131
|
+
return (
|
|
2132
|
+
<div className={`w-full h-full flex items-center justify-center bg-oq-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`} onClick={onClick || undefined}>
|
|
2133
|
+
<span className="kol-mono-12 text-fg-48">video</span>
|
|
2134
|
+
</div>
|
|
2135
|
+
)
|
|
2136
|
+
}
|
|
2137
|
+
/* ONE LADDER (user 2026-09-23). Every kind that is not the caller's own image goes through
|
|
2138
|
+
* `KindPreview` at `fit="tile"` — audio gets the same tile the pane shows, a PDF its first
|
|
2139
|
+
* page, a document its zoomed page. The glyph branch that stood here was a second ladder, and
|
|
2140
|
+
* a second ladder is the thing this pass exists to delete: a glyph is for a FOLDER. */
|
|
2141
|
+
const cls = `kol-media-thumb w-full h-full flex items-center justify-center bg-oq-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`
|
|
2142
|
+
const preview = <KindPreview o={o} fit="tile" urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
2143
|
+
if (DOC_KINDS.has(kind)) {
|
|
2144
|
+
return <DocThumb className={cls} pageWidth={pageWidthFor(kind)} onClick={onClick || undefined}>{preview}</DocThumb>
|
|
2145
|
+
}
|
|
2146
|
+
return <div className={cls} onClick={onClick || undefined}>{preview}</div>
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
const sorted = sortFiles(files, sortBy, sortDir)
|
|
2150
|
+
onSorted?.(sorted)
|
|
2151
|
+
const shown = sorted.slice(0, visible)
|
|
2152
|
+
const more = sorted.length - shown.length
|
|
2153
|
+
const isSelected = (key) => !!selected?.has(key)
|
|
2154
|
+
const openAt = (o, idx) => (onOpen ? onOpen(o, sorted) : setLightboxIndex(idx))
|
|
2155
|
+
return (
|
|
2156
|
+
<div className="flex flex-col gap-3">
|
|
2157
|
+
{sorted.length === 0 ? null : layout !== 'grid' ? (
|
|
2158
|
+
<div className="flex flex-col">
|
|
2159
|
+
{shown.map((o, idx) => (
|
|
2160
|
+
<ContentRow key={o.key} variant="default" media={renderThumb(o, null)} title={renderName(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
|
|
2161
|
+
actions={renderActions?.(o, true)} selected={isSelected(o.key)}
|
|
2162
|
+
onContextMenu={onContextMenu ? (e) => onContextMenu(e, o) : undefined}
|
|
2163
|
+
onDoubleClick={() => openAt(o, idx)}
|
|
2164
|
+
onClick={selectMode ? (e) => onToggleSelect?.(idx, o.key, e.shiftKey) : onPick ? (e) => onPick(o, e, sorted.map((f) => f.key)) : undefined} />
|
|
2165
|
+
))}
|
|
2166
|
+
</div>
|
|
2167
|
+
) : tiles === 'media' ? (
|
|
2168
|
+
<div ref={marquee?.ref} {...marquee?.props} className="relative grid gap-x-3 gap-y-5"
|
|
2169
|
+
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
|
|
2170
|
+
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) onBackgroundClick?.() }}>
|
|
2171
|
+
{marquee?.rect && <div className="kol-marquee" style={marquee.rect} />}
|
|
2172
|
+
{shown.map((o, idx) => (
|
|
2173
|
+
<MediaTile key={o.key} markKey={o.key} preview={renderThumb(o, null)} name={renderName(o)} selected={isSelected(o.key)}
|
|
2174
|
+
onContextMenu={onContextMenu ? (e) => onContextMenu(e, o) : undefined}
|
|
2175
|
+
onDoubleClick={() => openAt(o, idx)}
|
|
2176
|
+
onClick={onPick ? (e) => onPick(o, e, sorted.map((f) => f.key)) : undefined} />
|
|
2177
|
+
))}
|
|
2178
|
+
</div>
|
|
2179
|
+
) : (
|
|
2180
|
+
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}>
|
|
2181
|
+
{shown.map((o, idx) => (
|
|
2182
|
+
<ContentCard key={o.key} variant="default" media={renderThumb(o, null)}
|
|
2183
|
+
control={<ActionButton chrome="media" icon="download" confirmIcon="check" label="Download" confirmLabel="Downloaded" href={downloadUrl(o.key)} />}
|
|
2184
|
+
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}
|
|
2185
|
+
title={renderName(o)} date={formatDate(o.uploaded)} size={<SizeOrDownload href={downloadUrl(o.key)}>{formatSize(o.size)}</SizeOrDownload>}
|
|
2186
|
+
actions={renderActions?.(o)} selected={isSelected(o.key)}
|
|
2187
|
+
onContextMenu={onContextMenu ? (e) => onContextMenu(e, o) : undefined}
|
|
2188
|
+
/* ONE GESTURE SET ACROSS THE THREE VIEWS (2026-09-23): click selects, double-click
|
|
2189
|
+
* opens, right-click is the menu. The media used to open on a single click, which
|
|
2190
|
+
* made the grid the one view where a click did something else. */
|
|
2191
|
+
onDoubleClick={() => openAt(o, idx)}
|
|
2192
|
+
onClick={selectMode ? (e) => onToggleSelect?.(idx, o.key, e.shiftKey) : onPick ? (e) => onPick(o, e, sorted.map((f) => f.key)) : undefined} />
|
|
2193
|
+
))}
|
|
2194
|
+
</div>
|
|
2195
|
+
)}
|
|
2196
|
+
{more > 0 && (
|
|
2197
|
+
<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">
|
|
2198
|
+
Show {Math.min(more, PAGE)} more · {more} remaining
|
|
2199
|
+
</button>
|
|
2200
|
+
)}
|
|
2201
|
+
{lightboxIndex !== null && !onOpen && (
|
|
2202
|
+
<MediaInspector files={sorted} index={lightboxIndex} onClose={() => setLightboxIndex(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
|
|
2203
|
+
onPrev={() => setLightboxIndex((i) => (i - 1 + sorted.length) % sorted.length)} onNext={() => setLightboxIndex((i) => (i + 1) % sorted.length)} />
|
|
2204
|
+
)}
|
|
2205
|
+
</div>
|
|
2206
|
+
)
|
|
2207
|
+
}
|
|
2208
|
+
|
|
700
2209
|
/* ══ LIBRARY — the content-filters wall ═══════════════════════════════════ */
|
|
701
2210
|
export function MediaLibraryLibrary({
|
|
702
2211
|
client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
|
|
703
|
-
headerActions, refreshKey, header = true, stats = true, settingsFooter, className = '',
|
|
2212
|
+
headerActions, headerTrailing, refreshKey, header = true, stats = true, settingsFooter, className = '', banner,
|
|
704
2213
|
/* the same pill the browse page takes — the wall is one of the surfaces it
|
|
705
2214
|
switches between, so it has to carry it too (item 16) */
|
|
706
2215
|
tabs, activeTab, onTabChange,
|
|
@@ -724,8 +2233,9 @@ export function MediaLibraryLibrary({
|
|
|
724
2233
|
const bucketId = bucket ?? ownBucket
|
|
725
2234
|
const lib = useBucketLibrary({ client, bucket: bucketId, defaults, settings: settingsProp, onSettingsChange, refreshKey })
|
|
726
2235
|
const { buckets, bucketMeta, settings, setSettings, objects, setObjects, error, mediaUrl, downloadUrl, writable } = lib
|
|
2236
|
+
/* the same one dialog idiom as the browse page — see its note on `useModal()` */
|
|
2237
|
+
const modal = useModal()
|
|
727
2238
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
728
|
-
const [lightboxIndex, setLightboxIndex] = useState(null)
|
|
729
2239
|
const [editingKey, setEditingKey] = useState(null)
|
|
730
2240
|
const [editingValue, setEditingValue] = useState('')
|
|
731
2241
|
const [renaming, setRenaming] = useState(false)
|
|
@@ -739,11 +2249,7 @@ export function MediaLibraryLibrary({
|
|
|
739
2249
|
const kinds = new Set(settings.kinds)
|
|
740
2250
|
const setFlat = (v) => setSettings({ ...settings, flat: v })
|
|
741
2251
|
const setLayout = (v) => setSettings({ ...settings, layout: v })
|
|
742
|
-
const PAGE = pageSize || Infinity
|
|
743
|
-
const [visible, setVisible] = useState(PAGE)
|
|
744
2252
|
const listId = `${prefix}|${flat}|${[...kinds].sort().join(',')}|${pageSize}|${refreshKey}|${bucketMeta.id}`
|
|
745
|
-
const [lastListId, setLastListId] = useState(listId)
|
|
746
|
-
if (listId !== lastListId) { setLastListId(listId); setVisible(PAGE) }
|
|
747
2253
|
const handleSort = (field) => setSettings(sortBy === field ? { ...settings, sortDir: sortDir === 'asc' ? 'desc' : 'asc' } : { ...settings, sortBy: field, sortDir: 'asc' })
|
|
748
2254
|
|
|
749
2255
|
useEffect(() => {
|
|
@@ -772,8 +2278,8 @@ export function MediaLibraryLibrary({
|
|
|
772
2278
|
|
|
773
2279
|
const handleCopy = (key) => navigator.clipboard.writeText(mediaUrl(key))
|
|
774
2280
|
const handleDelete = async (key) => {
|
|
775
|
-
if (!confirm(`Delete "${key}"? This cannot be undone
|
|
776
|
-
try { await client.deleteObject(key); setObjects((prev) => prev.filter((o) => o.key !== key)) } catch (e) { alert(`Delete failed: ${e.message}`) }
|
|
2281
|
+
if (!(await modal.confirm(`Delete "${key}"? This cannot be undone.`, { okLabel: 'Delete' }))) return
|
|
2282
|
+
try { await client.deleteObject(key); setObjects((prev) => prev.filter((o) => o.key !== key)) } catch (e) { await modal.alert(`Delete failed: ${e.message}`) }
|
|
777
2283
|
}
|
|
778
2284
|
const startRename = (key) => { setEditingKey(key); setEditingValue(key) }
|
|
779
2285
|
const cancelRename = () => { setEditingKey(null); setEditingValue('') }
|
|
@@ -782,7 +2288,7 @@ export function MediaLibraryLibrary({
|
|
|
782
2288
|
if (!from || !to || from === to) { cancelRename(); return }
|
|
783
2289
|
setRenaming(true)
|
|
784
2290
|
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('') }
|
|
785
|
-
catch (e) { alert(`Rename failed: ${e.message}`) } finally { setRenaming(false) }
|
|
2291
|
+
catch (e) { await modal.alert(`Rename failed: ${e.message}`) } finally { setRenaming(false) }
|
|
786
2292
|
}
|
|
787
2293
|
const toggleSelect = (idx, key, shift) => {
|
|
788
2294
|
setSelected((prev) => {
|
|
@@ -797,8 +2303,8 @@ export function MediaLibraryLibrary({
|
|
|
797
2303
|
const selectAll = () => setSelected(new Set(sortedRef.current.map((o) => o.key)))
|
|
798
2304
|
const exitSelect = () => { setSelectMode(false); setSelected(new Set()); setLastIdx(null) }
|
|
799
2305
|
const batchMove = async () => {
|
|
800
|
-
const folder = prompt(`Move ${selected.size} file(s) into folder (under ${prefix || 'root'})
|
|
801
|
-
if (folder
|
|
2306
|
+
const folder = await modal.prompt(`Move ${selected.size} file(s) into folder (under ${prefix || 'root'}):`, '', { okLabel: 'Move' })
|
|
2307
|
+
if (folder == null) return
|
|
802
2308
|
const clean = folder.replace(/^\/+|\/+$/g, '').trim()
|
|
803
2309
|
if (!clean) return
|
|
804
2310
|
setBusy(true)
|
|
@@ -810,16 +2316,16 @@ export function MediaLibraryLibrary({
|
|
|
810
2316
|
}
|
|
811
2317
|
setObjects((prev) => prev.map((o) => (remap.has(o.key) ? { ...o, key: remap.get(o.key) } : o)))
|
|
812
2318
|
setBusy(false); exitSelect()
|
|
813
|
-
if (failures.length) alert(`Moved ${remap.size}. ${failures.length} failed:\n${failures.join('\n')}`)
|
|
2319
|
+
if (failures.length) await modal.alert(`Moved ${remap.size}. ${failures.length} failed:\n${failures.join('\n')}`)
|
|
814
2320
|
}
|
|
815
2321
|
const batchDelete = async () => {
|
|
816
|
-
if (!confirm(`Delete ${selected.size} file(s)? This cannot be undone
|
|
2322
|
+
if (!(await modal.confirm(`Delete ${selected.size} file(s)? This cannot be undone.`, { okLabel: 'Delete' }))) return
|
|
817
2323
|
setBusy(true)
|
|
818
2324
|
const done = []; const failures = []
|
|
819
2325
|
for (const key of selected) { try { await client.deleteObject(key); done.push(key) } catch (e) { failures.push(`${key.split('/').pop()}: ${e.message}`) } }
|
|
820
2326
|
setObjects((prev) => prev.filter((o) => !done.includes(o.key)))
|
|
821
2327
|
setBusy(false); exitSelect()
|
|
822
|
-
if (failures.length) alert(`Deleted ${done.length}. ${failures.length} failed:\n${failures.join('\n')}`)
|
|
2328
|
+
if (failures.length) await modal.alert(`Deleted ${done.length}. ${failures.length} failed:\n${failures.join('\n')}`)
|
|
823
2329
|
}
|
|
824
2330
|
const batchDownload = () => {
|
|
825
2331
|
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() }
|
|
@@ -857,20 +2363,6 @@ export function MediaLibraryLibrary({
|
|
|
857
2363
|
}
|
|
858
2364
|
return o.displayKey.split('/').pop()
|
|
859
2365
|
}
|
|
860
|
-
const renderThumb = (o, onClick) => {
|
|
861
|
-
const media = isImage(o.contentType) || isVideo(o.contentType)
|
|
862
|
-
const poster = (isVideo(o.contentType) || kindOf(o) === 'playlist') ? posterFor(o.key, keySet) : null
|
|
863
|
-
const imgSrc = isImage(o.contentType) ? mediaUrl(o.key) : poster && videoPreview !== 'none' ? mediaUrl(poster) : null
|
|
864
|
-
if (imgSrc) return <img src={imgSrc} alt="" loading="lazy" className={onClick ? 'cursor-zoom-in' : undefined} onClick={onClick || undefined} />
|
|
865
|
-
return (
|
|
866
|
-
<div className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${media && onClick ? ' cursor-zoom-in' : ''}`} onClick={media && onClick ? onClick : undefined}>
|
|
867
|
-
{isVideo(o.contentType) && videoPreview === 'autoload' ? <video src={mediaUrl(o.key)} className="w-full h-full object-cover" muted preload="metadata" />
|
|
868
|
-
: isVideo(o.contentType) ? <span className="kol-mono-12 text-fg-48">video</span>
|
|
869
|
-
: <span className="kol-mono-12 text-fg-48">{KIND_LABEL[kindOf(o)] || 'file'}{o.segmentCount ? ` ${o.segmentCount}` : ''}</span>}
|
|
870
|
-
</div>
|
|
871
|
-
)
|
|
872
|
-
}
|
|
873
|
-
|
|
874
2366
|
const layoutOptions = [
|
|
875
2367
|
...(writable ? [{ value: 'grid', label: selectMode ? 'CANCEL' : 'SELECT', active: !selectMode, title: 'Select multiple files', onClick: () => (selectMode ? exitSelect() : setSelectMode(true)) }] : []),
|
|
876
2368
|
{ value: 'list', label: flat ? 'TREE' : 'FLAT', active: flat, title: 'Show all files recursively', onClick: () => setFlat(!flat) },
|
|
@@ -879,9 +2371,12 @@ export function MediaLibraryLibrary({
|
|
|
879
2371
|
return (
|
|
880
2372
|
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
881
2373
|
{header && (
|
|
882
|
-
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={false} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions}
|
|
2374
|
+
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={false} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing}
|
|
883
2375
|
onBucket={(v) => { if (v === 'all') return; if (bucket == null) setOwnBucket(v); onBucketChange?.(v) }} onSettings={() => setSettingsOpen(true)} />
|
|
884
2376
|
)}
|
|
2377
|
+
{/* A SEAM ON ONE PAGE IS A SEAM ON BOTH — `banner` is shared chrome, so it lands here in the
|
|
2378
|
+
* same edit it landed on the browse page. See the note on `formatDate` above. */}
|
|
2379
|
+
{banner}
|
|
885
2380
|
{settingsOpen && (
|
|
886
2381
|
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
887
2382
|
)}
|
|
@@ -920,44 +2415,13 @@ export function MediaLibraryLibrary({
|
|
|
920
2415
|
onLayoutChange={setLayout}
|
|
921
2416
|
trailingActions={<div className="flex items-center gap-6"><ViewToggle viewMode={layout} onViewChange={setLayout} variant="icon" options={LAYOUT_OPTIONS} /></div>}
|
|
922
2417
|
belowActions={layout === 'off' ? null : <div className="h-8 flex items-center"><SortControls options={SORT_OPTIONS} sortBy={sortBy} sortDir={sortDir} onSort={handleSort} /></div>}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
<div className="flex flex-col gap-3">
|
|
931
|
-
{sorted.length === 0 ? null : layout !== 'grid' ? (
|
|
932
|
-
<div className="flex flex-col">
|
|
933
|
-
{shown.map((o, idx) => (
|
|
934
|
-
<ContentRow key={o.key} variant="default" media={renderThumb(o, selectMode ? null : () => setLightboxIndex(idx))} title={renderNameCell(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
|
|
935
|
-
actions={renderActions(o, true)} selected={selected.has(o.key)} onClick={selectMode ? (e) => toggleSelect(idx, o.key, e.shiftKey) : undefined} />
|
|
936
|
-
))}
|
|
937
|
-
</div>
|
|
938
|
-
) : (
|
|
939
|
-
<div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
|
940
|
-
{shown.map((o, idx) => (
|
|
941
|
-
<ContentCard key={o.key} variant="default" media={renderThumb(o, selectMode ? null : () => setLightboxIndex(idx))}
|
|
942
|
-
control={<ActionButton chrome="media" icon="download" confirmIcon="check" label="Download" confirmLabel="Downloaded" href={downloadUrl(o.key)} />}
|
|
943
|
-
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}
|
|
944
|
-
title={renderNameCell(o)} date={formatDate(o.uploaded)} size={<SizeOrDownload href={downloadUrl(o.key)}>{formatSize(o.size)}</SizeOrDownload>}
|
|
945
|
-
actions={renderActions(o)} selected={selected.has(o.key)} onClick={selectMode ? (e) => toggleSelect(idx, o.key, e.shiftKey) : undefined} />
|
|
946
|
-
))}
|
|
947
|
-
</div>
|
|
948
|
-
)}
|
|
949
|
-
{more > 0 && (
|
|
950
|
-
<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">
|
|
951
|
-
Show {Math.min(more, PAGE)} more · {more} remaining
|
|
952
|
-
</button>
|
|
953
|
-
)}
|
|
954
|
-
{lightboxIndex !== null && (
|
|
955
|
-
<MediaInspector files={sorted} index={lightboxIndex} onClose={() => setLightboxIndex(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
|
|
956
|
-
onPrev={() => setLightboxIndex((i) => (i - 1 + sorted.length) % sorted.length)} onNext={() => setLightboxIndex((i) => (i + 1) % sorted.length)} />
|
|
957
|
-
)}
|
|
958
|
-
</div>
|
|
959
|
-
)
|
|
960
|
-
}}
|
|
2418
|
+
/* ONE WALL BODY, shared with the merged surface (2026-09-22) */
|
|
2419
|
+
renderItem={(filtered) => (
|
|
2420
|
+
<WallBody files={filtered} layout={layout} sortBy={sortBy} sortDir={sortDir} pageSize={pageSize} listId={listId}
|
|
2421
|
+
mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} videoPreview={videoPreview} formatDate={formatDate}
|
|
2422
|
+
selected={selected} selectMode={selectMode} onToggleSelect={toggleSelect} onSorted={(list) => { sortedRef.current = list }}
|
|
2423
|
+
renderName={renderNameCell} renderActions={renderActions} />
|
|
2424
|
+
)}
|
|
961
2425
|
/>
|
|
962
2426
|
|
|
963
2427
|
{/* the pill again — the wall is one of the surfaces it switches, so it
|