@kolkrabbi/kol-component 0.207.0 → 0.209.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/hooks/svgExport.js +146 -0
- package/src/hooks/useHistory.js +121 -0
- package/src/index.js +5 -0
- package/src/organisms/ColumnBrowser.jsx +55 -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.209.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
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* svgExport — turn a finished SVG string into pixels, and embed the fonts it
|
|
3
|
+
* needs so the export reads like the screen.
|
|
4
|
+
*
|
|
5
|
+
* Packaged 2026-09-04 (`export-and-history-want-packaging`, kol-fxr for
|
|
6
|
+
* kol-client-olina, who had copied ~180 lines of it). **The BUILDER is
|
|
7
|
+
* deliberately not here.** Assembling an SVG welds to an app's own layer
|
|
8
|
+
* schema — slides in one editor, compose layers in another — so it was never
|
|
9
|
+
* the reusable half. This takes a finished string and gives back a Blob.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Rasterize an SVG string to a PNG Blob at `scale`× the SVG's own dimensions.
|
|
14
|
+
* Triggers no download.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} svgString - A complete, self-contained SVG document
|
|
17
|
+
* @param {number} [scale=1] - The @Nx multiplier
|
|
18
|
+
* @param {number} [fallbackSize=1080] - Used when the SVG declares no intrinsic size
|
|
19
|
+
* @returns {Promise<Blob>} the PNG
|
|
20
|
+
*/
|
|
21
|
+
export function svgToPngBlob(svgString, scale = 1, fallbackSize = 1080) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const blob = new Blob([svgString], { type: 'image/svg+xml' })
|
|
24
|
+
const url = URL.createObjectURL(blob)
|
|
25
|
+
const img = new Image()
|
|
26
|
+
img.onload = () => {
|
|
27
|
+
const canvas = document.createElement('canvas')
|
|
28
|
+
canvas.width = (img.width || fallbackSize) * scale
|
|
29
|
+
canvas.height = (img.height || fallbackSize) * scale
|
|
30
|
+
const ctx = canvas.getContext('2d')
|
|
31
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
|
32
|
+
URL.revokeObjectURL(url)
|
|
33
|
+
canvas.toBlob((pngBlob) => {
|
|
34
|
+
if (pngBlob) resolve(pngBlob)
|
|
35
|
+
else reject(new Error('PNG encode failed'))
|
|
36
|
+
}, 'image/png')
|
|
37
|
+
}
|
|
38
|
+
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG rasterize failed')) }
|
|
39
|
+
img.src = url
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const b64 = (buf) => {
|
|
44
|
+
const bytes = new Uint8Array(buf)
|
|
45
|
+
let bin = ''
|
|
46
|
+
const CH = 0x8000
|
|
47
|
+
for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH))
|
|
48
|
+
return btoa(bin)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const FORMAT = { woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' }
|
|
52
|
+
const formatFor = (url) => FORMAT[(url.split('?')[0].split('.').pop() || '').toLowerCase()] ?? 'truetype'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Inline every font a stylesheet references, by REWRITING its `@font-face`
|
|
56
|
+
* blocks and swapping only the `src`.
|
|
57
|
+
*
|
|
58
|
+
* ⚠ **NEVER SYNTHESIZE A FACE.** This is the bug the seam exists to stop
|
|
59
|
+
* (kol-client-olina, 2026-09-04): they harvested bare `url(...)` matches out
|
|
60
|
+
* of Google's CSS and emitted their own `@font-face` without `unicode-range`.
|
|
61
|
+
* Google splits ONE family into latin / latin-ext / cyrillic / greek subsets
|
|
62
|
+
* that are distinguished by that range and nothing else — so the first subset
|
|
63
|
+
* fetched got embedded, matched no glyph, and every family exported in a system
|
|
64
|
+
* fallback while the screen looked perfect. Rewriting the whole block keeps
|
|
65
|
+
* `unicode-range`, the weight and stretch ranges, and anything else the
|
|
66
|
+
* foundry declared.
|
|
67
|
+
*
|
|
68
|
+
* A face that fails to fetch is REPORTED, not swallowed: a silent `catch {}`
|
|
69
|
+
* is how the same export goes out in fallback with nothing to look at.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} cssText - The stylesheet whose `@font-face` blocks to inline
|
|
72
|
+
* @param {Function} [fetchFont] - `(url) => Promise<ArrayBuffer>`; defaults to `fetch`
|
|
73
|
+
* @returns {Promise<{css: string, failed: Array<{url: string, error: Error}>}>} the rewritten CSS and every face that could not be embedded
|
|
74
|
+
*/
|
|
75
|
+
export async function inlineFontFaces(cssText, fetchFont) {
|
|
76
|
+
const load = fetchFont ?? (async (url) => {
|
|
77
|
+
const r = await fetch(url)
|
|
78
|
+
if (!r.ok) throw new Error(`font fetch failed: ${r.status} ${url}`)
|
|
79
|
+
return r.arrayBuffer()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const failed = []
|
|
83
|
+
const blocks = [...cssText.matchAll(/@font-face\s*{[^}]*}/g)].map((m) => m[0])
|
|
84
|
+
const urls = new Map()
|
|
85
|
+
for (const block of blocks) {
|
|
86
|
+
for (const m of block.matchAll(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g)) {
|
|
87
|
+
if (!m[1].startsWith('data:')) urls.set(m[1], null)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await Promise.all([...urls.keys()].map(async (url) => {
|
|
92
|
+
try {
|
|
93
|
+
urls.set(url, `data:font/${formatFor(url)};base64,${b64(await load(url))}`)
|
|
94
|
+
} catch (error) {
|
|
95
|
+
failed.push({ url, error })
|
|
96
|
+
}
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
/* Only the `src` is touched — every other descriptor in the block, and
|
|
100
|
+
* `unicode-range` above all, survives exactly as authored. */
|
|
101
|
+
const css = cssText.replace(/url\(\s*['"]?([^'")]+)['"]?\s*\)(\s*format\(\s*['"]?[^'")]+['"]?\s*\))?/g,
|
|
102
|
+
(whole, url) => {
|
|
103
|
+
const data = urls.get(url)
|
|
104
|
+
return data ? `url(${data}) format('${formatFor(url)}')` : whole
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return { css, failed }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build one `@font-face` block for a SELF-HOSTED, full-range file — the only
|
|
112
|
+
* case where synthesizing is safe, because there are no subsets to confuse.
|
|
113
|
+
* Anything served as subsets (Google's families, any foundry CSS) must go
|
|
114
|
+
* through `inlineFontFaces` instead.
|
|
115
|
+
*
|
|
116
|
+
* @param {{family: string, url: string, weight?: string, stretch?: string}} face
|
|
117
|
+
* @param {Function} [fetchFont] - `(url) => Promise<ArrayBuffer>`
|
|
118
|
+
* @returns {Promise<string>} the `@font-face` block, fonts inlined
|
|
119
|
+
*/
|
|
120
|
+
export async function embedFontFace({ family, url, weight = '1 1000', stretch = '1% 1000%' }, fetchFont) {
|
|
121
|
+
const load = fetchFont ?? (async (u) => {
|
|
122
|
+
const r = await fetch(u)
|
|
123
|
+
if (!r.ok) throw new Error(`font fetch failed: ${r.status} ${u}`)
|
|
124
|
+
return r.arrayBuffer()
|
|
125
|
+
})
|
|
126
|
+
const data = `data:font/${formatFor(url)};base64,${b64(await load(url))}`
|
|
127
|
+
return `@font-face{font-family:'${family}';src:url(${data}) format('${formatFor(url)}');font-weight:${weight};font-stretch:${stretch};}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Download a Blob under a filename. The one place a DOM anchor is minted for
|
|
132
|
+
* a save, so a consumer's export path does not hand-roll it.
|
|
133
|
+
*
|
|
134
|
+
* @param {Blob} blob
|
|
135
|
+
* @param {string} filename
|
|
136
|
+
*/
|
|
137
|
+
export function downloadBlob(blob, filename) {
|
|
138
|
+
const url = URL.createObjectURL(blob)
|
|
139
|
+
const a = document.createElement('a')
|
|
140
|
+
a.href = url
|
|
141
|
+
a.download = filename
|
|
142
|
+
document.body.appendChild(a)
|
|
143
|
+
a.click()
|
|
144
|
+
a.remove()
|
|
145
|
+
URL.revokeObjectURL(url)
|
|
146
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useHistory — undo/redo over a value the CONSUMER owns, with transactions.
|
|
5
|
+
*
|
|
6
|
+
* Packaged 2026-09-04 (`export-and-history-want-packaging`, kol-fxr for
|
|
7
|
+
* kol-client-olina, who had copied it on our advice and came back asking for
|
|
8
|
+
* it by name). **Generic over the value, deliberately**: one editor's snapshot
|
|
9
|
+
* is `{ slides, active, selectedIds }`, another's is compose state. The
|
|
10
|
+
* consumer's words, and they are right — *if it knows about layers it stops
|
|
11
|
+
* being reusable*. Nothing here inspects what it stores.
|
|
12
|
+
*
|
|
13
|
+
* TWO THINGS MAKE IT WORTH PACKAGING, and both are behaviour rather than code:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Selection rides INSIDE the snapshot.** Undo restores what was selected,
|
|
16
|
+
* not only what was drawn — so put the selection in the value. An undo that
|
|
17
|
+
* redraws the old shape but leaves a stale selection is the version everyone
|
|
18
|
+
* writes first and nobody wants.
|
|
19
|
+
* 2. **A drag is ONE entry.** `begin()` before a gesture and `end()` after it:
|
|
20
|
+
* every `set` between them updates the live value without pushing, and `end`
|
|
21
|
+
* pushes once. Without it a pointer-move drag floods the stack.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ **NEVER PUSH INSIDE A `setState` UPDATER.** React StrictMode invokes
|
|
24
|
+
* updaters twice, so every entry doubles — silently, in dev only, and it looks
|
|
25
|
+
* like undo "skipping". The next value is computed against a ref here, outside
|
|
26
|
+
* the updater, which is what makes that impossible rather than merely avoided.
|
|
27
|
+
*
|
|
28
|
+
* const { value, set, begin, end, undo, redo, reset, canUndo, canRedo } = useHistory(initial)
|
|
29
|
+
* onPointerDown={begin} onPointerMove={(e) => set(next(e))} onPointerUp={end}
|
|
30
|
+
*
|
|
31
|
+
* @param {*} initialValue - The first snapshot; any shape
|
|
32
|
+
* @param {number} [limit=100] - Entries kept before the oldest is dropped
|
|
33
|
+
* @returns {{value: *, set: Function, begin: Function, end: Function, undo: Function, redo: Function, reset: Function, canUndo: boolean, canRedo: boolean}}
|
|
34
|
+
*/
|
|
35
|
+
export default function useHistory(initialValue, limit = 100) {
|
|
36
|
+
const [value, setValue] = useState(initialValue)
|
|
37
|
+
/* The ref is the truth the history reads. State drives the render; this
|
|
38
|
+
* drives the stack, so a push never depends on an updater running once. */
|
|
39
|
+
const valueRef = useRef(initialValue)
|
|
40
|
+
const past = useRef([])
|
|
41
|
+
const future = useRef([])
|
|
42
|
+
const txn = useRef(null) /* the value as it was when begin() was called */
|
|
43
|
+
const [, bump] = useState(0)
|
|
44
|
+
const rerender = () => bump((n) => n + 1)
|
|
45
|
+
|
|
46
|
+
const commit = useCallback((next) => {
|
|
47
|
+
valueRef.current = next
|
|
48
|
+
setValue(next)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
/* `set` takes a value or a producer. The producer is called HERE, against
|
|
52
|
+
* the ref, never inside setState — see the StrictMode note above. */
|
|
53
|
+
const set = useCallback((nextOrFn) => {
|
|
54
|
+
const prev = valueRef.current
|
|
55
|
+
const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn
|
|
56
|
+
if (Object.is(next, prev)) return
|
|
57
|
+
if (txn.current === null) {
|
|
58
|
+
past.current = [...past.current, prev].slice(-limit)
|
|
59
|
+
future.current = []
|
|
60
|
+
}
|
|
61
|
+
commit(next)
|
|
62
|
+
rerender()
|
|
63
|
+
}, [commit, limit])
|
|
64
|
+
|
|
65
|
+
/* Open a transaction: `set` keeps updating the live value, and the entry
|
|
66
|
+
* that lands on `end` is the value as it was when this was called. */
|
|
67
|
+
const begin = useCallback(() => {
|
|
68
|
+
if (txn.current === null) txn.current = { from: valueRef.current }
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
const end = useCallback(() => {
|
|
72
|
+
const open = txn.current
|
|
73
|
+
txn.current = null
|
|
74
|
+
if (!open) return
|
|
75
|
+
if (Object.is(open.from, valueRef.current)) return /* a gesture that moved nothing is not an entry */
|
|
76
|
+
past.current = [...past.current, open.from].slice(-limit)
|
|
77
|
+
future.current = []
|
|
78
|
+
rerender()
|
|
79
|
+
}, [limit])
|
|
80
|
+
|
|
81
|
+
const undo = useCallback(() => {
|
|
82
|
+
if (!past.current.length) return
|
|
83
|
+
const prev = past.current[past.current.length - 1]
|
|
84
|
+
past.current = past.current.slice(0, -1)
|
|
85
|
+
future.current = [valueRef.current, ...future.current]
|
|
86
|
+
commit(prev)
|
|
87
|
+
rerender()
|
|
88
|
+
}, [commit])
|
|
89
|
+
|
|
90
|
+
const redo = useCallback(() => {
|
|
91
|
+
if (!future.current.length) return
|
|
92
|
+
const next = future.current[0]
|
|
93
|
+
future.current = future.current.slice(1)
|
|
94
|
+
past.current = [...past.current, valueRef.current].slice(-limit)
|
|
95
|
+
commit(next)
|
|
96
|
+
rerender()
|
|
97
|
+
}, [commit, limit])
|
|
98
|
+
|
|
99
|
+
/* Drop the whole stack and start again (a fresh document, a loaded file).
|
|
100
|
+
* Not an undoable step — there is nothing behind it. Omit `next` to return
|
|
101
|
+
* to the value the hook was initialised with. */
|
|
102
|
+
const reset = useCallback((next) => {
|
|
103
|
+
past.current = []
|
|
104
|
+
future.current = []
|
|
105
|
+
txn.current = null
|
|
106
|
+
commit(next === undefined ? initialValue : next)
|
|
107
|
+
rerender()
|
|
108
|
+
}, [commit, initialValue])
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
value,
|
|
112
|
+
set,
|
|
113
|
+
begin,
|
|
114
|
+
end,
|
|
115
|
+
undo,
|
|
116
|
+
redo,
|
|
117
|
+
reset,
|
|
118
|
+
canUndo: past.current.length > 0,
|
|
119
|
+
canRedo: future.current.length > 0,
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/index.js
CHANGED
|
@@ -222,6 +222,11 @@ export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
|
222
222
|
* STRUCTURAL responsive fork, where the markup itself changes and a stylesheet
|
|
223
223
|
* cannot express it (ColumnBrowserStackMode, 2026-09-03). Sizes stay Tailwind's. */
|
|
224
224
|
export { default as useMediaQuery } from './hooks/useMediaQuery.js'
|
|
225
|
+
/* The two seams kol-client-olina asked for by name after copying both
|
|
226
|
+
* (export-and-history-want-packaging, 2026-09-04). The SVG BUILDER is
|
|
227
|
+
* deliberately absent — it welds to an app's own layer schema. */
|
|
228
|
+
export { svgToPngBlob, inlineFontFaces, embedFontFace, downloadBlob } from './hooks/svgExport.js'
|
|
229
|
+
export { default as useHistory } from './hooks/useHistory.js'
|
|
225
230
|
/* The rail gesture's other half. It lived in kol-framework until 2026-09-03 and
|
|
226
231
|
* moved here for the same reason `useGrabEdge` did: kol-component's own
|
|
227
232
|
* `EditorShell` needs resizable rails and cannot import framework. framework
|
|
@@ -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)
|
|
@@ -97,6 +98,54 @@ const defaultFormatSize = (bytes) => {
|
|
|
97
98
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
98
99
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
|
99
100
|
}
|
|
101
|
+
|
|
102
|
+
/* THE STACK'S ROW ORDER — walked, not iterated, and lifted out of the component
|
|
103
|
+
* so it is reachable by a check (the defect below is invisible to every
|
|
104
|
+
* structural assertion: the rows are all present and each carries the right
|
|
105
|
+
* `depth`, they are merely in the wrong ORDER).
|
|
106
|
+
*
|
|
107
|
+
* The open folder's children have to land DIRECTLY under its own row. That
|
|
108
|
+
* adjacency IS inline expand, and inline expand is the whole ruling. A flat
|
|
109
|
+
* loop over `openPath` appends each level after the one before it, so an open
|
|
110
|
+
* folder's children surfaced below its last SIBLING and below that level's
|
|
111
|
+
* files (D1, kol-r2b2 2026-09-04) — which read as an indent bug rather than a
|
|
112
|
+
* sequence bug, and is how a comment claiming "the loop IS the path" survived.
|
|
113
|
+
*
|
|
114
|
+
* @param {object[]} objects the flat key space
|
|
115
|
+
* @param {string[]} openPath the open levels, shallowest first
|
|
116
|
+
* @param {Function} partition (objects, level) => { folders, files }
|
|
117
|
+
* @returns {object[]} rows — `folder` | `file` | `empty`, each with its `depth`
|
|
118
|
+
*/
|
|
119
|
+
export function stackRows(objects, openPath, partition) {
|
|
120
|
+
const rows = []
|
|
121
|
+
const walk = (depth) => {
|
|
122
|
+
const level = openPath[depth]
|
|
123
|
+
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
124
|
+
const openFolder = openPath[depth + 1]?.slice(level.length) ?? null
|
|
125
|
+
folders.forEach((f) => {
|
|
126
|
+
const isOpen = f === openFolder
|
|
127
|
+
rows.push({ kind: 'folder', key: level + f, level, name: f, depth, open: isOpen })
|
|
128
|
+
if (isOpen) walk(depth + 1)
|
|
129
|
+
})
|
|
130
|
+
files.forEach((o) => rows.push({ kind: 'file', key: o.key, o, depth, level }))
|
|
131
|
+
if (!folders.length && !files.length) rows.push({ kind: 'empty', key: level + '·empty', depth })
|
|
132
|
+
}
|
|
133
|
+
walk(0)
|
|
134
|
+
return rows
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* A ROW SHOWS A DATE, NOT A TIMESTAMP. `2026-06-19T02:00:14.629Z` is storage
|
|
138
|
+
* answering "when" in its own voice, and it shipped into the stack meta line
|
|
139
|
+
* because the value arrives as a string and a string renders (D2, kol-r2b2
|
|
140
|
+
* 2026-09-04). A seam beside `formatSize` for the same reason that one is a
|
|
141
|
+
* seam: how a date reads is the consumer's call, not the DS's. The default is
|
|
142
|
+
* ISO-8601 date-only — unambiguous everywhere, which no locale format is.
|
|
143
|
+
* Anything unparseable comes through verbatim rather than as `Invalid Date`. */
|
|
144
|
+
const defaultFormatDate = (v) => {
|
|
145
|
+
if (!v) return ''
|
|
146
|
+
const d = new Date(v)
|
|
147
|
+
return Number.isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10)
|
|
148
|
+
}
|
|
100
149
|
/* kol-r2b2's partition: the next path segment is a folder, the rest are files */
|
|
101
150
|
const defaultPartition = (objects, prefix) => {
|
|
102
151
|
const folderSet = new Set()
|
|
@@ -227,7 +276,7 @@ function Row({
|
|
|
227
276
|
)
|
|
228
277
|
}
|
|
229
278
|
|
|
230
|
-
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width }) {
|
|
279
|
+
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, width }) {
|
|
231
280
|
// Pixel size and length come from the loaded media itself — the bucket stores
|
|
232
281
|
// none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
|
|
233
282
|
// `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
|
|
@@ -243,7 +292,7 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview, width
|
|
|
243
292
|
['Size', formatSize(o.size)],
|
|
244
293
|
...(sized ? [['Dimensions', dims?.w ? `${dims.w} × ${dims.h} px` : '…']] : []),
|
|
245
294
|
...(timed ? [['Length', dims?.len != null ? formatLength(dims.len) : '…']] : []),
|
|
246
|
-
['Date',
|
|
295
|
+
['Date', formatDate(o.uploaded) || '—'],
|
|
247
296
|
]
|
|
248
297
|
return (
|
|
249
298
|
<div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
|
|
@@ -343,6 +392,7 @@ export default function ColumnBrowser({
|
|
|
343
392
|
kindOf = defaultKindOf,
|
|
344
393
|
kindLabel = DEFAULT_KIND_LABEL,
|
|
345
394
|
formatSize = defaultFormatSize,
|
|
395
|
+
formatDate = defaultFormatDate,
|
|
346
396
|
partition = defaultPartition,
|
|
347
397
|
renderPreview,
|
|
348
398
|
height,
|
|
@@ -563,23 +613,9 @@ export default function ColumnBrowser({
|
|
|
563
613
|
|
|
564
614
|
/* One flat row list, walked down the open path: each level's items, with
|
|
565
615
|
* 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
|
-
})
|
|
616
|
+
const rows = stackRows(objects, openPath, partition)
|
|
581
617
|
|
|
582
|
-
const metaOf = (o) => [o.size != null && formatSize(o.size), o.uploaded].filter(Boolean).join(' · ')
|
|
618
|
+
const metaOf = (o) => [o.size != null && formatSize(o.size), formatDate(o.uploaded)].filter(Boolean).join(' · ')
|
|
583
619
|
|
|
584
620
|
return (
|
|
585
621
|
<div
|
|
@@ -741,7 +777,7 @@ export default function ColumnBrowser({
|
|
|
741
777
|
})}
|
|
742
778
|
{shown && (
|
|
743
779
|
<>
|
|
744
|
-
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} renderPreview={renderPreview} width={widthOf('preview')} />
|
|
780
|
+
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
|
|
745
781
|
<ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
|
|
746
782
|
</>
|
|
747
783
|
)}
|
|
@@ -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)} />
|