@kolkrabbi/kol-component 0.208.0 → 0.210.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/molecules/ContentCard.jsx +43 -16
- package/src/molecules/ContentRow.jsx +9 -2
- package/src/molecules/ContentText.jsx +24 -0
- package/src/molecules/KindPreview.jsx +33 -3
- package/src/organisms/ColumnBrowser.jsx +59 -19
- package/src/organisms/MediaLibraryPages.jsx +15 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "KOL design-system components
|
|
3
|
+
"version": "0.210.0",
|
|
4
|
+
"description": "KOL design-system components \u2014 atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
-
"@kolkrabbi/kol-icons": "
|
|
37
|
+
"@kolkrabbi/kol-icons": "workspace:^"
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
40
|
"src",
|
|
@@ -56,4 +56,4 @@
|
|
|
56
56
|
"access": "public"
|
|
57
57
|
},
|
|
58
58
|
"sideEffects": false
|
|
59
|
-
}
|
|
59
|
+
}
|
|
@@ -244,6 +244,23 @@ export default function ContentCard({
|
|
|
244
244
|
* One prop is not done until every derivation reads it. */
|
|
245
245
|
const framed = box.border != null || (bg ?? box.bg) != null
|
|
246
246
|
|
|
247
|
+
/* NO COVER IS NOT A MISSING COVER (content-card-needs-no-cover,
|
|
248
|
+
* kol-client-olina 2026-09-04). `ContentMedia` turns absent children into an
|
|
249
|
+
* `AssetPlaceholder` on purpose — a card whose image failed must not collapse
|
|
250
|
+
* into a text blob, and that dashed MISSING plate is the honest answer for a
|
|
251
|
+
* media library. It is the wrong answer for a markdown note in a database
|
|
252
|
+
* row, which has no picture and never will: their `/notes` page drew a wall
|
|
253
|
+
* of dashed frames with nothing misconfigured.
|
|
254
|
+
*
|
|
255
|
+
* The card cannot tell the two apart — only the consumer knows whether a
|
|
256
|
+
* cover is owed — so it is declared, and `media={false}` declares it: no
|
|
257
|
+
* media slot at all, text takes the full width. `false` rather than a new
|
|
258
|
+
* prop because it is React's own idiom for "render nothing", and because
|
|
259
|
+
* `media={false}` today renders an EMPTY framed box, a state nobody can want.
|
|
260
|
+
* `media={null}` and an omitted `media` keep the placeholder, so every
|
|
261
|
+
* existing consumer is untouched. */
|
|
262
|
+
const noMedia = media === false
|
|
263
|
+
|
|
247
264
|
const textNode = hasPlate ? (
|
|
248
265
|
<div
|
|
249
266
|
className="kol-card-plate relative"
|
|
@@ -251,7 +268,7 @@ export default function ContentCard({
|
|
|
251
268
|
'--kol-plate-pad': padding,
|
|
252
269
|
'--kol-plate-pad-md': box.padMd,
|
|
253
270
|
padding,
|
|
254
|
-
marginTop: box.layout === 'stack' ? box.mediaGap : undefined,
|
|
271
|
+
marginTop: box.layout === 'stack' && !noMedia ? box.mediaGap : undefined,
|
|
255
272
|
borderTop: (plateRule ?? box.plateTop) ? '1px solid var(--kol-fg-04)' : undefined,
|
|
256
273
|
background: box.layout === 'drawer' ? 'var(--kol-surface-inverse)' : box.plateBg,
|
|
257
274
|
color: box.layout === 'drawer' ? 'var(--kol-fg-inverse)' : undefined,
|
|
@@ -311,11 +328,15 @@ export default function ContentCard({
|
|
|
311
328
|
box.layout === 'stack' ? (
|
|
312
329
|
<>
|
|
313
330
|
{heroHeader}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
331
|
+
{/* the frame-corner controls go with the frame — `.kol-frame-control`
|
|
332
|
+
* positions against the media box, and there is none */}
|
|
333
|
+
{!noMedia && (
|
|
334
|
+
<div className="relative">
|
|
335
|
+
<ContentMedia ratio={r} {...mediaProps}>{media}</ContentMedia>
|
|
336
|
+
{controlNode}
|
|
337
|
+
{controlStartNode}
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
319
340
|
{textNode}
|
|
320
341
|
</>
|
|
321
342
|
) : box.layout === 'fill-card' ? (
|
|
@@ -326,6 +347,7 @@ export default function ContentCard({
|
|
|
326
347
|
* card gave each half 174 and the prose 126, and one module's specs
|
|
327
348
|
* ran 1177px tall. The 50% half is a `md:` class now, not an inline
|
|
328
349
|
* style — an inline flex-basis has no breakpoint. */}
|
|
350
|
+
{!noMedia && (
|
|
329
351
|
<div
|
|
330
352
|
className={`flex-1 min-w-0 min-h-0 relative overflow-hidden ${expanded ? 'max-md:flex-none max-md:aspect-[var(--kol-card-ratio)] md:flex-[0_0_50%]' : ''}`}
|
|
331
353
|
style={{ ...(expanded ? { '--kol-card-ratio': r ?? '3 / 2' } : null), ...(doFlip ? { perspective: '1000px' } : null) }}
|
|
@@ -344,6 +366,7 @@ export default function ContentCard({
|
|
|
344
366
|
{controlNode}
|
|
345
367
|
{controlStartNode}
|
|
346
368
|
</div>
|
|
369
|
+
)}
|
|
347
370
|
{expanded ? (
|
|
348
371
|
<div
|
|
349
372
|
className="flex flex-1 flex-col justify-between overflow-auto"
|
|
@@ -355,11 +378,13 @@ export default function ContentCard({
|
|
|
355
378
|
</>
|
|
356
379
|
) : box.layout === 'drawer' ? (
|
|
357
380
|
<>
|
|
358
|
-
|
|
359
|
-
<
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
381
|
+
{!noMedia && (
|
|
382
|
+
<div className="relative h-full">
|
|
383
|
+
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
384
|
+
{controlNode}
|
|
385
|
+
{controlStartNode}
|
|
386
|
+
</div>
|
|
387
|
+
)}
|
|
363
388
|
{/* the plate is INVERSE and hidden until hover — `kol-card-drawer` owns
|
|
364
389
|
* the reveal so the transition sits with the rest of the chrome */}
|
|
365
390
|
{textNode && <div className="kol-card-drawer">{textNode}</div>}
|
|
@@ -371,11 +396,13 @@ export default function ContentCard({
|
|
|
371
396
|
* choreography (kol-theme `.kol-card.has-reveal`), the consumer owns the
|
|
372
397
|
* node (what it says and which face it wears are never the family's). */
|
|
373
398
|
<>
|
|
374
|
-
|
|
375
|
-
<
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
399
|
+
{!noMedia && (
|
|
400
|
+
<div className="kol-card-canvas-media absolute" style={{ inset: 0 }}>
|
|
401
|
+
<ContentMedia ratio={null} {...mediaProps}>{media}</ContentMedia>
|
|
402
|
+
{controlNode}
|
|
403
|
+
{controlStartNode}
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
379
406
|
{textNode}
|
|
380
407
|
{reveal != null && (
|
|
381
408
|
<div className="kol-card-reveal absolute inset-0 flex items-center justify-center p-8 pointer-events-none" style={{ zIndex: 2 }}>{reveal}</div>
|
|
@@ -14,7 +14,8 @@ import ContentText from './ContentText.jsx'
|
|
|
14
14
|
* (`default` is an alias of `file`). `roster` is the pickable
|
|
15
15
|
* row: filled tile, no border or divider, a FIXED 56px height the
|
|
16
16
|
* content fills, a 40px square thumb and two truncated lines.
|
|
17
|
-
* @param {ReactNode} media
|
|
17
|
+
* @param {ReactNode|false} media thumb content (omit → placeholder; `false` → no thumb at all — the
|
|
18
|
+
* content HAS no cover, as against one that failed to load)
|
|
18
19
|
* @param {number|'fill'} thumb thumb edge px — overrides the ruled default; 0 hides; `'fill'` = a square
|
|
19
20
|
* the height of the row's content, whatever the rung (WorkListingRowsAndFilters)
|
|
20
21
|
* @param {string} ratio thumb aspect-ratio — overrides the ruled default.
|
|
@@ -175,7 +176,13 @@ export default function ContentRow({
|
|
|
175
176
|
const aliased = ALIAS[variantProp] ?? variantProp
|
|
176
177
|
const variant = aliased.startsWith('showcase') && layout ? LAYOUT_KEY[layout] ?? aliased : aliased
|
|
177
178
|
const box = BOX[variant] ?? BOX.file
|
|
178
|
-
|
|
179
|
+
/* `media={false}` — NO COVER, not a missing one (content-card-needs-no-cover,
|
|
180
|
+
* kol-client-olina 2026-09-04). The row could already hide its thumb with
|
|
181
|
+
* `thumb={0}`, but that is a SIZE answering a question about MEANING, and a
|
|
182
|
+
* consumer listing coverless documents should not have to say it two
|
|
183
|
+
* different ways on the card and the row. `media={false}` reads the same in
|
|
184
|
+
* both; `thumb` still wins when it is passed explicitly. */
|
|
185
|
+
const thumbPx = thumb ?? (media === false ? 0 : box.thumb)
|
|
179
186
|
const padY = (paddingY != null ? `${paddingY}px` : String(box.pad)).trim().split(/\s+/)[0]
|
|
180
187
|
|
|
181
188
|
/* The md: STEP is a custom property, not a Tailwind variant. Tailwind cannot
|
|
@@ -214,6 +214,21 @@ const GAPS = {
|
|
|
214
214
|
* because ContentText is exported and a consumer can drive it directly. */
|
|
215
215
|
const ALIAS = { default: 'file', print: 'catalog', work: 'showcase', typeface: 'showcaseCanvas' }
|
|
216
216
|
|
|
217
|
+
/* dev-only, and warns ONCE per offending prop — a card family renders these by
|
|
218
|
+
* the hundred in a list and a per-render warning would bury the first one. */
|
|
219
|
+
const warned = new Set()
|
|
220
|
+
function warnUnknownSlots(unknown) {
|
|
221
|
+
for (const key of Object.keys(unknown)) {
|
|
222
|
+
if (warned.has(key)) continue
|
|
223
|
+
warned.add(key)
|
|
224
|
+
console.warn(
|
|
225
|
+
`ContentText: "${key}" is not a text slot and was ignored. `
|
|
226
|
+
+ 'Slots: title · body · eyebrow (kicker) · detail · date · size · meta · tags, '
|
|
227
|
+
+ 'each with a matching *Class. This warning is dev-only.',
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
217
232
|
export default function ContentText({
|
|
218
233
|
variant: variantProp = 'file',
|
|
219
234
|
form = 'card',
|
|
@@ -222,7 +237,16 @@ export default function ContentText({
|
|
|
222
237
|
titleClass, bodyClass, eyebrowClass, kickerClass, detailClass, dateClass, sizeClass, metaClass, tagsClass,
|
|
223
238
|
text,
|
|
224
239
|
className = '',
|
|
240
|
+
...unknown
|
|
225
241
|
}) {
|
|
242
|
+
/* AN UNKNOWN SLOT SAYS SO (content-card-needs-no-cover aside, kol-client-olina
|
|
243
|
+
* 2026-09-04). They passed `summary`, every card rendered EMPTY, and nothing
|
|
244
|
+
* — build, lint or console — said a word; it cost an hour. A card's text
|
|
245
|
+
* slots are a closed vocabulary and a typo in one is indistinguishable from
|
|
246
|
+
* missing data, which is the worst kind of silent failure: the page looks
|
|
247
|
+
* built and is blank. Dev only, and it names the vocabulary rather than just
|
|
248
|
+
* the mistake, because "summary is not a slot" does not tell you `body` is. */
|
|
249
|
+
if (import.meta.env.DEV) warnUnknownSlots(unknown)
|
|
226
250
|
/* `kicker` / `kickerClass` = aliases of `eyebrow` / `eyebrowClass` (2026-08-27) */
|
|
227
251
|
eyebrow = eyebrow ?? kicker
|
|
228
252
|
eyebrowClass = eyebrowClass ?? kickerClass
|
|
@@ -28,6 +28,11 @@ import DocPage from './DocPage.jsx'
|
|
|
28
28
|
* `<img>` so it can read the dimensions). Text fetches cap at `textLimit`.
|
|
29
29
|
*
|
|
30
30
|
* @param {object} o the object — `{ key, contentType?, displayKey?, segmentCount? }`
|
|
31
|
+
* @param {string} text content ALREADY IN HAND — skips the fetch and joins the same render
|
|
32
|
+
* path. For a source with no file behind it (a database row). Pass
|
|
33
|
+
* `kind` with it: there is no extension to classify by
|
|
34
|
+
* @param {string} kind classification override — `markdown` · `json` · `yaml` · `code` ·
|
|
35
|
+
* `text` · `image` · `video` · `audio`. Wins over `kindOf(o)`
|
|
31
36
|
* @param {Function} urlOf (o) => string — the object's public URL (default: `o.url`)
|
|
32
37
|
* @param {string} poster a poster URL for HLS (the sibling image)
|
|
33
38
|
* @param {Function} kindOf · extOf classification seams (defaults: the DS mediaKinds)
|
|
@@ -63,13 +68,38 @@ function useTextContent(url, enabled, limit) {
|
|
|
63
68
|
return { ...result, loading: enabled && result.url !== url }
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
export default function KindPreview({ o, urlOf = (x) => x.url, poster, kindOf = defaultKindOf, extOf = defaultExtOf, kindLabel = KIND_LABEL, textLimit = TEXT_LIMIT }) {
|
|
67
|
-
|
|
71
|
+
export default function KindPreview({ o, text: textProp, kind: kindProp, urlOf = (x) => x.url, poster, kindOf = defaultKindOf, extOf = defaultExtOf, kindLabel = KIND_LABEL, textLimit = TEXT_LIMIT }) {
|
|
72
|
+
/* CONTENT IN HAND, OR A URL TO FETCH (content-card-needs-no-cover,
|
|
73
|
+
* kol-client-olina 2026-09-04; shape ruled by kol-r2b2, whose component this
|
|
74
|
+
* is). A markdown note in a D1 row has no file and no URL behind it, so the
|
|
75
|
+
* fetch has nothing to pull — but the RENDER path is the one this component
|
|
76
|
+
* already owns, and a second previewer in the estate is what the lobby exists
|
|
77
|
+
* to prevent.
|
|
78
|
+
*
|
|
79
|
+
* `text` sits BESIDE `o`, not inside it: `o` is the media object — key, size,
|
|
80
|
+
* contentType, uploaded — and that shape is what `kindOf`, `posterFor` and
|
|
81
|
+
* the resolution-set grouping all read. Prose inside it would make the object
|
|
82
|
+
* mean two things.
|
|
83
|
+
*
|
|
84
|
+
* `kind` is not optional sugar. Classification reads the EXTENSION off the
|
|
85
|
+
* key, and a database row has no filename, so `text` alone lands on `other`
|
|
86
|
+
* and renders the right content as the wrong thing — worse than the
|
|
87
|
+
* placeholder it replaces. The two ship together or the seam half-works for
|
|
88
|
+
* the only caller that asked. Precedent: `urlOf`, `kindOf`, `kindLabel` and
|
|
89
|
+
* `partition` are already caller-supplied for the same reason — the DS does
|
|
90
|
+
* not decide what a consumer's object IS. */
|
|
91
|
+
const kind = kindProp ?? kindOf(o)
|
|
68
92
|
const url = urlOf(o)
|
|
69
93
|
const ext = extOf(o.key)
|
|
70
94
|
const name = o.displayKey ?? o.key
|
|
71
95
|
const isText = kind === 'text' || kind === 'code' || kind === 'markdown' || kind === 'json' || kind === 'yaml'
|
|
72
|
-
const
|
|
96
|
+
const fetched = useTextContent(url, isText && textProp == null, textLimit)
|
|
97
|
+
/* content in hand is never loading and never errors — there is nothing to
|
|
98
|
+
* wait for. It still honours `textLimit`, so one long note cannot outgrow a
|
|
99
|
+
* tile the way a fetched one cannot. */
|
|
100
|
+
const { loading, text, error, truncated } = textProp != null
|
|
101
|
+
? { loading: false, error: null, text: String(textProp).slice(0, textLimit), truncated: String(textProp).length > textLimit }
|
|
102
|
+
: fetched
|
|
73
103
|
|
|
74
104
|
if (kind === 'playlist') {
|
|
75
105
|
return (
|
|
@@ -57,6 +57,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
|
57
57
|
* @param {Function} kindOf (o) => 'image' | 'video' | 'audio' | string
|
|
58
58
|
* @param {Object} kindLabel kind → label shown when there is no visual preview
|
|
59
59
|
* @param {Function} formatSize (bytes) => string
|
|
60
|
+
* @param {Function} formatDate (isoString) => string — the row/preview date, ISO date-only by default
|
|
60
61
|
* @param {Function} partition (objects, level) => { folders: string[], files: object[] }
|
|
61
62
|
* @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
|
|
62
63
|
* @param {number} height controlled height in px (omit for uncontrolled)
|
|
@@ -72,6 +73,10 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
|
72
73
|
* @param {string} className extra classes on the browser
|
|
73
74
|
*
|
|
74
75
|
* @param {Function} thumbnailFor (o) => node — the 44px tile's content below the breakpoint; null falls back to the kind glyph. WHERE a thumbnail comes from is the consumer's: R2 and B2 serve originals, so a 44px tile can mean a 2 MB download
|
|
76
|
+
* NOTE, both seams: the key/prefix they receive is the one the browser was GIVEN, verbatim. A
|
|
77
|
+
* consumer that mounts a virtual root above real storage (multi-bucket browse hands these
|
|
78
|
+
* `KOL-R2B2/<label>/…`) must strip its own prefix before it can look anything up or build a URL —
|
|
79
|
+
* the browser has no idea which part of a path is yours (kol-r2b2 2026-09-04).
|
|
75
80
|
* @param {Function} folderMeta (prefix, view) => string — a folder's own meta line (`date · N items` in list, `N items` in grid). A SEAM, not a computation: counting by prefix is O(n) per folder, and a consumer that already holds a folder tree answers it for free
|
|
76
81
|
* @param {'list'|'grid'} stackView the mobile view below the breakpoint (default 'list'); `grid` is 3-up tiles, flat — a grid has nowhere to put an inline child list
|
|
77
82
|
*
|
|
@@ -97,6 +102,54 @@ const defaultFormatSize = (bytes) => {
|
|
|
97
102
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
98
103
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
|
99
104
|
}
|
|
105
|
+
|
|
106
|
+
/* THE STACK'S ROW ORDER — walked, not iterated, and lifted out of the component
|
|
107
|
+
* so it is reachable by a check (the defect below is invisible to every
|
|
108
|
+
* structural assertion: the rows are all present and each carries the right
|
|
109
|
+
* `depth`, they are merely in the wrong ORDER).
|
|
110
|
+
*
|
|
111
|
+
* The open folder's children have to land DIRECTLY under its own row. That
|
|
112
|
+
* adjacency IS inline expand, and inline expand is the whole ruling. A flat
|
|
113
|
+
* loop over `openPath` appends each level after the one before it, so an open
|
|
114
|
+
* folder's children surfaced below its last SIBLING and below that level's
|
|
115
|
+
* files (D1, kol-r2b2 2026-09-04) — which read as an indent bug rather than a
|
|
116
|
+
* sequence bug, and is how a comment claiming "the loop IS the path" survived.
|
|
117
|
+
*
|
|
118
|
+
* @param {object[]} objects the flat key space
|
|
119
|
+
* @param {string[]} openPath the open levels, shallowest first
|
|
120
|
+
* @param {Function} partition (objects, level) => { folders, files }
|
|
121
|
+
* @returns {object[]} rows — `folder` | `file` | `empty`, each with its `depth`
|
|
122
|
+
*/
|
|
123
|
+
export function stackRows(objects, openPath, partition) {
|
|
124
|
+
const rows = []
|
|
125
|
+
const walk = (depth) => {
|
|
126
|
+
const level = openPath[depth]
|
|
127
|
+
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
128
|
+
const openFolder = openPath[depth + 1]?.slice(level.length) ?? null
|
|
129
|
+
folders.forEach((f) => {
|
|
130
|
+
const isOpen = f === openFolder
|
|
131
|
+
rows.push({ kind: 'folder', key: level + f, level, name: f, depth, open: isOpen })
|
|
132
|
+
if (isOpen) walk(depth + 1)
|
|
133
|
+
})
|
|
134
|
+
files.forEach((o) => rows.push({ kind: 'file', key: o.key, o, depth, level }))
|
|
135
|
+
if (!folders.length && !files.length) rows.push({ kind: 'empty', key: level + '·empty', depth })
|
|
136
|
+
}
|
|
137
|
+
walk(0)
|
|
138
|
+
return rows
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* A ROW SHOWS A DATE, NOT A TIMESTAMP. `2026-06-19T02:00:14.629Z` is storage
|
|
142
|
+
* answering "when" in its own voice, and it shipped into the stack meta line
|
|
143
|
+
* because the value arrives as a string and a string renders (D2, kol-r2b2
|
|
144
|
+
* 2026-09-04). A seam beside `formatSize` for the same reason that one is a
|
|
145
|
+
* seam: how a date reads is the consumer's call, not the DS's. The default is
|
|
146
|
+
* ISO-8601 date-only — unambiguous everywhere, which no locale format is.
|
|
147
|
+
* Anything unparseable comes through verbatim rather than as `Invalid Date`. */
|
|
148
|
+
const defaultFormatDate = (v) => {
|
|
149
|
+
if (!v) return ''
|
|
150
|
+
const d = new Date(v)
|
|
151
|
+
return Number.isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10)
|
|
152
|
+
}
|
|
100
153
|
/* kol-r2b2's partition: the next path segment is a folder, the rest are files */
|
|
101
154
|
const defaultPartition = (objects, prefix) => {
|
|
102
155
|
const folderSet = new Set()
|
|
@@ -227,7 +280,7 @@ function Row({
|
|
|
227
280
|
)
|
|
228
281
|
}
|
|
229
282
|
|
|
230
|
-
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width }) {
|
|
283
|
+
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, width }) {
|
|
231
284
|
// Pixel size and length come from the loaded media itself — the bucket stores
|
|
232
285
|
// none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
|
|
233
286
|
// `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
|
|
@@ -243,7 +296,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width
|
|
|
243
296
|
['Size', formatSize(o.size)],
|
|
244
297
|
...(sized ? [['Dimensions', dims?.w ? `${dims.w} × ${dims.h} px` : '…']] : []),
|
|
245
298
|
...(timed ? [['Length', dims?.len != null ? formatLength(dims.len) : '…']] : []),
|
|
246
|
-
['Date',
|
|
299
|
+
['Date', formatDate(o.uploaded) || '—'],
|
|
247
300
|
]
|
|
248
301
|
return (
|
|
249
302
|
<div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
|
|
@@ -343,6 +396,7 @@ export default function ColumnBrowser({
|
|
|
343
396
|
kindOf = defaultKindOf,
|
|
344
397
|
kindLabel = DEFAULT_KIND_LABEL,
|
|
345
398
|
formatSize = defaultFormatSize,
|
|
399
|
+
formatDate = defaultFormatDate,
|
|
346
400
|
partition = defaultPartition,
|
|
347
401
|
renderPreview,
|
|
348
402
|
height,
|
|
@@ -563,23 +617,9 @@ export default function ColumnBrowser({
|
|
|
563
617
|
|
|
564
618
|
/* One flat row list, walked down the open path: each level's items, with
|
|
565
619
|
* the open folder's children spliced in directly under it. */
|
|
566
|
-
const rows =
|
|
567
|
-
openPath.forEach((level, depth) => {
|
|
568
|
-
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
569
|
-
const openFolder = openPath[depth + 1]?.slice(level.length) ?? null
|
|
570
|
-
folders.forEach((f) => {
|
|
571
|
-
const isOpen = f === openFolder
|
|
572
|
-
rows.push({
|
|
573
|
-
kind: 'folder', key: level + f, level, name: f, depth, open: isOpen,
|
|
574
|
-
})
|
|
575
|
-
/* the open folder's own children are pushed by the next iteration —
|
|
576
|
-
* nothing recursive here, the loop IS the path */
|
|
577
|
-
})
|
|
578
|
-
files.forEach((o) => rows.push({ kind: 'file', key: o.key, o, depth, level }))
|
|
579
|
-
if (!folders.length && !files.length) rows.push({ kind: 'empty', key: level + '·empty', depth })
|
|
580
|
-
})
|
|
620
|
+
const rows = stackRows(objects, openPath, partition)
|
|
581
621
|
|
|
582
|
-
const metaOf = (o) => [o.size != null && formatSize(o.size), o.uploaded].filter(Boolean).join(' · ')
|
|
622
|
+
const metaOf = (o) => [o.size != null && formatSize(o.size), formatDate(o.uploaded)].filter(Boolean).join(' · ')
|
|
583
623
|
|
|
584
624
|
return (
|
|
585
625
|
<div
|
|
@@ -741,7 +781,7 @@ export default function ColumnBrowser({
|
|
|
741
781
|
})}
|
|
742
782
|
{shown && (
|
|
743
783
|
<>
|
|
744
|
-
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} renderPreview={renderPreview} width={widthOf('preview')} />
|
|
784
|
+
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
|
|
745
785
|
<ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
|
|
746
786
|
</>
|
|
747
787
|
)}
|
|
@@ -314,6 +314,17 @@ const profileOf = (objects, rawFiles, systemCount) => ({
|
|
|
314
314
|
export function MediaLibraryBrowse({
|
|
315
315
|
client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
|
|
316
316
|
folderTree, headerActions, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '',
|
|
317
|
+
/* PASS-THROUGHS TO `ColumnBrowser`. A documented prop this page does not
|
|
318
|
+
* forward is a prop no consumer of the PAGE can reach, which makes the seam
|
|
319
|
+
* fictional — `thumbnailFor` and `folderMeta` shipped, were announced, and
|
|
320
|
+
* rendered as if they never existed because this signature ended before them
|
|
321
|
+
* (ColumnBrowserMobileViews items 10 + 13, kol-r2b2 2026-09-04). Second time
|
|
322
|
+
* this shape bit: `SettingsPanel` documented a `footer` slot this file
|
|
323
|
+
* hardcoded past. The rule the sweep leaves behind — WHEN A PROP IS ADDED TO
|
|
324
|
+
* `ColumnBrowser`, IT IS ADDED HERE IN THE SAME EDIT, unless the page holds a
|
|
325
|
+
* real opinion about it (it owns `height`, the widths and `partition`, so
|
|
326
|
+
* those are deliberately absent from this list). */
|
|
327
|
+
thumbnailFor, folderMeta, formatDate, stackView,
|
|
317
328
|
}) {
|
|
318
329
|
const [ownPrefix, setOwnPrefix] = useState('')
|
|
319
330
|
const prefix = prefixProp ?? ownPrefix
|
|
@@ -483,6 +494,10 @@ export function MediaLibraryBrowse({
|
|
|
483
494
|
kindOf={kindOf}
|
|
484
495
|
kindLabel={KIND_LABEL}
|
|
485
496
|
formatSize={formatSize}
|
|
497
|
+
formatDate={formatDate}
|
|
498
|
+
thumbnailFor={thumbnailFor}
|
|
499
|
+
folderMeta={folderMeta}
|
|
500
|
+
stackView={stackView}
|
|
486
501
|
renderPreview={(o) => {
|
|
487
502
|
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
488
503
|
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
|