@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,10 +1,12 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
-
import KindPreview from '../molecules/KindPreview.jsx'
|
|
3
|
+
import KindPreview, { KIND_GLYPH } from '../molecules/KindPreview.jsx'
|
|
4
|
+
import FileIcon from '../atoms/FileIcon.jsx'
|
|
4
5
|
import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
5
6
|
import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
|
|
6
7
|
import useGrabEdge from '../hooks/useGrabEdge.js'
|
|
7
8
|
import useMediaQuery from '../hooks/useMediaQuery.js'
|
|
9
|
+
import useMarquee from '../hooks/useMarquee.js'
|
|
8
10
|
import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -60,9 +62,12 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
|
60
62
|
* @param {Function} formatDate (isoString) => string — the row/preview date, ISO date-only by default
|
|
61
63
|
* @param {Function} partition (objects, level) => { folders: string[], files: object[] }
|
|
62
64
|
* @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
|
|
63
|
-
* @param {number}
|
|
65
|
+
* @param {number|string} height controlled height — px as a number, or ANY CSS length (`'100%'`, `'60vh'`,
|
|
66
|
+
* `'calc(100dvh - 240px)'`) so the browser can fill the space it is given
|
|
67
|
+
* (kol-client-olina 2026-09-22); a CSS length draws no height grabber;
|
|
68
|
+
* omit for uncontrolled
|
|
64
69
|
* @param {number} defaultHeight uncontrolled start height (528); min 240
|
|
65
|
-
* @param {Function} onHeightChange (px) => void — on every drag step; the consumer persists it
|
|
70
|
+
* @param {Function} onHeightChange (px) => void — on every drag step, always px; the consumer persists it
|
|
66
71
|
* @param {number} columnWidth every column's start width (260); the preview column starts at 320; min 160
|
|
67
72
|
* @param {Object} columnWidths the CONTROLLED counterpart to `onColumnResize` (ColumnBrowserWidthsPersist,
|
|
68
73
|
* kol-r2b2 2026-08-27): a map keyed by column index plus `'preview'` —
|
|
@@ -70,6 +75,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
|
70
75
|
* so a consumer hands back what it stored. A key with no column is ignored;
|
|
71
76
|
* any column it does not name falls back to the drag state, then `columnWidth`
|
|
72
77
|
* @param {Function} onColumnResize (index, px) => void — the column's index, or `'preview'`
|
|
78
|
+
* @param {Function} onDropFiles (files, folderPath) => void — OS files dropped on a folder row or column; the consumer uploads
|
|
73
79
|
* @param {string} className extra classes on the browser
|
|
74
80
|
*
|
|
75
81
|
* @param {Function} thumbnailFor (o) => node — the 44px tile's content below the breakpoint; null falls back to the kind glyph. WHERE a thumbnail comes from is the consumer's: R2 and B2 serve originals, so a 44px tile can mean a 2 MB download
|
|
@@ -183,6 +189,8 @@ const defaultPartition = (objects, prefix) => {
|
|
|
183
189
|
return { folders: [...folderSet].sort(), files }
|
|
184
190
|
}
|
|
185
191
|
const isImage = (o) => (o.contentType || '').startsWith('image/')
|
|
192
|
+
/* a drag carrying files from the OS, as opposed to one of our own rows */
|
|
193
|
+
export const isFileDrag = (e) => [...(e.dataTransfer?.types ?? [])].includes('Files')
|
|
186
194
|
|
|
187
195
|
/* kol-icons has no `audio` glyph yet — audio rows wear `file` until it does */
|
|
188
196
|
const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'video' }
|
|
@@ -239,10 +247,30 @@ const ZONE_BOX = 44
|
|
|
239
247
|
* to what it was. */
|
|
240
248
|
function Row({
|
|
241
249
|
icon, label, active, cursor = false, trailing, onClick, muted = false,
|
|
242
|
-
indent = 0, meta, zones = false, thumb, onDisclose, disclosed,
|
|
250
|
+
indent = 0, meta, zones = false, thumb, onDisclose, disclosed, onContextMenu, drop, dropFiles, markKey,
|
|
243
251
|
}) {
|
|
252
|
+
const [over, setOver] = useState(false)
|
|
253
|
+
/* A COLUMN ROW IS THE SAME ROW as the list view's, so it takes the same two seams: right-click
|
|
254
|
+
* and, for a folder, a drop target. Without them every handler is undefined and nothing moves.
|
|
255
|
+
* `dropFiles(files)` is the OS half — files dragged in from the desktop, same highlight. */
|
|
256
|
+
const takesFiles = (e) => dropFiles && isFileDrag(e)
|
|
257
|
+
const dropProps = drop || dropFiles ? {
|
|
258
|
+
draggable: !!drop,
|
|
259
|
+
onDragStart: drop ? (e) => { e.stopPropagation(); drop.onDragStart(e, drop.path) } : undefined,
|
|
260
|
+
onDragOver: (e) => { if (takesFiles(e) || (drop && !isFileDrag(e) && drop.canDrop(drop.path))) { e.preventDefault(); setOver(true) } },
|
|
261
|
+
onDragLeave: () => setOver(false),
|
|
262
|
+
onDrop: (e) => {
|
|
263
|
+
e.preventDefault(); e.stopPropagation(); setOver(false)
|
|
264
|
+
if (takesFiles(e)) dropFiles(e.dataTransfer.files)
|
|
265
|
+
else if (drop && !isFileDrag(e)) drop.onDrop(drop.path)
|
|
266
|
+
},
|
|
267
|
+
} : {}
|
|
244
268
|
return (
|
|
245
269
|
<li
|
|
270
|
+
onContextMenu={onContextMenu}
|
|
271
|
+
{...dropProps}
|
|
272
|
+
data-marquee-key={markKey}
|
|
273
|
+
data-drop-over={over || undefined}
|
|
246
274
|
/* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
|
|
247
275
|
* 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last.
|
|
248
276
|
* The `zones` row sets its own 60px instead — a 44px thumbnail in a
|
|
@@ -292,11 +320,13 @@ function Row({
|
|
|
292
320
|
className="kol-column-browser-thumb shrink-0 inline-flex items-center justify-center overflow-hidden"
|
|
293
321
|
style={{ width: ZONE_BOX, height: ZONE_BOX, borderRadius: 5, background: thumb ? 'var(--kol-oq-04)' : 'transparent' }}
|
|
294
322
|
>
|
|
295
|
-
{thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-
|
|
323
|
+
{thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-oq-48" />}
|
|
296
324
|
</span>
|
|
297
325
|
) : (
|
|
298
|
-
<span className="w-5 shrink-0 flex items-center justify-center text-
|
|
299
|
-
|
|
326
|
+
<span className="w-5 shrink-0 flex items-center justify-center text-oq-48">
|
|
327
|
+
{/* a folder's mark is the only glyph a row carries now, so it is drawn at the box's
|
|
328
|
+
size rather than floating in it (user 2026-09-23) */}
|
|
329
|
+
<Icon name={icon} size={icon === 'folder' ? 18 : 14} />
|
|
300
330
|
</span>
|
|
301
331
|
)}
|
|
302
332
|
|
|
@@ -354,7 +384,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
|
|
|
354
384
|
</div>
|
|
355
385
|
) : (
|
|
356
386
|
<div
|
|
357
|
-
className="kol-column-browser-media w-full min-h-[160px] max-h-[60vh] overflow-auto rounded bg-
|
|
387
|
+
className="kol-column-browser-media w-full min-h-[160px] max-h-[60vh] overflow-auto rounded bg-oq-04 flex items-center justify-center"
|
|
358
388
|
onLoadCapture={(e) => { if (e.target?.tagName === 'IMG') setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight }) }}
|
|
359
389
|
onLoadedMetadataCapture={(e) => {
|
|
360
390
|
const t = e.target
|
|
@@ -441,6 +471,29 @@ export default function ColumnBrowser({
|
|
|
441
471
|
columnWidth = 260,
|
|
442
472
|
columnWidths,
|
|
443
473
|
onColumnResize,
|
|
474
|
+
/* RIGHT-CLICK AND DROP, handed down (2026-09-21). `onRowContextMenu(event, { type, path, o })`
|
|
475
|
+
* and `dragFor(path)` are the page's; this component owns the geometry of a row, never what a
|
|
476
|
+
* row can have done to it. Absent, the columns behave exactly as they did. */
|
|
477
|
+
onRowContextMenu,
|
|
478
|
+
dragFor,
|
|
479
|
+
/* `onDropFiles(files, folderPath)` — files dragged in FROM THE OS onto a folder row or a column
|
|
480
|
+
* (kol-client-olina 2026-09-22). The browser hands over the `FileList` and the target path and
|
|
481
|
+
* never uploads: each consumer's pipeline differs, so the upload is theirs, and they re-list.
|
|
482
|
+
* Absent, an OS drag shows nothing and does nothing. */
|
|
483
|
+
onDropFiles,
|
|
484
|
+
/* MULTI-SELECT BY DRAG (user 2026-09-22). `selectedKeys` is the set the caller owns — paths for
|
|
485
|
+
* folders, keys for files, exactly as this component hands them out — and `onSelectKeys(keys,
|
|
486
|
+
* additive)` reports what a band touched. Without the pair, a drag on the background does
|
|
487
|
+
* nothing, as before. */
|
|
488
|
+
selectedKeys,
|
|
489
|
+
onSelectKeys,
|
|
490
|
+
/* `onSelectClick(key, event, columnKeys)` — every row click, modifiers included, handed to the
|
|
491
|
+
* caller's selection model first (user 2026-09-23: ⇧-click *"should add to selection"*). Return
|
|
492
|
+
* true when the click was a selection gesture and the browser must not navigate. */
|
|
493
|
+
onSelectClick,
|
|
494
|
+
/* `folderIcon(path)` — the glyph a folder row wears (default `folder`). The media pages mark a
|
|
495
|
+
* bucket — a SOURCE, not a prefix — with `database` (user 2026-09-23). */
|
|
496
|
+
folderIcon,
|
|
444
497
|
/* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
|
|
445
498
|
* kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
|
|
446
499
|
* consumer, so neither is computed here:
|
|
@@ -479,7 +532,9 @@ export default function ColumnBrowser({
|
|
|
479
532
|
onColumnResize?.(i, w)
|
|
480
533
|
}
|
|
481
534
|
const resizeH = (dy) => {
|
|
482
|
-
|
|
535
|
+
/* a string height (`'100%'`, `calc()`) has no arithmetic — the drag starts from what it
|
|
536
|
+
* RENDERED at, so there is no jump, and reports px from then on */
|
|
537
|
+
if (dragBase.current == null) dragBase.current = typeof h === 'number' ? h : (rootRef.current?.getBoundingClientRect().height ?? MIN_H)
|
|
483
538
|
const next = Math.max(MIN_H, Math.round(dragBase.current + dy))
|
|
484
539
|
if (height == null) setOwnH(next)
|
|
485
540
|
onHeightChange?.(next)
|
|
@@ -555,13 +610,28 @@ export default function ColumnBrowser({
|
|
|
555
610
|
* on `prefix`, so a bucket switch from the header (which takes focus) hands the keyboard back. */
|
|
556
611
|
useEffect(() => { if (autoFocus) rootRef.current?.focus() }, [autoFocus, prefix])
|
|
557
612
|
|
|
613
|
+
/* THE KEYBOARD SELECTS TOO (user 2026-09-23: *"the highlight focus is on the correct item, this
|
|
614
|
+
* has been a problem for awhile"*). Clicks reported through `onSelectClick`; the arrows moved
|
|
615
|
+
* the cursor and the pick and told the caller nothing, so its selection kept the last CLICKED
|
|
616
|
+
* row lit beside the one the arrows had reached. Every keyboard move now replaces it. */
|
|
617
|
+
const selectOnly = (key) => onSelectKeys?.(key ? [key] : [], false)
|
|
558
618
|
const land = (level, item) => {
|
|
559
619
|
if (!item) return
|
|
560
|
-
if (item.type === 'folder') { pick(null); onPrefix(level + item.name) }
|
|
561
|
-
else pickFile(level, item.o)
|
|
620
|
+
if (item.type === 'folder') { pick(null); selectOnly(level + item.name); onPrefix(level + item.name) }
|
|
621
|
+
else { selectOnly(item.o.key); pickFile(level, item.o) }
|
|
562
622
|
}
|
|
563
623
|
|
|
624
|
+
const shiftAnchor = useRef(null)
|
|
564
625
|
const onKeyDown = (e) => {
|
|
626
|
+
/* ⌘↑ / ⌘↓ (user 2026-09-23: *"all modes should 'command up down' to move up and down parent
|
|
627
|
+
* child"*) — Finder's: ⌘↑ is ←, back to the enclosing folder; ⌘↓ opens what the cursor is on,
|
|
628
|
+
* → for a folder and Quick Look for a file. */
|
|
629
|
+
if (e.metaKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
630
|
+
e.preventDefault()
|
|
631
|
+
if (e.key === 'ArrowUp') return onKeyDown({ key: 'ArrowLeft', preventDefault() {} })
|
|
632
|
+
if (picked) return onKeyDown({ key: ' ', preventDefault() {} })
|
|
633
|
+
return onKeyDown({ key: 'ArrowRight', preventDefault() {} })
|
|
634
|
+
}
|
|
565
635
|
// Space = Quick Look over the picked column's files, starting at the picked one.
|
|
566
636
|
if (e.key === ' ' && picked) {
|
|
567
637
|
e.preventDefault()
|
|
@@ -577,6 +647,20 @@ export default function ColumnBrowser({
|
|
|
577
647
|
const items = itemsAt(lv[col])
|
|
578
648
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
579
649
|
const idx = Math.max(0, Math.min(items.length - 1, cursor.idx + (e.key === 'ArrowDown' ? 1 : -1)))
|
|
650
|
+
/* ⇧ + ↑↓ EXTENDS within the column (user 2026-09-23): the run from where it began to the
|
|
651
|
+
* cursor becomes the selection; nothing opens, nothing is previewed but the stack */
|
|
652
|
+
if (e.shiftKey && onSelectKeys) {
|
|
653
|
+
/* a new run starts wherever the cursor is unless it is still where the last ⇧ step left it */
|
|
654
|
+
const sa = shiftAnchor.current
|
|
655
|
+
if (!sa || sa.col !== col || sa.last !== cursor.idx) shiftAnchor.current = { col, idx: cursor.idx }
|
|
656
|
+
shiftAnchor.current.last = idx
|
|
657
|
+
const a = shiftAnchor.current.idx
|
|
658
|
+
const keyOf = (it) => (it.type === 'folder' ? lv[col] + it.name : it.o.key)
|
|
659
|
+
setCursor({ col, idx })
|
|
660
|
+
onSelectKeys(items.slice(Math.min(a, idx), Math.max(a, idx) + 1).map(keyOf), false)
|
|
661
|
+
return
|
|
662
|
+
}
|
|
663
|
+
shiftAnchor.current = null
|
|
580
664
|
/* an arrow that did not move the cursor does nothing — re-landing on an
|
|
581
665
|
* open folder re-fired onPrefix (ColumnBrowserCursorSeed) */
|
|
582
666
|
if (idx === cursor.idx && col === cursor.col) return
|
|
@@ -596,6 +680,7 @@ export default function ColumnBrowser({
|
|
|
596
680
|
const idx = Math.max(0, parentItems.findIndex((it) => it.type === 'folder' && it.name === opened))
|
|
597
681
|
setCursor({ col: col - 1, idx })
|
|
598
682
|
pick(null)
|
|
683
|
+
selectOnly(lv[col])
|
|
599
684
|
onPrefix(lv[col])
|
|
600
685
|
}
|
|
601
686
|
}
|
|
@@ -618,6 +703,7 @@ export default function ColumnBrowser({
|
|
|
618
703
|
if (!items[idx]) return
|
|
619
704
|
setCursor({ col, idx })
|
|
620
705
|
pick(items[idx].o)
|
|
706
|
+
selectOnly(items[idx].o.key)
|
|
621
707
|
const siblings = items.filter((it) => it.type === 'file').map((it) => it.o)
|
|
622
708
|
onQuickLook?.({ files: siblings, index: siblings.findIndex((o) => o.key === items[idx].o.key) })
|
|
623
709
|
}
|
|
@@ -655,6 +741,20 @@ export default function ColumnBrowser({
|
|
|
655
741
|
* `md` (768) — the one `ContentFilters` already uses, so a page has one
|
|
656
742
|
* responsive story and not two. */
|
|
657
743
|
const stack = useMediaQuery('(max-width: 767px)')
|
|
744
|
+
/* one band for the whole browser rather than one per column: a drag that starts in a column and
|
|
745
|
+
* runs into the next is a drag the user meant */
|
|
746
|
+
const marquee = useMarquee({
|
|
747
|
+
enabled: !!onSelectKeys,
|
|
748
|
+
onSelect: (keys, additive) => onSelectKeys?.(keys, additive),
|
|
749
|
+
})
|
|
750
|
+
const isPicked = (key) => selectedKeys?.has(key)
|
|
751
|
+
/* THE DEEPEST COLUMN STAYS IN VIEW (user 2026-09-23: arrowing onto a file left the preview
|
|
752
|
+
* clipped under the frame's right edge). Whenever the trail or the pick changes, the strip scrolls
|
|
753
|
+
* to its end — the newest column, or the preview, is always the one you just reached. */
|
|
754
|
+
useEffect(() => {
|
|
755
|
+
const box = marquee.ref.current
|
|
756
|
+
if (box && box.scrollWidth > box.clientWidth) box.scrollTo({ left: box.scrollWidth, behavior: 'smooth' })
|
|
757
|
+
}, [prefix, picked?.key, selectedKeys?.size]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
658
758
|
|
|
659
759
|
if (stack) {
|
|
660
760
|
/* THE CURRENT LEVEL IS THE LIST (StackModeChromeAndAncestors, kol-r2b2
|
|
@@ -692,7 +792,7 @@ export default function ColumnBrowser({
|
|
|
692
792
|
className="kol-column-browser-back flex items-center gap-2 px-4 py-3 border-b text-fg-default"
|
|
693
793
|
style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
694
794
|
>
|
|
695
|
-
<Icon name="chevron-left" size={14} className="text-
|
|
795
|
+
<Icon name="chevron-left" size={14} className="text-oq-48" />
|
|
696
796
|
{/* NAMES THE PARENT (item 2) — the folder it returns to, not '‹' */}
|
|
697
797
|
<span className="kol-mono-12 truncate">
|
|
698
798
|
{parent === '' ? 'All files' : parent.replace(/\/$/, '').split('/').pop()}
|
|
@@ -721,8 +821,8 @@ export default function ColumnBrowser({
|
|
|
721
821
|
>
|
|
722
822
|
<span className="kol-column-browser-tile-box">
|
|
723
823
|
{isFolder
|
|
724
|
-
? <Icon name="folder" size={28} className="text-
|
|
725
|
-
: (thumbnailFor?.(o) ?? <Icon name={COL_ICON[kindOf(o)] || 'file'} size={28} className="text-
|
|
824
|
+
? <Icon name="folder" size={28} className="text-oq-48" />
|
|
825
|
+
: (thumbnailFor?.(o) ?? <Icon name={COL_ICON[kindOf(o)] || 'file'} size={28} className="text-oq-48" />)}
|
|
726
826
|
</span>
|
|
727
827
|
<span className="kol-mono-12 truncate">{isFolder ? r.name.replace(/\/$/, '') : (o.displayKey ?? o.key)}</span>
|
|
728
828
|
{/* counts for a folder, size for a file — never dates (§2) */}
|
|
@@ -734,15 +834,18 @@ export default function ColumnBrowser({
|
|
|
734
834
|
})}
|
|
735
835
|
</div>
|
|
736
836
|
) : (
|
|
737
|
-
<ul className="kol-column-browser-column flex-1 overflow-y-auto"
|
|
837
|
+
<ul className="kol-column-browser-column flex-1 overflow-y-auto"
|
|
838
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: prefix })}>
|
|
738
839
|
{rows.map((r) =>
|
|
840
|
+
/* Same ruling as the desktop column: nothing to say, so it says nothing. This is the
|
|
841
|
+
* mobile stack's row for an empty folder. */
|
|
739
842
|
r.kind === 'empty' ? (
|
|
740
|
-
<li key={r.key} className="
|
|
843
|
+
<li key={r.key} className="min-h-12" />
|
|
741
844
|
) : r.kind === 'folder' ? (
|
|
742
845
|
<Row
|
|
743
846
|
key={r.key}
|
|
744
847
|
zones
|
|
745
|
-
icon=
|
|
848
|
+
icon={folderIcon?.(r.level + r.name) ?? 'folder'}
|
|
746
849
|
label={r.name.replace(/\/$/, '')}
|
|
747
850
|
indent={r.depth}
|
|
748
851
|
active={r.open}
|
|
@@ -795,27 +898,69 @@ export default function ColumnBrowser({
|
|
|
795
898
|
className={`kol-column-browser relative border rounded outline-none ${className}`.trim()}
|
|
796
899
|
style={{ borderColor: 'var(--kol-oq-08)', height: h }}
|
|
797
900
|
>
|
|
798
|
-
|
|
901
|
+
{/* A CLICK ON THE BACKGROUND DESELECTS (user 2026-09-22) — the band's own closing click is
|
|
902
|
+
swallowed by the hook, so this only ever fires on a real click into empty space. */}
|
|
903
|
+
<div ref={marquee.ref} {...marquee.props} className="relative flex h-full overflow-x-auto"
|
|
904
|
+
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { pick(null); onSelectKeys?.([], false) } }}>
|
|
905
|
+
{marquee.rect && <div className="kol-marquee" style={marquee.rect} />}
|
|
799
906
|
{levels.map((level, k) => {
|
|
800
907
|
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
801
908
|
const next = levels[k + 1]
|
|
802
909
|
const activeFolder = next ? next.slice(level.length) : null
|
|
803
910
|
const last = k === levels.length - 1 && !shown
|
|
911
|
+
const colKeys = [...folders.map((f) => level + f), ...files.map((o) => o.key)]
|
|
804
912
|
return (
|
|
805
913
|
<Fragment key={level || '/'}>
|
|
806
914
|
<ul
|
|
807
915
|
className="kol-column-browser-column shrink-0 overflow-y-auto border-r"
|
|
808
916
|
style={{ borderColor: 'var(--kol-oq-08)', width: widthOf(k) }}
|
|
917
|
+
/* THE WHOLE COLUMN IS A TARGET, not just its rows and not just an empty column.
|
|
918
|
+
*
|
|
919
|
+
* RIGHT-CLICK: `openAt` stops propagation, so a row that was hit answers first and
|
|
920
|
+
* this only fires on the blank area below the last row — where you reach for
|
|
921
|
+
* "new folder" in every file manager.
|
|
922
|
+
*
|
|
923
|
+
* DROP: a column IS the folder it is showing, so dropping anywhere in it files the
|
|
924
|
+
* item there. You could drop ON a folder row but not INTO the folder you were looking
|
|
925
|
+
* at, which is the one target that was missing. The highlight goes straight to a data
|
|
926
|
+
* attribute rather than through state — it is transient, it belongs to one node, and a
|
|
927
|
+
* re-render per dragover across every column buys nothing. */
|
|
928
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })}
|
|
929
|
+
onDragOver={(e) => {
|
|
930
|
+
const files = onDropFiles && isFileDrag(e)
|
|
931
|
+
/* `d.path`, never `level`: the caller may re-root the path it was handed (the media
|
|
932
|
+
* pages prefix every level with `<title>/<bucket>/`), and moving to the raw level
|
|
933
|
+
* renamed files under a prefix nothing lists — they vanished (user 2026-09-23). */
|
|
934
|
+
const d = !isFileDrag(e) && dragFor?.(level)
|
|
935
|
+
if (!files && !d?.canDrop(d.path)) return
|
|
936
|
+
e.preventDefault()
|
|
937
|
+
e.currentTarget.dataset.dropOver = '1'
|
|
938
|
+
}}
|
|
939
|
+
/* cleared however the drag ends: a drop on a row stops propagation, so capture runs
|
|
940
|
+
* first; and leaving through a child fires on the child, not on the column */
|
|
941
|
+
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) delete e.currentTarget.dataset.dropOver }}
|
|
942
|
+
onDropCapture={(e) => { delete e.currentTarget.dataset.dropOver }}
|
|
943
|
+
onDrop={(e) => {
|
|
944
|
+
if (onDropFiles && isFileDrag(e)) { e.preventDefault(); onDropFiles(e.dataTransfer.files, level); return }
|
|
945
|
+
const d = !isFileDrag(e) && dragFor?.(level)
|
|
946
|
+
if (!d?.canDrop(d.path)) return
|
|
947
|
+
e.preventDefault()
|
|
948
|
+
d.onDrop(d.path)
|
|
949
|
+
}}
|
|
809
950
|
>
|
|
810
951
|
{folders.map((f, i) => (
|
|
811
952
|
<Row
|
|
812
953
|
key={f}
|
|
813
|
-
icon=
|
|
954
|
+
icon={folderIcon?.(level + f) ?? 'folder'}
|
|
814
955
|
label={f.replace(/\/$/, '')}
|
|
815
|
-
|
|
956
|
+
markKey={level + f}
|
|
957
|
+
active={f === activeFolder || isPicked(level + f)}
|
|
816
958
|
cursor={cursorActive && cursor.col === k && cursor.idx === i}
|
|
817
|
-
trailing={<Icon name="chevron-right" size={12} className="text-
|
|
818
|
-
|
|
959
|
+
trailing={<Icon name="chevron-right" size={12} className="text-oq-32" />}
|
|
960
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path: level + f })}
|
|
961
|
+
drop={dragFor?.(level + f)}
|
|
962
|
+
dropFiles={onDropFiles ? (list) => onDropFiles(list, level + f) : undefined}
|
|
963
|
+
onClick={(e) => { if (onSelectClick?.(level + f, e, colKeys)) return; setCursor({ col: k, idx: i }); setCursorActive(true); rootRef.current?.focus(); pick(null); onPrefix(level + f) }}
|
|
819
964
|
/>
|
|
820
965
|
))}
|
|
821
966
|
{files.map((o, i) => (
|
|
@@ -823,27 +968,91 @@ export default function ColumnBrowser({
|
|
|
823
968
|
key={o.key}
|
|
824
969
|
icon={COL_ICON[kindOf(o)] || 'file'}
|
|
825
970
|
label={o.displayKey ?? o.key}
|
|
826
|
-
|
|
971
|
+
markKey={o.key}
|
|
972
|
+
active={shown?.key === o.key || isPicked(o.key)}
|
|
827
973
|
cursor={cursorActive && cursor.col === k && cursor.idx === folders.length + i}
|
|
828
|
-
|
|
974
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
|
|
975
|
+
drop={dragFor?.(o.key)}
|
|
976
|
+
onClick={(e) => { if (onSelectClick?.(o.key, e, colKeys)) return; setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
|
|
829
977
|
/>
|
|
830
978
|
))}
|
|
979
|
+
{/* AN EMPTY COLUMN IS VISIBLY EMPTY (user 2026-09-21). It used to print the word
|
|
980
|
+
* "empty", which tells you what you can already see. The row stays as a right-click
|
|
981
|
+
* target — that is the only thing it was ever load-bearing for — and says nothing. */}
|
|
831
982
|
{folders.length === 0 && files.length === 0 && (
|
|
832
|
-
<li className="
|
|
983
|
+
<li className="flex-1 min-h-12"
|
|
984
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })} />
|
|
833
985
|
)}
|
|
834
986
|
</ul>
|
|
835
987
|
<ResizeHandle axis="x" onDrag={resizeCol(k)} onEnd={endDrag} />
|
|
836
988
|
</Fragment>
|
|
837
989
|
)
|
|
838
990
|
})}
|
|
839
|
-
{
|
|
991
|
+
{selectedKeys?.size > 1 ? (
|
|
992
|
+
<>
|
|
993
|
+
<SelectionPreview keys={[...selectedKeys]} objects={objects} urlOf={urlOf} kindOf={kindOf} formatSize={formatSize} formatDate={formatDate} width={widthOf('preview')} />
|
|
994
|
+
<ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
|
|
995
|
+
</>
|
|
996
|
+
) : shown && (
|
|
840
997
|
<>
|
|
841
998
|
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
|
|
842
999
|
<ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
|
|
843
1000
|
</>
|
|
844
1001
|
)}
|
|
845
1002
|
</div>
|
|
846
|
-
|
|
1003
|
+
{/* A FILL HEIGHT HAS NO GRABBER (user 2026-09-22). A CSS length says "take the room you
|
|
1004
|
+
* are given"; dragging would fight it. A px height keeps its handle. */}
|
|
1005
|
+
{typeof h !== 'string' && <ResizeHandle axis="y" onDrag={resizeH} onEnd={endDrag} />}
|
|
1006
|
+
</div>
|
|
1007
|
+
)
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/* The preview column, for the media page's ROW view (user 2026-09-22 — Finder's "Show preview"):
|
|
1011
|
+
* one preview, the same pane beside rows as beside columns. Not in the barrel. */
|
|
1012
|
+
export { Preview, SelectionPreview }
|
|
1013
|
+
|
|
1014
|
+
/* SelectionPreview — the preview pane for MORE THAN ONE selected item (user 2026-09-23, Finder's:
|
|
1015
|
+
* *"preview of 3 overlapping files, 2 and 3 slightly rotated behind 1 … the info fields should say
|
|
1016
|
+
* how many files and calculated size"*). The pane kept describing one file while four were lit.
|
|
1017
|
+
* The stack says "several"; the facts do the counting. Shared by the column and row panes.
|
|
1018
|
+
*
|
|
1019
|
+
* `keys` — the selected keys (folders end in `/`); `objects` — every object the view knows, for
|
|
1020
|
+
* sizes, kinds and dates; `urlOf(o)` — an image's own picture. */
|
|
1021
|
+
function SelectionPreview({ keys, objects, urlOf, kindOf = dsKindOf, formatSize = (n) => `${n} B`, formatDate = (d) => d, width }) {
|
|
1022
|
+
const byKey = new Map(objects.map((o) => [o.key, o]))
|
|
1023
|
+
const folders = keys.filter((k) => k.endsWith('/'))
|
|
1024
|
+
const files = keys.filter((k) => !k.endsWith('/')).map((k) => byKey.get(k)).filter(Boolean)
|
|
1025
|
+
const inFolders = objects.filter((o) => folders.some((f) => o.key.startsWith(f)))
|
|
1026
|
+
const all = [...files, ...inFolders]
|
|
1027
|
+
const bytes = all.reduce((n, o) => n + (o.size || 0), 0)
|
|
1028
|
+
const dates = all.map((o) => o.uploaded).filter(Boolean).sort()
|
|
1029
|
+
const range = dates.length ? (dates[0] === dates[dates.length - 1] ? formatDate(dates[0]) : `${formatDate(dates[0])} – ${formatDate(dates[dates.length - 1])}`) : '—'
|
|
1030
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
|
1031
|
+
const kinds = [files.length && plural(files.length, 'document'), folders.length && plural(folders.length, 'folder')].filter(Boolean).join(', ')
|
|
1032
|
+
const face = (k) => {
|
|
1033
|
+
if (k.endsWith('/')) return <FileIcon glyph="folder" className="w-[46%]" />
|
|
1034
|
+
const o = byKey.get(k)
|
|
1035
|
+
if (o && isImage(o) && urlOf) return <img src={urlOf(o)} alt="" className="w-full h-full object-cover" />
|
|
1036
|
+
const ext = k.split('.').pop()
|
|
1037
|
+
return <FileIcon ext={ext} glyph={o ? KIND_GLYPH[kindOf(o)] : undefined} className="w-[46%]" />
|
|
1038
|
+
}
|
|
1039
|
+
const stack = keys.slice(0, 3)
|
|
1040
|
+
return (
|
|
1041
|
+
<div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
|
|
1042
|
+
<div className="kol-selection-stack">
|
|
1043
|
+
{stack.map((k, i) => (
|
|
1044
|
+
<div key={k} className="kol-selection-stack-card" data-depth={i}>{face(k)}</div>
|
|
1045
|
+
))}
|
|
1046
|
+
</div>
|
|
1047
|
+
<p className="kol-mono-12 text-fg-default">{plural(keys.length, 'item')}</p>
|
|
1048
|
+
<dl className="flex flex-col gap-1">
|
|
1049
|
+
{[['Kind', kinds], ['Size', formatSize(bytes)], ['Date', range]].map(([k, v]) => (
|
|
1050
|
+
<div key={k} className="flex justify-between gap-4 kol-mono-12">
|
|
1051
|
+
<dt className="text-fg-48">{k}</dt>
|
|
1052
|
+
<dd className="text-fg-default text-right break-all">{v}</dd>
|
|
1053
|
+
</div>
|
|
1054
|
+
))}
|
|
1055
|
+
</dl>
|
|
847
1056
|
</div>
|
|
848
1057
|
)
|
|
849
1058
|
}
|
|
@@ -6,6 +6,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
6
6
|
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
7
7
|
import SearchInput from '../molecules/SearchInput.jsx'
|
|
8
8
|
import IconFrame from '../atoms/IconFrame.jsx'
|
|
9
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* ContentFilters — universal filter component for content grids.
|
|
@@ -76,6 +77,13 @@ const ContentFilters = ({
|
|
|
76
77
|
totalCount,
|
|
77
78
|
titleIcon,
|
|
78
79
|
filterGroups = [],
|
|
80
|
+
/* THE PANEL, CONTROLLABLE (media merge, 2026-09-22). The bar's own funnel is the only way to
|
|
81
|
+
* reach the chips, so a consumer that already spends a control on "show me the filters" makes
|
|
82
|
+
* the user press two funnels to see one panel. Passing `filtersOpen` hands that state over —
|
|
83
|
+
* the funnel still renders and still fires, it just reports through `onFiltersOpenChange`
|
|
84
|
+
* instead of toggling in private. Absent, this is exactly what it was. */
|
|
85
|
+
filtersOpen,
|
|
86
|
+
onFiltersOpenChange,
|
|
79
87
|
initialFilters,
|
|
80
88
|
renderItem,
|
|
81
89
|
viewModeOptions,
|
|
@@ -158,7 +166,12 @@ const ContentFilters = ({
|
|
|
158
166
|
* sources of truth. Entries are `"<groupKey>:<value>"`, the same strings the
|
|
159
167
|
* chips toggle. */
|
|
160
168
|
const [activeFilters, setActiveFilters] = useState(() => new Set(initialFilters))
|
|
161
|
-
const [
|
|
169
|
+
const [internalExpanded, setInternalExpanded] = useState(false)
|
|
170
|
+
const isExpanded = filtersOpen !== undefined ? filtersOpen : internalExpanded
|
|
171
|
+
const setIsExpanded = (next) => {
|
|
172
|
+
setInternalExpanded(next)
|
|
173
|
+
onFiltersOpenChange?.(next)
|
|
174
|
+
}
|
|
162
175
|
const [internalViewMode, setInternalViewMode] = useState(defaultViewMode ?? viewModeOptions?.[0]?.value)
|
|
163
176
|
const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
|
|
164
177
|
/* Controlled when `layout` is passed, internal otherwise — the same pair viewMode uses. */
|
|
@@ -256,18 +269,21 @@ const ContentFilters = ({
|
|
|
256
269
|
* short group ate a full row it did not need. */
|
|
257
270
|
const layoutStrip = layoutOptions ? (
|
|
258
271
|
<div className="flex items-center gap-4">
|
|
259
|
-
{layoutOptions.map((opt) =>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
272
|
+
{layoutOptions.map((opt) => {
|
|
273
|
+
const chip = (
|
|
274
|
+
<span
|
|
275
|
+
key={opt.value}
|
|
276
|
+
onClick={opt.onClick ?? (() => setLayout(opt.value))}
|
|
277
|
+
aria-pressed={opt.active !== undefined ? !!opt.active : undefined}
|
|
278
|
+
className={`${layoutClassName} cursor-pointer select-none ${(opt.active ?? layout === opt.value) ? stripActiveClassName : stripRestClassName}`}
|
|
279
|
+
style={{ letterSpacing: 1 }}
|
|
280
|
+
>
|
|
281
|
+
{opt.label}
|
|
282
|
+
</span>
|
|
283
|
+
)
|
|
284
|
+
/* `opt.title` is a hint — the DS Tooltip, not a native `title` (kol-client-olina 2026-09-22) */
|
|
285
|
+
return opt.title ? <Tooltip key={opt.value} label={opt.title}>{chip}</Tooltip> : chip
|
|
286
|
+
})}
|
|
271
287
|
</div>
|
|
272
288
|
) : null
|
|
273
289
|
|
|
@@ -8,11 +8,13 @@ import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
|
|
|
8
8
|
import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
|
|
9
9
|
import ViewToggle from '../atoms/ViewToggle.jsx'
|
|
10
10
|
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
11
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
11
12
|
import ContentCard from '../molecules/ContentCard.jsx'
|
|
12
13
|
import ContentRow from '../molecules/ContentRow.jsx'
|
|
13
14
|
import ContentFilters from './ContentFilters.jsx'
|
|
14
15
|
import MediaViewer from './MediaViewer.jsx'
|
|
15
16
|
import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
|
|
17
|
+
import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
|
|
16
18
|
import { SettingsChipRow, chipCls } from './SettingsPanel.jsx'
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -496,11 +498,11 @@ function Thumb({ row, mediaUrl }) {
|
|
|
496
498
|
function FolderRow({ folder, struck = false, onEnter }) {
|
|
497
499
|
return (
|
|
498
500
|
<li className="kol-media-folder" onClick={onEnter}>
|
|
499
|
-
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-
|
|
501
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-oq-48">
|
|
500
502
|
<Icon name="folder" size={18} />
|
|
501
503
|
</span>
|
|
502
504
|
<span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-meta' : 'text-body'}`}>{folder.label}</span>
|
|
503
|
-
<Icon name="chevron-right" size={14} className="text-
|
|
505
|
+
<Icon name="chevron-right" size={14} className="text-oq-24" />
|
|
504
506
|
</li>
|
|
505
507
|
)
|
|
506
508
|
}
|
|
@@ -629,15 +631,17 @@ function Toolbar({ viewMode, onViewMode }) {
|
|
|
629
631
|
)}
|
|
630
632
|
</div>
|
|
631
633
|
<div className="flex items-center gap-3">
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
634
|
+
{/* The DS's own Tooltip, not a `title` attribute — see LibraryHeader. */}
|
|
635
|
+
<Tooltip label="Show all files recursively">
|
|
636
|
+
<button
|
|
637
|
+
type="button"
|
|
638
|
+
aria-pressed={flat}
|
|
639
|
+
onClick={() => setFlat(!flat)}
|
|
640
|
+
className={chipCls(flat)}
|
|
641
|
+
>
|
|
642
|
+
Flat
|
|
643
|
+
</button>
|
|
644
|
+
</Tooltip>
|
|
641
645
|
<ViewToggle viewMode={viewMode} onViewChange={onViewMode} variant="icon" />
|
|
642
646
|
<Divider variant="vertical" />
|
|
643
647
|
<SortControls />
|
|
@@ -970,7 +974,10 @@ function PickerShell({ onClose, onPick }) {
|
|
|
970
974
|
* `defaults` to seed) · `folderTree` (browse: the baked tree) · `headerActions`
|
|
971
975
|
* (the app's own upload / write icons) · `refreshKey`.
|
|
972
976
|
*
|
|
973
|
-
* @param {string} variant 'browse' | 'library' | 'modal' | 'page' (alias of library)
|
|
977
|
+
* @param {string} variant 'explorer' | 'browse' | 'library' | 'modal' | 'page' (alias of library)
|
|
978
|
+
* `explorer` is browse + library as two views of ONE surface — one header, one
|
|
979
|
+
* count, one listing, a switch beside the bucket dropdown. Prefer it for a new
|
|
980
|
+
* consumer; the other two remain for the ones that stack them by hand.
|
|
974
981
|
* @param {boolean} open modal only — mounts the overlay
|
|
975
982
|
* @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
|
|
976
983
|
* @param {string|string[]} accept 'all' (default) = everything · one kind · an
|
|
@@ -999,6 +1006,12 @@ export default function MediaLibrary({
|
|
|
999
1006
|
return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, opts)
|
|
1000
1007
|
}
|
|
1001
1008
|
if (variant === 'browse') return <MediaLibraryBrowse client={client} {...pageProps} />
|
|
1009
|
+
/* `explorer` = the two above as TWO VIEWS OF ONE SURFACE, one mounted at a
|
|
1010
|
+
* time behind a switch in the header — one wordmark, one count, one listing,
|
|
1011
|
+
* and the upload target reachable without scrolling past a full-height
|
|
1012
|
+
* browser. Added 2026-09-21; `browse` and `library` are unchanged, so no
|
|
1013
|
+
* consumer moves until it chooses to. */
|
|
1014
|
+
if (variant === 'explorer') return <MediaLibraryExplorer client={client} {...pageProps} />
|
|
1002
1015
|
/* `page` = the library wall (alias, one release): its old knobs map onto the settings seed */
|
|
1003
1016
|
const seed = variant === 'page' ? { defaults: { pageSize, sortBy: defaultSort?.by, sortDir: defaultSort?.dir, flat, ...(pageProps.defaults ?? {}) } } : {}
|
|
1004
1017
|
return <MediaLibraryLibrary client={client} {...pageProps} {...seed} />
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { MediaLibraryBrowse } from './MediaLibraryPages.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MediaLibraryExplorer — the media surface, and since 2026-09-22 a thin name for it.
|
|
6
|
+
*
|
|
7
|
+
* IT USED TO BE THE MERGE ITSELF: it held a BROWSE · FILES switch and mounted one of two pages,
|
|
8
|
+
* each of which listed the bucket, drew its own header and printed its own count. The user's read
|
|
9
|
+
* of that (2026-09-22) ended it — *"they both just display files … 4 views, not 2 modes each with
|
|
10
|
+
* 2 views"* — so the surface is ONE page now with four views (columns · rows · grid · list), one
|
|
11
|
+
* listing, one header, one count line and one filter bar, and this component only carries the
|
|
12
|
+
* view state for a consumer that wants to own it.
|
|
13
|
+
*
|
|
14
|
+
* `MediaLibraryBrowse` and `MediaLibraryLibrary` remain exported: the first IS the surface, the
|
|
15
|
+
* second is the standalone wall a site embeds without a tree (kol-website's asset page).
|
|
16
|
+
*
|
|
17
|
+
* @param {'columns'|'rows'|'grid'|'list'} view controlled view; omit and the page reads its own setting
|
|
18
|
+
* @param {Function} onViewChange (view) => void
|
|
19
|
+
* @param {'columns'|'rows'|'grid'|'list'} defaultView first view when uncontrolled and nothing is stored
|
|
20
|
+
*/
|
|
21
|
+
export default function MediaLibraryExplorer({ view, onViewChange, defaultView, ...pageProps }) {
|
|
22
|
+
const [ownView, setOwnView] = useState(view ?? defaultView)
|
|
23
|
+
const current = view ?? ownView
|
|
24
|
+
return (
|
|
25
|
+
<MediaLibraryBrowse
|
|
26
|
+
{...pageProps}
|
|
27
|
+
view={current}
|
|
28
|
+
onViewChange={(v) => { setOwnView(v); onViewChange?.(v) }}
|
|
29
|
+
/>
|
|
30
|
+
)
|
|
31
|
+
}
|