@kolkrabbi/kol-component 0.116.0 → 0.118.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 +2 -2
- package/src/atoms/Input.jsx +5 -0
- package/src/atoms/ViewToggle.jsx +11 -1
- package/src/index.js +2 -1
- package/src/molecules/Dropdown.jsx +7 -1
- package/src/molecules/SearchInput.jsx +4 -2
- package/src/organisms/ContentFilters.jsx +4 -0
- package/src/organisms/MediaLibrary.jsx +29 -6
- package/src/organisms/MediaLibraryPages.jsx +709 -0
- package/src/utilities/mediaKinds.js +89 -0
- package/src/utilities/ratios.js +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.118.0",
|
|
4
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",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"react-router-dom": "^6.0.0 || ^7.0.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
35
|
+
"@kolkrabbi/kol-icons": "^0.24.0"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
38
|
"src",
|
package/src/atoms/Input.jsx
CHANGED
|
@@ -8,6 +8,8 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
8
8
|
* variant="outline" — bordered, transparent bg — THE secondary
|
|
9
9
|
* treatment (2026-07-08 chrome law: one
|
|
10
10
|
* secondary, always subordinate to filled)
|
|
11
|
+
* tone="inverse" — the dark chip (`fg-absolute-24`) for a washed plane
|
|
12
|
+
* (ControlToneInverse, 2026-08-27); same prop on ViewToggle · Dropdown
|
|
11
13
|
* variant="ghost" — legacy alias, resolves to outline
|
|
12
14
|
* variant="property" — the Figma property field (PropertyField,
|
|
13
15
|
* 2026-08-12): filled chrome, dim `affordance`
|
|
@@ -50,6 +52,7 @@ export default function Input({
|
|
|
50
52
|
value,
|
|
51
53
|
onChange,
|
|
52
54
|
variant = 'filled',
|
|
55
|
+
tone = 'default',
|
|
53
56
|
size = 'md',
|
|
54
57
|
chars,
|
|
55
58
|
prefix,
|
|
@@ -86,6 +89,8 @@ export default function Input({
|
|
|
86
89
|
`kol-control-${size}`,
|
|
87
90
|
SIZE_TYPE[size],
|
|
88
91
|
'cursor-text',
|
|
92
|
+
/* the dark chip on a washed plane (ControlToneInverse, kol-website 2026-08-27) */
|
|
93
|
+
tone === 'inverse' && 'kol-tone-inverse',
|
|
89
94
|
isProperty && 'w-full',
|
|
90
95
|
className,
|
|
91
96
|
].filter(Boolean).join(' ')
|
package/src/atoms/ViewToggle.jsx
CHANGED
|
@@ -18,6 +18,15 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
18
18
|
* icon cut for variant="icon" ('stroke' default; 'solid' reads better at
|
|
19
19
|
* 14px). For `variant="single"`, the FIRST option in `options` is the "off"
|
|
20
20
|
* value; the SECOND is "on".
|
|
21
|
+
*
|
|
22
|
+
* `tone="inverse"` (ControlToneInverse, kol-website 2026-08-27 — user: "a
|
|
23
|
+
* flipped version of this color scheme, where the darker is background and grey
|
|
24
|
+
* is the active … it would fit better on the light grey"): on a washed plane
|
|
25
|
+
* (`pageWash`, `fg-04`) the default grey well reads as a second plate, so the
|
|
26
|
+
* icon variant's two values swap — the well takes the dark chip
|
|
27
|
+
* (`fg-absolute-24`), the active chip the `fg-16` ink wash (user, on the live
|
|
28
|
+
* page: "just use bg-fg-16"), the inactive hover a lighter wash. Theme rules on `.kol-tone-inverse`
|
|
29
|
+
* (kol-theme ≥0.78.0); the same prop on `Dropdown` and `Input`.
|
|
21
30
|
*/
|
|
22
31
|
const ViewToggle = ({
|
|
23
32
|
size = 'sm',
|
|
@@ -25,6 +34,7 @@ const ViewToggle = ({
|
|
|
25
34
|
onViewChange,
|
|
26
35
|
variant = 'text',
|
|
27
36
|
iconVariant = 'stroke',
|
|
37
|
+
tone = 'default',
|
|
28
38
|
options = [
|
|
29
39
|
{ value: 'grid', label: 'Grid view', icon: 'grid' },
|
|
30
40
|
{ value: 'list', label: 'List view', icon: 'view-list' }
|
|
@@ -60,7 +70,7 @@ const ViewToggle = ({
|
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
const containerClasses = isIconVariant
|
|
63
|
-
? `inline-flex items-center gap-1 p-1 bg-surface-secondary rounded ${className}
|
|
73
|
+
? `kol-view-toggle inline-flex items-center gap-1 p-1 bg-surface-secondary rounded ${tone === 'inverse' ? 'kol-tone-inverse' : ''} ${className}`.replace(/\s+/g, ' ').trim()
|
|
64
74
|
: `flex gap-2 ${className}`
|
|
65
75
|
|
|
66
76
|
const buttonClasses = (isActive) => {
|
package/src/index.js
CHANGED
|
@@ -150,7 +150,8 @@ export { default as DocPage } from './molecules/DocPage.jsx'
|
|
|
150
150
|
export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
|
|
151
151
|
export { parseFrontmatter } from './utilities/frontmatter.js'
|
|
152
152
|
export { readCover } from './utilities/id3.js'
|
|
153
|
-
export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL } from './utilities/mediaKinds.js'
|
|
153
|
+
export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegment, groupSegments, groupVariants, posterFor, partition } from './utilities/mediaKinds.js'
|
|
154
|
+
export { nearestRatio, RATIOS } from './utilities/ratios.js'
|
|
154
155
|
export { default as markdownToHtml, inlineToHtml } from './utilities/markdownToHtml.js'
|
|
155
156
|
export { default as RecordManager } from './organisms/RecordManager.jsx'
|
|
156
157
|
export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
|
|
@@ -16,6 +16,9 @@ import { indicatorSize } from '../hooks/glyphLadders.js'
|
|
|
16
16
|
* same fill (one piece: no border, no gap, hairline divider inside)
|
|
17
17
|
* variant="grey" — oq-12 filled trigger (opaque per the fill
|
|
18
18
|
* law); panel continues it
|
|
19
|
+
* tone="inverse" — the dark chip (`fg-absolute-24`) for a washed
|
|
20
|
+
* plane, the panel continuing it (ControlToneInverse,
|
|
21
|
+
* kol-website 2026-08-27); same prop on ViewToggle · Input
|
|
19
22
|
* variant="outline" — bordered trigger; open panel carries the
|
|
20
23
|
* same border, trigger's bottom edge acts as the divider
|
|
21
24
|
*
|
|
@@ -42,6 +45,7 @@ const Dropdown = ({
|
|
|
42
45
|
onChange,
|
|
43
46
|
size,
|
|
44
47
|
variant = 'primary',
|
|
48
|
+
tone = 'default',
|
|
45
49
|
defaultOpen = false,
|
|
46
50
|
className = ''
|
|
47
51
|
}) => {
|
|
@@ -98,6 +102,8 @@ const Dropdown = ({
|
|
|
98
102
|
SIZE_TYPE[resolvedSize],
|
|
99
103
|
'kol-dd-trigger',
|
|
100
104
|
isOpen && 'kol-dd-trigger--open',
|
|
105
|
+
/* the dark chip on a washed plane; the panel continues it (ControlToneInverse, 2026-08-27) */
|
|
106
|
+
tone === 'inverse' && 'kol-tone-inverse',
|
|
101
107
|
].filter(Boolean).join(' ')
|
|
102
108
|
|
|
103
109
|
return (
|
|
@@ -131,7 +137,7 @@ const Dropdown = ({
|
|
|
131
137
|
popover={popover}
|
|
132
138
|
panel={false}
|
|
133
139
|
focus={false}
|
|
134
|
-
className={`kol-dd-panel kol-dd-panel--${resolvedVariant}
|
|
140
|
+
className={`kol-dd-panel kol-dd-panel--${resolvedVariant} ${tone === 'inverse' ? 'kol-tone-inverse' : ''}`.trim()}
|
|
135
141
|
>
|
|
136
142
|
{(resolvedVariant === 'primary' || resolvedVariant === 'grey') && <div className="kol-dd-div" />}
|
|
137
143
|
|
|
@@ -34,6 +34,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
34
34
|
* @param {Function} onFocus (event) => void — input focus
|
|
35
35
|
* @param {string} size 'sm' | 'md' — kol-control size + matched mono type class
|
|
36
36
|
* @param {string} variant 'filled' | 'ghost' | 'outline' — kol-control variant, same chrome as Input (ignored when bare/expanding)
|
|
37
|
+
* @param {string} tone 'default' | 'inverse' — the dark chip for a washed plane (ControlToneInverse, 2026-08-27); on the expanding pill it is the OPEN fill
|
|
37
38
|
* @param {boolean} bare borderless inline field for overlay panels (full width, no shell chrome; keeps icon/clear/chip slots)
|
|
38
39
|
* @param {boolean} expanding round icon-button pill that animates open into an inline field
|
|
39
40
|
* @param {boolean} open (expanding) controlled open state; omit for internal state
|
|
@@ -76,6 +77,7 @@ export default function SearchInput({
|
|
|
76
77
|
onFocus,
|
|
77
78
|
size = 'md',
|
|
78
79
|
variant = 'filled',
|
|
80
|
+
tone = 'default',
|
|
79
81
|
bare = false,
|
|
80
82
|
expanding = false,
|
|
81
83
|
open,
|
|
@@ -154,7 +156,7 @@ export default function SearchInput({
|
|
|
154
156
|
control: sm 28 · md 32 · lg 36 (hooks/glyphLadders.js). This was a
|
|
155
157
|
hardcoded 36 — the LG square — so an expanding search sat beside a
|
|
156
158
|
`kol-btn-md` filter button at two different sizes. */
|
|
157
|
-
className={`kol-expand flex items-center rounded-full ${isOpen ? 'bg-fg-04' : ''} ${className}`.trim()}
|
|
159
|
+
className={`kol-expand flex items-center rounded-full ${isOpen ? (tone === 'inverse' ? 'kol-tone-inverse' : 'bg-fg-04') : ''} ${className}`.trim()}
|
|
158
160
|
style={{ height: isOpen ? fieldH : square, width: isOpen ? expandedWidth : square }}
|
|
159
161
|
>
|
|
160
162
|
{/* THE GLYPH IS THE CLOSED STATE, and only that (user ruling
|
|
@@ -196,7 +198,7 @@ export default function SearchInput({
|
|
|
196
198
|
const shellCls = [
|
|
197
199
|
bare
|
|
198
200
|
? 'flex w-full gap-2.5 px-4 py-3'
|
|
199
|
-
: `kol-control kol-control--${variant} kol-control-${size} gap-2`,
|
|
201
|
+
: `kol-control kol-control--${variant} kol-control-${size} gap-2${tone === 'inverse' ? ' kol-tone-inverse' : ''}`,
|
|
200
202
|
'items-center cursor-text',
|
|
201
203
|
SIZE_TYPE[size],
|
|
202
204
|
className,
|
|
@@ -56,8 +56,11 @@ import IconFrame from '../atoms/IconFrame.jsx'
|
|
|
56
56
|
* @param {string} props.stripActiveClassName — strip ink, selected (both strips)
|
|
57
57
|
* @param {string} props.stripRestClassName — strip ink, unselected (both strips)
|
|
58
58
|
* @param {string} props.countClassName — the "N of N" type/ink
|
|
59
|
+
* @param {string} props.tone — 'default' | 'inverse' — forwarded to the search field (ControlToneInverse,
|
|
60
|
+
* 2026-08-27): a page on a wash sets its header row's tone in one place
|
|
59
61
|
*/
|
|
60
62
|
const ContentFilters = ({
|
|
63
|
+
tone = 'default',
|
|
61
64
|
items,
|
|
62
65
|
title,
|
|
63
66
|
totalCount,
|
|
@@ -371,6 +374,7 @@ const ContentFilters = ({
|
|
|
371
374
|
* shows while searching); SearchInput takes it controlled. */}
|
|
372
375
|
<SearchInput
|
|
373
376
|
expanding
|
|
377
|
+
tone={tone}
|
|
374
378
|
open={searchOpen}
|
|
375
379
|
onOpenChange={(next) => { setSearchOpen(next); if (!next) setSearchText('') }}
|
|
376
380
|
value={searchText}
|
|
@@ -12,6 +12,7 @@ import ContentCard from '../molecules/ContentCard.jsx'
|
|
|
12
12
|
import ContentRow from '../molecules/ContentRow.jsx'
|
|
13
13
|
import ContentFilters from './ContentFilters.jsx'
|
|
14
14
|
import MediaViewer from './MediaViewer.jsx'
|
|
15
|
+
import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
|
|
15
16
|
import { SettingsChipRow, chipCls } from './SettingsPanel.jsx'
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -787,7 +788,8 @@ function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
|
|
|
787
788
|
)
|
|
788
789
|
}
|
|
789
790
|
|
|
790
|
-
/* ── The PAGE —
|
|
791
|
+
/* ── The old PAGE — the 08-26 reconcile; replaced by MediaLibraryPages (browse · library) 2026-08-27, kept only as the picker's parts' first host ── */
|
|
792
|
+
// eslint-disable-next-line no-unused-vars
|
|
791
793
|
function BrowserShell({ onSelect }) {
|
|
792
794
|
const lib = useMediaLibrary()
|
|
793
795
|
const [viewMode, setViewMode] = useState('grid')
|
|
@@ -946,12 +948,29 @@ function PickerShell({ onClose, onPick }) {
|
|
|
946
948
|
}
|
|
947
949
|
|
|
948
950
|
/**
|
|
949
|
-
* MediaLibrary — ONE component,
|
|
951
|
+
* MediaLibrary — ONE component, its variants. The user's ruling 2026-08-01:
|
|
950
952
|
* "arent different components, they are more like variants, same shit
|
|
951
|
-
* different viewing."
|
|
952
|
-
*
|
|
953
|
+
* different viewing." Since 2026-08-27 (MediaLibraryPages — user: "one page for
|
|
954
|
+
* the content filters, one page for folder/files … bucket is a control, not a
|
|
955
|
+
* page") the in-flow page is kol-r2b2's, cut in two:
|
|
953
956
|
*
|
|
954
|
-
*
|
|
957
|
+
* `browse` folder / files — the bucket Dropdown, the crumb line, ColumnBrowser
|
|
958
|
+
* (or folder rows), the count line
|
|
959
|
+
* `library` the content-filters wall — FILES · kinds · search · SELECT / FLAT ·
|
|
960
|
+
* grid | list | off · sort · the cards, paging, the inspector lightbox
|
|
961
|
+
* `modal` the picker, as before
|
|
962
|
+
* `page` DEPRECATED alias of `library` (one release) — the 08-26 reconcile
|
|
963
|
+
* of r2b2's list; the two variants above replace it
|
|
964
|
+
*
|
|
965
|
+
* Both pages take the same injected client (`buckets()` for the dropdown,
|
|
966
|
+
* kol-media-client ≥0.2.0) and render read-only unless it carries the write
|
|
967
|
+
* seams (`deleteObject` · `renameObject` · `downloadUrl`). Props of the pages:
|
|
968
|
+
* `title` · `bucket` / `onBucketChange` · `prefix` / `onPrefix` (browse: controlled
|
|
969
|
+
* or internal) · `settings` / `onSettingsChange` (r2b2's per-bucket model,
|
|
970
|
+
* `defaults` to seed) · `folderTree` (browse: the baked tree) · `headerActions`
|
|
971
|
+
* (the app's own upload / write icons) · `refreshKey`.
|
|
972
|
+
*
|
|
973
|
+
* @param {string} variant 'browse' | 'library' | 'modal' | 'page' (alias of library)
|
|
955
974
|
* @param {boolean} open modal only — mounts the overlay
|
|
956
975
|
* @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
|
|
957
976
|
* @param {string|string[]} accept 'all' (default) = everything · one kind · an
|
|
@@ -972,13 +991,17 @@ export default function MediaLibrary({
|
|
|
972
991
|
flat = false,
|
|
973
992
|
onClose,
|
|
974
993
|
onSelect = null,
|
|
994
|
+
...pageProps
|
|
975
995
|
}) {
|
|
976
996
|
const opts = { client, accept, pageSize, defaultSort, flat }
|
|
977
997
|
if (variant === 'modal') {
|
|
978
998
|
if (!open) return null
|
|
979
999
|
return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, opts)
|
|
980
1000
|
}
|
|
981
|
-
return
|
|
1001
|
+
if (variant === 'browse') return <MediaLibraryBrowse client={client} {...pageProps} />
|
|
1002
|
+
/* `page` = the library wall (alias, one release): its old knobs map onto the settings seed */
|
|
1003
|
+
const seed = variant === 'page' ? { defaults: { pageSize, sortBy: defaultSort?.by, sortDir: defaultSort?.dir, flat, ...(pageProps.defaults ?? {}) } } : {}
|
|
1004
|
+
return <MediaLibraryLibrary client={client} {...pageProps} {...seed} />
|
|
982
1005
|
}
|
|
983
1006
|
|
|
984
1007
|
/** @deprecated 2026-08-01 — alias of `MediaLibrary variant="modal"`. Kept so
|
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
import Button from '../atoms/Button.jsx'
|
|
4
|
+
import Divider from '../atoms/Divider.jsx'
|
|
5
|
+
import Input from '../atoms/Input.jsx'
|
|
6
|
+
import IconFrame from '../atoms/IconFrame.jsx'
|
|
7
|
+
import ActionButton from '../atoms/ActionButton.jsx'
|
|
8
|
+
import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
|
|
9
|
+
import ToggleCheckbox from '../atoms/ToggleCheckbox.jsx'
|
|
10
|
+
import ViewToggle from '../atoms/ViewToggle.jsx'
|
|
11
|
+
import Dropdown from '../molecules/Dropdown.jsx'
|
|
12
|
+
import ContentCard from '../molecules/ContentCard.jsx'
|
|
13
|
+
import ContentRow from '../molecules/ContentRow.jsx'
|
|
14
|
+
import SortControls from '../molecules/SortControls.jsx'
|
|
15
|
+
import KindPreview from '../molecules/KindPreview.jsx'
|
|
16
|
+
import AudioSheet from '../molecules/AudioSheet.jsx'
|
|
17
|
+
import VideoSheet from '../molecules/VideoSheet.jsx'
|
|
18
|
+
import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
19
|
+
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
20
|
+
import ContentFilters from './ContentFilters.jsx'
|
|
21
|
+
import ColumnBrowser from './ColumnBrowser.jsx'
|
|
22
|
+
import SettingsPanel, { LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
|
|
23
|
+
import { kindOf, KIND_LABEL, KINDS, DEFAULT_KINDS, isSystemFile, isSegment, groupSegments, groupVariants, posterFor, partition } from '../utilities/mediaKinds.js'
|
|
24
|
+
import { nearestRatio } from '../utilities/ratios.js'
|
|
25
|
+
|
|
26
|
+
/* taxonomy-ok: organism — the two media pages composed of DS parts. */
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* MediaLibrary's two PAGES — kol-r2b2's app cut in two (MediaLibraryPages,
|
|
30
|
+
* 2026-08-27; user: "wouldn't it make sense to have these much newer components
|
|
31
|
+
* available in library?" — and the split, ruled the same hour: "one page for the
|
|
32
|
+
* content filters, one page for folder/files, and a local gallery. Bucket is a
|
|
33
|
+
* control, not a page"):
|
|
34
|
+
*
|
|
35
|
+
* MediaLibraryBrowse `variant="browse"` — r2b2's header (the bucket Dropdown,
|
|
36
|
+
* a lock when read-only, settings) · the crumb line with the
|
|
37
|
+
* ROW | COLUMN toggle · ColumnBrowser (or folder rows) on the
|
|
38
|
+
* flat key space · the count line
|
|
39
|
+
* MediaLibraryLibrary `variant="library"` — r2b2's FileList wall, promoted
|
|
40
|
+
* verbatim minus the app wiring: ContentFilters (FILES ·
|
|
41
|
+
* kind chips · search · SELECT / FLAT · grid | list | off ·
|
|
42
|
+
* SortControls · the selection bar while selecting), the
|
|
43
|
+
* wall on ContentCard / ContentRow default, paging, the
|
|
44
|
+
* per-bucket list cache, the stats line, the inspector
|
|
45
|
+
* lightbox (image · VideoSheet · AudioSheet · DocPage via
|
|
46
|
+
* KindPreview)
|
|
47
|
+
*
|
|
48
|
+
* Both take the injected `client` (`{ listMedia, mediaUrl, proxied?, buckets? }`
|
|
49
|
+
* — ARCHITECTURE §3 stands: never imported) and render READ-ONLY unless the
|
|
50
|
+
* client carries the write seams (`deleteObject` · `renameObject` · `downloadUrl`
|
|
51
|
+
* — the admin app's; brand's read client shows none). Buckets come from
|
|
52
|
+
* `client.buckets()` (kol-media-client ≥0.2.0); a client with none is one bucket
|
|
53
|
+
* and no dropdown. Settings are kol-r2b2's per-bucket model (`SETTINGS_BASE`),
|
|
54
|
+
* controlled through `settings` / `onSettingsChange` so a consumer persists them.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/* ── the settings model — kol-r2b2's lib/settings.js BASE, verbatim ───────── */
|
|
58
|
+
export const ALL_KINDS = [...KINDS, 'segments', 'system']
|
|
59
|
+
export const SETTINGS_BASE = {
|
|
60
|
+
kinds: [...DEFAULT_KINDS],
|
|
61
|
+
flat: false,
|
|
62
|
+
groupVariants: true,
|
|
63
|
+
foldSegments: true,
|
|
64
|
+
pageSize: 200,
|
|
65
|
+
videoPreview: 'poster', // 'poster' | 'none' | 'autoload'
|
|
66
|
+
layout: 'off', // 'off' | 'grid' | 'list' — off by default: the column view browses, cards load thumbnails
|
|
67
|
+
folderView: 'columns', // 'rows' | 'columns'
|
|
68
|
+
sortBy: 'name',
|
|
69
|
+
sortDir: 'asc',
|
|
70
|
+
columnHeight: 528,
|
|
71
|
+
columnWidths: {},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const LAYOUT_OPTIONS = [
|
|
75
|
+
{ value: 'grid', label: 'Grid', icon: 'grid' },
|
|
76
|
+
{ value: 'list', label: 'List', icon: 'view-list' },
|
|
77
|
+
{ value: 'off', label: 'Off', icon: 'eye-off' },
|
|
78
|
+
]
|
|
79
|
+
const FOLDER_VIEW_OPTIONS = [
|
|
80
|
+
{ value: 'rows', label: 'Rows', icon: 'view-list' },
|
|
81
|
+
{ value: 'columns', label: 'Columns', icon: 'columns' },
|
|
82
|
+
]
|
|
83
|
+
const SORT_OPTIONS = [
|
|
84
|
+
{ value: 'name', label: 'Name' },
|
|
85
|
+
{ value: 'date', label: 'Date' },
|
|
86
|
+
{ value: 'size', label: 'Size' },
|
|
87
|
+
{ value: 'kind', label: 'Kind' },
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
/* bytes → weight, duplicated from the client on purpose (§3: the UI never imports it) */
|
|
91
|
+
function formatSize(bytes) {
|
|
92
|
+
if (bytes == null) return ''
|
|
93
|
+
if (bytes < 1024) return `${bytes} B`
|
|
94
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
95
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
96
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
|
97
|
+
}
|
|
98
|
+
const formatDate = (iso) => (iso ? new Date(iso).toISOString().slice(0, 10) : '')
|
|
99
|
+
const isImage = (ct) => !!ct && ct.startsWith('image/')
|
|
100
|
+
const isVideo = (ct) => !!ct && ct.startsWith('video/')
|
|
101
|
+
|
|
102
|
+
function sortFiles(files, sortBy, sortDir) {
|
|
103
|
+
const dir = sortDir === 'desc' ? -1 : 1
|
|
104
|
+
return [...files].sort((a, b) => {
|
|
105
|
+
if (sortBy === 'date') return dir * (new Date(a.uploaded) - new Date(b.uploaded))
|
|
106
|
+
if (sortBy === 'size') return dir * (a.size - b.size)
|
|
107
|
+
if (sortBy === 'kind') return dir * kindOf(a).localeCompare(kindOf(b)) || a.displayKey.localeCompare(b.displayKey)
|
|
108
|
+
return dir * a.displayKey.localeCompare(b.displayKey)
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* R2 has no real folders — "move" = rewrite the key with a folder prefix */
|
|
113
|
+
const moveKey = (key, prefix, folder) => {
|
|
114
|
+
const clean = folder.replace(/^\/+|\/+$/g, '').trim()
|
|
115
|
+
const rel = prefix ? key.slice(prefix.length) : key
|
|
116
|
+
return `${prefix}${clean}/${rel}`
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ── ONE hook: the bucket, its list, its settings ───────────────────────────
|
|
120
|
+
* Lists from root and partitions client-side so folder counts are right; the
|
|
121
|
+
* result carries the refresh key it answered so `loading` is derived. A
|
|
122
|
+
* per-bucket cache makes switching back instant while the fetch replaces it. */
|
|
123
|
+
function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSettingsChange, refreshKey = 0 }) {
|
|
124
|
+
const buckets = useMemo(() => client?.buckets?.() ?? [], [client])
|
|
125
|
+
const bucketMeta = buckets.find((b) => b.id === bucket) ?? buckets[0] ?? { id: null, label: '', writable: false }
|
|
126
|
+
const bucketId = bucketMeta.id
|
|
127
|
+
const [own, setOwn] = useState(() => ({ ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) }))
|
|
128
|
+
const settings = controlled ?? own
|
|
129
|
+
const setSettings = (next) => {
|
|
130
|
+
if (next === null) { const back = { ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) }; if (!controlled) setOwn(back); onSettingsChange?.(back); return }
|
|
131
|
+
if (!controlled) setOwn(next)
|
|
132
|
+
onSettingsChange?.(next)
|
|
133
|
+
}
|
|
134
|
+
const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], error: null })
|
|
135
|
+
const [cache, setCache] = useState({})
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!client) return undefined
|
|
138
|
+
let cancelled = false
|
|
139
|
+
const controller = new AbortController()
|
|
140
|
+
client.listMedia('', { signal: controller.signal, bucket: bucketId ?? undefined })
|
|
141
|
+
.then((objs) => { if (cancelled) return; setLoaded({ key: refreshKey, bucket: bucketId, objects: objs, error: null }); setCache((c) => ({ ...c, [bucketId]: objs })) })
|
|
142
|
+
.catch((e) => { if (!cancelled && e.name !== 'AbortError') setLoaded({ key: refreshKey, bucket: bucketId, objects: [], error: e.message }) })
|
|
143
|
+
return () => { cancelled = true; controller.abort() }
|
|
144
|
+
}, [client, refreshKey, bucketId])
|
|
145
|
+
const ready = loaded.key === refreshKey && loaded.bucket === bucketId
|
|
146
|
+
const objects = ready ? loaded.objects : (cache[bucketId] ?? [])
|
|
147
|
+
const setObjects = (fn) => setLoaded((prev) => ({ ...prev, objects: fn(prev.objects) }))
|
|
148
|
+
const error = ready ? loaded.error : null
|
|
149
|
+
const mediaUrl = (key) => client?.mediaUrl?.(key, bucketId ?? undefined) ?? key
|
|
150
|
+
const downloadUrl = (key) => client?.downloadUrl?.(key, bucketId ?? undefined) ?? mediaUrl(key)
|
|
151
|
+
const writable = !!bucketMeta.writable && !!(client?.deleteObject || client?.renameObject)
|
|
152
|
+
return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, error, ready, mediaUrl, downloadUrl, writable }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
|
|
156
|
+
|
|
157
|
+
/* The frame is square until the file reports its size, then snaps to its nearest preset. */
|
|
158
|
+
function ImageFrame({ src }) {
|
|
159
|
+
const [ratio, setRatio] = useState('1 / 1')
|
|
160
|
+
return (
|
|
161
|
+
<div className="w-full rounded overflow-hidden flex items-center justify-center" style={{ aspectRatio: ratio }}>
|
|
162
|
+
<img src={src} alt="" className="w-full h-full object-cover" loading="lazy" onLoad={(e) => setRatio(nearestRatio(e.target.naturalWidth, e.target.naturalHeight))} />
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* `struck` = folder grouping is bypassed (flat mode) — still navigable, de-emphasised. */
|
|
168
|
+
function FolderRow({ name, onClick, struck = false }) {
|
|
169
|
+
return (
|
|
170
|
+
<li className="flex items-center gap-3 py-2 border-b cursor-pointer hover:bg-fg-04 transition-colors px-1 rounded" style={{ borderColor: 'var(--kol-fg-08)' }} onClick={onClick}>
|
|
171
|
+
<div className="w-8 h-8 shrink-0 flex items-center justify-center text-fg-48"><Icon name="folder" size={18} /></div>
|
|
172
|
+
<span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-fg-48' : 'text-fg-default'}`}>{name}</span>
|
|
173
|
+
<Icon name="chevron-right" size={14} className="text-fg-32" />
|
|
174
|
+
</li>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* The inspector — shell is FullscreenOverlay; the STAGE is kol-r2b2's
|
|
179
|
+
* MediaLightbox, verbatim: scrubbable audio-on video (VideoSheet), AudioSheet,
|
|
180
|
+
* DocPage-through-KindPreview for documents, the facts line, prev / next FIXED
|
|
181
|
+
* at the viewport edges (DocPageAndKindShowcase). Not MediaViewer, which is a
|
|
182
|
+
* gallery — muted/loop/no-controls, image-and-video only. */
|
|
183
|
+
function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet }) {
|
|
184
|
+
const o = files[index]
|
|
185
|
+
const [dims, setDims] = useState(null)
|
|
186
|
+
const shownDims = dims && dims.key === o?.key ? dims : null
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const onKey = (e) => { if (e.key === 'ArrowLeft') onPrev(); if (e.key === 'ArrowRight') onNext() }
|
|
189
|
+
window.addEventListener('keydown', onKey)
|
|
190
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
191
|
+
}, [onPrev, onNext])
|
|
192
|
+
if (!o) return null
|
|
193
|
+
const kind = kindOf(o)
|
|
194
|
+
const poster = kind === 'video' ? posterFor(o.key, keySet) : null
|
|
195
|
+
const ARROW = 'fixed top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center rounded text-fg-48 hover:text-fg-default hover:bg-fg-absolute-24 transition-colors'
|
|
196
|
+
return (
|
|
197
|
+
<FullscreenOverlay open onClose={onClose} closeButton={false}>
|
|
198
|
+
<button type="button" className={`${ARROW} left-6`} onClick={onPrev} disabled={files.length <= 1} aria-label="Previous"><Icon name="chevron-left" size={22} /></button>
|
|
199
|
+
<div className="max-w-[calc(100vw-10rem)] max-h-[85vh] flex flex-col items-center gap-3">
|
|
200
|
+
{isImage(o.contentType) ? (
|
|
201
|
+
<img src={mediaUrl(o.key)} alt={o.displayKey} className="max-w-full max-h-[78vh] object-contain rounded" onLoad={(e) => setDims({ key: o.key, w: e.target.naturalWidth, h: e.target.naturalHeight })} />
|
|
202
|
+
) : isVideo(o.contentType) ? (
|
|
203
|
+
<VideoSheet src={mediaUrl(o.key)} poster={poster ? mediaUrl(poster) : undefined} onMeta={(m) => setDims({ key: o.key, ...m })} />
|
|
204
|
+
) : kind === 'audio' ? (
|
|
205
|
+
<AudioSheet src={mediaUrl(o.key)} onDuration={(len) => setDims({ key: o.key, len })} />
|
|
206
|
+
) : (
|
|
207
|
+
<KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} />
|
|
208
|
+
)}
|
|
209
|
+
<div className="flex items-center gap-4">
|
|
210
|
+
<span className="kol-mono-12 text-fg-48">{o.displayKey ?? o.key}</span>
|
|
211
|
+
<span className="kol-mono-12 text-fg-32">{formatSize(o.size)}</span>
|
|
212
|
+
{shownDims?.w && <span className="kol-mono-12 text-fg-32">{shownDims.w} × {shownDims.h} px</span>}
|
|
213
|
+
{shownDims?.len && <span className="kol-mono-12 text-fg-32">{formatLength(shownDims.len)}</span>}
|
|
214
|
+
<ActionButton chrome="inline" size="sm" icon="download" confirmIcon="check" label="Download" confirmLabel="Downloaded" href={downloadUrl(o.key)} />
|
|
215
|
+
</div>
|
|
216
|
+
<span className="kol-mono-10 text-fg-24">{index + 1} / {files.length}</span>
|
|
217
|
+
</div>
|
|
218
|
+
<button type="button" className={`${ARROW} right-6`} onClick={onNext} disabled={files.length <= 1} aria-label="Next"><Icon name="chevron-right" size={22} /></button>
|
|
219
|
+
</FullscreenOverlay>
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* Display settings for the ACTIVE bucket — kol-r2b2's drawer wiring on the DS
|
|
224
|
+
* organism (the composition the user locked 2026-08-27). Every control sets a
|
|
225
|
+
* default, never a gate. */
|
|
226
|
+
function MediaSettings({ bucketMeta, settings, onChange, onReset, onClose, profile }) {
|
|
227
|
+
const set = (patch) => onChange({ ...settings, ...patch })
|
|
228
|
+
const toggleKind = (k) => set({ kinds: settings.kinds.includes(k) ? settings.kinds.filter((x) => x !== k) : [...settings.kinds, k] })
|
|
229
|
+
const noVariants = profile.variantSets === 0
|
|
230
|
+
const noSegments = profile.segments === 0
|
|
231
|
+
return (
|
|
232
|
+
<SettingsPanel variant="drawer" title="Display settings" onClose={onClose} footer={<SettingsFooter onReset={onReset} />}>
|
|
233
|
+
<LabeledControlSection label="Structure" rowGap={1} divided>
|
|
234
|
+
<SettingsRow label="Columns" hint="Finder-style columns instead of folder rows">
|
|
235
|
+
<SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} />
|
|
236
|
+
</SettingsRow>
|
|
237
|
+
<SettingsRow label="Flat" hint="ignore folders, show the whole subtree">
|
|
238
|
+
<SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} />
|
|
239
|
+
</SettingsRow>
|
|
240
|
+
<SettingsRow label="Group resolutions" hint={noVariants ? 'no resolution sets in this bucket' : `${profile.variantSets} sets — previews the smallest file`}>
|
|
241
|
+
<SettingsSwitch label="Group resolutions" on={settings.groupVariants} onChange={(v) => set({ groupVariants: v })} disabled={noVariants} disabledHint="nothing to group here" />
|
|
242
|
+
</SettingsRow>
|
|
243
|
+
<SettingsRow label="Fold HLS segments" hint={noSegments ? 'no segments in this bucket' : `${profile.segments} segments into stream rows`}>
|
|
244
|
+
<SettingsSwitch label="Fold HLS segments" on={settings.foldSegments} onChange={(v) => set({ foldSegments: v })} disabled={noSegments} disabledHint="nothing to fold here" />
|
|
245
|
+
</SettingsRow>
|
|
246
|
+
</LabeledControlSection>
|
|
247
|
+
<LabeledControlSection label="Loading" divided>
|
|
248
|
+
<SettingsRow label="Kinds" align="fill">
|
|
249
|
+
<SettingsMulti options={ALL_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] || k }))} selected={settings.kinds} onToggle={toggleKind} noun="kinds" />
|
|
250
|
+
</SettingsRow>
|
|
251
|
+
<SettingsRow label="Page size" hint="entries mounted at once" align="fill">
|
|
252
|
+
<SettingsChoice options={[{ value: 100, label: '100' }, { value: 200, label: '200' }, { value: 500, label: '500' }, { value: 0, label: 'All' }]} value={settings.pageSize} onChange={(v) => set({ pageSize: v })} />
|
|
253
|
+
</SettingsRow>
|
|
254
|
+
<SettingsRow label="Video preview" hint="poster uses the sibling image; autoload fetches the file" align="fill">
|
|
255
|
+
<SettingsChoice options={[{ value: 'poster', label: 'Poster' }, { value: 'none', label: 'None' }, { value: 'autoload', label: 'Autoload' }]} value={settings.videoPreview} onChange={(v) => set({ videoPreview: v })} />
|
|
256
|
+
</SettingsRow>
|
|
257
|
+
</LabeledControlSection>
|
|
258
|
+
<LabeledControlSection label="Layout" divided>
|
|
259
|
+
<SettingsRow label="View" align="fill">
|
|
260
|
+
<SettingsChoice options={[{ value: 'off', label: 'Off' }, { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} value={settings.layout} onChange={(v) => set({ layout: v })} />
|
|
261
|
+
</SettingsRow>
|
|
262
|
+
<SettingsRow label="Sort" align="fill">
|
|
263
|
+
<SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} />
|
|
264
|
+
</SettingsRow>
|
|
265
|
+
<SettingsRow label="Direction" align="fill">
|
|
266
|
+
<SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} />
|
|
267
|
+
</SettingsRow>
|
|
268
|
+
</LabeledControlSection>
|
|
269
|
+
{void bucketMeta}
|
|
270
|
+
</SettingsPanel>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ── the header — bucket Dropdown · lock / the app's actions · settings ──── */
|
|
275
|
+
function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings }) {
|
|
276
|
+
const options = buckets.length ? [{ value: 'all', label: `${title} · all` }, ...buckets.map((b) => ({ value: b.id, label: b.label }))] : []
|
|
277
|
+
return (
|
|
278
|
+
<header className="flex items-baseline justify-between gap-4">
|
|
279
|
+
<h1 className="kol-sans-display-03">{title}</h1>
|
|
280
|
+
<div className="flex items-center gap-2">
|
|
281
|
+
{options.length > 0 && <Dropdown className="w-48" value={appRoot ? 'all' : bucketId} onChange={onBucket} options={options} />}
|
|
282
|
+
{headerActions}
|
|
283
|
+
{!writable && bucketMeta.id && (
|
|
284
|
+
<IconFrame name="lock" variant="primary" size="sm" title={`${bucketMeta.label} is read-only here`} aria-label="Read-only" />
|
|
285
|
+
)}
|
|
286
|
+
<IconFrame name="settings-01" variant="primary" size="sm" onClick={onSettings} aria-label="Display settings" title="Display settings" />
|
|
287
|
+
</div>
|
|
288
|
+
</header>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/* the profile — what the bucket holds; drives the chips and the settings' "nothing to fold" states */
|
|
293
|
+
const profileOf = (objects, rawFiles, systemCount) => ({
|
|
294
|
+
variantSets: groupVariants(objects.map((o) => ({ ...o, displayKey: o.key }))).filter((g) => g.variants).length,
|
|
295
|
+
segments: objects.filter((o) => isSegment(o.key)).length,
|
|
296
|
+
kinds: { ...rawFiles.reduce((acc, o) => { acc[o.kind] = (acc[o.kind] || 0) + 1; return acc }, {}), ...(systemCount ? { system: systemCount } : {}) },
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
/* ══ BROWSE — folder / files ═══════════════════════════════════════════════ */
|
|
300
|
+
export function MediaLibraryBrowse({
|
|
301
|
+
client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
|
|
302
|
+
folderTree, headerActions, refreshKey, onOpen, className = '',
|
|
303
|
+
}) {
|
|
304
|
+
const [ownPrefix, setOwnPrefix] = useState('')
|
|
305
|
+
const prefix = prefixProp ?? ownPrefix
|
|
306
|
+
const setPrefix = (v) => { if (prefixProp == null) setOwnPrefix(v); onPrefix?.(v) }
|
|
307
|
+
const [appRoot, setAppRoot] = useState(false)
|
|
308
|
+
const [ownBucket, setOwnBucket] = useState(bucket)
|
|
309
|
+
const bucketId = bucket ?? ownBucket
|
|
310
|
+
const lib = useBucketLibrary({ client, bucket: bucketId, defaults, settings: settingsProp, onSettingsChange, refreshKey })
|
|
311
|
+
const { buckets, bucketMeta, settings, setSettings, objects, error, mediaUrl, downloadUrl, writable } = lib
|
|
312
|
+
const switchBucket = (id, pfx = '') => { if (bucket == null) setOwnBucket(id); onBucketChange?.(id); setPrefix(pfx) }
|
|
313
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
314
|
+
const [pickedFile, setPickedFile] = useState(null)
|
|
315
|
+
const [quickLook, setQuickLook] = useState(null)
|
|
316
|
+
const columnsRef = useRef(null)
|
|
317
|
+
const { folderView = 'columns', flat } = settings
|
|
318
|
+
|
|
319
|
+
const scoped = prefix ? objects.filter((o) => o.key.startsWith(prefix)) : objects
|
|
320
|
+
const { folders, files: dirFiles } = partition(scoped, prefix)
|
|
321
|
+
const keySet = new Set(objects.map((o) => o.key))
|
|
322
|
+
const levelFiles = flat ? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key })) : dirFiles
|
|
323
|
+
const systemCount = levelFiles.filter((o) => isSystemFile(o.key)).length
|
|
324
|
+
const rawFiles = levelFiles.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o) }))
|
|
325
|
+
const profile = profileOf(objects, rawFiles, systemCount)
|
|
326
|
+
const totalBytes = rawFiles.reduce((n, o) => n + (o.size ?? 0), 0)
|
|
327
|
+
const bucketFiles = objects.length
|
|
328
|
+
const bucketBytes = objects.reduce((n, o) => n + (o.size || 0), 0)
|
|
329
|
+
const crumbs = prefix ? prefix.replace(/\/$/, '').split('/') : []
|
|
330
|
+
|
|
331
|
+
/* the virtual root — one browser for the whole client: root row = the title,
|
|
332
|
+
* its children the buckets, stepping into one IS the bucket switch */
|
|
333
|
+
const ROOT = title
|
|
334
|
+
const label = bucketMeta.label || 'bucket'
|
|
335
|
+
const VROOT = `${ROOT}/${label}/`
|
|
336
|
+
const treeFolders = (level) => (folderTree?.[bucketMeta.id]?.folders ?? [])
|
|
337
|
+
.filter((p) => p.startsWith(level) && p.length > level.length && !p.slice(level.length, -1).includes('/'))
|
|
338
|
+
.map((p) => p.slice(level.length))
|
|
339
|
+
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (folderView !== 'columns') return undefined
|
|
342
|
+
const root = columnsRef.current?.querySelector('[tabindex="0"]')
|
|
343
|
+
if (!root) return undefined
|
|
344
|
+
const id = requestAnimationFrame(() => root.scrollTo({ left: root.scrollWidth, behavior: 'smooth' }))
|
|
345
|
+
return () => cancelAnimationFrame(id)
|
|
346
|
+
}, [folderView, prefix, appRoot, pickedFile, bucketMeta.id])
|
|
347
|
+
|
|
348
|
+
if (error) return <p className="kol-mono-12 text-ui-error">Error: {error}</p>
|
|
349
|
+
const crumbCls = (active) => `cursor-pointer select-none transition-colors ${active ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
353
|
+
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions}
|
|
354
|
+
onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
|
|
355
|
+
onSettings={() => setSettingsOpen(true)} />
|
|
356
|
+
|
|
357
|
+
<div className="flex flex-col gap-3">
|
|
358
|
+
{/* Breadcrumb (uppercase, active segment at full ink) · folder-view toggle */}
|
|
359
|
+
<div className="flex items-center justify-between gap-4">
|
|
360
|
+
<div className="flex items-center gap-2 kol-mono-12">
|
|
361
|
+
<button className={crumbCls(appRoot)} onClick={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}>{ROOT}</button>
|
|
362
|
+
{!appRoot && bucketMeta.id && (
|
|
363
|
+
<span className="flex items-center gap-2">
|
|
364
|
+
<span className="text-oq-32">/</span>
|
|
365
|
+
<button className={crumbCls(crumbs.length === 0 && !(folderView === 'columns' && pickedFile))} onClick={() => setPrefix('')}>{label.toUpperCase()}</button>
|
|
366
|
+
</span>
|
|
367
|
+
)}
|
|
368
|
+
{!appRoot && crumbs.map((seg, i) => {
|
|
369
|
+
const to = crumbs.slice(0, i + 1).join('/') + '/'
|
|
370
|
+
const last = i === crumbs.length - 1
|
|
371
|
+
return (
|
|
372
|
+
<span key={to} className="flex items-center gap-2">
|
|
373
|
+
<span className="text-oq-32">/</span>
|
|
374
|
+
<button className={crumbCls(last && !(folderView === 'columns' && pickedFile))} onClick={() => setPrefix(to)}>{seg.toUpperCase()}</button>
|
|
375
|
+
</span>
|
|
376
|
+
)
|
|
377
|
+
})}
|
|
378
|
+
{folderView === 'columns' && pickedFile && pickedFile.key.startsWith(prefix) && (
|
|
379
|
+
<span className="flex items-center gap-2"><span className="text-oq-32">/</span><span className="text-oq-96">{pickedFile.key.slice(prefix.length).toUpperCase()}</span></span>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
<div className="flex items-center gap-6">
|
|
383
|
+
<ViewToggle viewMode={folderView} onViewChange={(v) => setSettings({ ...settings, folderView: v })} variant="icon" options={FOLDER_VIEW_OPTIONS} />
|
|
384
|
+
<Divider variant="vertical" />
|
|
385
|
+
<div className="flex items-center gap-4">
|
|
386
|
+
{[{ value: 'rows', label: 'ROW' }, { value: 'columns', label: 'COLUMN' }].map((opt) => (
|
|
387
|
+
<span key={opt.value} role="button" onClick={() => setSettings({ ...settings, folderView: opt.value })}
|
|
388
|
+
className={`kol-helper-14 cursor-pointer select-none ${folderView === opt.value ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`} style={{ letterSpacing: 1 }}>
|
|
389
|
+
{opt.label}
|
|
390
|
+
</span>
|
|
391
|
+
))}
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
{quickLook && (
|
|
397
|
+
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={() => setQuickLook(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
|
|
398
|
+
onPrev={() => setQuickLook((q) => ({ ...q, index: (q.index - 1 + q.files.length) % q.files.length }))}
|
|
399
|
+
onNext={() => setQuickLook((q) => ({ ...q, index: (q.index + 1) % q.files.length }))} />
|
|
400
|
+
)}
|
|
401
|
+
{settingsOpen && (
|
|
402
|
+
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} />
|
|
403
|
+
)}
|
|
404
|
+
|
|
405
|
+
{folderView === 'columns' ? (
|
|
406
|
+
<div ref={columnsRef} className="relative">
|
|
407
|
+
<ColumnBrowser
|
|
408
|
+
className={appRoot ? 'is-root' : ''}
|
|
409
|
+
height={settings.columnHeight}
|
|
410
|
+
onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
|
|
411
|
+
columnWidths={settings.columnWidths}
|
|
412
|
+
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
413
|
+
objects={objects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
414
|
+
prefix={appRoot ? `${ROOT}/` : `${VROOT}${prefix}`}
|
|
415
|
+
onPrefix={(v) => {
|
|
416
|
+
const [, seg, ...rest] = v.split('/')
|
|
417
|
+
const target = buckets.find((b) => b.label === seg)
|
|
418
|
+
if (!target && buckets.length) { setAppRoot(true); setPickedFile(null); setPrefix(''); return }
|
|
419
|
+
setAppRoot(false)
|
|
420
|
+
const real = rest.join('/')
|
|
421
|
+
if (target && target.id !== bucketMeta.id) switchBucket(target.id, real); else setPrefix(real)
|
|
422
|
+
}}
|
|
423
|
+
onPick={(o) => setPickedFile(o ? { ...o, key: o.key.slice(VROOT.length) } : null)}
|
|
424
|
+
quickLook={quickLook && { ...quickLook, files: quickLook.files.map((o) => ({ ...o, key: `${VROOT}${o.key}` })) }}
|
|
425
|
+
onQuickLook={(q) => setQuickLook(q && { ...q, files: q.files.map((o) => ({ ...o, key: o.key.slice(VROOT.length) })) })}
|
|
426
|
+
urlOf={(o) => mediaUrl(o.key.slice(VROOT.length))}
|
|
427
|
+
kindOf={kindOf}
|
|
428
|
+
kindLabel={KIND_LABEL}
|
|
429
|
+
formatSize={formatSize}
|
|
430
|
+
renderPreview={(o) => {
|
|
431
|
+
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
432
|
+
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
|
|
433
|
+
const poster = posterFor(real.key, keySet)
|
|
434
|
+
return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
435
|
+
}}
|
|
436
|
+
partition={(objs, level) => {
|
|
437
|
+
if (level === '') return { folders: [`${ROOT}/`], files: [] }
|
|
438
|
+
if (level === `${ROOT}/`) return { folders: (buckets.length ? buckets : [bucketMeta]).map((b) => `${b.label || 'bucket'}/`), files: [] }
|
|
439
|
+
const live = partition(objs, level)
|
|
440
|
+
const baked = treeFolders(level.slice(VROOT.length))
|
|
441
|
+
return { folders: [...new Set([...baked, ...live.folders])].sort(), files: live.files }
|
|
442
|
+
}}
|
|
443
|
+
/>
|
|
444
|
+
</div>
|
|
445
|
+
) : folders.length > 0 && (
|
|
446
|
+
<ul className="flex flex-col">{folders.map((f) => <FolderRow key={f} name={f} onClick={() => setPrefix(prefix + f)} struck={flat} />)}</ul>
|
|
447
|
+
)}
|
|
448
|
+
|
|
449
|
+
{appRoot && folderView === 'columns' && folderTree ? (
|
|
450
|
+
<p className="kol-mono-12 text-fg-48">
|
|
451
|
+
{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))}
|
|
452
|
+
</p>
|
|
453
|
+
) : (
|
|
454
|
+
<p className="kol-mono-12 text-fg-48">
|
|
455
|
+
{folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
|
|
456
|
+
{rawFiles.length} {rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
|
|
457
|
+
{(!prefix || flat) && rawFiles.length !== bucketFiles && <span className="text-fg-32">{' · bucket: '}{bucketFiles} files · {formatSize(bucketBytes)}</span>}
|
|
458
|
+
{systemCount > 0 && <span className="text-fg-32">{' · '}{systemCount} system files hidden</span>}
|
|
459
|
+
</p>
|
|
460
|
+
)}
|
|
461
|
+
{void onOpen}
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/* ══ LIBRARY — the content-filters wall ═══════════════════════════════════ */
|
|
468
|
+
export function MediaLibraryLibrary({
|
|
469
|
+
client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
|
|
470
|
+
headerActions, refreshKey, header = true, className = '',
|
|
471
|
+
}) {
|
|
472
|
+
const [ownBucket, setOwnBucket] = useState(bucket)
|
|
473
|
+
const bucketId = bucket ?? ownBucket
|
|
474
|
+
const lib = useBucketLibrary({ client, bucket: bucketId, defaults, settings: settingsProp, onSettingsChange, refreshKey })
|
|
475
|
+
const { buckets, bucketMeta, settings, setSettings, objects, setObjects, error, mediaUrl, downloadUrl, writable } = lib
|
|
476
|
+
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
477
|
+
const [lightboxIndex, setLightboxIndex] = useState(null)
|
|
478
|
+
const [editingKey, setEditingKey] = useState(null)
|
|
479
|
+
const [editingValue, setEditingValue] = useState('')
|
|
480
|
+
const [renaming, setRenaming] = useState(false)
|
|
481
|
+
const [selectMode, setSelectMode] = useState(false)
|
|
482
|
+
const [selected, setSelected] = useState(() => new Set())
|
|
483
|
+
const [lastIdx, setLastIdx] = useState(null)
|
|
484
|
+
const [busy, setBusy] = useState(false)
|
|
485
|
+
const sortedRef = useRef([])
|
|
486
|
+
/* the wall is FLAT by default — the folders are the browse page's */
|
|
487
|
+
const { flat = true, layout, sortBy, sortDir, pageSize, videoPreview } = { flat: true, ...settings }
|
|
488
|
+
const kinds = new Set(settings.kinds)
|
|
489
|
+
const setFlat = (v) => setSettings({ ...settings, flat: v })
|
|
490
|
+
const setLayout = (v) => setSettings({ ...settings, layout: v })
|
|
491
|
+
const PAGE = pageSize || Infinity
|
|
492
|
+
const [visible, setVisible] = useState(PAGE)
|
|
493
|
+
const listId = `${prefix}|${flat}|${[...kinds].sort().join(',')}|${pageSize}|${refreshKey}|${bucketMeta.id}`
|
|
494
|
+
const [lastListId, setLastListId] = useState(listId)
|
|
495
|
+
if (listId !== lastListId) { setLastListId(listId); setVisible(PAGE) }
|
|
496
|
+
const handleSort = (field) => setSettings(sortBy === field ? { ...settings, sortDir: sortDir === 'asc' ? 'desc' : 'asc' } : { ...settings, sortBy: field, sortDir: 'asc' })
|
|
497
|
+
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (!selectMode) return undefined
|
|
500
|
+
const onKey = (e) => { if (e.key === 'Escape') { setSelectMode(false); setSelected(new Set()); setLastIdx(null) } }
|
|
501
|
+
window.addEventListener('keydown', onKey)
|
|
502
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
503
|
+
}, [selectMode])
|
|
504
|
+
|
|
505
|
+
if (error) return <p className="kol-mono-12 text-ui-error">Error: {error}</p>
|
|
506
|
+
|
|
507
|
+
const scoped = prefix ? objects.filter((o) => o.key.startsWith(prefix)) : objects
|
|
508
|
+
const { files: dirFiles } = partition(scoped, prefix)
|
|
509
|
+
const flatFiles = scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key }))
|
|
510
|
+
const levelFiles = flat ? flatFiles : dirFiles
|
|
511
|
+
const systemCount = levelFiles.filter((o) => isSystemFile(o.key)).length
|
|
512
|
+
const visibleFiles = kinds.has('system') ? levelFiles : levelFiles.filter((o) => !isSystemFile(o.key))
|
|
513
|
+
const keySet = new Set(objects.map((o) => o.key))
|
|
514
|
+
const grouped = settings.groupVariants ? groupVariants(visibleFiles) : visibleFiles
|
|
515
|
+
const rawFiles = (settings.foldSegments ? groupSegments(grouped) : grouped).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet) }))
|
|
516
|
+
const profile = profileOf(objects, rawFiles, systemCount)
|
|
517
|
+
const presentKinds = Object.keys(profile.kinds).sort()
|
|
518
|
+
const files = rawFiles.filter((o) => kinds.has(o.kind))
|
|
519
|
+
const chipKinds = presentKinds.filter((k) => kinds.has(k))
|
|
520
|
+
const totalBytes = rawFiles.reduce((n, o) => n + (o.totalSize ?? o.size ?? 0), 0)
|
|
521
|
+
|
|
522
|
+
const handleCopy = (key) => navigator.clipboard.writeText(mediaUrl(key))
|
|
523
|
+
const handleDelete = async (key) => {
|
|
524
|
+
if (!confirm(`Delete "${key}"? This cannot be undone.`)) return
|
|
525
|
+
try { await client.deleteObject(key); setObjects((prev) => prev.filter((o) => o.key !== key)) } catch (e) { alert(`Delete failed: ${e.message}`) }
|
|
526
|
+
}
|
|
527
|
+
const startRename = (key) => { setEditingKey(key); setEditingValue(key) }
|
|
528
|
+
const cancelRename = () => { setEditingKey(null); setEditingValue('') }
|
|
529
|
+
const commitRename = async () => {
|
|
530
|
+
const from = editingKey; const to = editingValue.trim()
|
|
531
|
+
if (!from || !to || from === to) { cancelRename(); return }
|
|
532
|
+
setRenaming(true)
|
|
533
|
+
try { const r = await client.renameObject(from, to); const newKey = r?.to || to; setObjects((prev) => prev.map((o) => (o.key === from ? { ...o, key: newKey } : o))); setEditingKey(null); setEditingValue('') }
|
|
534
|
+
catch (e) { alert(`Rename failed: ${e.message}`) } finally { setRenaming(false) }
|
|
535
|
+
}
|
|
536
|
+
const toggleSelect = (idx, key, shift) => {
|
|
537
|
+
setSelected((prev) => {
|
|
538
|
+
const next = new Set(prev)
|
|
539
|
+
if (shift && lastIdx !== null) { const [a, b] = [Math.min(lastIdx, idx), Math.max(lastIdx, idx)]; for (let i = a; i <= b; i++) next.add(sortedRef.current[i].key) }
|
|
540
|
+
else if (next.has(key)) next.delete(key)
|
|
541
|
+
else next.add(key)
|
|
542
|
+
return next
|
|
543
|
+
})
|
|
544
|
+
setLastIdx(idx)
|
|
545
|
+
}
|
|
546
|
+
const selectAll = () => setSelected(new Set(sortedRef.current.map((o) => o.key)))
|
|
547
|
+
const exitSelect = () => { setSelectMode(false); setSelected(new Set()); setLastIdx(null) }
|
|
548
|
+
const batchMove = async () => {
|
|
549
|
+
const folder = prompt(`Move ${selected.size} file(s) into folder (under ${prefix || 'root'}):`)
|
|
550
|
+
if (folder === null) return
|
|
551
|
+
const clean = folder.replace(/^\/+|\/+$/g, '').trim()
|
|
552
|
+
if (!clean) return
|
|
553
|
+
setBusy(true)
|
|
554
|
+
const remap = new Map(); const failures = []
|
|
555
|
+
for (const from of selected) {
|
|
556
|
+
const to = moveKey(from, prefix, clean)
|
|
557
|
+
if (from === to) continue
|
|
558
|
+
try { const r = await client.renameObject(from, to); remap.set(from, r?.to || to) } catch (e) { failures.push(`${from.split('/').pop()}: ${e.message}`) }
|
|
559
|
+
}
|
|
560
|
+
setObjects((prev) => prev.map((o) => (remap.has(o.key) ? { ...o, key: remap.get(o.key) } : o)))
|
|
561
|
+
setBusy(false); exitSelect()
|
|
562
|
+
if (failures.length) alert(`Moved ${remap.size}. ${failures.length} failed:\n${failures.join('\n')}`)
|
|
563
|
+
}
|
|
564
|
+
const batchDelete = async () => {
|
|
565
|
+
if (!confirm(`Delete ${selected.size} file(s)? This cannot be undone.`)) return
|
|
566
|
+
setBusy(true)
|
|
567
|
+
const done = []; const failures = []
|
|
568
|
+
for (const key of selected) { try { await client.deleteObject(key); done.push(key) } catch (e) { failures.push(`${key.split('/').pop()}: ${e.message}`) } }
|
|
569
|
+
setObjects((prev) => prev.filter((o) => !done.includes(o.key)))
|
|
570
|
+
setBusy(false); exitSelect()
|
|
571
|
+
if (failures.length) alert(`Deleted ${done.length}. ${failures.length} failed:\n${failures.join('\n')}`)
|
|
572
|
+
}
|
|
573
|
+
const batchDownload = () => {
|
|
574
|
+
for (const key of selected) { const a = document.createElement('a'); a.href = downloadUrl(key); a.download = key.split('/').pop(); document.body.appendChild(a); a.click(); a.remove() }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const renderActions = (o, row = false) => {
|
|
578
|
+
if (editingKey === o.key) {
|
|
579
|
+
return (
|
|
580
|
+
<div className="flex gap-2">
|
|
581
|
+
<Button variant="primary" size="sm" onClick={commitRename} disabled={renaming}>{renaming ? 'Saving…' : 'Save'}</Button>
|
|
582
|
+
<Button variant="ghost" size="sm" onClick={cancelRename} disabled={renaming}>Cancel</Button>
|
|
583
|
+
</div>
|
|
584
|
+
)
|
|
585
|
+
}
|
|
586
|
+
return (
|
|
587
|
+
<div className={row ? 'flex translate-y-[2px] items-center gap-2' : 'flex h-full flex-col items-center justify-between'}>
|
|
588
|
+
{!selectMode && (
|
|
589
|
+
<>
|
|
590
|
+
<ActionButton chrome="inline" size="sm" icon="copy" confirmIcon="check" label="Copy URL" confirmLabel="Copied" onAction={() => handleCopy(o.key)} />
|
|
591
|
+
{writable && !row && <span className="invisible" aria-hidden><ActionButton chrome="inline" size="sm" icon="trash" label="" /></span>}
|
|
592
|
+
</>
|
|
593
|
+
)}
|
|
594
|
+
{writable && selectMode && (
|
|
595
|
+
<>
|
|
596
|
+
<ActionButton chrome="inline" size="sm" icon="edit" label="Rename" onAction={() => startRename(o.key)} />
|
|
597
|
+
<ActionButton chrome="inline" size="sm" icon="trash" confirmIcon="check" label="Delete" confirmLabel="Deleted" onAction={() => handleDelete(o.key)} />
|
|
598
|
+
</>
|
|
599
|
+
)}
|
|
600
|
+
</div>
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
const renderNameCell = (o) => {
|
|
604
|
+
if (editingKey === o.key) {
|
|
605
|
+
return <Input size="sm" width="100%" value={editingValue} autoFocus disabled={renaming} onChange={(e) => setEditingValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') cancelRename() }} />
|
|
606
|
+
}
|
|
607
|
+
return o.displayKey.split('/').pop()
|
|
608
|
+
}
|
|
609
|
+
const renderThumb = (o, onClick) => {
|
|
610
|
+
const media = isImage(o.contentType) || isVideo(o.contentType)
|
|
611
|
+
const poster = (isVideo(o.contentType) || kindOf(o) === 'playlist') ? posterFor(o.key, keySet) : null
|
|
612
|
+
const imgSrc = isImage(o.contentType) ? mediaUrl(o.key) : poster && videoPreview !== 'none' ? mediaUrl(poster) : null
|
|
613
|
+
if (imgSrc) return <img src={imgSrc} alt="" loading="lazy" className={onClick ? 'cursor-zoom-in' : undefined} onClick={onClick || undefined} />
|
|
614
|
+
return (
|
|
615
|
+
<div className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${media && onClick ? ' cursor-zoom-in' : ''}`} onClick={media && onClick ? onClick : undefined}>
|
|
616
|
+
{isVideo(o.contentType) && videoPreview === 'autoload' ? <video src={mediaUrl(o.key)} className="w-full h-full object-cover" muted preload="metadata" />
|
|
617
|
+
: isVideo(o.contentType) ? <span className="kol-mono-12 text-fg-48">video</span>
|
|
618
|
+
: <span className="kol-mono-12 text-fg-48">{KIND_LABEL[kindOf(o)] || 'file'}{o.segmentCount ? ` ${o.segmentCount}` : ''}</span>}
|
|
619
|
+
</div>
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const layoutOptions = [
|
|
624
|
+
...(writable ? [{ value: 'grid', label: selectMode ? 'CANCEL' : 'SELECT', active: !selectMode, title: 'Select multiple files', onClick: () => (selectMode ? exitSelect() : setSelectMode(true)) }] : []),
|
|
625
|
+
{ value: 'list', label: flat ? 'TREE' : 'FLAT', active: flat, title: 'Show all files recursively', onClick: () => setFlat(!flat) },
|
|
626
|
+
]
|
|
627
|
+
|
|
628
|
+
return (
|
|
629
|
+
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
630
|
+
{header && (
|
|
631
|
+
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={false} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions}
|
|
632
|
+
onBucket={(v) => { if (v === 'all') return; if (bucket == null) setOwnBucket(v); onBucketChange?.(v) }} onSettings={() => setSettingsOpen(true)} />
|
|
633
|
+
)}
|
|
634
|
+
{settingsOpen && (
|
|
635
|
+
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} />
|
|
636
|
+
)}
|
|
637
|
+
|
|
638
|
+
<p className="kol-mono-12 text-fg-48">
|
|
639
|
+
{files.length !== rawFiles.length ? `${files.length} of ${rawFiles.length}` : rawFiles.length}{' '}{rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
|
|
640
|
+
{systemCount > 0 && <span className="text-fg-32">{' · '}{systemCount} system files hidden</span>}
|
|
641
|
+
</p>
|
|
642
|
+
|
|
643
|
+
<ContentFilters
|
|
644
|
+
items={files}
|
|
645
|
+
title="Files"
|
|
646
|
+
totalCount={files.length}
|
|
647
|
+
searchKeys={['displayKey']}
|
|
648
|
+
filterGroups={[{ label: 'Kind', key: 'kind', values: chipKinds }]}
|
|
649
|
+
mutuallyExclusiveFilters={['kind']}
|
|
650
|
+
leadingActions={writable ? (
|
|
651
|
+
<div className={`flex items-center gap-4 kol-mono-12 text-fg-48 h-8 ${selectMode ? '' : 'hidden'}`} aria-hidden={!selectMode}>
|
|
652
|
+
<span className="text-fg-default">{selected.size} selected</span>
|
|
653
|
+
<button type="button" onClick={selectAll} className="hover:text-fg-default transition-colors">Select all ({files.length})</button>
|
|
654
|
+
<Divider variant="vertical" />
|
|
655
|
+
<button type="button" disabled={!selected.size || busy} onClick={batchMove} className="hover:text-fg-default transition-colors disabled:opacity-40 disabled:pointer-events-none">Move to folder…</button>
|
|
656
|
+
<button type="button" disabled={!selected.size || busy} onClick={batchDownload} className="hover:text-fg-default transition-colors disabled:opacity-40 disabled:pointer-events-none">Download</button>
|
|
657
|
+
<button type="button" disabled={!selected.size || busy} onClick={batchDelete} className="hover:text-fg-default transition-colors disabled:opacity-40 disabled:pointer-events-none">Delete</button>
|
|
658
|
+
{busy && <span>working…</span>}
|
|
659
|
+
</div>
|
|
660
|
+
) : undefined}
|
|
661
|
+
layoutPlacement="header"
|
|
662
|
+
layoutClassName="kol-helper-14"
|
|
663
|
+
layoutOptions={layoutOptions}
|
|
664
|
+
layout={layout}
|
|
665
|
+
onLayoutChange={setLayout}
|
|
666
|
+
trailingActions={<div className="flex items-center gap-6"><ViewToggle viewMode={layout} onViewChange={setLayout} variant="icon" options={LAYOUT_OPTIONS} /></div>}
|
|
667
|
+
belowActions={layout === 'off' ? null : <div className="h-8 flex items-center"><SortControls options={SORT_OPTIONS} sortBy={sortBy} sortDir={sortDir} onSort={handleSort} /></div>}
|
|
668
|
+
renderItem={(filtered) => {
|
|
669
|
+
if (layout === 'off') return null
|
|
670
|
+
const sorted = sortFiles(filtered, sortBy, sortDir)
|
|
671
|
+
sortedRef.current = sorted
|
|
672
|
+
const shown = sorted.slice(0, visible)
|
|
673
|
+
const more = sorted.length - shown.length
|
|
674
|
+
return (
|
|
675
|
+
<div className="flex flex-col gap-3">
|
|
676
|
+
{sorted.length === 0 ? null : layout !== 'grid' ? (
|
|
677
|
+
<div className="flex flex-col">
|
|
678
|
+
{shown.map((o, idx) => (
|
|
679
|
+
<ContentRow key={o.key} variant="default" media={renderThumb(o, selectMode ? null : () => setLightboxIndex(idx))} title={renderNameCell(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
|
|
680
|
+
actions={renderActions(o, true)} selected={selected.has(o.key)} onClick={selectMode ? (e) => toggleSelect(idx, o.key, e.shiftKey) : undefined} />
|
|
681
|
+
))}
|
|
682
|
+
</div>
|
|
683
|
+
) : (
|
|
684
|
+
<div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
|
685
|
+
{shown.map((o, idx) => (
|
|
686
|
+
<ContentCard key={o.key} variant="default" media={renderThumb(o, selectMode ? null : () => setLightboxIndex(idx))}
|
|
687
|
+
control={<ActionButton chrome="media" icon="download" confirmIcon="check" label="Download" confirmLabel="Downloaded" href={downloadUrl(o.key)} />}
|
|
688
|
+
controlStart={selectMode ? <ToggleCheckbox variant="media" checked={selected.has(o.key)} onChange={() => toggleSelect(idx, o.key, false)} onClick={(e) => e.stopPropagation()} aria-label={`Select ${o.key}`} /> : undefined}
|
|
689
|
+
title={renderNameCell(o)} date={formatDate(o.uploaded)} size={<SizeOrDownload href={downloadUrl(o.key)}>{formatSize(o.size)}</SizeOrDownload>}
|
|
690
|
+
actions={renderActions(o)} selected={selected.has(o.key)} onClick={selectMode ? (e) => toggleSelect(idx, o.key, e.shiftKey) : undefined} />
|
|
691
|
+
))}
|
|
692
|
+
</div>
|
|
693
|
+
)}
|
|
694
|
+
{more > 0 && (
|
|
695
|
+
<button type="button" onClick={() => setVisible((v) => v + PAGE)} className="kol-mono-12 text-fg-48 hover:text-fg-default transition-colors self-start py-2">
|
|
696
|
+
Show {Math.min(more, PAGE)} more · {more} remaining
|
|
697
|
+
</button>
|
|
698
|
+
)}
|
|
699
|
+
{lightboxIndex !== null && (
|
|
700
|
+
<MediaInspector files={sorted} index={lightboxIndex} onClose={() => setLightboxIndex(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
|
|
701
|
+
onPrev={() => setLightboxIndex((i) => (i - 1 + sorted.length) % sorted.length)} onNext={() => setLightboxIndex((i) => (i + 1) % sorted.length)} />
|
|
702
|
+
)}
|
|
703
|
+
</div>
|
|
704
|
+
)
|
|
705
|
+
}}
|
|
706
|
+
/>
|
|
707
|
+
</div>
|
|
708
|
+
)
|
|
709
|
+
}
|
|
@@ -49,3 +49,92 @@ export const KIND_LABEL = {
|
|
|
49
49
|
playlist: 'HLS', font: 'font', archive: 'archive', segments: 'HLS segments',
|
|
50
50
|
system: 'system', other: 'file',
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/* The chips a bucket always shows (user ruling 2026-08-27): media, then the text
|
|
54
|
+
* kinds, then code. Every other kind appears only when the bucket has some. */
|
|
55
|
+
export const DEFAULT_KINDS = ['audio', 'video', 'image', 'markdown', 'json', 'yaml', 'text', 'code']
|
|
56
|
+
|
|
57
|
+
/* ── The rest of kol-r2b2's lib/media.js, promoted verbatim 2026-08-27
|
|
58
|
+
* (MediaLibraryPages) — segments, poster pairing, variant grouping, partition. */
|
|
59
|
+
|
|
60
|
+
const SEGMENT_RE = /^segment_\d+\.ts$/i
|
|
61
|
+
export function isSegment(key) {
|
|
62
|
+
return SEGMENT_RE.test(key.slice(key.lastIndexOf('/') + 1))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Fold each folder's segment_*.ts files into one synthetic entry carrying the
|
|
66
|
+
* set's file count and total bytes — one row saying "231 segments, 340 MB". */
|
|
67
|
+
export function groupSegments(files) {
|
|
68
|
+
const bins = new Map()
|
|
69
|
+
const out = []
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
if (!isSegment(f.key)) { out.push(f); continue }
|
|
72
|
+
const dir = f.displayKey.includes('/') ? f.displayKey.slice(0, f.displayKey.lastIndexOf('/') + 1) : ''
|
|
73
|
+
const list = bins.get(dir) || []
|
|
74
|
+
list.push(f)
|
|
75
|
+
bins.set(dir, list)
|
|
76
|
+
}
|
|
77
|
+
for (const [dir, list] of bins) {
|
|
78
|
+
if (list.length === 1) { out.push(list[0]); continue }
|
|
79
|
+
const bytes = list.reduce((n, f) => n + (f.size || 0), 0)
|
|
80
|
+
out.push({ ...list[0], displayKey: `${dir}segment_*.ts`, segmentCount: list.length, totalSize: bytes, size: bytes, contentType: null, forcedKind: 'segments' })
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The sibling <name>.png beside a video — the poster, so preload="none" stays honest. */
|
|
86
|
+
export function posterFor(videoKey, keySet) {
|
|
87
|
+
const stem = videoKey.slice(0, videoKey.lastIndexOf('.'))
|
|
88
|
+
for (const ext of ['png', 'jpg', 'jpeg', 'webp']) {
|
|
89
|
+
const candidate = `${stem}.${ext}`
|
|
90
|
+
if (keySet.has(candidate)) return candidate
|
|
91
|
+
}
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const VARIANT_RE = /^(.*)-(\d{2,5})$/
|
|
96
|
+
const MIN_VARIANT_WIDTH = 100
|
|
97
|
+
function splitVariant(displayKey) {
|
|
98
|
+
const dot = displayKey.lastIndexOf('.')
|
|
99
|
+
if (dot === -1) return null
|
|
100
|
+
const stem = displayKey.slice(0, dot)
|
|
101
|
+
const ext = displayKey.slice(dot)
|
|
102
|
+
const m = VARIANT_RE.exec(stem)
|
|
103
|
+
if (!m) return null
|
|
104
|
+
const width = Number(m[2])
|
|
105
|
+
if (width < MIN_VARIANT_WIDTH) return null
|
|
106
|
+
return { base: m[1] + ext, width }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Collapse resolution sets (name-566 / -1132 / -1700 / -2840) into one entry —
|
|
110
|
+
* the SMALLEST variant, carrying `variants` (ascending) and `totalSize`. */
|
|
111
|
+
export function groupVariants(files) {
|
|
112
|
+
const groups = new Map()
|
|
113
|
+
const out = []
|
|
114
|
+
for (const f of files) {
|
|
115
|
+
const v = kindOf(f) === 'image' ? splitVariant(f.displayKey) : null
|
|
116
|
+
if (!v) { out.push(f); continue }
|
|
117
|
+
const list = groups.get(v.base) || []
|
|
118
|
+
list.push({ ...f, width: v.width })
|
|
119
|
+
groups.set(v.base, list)
|
|
120
|
+
}
|
|
121
|
+
for (const [base, list] of groups) {
|
|
122
|
+
if (list.length < 2) { out.push(list[0]); continue }
|
|
123
|
+
list.sort((a, b) => a.width - b.width)
|
|
124
|
+
out.push({ ...list[0], displayKey: base, variants: list, totalSize: list.reduce((n, f) => n + (f.size || 0), 0) })
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Split a flat key list into { folders, files } relative to `prefix`. */
|
|
130
|
+
export function partition(objects, prefix) {
|
|
131
|
+
const folderSet = new Set()
|
|
132
|
+
const files = []
|
|
133
|
+
for (const o of objects) {
|
|
134
|
+
const rel = prefix ? o.key.slice(prefix.length) : o.key
|
|
135
|
+
const slash = rel.indexOf('/')
|
|
136
|
+
if (slash !== -1) folderSet.add(rel.slice(0, slash + 1))
|
|
137
|
+
else files.push({ ...o, displayKey: rel })
|
|
138
|
+
}
|
|
139
|
+
return { folders: [...folderSet].sort(), files }
|
|
140
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/* The export-specs ladder (the img-canvas.sh presets) — kol-r2b2's lib/ratios.js,
|
|
2
|
+
* promoted verbatim 2026-08-27 (MediaLibraryPages). The column preview frame
|
|
3
|
+
* takes the NEAREST of these to the file's own ratio, so a story-shaped image
|
|
4
|
+
* gets a tall box and a banner a wide one instead of everything letterboxed
|
|
5
|
+
* into a square. Nearest by LOG distance: ratios are multiplicative. 2:3 / 3:2
|
|
6
|
+
* are deliberately out — not img-canvas presets. */
|
|
7
|
+
export const RATIOS = [[9, 16], [3, 5], [4, 5], [1, 1], [5, 4], [5, 3], [16, 9]]
|
|
8
|
+
|
|
9
|
+
export function nearestRatio(w, h) {
|
|
10
|
+
if (!w || !h) return '1 / 1'
|
|
11
|
+
const target = Math.log(w / h)
|
|
12
|
+
let best = { d: Infinity, css: '1 / 1' }
|
|
13
|
+
for (const [a, b] of RATIOS) {
|
|
14
|
+
const d = Math.abs(Math.log(a / b) - target)
|
|
15
|
+
if (d < best.d) best = { d, css: `${a} / ${b}` }
|
|
16
|
+
}
|
|
17
|
+
return best.css
|
|
18
|
+
}
|