@kolkrabbi/kol-component 0.214.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 +480 -30
- 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'
|
|
@@ -99,7 +101,10 @@ const SORT_OPTIONS = [
|
|
|
99
101
|
]
|
|
100
102
|
|
|
101
103
|
/* bytes → weight: `utilities/formatSize` since 0.178.0 — one copy, exported */
|
|
102
|
-
|
|
104
|
+
/* the pages' shared date default. Named `default*` because BOTH pages now take
|
|
105
|
+
* a `formatDate` prop, and a prop of the same name would shadow this into a TDZ
|
|
106
|
+
* error at the destructure. */
|
|
107
|
+
const defaultFormatDate = (iso) => (iso ? new Date(iso).toISOString().slice(0, 10) : '')
|
|
103
108
|
const isImage = (ct) => !!ct && ct.startsWith('image/')
|
|
104
109
|
const isVideo = (ct) => !!ct && ct.startsWith('video/')
|
|
105
110
|
|
|
@@ -168,13 +173,93 @@ function ImageFrame({ src }) {
|
|
|
168
173
|
)
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
/* `struck` = folder grouping is bypassed (flat mode) — still navigable, de-emphasised.
|
|
172
|
-
|
|
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
|
+
} : {}
|
|
173
193
|
return (
|
|
174
|
-
<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" />}
|
|
175
203
|
<div className="w-8 h-8 shrink-0 flex items-center justify-center text-fg-48"><Icon name="folder" size={18} /></div>
|
|
176
|
-
<span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-fg-48' : 'text-fg-default'}`}>{name}</span>
|
|
177
|
-
<
|
|
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>
|
|
178
263
|
</li>
|
|
179
264
|
)
|
|
180
265
|
}
|
|
@@ -313,18 +398,39 @@ function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profi
|
|
|
313
398
|
* and pushed the gear off the right edge — they resolved it by SWAPPING a
|
|
314
399
|
* control below `md` rather than adding one, which is the pattern to copy. If a
|
|
315
400
|
* consumer needs more than one at this width, it belongs in a sheet, not here. */
|
|
316
|
-
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 }) {
|
|
317
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. */
|
|
318
407
|
return (
|
|
319
408
|
<header className="flex items-baseline justify-between gap-4">
|
|
320
|
-
|
|
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
|
+
)}
|
|
321
416
|
<div className="flex items-center gap-2 min-w-0">
|
|
322
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} />}
|
|
323
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. */}
|
|
324
422
|
{!writable && bucketMeta.id && (
|
|
325
|
-
<
|
|
423
|
+
<Tooltip label={`${bucketMeta.label} is read-only here`}>
|
|
424
|
+
<IconFrame name="lock" variant="primary" size="sm" aria-label="Read-only" />
|
|
425
|
+
</Tooltip>
|
|
326
426
|
)}
|
|
327
|
-
<
|
|
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}
|
|
328
434
|
</div>
|
|
329
435
|
</header>
|
|
330
436
|
)
|
|
@@ -367,7 +473,15 @@ function sortObjects(objects, sortBy = 'name', sortDir = 'asc') {
|
|
|
367
473
|
|
|
368
474
|
export function MediaLibraryBrowse({
|
|
369
475
|
client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
|
|
370
|
-
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,
|
|
371
485
|
/* PASS-THROUGHS TO `ColumnBrowser`. A documented prop this page does not
|
|
372
486
|
* forward is a prop no consumer of the PAGE can reach, which makes the seam
|
|
373
487
|
* fictional — `thumbnailFor` and `folderMeta` shipped, were announced, and
|
|
@@ -378,7 +492,9 @@ export function MediaLibraryBrowse({
|
|
|
378
492
|
* `ColumnBrowser`, IT IS ADDED HERE IN THE SAME EDIT, unless the page holds a
|
|
379
493
|
* real opinion about it (it owns `height`, the widths and `partition`, so
|
|
380
494
|
* those are deliberately absent from this list). */
|
|
381
|
-
thumbnailFor, folderMeta,
|
|
495
|
+
thumbnailFor, folderMeta, stackView,
|
|
496
|
+
/* defaulted, not bare — see the note on the sibling page */
|
|
497
|
+
formatDate = defaultFormatDate,
|
|
382
498
|
/* THE TAB PILL'S LIST (ColumnBrowserMobileViews item 16). What a tab MEANS is
|
|
383
499
|
* the consumer's — a repo whose surfaces are routes wires its router here.
|
|
384
500
|
* No tabs, no pill, and the page is exactly what it was. */
|
|
@@ -395,6 +511,133 @@ export function MediaLibraryBrowse({
|
|
|
395
511
|
const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
|
|
396
512
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
397
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
|
+
}
|
|
398
641
|
const [quickLook, setQuickLook] = useState(null)
|
|
399
642
|
const columnsRef = useRef(null)
|
|
400
643
|
const { folderView = 'columns', flat } = settings
|
|
@@ -416,8 +659,17 @@ export function MediaLibraryBrowse({
|
|
|
416
659
|
[searched, settings.sortBy, settings.sortDir],
|
|
417
660
|
)
|
|
418
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
|
+
|
|
419
666
|
const scoped = prefix ? sortedObjects.filter((o) => o.key.startsWith(prefix)) : sortedObjects
|
|
420
|
-
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()
|
|
421
673
|
const keySet = new Set(objects.map((o) => o.key))
|
|
422
674
|
const levelFiles = flat ? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key })) : dirFiles
|
|
423
675
|
const systemCount = levelFiles.filter((o) => isSystemFile(o.key)).length
|
|
@@ -441,9 +693,24 @@ export function MediaLibraryBrowse({
|
|
|
441
693
|
const ROOT = title
|
|
442
694
|
const label = bucketMeta.label || 'bucket'
|
|
443
695
|
const VROOT = single ? '' : `${ROOT}/${label}/`
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
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
|
+
}
|
|
447
714
|
|
|
448
715
|
useEffect(() => {
|
|
449
716
|
if (folderView !== 'columns') return undefined
|
|
@@ -458,10 +725,19 @@ export function MediaLibraryBrowse({
|
|
|
458
725
|
|
|
459
726
|
return (
|
|
460
727
|
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
461
|
-
<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('') }}
|
|
462
730
|
onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
|
|
463
731
|
onSettings={() => setSettingsOpen(true)} />
|
|
464
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
|
+
|
|
465
741
|
<div className="flex flex-col gap-3">
|
|
466
742
|
{/* Breadcrumb (uppercase, active segment at full ink) · folder-view toggle
|
|
467
743
|
*
|
|
@@ -641,6 +917,13 @@ export function MediaLibraryBrowse({
|
|
|
641
917
|
const poster = posterFor(real.key, keySet)
|
|
642
918
|
return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
643
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}
|
|
644
927
|
partition={(objs, level) => {
|
|
645
928
|
if (!single) {
|
|
646
929
|
if (level === '') return { folders: [`${ROOT}/`], files: [] }
|
|
@@ -652,16 +935,90 @@ export function MediaLibraryBrowse({
|
|
|
652
935
|
}}
|
|
653
936
|
/>
|
|
654
937
|
</div>
|
|
655
|
-
) :
|
|
656
|
-
|
|
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>
|
|
657
1009
|
)}
|
|
658
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. */}
|
|
659
1016
|
{appRoot && folderView === 'columns' && folderTree ? (
|
|
660
|
-
<p className="kol-mono-12 text-fg-48">
|
|
1017
|
+
<p className="kol-mono-12 text-fg-48 mt-2">
|
|
661
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))}
|
|
662
1019
|
</p>
|
|
663
1020
|
) : (
|
|
664
|
-
<p className="kol-mono-12 text-fg-48">
|
|
1021
|
+
<p className="kol-mono-12 text-fg-48 mt-2">
|
|
665
1022
|
{folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
|
|
666
1023
|
{rawFiles.length} {rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
|
|
667
1024
|
{(!prefix || flat) && rawFiles.length !== bucketFiles && <span className="text-fg-32">{' · bucket: '}{bucketFiles} files · {formatSize(bucketBytes)}</span>}
|
|
@@ -670,6 +1027,65 @@ export function MediaLibraryBrowse({
|
|
|
670
1027
|
)}
|
|
671
1028
|
{void onOpen}
|
|
672
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
|
+
|
|
673
1089
|
{/* THE TAB PILL (item 16) — floats over the list, so the list owes it
|
|
674
1090
|
room or its last row sits under the bar forever.
|
|
675
1091
|
THE SPACER GOES LAST, AND THAT IS THE WHOLE POINT
|
|
@@ -695,10 +1111,25 @@ export function MediaLibraryBrowse({
|
|
|
695
1111
|
/* ══ LIBRARY — the content-filters wall ═══════════════════════════════════ */
|
|
696
1112
|
export function MediaLibraryLibrary({
|
|
697
1113
|
client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
|
|
698
|
-
headerActions, refreshKey, header = true, stats = true, settingsFooter, className = '',
|
|
1114
|
+
headerActions, headerTrailing, refreshKey, header = true, stats = true, settingsFooter, className = '', banner,
|
|
699
1115
|
/* the same pill the browse page takes — the wall is one of the surfaces it
|
|
700
1116
|
switches between, so it has to carry it too (item 16) */
|
|
701
1117
|
tabs, activeTab, onTabChange,
|
|
1118
|
+
/* A SEAM ON ONE PAGE IS A SEAM ON BOTH, when the field is one both render
|
|
1119
|
+
* (FormatDateSkipsTheLibraryPage, kol-r2b2 2026-09-04). This reached
|
|
1120
|
+
* `MediaLibraryBrowse` and not here, so a consumer passing ONE props object
|
|
1121
|
+
* to both got `19.6.2026` on the Browse tab and `2026-06-19` on the Files tab
|
|
1122
|
+
* — two formats for one field, one tap apart. The desktop stack had the same
|
|
1123
|
+
* mismatch; the tab pill just moved the two halves close enough to see it.
|
|
1124
|
+
*
|
|
1125
|
+
* THE RULE, WIDENED — the fourth defect of this exact shape (`settingsFooter`
|
|
1126
|
+
* documented and hardcoded past · `thumbnailFor` and `folderMeta` not
|
|
1127
|
+
* forwarded · now this). These two pages are ONE SURFACE SPLIT IN TWO and a
|
|
1128
|
+
* consumer hands the same props object to both, so: a prop naming how a
|
|
1129
|
+
* SHARED FIELD renders is added to BOTH pages in the same edit. Props about a
|
|
1130
|
+
* concept only one page has stay put — `thumbnailFor`, `folderMeta` and
|
|
1131
|
+
* `stackView` are folder and stack concepts and are correctly absent here. */
|
|
1132
|
+
formatDate = defaultFormatDate,
|
|
702
1133
|
}) {
|
|
703
1134
|
const [ownBucket, setOwnBucket] = useState(bucket)
|
|
704
1135
|
const bucketId = bucket ?? ownBucket
|
|
@@ -837,16 +1268,32 @@ export function MediaLibraryLibrary({
|
|
|
837
1268
|
}
|
|
838
1269
|
return o.displayKey.split('/').pop()
|
|
839
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. */
|
|
840
1282
|
const renderThumb = (o, onClick) => {
|
|
841
|
-
const
|
|
842
|
-
const poster = (isVideo(o.contentType) ||
|
|
1283
|
+
const kind = kindOf(o)
|
|
1284
|
+
const poster = (isVideo(o.contentType) || kind === 'playlist') ? posterFor(o.key, keySet) : null
|
|
843
1285
|
const imgSrc = isImage(o.contentType) ? mediaUrl(o.key) : poster && videoPreview !== 'none' ? mediaUrl(poster) : null
|
|
844
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
|
+
}
|
|
845
1294
|
return (
|
|
846
|
-
<div className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${
|
|
847
|
-
{
|
|
848
|
-
: isVideo(o.contentType) ? <span className="kol-mono-12 text-fg-48">video</span>
|
|
849
|
-
: <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} />
|
|
850
1297
|
</div>
|
|
851
1298
|
)
|
|
852
1299
|
}
|
|
@@ -859,9 +1306,12 @@ export function MediaLibraryLibrary({
|
|
|
859
1306
|
return (
|
|
860
1307
|
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
861
1308
|
{header && (
|
|
862
|
-
<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}
|
|
863
1310
|
onBucket={(v) => { if (v === 'all') return; if (bucket == null) setOwnBucket(v); onBucketChange?.(v) }} onSettings={() => setSettingsOpen(true)} />
|
|
864
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}
|
|
865
1315
|
{settingsOpen && (
|
|
866
1316
|
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
867
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
|
+
}
|