@kolkrabbi/kol-component 0.15.2 → 0.19.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 +1 -1
- package/src/atoms/AssetPlaceholder.jsx +1 -1
- package/src/atoms/DocsToc.jsx +11 -6
- package/src/atoms/FullscreenOverlay.jsx +18 -5
- package/src/atoms/IconFrame.jsx +15 -3
- package/src/atoms/InteractiveImage.jsx +152 -0
- package/src/atoms/OverlayGlassPanel.jsx +1 -1
- package/src/atoms/Pill.jsx +10 -1
- package/src/atoms/SearchInput.jsx +1 -1
- package/src/atoms/Tag.jsx +49 -25
- package/src/atoms/TiltCard.jsx +1 -21
- package/src/hooks/useCoarsePointer.js +27 -0
- package/src/hooks/useScrollSpy.js +18 -9
- package/src/index.js +3 -0
- package/src/molecules/ColorInputRow.jsx +1 -1
- package/src/molecules/ColorSwatch.jsx +1 -1
- package/src/molecules/FramedMediaBand.jsx +2 -2
- package/src/molecules/ShellDrawer.jsx +1 -1
- package/src/molecules/ShellSearchOverlay.jsx +59 -8
- package/src/molecules/SpectrumControls.jsx +5 -5
- package/src/organisms/ContentFilters.jsx +18 -10
- package/src/organisms/FeatureSplit.jsx +1 -1
- package/src/organisms/GalleryCarousel.jsx +1 -1
- package/src/organisms/MediaLibrary.jsx +516 -0
- package/src/organisms/MediaViewer.jsx +9 -1
- package/src/organisms/Table.jsx +67 -4
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
import Button from '../atoms/Button.jsx'
|
|
4
|
+
import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
|
|
5
|
+
import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
|
|
6
|
+
import MediaCard from '../molecules/MediaCard.jsx'
|
|
7
|
+
import MediaRow from '../molecules/MediaRow.jsx'
|
|
8
|
+
import ContentFilters from './ContentFilters.jsx'
|
|
9
|
+
import MediaViewer from './MediaViewer.jsx'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* MediaLibrary — a browser over an object bucket, in two views over one
|
|
13
|
+
* headless core. Consolidates four consumer forks (kol-ds-fxr, kol-labs-single,
|
|
14
|
+
* kol-client-kolkrabbi, kol-website/brand — 9 files, ~1542 lines) that had
|
|
15
|
+
* already diverged: only fxr carried the canvas-taint fix, only labs carried
|
|
16
|
+
* the write paths, and neither page view ever learned folders.
|
|
17
|
+
*
|
|
18
|
+
* THE CLIENT IS INJECTED, NEVER IMPORTED. ARCHITECTURE §3 keeps the clients
|
|
19
|
+
* tier free of UI dependencies in both directions, so this package does not
|
|
20
|
+
* import `@kolkrabbi/kol-media-client` — the consumer passes an instance in.
|
|
21
|
+
* Same contract as kol-dashboards / kol-chess / kol-content.
|
|
22
|
+
*
|
|
23
|
+
* COMPOSED, NOT BUILT. Every part is an existing DS member:
|
|
24
|
+
* ContentFilters — filter groups, animated search, view toggle, N-of-M count
|
|
25
|
+
* MediaCard — the grid tile (thumb · download chip · name · meta · actions)
|
|
26
|
+
* MediaRow — the list row (thumb · name · date · size · actions)
|
|
27
|
+
* MediaViewer — the lightbox, via its `actions` slot
|
|
28
|
+
* FullscreenOverlay — the picker's scrim, dismissal and close button
|
|
29
|
+
* The first pass hand-rolled a tile grid and a folder row while MediaCard and
|
|
30
|
+
* MediaRow — built from this same source in the 2026-07-03 sweep — sat unused.
|
|
31
|
+
*
|
|
32
|
+
* NAVIGATION IS FINDER'S LIST MODEL, not click-to-enter. Folders are rows in
|
|
33
|
+
* the same list with a disclosure chevron and expand IN PLACE, so the parent
|
|
34
|
+
* never leaves the screen and there is no breadcrumb stacked above a divider.
|
|
35
|
+
* The path bar sits at the FOOT, where Finder puts it.
|
|
36
|
+
*
|
|
37
|
+
* Read-only by design. Upload / rename / delete stay in kol-media-admin —
|
|
38
|
+
* write auth does not belong in a browser-shipped package.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const MediaLibraryContext = createContext(null)
|
|
42
|
+
|
|
43
|
+
const isImage = (ct) => !!ct && ct.startsWith('image/')
|
|
44
|
+
const isVideo = (ct) => !!ct && ct.startsWith('video/')
|
|
45
|
+
|
|
46
|
+
/* Bytes → a human-readable weight. Duplicated from the client on purpose:
|
|
47
|
+
* importing it would create the very UI→clients edge §3 forbids, and it is
|
|
48
|
+
* four lines of arithmetic with no contract behind it. */
|
|
49
|
+
function formatSize(bytes) {
|
|
50
|
+
if (bytes == null) return ''
|
|
51
|
+
if (bytes < 1024) return `${bytes} B`
|
|
52
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
53
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
54
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fileName = (key) => key.slice(key.lastIndexOf('/') + 1)
|
|
58
|
+
const folderOf = (key) => key.slice(0, key.lastIndexOf('/') + 1)
|
|
59
|
+
|
|
60
|
+
/* A video element with no poster paints an empty box until played, and 222 of
|
|
61
|
+
* the reference bucket's 433 objects are video — a time fragment makes the
|
|
62
|
+
* browser seek and paint frame one instead. */
|
|
63
|
+
const posterSrc = (url) => `${url}#t=0.1`
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Flatten the bucket's flat key list into ONE ordered row list, folders and
|
|
67
|
+
* files interleaved, honouring which folders are open. The list endpoint
|
|
68
|
+
* returns keys with no `prefixes` key and `?delimiter=/` changes nothing
|
|
69
|
+
* (probed 2026-08-01), so the tree is derived here — this function is the
|
|
70
|
+
* whole navigation feature.
|
|
71
|
+
*/
|
|
72
|
+
function buildRows(objects, expanded, sort) {
|
|
73
|
+
const childrenOf = new Map()
|
|
74
|
+
const folders = new Set()
|
|
75
|
+
|
|
76
|
+
for (const o of objects) {
|
|
77
|
+
const dir = folderOf(o.key)
|
|
78
|
+
if (dir) {
|
|
79
|
+
/* register every ancestor so a deep key materialises its whole chain */
|
|
80
|
+
const parts = dir.slice(0, -1).split('/')
|
|
81
|
+
for (let i = 0; i < parts.length; i += 1) folders.add(`${parts.slice(0, i + 1).join('/')}/`)
|
|
82
|
+
}
|
|
83
|
+
if (!childrenOf.has(dir)) childrenOf.set(dir, [])
|
|
84
|
+
childrenOf.get(dir).push(o)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const subFoldersOf = (prefix) =>
|
|
88
|
+
[...folders].filter((f) => folderOf(f.slice(0, -1)) === prefix).sort()
|
|
89
|
+
|
|
90
|
+
const sorted = (list) => {
|
|
91
|
+
const by = {
|
|
92
|
+
name: (a, b) => a.key.localeCompare(b.key),
|
|
93
|
+
date: (a, b) => String(b.uploaded ?? '').localeCompare(String(a.uploaded ?? '')),
|
|
94
|
+
size: (a, b) => (b.size ?? 0) - (a.size ?? 0),
|
|
95
|
+
kind: (a, b) => String(a.contentType ?? '').localeCompare(String(b.contentType ?? '')),
|
|
96
|
+
}
|
|
97
|
+
return [...list].sort(by[sort] ?? by.name)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rows = []
|
|
101
|
+
const walk = (prefix, depth) => {
|
|
102
|
+
for (const f of subFoldersOf(prefix)) {
|
|
103
|
+
rows.push({ type: 'folder', key: f, label: fileName(f.slice(0, -1)) + '/', depth })
|
|
104
|
+
if (expanded.has(f)) walk(f, depth + 1)
|
|
105
|
+
}
|
|
106
|
+
for (const o of sorted(childrenOf.get(prefix) ?? [])) {
|
|
107
|
+
rows.push({ type: 'file', depth, ...o, displayKey: fileName(o.key) })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
walk('', 0)
|
|
111
|
+
return rows
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* MediaLibraryProvider — the headless core: one list call, client-side tree
|
|
116
|
+
* derivation, the open-folder set and the sort key.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} client `{ listMedia, mediaUrl, proxied? }` — required
|
|
119
|
+
* @param {string} accept 'image' | 'video' | 'all' — which types are listed
|
|
120
|
+
*/
|
|
121
|
+
export function MediaLibraryProvider({ client, accept = 'all', children }) {
|
|
122
|
+
const [objects, setObjects] = useState([])
|
|
123
|
+
const [expanded, setExpanded] = useState(() => new Set())
|
|
124
|
+
const [sort, setSort] = useState('name')
|
|
125
|
+
const [loading, setLoading] = useState(true)
|
|
126
|
+
const [error, setError] = useState(null)
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
if (!client) return undefined
|
|
130
|
+
let cancelled = false
|
|
131
|
+
const controller = new AbortController()
|
|
132
|
+
setLoading(true)
|
|
133
|
+
setError(null)
|
|
134
|
+
client
|
|
135
|
+
.listMedia('', { signal: controller.signal })
|
|
136
|
+
.then((objs) => { if (!cancelled) setObjects(objs) })
|
|
137
|
+
.catch((e) => { if (!cancelled && e.name !== 'AbortError') setError(e.message) })
|
|
138
|
+
.finally(() => { if (!cancelled) setLoading(false) })
|
|
139
|
+
return () => { cancelled = true; controller.abort() }
|
|
140
|
+
}, [client])
|
|
141
|
+
|
|
142
|
+
const toggleFolder = (key) =>
|
|
143
|
+
setExpanded((prev) => {
|
|
144
|
+
const next = new Set(prev)
|
|
145
|
+
if (next.has(key)) next.delete(key)
|
|
146
|
+
else next.add(key)
|
|
147
|
+
return next
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const value = useMemo(() => {
|
|
151
|
+
const wanted = (o) =>
|
|
152
|
+
accept === 'video' ? isVideo(o.contentType)
|
|
153
|
+
: accept === 'image' ? isImage(o.contentType)
|
|
154
|
+
: isImage(o.contentType) || isVideo(o.contentType)
|
|
155
|
+
|
|
156
|
+
const kept = objects.filter(wanted)
|
|
157
|
+
return {
|
|
158
|
+
objects: kept,
|
|
159
|
+
rows: buildRows(kept, expanded, sort),
|
|
160
|
+
files: kept,
|
|
161
|
+
expanded,
|
|
162
|
+
toggleFolder,
|
|
163
|
+
sort,
|
|
164
|
+
setSort,
|
|
165
|
+
loading,
|
|
166
|
+
error,
|
|
167
|
+
mediaUrl: client?.mediaUrl ?? ((key) => key),
|
|
168
|
+
proxied: client?.proxied ?? ((url) => url),
|
|
169
|
+
}
|
|
170
|
+
}, [objects, expanded, sort, loading, error, accept, client])
|
|
171
|
+
|
|
172
|
+
return <MediaLibraryContext.Provider value={value}>{children}</MediaLibraryContext.Provider>
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Read the surrounding library. Throws outside a provider — a silent null
|
|
176
|
+
* here would surface as an empty grid with no explanation. */
|
|
177
|
+
export function useMediaLibrary() {
|
|
178
|
+
const ctx = useContext(MediaLibraryContext)
|
|
179
|
+
if (!ctx) throw new Error('useMediaLibrary must be used inside <MediaLibraryProvider>')
|
|
180
|
+
return ctx
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function withProvider(node, { client, accept }) {
|
|
184
|
+
if (!client) return node
|
|
185
|
+
return <MediaLibraryProvider client={client} accept={accept}>{node}</MediaLibraryProvider>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* Indentation per tree depth. A rem step rather than a magic pixel, and it
|
|
189
|
+
* rides the spacing scale's 1rem rung. */
|
|
190
|
+
const indent = (depth) => ({ paddingInlineStart: `calc(${depth} * var(--kol-spacing-4))` })
|
|
191
|
+
|
|
192
|
+
function FolderRow({ row, open, onToggle }) {
|
|
193
|
+
return (
|
|
194
|
+
<li
|
|
195
|
+
className="kol-media-folder"
|
|
196
|
+
style={indent(row.depth)}
|
|
197
|
+
onClick={onToggle}
|
|
198
|
+
aria-expanded={open}
|
|
199
|
+
>
|
|
200
|
+
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={14} />
|
|
201
|
+
<Icon name="folder" size={16} />
|
|
202
|
+
<span className="kol-mono-12 text-emphasis flex-1">{row.label}</span>
|
|
203
|
+
</li>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* Copy-URL is the one action every view carries; the admin's Rename/Delete are
|
|
208
|
+
* write ops and stay out of the DS (ARCHITECTURE §3, and the spec's own note). */
|
|
209
|
+
function useCopy() {
|
|
210
|
+
const [copied, setCopied] = useState(null)
|
|
211
|
+
const copy = async (url) => {
|
|
212
|
+
try { await navigator.clipboard.writeText(url) } catch { /* clipboard blocked */ }
|
|
213
|
+
setCopied(url)
|
|
214
|
+
setTimeout(() => setCopied(null), 1500)
|
|
215
|
+
}
|
|
216
|
+
return [copied, copy]
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function Thumb({ row, mediaUrl }) {
|
|
220
|
+
return isVideo(row.contentType)
|
|
221
|
+
? <video src={posterSrc(mediaUrl(row.key))} muted preload="metadata" className="w-full h-full object-cover" />
|
|
222
|
+
: <img src={mediaUrl(row.key)} alt="" loading="lazy" className="w-full h-full object-cover" />
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* Folders, then the tiles or rows. Shared by both views — the modal shell and
|
|
226
|
+
* the pick action are the ONLY differences between them. */
|
|
227
|
+
function LibraryBody({ rows, viewMode, onOpen, onPick }) {
|
|
228
|
+
const { expanded, toggleFolder, mediaUrl, loading, error } = useMediaLibrary()
|
|
229
|
+
const [copied, copy] = useCopy()
|
|
230
|
+
|
|
231
|
+
if (error) return <p className="kol-helper-12 text-ui-error">Couldn’t load: {error}</p>
|
|
232
|
+
if (loading) return <p className="kol-helper-12 text-meta">Loading…</p>
|
|
233
|
+
if (rows.length === 0) return <p className="kol-helper-12 text-meta">Nothing here.</p>
|
|
234
|
+
|
|
235
|
+
const files = rows.filter((r) => r.type === 'file')
|
|
236
|
+
const indexOfFile = (row) => files.findIndex((f) => f.key === row.key)
|
|
237
|
+
|
|
238
|
+
const actionsFor = (row) => (
|
|
239
|
+
<div className="flex items-center gap-2">
|
|
240
|
+
{onPick && <Button size="sm" onClick={() => onPick(row)}>Use</Button>}
|
|
241
|
+
<Button variant="secondary" size="sm" onClick={() => copy(mediaUrl(row.key))}>
|
|
242
|
+
{copied === mediaUrl(row.key) ? 'Copied' : 'Copy URL'}
|
|
243
|
+
</Button>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if (viewMode === 'list') {
|
|
248
|
+
return (
|
|
249
|
+
<ul className="kol-media-scroll kol-media-list">
|
|
250
|
+
{rows.map((row) =>
|
|
251
|
+
row.type === 'folder' ? (
|
|
252
|
+
<FolderRow key={row.key} row={row} open={expanded.has(row.key)} onToggle={() => toggleFolder(row.key)} />
|
|
253
|
+
) : (
|
|
254
|
+
<div key={row.key} style={indent(row.depth)}>
|
|
255
|
+
<MediaRow
|
|
256
|
+
thumb={<Thumb row={row} mediaUrl={mediaUrl} />}
|
|
257
|
+
name={
|
|
258
|
+
<button type="button" className="kol-mono-12 text-emphasis" onClick={() => onOpen(indexOfFile(row))}>
|
|
259
|
+
{row.displayKey}
|
|
260
|
+
</button>
|
|
261
|
+
}
|
|
262
|
+
date={row.uploaded ? String(row.uploaded).slice(0, 10) : ''}
|
|
263
|
+
size={formatSize(row.size)}
|
|
264
|
+
actions={actionsFor(row)}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
),
|
|
268
|
+
)}
|
|
269
|
+
</ul>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<div className="kol-media-scroll">
|
|
275
|
+
<ul className="kol-media-list">
|
|
276
|
+
{rows.filter((r) => r.type === 'folder').map((row) => (
|
|
277
|
+
<FolderRow key={row.key} row={row} open={expanded.has(row.key)} onToggle={() => toggleFolder(row.key)} />
|
|
278
|
+
))}
|
|
279
|
+
</ul>
|
|
280
|
+
<ul className="kol-media-grid">
|
|
281
|
+
{files.map((row, i) => (
|
|
282
|
+
<MediaCard
|
|
283
|
+
key={row.key}
|
|
284
|
+
thumb={
|
|
285
|
+
<div className="w-full h-full cursor-pointer" onClick={() => onOpen(i)}>
|
|
286
|
+
<Thumb row={row} mediaUrl={mediaUrl} />
|
|
287
|
+
</div>
|
|
288
|
+
}
|
|
289
|
+
name={<p className="kol-mono-12 text-emphasis truncate">{row.displayKey}</p>}
|
|
290
|
+
meta={`${formatSize(row.size)}${row.uploaded ? ` · ${String(row.uploaded).slice(0, 10)}` : ''}`}
|
|
291
|
+
downloadHref={mediaUrl(row.key)}
|
|
292
|
+
actions={actionsFor(row)}
|
|
293
|
+
/>
|
|
294
|
+
))}
|
|
295
|
+
</ul>
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* Finder puts the path at the window FOOT, not stacked above the content. */
|
|
301
|
+
function PathBar({ rows }) {
|
|
302
|
+
const open = rows.filter((r) => r.type === 'folder' && r.depth > 0)
|
|
303
|
+
const trail = open.length ? open[open.length - 1].key.replace(/\/$/, '').split('/') : []
|
|
304
|
+
return (
|
|
305
|
+
<div className="kol-media-pathbar">
|
|
306
|
+
<Icon name="folder" size={12} />
|
|
307
|
+
<span className="kol-helper-12 text-meta">root</span>
|
|
308
|
+
{trail.map((seg) => (
|
|
309
|
+
<span key={seg} className="flex items-center gap-1">
|
|
310
|
+
<Icon name="chevron-right" size={10} />
|
|
311
|
+
<span className="kol-helper-12 text-meta">{seg}</span>
|
|
312
|
+
</span>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const VIEW_OPTIONS = [
|
|
319
|
+
{ value: 'grid', icon: 'grid', label: 'Grid' },
|
|
320
|
+
{ value: 'list', icon: 'view-list', label: 'List' },
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
/* Sort is a SegmentedToggle — the DS's joined N-way control. The first
|
|
324
|
+
* pass hand-rolled four <button className="kol-helper-12"> instead. */
|
|
325
|
+
const SORTS = [
|
|
326
|
+
{ value: 'name', label: 'name' },
|
|
327
|
+
{ value: 'date', label: 'date' },
|
|
328
|
+
{ value: 'size', label: 'size' },
|
|
329
|
+
{ value: 'kind', label: 'kind' },
|
|
330
|
+
]
|
|
331
|
+
|
|
332
|
+
/* The chrome — ContentFilters owns the animated search, the filter groups, the
|
|
333
|
+
* view toggle and the N-of-M count. It was hand-rolled as a static <Input> on
|
|
334
|
+
* the first pass while this organism sat one import away. */
|
|
335
|
+
function LibraryChrome({ onOpen, onPick }) {
|
|
336
|
+
const { rows, sort, setSort } = useMediaLibrary()
|
|
337
|
+
const [viewMode, setViewMode] = useState('grid')
|
|
338
|
+
|
|
339
|
+
const items = useMemo(
|
|
340
|
+
() => rows.map((r) => ({
|
|
341
|
+
...r,
|
|
342
|
+
name: r.type === 'folder' ? r.label : r.displayKey,
|
|
343
|
+
kind: r.type === 'folder' ? 'folder' : isVideo(r.contentType) ? 'video' : 'image',
|
|
344
|
+
})),
|
|
345
|
+
[rows],
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<>
|
|
350
|
+
<ContentFilters
|
|
351
|
+
items={items}
|
|
352
|
+
title="Media library"
|
|
353
|
+
titleIcon="folder"
|
|
354
|
+
totalCount={items.length}
|
|
355
|
+
searchKeys={['name']}
|
|
356
|
+
viewMode={viewMode}
|
|
357
|
+
onViewModeChange={setViewMode}
|
|
358
|
+
viewModeOptions={VIEW_OPTIONS}
|
|
359
|
+
mutuallyExclusiveFilters={['kind']}
|
|
360
|
+
filterGroups={[{ label: 'Kind', key: 'kind', values: ['image', 'video', 'folder'] }]}
|
|
361
|
+
headerActions={
|
|
362
|
+
<SegmentedToggle
|
|
363
|
+
size="sm"
|
|
364
|
+
value={sort}
|
|
365
|
+
onChange={setSort}
|
|
366
|
+
options={SORTS}
|
|
367
|
+
ariaLabel="Sort by"
|
|
368
|
+
/>
|
|
369
|
+
}
|
|
370
|
+
renderItem={(filtered, mode) => (
|
|
371
|
+
<LibraryBody rows={filtered} viewMode={mode} onOpen={onOpen} onPick={onPick} />
|
|
372
|
+
)}
|
|
373
|
+
/>
|
|
374
|
+
<PathBar rows={rows} />
|
|
375
|
+
</>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/* The lightbox is MediaViewer — the DS already has ONE fullscreen paged viewer
|
|
380
|
+
* and this is not a second one. Use / Copy URL ride its `actions` slot. */
|
|
381
|
+
function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
|
|
382
|
+
const { files, mediaUrl } = useMediaLibrary()
|
|
383
|
+
const [copied, copy] = useCopy()
|
|
384
|
+
|
|
385
|
+
const media = files.map((o) => ({
|
|
386
|
+
url: mediaUrl(o.key),
|
|
387
|
+
alt: fileName(o.key),
|
|
388
|
+
kind: isVideo(o.contentType) ? 'video' : 'image',
|
|
389
|
+
caption: `${fileName(o.key)} · ${formatSize(o.size)}`,
|
|
390
|
+
}))
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<MediaViewer
|
|
394
|
+
open={index !== null}
|
|
395
|
+
media={media}
|
|
396
|
+
index={index ?? 0}
|
|
397
|
+
onIndexChange={onIndexChange}
|
|
398
|
+
onClose={onClose}
|
|
399
|
+
actions={(item, i) => (
|
|
400
|
+
<>
|
|
401
|
+
{onPick && <Button size="sm" onClick={() => onPick(files[i])}>Use</Button>}
|
|
402
|
+
<Button variant="secondary" size="sm" onClick={() => copy(item.url)}>
|
|
403
|
+
{copied === item.url ? 'Copied' : 'Copy URL'}
|
|
404
|
+
</Button>
|
|
405
|
+
</>
|
|
406
|
+
)}
|
|
407
|
+
/>
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function PickerShell({ onClose, onPick }) {
|
|
412
|
+
const { files, mediaUrl } = useMediaLibrary()
|
|
413
|
+
const [viewerIndex, setViewerIndex] = useState(null)
|
|
414
|
+
|
|
415
|
+
const pick = (o) => {
|
|
416
|
+
onPick?.(mediaUrl(o.key), { contentType: o.contentType })
|
|
417
|
+
onClose?.()
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return (
|
|
421
|
+
<>
|
|
422
|
+
{/* While the viewer is up it owns Escape — handing the picker a no-op
|
|
423
|
+
* close means one keypress steps back one level instead of exiting the
|
|
424
|
+
* whole picker, which is the behaviour the fxr fork hand-rolled. */}
|
|
425
|
+
<FullscreenOverlay open onClose={viewerIndex === null ? onClose : () => {}}>
|
|
426
|
+
<div className="kol-media-picker">
|
|
427
|
+
<LibraryChrome onOpen={setViewerIndex} onPick={pick} />
|
|
428
|
+
</div>
|
|
429
|
+
</FullscreenOverlay>
|
|
430
|
+
|
|
431
|
+
{viewerIndex !== null && files[viewerIndex] && (
|
|
432
|
+
<LibraryViewer
|
|
433
|
+
index={viewerIndex}
|
|
434
|
+
onIndexChange={setViewerIndex}
|
|
435
|
+
onClose={() => setViewerIndex(null)}
|
|
436
|
+
onPick={pick}
|
|
437
|
+
/>
|
|
438
|
+
)}
|
|
439
|
+
</>
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* MediaLibrary — ONE component, two variants. The user's ruling 2026-08-01:
|
|
445
|
+
* "arent different components, they are more like variants, same shit
|
|
446
|
+
* different viewing." He is right — `page` and `modal` render the identical
|
|
447
|
+
* body and differ only in the shell around it and whether picking closes.
|
|
448
|
+
*
|
|
449
|
+
* Variant is CONTAINER GEOMETRY ONLY, the ThemeToggle precedent: everything
|
|
450
|
+
* else is a prop. `MediaBrowser` and `MediaPicker` survive below as thin
|
|
451
|
+
* aliases so no call site breaks.
|
|
452
|
+
*
|
|
453
|
+
* @param {string} variant 'page' (in-flow, fills its box) | 'modal' (overlay)
|
|
454
|
+
* @param {boolean} open modal only — mounts the overlay
|
|
455
|
+
* @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
|
|
456
|
+
* @param {string} accept 'image' | 'video' | 'all'
|
|
457
|
+
* @param {Function} onClose modal only — Esc, backdrop, close button
|
|
458
|
+
* @param {Function} onSelect `(url, { contentType })`. In `modal` it also closes.
|
|
459
|
+
*/
|
|
460
|
+
export default function MediaLibrary({
|
|
461
|
+
variant = 'page',
|
|
462
|
+
open = true,
|
|
463
|
+
client,
|
|
464
|
+
accept = 'all',
|
|
465
|
+
onClose,
|
|
466
|
+
onSelect = null,
|
|
467
|
+
}) {
|
|
468
|
+
if (variant === 'modal') {
|
|
469
|
+
if (!open) return null
|
|
470
|
+
return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, { client, accept })
|
|
471
|
+
}
|
|
472
|
+
return withProvider(<BrowserShell onSelect={onSelect} />, { client, accept })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Alias — `MediaLibrary variant="modal"`. Kept so existing call sites and the
|
|
476
|
+
* fxr editor's `onPick` naming keep working. */
|
|
477
|
+
export function MediaPicker({ open, client, accept = 'all', onClose, onPick }) {
|
|
478
|
+
return (
|
|
479
|
+
<MediaLibrary
|
|
480
|
+
variant="modal"
|
|
481
|
+
open={open}
|
|
482
|
+
client={client}
|
|
483
|
+
accept={accept}
|
|
484
|
+
onClose={onClose}
|
|
485
|
+
onSelect={onPick}
|
|
486
|
+
/>
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function BrowserShell({ onSelect }) {
|
|
491
|
+
const { files, mediaUrl } = useMediaLibrary()
|
|
492
|
+
const [viewerIndex, setViewerIndex] = useState(null)
|
|
493
|
+
|
|
494
|
+
const pick = onSelect ? (o) => onSelect(mediaUrl(o.key), { contentType: o.contentType }) : undefined
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<div className="kol-media-browser">
|
|
498
|
+
<LibraryChrome onOpen={setViewerIndex} onPick={pick} />
|
|
499
|
+
|
|
500
|
+
{viewerIndex !== null && files[viewerIndex] && (
|
|
501
|
+
<LibraryViewer
|
|
502
|
+
index={viewerIndex}
|
|
503
|
+
onIndexChange={setViewerIndex}
|
|
504
|
+
onClose={() => setViewerIndex(null)}
|
|
505
|
+
onPick={pick}
|
|
506
|
+
/>
|
|
507
|
+
)}
|
|
508
|
+
</div>
|
|
509
|
+
)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Alias — `MediaLibrary variant="page"`. Without `onSelect` the actions offer
|
|
513
|
+
* Copy URL only, which is the read-only page a brand book wants. */
|
|
514
|
+
export function MediaBrowser({ client, accept = 'all', onSelect = null }) {
|
|
515
|
+
return <MediaLibrary variant="page" client={client} accept={accept} onSelect={onSelect} />
|
|
516
|
+
}
|
|
@@ -22,6 +22,9 @@ import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
|
|
|
22
22
|
* @param {number} index active item index (parent-owned)
|
|
23
23
|
* @param {Function} onIndexChange fires with the new index on page
|
|
24
24
|
* @param {Function} onClose close request (Esc, backdrop, close button)
|
|
25
|
+
* @param {Function|ReactNode} actions optional row under the stage; a function
|
|
26
|
+
* receives `(activeItem, index)` so openers can offer per-item actions
|
|
27
|
+
* (MediaPicker's Use / Copy URL) without forking a second lightbox
|
|
25
28
|
*/
|
|
26
29
|
|
|
27
30
|
/* Inverse-tier chip tokens: the overlay scrim is surface-inverse, and .kol-overlay
|
|
@@ -109,12 +112,17 @@ function ViewerStage({ media, index, onIndexChange }) {
|
|
|
109
112
|
)
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
export default function MediaViewer({ open, media = [], index = 0, onIndexChange, onClose }) {
|
|
115
|
+
export default function MediaViewer({ open, media = [], index = 0, onIndexChange, onClose, actions }) {
|
|
113
116
|
if (!open || !media[index]) return null
|
|
114
117
|
|
|
118
|
+
const actionRow = typeof actions === 'function' ? actions(media[index], index) : actions
|
|
119
|
+
|
|
115
120
|
return (
|
|
116
121
|
<FullscreenOverlay open onClose={onClose}>
|
|
117
122
|
<ViewerStage media={media} index={index} onIndexChange={onIndexChange} />
|
|
123
|
+
{actionRow && (
|
|
124
|
+
<div className="mt-4 flex items-center justify-center gap-2">{actionRow}</div>
|
|
125
|
+
)}
|
|
118
126
|
</FullscreenOverlay>
|
|
119
127
|
)
|
|
120
128
|
}
|
package/src/organisms/Table.jsx
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react'
|
|
2
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Table — data table.
|
|
3
6
|
* Styles: src/styles/kol-components-organisms.css.
|
|
@@ -5,10 +8,54 @@
|
|
|
5
8
|
* Variants:
|
|
6
9
|
* default — bordered, column dividers, header bg
|
|
7
10
|
* simple — borderless, flush, no column dividers
|
|
11
|
+
*
|
|
12
|
+
* SORTING (2026-08-01): a column opts in with `sortable: true`. Clicking its
|
|
13
|
+
* header cycles asc → desc → none, and the glyph says which. The header is a
|
|
14
|
+
* real <button> inside the <th>, so it is keyboard-reachable and screen
|
|
15
|
+
* readers get `aria-sort` on the cell. Uncontrolled by design — a table that
|
|
16
|
+
* sorts itself needs no state from the page. Pass `sortValue(row)` when the
|
|
17
|
+
* rendered cell is not what should be compared (a chip, a link, a date
|
|
18
|
+
* string).
|
|
8
19
|
*/
|
|
9
|
-
const
|
|
20
|
+
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
|
21
|
+
|
|
22
|
+
/* WIDTH IS A CONTRACT, NOT A COMMENT (2026-08-01). A table used to be capped
|
|
23
|
+
* by whatever div a page happened to wrap it in, and the gate policed that by
|
|
24
|
+
* grepping the page for a token name — so a legitimate wide table needed a
|
|
25
|
+
* magic `width-ok:` comment to buy clearance. The table declares its own width
|
|
26
|
+
* now: `panel` (the default, prose tables) caps at the panel rung; `column`
|
|
27
|
+
* runs the content column for a DATA table whose columns need the room. Both
|
|
28
|
+
* are correct by construction, so the gate can assert the contract instead of
|
|
29
|
+
* a string. */
|
|
30
|
+
const WIDTHS = {
|
|
31
|
+
panel: 'max-w-[var(--kol-content-panel)]',
|
|
32
|
+
column: '',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const Table = ({ caption, columns, rows, variant = 'default', className = '', width = 'panel' }) => {
|
|
36
|
+
const [sort, setSort] = useState({ key: null, dir: null })
|
|
37
|
+
|
|
38
|
+
const cycle = (key) =>
|
|
39
|
+
setSort((prev) =>
|
|
40
|
+
prev.key !== key ? { key, dir: 'asc' }
|
|
41
|
+
: prev.dir === 'asc' ? { key, dir: 'desc' }
|
|
42
|
+
: { key: null, dir: null })
|
|
43
|
+
|
|
44
|
+
const sorted = useMemo(() => {
|
|
45
|
+
if (!sort.key || !sort.dir) return rows
|
|
46
|
+
const col = columns.find((c) => c.accessor === sort.key)
|
|
47
|
+
if (!col) return rows
|
|
48
|
+
const val = (r) => (col.sortValue ? col.sortValue(r) : r[col.accessor])
|
|
49
|
+
const sign = sort.dir === 'asc' ? 1 : -1
|
|
50
|
+
return [...rows].sort((a, b) => {
|
|
51
|
+
const x = val(a), y = val(b)
|
|
52
|
+
if (typeof x === 'number' && typeof y === 'number') return (x - y) * sign
|
|
53
|
+
return collator.compare(String(x ?? ''), String(y ?? '')) * sign
|
|
54
|
+
})
|
|
55
|
+
}, [rows, columns, sort])
|
|
56
|
+
|
|
10
57
|
const variantClass = variant === 'simple' ? 'kol-table--simple' : ''
|
|
11
|
-
const wrapperClass = ['kol-table-wrapper', variantClass, className].filter(Boolean).join(' ')
|
|
58
|
+
const wrapperClass = ['kol-table-wrapper', variantClass, WIDTHS[width] ?? WIDTHS.panel, className].filter(Boolean).join(' ')
|
|
12
59
|
return (
|
|
13
60
|
<div className={wrapperClass}>
|
|
14
61
|
<table className="kol-table">
|
|
@@ -21,14 +68,30 @@ const Table = ({ caption, columns, rows, variant = 'default', className = '' })
|
|
|
21
68
|
scope="col"
|
|
22
69
|
className={column.headerClassName ?? 'kol-table-cell-title'}
|
|
23
70
|
style={column.style}
|
|
71
|
+
aria-sort={sort.key === column.accessor ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined}
|
|
24
72
|
>
|
|
25
|
-
{column.
|
|
73
|
+
{column.sortable ? (
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
className="kol-table-sort"
|
|
77
|
+
onClick={() => cycle(column.accessor)}
|
|
78
|
+
>
|
|
79
|
+
{column.header}
|
|
80
|
+
<Icon
|
|
81
|
+
name={sort.key === column.accessor && sort.dir === 'desc' ? 'chevron-down' : 'chevron-up'}
|
|
82
|
+
size={12}
|
|
83
|
+
className={sort.key === column.accessor ? 'opacity-100' : 'opacity-0 group-hover:opacity-40'}
|
|
84
|
+
/>
|
|
85
|
+
</button>
|
|
86
|
+
) : (
|
|
87
|
+
column.header
|
|
88
|
+
)}
|
|
26
89
|
</th>
|
|
27
90
|
))}
|
|
28
91
|
</tr>
|
|
29
92
|
</thead>
|
|
30
93
|
<tbody>
|
|
31
|
-
{
|
|
94
|
+
{sorted.map((row, rowIndex) => (
|
|
32
95
|
<tr key={row.id ?? row.token ?? rowIndex} className="kol-table-row">
|
|
33
96
|
{columns.map((column) => (
|
|
34
97
|
<td key={column.accessor} className={(typeof column.className === 'function' ? column.className(row) : column.className) ?? 'kol-table-cell-text'} style={column.style}>
|