@kolkrabbi/kol-component 0.117.0 → 0.118.1

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.117.0",
3
+ "version": "0.118.1",
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",
@@ -14,6 +14,9 @@
14
14
  "./utilities/*": "./src/utilities/*.jsx",
15
15
  "./utilities/id3": "./src/utilities/id3.js",
16
16
  "./utilities/frontmatter": "./src/utilities/frontmatter.js",
17
+ "./utilities/markdownToHtml": "./src/utilities/markdownToHtml.js",
18
+ "./utilities/mediaKinds": "./src/utilities/mediaKinds.js",
19
+ "./utilities/ratios": "./src/utilities/ratios.js",
17
20
  "./hooks/*": "./src/hooks/*.js"
18
21
  },
19
22
  "dependencies": {
@@ -32,7 +35,7 @@
32
35
  "react-router-dom": "^6.0.0 || ^7.0.0"
33
36
  },
34
37
  "devDependencies": {
35
- "@kolkrabbi/kol-icons": "^0.23.0"
38
+ "@kolkrabbi/kol-icons": "^0.24.0"
36
39
  },
37
40
  "files": [
38
41
  "src",
@@ -24,8 +24,8 @@ import { Icon } from '@kolkrabbi/kol-icons'
24
24
  * is the active … it would fit better on the light grey"): on a washed plane
25
25
  * (`pageWash`, `fg-04`) the default grey well reads as a second plate, so the
26
26
  * icon variant's two values swap — the well takes the dark chip
27
- * (`fg-absolute-24`), the active chip a lighter grey (`surface-tertiary`
28
- * user: "the active grey can be lighter"), the inactive hover a lighter wash. Theme rules on `.kol-tone-inverse`
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
29
  * (kol-theme ≥0.78.0); the same prop on `Dropdown` and `Input`.
30
30
  */
31
31
  const ViewToggle = ({
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'
@@ -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
+ }