@kolkrabbi/kol-component 0.243.0 → 0.245.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 +1 -1
- package/src/atoms/SegmentedToggle.jsx +14 -4
- package/src/hooks/usePointerSort.js +51 -0
- package/src/index.js +1 -0
- package/src/molecules/ContextMenu.jsx +8 -0
- package/src/molecules/StepList.jsx +109 -0
- package/src/organisms/ColumnBrowser.jsx +6 -0
- package/src/organisms/MediaLibraryPages.jsx +42 -14
- package/src/organisms/RecordManager.jsx +5 -32
- package/src/utilities/mediaKinds.js +2 -2
package/package.json
CHANGED
|
@@ -29,8 +29,12 @@
|
|
|
29
29
|
* strip (canvas alignment, transform cluster); onChange is the
|
|
30
30
|
* action dispatch.
|
|
31
31
|
* onChange — handler (newValue) => void
|
|
32
|
-
* options — [{ value, label, ariaLabel?, tooltip? }] — a cell whose label is not a string
|
|
33
|
-
* (a glyph) shows its `ariaLabel` as a KOL Tooltip; `tooltip` overrides it
|
|
32
|
+
* options — [{ value, label, ariaLabel?, tooltip?, disabled? }] — a cell whose label is not a string
|
|
33
|
+
* (a glyph) shows its `ariaLabel` as a KOL Tooltip; `tooltip` overrides it.
|
|
34
|
+
* `disabled` (kol-fxr 2026-10-09): the cell draws the family's disabled ink
|
|
35
|
+
* (`aria-disabled`, not the attribute — the tooltip still gives the reason),
|
|
36
|
+
* refuses the press, and ←/→ step over it. It may still be the current `value`
|
|
37
|
+
* (the ground changed under it): selected + disabled, no throw.
|
|
34
38
|
* variant — 'default' (shipped chrome: shared outer stroke + dividers,
|
|
35
39
|
* filled active cell) | 'filled' (the state law's tiles: every
|
|
36
40
|
* cell a surface-secondary tile with 1px transparent gaps, NO
|
|
@@ -66,7 +70,12 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
|
|
|
66
70
|
const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key]
|
|
67
71
|
if (!dir || !options.length) return
|
|
68
72
|
e.preventDefault()
|
|
69
|
-
|
|
73
|
+
let next = focusIdx
|
|
74
|
+
for (let step = 0; step < options.length; step++) {
|
|
75
|
+
next = (next + dir + options.length) % options.length
|
|
76
|
+
if (!options[next].disabled) break
|
|
77
|
+
}
|
|
78
|
+
if (options[next].disabled) return
|
|
70
79
|
onChange?.(options[next].value)
|
|
71
80
|
e.currentTarget.children[next]?.focus()
|
|
72
81
|
}
|
|
@@ -94,8 +103,9 @@ export default function SegmentedToggle({ value, onChange, options = [], variant
|
|
|
94
103
|
role={stateless ? undefined : 'radio'}
|
|
95
104
|
aria-checked={stateless ? undefined : isActive}
|
|
96
105
|
aria-label={opt.ariaLabel}
|
|
106
|
+
aria-disabled={opt.disabled || undefined}
|
|
97
107
|
tabIndex={stateless ? 0 : (i === focusIdx ? 0 : -1)}
|
|
98
|
-
onClick={() => onChange?.(opt.value)}
|
|
108
|
+
onClick={opt.disabled ? undefined : () => onChange?.(opt.value)}
|
|
99
109
|
className={['kol-seg-cell', cellType, isActive && 'is-active'].filter(Boolean).join(' ')}
|
|
100
110
|
>
|
|
101
111
|
{opt.label}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* usePointerSort — a vertical reorder by pointer, the ~50 lines `RecordManager` carried inline
|
|
5
|
+
* (2026-08-09, "util before any dependency"), lifted so `StepList` is not a second copy (kol-fxr
|
|
6
|
+
* StepList, 2026-10-09). Pointer events, so a finger works where HTML drag does not.
|
|
7
|
+
*
|
|
8
|
+
* const { drag, startDrag } = usePointerSort(onReorder)
|
|
9
|
+
* <button onPointerDown={(e) => startDrag(e, i, rowEls)} className="touch-none">⠿</button>
|
|
10
|
+
*
|
|
11
|
+
* `startDrag(e, from, rowEls)`: `rowEls` is the list of row elements in order (a NodeList or an
|
|
12
|
+
* array); their rects are measured once at the start. While the pointer is down `drag` is
|
|
13
|
+
* `{ from, over, x, y }` — `over` is the row under the pointer, `x`/`y` the pointer, for a
|
|
14
|
+
* floating label — and null otherwise. On release `onReorder(from, over)` fires when they differ.
|
|
15
|
+
* Without `onReorder` a start is a no-op, so a handle can render conditionally on it.
|
|
16
|
+
*/
|
|
17
|
+
export default function usePointerSort(onReorder) {
|
|
18
|
+
const dragRef = useRef(null)
|
|
19
|
+
const [drag, setDrag] = useState(null)
|
|
20
|
+
|
|
21
|
+
const startDrag = (e, from, rowEls) => {
|
|
22
|
+
if (!onReorder) return
|
|
23
|
+
e.preventDefault()
|
|
24
|
+
if (!rowEls?.length) return
|
|
25
|
+
const rects = [...rowEls].map((el) => el.getBoundingClientRect())
|
|
26
|
+
const move = (ev) => {
|
|
27
|
+
const hit = rects.findIndex((r) => ev.clientY < r.bottom)
|
|
28
|
+
const over = hit === -1 ? rects.length - 1 : hit
|
|
29
|
+
dragRef.current = { from, over, x: ev.clientX, y: ev.clientY }
|
|
30
|
+
setDrag(dragRef.current)
|
|
31
|
+
}
|
|
32
|
+
const up = () => {
|
|
33
|
+
window.removeEventListener('pointermove', move)
|
|
34
|
+
window.removeEventListener('pointerup', up)
|
|
35
|
+
document.body.style.cursor = ''
|
|
36
|
+
document.body.style.userSelect = ''
|
|
37
|
+
const d = dragRef.current
|
|
38
|
+
dragRef.current = null
|
|
39
|
+
setDrag(null)
|
|
40
|
+
if (d && d.over !== d.from) onReorder(d.from, d.over)
|
|
41
|
+
}
|
|
42
|
+
window.addEventListener('pointermove', move)
|
|
43
|
+
window.addEventListener('pointerup', up)
|
|
44
|
+
document.body.style.cursor = 'grabbing'
|
|
45
|
+
document.body.style.userSelect = 'none'
|
|
46
|
+
dragRef.current = { from, over: from, x: e.clientX, y: e.clientY }
|
|
47
|
+
setDrag(dragRef.current)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { drag, startDrag }
|
|
51
|
+
}
|
package/src/index.js
CHANGED
|
@@ -79,6 +79,7 @@ export { default as InspectorRail } from './molecules/InspectorRail.jsx'
|
|
|
79
79
|
export { default as PathNodeOverlay } from './utilities/PathNodeOverlay.jsx'
|
|
80
80
|
export { default as CropOverlay } from './utilities/CropOverlay.jsx'
|
|
81
81
|
export { default as LayerStack, AddLayerButton, BLEND_MODES } from './organisms/LayerStack.jsx'
|
|
82
|
+
export { default as StepList } from './molecules/StepList.jsx'
|
|
82
83
|
export { TYPE_LABELS, BOOL_OP_LABELS, SHAPE_KIND_LABELS, labelForLayer, rowLabelForLayer, findLayerDeep } from './hooks/layerTree.js'
|
|
83
84
|
export { default as TimelineDock, sampleTrack, TIMELINE_EASINGS } from './organisms/TimelineDock.jsx'
|
|
84
85
|
export { default as CurveEditor, CURVE_KINDS, defaultCurveFor } from './organisms/CurveEditor.jsx'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from 'react'
|
|
2
2
|
import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
|
|
3
|
+
import { pushLayer, popLayer } from '../utilities/layerStack.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* ContextMenu — a right-click menu, anchored at the pointer.
|
|
@@ -63,6 +64,13 @@ export function useContextMenu() {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
export default function ContextMenu({ menu, children, className = '' }) {
|
|
67
|
+
/* ONE ESCAPE, ONE LEVEL (kol-fxr 2026-10-08: a menu opened inside a FullscreenOverlay took the
|
|
68
|
+
* dialog with it). Open, the menu is the top layer, so the overlay under it ignores that Escape. */
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!menu.open) return undefined
|
|
71
|
+
const layer = pushLayer()
|
|
72
|
+
return () => popLayer(layer)
|
|
73
|
+
}, [menu.open])
|
|
66
74
|
if (!menu.open) return null
|
|
67
75
|
return (
|
|
68
76
|
/* `focus={false}` — FloatingFocusManager reads attributes off the reference,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
import Button from '../atoms/Button.jsx'
|
|
3
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
|
+
import usePointerSort from '../hooks/usePointerSort.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* StepList — A numbered list of slots ordered by hand. the Morph rail's steps in kol-fxr (each row a
|
|
8
|
+
* generator preset, the morph tweening from row 1 to row N), generic enough for any ordered slots —
|
|
9
|
+
* a playlist, a chain of stages (lobby: StepList, kol-fxr 2026-10-09). One row is active, × removes
|
|
10
|
+
* a row, the dashed last row adds one.
|
|
11
|
+
*
|
|
12
|
+
* <StepList items={steps} activeIndex={i} onSelect={setI} onRemove={remove} onMove={move} onAdd={add} />
|
|
13
|
+
*
|
|
14
|
+
* Reorder is `usePointerSort` — the pointer sort `RecordManager` runs — not the HTML drag the brief
|
|
15
|
+
* named from `LayerStack`: HTML drag does nothing under a finger, and fxr runs on a phone. The
|
|
16
|
+
* `arrows` variant is the keyboard path.
|
|
17
|
+
*
|
|
18
|
+
* @param {{ id: string|number, label: import('react').ReactNode }[]} items the rows, in order
|
|
19
|
+
* @param {number|null} activeIndex the highlighted (editing) row
|
|
20
|
+
* @param {'grab'|'arrows'} reorder `grab`: a ⠿ handle and a pointer drag (touch too) · `arrows`: ↑ ↓ quiet buttons, one slot a press
|
|
21
|
+
* @param {boolean} readOnly no handle or arrows, no ×, no add row; the body does not open
|
|
22
|
+
* @param {'xs'|'sm'|'md'|'lg'} size the control rung the × and the arrows ride
|
|
23
|
+
* @param {(index: number) => void} onSelect the body pressed
|
|
24
|
+
* @param {(index: number) => void} onRemove ×
|
|
25
|
+
* @param {(from: number, to: number) => void} onMove after a drop or an arrow press
|
|
26
|
+
* @param {() => void} onAdd the dashed row
|
|
27
|
+
* @param {import('react').ReactNode} addLabel the dashed row's words — the consumer's copy
|
|
28
|
+
* @param {string} className on the list
|
|
29
|
+
*/
|
|
30
|
+
export default function StepList({
|
|
31
|
+
items = [],
|
|
32
|
+
activeIndex = null,
|
|
33
|
+
reorder = 'grab',
|
|
34
|
+
readOnly = false,
|
|
35
|
+
size = 'sm',
|
|
36
|
+
onSelect,
|
|
37
|
+
onRemove,
|
|
38
|
+
onMove,
|
|
39
|
+
onAdd,
|
|
40
|
+
addLabel = 'Add a step',
|
|
41
|
+
className = '',
|
|
42
|
+
}) {
|
|
43
|
+
const listRef = useRef(null)
|
|
44
|
+
const { drag, startDrag } = usePointerSort(readOnly ? null : onMove)
|
|
45
|
+
const grab = !readOnly && reorder === 'grab' && !!onMove
|
|
46
|
+
const arrows = !readOnly && reorder === 'arrows' && !!onMove
|
|
47
|
+
const rows = () => listRef.current?.querySelectorAll('.kol-step-list-item')
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ul ref={listRef} className={`flex flex-col gap-1 ${className}`.trim()}>
|
|
51
|
+
{items.map((it, i) => {
|
|
52
|
+
const active = i === activeIndex
|
|
53
|
+
const dragging = drag && drag.from === i
|
|
54
|
+
const over = drag && drag.over === i && drag.over !== drag.from
|
|
55
|
+
const mark = over ? (drag.over > drag.from ? 'bottom-0' : 'top-0') : null
|
|
56
|
+
return (
|
|
57
|
+
<li
|
|
58
|
+
key={it.id}
|
|
59
|
+
className={`kol-step-list-item relative flex items-center gap-2 rounded px-2 py-1${active ? ' bg-fg-08' : ''}${dragging ? ' opacity-40' : ''}`}
|
|
60
|
+
>
|
|
61
|
+
{/* the drop mark: a 1px accent line above or below the row under the pointer */}
|
|
62
|
+
{mark && <span aria-hidden="true" className={`absolute left-2 right-2 h-px ${mark}`} style={{ background: 'var(--kol-accent-primary)' }} />}
|
|
63
|
+
{grab && (
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
aria-label={`Reorder step ${i + 1}`}
|
|
67
|
+
className="cursor-grab shrink-0 touch-none border-0 bg-transparent p-0"
|
|
68
|
+
onPointerDown={(e) => startDrag(e, i, rows())}
|
|
69
|
+
>
|
|
70
|
+
<Icon name="drag-handle" size={12} className="text-oq-48" />
|
|
71
|
+
</button>
|
|
72
|
+
)}
|
|
73
|
+
{arrows && (
|
|
74
|
+
<span className="flex shrink-0 items-center">
|
|
75
|
+
<Button tone="ghost" quiet iconOnly="chevron-up" size={size} aria-label={`Move step ${i + 1} up`} disabled={i === 0} onClick={() => onMove(i, i - 1)} />
|
|
76
|
+
<Button tone="ghost" quiet iconOnly="chevron-down" size={size} aria-label={`Move step ${i + 1} down`} disabled={i === items.length - 1} onClick={() => onMove(i, i + 1)} />
|
|
77
|
+
</span>
|
|
78
|
+
)}
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
className="flex min-w-0 flex-1 items-center gap-2 border-0 bg-transparent p-0 text-left"
|
|
82
|
+
onClick={readOnly ? undefined : () => onSelect?.(i)}
|
|
83
|
+
disabled={readOnly}
|
|
84
|
+
>
|
|
85
|
+
<span className="kol-helper-10 text-meta tabular-nums w-4 shrink-0">{i + 1}</span>
|
|
86
|
+
<span className="kol-mono-12 text-emphasis truncate">{it.label}</span>
|
|
87
|
+
</button>
|
|
88
|
+
{!readOnly && onRemove && (
|
|
89
|
+
<Button tone="ghost" quiet iconOnly="x" size={size} aria-label={`Remove step ${i + 1}`} onClick={() => onRemove(i)} />
|
|
90
|
+
)}
|
|
91
|
+
</li>
|
|
92
|
+
)
|
|
93
|
+
})}
|
|
94
|
+
{!readOnly && onAdd && (
|
|
95
|
+
<li>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className="flex w-full items-center gap-2 rounded border border-dashed border-oq-16 px-2 py-1 text-meta hover:text-body hover:border-oq-24"
|
|
99
|
+
onClick={onAdd}
|
|
100
|
+
>
|
|
101
|
+
<span className="kol-helper-10 tabular-nums w-4 shrink-0">{items.length + 1}</span>
|
|
102
|
+
<Icon name="plus" size={12} className="text-oq-48" />
|
|
103
|
+
<span className="kol-mono-12">{addLabel}</span>
|
|
104
|
+
</button>
|
|
105
|
+
</li>
|
|
106
|
+
)}
|
|
107
|
+
</ul>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -87,6 +87,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
|
87
87
|
* `KOL-R2B2/<label>/…`) must strip its own prefix before it can look anything up or build a URL —
|
|
88
88
|
* the browser has no idea which part of a path is yours (kol-r2b2 2026-09-04).
|
|
89
89
|
* @param {Function} folderMeta (prefix, view) => string — a folder's own meta line (`date · N items` in list, `N items` in grid). A SEAM, not a computation: counting by prefix is O(n) per folder, and a consumer that already holds a folder tree answers it for free
|
|
90
|
+
* @param {boolean} tapSelects below the breakpoint a file tap selects only, no Quick Look (a picker)
|
|
90
91
|
* @param {'list'|'grid'} stackView the mobile view below the breakpoint (default 'list'); `grid` is 3-up tiles, flat — a grid has nowhere to put an inline child list
|
|
91
92
|
*
|
|
92
93
|
* BELOW `md` (768) THIS IS A DIFFERENT TREE — one full-width inline-expanding
|
|
@@ -573,6 +574,9 @@ export default function ColumnBrowser({
|
|
|
573
574
|
* media bucket the thumbnail IS the tile, so it carries counts and sizes
|
|
574
575
|
* rather than dates (§2). Above the breakpoint the columns are unaffected. */
|
|
575
576
|
stackView = 'list',
|
|
577
|
+
/* `tapSelects` — below the breakpoint a file tap SELECTS and opens nothing (kol-fxr 2026-10-08: a
|
|
578
|
+
* picker's tap must pick, and Quick Look covered the host's Open button). Off: a tap opens Quick Look. */
|
|
579
|
+
tapSelects = false,
|
|
576
580
|
autoFocus = false,
|
|
577
581
|
className = '',
|
|
578
582
|
}) {
|
|
@@ -899,6 +903,7 @@ export default function ColumnBrowser({
|
|
|
899
903
|
if (isFolder) { onPrefix(r.level + r.name); return }
|
|
900
904
|
const files = itemsAt(r.level ?? '').filter((it) => it.type === 'file').map((it) => it.o)
|
|
901
905
|
pick(o)
|
|
906
|
+
if (tapSelects) return
|
|
902
907
|
onQuickLook?.({ files: files.length ? files : [o], index: Math.max(0, files.findIndex((f) => f.key === o.key)) })
|
|
903
908
|
}}
|
|
904
909
|
>
|
|
@@ -966,6 +971,7 @@ export default function ColumnBrowser({
|
|
|
966
971
|
onClick={() => {
|
|
967
972
|
const files = itemsAt(r.level ?? '').filter((it) => it.type === 'file').map((it) => it.o)
|
|
968
973
|
pick(r.o)
|
|
974
|
+
if (tapSelects) return
|
|
969
975
|
onQuickLook?.({ files: files.length ? files : [r.o], index: Math.max(0, files.findIndex((f) => f.key === r.o.key)) })
|
|
970
976
|
}}
|
|
971
977
|
/>
|
|
@@ -247,7 +247,10 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
|
|
|
247
247
|
const mediaUrl = (key) => client?.mediaUrl?.(key, bucketId ?? undefined) ?? key
|
|
248
248
|
const downloadUrl = (key) => client?.downloadUrl?.(key, bucketId ?? undefined) ?? mediaUrl(key)
|
|
249
249
|
const writable = !!bucketMeta.writable && !!(client?.deleteObject || client?.renameObject)
|
|
250
|
-
|
|
250
|
+
/* A CLIENT WITH NO `mediaUrl` HAS NO URLS (kol-fxr 2026-10-08: a stored preset is not a fetchable
|
|
251
|
+
* object) — Copy URL, Download and Quick Look's fetch all go, as write verbs go without a writer. */
|
|
252
|
+
const hasUrl = typeof client?.mediaUrl === 'function'
|
|
253
|
+
return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, folderInfo, setFolderInfo, reload, error, ready, mediaUrl, downloadUrl, writable, hasUrl }
|
|
251
254
|
}
|
|
252
255
|
|
|
253
256
|
/* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
|
|
@@ -622,7 +625,7 @@ function FileEditorHost({ o, client, bucket, onClose, onSaved, assets }) {
|
|
|
622
625
|
* @param {boolean} showNav - ‹ › and n / N in the header (default on); off when not paging a selection
|
|
623
626
|
* @param {Function} onEdit - `(file) => void` — an Edit action in the header for a text file (markdown · json · yaml · text · code); absent, no Edit
|
|
624
627
|
*/
|
|
625
|
-
export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true, onEdit }) {
|
|
628
|
+
export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true, onEdit, hasUrl = true }) {
|
|
626
629
|
const o = files[index]
|
|
627
630
|
const [dims, setDims] = useState(null)
|
|
628
631
|
/* the window's size survives paging — Finder keeps it — so it lives here, not in the frame,
|
|
@@ -666,17 +669,26 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
666
669
|
<Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
|
|
667
670
|
</Tooltip>
|
|
668
671
|
)}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
672
|
+
{hasUrl && (
|
|
673
|
+
<>
|
|
674
|
+
<CopyAction label="Copy URL" text={mediaUrl(o.key)} />
|
|
675
|
+
<Tooltip label="Download">
|
|
676
|
+
<Button variant="nav" size="sm" iconOnly="download" href={downloadUrl(o.key)} download={o.key.split('/').pop()} aria-label="Download" />
|
|
677
|
+
</Tooltip>
|
|
678
|
+
</>
|
|
679
|
+
)}
|
|
673
680
|
</span>
|
|
674
681
|
),
|
|
675
682
|
}
|
|
676
683
|
return (
|
|
677
684
|
/* QUICK LOOK DIMS, it does not black out — you keep the list you came from in view */
|
|
678
685
|
<FullscreenOverlay open onClose={onClose} closeButton={false} scrim>
|
|
679
|
-
{
|
|
686
|
+
{!hasUrl ? (
|
|
687
|
+
/* nothing to fetch, so nothing to draw — said, not spun on (kol-fxr 2026-10-08) */
|
|
688
|
+
<QuickLookFrame {...frame}>
|
|
689
|
+
<p className="kol-helper-12 text-meta p-8">No preview</p>
|
|
690
|
+
</QuickLookFrame>
|
|
691
|
+
) : isImage(o.contentType) ? (
|
|
680
692
|
<QuickLookFrame {...frame}>
|
|
681
693
|
{/* A VECTOR HAS NO PIXEL SIZE (user 2026-09-24: a 32px logo.svg opened as a 32px window). It
|
|
682
694
|
* is drawn in a 640 box — the window's max if that is smaller — and contained in it. */}
|
|
@@ -1081,6 +1093,8 @@ export function MediaLibraryBrowse({
|
|
|
1081
1093
|
* browse surface inside the modal card needs the selection for its Use button). Keys are
|
|
1082
1094
|
* bucket-relative. Absent, nothing. */
|
|
1083
1095
|
onPickFile,
|
|
1096
|
+
/* `searchPlaceholder` — the search field's words; a store that is not a bucket names itself (kol-fxr 2026-10-08) */
|
|
1097
|
+
searchPlaceholder = 'Search this bucket',
|
|
1084
1098
|
/* `o.displayName` on a listed object is its LABEL in every view, Quick Look and search (kol-fxr
|
|
1085
1099
|
* 2026-10-08: names not unique, so the key is an id). Absent, the label is the key, as before. */
|
|
1086
1100
|
}) {
|
|
@@ -1091,7 +1105,11 @@ export function MediaLibraryBrowse({
|
|
|
1091
1105
|
const [ownBucket, setOwnBucket] = useState(bucket)
|
|
1092
1106
|
const bucketId = bucket ?? ownBucket
|
|
1093
1107
|
const lib = useBucketLibrary({ client, bucket: bucketId, defaults, settings: settingsProp, onSettingsChange, refreshKey })
|
|
1094
|
-
const { buckets, bucketMeta, settings, setSettings, objects, error, mediaUrl, downloadUrl,
|
|
1108
|
+
const { buckets, bucketMeta, settings, setSettings, objects, error, mediaUrl, downloadUrl, hasUrl } = lib
|
|
1109
|
+
/* `fileActions` GIVEN IS A WRITER (kol-fxr 2026-10-08): the consumer brings its own verbs, so the
|
|
1110
|
+
* client need not carry a `deleteObject` stub to be treated as writable. The bucket still has to
|
|
1111
|
+
* say it is writable. */
|
|
1112
|
+
const writable = lib.writable || (!!fileActions && !!bucketMeta.writable)
|
|
1095
1113
|
const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
|
|
1096
1114
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
1097
1115
|
const [trashOpen, setTrashOpen] = useState(false)
|
|
@@ -2008,6 +2026,7 @@ export function MediaLibraryBrowse({
|
|
|
2008
2026
|
<ColumnBrowser
|
|
2009
2027
|
autoFocus={autoFocus}
|
|
2010
2028
|
className={atTitleRoot ? 'is-root' : ''}
|
|
2029
|
+
tapSelects={!!onPickFile}
|
|
2011
2030
|
height={colH}
|
|
2012
2031
|
onHeightChange={onColH}
|
|
2013
2032
|
rowSize={rowSize}
|
|
@@ -2435,7 +2454,7 @@ export function MediaLibraryBrowse({
|
|
|
2435
2454
|
value={query}
|
|
2436
2455
|
onChange={(e) => setQuery(e.target.value ?? '')}
|
|
2437
2456
|
onClear={() => setQuery('')}
|
|
2438
|
-
placeholder=
|
|
2457
|
+
placeholder={searchPlaceholder}
|
|
2439
2458
|
size="sm"
|
|
2440
2459
|
className="flex-1 min-w-0"
|
|
2441
2460
|
/>
|
|
@@ -2499,7 +2518,7 @@ export function MediaLibraryBrowse({
|
|
|
2499
2518
|
onClose={() => { setSearchOpen(false); setSearchQuery('') }}
|
|
2500
2519
|
query={searchQuery}
|
|
2501
2520
|
onQueryChange={setSearchQuery}
|
|
2502
|
-
placeholder=
|
|
2521
|
+
placeholder={searchPlaceholder}
|
|
2503
2522
|
results={searchResults}
|
|
2504
2523
|
onExpand={() => searchResults[0] && jumpTo(searchResults[0].id)}
|
|
2505
2524
|
onSelect={(item) => (item.run ? item.run() : jumpTo(item.id))}
|
|
@@ -2516,7 +2535,7 @@ export function MediaLibraryBrowse({
|
|
|
2516
2535
|
</FullscreenOverlay>
|
|
2517
2536
|
)}
|
|
2518
2537
|
{quickLook && (
|
|
2519
|
-
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
|
|
2538
|
+
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} hasUrl={hasUrl} keySet={keySet} showNav={rowSelection.size > 1}
|
|
2520
2539
|
onEdit={canEdit ? (f) => { closeQuickLook(); openEditor(f) } : undefined}
|
|
2521
2540
|
onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
|
|
2522
2541
|
)}
|
|
@@ -2639,7 +2658,7 @@ export function MediaLibraryBrowse({
|
|
|
2639
2658
|
{fa.move && (
|
|
2640
2659
|
<MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
|
|
2641
2660
|
)}
|
|
2642
|
-
{many.every((p) => !p.endsWith('/')) && (
|
|
2661
|
+
{hasUrl && many.every((p) => !p.endsWith('/')) && (
|
|
2643
2662
|
<>
|
|
2644
2663
|
<MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl(many)}>Copy {many.length} URLs</MenuDropdownItem>
|
|
2645
2664
|
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload(many)}>Download {many.length}</MenuDropdownItem>
|
|
@@ -2653,8 +2672,17 @@ export function MediaLibraryBrowse({
|
|
|
2653
2672
|
</>
|
|
2654
2673
|
)
|
|
2655
2674
|
}
|
|
2675
|
+
const qlFile = onPickFile && target.type === 'file' ? objects.find((o) => o.key === target.path) : null
|
|
2656
2676
|
return (
|
|
2657
2677
|
<>
|
|
2678
|
+
{/* A PICKER'S TAP SELECTS (`tapSelects`), so Quick Look needs a door of its own on a
|
|
2679
|
+
* phone — the menu (kol-fxr 2026-10-08). Only with `onPickFile`; others are unchanged. */}
|
|
2680
|
+
{qlFile && (
|
|
2681
|
+
<>
|
|
2682
|
+
<MenuDropdownItem iconLeft={<Icon name="eye-on" size={14} />} onClick={() => openQuickLook({ files: [qlFile], index: 0 })}>Quick Look</MenuDropdownItem>
|
|
2683
|
+
<MenuDropdownDivider />
|
|
2684
|
+
</>
|
|
2685
|
+
)}
|
|
2658
2686
|
{fa.createFolder && (
|
|
2659
2687
|
<MenuDropdownItem iconLeft={<Icon name="folder" size={14} />} onClick={() => doCreateFolder(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2660
2688
|
New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
|
|
@@ -2687,10 +2715,10 @@ export function MediaLibraryBrowse({
|
|
|
2687
2715
|
seam a file goes through, so what a folder URL means is the consumer's call
|
|
2688
2716
|
(a CDN prefix, a listing, a signed path) and never this page's guess. No
|
|
2689
2717
|
Download: a folder is not an object to fetch. */}
|
|
2690
|
-
{!isLevel && (
|
|
2718
|
+
{hasUrl && !isLevel && (
|
|
2691
2719
|
<MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl([target.path])}>Copy URL</MenuDropdownItem>
|
|
2692
2720
|
)}
|
|
2693
|
-
{target.type === 'file' && (
|
|
2721
|
+
{hasUrl && target.type === 'file' && (
|
|
2694
2722
|
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
|
|
2695
2723
|
)}
|
|
2696
2724
|
{!isLevel && canTag && (
|
|
@@ -10,6 +10,7 @@ import FieldRow, { StatusChip } from '../molecules/FieldRow'
|
|
|
10
10
|
import { Tooltip } from '../utilities/Popover'
|
|
11
11
|
import Table from './Table'
|
|
12
12
|
import MediaLibrary from './MediaLibrary'
|
|
13
|
+
import usePointerSort from '../hooks/usePointerSort.js'
|
|
13
14
|
|
|
14
15
|
/* TOOLBAR ICONS ARE BARE GLYPHS (user ruling 2026-08-09, verbatim: "did I
|
|
15
16
|
* ask for a button?? … the only thing that should hover is full opacity and
|
|
@@ -93,8 +94,9 @@ export default function RecordManager({
|
|
|
93
94
|
}) {
|
|
94
95
|
const wrapRef = useRef(null)
|
|
95
96
|
const [searchOpen, setSearchOpen] = useState(false)
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
/* { from, over, x, y } during a drag — the sort lives in hooks/usePointerSort (lifted 2026-10-09
|
|
98
|
+
* so StepList is not a second copy); this file keeps only which rows it measures */
|
|
99
|
+
const { drag, startDrag: beginSort } = usePointerSort(onReorder)
|
|
98
100
|
const [active, setActive] = useState(null)
|
|
99
101
|
const [picker, setPicker] = useState(null) // the field being picked for
|
|
100
102
|
const [selected, setSelected] = useState(() => new Set())
|
|
@@ -118,36 +120,7 @@ export default function RecordManager({
|
|
|
118
120
|
onSelectionChange?.([...next])
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
const startDrag = (e, from) => {
|
|
123
|
-
if (!onReorder) return
|
|
124
|
-
e.preventDefault()
|
|
125
|
-
const rowEls = wrapRef.current?.querySelectorAll('.kol-table-row')
|
|
126
|
-
if (!rowEls?.length) return
|
|
127
|
-
const rects = [...rowEls].map((el) => el.getBoundingClientRect())
|
|
128
|
-
const move = (ev) => {
|
|
129
|
-
const hit = rects.findIndex((r) => ev.clientY < r.bottom)
|
|
130
|
-
const over = hit === -1 ? rects.length - 1 : hit
|
|
131
|
-
dragRef.current = { from, over, x: ev.clientX, y: ev.clientY }
|
|
132
|
-
setDrag(dragRef.current)
|
|
133
|
-
}
|
|
134
|
-
const up = () => {
|
|
135
|
-
window.removeEventListener('pointermove', move)
|
|
136
|
-
window.removeEventListener('pointerup', up)
|
|
137
|
-
document.body.style.cursor = ''
|
|
138
|
-
document.body.style.userSelect = ''
|
|
139
|
-
const d = dragRef.current
|
|
140
|
-
dragRef.current = null
|
|
141
|
-
setDrag(null)
|
|
142
|
-
if (d && d.over !== d.from) onReorder(d.from, d.over)
|
|
143
|
-
}
|
|
144
|
-
window.addEventListener('pointermove', move)
|
|
145
|
-
window.addEventListener('pointerup', up)
|
|
146
|
-
document.body.style.cursor = 'grabbing'
|
|
147
|
-
document.body.style.userSelect = 'none'
|
|
148
|
-
dragRef.current = { from, over: from, x: e.clientX, y: e.clientY }
|
|
149
|
-
setDrag(dragRef.current)
|
|
150
|
-
}
|
|
123
|
+
const startDrag = (e, from) => beginSort(e, from, wrapRef.current?.querySelectorAll('.kol-table-row'))
|
|
151
124
|
|
|
152
125
|
/* ── columns: resolve types, prepend handle + checkbox ────────────────── */
|
|
153
126
|
const builtColumns = useMemo(() => {
|
|
@@ -127,7 +127,7 @@ export function groupVariants(files) {
|
|
|
127
127
|
for (const [base, list] of groups) {
|
|
128
128
|
if (list.length < 2) { out.push(list[0]); continue }
|
|
129
129
|
list.sort((a, b) => a.width - b.width)
|
|
130
|
-
out.push({ ...list[0], displayKey: base, variants: list, totalSize: list.reduce((n, f) => n + (f.size || 0), 0) })
|
|
130
|
+
out.push({ ...list[0], displayKey: list[0].displayName ?? base, variants: list, totalSize: list.reduce((n, f) => n + (f.size || 0), 0) })
|
|
131
131
|
}
|
|
132
132
|
return out
|
|
133
133
|
}
|
|
@@ -140,7 +140,7 @@ export function partition(objects, prefix) {
|
|
|
140
140
|
const rel = prefix ? o.key.slice(prefix.length) : o.key
|
|
141
141
|
const slash = rel.indexOf('/')
|
|
142
142
|
if (slash !== -1) folderSet.add(rel.slice(0, slash + 1))
|
|
143
|
-
else files.push({ ...o, displayKey: rel })
|
|
143
|
+
else files.push({ ...o, displayKey: o.displayName ?? rel })
|
|
144
144
|
}
|
|
145
145
|
return { folders: [...folderSet].sort(), files }
|
|
146
146
|
}
|