@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.116.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.23.0"
35
+ "@kolkrabbi/kol-icons": "^0.24.0"
36
36
  },
37
37
  "files": [
38
38
  "src",
@@ -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(' ')
@@ -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 — FileList's read-only render ─────────────────────────────── */
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, two variants. The user's ruling 2026-08-01:
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." `page` and `modal` differ in the shell around the body
952
- * and in whether picking closes.
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
- * @param {string} variant 'page' (in-flow, FileList's render) | 'modal' (overlay picker)
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 withProvider(<BrowserShell onSelect={onSelect} />, opts)
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
+ }