@kolkrabbi/kol-component 0.215.0 → 0.216.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 +4 -4
- package/src/index.js +2 -0
- package/src/organisms/ColumnBrowser.jsx +59 -4
- package/src/organisms/MediaLibrary.jsx +23 -10
- package/src/organisms/MediaLibraryExplorer.jsx +116 -0
- package/src/organisms/MediaLibraryPages.jsx +458 -28
- package/src/utilities/ContextMenu.jsx +80 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "KOL design-system components
|
|
3
|
+
"version": "0.216.0",
|
|
4
|
+
"description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@kolkrabbi/kol-icons": "
|
|
37
|
+
"@kolkrabbi/kol-icons": "^0.27.0"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
40
|
"src",
|
|
@@ -56,4 +56,4 @@
|
|
|
56
56
|
"access": "public"
|
|
57
57
|
},
|
|
58
58
|
"sideEffects": false
|
|
59
|
-
}
|
|
59
|
+
}
|
package/src/index.js
CHANGED
|
@@ -48,6 +48,7 @@ export { default as LabeledControl } from './molecules/LabeledControl.jsx'
|
|
|
48
48
|
export { default as OverlayGlassPanel } from './utilities/OverlayGlassPanel.jsx'
|
|
49
49
|
export { default as Pill } from './atoms/Pill.jsx'
|
|
50
50
|
export { usePopover, PopoverPanel, Tooltip } from './utilities/Popover.jsx'
|
|
51
|
+
export { default as ContextMenu, useContextMenu } from './utilities/ContextMenu.jsx'
|
|
51
52
|
export { default as ProsePreview } from './utilities/ProsePreview.jsx'
|
|
52
53
|
export { default as QuantityInput } from './molecules/QuantityInput.jsx'
|
|
53
54
|
export { default as RotaryDial } from './atoms/RotaryDial.jsx'
|
|
@@ -164,6 +165,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
|
|
|
164
165
|
* rendered both mobile viewer views and was module-private, so every consumer
|
|
165
166
|
* needing a phone viewer hand-rolled one. */
|
|
166
167
|
export { MediaInspector } from './organisms/MediaLibraryPages.jsx'
|
|
168
|
+
export { default as MediaLibraryExplorer } from './organisms/MediaLibraryExplorer.jsx'
|
|
167
169
|
export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
|
|
168
170
|
export { default as MediaViewer } from './organisms/MediaViewer.jsx'
|
|
169
171
|
export { default as SettingsPanel, LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
|
|
@@ -239,10 +239,23 @@ const ZONE_BOX = 44
|
|
|
239
239
|
* to what it was. */
|
|
240
240
|
function Row({
|
|
241
241
|
icon, label, active, cursor = false, trailing, onClick, muted = false,
|
|
242
|
-
indent = 0, meta, zones = false, thumb, onDisclose, disclosed,
|
|
242
|
+
indent = 0, meta, zones = false, thumb, onDisclose, disclosed, onContextMenu, drop,
|
|
243
243
|
}) {
|
|
244
|
+
const [over, setOver] = useState(false)
|
|
245
|
+
/* 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
|
+
const dropProps = drop ? {
|
|
248
|
+
draggable: true,
|
|
249
|
+
onDragStart: (e) => { e.stopPropagation(); drop.onDragStart(e, drop.path) },
|
|
250
|
+
onDragOver: (e) => { if (drop.canDrop(drop.path)) { e.preventDefault(); setOver(true) } },
|
|
251
|
+
onDragLeave: () => setOver(false),
|
|
252
|
+
onDrop: (e) => { e.preventDefault(); e.stopPropagation(); setOver(false); drop.onDrop(drop.path) },
|
|
253
|
+
} : {}
|
|
244
254
|
return (
|
|
245
255
|
<li
|
|
256
|
+
onContextMenu={onContextMenu}
|
|
257
|
+
{...dropProps}
|
|
258
|
+
data-drop-over={over || undefined}
|
|
246
259
|
/* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
|
|
247
260
|
* 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last.
|
|
248
261
|
* The `zones` row sets its own 60px instead — a 44px thumbnail in a
|
|
@@ -441,6 +454,11 @@ export default function ColumnBrowser({
|
|
|
441
454
|
columnWidth = 260,
|
|
442
455
|
columnWidths,
|
|
443
456
|
onColumnResize,
|
|
457
|
+
/* RIGHT-CLICK AND DROP, handed down (2026-09-21). `onRowContextMenu(event, { type, path, o })`
|
|
458
|
+
* and `dragFor(path)` are the page's; this component owns the geometry of a row, never what a
|
|
459
|
+
* row can have done to it. Absent, the columns behave exactly as they did. */
|
|
460
|
+
onRowContextMenu,
|
|
461
|
+
dragFor,
|
|
444
462
|
/* THE TWO SEAMS THE MOBILE TICKET'S RULINGS POINT AT (ColumnBrowserMobileViews,
|
|
445
463
|
* kol-r2b2 2026-09-03). Both are questions the DS must not answer for a
|
|
446
464
|
* consumer, so neither is computed here:
|
|
@@ -734,10 +752,13 @@ export default function ColumnBrowser({
|
|
|
734
752
|
})}
|
|
735
753
|
</div>
|
|
736
754
|
) : (
|
|
737
|
-
<ul className="kol-column-browser-column flex-1 overflow-y-auto"
|
|
755
|
+
<ul className="kol-column-browser-column flex-1 overflow-y-auto"
|
|
756
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: prefix })}>
|
|
738
757
|
{rows.map((r) =>
|
|
758
|
+
/* Same ruling as the desktop column: nothing to say, so it says nothing. This is the
|
|
759
|
+
* mobile stack's row for an empty folder. */
|
|
739
760
|
r.kind === 'empty' ? (
|
|
740
|
-
<li key={r.key} className="
|
|
761
|
+
<li key={r.key} className="min-h-12" />
|
|
741
762
|
) : r.kind === 'folder' ? (
|
|
742
763
|
<Row
|
|
743
764
|
key={r.key}
|
|
@@ -806,6 +827,32 @@ export default function ColumnBrowser({
|
|
|
806
827
|
<ul
|
|
807
828
|
className="kol-column-browser-column shrink-0 overflow-y-auto border-r"
|
|
808
829
|
style={{ borderColor: 'var(--kol-oq-08)', width: widthOf(k) }}
|
|
830
|
+
/* THE WHOLE COLUMN IS A TARGET, not just its rows and not just an empty column.
|
|
831
|
+
*
|
|
832
|
+
* RIGHT-CLICK: `openAt` stops propagation, so a row that was hit answers first and
|
|
833
|
+
* this only fires on the blank area below the last row — where you reach for
|
|
834
|
+
* "new folder" in every file manager.
|
|
835
|
+
*
|
|
836
|
+
* DROP: a column IS the folder it is showing, so dropping anywhere in it files the
|
|
837
|
+
* item there. You could drop ON a folder row but not INTO the folder you were looking
|
|
838
|
+
* at, which is the one target that was missing. The highlight goes straight to a data
|
|
839
|
+
* attribute rather than through state — it is transient, it belongs to one node, and a
|
|
840
|
+
* re-render per dragover across every column buys nothing. */
|
|
841
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })}
|
|
842
|
+
onDragOver={(e) => {
|
|
843
|
+
const d = dragFor?.(level)
|
|
844
|
+
if (!d?.canDrop(level)) return
|
|
845
|
+
e.preventDefault()
|
|
846
|
+
e.currentTarget.dataset.dropOver = '1'
|
|
847
|
+
}}
|
|
848
|
+
onDragLeave={(e) => { if (e.currentTarget === e.target) delete e.currentTarget.dataset.dropOver }}
|
|
849
|
+
onDrop={(e) => {
|
|
850
|
+
delete e.currentTarget.dataset.dropOver
|
|
851
|
+
const d = dragFor?.(level)
|
|
852
|
+
if (!d?.canDrop(level)) return
|
|
853
|
+
e.preventDefault()
|
|
854
|
+
d.onDrop(level)
|
|
855
|
+
}}
|
|
809
856
|
>
|
|
810
857
|
{folders.map((f, i) => (
|
|
811
858
|
<Row
|
|
@@ -815,6 +862,8 @@ export default function ColumnBrowser({
|
|
|
815
862
|
active={f === activeFolder}
|
|
816
863
|
cursor={cursorActive && cursor.col === k && cursor.idx === i}
|
|
817
864
|
trailing={<Icon name="chevron-right" size={12} className="text-fg-32" />}
|
|
865
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path: level + f })}
|
|
866
|
+
drop={dragFor?.(level + f)}
|
|
818
867
|
onClick={() => { setCursor({ col: k, idx: i }); setCursorActive(true); rootRef.current?.focus(); pick(null); onPrefix(level + f) }}
|
|
819
868
|
/>
|
|
820
869
|
))}
|
|
@@ -825,11 +874,17 @@ export default function ColumnBrowser({
|
|
|
825
874
|
label={o.displayKey ?? o.key}
|
|
826
875
|
active={shown?.key === o.key}
|
|
827
876
|
cursor={cursorActive && cursor.col === k && cursor.idx === folders.length + i}
|
|
877
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
|
|
878
|
+
drop={dragFor?.(o.key)}
|
|
828
879
|
onClick={() => { setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
|
|
829
880
|
/>
|
|
830
881
|
))}
|
|
882
|
+
{/* AN EMPTY COLUMN IS VISIBLY EMPTY (user 2026-09-21). It used to print the word
|
|
883
|
+
* "empty", which tells you what you can already see. The row stays as a right-click
|
|
884
|
+
* target — that is the only thing it was ever load-bearing for — and says nothing. */}
|
|
831
885
|
{folders.length === 0 && files.length === 0 && (
|
|
832
|
-
<li className="
|
|
886
|
+
<li className="flex-1 min-h-12"
|
|
887
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'level', path: level })} />
|
|
833
888
|
)}
|
|
834
889
|
</ul>
|
|
835
890
|
<ResizeHandle axis="x" onDrag={resizeCol(k)} onEnd={endDrag} />
|
|
@@ -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
|
/**
|
|
@@ -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,116 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import Divider from '../atoms/Divider.jsx'
|
|
3
|
+
import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MediaLibraryExplorer — the browse tree and the file wall as TWO VIEWS OF ONE
|
|
7
|
+
* SURFACE, which is what `MediaLibraryPages` has called them in its own comments
|
|
8
|
+
* since 2026-09-04 while shipping them as two pages a consumer stacks by hand.
|
|
9
|
+
*
|
|
10
|
+
* WHAT WAS WRONG WITH THE STACK (kol-r2b2, the apps-tier ticket):
|
|
11
|
+
* - Both pages rendered ALWAYS, the browser at a fixed 800px, so the wall and
|
|
12
|
+
* the upload drop zone sat below the fold. Pressing Upload scrolled you past
|
|
13
|
+
* a full-height browser to reach the target.
|
|
14
|
+
* - Both rendered `LibraryHeader`, so the consumer passed `header={false}` to
|
|
15
|
+
* suppress a duplicate wordmark, bucket dropdown and gear.
|
|
16
|
+
* - Both printed a stats line, so the consumer passed `stats={false}` too.
|
|
17
|
+
* - Both called `useBucketLibrary`, so one bucket was listed twice into two
|
|
18
|
+
* object lists that could disagree.
|
|
19
|
+
* - Every shared seam had to be handed to both by the consumer, which is the
|
|
20
|
+
* divergence-defect family the pages' own comments document at length.
|
|
21
|
+
*
|
|
22
|
+
* WHAT THIS DOES: holds the view, mounts exactly one page, and hands it the
|
|
23
|
+
* seams once. One page mounted means one header, one stats line, one listing and
|
|
24
|
+
* one body — every symptom above falls out of that rather than being patched.
|
|
25
|
+
*
|
|
26
|
+
* WHAT IT DOES NOT DO: it does not hoist `useBucketLibrary` into itself. The two
|
|
27
|
+
* pages keep their own state, so switching views re-lists the bucket and drops
|
|
28
|
+
* column scroll position and selection. Against this repo's fixture that is
|
|
29
|
+
* free; against a live bucket it is a 30s-cached GET. Hoisting the hook and the
|
|
30
|
+
* header into a true shared parent is the deeper fix and it is a rewrite of both
|
|
31
|
+
* page bodies — worth doing, not worth blocking the surface on.
|
|
32
|
+
*
|
|
33
|
+
* `prefix` stays OWNED BY THE CONSUMER (it already was), so the folder you are
|
|
34
|
+
* in survives the switch even though the page instance does not.
|
|
35
|
+
*
|
|
36
|
+
* ADDITIVE. `variant="browse"` and `variant="library"` are untouched, so
|
|
37
|
+
* kol-r2b2 and kol-client-olina keep rendering exactly what they render today.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const VIEWS = [
|
|
41
|
+
{ value: 'browse', label: 'BROWSE' },
|
|
42
|
+
{ value: 'files', label: 'FILES' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
export default function MediaLibraryExplorer({
|
|
46
|
+
view: viewProp,
|
|
47
|
+
onViewChange,
|
|
48
|
+
defaultView = 'browse',
|
|
49
|
+
headerActions,
|
|
50
|
+
className = '',
|
|
51
|
+
...pageProps
|
|
52
|
+
}) {
|
|
53
|
+
const [ownView, setOwnView] = useState(defaultView)
|
|
54
|
+
const view = viewProp ?? ownView
|
|
55
|
+
const setView = (v) => { setOwnView(v); onViewChange?.(v) }
|
|
56
|
+
|
|
57
|
+
/* The switch rides `headerActions` rather than being a fourth thing in the
|
|
58
|
+
* header's own markup: the header already has a slot for consumer chrome, and
|
|
59
|
+
* putting the view control there keeps it beside the bucket dropdown where
|
|
60
|
+
* every reference product puts it. The consumer's own actions follow it.
|
|
61
|
+
*
|
|
62
|
+
* HIDDEN BELOW `md`. At 390 the header carries a wordmark, a bucket dropdown
|
|
63
|
+
* and up to four icons; adding a two-segment strip pushed the strip off the
|
|
64
|
+
* right edge and truncated the wordmark to three glyphs. The pages already
|
|
65
|
+
* take the mobile tab pill (`tabs` / `activeTab` / `onTabChange`), which is
|
|
66
|
+
* the same control for the same question — so the phone keeps the pill and
|
|
67
|
+
* the desktop keeps the strip, rather than both competing for one row.
|
|
68
|
+
*
|
|
69
|
+
* BARE TEXT, NOT A `SegmentedToggle`. This surface already has a switch
|
|
70
|
+
* idiom and uses it twice — ROW·COLUMN on the crumb line and SELECT·FLAT on
|
|
71
|
+
* the filter bar are both `kol-helper-14`, `oq-96` active over `oq-48` at
|
|
72
|
+
* rest, letterspacing 1, no frame. A boxed toggle put a bordered chip next to
|
|
73
|
+
* an unbordered dropdown and two unbordered switches, which read as a style
|
|
74
|
+
* break because it was one. Same control, third instance, same treatment.
|
|
75
|
+
*
|
|
76
|
+
* AND IT SITS LAST, RIGHT OF A DIVIDER. The crumb row directly beneath this
|
|
77
|
+
* header reads `[list][columns] │ ROW COLUMN` — icons, divider, text switch.
|
|
78
|
+
* Placed before the icons the header mirrored the row under it, which is the
|
|
79
|
+
* same inconsistency in a different direction. Icons left, divider, text
|
|
80
|
+
* right; two rows, one arrangement. It rides `headerTrailing` rather than
|
|
81
|
+
* `headerActions` because the gear is rendered by the header itself and the
|
|
82
|
+
* switch has to clear it. */
|
|
83
|
+
/* THE GAPS ARE THE CRUMB ROW'S, MEASURED: `gap-6` (24px) around the divider,
|
|
84
|
+
* `gap-4` (16px) between the two labels. Built flat at gap-4 throughout, the
|
|
85
|
+
* divider sat 8px too tight against the icons and the row below it did not
|
|
86
|
+
* line up with the row above. Two nested containers, same two numbers. */
|
|
87
|
+
/* `ml-4` IS NOT DECORATION. The divider needs 24px on BOTH sides to match the
|
|
88
|
+
* crumb row, where its own `gap-6` container supplies both. Here the LEFT gap
|
|
89
|
+
* comes from the header's `gap-2` between this group and the gear — 8px — so
|
|
90
|
+
* the divider sat tight against the icons and open against the text. 8 + 16
|
|
91
|
+
* puts it back on 24. */
|
|
92
|
+
const trailing = (
|
|
93
|
+
<span className="max-md:hidden flex items-center gap-6 ml-4">
|
|
94
|
+
<Divider variant="vertical" />
|
|
95
|
+
<span className="flex items-center gap-4">
|
|
96
|
+
{VIEWS.map((opt) => (
|
|
97
|
+
<span key={opt.value} role="button" onClick={() => setView(opt.value)}
|
|
98
|
+
aria-pressed={view === opt.value}
|
|
99
|
+
className={`kol-helper-14 cursor-pointer select-none ${view === opt.value ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`}
|
|
100
|
+
style={{ letterSpacing: 1 }}>
|
|
101
|
+
{opt.label}
|
|
102
|
+
</span>
|
|
103
|
+
))}
|
|
104
|
+
</span>
|
|
105
|
+
</span>
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const shared = { ...pageProps, headerActions, headerTrailing: trailing, className }
|
|
109
|
+
|
|
110
|
+
/* `header` and `stats` are NOT forwarded and NOT accepted. One surface prints
|
|
111
|
+
* one header and one count; a consumer suppressing either is the workaround
|
|
112
|
+
* this component exists to delete. */
|
|
113
|
+
return view === 'files'
|
|
114
|
+
? <MediaLibraryLibrary {...shared} />
|
|
115
|
+
: <MediaLibraryBrowse {...shared} />
|
|
116
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import formatSize from '../utilities/formatSize.js'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
@@ -16,6 +16,8 @@ import SortControls from '../molecules/SortControls.jsx'
|
|
|
16
16
|
import SearchInput from '../molecules/SearchInput.jsx'
|
|
17
17
|
import MobileTabBar, { TABBAR_H } from '../molecules/MobileTabBar.jsx'
|
|
18
18
|
import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
|
|
19
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
20
|
+
import ContextMenu, { useContextMenu } from '../utilities/ContextMenu.jsx'
|
|
19
21
|
import KindPreview from '../molecules/KindPreview.jsx'
|
|
20
22
|
import AudioSheet from '../molecules/AudioSheet.jsx'
|
|
21
23
|
import VideoSheet from '../molecules/VideoSheet.jsx'
|
|
@@ -171,13 +173,93 @@ function ImageFrame({ src }) {
|
|
|
171
173
|
)
|
|
172
174
|
}
|
|
173
175
|
|
|
174
|
-
/* `struck` = folder grouping is bypassed (flat mode) — still navigable, de-emphasised.
|
|
175
|
-
|
|
176
|
+
/* `struck` = folder grouping is bypassed (flat mode) — still navigable, de-emphasised.
|
|
177
|
+
*
|
|
178
|
+
* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
|
|
179
|
+
* disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
|
|
180
|
+
* that navigates — so nothing that already renders these moves. */
|
|
181
|
+
function FolderRow({ name, onClick, struck = false, depth = 0, expanded, onToggle, meta, onContextMenu, drag, selected }) {
|
|
182
|
+
const [over, setOver] = useState(false)
|
|
183
|
+
/* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
|
|
184
|
+
* row highlights only while something is actually over it, so an accidental hover reads as
|
|
185
|
+
* nothing. Without `drag` every handler below is undefined and the row is exactly what it was. */
|
|
186
|
+
const dropProps = drag ? {
|
|
187
|
+
draggable: true,
|
|
188
|
+
onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, drag.path) },
|
|
189
|
+
onDragOver: (e) => { if (drag.canDrop(drag.path)) { e.preventDefault(); setOver(true) } },
|
|
190
|
+
onDragLeave: () => setOver(false),
|
|
191
|
+
onDrop: (e) => { e.preventDefault(); e.stopPropagation(); setOver(false); drag.onDrop(drag.path) },
|
|
192
|
+
} : {}
|
|
176
193
|
return (
|
|
177
|
-
<li className=
|
|
194
|
+
<li onContextMenu={onContextMenu} {...dropProps} className={`flex items-center gap-3 py-2 border-b cursor-pointer transition-colors px-1 rounded ${over ? 'bg-fg-08' : selected ? 'bg-fg-04' : 'hover:bg-fg-04'}`} style={{ borderColor: 'var(--kol-fg-08)', paddingLeft: depth * 20 + 4 }} onClick={onClick}>
|
|
195
|
+
{onToggle ? (
|
|
196
|
+
<button type="button" aria-label={expanded ? `Collapse ${name}` : `Expand ${name}`} aria-expanded={!!expanded}
|
|
197
|
+
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
|
198
|
+
className="w-4 h-4 shrink-0 flex items-center justify-center text-fg-32 hover:text-fg-default transition-transform"
|
|
199
|
+
style={{ transform: expanded ? 'rotate(90deg)' : 'none' }}>
|
|
200
|
+
<Icon name="chevron-right" size={12} />
|
|
201
|
+
</button>
|
|
202
|
+
) : <span className="w-4 shrink-0" />}
|
|
178
203
|
<div className="w-8 h-8 shrink-0 flex items-center justify-center text-fg-48"><Icon name="folder" size={18} /></div>
|
|
179
|
-
<span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-fg-48' : 'text-fg-default'}`}>{name}</span>
|
|
180
|
-
<
|
|
204
|
+
<span className={`kol-mono-12 flex-1 min-w-0 truncate ${struck ? 'line-through text-fg-48' : 'text-fg-default'}`}>{name}</span>
|
|
205
|
+
{meta && <span className="kol-mono-12 text-fg-32 shrink-0">{meta}</span>}
|
|
206
|
+
<Icon name="chevron-right" size={14} className="text-fg-32 shrink-0" />
|
|
207
|
+
</li>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* One expanded level, drawn inline under its folder, recursing for anything expanded beneath it —
|
|
212
|
+
* the disclosure behaviour a list view is expected to have. Depth only indents; the data comes from
|
|
213
|
+
* the same `rowLevel` the top level uses, so an expanded branch cannot disagree with the columns. */
|
|
214
|
+
function RowSubtree({ level, depth, rowLevel, expanded, onToggle, onOpenFolder, onOpenFile, formatDate, folderMeta, thumbnailFor, flat, onRowContextMenu, dragFor }) {
|
|
215
|
+
const { folders, files } = rowLevel(level)
|
|
216
|
+
return (
|
|
217
|
+
<>
|
|
218
|
+
{folders.map((f) => {
|
|
219
|
+
const path = level + f
|
|
220
|
+
const open = expanded.has(path)
|
|
221
|
+
return (
|
|
222
|
+
<Fragment key={path}>
|
|
223
|
+
<FolderRow
|
|
224
|
+
name={f} depth={depth} struck={flat} expanded={open}
|
|
225
|
+
meta={folderMeta?.(path) || undefined}
|
|
226
|
+
onToggle={() => onToggle(path)}
|
|
227
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'folder', path })}
|
|
228
|
+
drag={dragFor?.(path)}
|
|
229
|
+
onClick={() => onOpenFolder(path)}
|
|
230
|
+
/>
|
|
231
|
+
{open && <RowSubtree
|
|
232
|
+
level={path} depth={depth + 1} rowLevel={rowLevel} expanded={expanded} onToggle={onToggle}
|
|
233
|
+
onOpenFolder={onOpenFolder} onOpenFile={onOpenFile} onRowContextMenu={onRowContextMenu} dragFor={dragFor}
|
|
234
|
+
formatDate={formatDate} folderMeta={folderMeta} thumbnailFor={thumbnailFor} flat={flat}
|
|
235
|
+
/>}
|
|
236
|
+
</Fragment>
|
|
237
|
+
)
|
|
238
|
+
})}
|
|
239
|
+
{files.map((o) => (
|
|
240
|
+
<FileRow key={o.key} o={o} depth={depth} formatDate={formatDate} thumb={thumbnailFor?.(o)}
|
|
241
|
+
onContextMenu={(e) => onRowContextMenu?.(e, { type: 'file', path: o.key, o })}
|
|
242
|
+
drag={dragFor?.(o.key)}
|
|
243
|
+
onClick={() => onOpenFile(o)} />
|
|
244
|
+
))}
|
|
245
|
+
</>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* The file half of the row view. Row view listed FOLDERS ONLY and dropped `files` on the floor, so
|
|
250
|
+
* `#img/` drew two rows where the column browser drew four — the two folders, the empty folder the
|
|
251
|
+
* columns knew about, and a file. A list view that cannot show a file is not a list view. */
|
|
252
|
+
function FileRow({ o, onClick, depth = 0, formatDate, thumb, onContextMenu, drag, selected }) {
|
|
253
|
+
const dragProps = drag ? { draggable: true, onDragStart: (e) => { e.stopPropagation(); drag.onDragStart(e, o.key) } } : {}
|
|
254
|
+
return (
|
|
255
|
+
<li onContextMenu={onContextMenu} {...dragProps} className={`flex items-center gap-3 py-2 border-b cursor-pointer transition-colors px-1 rounded ${selected ? 'bg-fg-04' : 'hover:bg-fg-04'}`} style={{ borderColor: 'var(--kol-fg-08)', paddingLeft: depth * 20 + 4 }} onClick={onClick}>
|
|
256
|
+
<span className="w-4 shrink-0" />
|
|
257
|
+
<div className="w-8 h-8 shrink-0 flex items-center justify-center text-fg-48 overflow-hidden rounded">
|
|
258
|
+
{thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={18} />}
|
|
259
|
+
</div>
|
|
260
|
+
<span className="kol-mono-12 flex-1 min-w-0 truncate text-fg-default">{o.displayKey ?? o.key.split('/').pop()}</span>
|
|
261
|
+
<span className="kol-mono-12 text-fg-32 shrink-0">{formatDate?.(o.uploaded)}</span>
|
|
262
|
+
<span className="kol-mono-12 text-fg-32 shrink-0 w-20 text-right">{formatSize(o.size)}</span>
|
|
181
263
|
</li>
|
|
182
264
|
)
|
|
183
265
|
}
|
|
@@ -316,18 +398,39 @@ function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profi
|
|
|
316
398
|
* and pushed the gear off the right edge — they resolved it by SWAPPING a
|
|
317
399
|
* control below `md` rather than adding one, which is the pattern to copy. If a
|
|
318
400
|
* consumer needs more than one at this width, it belongs in a sheet, not here. */
|
|
319
|
-
function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings }) {
|
|
401
|
+
function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings, onHome, headerTrailing }) {
|
|
320
402
|
const options = buckets.length ? [{ value: 'all', label: `${title} · all` }, ...buckets.map((b) => ({ value: b.id, label: b.label }))] : []
|
|
403
|
+
/* THE WORDMARK IS THE HOME BUTTON when `onHome` is given (user 2026-09-21: "it should reload,
|
|
404
|
+
* like a home button"). The crumb row's root already did exactly this and the wordmark above it
|
|
405
|
+
* did nothing — the one element every file manager makes clickable was the one that was inert.
|
|
406
|
+
* Without the prop it stays a plain <h1>, so nothing existing changes. */
|
|
321
407
|
return (
|
|
322
408
|
<header className="flex items-baseline justify-between gap-4">
|
|
323
|
-
|
|
409
|
+
{onHome ? (
|
|
410
|
+
<button type="button" onClick={onHome}
|
|
411
|
+
className="kol-sans-display-03 min-w-0 truncate text-left cursor-pointer transition-opacity hover:opacity-64"
|
|
412
|
+
aria-label={`${title} — back to the top`}>{title}</button>
|
|
413
|
+
) : (
|
|
414
|
+
<h1 className="kol-sans-display-03 min-w-0 truncate">{title}</h1>
|
|
415
|
+
)}
|
|
324
416
|
<div className="flex items-center gap-2 min-w-0">
|
|
325
417
|
{options.length > 0 && <Dropdown className="min-w-0 max-w-[45vw] md:w-48 md:max-w-none" value={appRoot ? 'all' : bucketId} onChange={onBucket} options={options} />}
|
|
326
418
|
{headerActions}
|
|
419
|
+
{/* The DS's own `Tooltip` (utilities/Popover → `.kol-tooltip`), not a `title`
|
|
420
|
+
attribute. These two were the only header chrome still handing the label to
|
|
421
|
+
the browser, which drew its native box beside DS-styled ones. */}
|
|
327
422
|
{!writable && bucketMeta.id && (
|
|
328
|
-
<
|
|
423
|
+
<Tooltip label={`${bucketMeta.label} is read-only here`}>
|
|
424
|
+
<IconFrame name="lock" variant="primary" size="sm" aria-label="Read-only" />
|
|
425
|
+
</Tooltip>
|
|
329
426
|
)}
|
|
330
|
-
<
|
|
427
|
+
<Tooltip label="Display settings">
|
|
428
|
+
<IconFrame name="settings-01" variant="primary" size="sm" onClick={onSettings} aria-label="Display settings" />
|
|
429
|
+
</Tooltip>
|
|
430
|
+
{/* AFTER the icons, because the crumb row below reads `[icons] │ ROW COLUMN` and a text
|
|
431
|
+
* switch in this surface sits to the RIGHT of its divider. Put before them it mirrored
|
|
432
|
+
* the row directly beneath it. */}
|
|
433
|
+
{headerTrailing}
|
|
331
434
|
</div>
|
|
332
435
|
</header>
|
|
333
436
|
)
|
|
@@ -370,7 +473,15 @@ function sortObjects(objects, sortBy = 'name', sortDir = 'asc') {
|
|
|
370
473
|
|
|
371
474
|
export function MediaLibraryBrowse({
|
|
372
475
|
client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
|
|
373
|
-
folderTree, headerActions, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '',
|
|
476
|
+
folderTree, headerActions, headerTrailing, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '', banner,
|
|
477
|
+
/* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
|
|
478
|
+
* optional and each async; whatever is supplied becomes a right-click menu entry and
|
|
479
|
+
* anything absent simply is not offered. One object rather than four props because they
|
|
480
|
+
* arrive together — a consumer with a writable store has all of them, one with a read-only
|
|
481
|
+
* bucket has none — and because the page must never have to know WHICH verbs exist to
|
|
482
|
+
* decide whether to draw a menu at all. The consumer re-lists after a mutation by bumping
|
|
483
|
+
* `refreshKey`; the page does not own the data. */
|
|
484
|
+
fileActions,
|
|
374
485
|
/* PASS-THROUGHS TO `ColumnBrowser`. A documented prop this page does not
|
|
375
486
|
* forward is a prop no consumer of the PAGE can reach, which makes the seam
|
|
376
487
|
* fictional — `thumbnailFor` and `folderMeta` shipped, were announced, and
|
|
@@ -400,6 +511,133 @@ export function MediaLibraryBrowse({
|
|
|
400
511
|
const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
|
|
401
512
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
402
513
|
const [pickedFile, setPickedFile] = useState(null)
|
|
514
|
+
/* Row view's inline disclosure set — folder paths currently expanded. Kept here rather than in
|
|
515
|
+
* settings: it is a transient reading state, not a preference, and Finder does not persist it
|
|
516
|
+
* across a relaunch either. */
|
|
517
|
+
const [expandedRows, setExpandedRows] = useState(() => new Set())
|
|
518
|
+
|
|
519
|
+
/* ── THE FILE VERBS ────────────────────────────────────────────────────────────────────────
|
|
520
|
+
* Right-click is the surface; `fileActions` is the implementation. The page owns neither the
|
|
521
|
+
* data nor the store — it collects an intent, asks for the one piece of text it needs, calls
|
|
522
|
+
* the consumer's verb and lets the consumer re-list. `prompt`/`confirm` are deliberate: this
|
|
523
|
+
* file already uses both for batch move and delete, and inventing a dialog here would be a
|
|
524
|
+
* second modal idiom in one component. */
|
|
525
|
+
const menu = useContextMenu()
|
|
526
|
+
const canWrite = !!fileActions && writable
|
|
527
|
+
const [busyAction, setBusyAction] = useState(false)
|
|
528
|
+
|
|
529
|
+
const runAction = async (fn) => {
|
|
530
|
+
if (!fn) return
|
|
531
|
+
setBusyAction(true)
|
|
532
|
+
try { await fn() } catch (e) { alert(e.message) } finally { setBusyAction(false) }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const doCreateFolder = (parent) => {
|
|
536
|
+
const name = prompt(`New folder in ${parent || 'the bucket root'}:`)
|
|
537
|
+
if (!name?.trim()) return
|
|
538
|
+
runAction(() => fileActions.createFolder(`${parent}${name.trim().replace(/^\/+|\/+$/g, '')}/`))
|
|
539
|
+
}
|
|
540
|
+
const doCreateFile = (parent) => {
|
|
541
|
+
const name = prompt(`New file in ${parent || 'the bucket root'}:`, 'untitled.txt')
|
|
542
|
+
if (!name?.trim()) return
|
|
543
|
+
runAction(() => fileActions.createFile(`${parent}${name.trim().replace(/^\/+/, '')}`))
|
|
544
|
+
}
|
|
545
|
+
const doRename = (path, isFolder) => {
|
|
546
|
+
const current = path.replace(/\/$/, '').split('/').pop()
|
|
547
|
+
const name = prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current)
|
|
548
|
+
if (!name?.trim() || name === current) return
|
|
549
|
+
const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
|
|
550
|
+
runAction(() => fileActions.rename(path, `${parent}${name.trim()}${isFolder ? '/' : ''}`))
|
|
551
|
+
}
|
|
552
|
+
const doMove = (path) => {
|
|
553
|
+
const dest = prompt(`Move into which folder? (blank = bucket root)`, prefix)
|
|
554
|
+
if (dest === null) return
|
|
555
|
+
const clean = dest ? `${dest.replace(/^\/+|\/+$/g, '')}/` : ''
|
|
556
|
+
runAction(() => fileActions.move(path, clean))
|
|
557
|
+
}
|
|
558
|
+
/* DRAG TO MOVE. HTML5 drag-and-drop, not a pointer-tracking implementation: the rows are a list,
|
|
559
|
+
* the targets are folders, and the browser already does the hit-testing and the cursor. The
|
|
560
|
+
* dragged path lives in a ref rather than dataTransfer alone so `canDrop` can answer without
|
|
561
|
+
* reading the event — a folder may not be dropped into itself or into its own subtree, which is
|
|
562
|
+
* the one rule that makes this safe to wire straight to `move`. */
|
|
563
|
+
/* MULTI-SELECT in the browse views. ⌘/Ctrl-click adds one, ⇧-click takes the run between the
|
|
564
|
+
* anchor and the click, a plain click clears back to navigation. The wall has had its own SELECT
|
|
565
|
+
* mode since 0.119.0; this is the browse half, and it deliberately does NOT reuse that mode's
|
|
566
|
+
* chrome — there the wall IS the surface and a mode bar fits, here selection is a modifier on a
|
|
567
|
+
* tree you are still walking. The menu acts on the whole set when the clicked row is in it. */
|
|
568
|
+
const [rowSelection, setRowSelection] = useState(() => new Set())
|
|
569
|
+
const selectAnchorRef = useRef(null)
|
|
570
|
+
|
|
571
|
+
const selectRow = (path, e, orderedPaths) => {
|
|
572
|
+
const additive = e.metaKey || e.ctrlKey
|
|
573
|
+
const ranged = e.shiftKey && selectAnchorRef.current
|
|
574
|
+
if (!additive && !ranged) { setRowSelection(new Set()); selectAnchorRef.current = path; return false }
|
|
575
|
+
e.preventDefault()
|
|
576
|
+
setRowSelection((prev) => {
|
|
577
|
+
const next = new Set(prev)
|
|
578
|
+
if (ranged) {
|
|
579
|
+
const a = orderedPaths.indexOf(selectAnchorRef.current)
|
|
580
|
+
const b = orderedPaths.indexOf(path)
|
|
581
|
+
if (a !== -1 && b !== -1) for (let i = Math.min(a, b); i <= Math.max(a, b); i++) next.add(orderedPaths[i])
|
|
582
|
+
} else {
|
|
583
|
+
next.has(path) ? next.delete(path) : next.add(path)
|
|
584
|
+
selectAnchorRef.current = path
|
|
585
|
+
}
|
|
586
|
+
return next
|
|
587
|
+
})
|
|
588
|
+
return true
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/* The set the menu acts on: the selection when you right-clicked something inside it, otherwise
|
|
592
|
+
* just the row you hit — which is what every file manager does and what stops a right-click on an
|
|
593
|
+
* unrelated row from silently deleting five things. */
|
|
594
|
+
const targetsFor = (path) => (rowSelection.has(path) ? [...rowSelection] : [path])
|
|
595
|
+
|
|
596
|
+
const doBatch = (paths, verb, label) => {
|
|
597
|
+
if (paths.length === 1) return null
|
|
598
|
+
if (label === 'Delete' && !confirm(`Delete ${paths.length} items? This cannot be undone.`)) return null
|
|
599
|
+
let dest = null
|
|
600
|
+
if (label === 'Move') {
|
|
601
|
+
dest = prompt(`Move ${paths.length} items into which folder? (blank = bucket root)`, prefix)
|
|
602
|
+
if (dest === null) return null
|
|
603
|
+
dest = dest ? `${dest.replace(/^\/+|\/+$/g, '')}/` : ''
|
|
604
|
+
}
|
|
605
|
+
runAction(async () => {
|
|
606
|
+
for (const p of paths) {
|
|
607
|
+
if (label === 'Delete') await fileActions.remove(p)
|
|
608
|
+
else await fileActions.move(p, dest)
|
|
609
|
+
}
|
|
610
|
+
setRowSelection(new Set())
|
|
611
|
+
})
|
|
612
|
+
void verb
|
|
613
|
+
return true
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const draggingRef = useRef(null)
|
|
617
|
+
const dragFor = canWrite && fileActions.move ? (path) => ({
|
|
618
|
+
path,
|
|
619
|
+
onDragStart: (e, from) => { draggingRef.current = from; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', from) },
|
|
620
|
+
canDrop: (dest) => {
|
|
621
|
+
const from = draggingRef.current
|
|
622
|
+
if (!from || from === dest) return false
|
|
623
|
+
if (from.endsWith('/') && dest.startsWith(from)) return false // into itself
|
|
624
|
+
const parent = from.replace(/\/$/, '').split('/').slice(0, -1).join('/')
|
|
625
|
+
return dest !== (parent ? `${parent}/` : '') // already there
|
|
626
|
+
},
|
|
627
|
+
onDrop: (dest) => {
|
|
628
|
+
const from = draggingRef.current
|
|
629
|
+
draggingRef.current = null
|
|
630
|
+
if (!from) return
|
|
631
|
+
const name = from.replace(/\/$/, '').split('/').pop()
|
|
632
|
+
runAction(() => fileActions.move(from, dest))
|
|
633
|
+
void name
|
|
634
|
+
},
|
|
635
|
+
}) : null
|
|
636
|
+
|
|
637
|
+
const doDelete = (path, isFolder) => {
|
|
638
|
+
if (!confirm(`Delete ${isFolder ? `"${path}" and everything in it` : `"${path}"`}? This cannot be undone.`)) return
|
|
639
|
+
runAction(() => fileActions.remove(path))
|
|
640
|
+
}
|
|
403
641
|
const [quickLook, setQuickLook] = useState(null)
|
|
404
642
|
const columnsRef = useRef(null)
|
|
405
643
|
const { folderView = 'columns', flat } = settings
|
|
@@ -421,8 +659,17 @@ export function MediaLibraryBrowse({
|
|
|
421
659
|
[searched, settings.sortBy, settings.sortDir],
|
|
422
660
|
)
|
|
423
661
|
|
|
662
|
+
const treeFolders = (level) => (folderTree?.[bucketMeta.id]?.folders ?? [])
|
|
663
|
+
.filter((p) => p.startsWith(level) && p.length > level.length && !p.slice(level.length, -1).includes('/'))
|
|
664
|
+
.map((p) => p.slice(level.length))
|
|
665
|
+
|
|
424
666
|
const scoped = prefix ? sortedObjects.filter((o) => o.key.startsWith(prefix)) : sortedObjects
|
|
425
|
-
const { folders, files: dirFiles } = partition(scoped, prefix)
|
|
667
|
+
const { folders: keyFolders, files: dirFiles } = partition(scoped, prefix)
|
|
668
|
+
/* THE COUNT COUNTS WHAT IS ON SCREEN. `partition` derives folders from file keys, so an EMPTY
|
|
669
|
+
* folder is invisible to it — the stats line said "2 folders" under a list showing three, the
|
|
670
|
+
* third being `03-scratch/`, which exists in `folderTree` and nowhere else. Both views draw the
|
|
671
|
+
* merged set now, so the tally is taken from the same place. */
|
|
672
|
+
const folders = [...new Set([...treeFolders(prefix), ...keyFolders])].sort()
|
|
426
673
|
const keySet = new Set(objects.map((o) => o.key))
|
|
427
674
|
const levelFiles = flat ? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key })) : dirFiles
|
|
428
675
|
const systemCount = levelFiles.filter((o) => isSystemFile(o.key)).length
|
|
@@ -446,9 +693,24 @@ export function MediaLibraryBrowse({
|
|
|
446
693
|
const ROOT = title
|
|
447
694
|
const label = bucketMeta.label || 'bucket'
|
|
448
695
|
const VROOT = single ? '' : `${ROOT}/${label}/`
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
696
|
+
const unroot = (p) => (VROOT && p.startsWith(VROOT) ? p.slice(VROOT.length) : p)
|
|
697
|
+
/* ONE LEVEL, ONE ANSWER. The column view merges the baked tree with the live partition inside its
|
|
698
|
+
* `partition` prop; the row view used the live partition alone and therefore disagreed with the
|
|
699
|
+
* columns about what a folder even is. This is that merge, lifted so both callers share it —
|
|
700
|
+
* folders from `folderTree` (real nodes, empty ones included) unioned with the ones derived from
|
|
701
|
+
* keys, plus the level's own files, which the row view never rendered at all. */
|
|
702
|
+
const rowLevel = (level) => {
|
|
703
|
+
/* `partition` slices every key by `prefix` WITHOUT checking it matches, so it must be handed an
|
|
704
|
+
* already-scoped list. Given the whole bucket it cut four characters off every root key and
|
|
705
|
+
* drew `readme.md` as `me.md` and `video/` as `o/`. Scope first, always. */
|
|
706
|
+
const inLevel = level ? objects.filter((o) => o.key.startsWith(level)) : objects
|
|
707
|
+
const live = partition(inLevel, level)
|
|
708
|
+
const baked = treeFolders(level)
|
|
709
|
+
return {
|
|
710
|
+
folders: [...new Set([...baked, ...live.folders])].sort(),
|
|
711
|
+
files: sortObjects(live.files.filter((o) => !isSystemFile(o.key)), settings.sortBy, settings.sortDir),
|
|
712
|
+
}
|
|
713
|
+
}
|
|
452
714
|
|
|
453
715
|
useEffect(() => {
|
|
454
716
|
if (folderView !== 'columns') return undefined
|
|
@@ -463,10 +725,19 @@ export function MediaLibraryBrowse({
|
|
|
463
725
|
|
|
464
726
|
return (
|
|
465
727
|
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
466
|
-
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions}
|
|
728
|
+
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing}
|
|
729
|
+
onHome={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}
|
|
467
730
|
onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
|
|
468
731
|
onSettings={() => setSettingsOpen(true)} />
|
|
469
732
|
|
|
733
|
+
{/* THE BANNER SLOT — anything that must sit directly under the header and above the body.
|
|
734
|
+
* The upload drop zone is why it exists: a consumer rendering it AFTER the page put it
|
|
735
|
+
* below an 800px column browser, so pressing Upload scrolled you past the whole browser to
|
|
736
|
+
* reach the target you had just asked for. A slot is the fix; telling the consumer to
|
|
737
|
+
* re-order is not, because the header is ours and the body is ours and there was no
|
|
738
|
+
* between. */}
|
|
739
|
+
{banner}
|
|
740
|
+
|
|
470
741
|
<div className="flex flex-col gap-3">
|
|
471
742
|
{/* Breadcrumb (uppercase, active segment at full ink) · folder-view toggle
|
|
472
743
|
*
|
|
@@ -646,6 +917,13 @@ export function MediaLibraryBrowse({
|
|
|
646
917
|
const poster = posterFor(real.key, keySet)
|
|
647
918
|
return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
648
919
|
}}
|
|
920
|
+
/* THE COLUMNS SPEAK IN VIRTUAL PATHS. With more than one bucket every level is
|
|
921
|
+
* prefixed `<title>/<bucket>/` so the stores share one tree — so a path coming OUT
|
|
922
|
+
* of the browser must be un-rooted before it reaches a file verb, or the store is
|
|
923
|
+
* asked to move `MEDIA/R2 · kol-media/img/x.jpg`, which it has never heard of. The
|
|
924
|
+
* row view needs none of this; it is already bucket-relative. */
|
|
925
|
+
onRowContextMenu={(e, payload) => menu.openAt(e, { ...payload, path: unroot(payload.path) })}
|
|
926
|
+
dragFor={dragFor ? (path) => dragFor(unroot(path)) : undefined}
|
|
649
927
|
partition={(objs, level) => {
|
|
650
928
|
if (!single) {
|
|
651
929
|
if (level === '') return { folders: [`${ROOT}/`], files: [] }
|
|
@@ -657,16 +935,90 @@ export function MediaLibraryBrowse({
|
|
|
657
935
|
}}
|
|
658
936
|
/>
|
|
659
937
|
</div>
|
|
660
|
-
) :
|
|
661
|
-
|
|
938
|
+
) : (
|
|
939
|
+
/* ROW VIEW — the same level the column view draws, not a different one. It used to render
|
|
940
|
+
* `partition(scoped, prefix).folders` and nothing else, which lost two things: an EMPTY
|
|
941
|
+
* folder (partition derives folders from file keys, so a folder with no files under it
|
|
942
|
+
* does not exist to it — `folderTree` is where real nodes live, and the column view was
|
|
943
|
+
* already merging the two) and every FILE at the level. `#img/` drew 2 rows against the
|
|
944
|
+
* columns' 4. Folders first, then files, each expandable in place like a list view. */
|
|
945
|
+
/* SAME AREA AS THE COLUMNS. The row view used to be content-height, so the two folder
|
|
946
|
+
* views made the page jump between them and the blank space below the last row — where
|
|
947
|
+
* you right-click for "new folder" and drop to file something here — did not exist. It
|
|
948
|
+
* takes the column browser's own height now, from the same setting.
|
|
949
|
+
*
|
|
950
|
+
* The list IS the current folder, so it is a drop target for `prefix`, matching a column
|
|
951
|
+
* being a target for its level. Highlight via a data attribute for the same reason. */
|
|
952
|
+
<ul className="flex flex-col overflow-y-auto kol-row-browser"
|
|
953
|
+
style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}
|
|
954
|
+
onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
955
|
+
onDragOver={(e) => {
|
|
956
|
+
const d = dragFor?.(prefix)
|
|
957
|
+
if (!d?.canDrop(prefix)) return
|
|
958
|
+
e.preventDefault()
|
|
959
|
+
e.currentTarget.dataset.dropOver = '1'
|
|
960
|
+
}}
|
|
961
|
+
onDragLeave={(e) => { if (e.currentTarget === e.target) delete e.currentTarget.dataset.dropOver }}
|
|
962
|
+
onDrop={(e) => {
|
|
963
|
+
delete e.currentTarget.dataset.dropOver
|
|
964
|
+
const d = dragFor?.(prefix)
|
|
965
|
+
if (!d?.canDrop(prefix)) return
|
|
966
|
+
e.preventDefault()
|
|
967
|
+
d.onDrop(prefix)
|
|
968
|
+
}}>
|
|
969
|
+
{rowLevel(prefix).folders.map((f) => {
|
|
970
|
+
const path = prefix + f
|
|
971
|
+
const open = expandedRows.has(path)
|
|
972
|
+
const order = [...rowLevel(prefix).folders.map((x) => prefix + x), ...rowLevel(prefix).files.map((x) => x.key)]
|
|
973
|
+
return (
|
|
974
|
+
<Fragment key={f}>
|
|
975
|
+
<FolderRow
|
|
976
|
+
name={f} struck={flat} expanded={open}
|
|
977
|
+
selected={rowSelection.has(path)}
|
|
978
|
+
onContextMenu={(e) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
|
|
979
|
+
drag={dragFor?.(path)}
|
|
980
|
+
meta={folderMeta?.(path) || undefined}
|
|
981
|
+
onToggle={() => setExpandedRows((prev) => {
|
|
982
|
+
const next = new Set(prev)
|
|
983
|
+
next.has(path) ? next.delete(path) : next.add(path)
|
|
984
|
+
return next
|
|
985
|
+
})}
|
|
986
|
+
onClick={(e) => { if (!selectRow(path, e, order)) setPrefix(path) }}
|
|
987
|
+
/>
|
|
988
|
+
{open && <RowSubtree
|
|
989
|
+
level={path} depth={1} rowLevel={rowLevel} expanded={expandedRows}
|
|
990
|
+
onToggle={(p) => setExpandedRows((prev) => { const n = new Set(prev); n.has(p) ? n.delete(p) : n.add(p); return n })}
|
|
991
|
+
onOpenFolder={setPrefix} onOpenFile={(o) => setPickedFile(o)}
|
|
992
|
+
onRowContextMenu={(e, payload) => menu.openAt(e, payload)} dragFor={dragFor}
|
|
993
|
+
formatDate={formatDate} folderMeta={folderMeta} thumbnailFor={thumbnailFor} flat={flat}
|
|
994
|
+
/>}
|
|
995
|
+
</Fragment>
|
|
996
|
+
)
|
|
997
|
+
})}
|
|
998
|
+
{rowLevel(prefix).files.map((o) => (
|
|
999
|
+
<FileRow key={o.key} o={o} formatDate={formatDate} thumb={thumbnailFor?.(o)}
|
|
1000
|
+
selected={rowSelection.has(o.key)}
|
|
1001
|
+
onContextMenu={(e) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })}
|
|
1002
|
+
drag={dragFor?.(o.key)}
|
|
1003
|
+
onClick={(e) => {
|
|
1004
|
+
const order = [...rowLevel(prefix).folders.map((x) => prefix + x), ...rowLevel(prefix).files.map((x) => x.key)]
|
|
1005
|
+
if (!selectRow(o.key, e, order)) setPickedFile(o)
|
|
1006
|
+
}} />
|
|
1007
|
+
))}
|
|
1008
|
+
</ul>
|
|
662
1009
|
)}
|
|
663
1010
|
|
|
1011
|
+
{/* `mt-2` BALANCES THE INK, not the boxes (user 2026-09-21: "what was unclear about matching
|
|
1012
|
+
* the gap above to below"). The column pane, the crumb row above it and this line share one
|
|
1013
|
+
* `gap-3`, so box-to-box both gaps are 12px — but the crumb row's box runs 8px lower than
|
|
1014
|
+
* its own text, because the ROW·COLUMN icon buttons are taller than the crumbs. Measured
|
|
1015
|
+
* against the pane's BORDER: 20px above, 12px below. The 8 puts the text gaps level. */}
|
|
664
1016
|
{appRoot && folderView === 'columns' && folderTree ? (
|
|
665
|
-
<p className="kol-mono-12 text-fg-48">
|
|
1017
|
+
<p className="kol-mono-12 text-fg-48 mt-2">
|
|
666
1018
|
{buckets.length} buckets · {Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · {formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}
|
|
667
1019
|
</p>
|
|
668
1020
|
) : (
|
|
669
|
-
<p className="kol-mono-12 text-fg-48">
|
|
1021
|
+
<p className="kol-mono-12 text-fg-48 mt-2">
|
|
670
1022
|
{folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
|
|
671
1023
|
{rawFiles.length} {rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
|
|
672
1024
|
{(!prefix || flat) && rawFiles.length !== bucketFiles && <span className="text-fg-32">{' · bucket: '}{bucketFiles} files · {formatSize(bucketBytes)}</span>}
|
|
@@ -675,6 +1027,65 @@ export function MediaLibraryBrowse({
|
|
|
675
1027
|
)}
|
|
676
1028
|
{void onOpen}
|
|
677
1029
|
|
|
1030
|
+
{/* ONE MENU FOR EVERY ROW. `useContextMenu` carries the payload from whichever row opened
|
|
1031
|
+
* it, so this is a single instance rather than one per row, and the entries are built from
|
|
1032
|
+
* what `fileActions` actually supplies — a read-only bucket gets no menu at all. */}
|
|
1033
|
+
{canWrite && (
|
|
1034
|
+
<ContextMenu menu={menu}>
|
|
1035
|
+
{(target) => {
|
|
1036
|
+
if (!target) return null
|
|
1037
|
+
const isFolder = target.type === 'folder'
|
|
1038
|
+
const isLevel = target.type === 'level'
|
|
1039
|
+
/* A SELECTION OF MORE THAN ONE takes over the verbs that can act on a set. Rename is
|
|
1040
|
+
* not one of them — renaming five things to one name is not a thing — so it stays
|
|
1041
|
+
* single and disappears from a multi-selection rather than lying about what it does. */
|
|
1042
|
+
const many = (target.targets ?? []).length > 1 ? target.targets : null
|
|
1043
|
+
if (many) {
|
|
1044
|
+
return (
|
|
1045
|
+
<>
|
|
1046
|
+
<MenuDropdownItem disabled>{many.length} selected</MenuDropdownItem>
|
|
1047
|
+
<MenuDropdownDivider />
|
|
1048
|
+
{fileActions.move && (
|
|
1049
|
+
<MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
|
|
1050
|
+
)}
|
|
1051
|
+
{fileActions.remove && (
|
|
1052
|
+
<MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doBatch(many, 'remove', 'Delete')}>Delete {many.length}</MenuDropdownItem>
|
|
1053
|
+
)}
|
|
1054
|
+
</>
|
|
1055
|
+
)
|
|
1056
|
+
}
|
|
1057
|
+
return (
|
|
1058
|
+
<>
|
|
1059
|
+
{fileActions.createFolder && (
|
|
1060
|
+
<MenuDropdownItem iconLeft={<Icon name="folder" size={14} />} onClick={() => doCreateFolder(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
1061
|
+
New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
|
|
1062
|
+
</MenuDropdownItem>
|
|
1063
|
+
)}
|
|
1064
|
+
{fileActions.createFile && (
|
|
1065
|
+
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
1066
|
+
New file
|
|
1067
|
+
</MenuDropdownItem>
|
|
1068
|
+
)}
|
|
1069
|
+
{!isLevel && <MenuDropdownDivider />}
|
|
1070
|
+
{!isLevel && fileActions.rename && (
|
|
1071
|
+
<MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => doRename(target.path, isFolder)}>Rename</MenuDropdownItem>
|
|
1072
|
+
)}
|
|
1073
|
+
{!isLevel && fileActions.move && (
|
|
1074
|
+
<MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doMove(target.path)}>Move to…</MenuDropdownItem>
|
|
1075
|
+
)}
|
|
1076
|
+
{!isLevel && fileActions.remove && (
|
|
1077
|
+
<>
|
|
1078
|
+
<MenuDropdownDivider />
|
|
1079
|
+
<MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doDelete(target.path, isFolder)}>Delete</MenuDropdownItem>
|
|
1080
|
+
</>
|
|
1081
|
+
)}
|
|
1082
|
+
</>
|
|
1083
|
+
)
|
|
1084
|
+
}}
|
|
1085
|
+
</ContextMenu>
|
|
1086
|
+
)}
|
|
1087
|
+
{busyAction && <span className="sr-only" role="status">Working…</span>}
|
|
1088
|
+
|
|
678
1089
|
{/* THE TAB PILL (item 16) — floats over the list, so the list owes it
|
|
679
1090
|
room or its last row sits under the bar forever.
|
|
680
1091
|
THE SPACER GOES LAST, AND THAT IS THE WHOLE POINT
|
|
@@ -700,7 +1111,7 @@ export function MediaLibraryBrowse({
|
|
|
700
1111
|
/* ══ LIBRARY — the content-filters wall ═══════════════════════════════════ */
|
|
701
1112
|
export function MediaLibraryLibrary({
|
|
702
1113
|
client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
|
|
703
|
-
headerActions, refreshKey, header = true, stats = true, settingsFooter, className = '',
|
|
1114
|
+
headerActions, headerTrailing, refreshKey, header = true, stats = true, settingsFooter, className = '', banner,
|
|
704
1115
|
/* the same pill the browse page takes — the wall is one of the surfaces it
|
|
705
1116
|
switches between, so it has to carry it too (item 16) */
|
|
706
1117
|
tabs, activeTab, onTabChange,
|
|
@@ -857,16 +1268,32 @@ export function MediaLibraryLibrary({
|
|
|
857
1268
|
}
|
|
858
1269
|
return o.displayKey.split('/').pop()
|
|
859
1270
|
}
|
|
1271
|
+
/* ONE PREVIEWER (2026-09-21). This was a hand-rolled ladder that knew image, a sibling poster
|
|
1272
|
+
* and an autoloaded video, and printed the literal word "video" or "markdown" for everything
|
|
1273
|
+
* else — while the column preview eight hundred lines up already called `KindPreview`, which
|
|
1274
|
+
* renders markdown, code, json, yaml, audio and HLS for real. That is why every kind previewed
|
|
1275
|
+
* in the column pane and nothing previewed in the grid or the list: two previewers for one
|
|
1276
|
+
* object, and the wall's predates the component.
|
|
1277
|
+
*
|
|
1278
|
+
* Images stay the caller's, as they are in the column pane and as `KindPreview` documents —
|
|
1279
|
+
* it has no image branch on purpose, because in a frame the frame draws its own. A video keeps
|
|
1280
|
+
* its poster when the bucket has a sibling still, because a poster is cheaper than the file and
|
|
1281
|
+
* `videoPreview` is the consumer's setting about exactly that. Everything else falls through. */
|
|
860
1282
|
const renderThumb = (o, onClick) => {
|
|
861
|
-
const
|
|
862
|
-
const poster = (isVideo(o.contentType) ||
|
|
1283
|
+
const kind = kindOf(o)
|
|
1284
|
+
const poster = (isVideo(o.contentType) || kind === 'playlist') ? posterFor(o.key, keySet) : null
|
|
863
1285
|
const imgSrc = isImage(o.contentType) ? mediaUrl(o.key) : poster && videoPreview !== 'none' ? mediaUrl(poster) : null
|
|
864
1286
|
if (imgSrc) return <img src={imgSrc} alt="" loading="lazy" className={onClick ? 'cursor-zoom-in' : undefined} onClick={onClick || undefined} />
|
|
1287
|
+
if (isVideo(o.contentType) && videoPreview === 'none') {
|
|
1288
|
+
return (
|
|
1289
|
+
<div className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`} onClick={onClick || undefined}>
|
|
1290
|
+
<span className="kol-mono-12 text-fg-48">video</span>
|
|
1291
|
+
</div>
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
865
1294
|
return (
|
|
866
|
-
<div className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${
|
|
867
|
-
{
|
|
868
|
-
: isVideo(o.contentType) ? <span className="kol-mono-12 text-fg-48">video</span>
|
|
869
|
-
: <span className="kol-mono-12 text-fg-48">{KIND_LABEL[kindOf(o)] || 'file'}{o.segmentCount ? ` ${o.segmentCount}` : ''}</span>}
|
|
1295
|
+
<div className={`kol-media-thumb w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${onClick ? ' cursor-zoom-in' : ''}`} onClick={onClick || undefined}>
|
|
1296
|
+
<KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
870
1297
|
</div>
|
|
871
1298
|
)
|
|
872
1299
|
}
|
|
@@ -879,9 +1306,12 @@ export function MediaLibraryLibrary({
|
|
|
879
1306
|
return (
|
|
880
1307
|
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
881
1308
|
{header && (
|
|
882
|
-
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={false} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions}
|
|
1309
|
+
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={false} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing}
|
|
883
1310
|
onBucket={(v) => { if (v === 'all') return; if (bucket == null) setOwnBucket(v); onBucketChange?.(v) }} onSettings={() => setSettingsOpen(true)} />
|
|
884
1311
|
)}
|
|
1312
|
+
{/* A SEAM ON ONE PAGE IS A SEAM ON BOTH — `banner` is shared chrome, so it lands here in the
|
|
1313
|
+
* same edit it landed on the browse page. See the note on `formatDate` above. */}
|
|
1314
|
+
{banner}
|
|
885
1315
|
{settingsOpen && (
|
|
886
1316
|
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
887
1317
|
)}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import { usePopover, PopoverPanel } from './Popover.jsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ContextMenu — a right-click menu, anchored at the pointer.
|
|
6
|
+
*
|
|
7
|
+
* `onContextMenu` appeared NOWHERE in this package before 2026-09-21, which is
|
|
8
|
+
* why every file-manager verb the media product needed (new folder, rename,
|
|
9
|
+
* move, delete) had no surface to be invoked from: the operations existed on
|
|
10
|
+
* the client and nothing could reach them. This is that surface.
|
|
11
|
+
*
|
|
12
|
+
* Built on `usePopover`'s `referenceElement` seam rather than a new floating
|
|
13
|
+
* implementation — a context menu is a popover whose anchor is a point instead
|
|
14
|
+
* of an element, and floating-ui takes a virtual element for exactly this. So
|
|
15
|
+
* it inherits flip, shift, the portal, dismissal and the `.kol-popover` chrome,
|
|
16
|
+
* and a menu near the viewport edge behaves like every other panel.
|
|
17
|
+
*
|
|
18
|
+
* const menu = useContextMenu()
|
|
19
|
+
* <li onContextMenu={(e) => menu.openAt(e, row)}>…</li>
|
|
20
|
+
* <ContextMenu menu={menu}>
|
|
21
|
+
* {(row) => <MenuDropdownItem onClick={() => rename(row)}>Rename</MenuDropdownItem>}
|
|
22
|
+
* </ContextMenu>
|
|
23
|
+
*
|
|
24
|
+
* `children` may be a render function taking the payload handed to `openAt`,
|
|
25
|
+
* so one menu instance serves a whole list instead of one per row.
|
|
26
|
+
*/
|
|
27
|
+
export function useContextMenu() {
|
|
28
|
+
const [state, setState] = useState(null) // { x, y, payload } | null
|
|
29
|
+
|
|
30
|
+
const openAt = useCallback((event, payload = null) => {
|
|
31
|
+
event.preventDefault()
|
|
32
|
+
event.stopPropagation()
|
|
33
|
+
setState({ x: event.clientX, y: event.clientY, payload })
|
|
34
|
+
}, [])
|
|
35
|
+
|
|
36
|
+
const close = useCallback(() => setState(null), [])
|
|
37
|
+
|
|
38
|
+
const popover = usePopover({
|
|
39
|
+
open: !!state,
|
|
40
|
+
onOpenChange: (v) => { if (!v) close() },
|
|
41
|
+
placement: 'right-start',
|
|
42
|
+
offset: 2,
|
|
43
|
+
click: false,
|
|
44
|
+
role: 'menu',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/* A VIRTUAL ELEMENT — a zero-size rect at the pointer. It goes through
|
|
48
|
+
* `setPositionReference`, NOT the `elements.reference` option: floating-ui
|
|
49
|
+
* rejects a virtual element there ("must be a real DOM element") because that
|
|
50
|
+
* slot also feeds the interaction hooks, which call `getAttribute` on it.
|
|
51
|
+
* `setPositionReference` is the seam for exactly this — position from a point,
|
|
52
|
+
* interactions from nothing. */
|
|
53
|
+
const { setPositionReference } = popover.refs
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!state) return
|
|
56
|
+
const { x, y } = state
|
|
57
|
+
setPositionReference({
|
|
58
|
+
getBoundingClientRect: () => ({ width: 0, height: 0, x, y, top: y, left: x, right: x, bottom: y }),
|
|
59
|
+
})
|
|
60
|
+
}, [state, setPositionReference])
|
|
61
|
+
|
|
62
|
+
return { open: !!state, payload: state?.payload ?? null, openAt, close, popover }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default function ContextMenu({ menu, children, className = '' }) {
|
|
66
|
+
if (!menu.open) return null
|
|
67
|
+
return (
|
|
68
|
+
/* `focus={false}` — FloatingFocusManager reads attributes off the reference,
|
|
69
|
+
* and a point has none. Dismissal is outside-click and Escape, which the
|
|
70
|
+
* popover already wires. */
|
|
71
|
+
<PopoverPanel popover={menu.popover} focus={false} className={`kol-dd-list min-w-44 ${className}`.trim()}>
|
|
72
|
+
{/* Any click inside closes — a menu item that leaves the menu open after
|
|
73
|
+
* acting is the one interaction people report as broken. Items keep their
|
|
74
|
+
* own onClick; this runs after it on the way up. */}
|
|
75
|
+
<div onClick={menu.close} role="none">
|
|
76
|
+
{typeof children === 'function' ? children(menu.payload) : children}
|
|
77
|
+
</div>
|
|
78
|
+
</PopoverPanel>
|
|
79
|
+
)
|
|
80
|
+
}
|