@kolkrabbi/kol-component 0.216.0 → 0.218.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- 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 +3 -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 +191 -37
- package/src/organisms/ContentFilters.jsx +29 -13
- package/src/organisms/MediaLibrary.jsx +2 -2
- package/src/organisms/MediaLibraryExplorer.jsx +22 -107
- package/src/organisms/MediaLibraryPages.jsx +1389 -348
- package/src/organisms/SettingsPanel.jsx +16 -6
- 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,22 +247,29 @@ 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, onContextMenu, drop,
|
|
250
|
+
indent = 0, meta, zones = false, thumb, onDisclose, disclosed, onContextMenu, drop, dropFiles, markKey,
|
|
243
251
|
}) {
|
|
244
252
|
const [over, setOver] = useState(false)
|
|
245
253
|
/* A COLUMN ROW IS THE SAME ROW as the list view's, so it takes the same two seams: right-click
|
|
246
|
-
* and, for a folder, a drop target. Without them every handler is undefined and nothing moves.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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) } },
|
|
251
261
|
onDragLeave: () => setOver(false),
|
|
252
|
-
onDrop: (e) => {
|
|
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
|
+
},
|
|
253
267
|
} : {}
|
|
254
268
|
return (
|
|
255
269
|
<li
|
|
256
270
|
onContextMenu={onContextMenu}
|
|
257
271
|
{...dropProps}
|
|
272
|
+
data-marquee-key={markKey}
|
|
258
273
|
data-drop-over={over || undefined}
|
|
259
274
|
/* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
|
|
260
275
|
* 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last.
|
|
@@ -305,11 +320,13 @@ function Row({
|
|
|
305
320
|
className="kol-column-browser-thumb shrink-0 inline-flex items-center justify-center overflow-hidden"
|
|
306
321
|
style={{ width: ZONE_BOX, height: ZONE_BOX, borderRadius: 5, background: thumb ? 'var(--kol-oq-04)' : 'transparent' }}
|
|
307
322
|
>
|
|
308
|
-
{thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-
|
|
323
|
+
{thumb ?? <Icon name={icon} size={ZONE_BOX} className="text-oq-48" />}
|
|
309
324
|
</span>
|
|
310
325
|
) : (
|
|
311
|
-
<span className="w-5 shrink-0 flex items-center justify-center text-
|
|
312
|
-
|
|
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} />
|
|
313
330
|
</span>
|
|
314
331
|
)}
|
|
315
332
|
|
|
@@ -367,7 +384,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
|
|
|
367
384
|
</div>
|
|
368
385
|
) : (
|
|
369
386
|
<div
|
|
370
|
-
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"
|
|
371
388
|
onLoadCapture={(e) => { if (e.target?.tagName === 'IMG') setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight }) }}
|
|
372
389
|
onLoadedMetadataCapture={(e) => {
|
|
373
390
|
const t = e.target
|
|
@@ -459,6 +476,24 @@ export default function ColumnBrowser({
|
|
|
459
476
|
* row can have done to it. Absent, the columns behave exactly as they did. */
|
|
460
477
|
onRowContextMenu,
|
|
461
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,
|
|
462
497
|
/* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
|
|
463
498
|
* kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
|
|
464
499
|
* consumer, so neither is computed here:
|
|
@@ -497,7 +532,9 @@ export default function ColumnBrowser({
|
|
|
497
532
|
onColumnResize?.(i, w)
|
|
498
533
|
}
|
|
499
534
|
const resizeH = (dy) => {
|
|
500
|
-
|
|
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)
|
|
501
538
|
const next = Math.max(MIN_H, Math.round(dragBase.current + dy))
|
|
502
539
|
if (height == null) setOwnH(next)
|
|
503
540
|
onHeightChange?.(next)
|
|
@@ -573,13 +610,28 @@ export default function ColumnBrowser({
|
|
|
573
610
|
* on `prefix`, so a bucket switch from the header (which takes focus) hands the keyboard back. */
|
|
574
611
|
useEffect(() => { if (autoFocus) rootRef.current?.focus() }, [autoFocus, prefix])
|
|
575
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)
|
|
576
618
|
const land = (level, item) => {
|
|
577
619
|
if (!item) return
|
|
578
|
-
if (item.type === 'folder') { pick(null); onPrefix(level + item.name) }
|
|
579
|
-
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) }
|
|
580
622
|
}
|
|
581
623
|
|
|
624
|
+
const shiftAnchor = useRef(null)
|
|
582
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
|
+
}
|
|
583
635
|
// Space = Quick Look over the picked column's files, starting at the picked one.
|
|
584
636
|
if (e.key === ' ' && picked) {
|
|
585
637
|
e.preventDefault()
|
|
@@ -595,6 +647,20 @@ export default function ColumnBrowser({
|
|
|
595
647
|
const items = itemsAt(lv[col])
|
|
596
648
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
597
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
|
|
598
664
|
/* an arrow that did not move the cursor does nothing — re-landing on an
|
|
599
665
|
* open folder re-fired onPrefix (ColumnBrowserCursorSeed) */
|
|
600
666
|
if (idx === cursor.idx && col === cursor.col) return
|
|
@@ -614,6 +680,7 @@ export default function ColumnBrowser({
|
|
|
614
680
|
const idx = Math.max(0, parentItems.findIndex((it) => it.type === 'folder' && it.name === opened))
|
|
615
681
|
setCursor({ col: col - 1, idx })
|
|
616
682
|
pick(null)
|
|
683
|
+
selectOnly(lv[col])
|
|
617
684
|
onPrefix(lv[col])
|
|
618
685
|
}
|
|
619
686
|
}
|
|
@@ -636,6 +703,7 @@ export default function ColumnBrowser({
|
|
|
636
703
|
if (!items[idx]) return
|
|
637
704
|
setCursor({ col, idx })
|
|
638
705
|
pick(items[idx].o)
|
|
706
|
+
selectOnly(items[idx].o.key)
|
|
639
707
|
const siblings = items.filter((it) => it.type === 'file').map((it) => it.o)
|
|
640
708
|
onQuickLook?.({ files: siblings, index: siblings.findIndex((o) => o.key === items[idx].o.key) })
|
|
641
709
|
}
|
|
@@ -673,6 +741,20 @@ export default function ColumnBrowser({
|
|
|
673
741
|
* `md` (768) — the one `ContentFilters` already uses, so a page has one
|
|
674
742
|
* responsive story and not two. */
|
|
675
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
|
|
676
758
|
|
|
677
759
|
if (stack) {
|
|
678
760
|
/* THE CURRENT LEVEL IS THE LIST (StackModeChromeAndAncestors, kol-r2b2
|
|
@@ -710,7 +792,7 @@ export default function ColumnBrowser({
|
|
|
710
792
|
className="kol-column-browser-back flex items-center gap-2 px-4 py-3 border-b text-fg-default"
|
|
711
793
|
style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
712
794
|
>
|
|
713
|
-
<Icon name="chevron-left" size={14} className="text-
|
|
795
|
+
<Icon name="chevron-left" size={14} className="text-oq-48" />
|
|
714
796
|
{/* NAMES THE PARENT (item 2) — the folder it returns to, not '‹' */}
|
|
715
797
|
<span className="kol-mono-12 truncate">
|
|
716
798
|
{parent === '' ? 'All files' : parent.replace(/\/$/, '').split('/').pop()}
|
|
@@ -739,8 +821,8 @@ export default function ColumnBrowser({
|
|
|
739
821
|
>
|
|
740
822
|
<span className="kol-column-browser-tile-box">
|
|
741
823
|
{isFolder
|
|
742
|
-
? <Icon name="folder" size={28} className="text-
|
|
743
|
-
: (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" />)}
|
|
744
826
|
</span>
|
|
745
827
|
<span className="kol-mono-12 truncate">{isFolder ? r.name.replace(/\/$/, '') : (o.displayKey ?? o.key)}</span>
|
|
746
828
|
{/* counts for a folder, size for a file — never dates (§2) */}
|
|
@@ -763,7 +845,7 @@ export default function ColumnBrowser({
|
|
|
763
845
|
<Row
|
|
764
846
|
key={r.key}
|
|
765
847
|
zones
|
|
766
|
-
icon=
|
|
848
|
+
icon={folderIcon?.(r.level + r.name) ?? 'folder'}
|
|
767
849
|
label={r.name.replace(/\/$/, '')}
|
|
768
850
|
indent={r.depth}
|
|
769
851
|
active={r.open}
|
|
@@ -816,12 +898,17 @@ export default function ColumnBrowser({
|
|
|
816
898
|
className={`kol-column-browser relative border rounded outline-none ${className}`.trim()}
|
|
817
899
|
style={{ borderColor: 'var(--kol-oq-08)', height: h }}
|
|
818
900
|
>
|
|
819
|
-
|
|
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} />}
|
|
820
906
|
{levels.map((level, k) => {
|
|
821
907
|
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
822
908
|
const next = levels[k + 1]
|
|
823
909
|
const activeFolder = next ? next.slice(level.length) : null
|
|
824
910
|
const last = k === levels.length - 1 && !shown
|
|
911
|
+
const colKeys = [...folders.map((f) => level + f), ...files.map((o) => o.key)]
|
|
825
912
|
return (
|
|
826
913
|
<Fragment key={level || '/'}>
|
|
827
914
|
<ul
|
|
@@ -840,31 +927,40 @@ export default function ColumnBrowser({
|
|
|
840
927
|
* re-render per dragover across every column buys nothing. */
|
|
841
928
|
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })}
|
|
842
929
|
onDragOver={(e) => {
|
|
843
|
-
const
|
|
844
|
-
|
|
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
|
|
845
936
|
e.preventDefault()
|
|
846
937
|
e.currentTarget.dataset.dropOver = '1'
|
|
847
938
|
}}
|
|
848
|
-
|
|
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 }}
|
|
849
943
|
onDrop={(e) => {
|
|
850
|
-
|
|
851
|
-
const d = dragFor?.(level)
|
|
852
|
-
if (!d?.canDrop(
|
|
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
|
|
853
947
|
e.preventDefault()
|
|
854
|
-
d.onDrop(
|
|
948
|
+
d.onDrop(d.path)
|
|
855
949
|
}}
|
|
856
950
|
>
|
|
857
951
|
{folders.map((f, i) => (
|
|
858
952
|
<Row
|
|
859
953
|
key={f}
|
|
860
|
-
icon=
|
|
954
|
+
icon={folderIcon?.(level + f) ?? 'folder'}
|
|
861
955
|
label={f.replace(/\/$/, '')}
|
|
862
|
-
|
|
956
|
+
markKey={level + f}
|
|
957
|
+
active={f === activeFolder || isPicked(level + f)}
|
|
863
958
|
cursor={cursorActive && cursor.col === k && cursor.idx === i}
|
|
864
|
-
trailing={<Icon name="chevron-right" size={12} className="text-
|
|
959
|
+
trailing={<Icon name="chevron-right" size={12} className="text-oq-32" />}
|
|
865
960
|
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path: level + f })}
|
|
866
961
|
drop={dragFor?.(level + f)}
|
|
867
|
-
|
|
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) }}
|
|
868
964
|
/>
|
|
869
965
|
))}
|
|
870
966
|
{files.map((o, i) => (
|
|
@@ -872,11 +968,12 @@ export default function ColumnBrowser({
|
|
|
872
968
|
key={o.key}
|
|
873
969
|
icon={COL_ICON[kindOf(o)] || 'file'}
|
|
874
970
|
label={o.displayKey ?? o.key}
|
|
875
|
-
|
|
971
|
+
markKey={o.key}
|
|
972
|
+
active={shown?.key === o.key || isPicked(o.key)}
|
|
876
973
|
cursor={cursorActive && cursor.col === k && cursor.idx === folders.length + i}
|
|
877
974
|
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
|
|
878
975
|
drop={dragFor?.(o.key)}
|
|
879
|
-
onClick={() => { setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
|
|
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) }}
|
|
880
977
|
/>
|
|
881
978
|
))}
|
|
882
979
|
{/* AN EMPTY COLUMN IS VISIBLY EMPTY (user 2026-09-21). It used to print the word
|
|
@@ -891,14 +988,71 @@ export default function ColumnBrowser({
|
|
|
891
988
|
</Fragment>
|
|
892
989
|
)
|
|
893
990
|
})}
|
|
894
|
-
{
|
|
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 && (
|
|
895
997
|
<>
|
|
896
998
|
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
|
|
897
999
|
<ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
|
|
898
1000
|
</>
|
|
899
1001
|
)}
|
|
900
1002
|
</div>
|
|
901
|
-
|
|
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>
|
|
902
1056
|
</div>
|
|
903
1057
|
)
|
|
904
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
|
|
|
@@ -498,11 +498,11 @@ function Thumb({ row, mediaUrl }) {
|
|
|
498
498
|
function FolderRow({ folder, struck = false, onEnter }) {
|
|
499
499
|
return (
|
|
500
500
|
<li className="kol-media-folder" onClick={onEnter}>
|
|
501
|
-
<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">
|
|
502
502
|
<Icon name="folder" size={18} />
|
|
503
503
|
</span>
|
|
504
504
|
<span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-meta' : 'text-body'}`}>{folder.label}</span>
|
|
505
|
-
<Icon name="chevron-right" size={14} className="text-
|
|
505
|
+
<Icon name="chevron-right" size={14} className="text-oq-24" />
|
|
506
506
|
</li>
|
|
507
507
|
)
|
|
508
508
|
}
|