@kolkrabbi/kol-component 0.68.1 → 0.69.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/index.js +1 -0
- package/src/organisms/MediaLibrary.jsx +511 -280
- package/src/organisms/SettingsPanel.jsx +177 -0
- package/src/utilities/FullscreenOverlay.jsx +25 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.0",
|
|
4
4
|
"description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.js
CHANGED
|
@@ -130,6 +130,7 @@ export { default as LoaderOverlay } from './utilities/LoaderOverlay.jsx'
|
|
|
130
130
|
export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPicker, MediaBrowser } from './organisms/MediaLibrary.jsx'
|
|
131
131
|
export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
|
|
132
132
|
export { default as MediaViewer } from './organisms/MediaViewer.jsx'
|
|
133
|
+
export { default as SettingsPanel, SettingsRow, SettingsSwitch, SettingsChoice, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
|
|
133
134
|
export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
|
|
134
135
|
export { default as RecordManager } from './organisms/RecordManager.jsx'
|
|
135
136
|
export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import Button from '../atoms/Button.jsx'
|
|
4
|
+
import Divider from '../atoms/Divider.jsx'
|
|
5
|
+
import Input from '../atoms/Input.jsx'
|
|
4
6
|
import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
|
|
7
|
+
import ViewToggle from '../atoms/ViewToggle.jsx'
|
|
5
8
|
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
6
9
|
import MediaCard from '../molecules/MediaCard.jsx'
|
|
7
10
|
import MediaRow from '../molecules/MediaRow.jsx'
|
|
8
11
|
import ContentFilters from './ContentFilters.jsx'
|
|
9
12
|
import MediaViewer from './MediaViewer.jsx'
|
|
13
|
+
import { SettingsChipRow, chipCls } from './SettingsPanel.jsx'
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* MediaLibrary — a browser over an object bucket, in two views over one
|
|
@@ -21,20 +25,24 @@ import MediaViewer from './MediaViewer.jsx'
|
|
|
21
25
|
* Same contract as kol-dashboards / kol-chess / kol-content.
|
|
22
26
|
*
|
|
23
27
|
* COMPOSED, NOT BUILT. Every part is an existing DS member:
|
|
24
|
-
* ContentFilters — filter groups, animated search, view toggle, N-of-M count
|
|
25
28
|
* MediaCard — the grid tile (thumb · download chip · name · meta · actions)
|
|
26
29
|
* MediaRow — the list row (thumb · name · date · size · actions)
|
|
27
30
|
* MediaViewer — the lightbox, via its `actions` slot
|
|
31
|
+
* ContentFilters — the PICKER's chrome (search, kind filter, view toggle, N-of-M)
|
|
28
32
|
* 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
33
|
*
|
|
32
|
-
* NAVIGATION IS
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* NAVIGATION IS CLICK-TO-ENTER + BREADCRUMB (MediaLibraryReconcile, kol-r2b2
|
|
35
|
+
* 2026-08-26). The page variant is the read-only render of kol-r2b2's
|
|
36
|
+
* `FileList` — the one the user works in on media.kolkrabbi.io — so brand
|
|
37
|
+
* `/library` and media. are ONE picture: breadcrumb on top, a stats line
|
|
38
|
+
* (level · bucket · system files hidden), folder rows that ENTER a prefix,
|
|
39
|
+
* a bare toolbar (filter · search | Flat · view · sort with direction), a
|
|
40
|
+
* 260px tile grid, and `Show N more` paging. Finder's disclose-in-place tree
|
|
41
|
+
* (2026-08-01) is DROPPED, not kept as a variant — two navigation models in
|
|
42
|
+
* one organism is the fork this ticket exists to end. The picker keeps its
|
|
43
|
+
* ContentFilters chrome and its path bar at the foot; it navigates the same way.
|
|
36
44
|
*
|
|
37
|
-
* Read-only by design. Upload / rename / delete stay in kol-
|
|
45
|
+
* Read-only by design. Upload / rename / delete / select stay in kol-r2b2 —
|
|
38
46
|
* write auth does not belong in a browser-shipped package.
|
|
39
47
|
*/
|
|
40
48
|
|
|
@@ -203,71 +211,82 @@ function pairPosters(list) {
|
|
|
203
211
|
})
|
|
204
212
|
}
|
|
205
213
|
|
|
206
|
-
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
for (const o of objects) {
|
|
218
|
-
const dir = folderOf(o.key)
|
|
219
|
-
if (dir) {
|
|
220
|
-
/* register every ancestor so a deep key materialises its whole chain */
|
|
221
|
-
const parts = dir.slice(0, -1).split('/')
|
|
222
|
-
for (let i = 0; i < parts.length; i += 1) folders.add(`${parts.slice(0, i + 1).join('/')}/`)
|
|
223
|
-
}
|
|
224
|
-
if (!childrenOf.has(dir)) childrenOf.set(dir, [])
|
|
225
|
-
childrenOf.get(dir).push(o)
|
|
214
|
+
/* ── The prefix-scoped list ────────────────────────────────────────────────
|
|
215
|
+
* The list endpoint returns keys with no `prefixes` key and `?delimiter=/`
|
|
216
|
+
* changes nothing (probed 2026-08-01), so folders are derived here from the
|
|
217
|
+
* keys under the current prefix — this function is the whole navigation. */
|
|
218
|
+
function foldersUnder(list, prefix) {
|
|
219
|
+
const names = new Set()
|
|
220
|
+
for (const o of list) {
|
|
221
|
+
const rest = o.key.slice(prefix.length)
|
|
222
|
+
const i = rest.indexOf('/')
|
|
223
|
+
if (i > 0) names.add(rest.slice(0, i))
|
|
226
224
|
}
|
|
225
|
+
return [...names].sort().map((name) => ({ key: `${prefix}${name}/`, label: name }))
|
|
226
|
+
}
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
const SORT_OPTIONS = [
|
|
229
|
+
{ value: 'name', label: 'Name' },
|
|
230
|
+
{ value: 'date', label: 'Date' },
|
|
231
|
+
{ value: 'size', label: 'Size' },
|
|
232
|
+
{ value: 'kind', label: 'Kind' },
|
|
233
|
+
]
|
|
230
234
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
kind: (a, b) => String(a.contentType ?? '').localeCompare(String(b.contentType ?? '')),
|
|
237
|
-
}
|
|
238
|
-
return [...list].sort(by[sort] ?? by.name)
|
|
239
|
-
}
|
|
235
|
+
/* Chip labels — authored here, no text-transform (the kind key is the value). */
|
|
236
|
+
const KIND_LABEL = {
|
|
237
|
+
image: 'Image', video: 'Video', audio: 'Audio', text: 'Text', code: 'Code',
|
|
238
|
+
playlist: 'Playlist', font: 'Font', archive: 'Archive', other: 'Other',
|
|
239
|
+
}
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return
|
|
241
|
+
/* Sortable-header semantics (FileList): arrow-down = ascending (A→Z, oldest
|
|
242
|
+
* first, smallest first); ties fall back to the name. */
|
|
243
|
+
function sortFiles(files, { by, dir }) {
|
|
244
|
+
const d = dir === 'desc' ? -1 : 1
|
|
245
|
+
const name = (a, b) => a.displayKey.localeCompare(b.displayKey)
|
|
246
|
+
const cmp = {
|
|
247
|
+
name,
|
|
248
|
+
date: (a, b) => String(a.uploaded ?? '').localeCompare(String(b.uploaded ?? '')) || name(a, b),
|
|
249
|
+
size: (a, b) => (a.size ?? 0) - (b.size ?? 0) || name(a, b),
|
|
250
|
+
kind: (a, b) => a.kind.localeCompare(b.kind) || name(a, b),
|
|
251
|
+
}[by] ?? name
|
|
252
|
+
return [...files].sort((a, b) => d * cmp(a, b))
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
/**
|
|
256
|
-
* MediaLibraryProvider — the headless core: one list call,
|
|
257
|
-
*
|
|
256
|
+
* MediaLibraryProvider — the headless core: one list call, then a
|
|
257
|
+
* PREFIX-SCOPED view over it: the folders directly under the prefix, the
|
|
258
|
+
* files at that level (or the whole subtree in `flat`), the kind allow-list,
|
|
259
|
+
* the search, the sort with direction, and paging.
|
|
258
260
|
*
|
|
259
261
|
* @param {object} client `{ listMedia, mediaUrl, proxied? }` — required
|
|
260
262
|
* @param {string|string[]} accept 'all' (default) = everything · one kind ·
|
|
261
263
|
* or an allow-list, `['image','video']`, which is what a picker wants.
|
|
262
264
|
* Kinds: image · video · audio · text · code · playlist · font · archive ·
|
|
263
265
|
* other. Browsing never filters by default.
|
|
266
|
+
* @param {number} pageSize rows mounted before `Show N more` (60; 0 = all)
|
|
267
|
+
* @param {object} defaultSort `{ by, dir }` — `{ by: 'date', dir: 'desc' }`
|
|
268
|
+
* @param {boolean} flat start in flat mode (every object under the prefix)
|
|
264
269
|
*/
|
|
265
|
-
export function MediaLibraryProvider({
|
|
270
|
+
export function MediaLibraryProvider({
|
|
271
|
+
client,
|
|
272
|
+
accept = 'all',
|
|
273
|
+
pageSize = 60,
|
|
274
|
+
defaultSort = { by: 'date', dir: 'desc' },
|
|
275
|
+
flat: initialFlat = false,
|
|
276
|
+
children,
|
|
277
|
+
}) {
|
|
266
278
|
const [objects, setObjects] = useState([])
|
|
267
|
-
const [expanded, setExpanded] = useState(() => new Set())
|
|
268
|
-
const [sort, setSort] = useState('name')
|
|
269
279
|
const [loading, setLoading] = useState(true)
|
|
270
280
|
const [error, setError] = useState(null)
|
|
281
|
+
const [prefix, setPrefix] = useState('')
|
|
282
|
+
const [flat, setFlat] = useState(initialFlat)
|
|
283
|
+
const [sort, setSortState] = useState(defaultSort)
|
|
284
|
+
const [search, setSearch] = useState('')
|
|
285
|
+
const [kinds, setKinds] = useState(() => new Set())
|
|
286
|
+
/* Paging is keyed to WHAT is listed: prefix / flat / search / kinds change →
|
|
287
|
+
* back to one page, or walking into a folder would inherit the page count
|
|
288
|
+
* from the flat view you just left. Derived, not an effect. */
|
|
289
|
+
const [paging, setPaging] = useState({ id: null, visible: 0 })
|
|
271
290
|
|
|
272
291
|
useEffect(() => {
|
|
273
292
|
if (!client) return undefined
|
|
@@ -283,47 +302,97 @@ export function MediaLibraryProvider({ client, accept = 'all', children }) {
|
|
|
283
302
|
return () => { cancelled = true; controller.abort() }
|
|
284
303
|
}, [client])
|
|
285
304
|
|
|
286
|
-
|
|
287
|
-
|
|
305
|
+
/* click an inactive field → ascending; click the active one → flip */
|
|
306
|
+
const sortBy = (by) =>
|
|
307
|
+
setSortState((s) => (s.by === by ? { by, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { by, dir: 'asc' }))
|
|
308
|
+
const toggleKind = (k) =>
|
|
309
|
+
setKinds((prev) => {
|
|
288
310
|
const next = new Set(prev)
|
|
289
|
-
if (next.has(
|
|
290
|
-
else next.add(
|
|
311
|
+
if (next.has(k)) next.delete(k)
|
|
312
|
+
else next.add(k)
|
|
291
313
|
return next
|
|
292
314
|
})
|
|
293
315
|
|
|
294
316
|
const value = useMemo(() => {
|
|
295
317
|
const annotated = objects.map((o) => ({ ...o, kind: kindOf(o) }))
|
|
296
|
-
const
|
|
297
|
-
|
|
318
|
+
const scoped = prefix ? annotated.filter((o) => o.key.startsWith(prefix)) : annotated
|
|
319
|
+
const folders = foldersUnder(scoped, prefix)
|
|
320
|
+
/* level = the direct children; flat = every object under the prefix */
|
|
321
|
+
const level = flat ? scoped : scoped.filter((o) => !o.key.slice(prefix.length).includes('/'))
|
|
322
|
+
/* .DS_Store × 116 in the website bucket — dropped from the list, counted
|
|
323
|
+
* and reported: hiding them silently would be the same lie as the
|
|
324
|
+
* level-only totals were. */
|
|
325
|
+
const systemCount = level.reduce((n, o) => n + (o.kind === 'system' ? 1 : 0), 0)
|
|
298
326
|
/* Fold before pairing: a poster must be matched against real image keys,
|
|
299
327
|
* and resolution sets must be collapsed after that or the poster's own
|
|
300
328
|
* width suffix would swallow it. */
|
|
301
|
-
const
|
|
302
|
-
|
|
329
|
+
const folded = foldResolutionSets(pairPosters(foldHlsSegments(level.filter((o) => o.kind !== 'system'))))
|
|
330
|
+
const files = folded
|
|
331
|
+
.filter(acceptsKind(accept))
|
|
332
|
+
.map((o) => ({ ...o, displayKey: o.displayName ?? (flat ? o.key.slice(prefix.length) : fileName(o.key)) }))
|
|
333
|
+
|
|
334
|
+
const kindCounts = files.reduce((acc, o) => { acc[o.kind] = (acc[o.kind] || 0) + 1; return acc }, {})
|
|
335
|
+
const kindsPresent = Object.keys(kindCounts).sort().map((k) => ({ value: k, label: KIND_LABEL[k] ?? k, count: kindCounts[k] }))
|
|
336
|
+
|
|
337
|
+
const q = search.trim().toLowerCase()
|
|
338
|
+
const filtered = files.filter(
|
|
339
|
+
(o) => (kinds.size === 0 || kinds.has(o.kind)) && (!q || o.displayKey.toLowerCase().includes(q)),
|
|
303
340
|
)
|
|
341
|
+
const sorted = sortFiles(filtered, sort)
|
|
342
|
+
|
|
343
|
+
const page = pageSize || Infinity
|
|
344
|
+
const listId = `${prefix}|${flat}|${q}|${[...kinds].sort().join(',')}|${pageSize}`
|
|
345
|
+
const visible = paging.id === listId ? paging.visible : page
|
|
346
|
+
const shown = sorted.slice(0, visible)
|
|
347
|
+
const more = sorted.length - shown.length
|
|
304
348
|
|
|
305
|
-
const kept = visible.filter(acceptsKind(accept))
|
|
306
349
|
/* The lightbox pages images and videos; a .json in that list is a broken
|
|
307
|
-
* frame with a next-arrow. Its index space is this list, not `
|
|
308
|
-
const viewable =
|
|
350
|
+
* frame with a next-arrow. Its index space is this list, not `sorted`. */
|
|
351
|
+
const viewable = sorted.filter((o) => o.kind === 'image' || o.kind === 'video')
|
|
352
|
+
|
|
353
|
+
const bucketFiles = annotated.reduce((n, o) => n + (o.kind === 'system' ? 0 : 1), 0)
|
|
354
|
+
const bucketBytes = annotated.reduce((n, o) => n + (o.size ?? 0), 0)
|
|
309
355
|
|
|
310
356
|
return {
|
|
311
|
-
objects:
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
357
|
+
objects: files,
|
|
358
|
+
files,
|
|
359
|
+
filtered,
|
|
360
|
+
sorted,
|
|
361
|
+
shown,
|
|
362
|
+
more,
|
|
363
|
+
pageSize: page,
|
|
364
|
+
showMore: () => setPaging({ id: listId, visible: visible + page }),
|
|
365
|
+
prefix,
|
|
366
|
+
setPrefix,
|
|
367
|
+
crumbs: prefix ? prefix.replace(/\/$/, '').split('/') : [],
|
|
368
|
+
folders,
|
|
369
|
+
flat,
|
|
370
|
+
setFlat,
|
|
371
|
+
kindsPresent,
|
|
372
|
+
kinds,
|
|
373
|
+
toggleKind,
|
|
374
|
+
search,
|
|
375
|
+
setSearch,
|
|
319
376
|
sort,
|
|
320
|
-
|
|
377
|
+
sortBy,
|
|
378
|
+
stats: {
|
|
379
|
+
folders: folders.length,
|
|
380
|
+
files: filtered.length,
|
|
381
|
+
rawFiles: files.length,
|
|
382
|
+
bytes: files.reduce((n, o) => n + (o.size ?? 0), 0),
|
|
383
|
+
bucketFiles,
|
|
384
|
+
bucketBytes,
|
|
385
|
+
atRoot: !prefix,
|
|
386
|
+
systemCount,
|
|
387
|
+
filtering: kinds.size > 0 || q.length > 0,
|
|
388
|
+
},
|
|
389
|
+
viewable,
|
|
321
390
|
loading,
|
|
322
391
|
error,
|
|
323
392
|
mediaUrl: client?.mediaUrl ?? ((key) => key),
|
|
324
393
|
proxied: client?.proxied ?? ((url) => url),
|
|
325
394
|
}
|
|
326
|
-
}, [objects,
|
|
395
|
+
}, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client])
|
|
327
396
|
|
|
328
397
|
return <MediaLibraryContext.Provider value={value}>{children}</MediaLibraryContext.Provider>
|
|
329
398
|
}
|
|
@@ -336,27 +405,12 @@ export function useMediaLibrary() {
|
|
|
336
405
|
return ctx
|
|
337
406
|
}
|
|
338
407
|
|
|
339
|
-
function withProvider(node, { client, accept }) {
|
|
408
|
+
function withProvider(node, { client, accept, pageSize, defaultSort, flat }) {
|
|
340
409
|
if (!client) return node
|
|
341
|
-
return <MediaLibraryProvider client={client} accept={accept}>{node}</MediaLibraryProvider>
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/* Indentation per tree depth. A rem step rather than a magic pixel, and it
|
|
345
|
-
* rides the spacing scale's 1rem rung. */
|
|
346
|
-
const indent = (depth) => ({ paddingInlineStart: `calc(${depth} * var(--kol-spacing-4))` })
|
|
347
|
-
|
|
348
|
-
function FolderRow({ row, open, onToggle }) {
|
|
349
410
|
return (
|
|
350
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
onClick={onToggle}
|
|
354
|
-
aria-expanded={open}
|
|
355
|
-
>
|
|
356
|
-
<Icon name={open ? 'chevron-down' : 'chevron-right'} size={14} />
|
|
357
|
-
<Icon name="folder" size={16} />
|
|
358
|
-
<span className="kol-mono-12 text-emphasis flex-1">{row.label}</span>
|
|
359
|
-
</li>
|
|
411
|
+
<MediaLibraryProvider client={client} accept={accept} pageSize={pageSize} defaultSort={defaultSort} flat={flat}>
|
|
412
|
+
{node}
|
|
413
|
+
</MediaLibraryProvider>
|
|
360
414
|
)
|
|
361
415
|
}
|
|
362
416
|
|
|
@@ -429,151 +483,390 @@ function Thumb({ row, mediaUrl }) {
|
|
|
429
483
|
)
|
|
430
484
|
}
|
|
431
485
|
|
|
432
|
-
/*
|
|
433
|
-
*
|
|
434
|
-
|
|
435
|
-
|
|
486
|
+
/* One folder, click-to-enter. `struck` = flat mode: the folder is bypassed
|
|
487
|
+
* but still navigable, so it stays and reads de-emphasised. Box in
|
|
488
|
+
* .kol-media-folder (kol-components-organisms.css). */
|
|
489
|
+
function FolderRow({ folder, struck = false, onEnter }) {
|
|
490
|
+
return (
|
|
491
|
+
<li className="kol-media-folder" onClick={onEnter}>
|
|
492
|
+
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-meta">
|
|
493
|
+
<Icon name="folder" size={18} />
|
|
494
|
+
</span>
|
|
495
|
+
<span className={`kol-mono-12 flex-1 ${struck ? 'line-through text-meta' : 'text-body'}`}>{folder.label}</span>
|
|
496
|
+
<Icon name="chevron-right" size={14} className="text-subtle" />
|
|
497
|
+
</li>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function FolderRows() {
|
|
502
|
+
const { folders, flat, setPrefix } = useMediaLibrary()
|
|
503
|
+
if (!folders.length) return null
|
|
504
|
+
return (
|
|
505
|
+
<ul className="kol-media-list">
|
|
506
|
+
{folders.map((f) => (
|
|
507
|
+
<FolderRow key={f.key} folder={f} struck={flat} onEnter={() => setPrefix(f.key)} />
|
|
508
|
+
))}
|
|
509
|
+
</ul>
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/* Breadcrumb — `root / seg / seg`, every crumb a step back. */
|
|
514
|
+
function Breadcrumb({ className = '' }) {
|
|
515
|
+
const { crumbs, setPrefix } = useMediaLibrary()
|
|
516
|
+
return (
|
|
517
|
+
<div className={`flex items-center gap-1 kol-mono-12 text-meta ${className}`}>
|
|
518
|
+
<button type="button" className="hover:text-emphasis transition-colors" onClick={() => setPrefix('')}>root</button>
|
|
519
|
+
{crumbs.map((seg, i) => {
|
|
520
|
+
const to = `${crumbs.slice(0, i + 1).join('/')}/`
|
|
521
|
+
return (
|
|
522
|
+
<span key={to} className="flex items-center gap-1">
|
|
523
|
+
<span>/</span>
|
|
524
|
+
<button type="button" className="hover:text-emphasis transition-colors" onClick={() => setPrefix(to)}>{seg}</button>
|
|
525
|
+
</span>
|
|
526
|
+
)
|
|
527
|
+
})}
|
|
528
|
+
</div>
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/* The stats line. Whole-bucket figures at root or in flat — the level-only
|
|
533
|
+
* line used to read "0 files · 0 B" at a B2 root: true of the level, a lie
|
|
534
|
+
* about the bucket. */
|
|
535
|
+
function Stats() {
|
|
536
|
+
const { stats: s, flat } = useMediaLibrary()
|
|
537
|
+
return (
|
|
538
|
+
<p className="kol-mono-12 text-meta">
|
|
539
|
+
{s.folders > 0 && `${s.folders} folder${s.folders > 1 ? 's' : ''} · `}
|
|
540
|
+
{s.filtering && s.rawFiles !== s.files ? `${s.files} of ${s.rawFiles}` : s.rawFiles}
|
|
541
|
+
{' '}{s.rawFiles === 1 ? 'file' : 'files'} · {formatSize(s.bytes)}
|
|
542
|
+
{(s.atRoot || flat) && s.rawFiles !== s.bucketFiles && (
|
|
543
|
+
<span className="text-subtle">{' · bucket: '}{s.bucketFiles} files · {formatSize(s.bucketBytes)}</span>
|
|
544
|
+
)}
|
|
545
|
+
{s.systemCount > 0 && (
|
|
546
|
+
<span className="text-subtle">{' · '}{s.systemCount} system file{s.systemCount === 1 ? '' : 's'} hidden</span>
|
|
547
|
+
)}
|
|
548
|
+
</p>
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/* Sort — label buttons; the active one carries the direction glyph. */
|
|
553
|
+
function SortControls() {
|
|
554
|
+
const { sort, sortBy } = useMediaLibrary()
|
|
555
|
+
return (
|
|
556
|
+
<div className="flex items-center gap-4">
|
|
557
|
+
{SORT_OPTIONS.map((opt) => {
|
|
558
|
+
const active = sort.by === opt.value
|
|
559
|
+
return (
|
|
560
|
+
<button
|
|
561
|
+
key={opt.value}
|
|
562
|
+
type="button"
|
|
563
|
+
aria-pressed={active}
|
|
564
|
+
onClick={() => sortBy(opt.value)}
|
|
565
|
+
className={`kol-mono-12 flex items-center gap-1 transition-colors ${active ? 'text-emphasis' : 'text-meta hover:text-body'}`}
|
|
566
|
+
>
|
|
567
|
+
{opt.label}
|
|
568
|
+
{active && <Icon name={sort.dir === 'asc' ? 'arrow-down' : 'arrow-up'} size={10} />}
|
|
569
|
+
</button>
|
|
570
|
+
)
|
|
571
|
+
})}
|
|
572
|
+
</div>
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/* The bare toolbar — filter · search on the left; Flat · view · sort on the
|
|
577
|
+
* right. No title: that is the consumer's page header. The icon buttons are
|
|
578
|
+
* DS Buttons on the nav rung (the box has an owner), `pressed` when the
|
|
579
|
+
* filter is open or narrowing. */
|
|
580
|
+
function Toolbar({ viewMode, onViewMode }) {
|
|
581
|
+
const { search, setSearch, kindsPresent, kinds, toggleKind, flat, setFlat } = useMediaLibrary()
|
|
582
|
+
const [filterOpen, setFilterOpen] = useState(false)
|
|
583
|
+
const [searchOpen, setSearchOpen] = useState(false)
|
|
584
|
+
|
|
585
|
+
return (
|
|
586
|
+
<>
|
|
587
|
+
<div className="flex items-center justify-between gap-4">
|
|
588
|
+
<div className="flex items-center gap-2">
|
|
589
|
+
<Button
|
|
590
|
+
variant="nav"
|
|
591
|
+
size="sm"
|
|
592
|
+
iconOnly="filter"
|
|
593
|
+
iconSize={16}
|
|
594
|
+
quiet
|
|
595
|
+
pressed={filterOpen || kinds.size > 0}
|
|
596
|
+
aria-label="Toggle kind filter"
|
|
597
|
+
onClick={() => setFilterOpen((v) => !v)}
|
|
598
|
+
/>
|
|
599
|
+
{searchOpen ? (
|
|
600
|
+
<Input
|
|
601
|
+
size="sm"
|
|
602
|
+
variant="outline"
|
|
603
|
+
width="200px"
|
|
604
|
+
value={search}
|
|
605
|
+
autoFocus
|
|
606
|
+
placeholder="search name…"
|
|
607
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
608
|
+
onKeyDown={(e) => { if (e.key === 'Escape') { setSearch(''); setSearchOpen(false) } }}
|
|
609
|
+
onBlur={() => { if (!search) setSearchOpen(false) }}
|
|
610
|
+
/>
|
|
611
|
+
) : (
|
|
612
|
+
<Button
|
|
613
|
+
variant="nav"
|
|
614
|
+
size="sm"
|
|
615
|
+
iconOnly="search"
|
|
616
|
+
iconSize={16}
|
|
617
|
+
quiet
|
|
618
|
+
pressed={search.length > 0}
|
|
619
|
+
aria-label="Search"
|
|
620
|
+
onClick={() => setSearchOpen(true)}
|
|
621
|
+
/>
|
|
622
|
+
)}
|
|
623
|
+
</div>
|
|
624
|
+
<div className="flex items-center gap-3">
|
|
625
|
+
<button
|
|
626
|
+
type="button"
|
|
627
|
+
aria-pressed={flat}
|
|
628
|
+
title="Show all files recursively"
|
|
629
|
+
onClick={() => setFlat(!flat)}
|
|
630
|
+
className={chipCls(flat)}
|
|
631
|
+
>
|
|
632
|
+
Flat
|
|
633
|
+
</button>
|
|
634
|
+
<ViewToggle viewMode={viewMode} onViewChange={onViewMode} variant="icon" />
|
|
635
|
+
<Divider variant="vertical" />
|
|
636
|
+
<SortControls />
|
|
637
|
+
</div>
|
|
638
|
+
</div>
|
|
639
|
+
{filterOpen && (
|
|
640
|
+
<div className="flex items-center gap-2">
|
|
641
|
+
<span className="kol-mono-12 text-subtle">Kind</span>
|
|
642
|
+
<SettingsChipRow options={kindsPresent} selected={kinds} onToggle={toggleKind} />
|
|
643
|
+
</div>
|
|
644
|
+
)}
|
|
645
|
+
</>
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/* The tiles or the rows over ONE paged list — shared by the page and the
|
|
650
|
+
* picker; `onPick` is the only difference between them. */
|
|
651
|
+
function FilesBody({ files, viewMode, onOpen, onPick }) {
|
|
652
|
+
const { mediaUrl, viewable, prefix, stats, more, pageSize, showMore } = useMediaLibrary()
|
|
436
653
|
const [copied, copy] = useCopy()
|
|
437
654
|
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
655
|
+
if (files.length === 0) {
|
|
656
|
+
return (
|
|
657
|
+
<p className="kol-mono-12 text-meta">
|
|
658
|
+
{stats.filtering ? 'No files match.' : `No files${prefix ? ` in "${prefix}"` : ''} yet.`}
|
|
659
|
+
</p>
|
|
660
|
+
)
|
|
661
|
+
}
|
|
441
662
|
|
|
442
|
-
const files = rows.filter((r) => r.type === 'file')
|
|
443
663
|
/* Index into `viewable`, which is what the lightbox pages — indexing into the
|
|
444
664
|
* filtered rows meant a search narrowing the grid opened the wrong file. */
|
|
445
665
|
const openerFor = (row) => {
|
|
446
666
|
const i = viewable.findIndex((f) => f.key === row.key)
|
|
447
667
|
return i < 0 ? undefined : () => onOpen(i)
|
|
448
668
|
}
|
|
449
|
-
|
|
450
|
-
/* Copy hands over the full-size variant, not the thumbnail the tile loaded. */
|
|
669
|
+
/* Copy and download hand over the full-size variant, not the thumbnail. */
|
|
451
670
|
const urlFor = (row) => mediaUrl(row.fullKey ?? row.key)
|
|
452
671
|
|
|
672
|
+
const thumbFor = (row) => {
|
|
673
|
+
const open = openerFor(row)
|
|
674
|
+
return (
|
|
675
|
+
<div
|
|
676
|
+
className={`w-full h-full flex items-center justify-center bg-fg-04 overflow-hidden${open ? ' cursor-zoom-in' : ''}`}
|
|
677
|
+
onClick={open}
|
|
678
|
+
>
|
|
679
|
+
<Thumb row={row} mediaUrl={mediaUrl} />
|
|
680
|
+
</div>
|
|
681
|
+
)
|
|
682
|
+
}
|
|
453
683
|
const actionsFor = (row) => (
|
|
454
684
|
<div className="flex items-center gap-2">
|
|
455
685
|
{onPick && <Button size="sm" onClick={() => onPick(row)}>Use</Button>}
|
|
456
686
|
<Button variant="secondary" size="sm" onClick={() => copy(urlFor(row))}>
|
|
457
687
|
{copied === urlFor(row) ? 'Copied' : 'Copy URL'}
|
|
458
688
|
</Button>
|
|
689
|
+
<Button variant="ghost" size="sm" iconOnly="download" iconSize={14} href={urlFor(row)} aria-label={`Download ${row.displayKey}`} />
|
|
459
690
|
</div>
|
|
460
691
|
)
|
|
692
|
+
const nameFor = (row) => <p className="kol-mono-12 text-body truncate" title={row.key}>{row.displayKey}</p>
|
|
693
|
+
const date = (row) => (row.uploaded ? String(row.uploaded).slice(0, 10) : '')
|
|
461
694
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
row
|
|
467
|
-
<
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
695
|
+
return (
|
|
696
|
+
<>
|
|
697
|
+
{viewMode === 'list' ? (
|
|
698
|
+
<ul className="kol-media-list">
|
|
699
|
+
{files.map((row) => (
|
|
700
|
+
<MediaRow
|
|
701
|
+
key={row.key}
|
|
702
|
+
thumb={thumbFor(row)}
|
|
703
|
+
name={nameFor(row)}
|
|
704
|
+
date={date(row)}
|
|
705
|
+
size={formatSize(row.size)}
|
|
706
|
+
actions={actionsFor(row)}
|
|
707
|
+
/>
|
|
708
|
+
))}
|
|
709
|
+
</ul>
|
|
710
|
+
) : (
|
|
711
|
+
<ul className="kol-media-grid">
|
|
712
|
+
{files.map((row) => (
|
|
713
|
+
<MediaCard
|
|
714
|
+
key={row.key}
|
|
715
|
+
thumb={thumbFor(row)}
|
|
716
|
+
name={nameFor(row)}
|
|
717
|
+
meta={`${formatSize(row.size)}${date(row) ? ` · ${date(row)}` : ''}`}
|
|
718
|
+
downloadHref={urlFor(row)}
|
|
719
|
+
actions={actionsFor(row)}
|
|
720
|
+
/>
|
|
721
|
+
))}
|
|
722
|
+
</ul>
|
|
723
|
+
)}
|
|
724
|
+
{more > 0 && (
|
|
725
|
+
<button
|
|
726
|
+
type="button"
|
|
727
|
+
onClick={showMore}
|
|
728
|
+
className="kol-mono-12 text-meta hover:text-emphasis transition-colors self-start py-2"
|
|
729
|
+
>
|
|
730
|
+
Show {Math.min(more, pageSize)} more · {more} remaining
|
|
731
|
+
</button>
|
|
732
|
+
)}
|
|
733
|
+
</>
|
|
734
|
+
)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/* The lightbox is MediaViewer — the DS already has ONE fullscreen paged viewer
|
|
738
|
+
* and this is not a second one. Use / Copy URL ride its `actions` slot. */
|
|
739
|
+
function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
|
|
740
|
+
const { viewable, mediaUrl } = useMediaLibrary()
|
|
741
|
+
const [copied, copy] = useCopy()
|
|
742
|
+
|
|
743
|
+
/* Full-size in the lightbox — `key` is the thumbnail variant for folded sets. */
|
|
744
|
+
const media = viewable.map((o) => ({
|
|
745
|
+
url: mediaUrl(o.fullKey ?? o.key),
|
|
746
|
+
alt: fileName(o.key),
|
|
747
|
+
kind: o.kind === 'video' ? 'video' : 'image',
|
|
748
|
+
caption: `${o.displayName ?? fileName(o.key)} · ${formatSize(o.size)}`,
|
|
749
|
+
}))
|
|
491
750
|
|
|
492
751
|
return (
|
|
493
|
-
<
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
<
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
<Thumb row={row} mediaUrl={mediaUrl} />
|
|
509
|
-
</div>
|
|
510
|
-
}
|
|
511
|
-
name={<p className="kol-mono-12 text-emphasis truncate">{row.displayKey}</p>}
|
|
512
|
-
meta={`${formatSize(row.size)}${row.uploaded ? ` · ${String(row.uploaded).slice(0, 10)}` : ''}`}
|
|
513
|
-
/* The set's largest variant, not the thumbnail the tile painted. */
|
|
514
|
-
downloadHref={mediaUrl(row.fullKey ?? row.key)}
|
|
515
|
-
actions={actionsFor(row)}
|
|
516
|
-
/>
|
|
517
|
-
))}
|
|
518
|
-
</ul>
|
|
519
|
-
</div>
|
|
752
|
+
<MediaViewer
|
|
753
|
+
open={index !== null}
|
|
754
|
+
media={media}
|
|
755
|
+
index={index ?? 0}
|
|
756
|
+
onIndexChange={onIndexChange}
|
|
757
|
+
onClose={onClose}
|
|
758
|
+
actions={(item, i) => (
|
|
759
|
+
<>
|
|
760
|
+
{onPick && <Button size="sm" onClick={() => onPick(viewable[i])}>Use</Button>}
|
|
761
|
+
<Button variant="secondary" size="sm" onClick={() => copy(item.url)}>
|
|
762
|
+
{copied === item.url ? 'Copied' : 'Copy URL'}
|
|
763
|
+
</Button>
|
|
764
|
+
</>
|
|
765
|
+
)}
|
|
766
|
+
/>
|
|
520
767
|
)
|
|
521
768
|
}
|
|
522
769
|
|
|
523
|
-
/*
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
770
|
+
/* ── The PAGE — FileList's read-only render ─────────────────────────────── */
|
|
771
|
+
function BrowserShell({ onSelect }) {
|
|
772
|
+
const lib = useMediaLibrary()
|
|
773
|
+
const [viewMode, setViewMode] = useState('grid')
|
|
774
|
+
const [viewerIndex, setViewerIndex] = useState(null)
|
|
775
|
+
|
|
776
|
+
const pick = onSelect
|
|
777
|
+
? (o) => onSelect(lib.mediaUrl(o.fullKey ?? o.key), { contentType: o.contentType, kind: o.kind })
|
|
778
|
+
: undefined
|
|
779
|
+
|
|
529
780
|
return (
|
|
530
|
-
<div className="kol-media-
|
|
531
|
-
<
|
|
532
|
-
<
|
|
533
|
-
{
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
{
|
|
540
|
-
<
|
|
541
|
-
{
|
|
542
|
-
|
|
781
|
+
<div className="kol-media-browser gap-3">
|
|
782
|
+
<Breadcrumb />
|
|
783
|
+
<Stats />
|
|
784
|
+
{lib.error && <p className="kol-mono-12 text-ui-error">Couldn’t load: {lib.error}</p>}
|
|
785
|
+
{lib.loading && <p className="kol-mono-12 text-meta">Loading…</p>}
|
|
786
|
+
{!lib.loading && !lib.error && lib.stats.folders === 0 && lib.stats.rawFiles === 0 && (
|
|
787
|
+
<p className="kol-mono-12 text-meta">No files{lib.prefix ? ` in "${lib.prefix}"` : ''} yet.</p>
|
|
788
|
+
)}
|
|
789
|
+
<FolderRows />
|
|
790
|
+
{lib.stats.rawFiles > 0 && (
|
|
791
|
+
<div className="flex flex-col gap-3">
|
|
792
|
+
<Toolbar viewMode={viewMode} onViewMode={setViewMode} />
|
|
793
|
+
<Divider />
|
|
794
|
+
<FilesBody files={lib.shown} viewMode={viewMode} onOpen={setViewerIndex} onPick={pick} />
|
|
795
|
+
</div>
|
|
796
|
+
)}
|
|
797
|
+
{viewerIndex !== null && lib.viewable[viewerIndex] && (
|
|
798
|
+
<LibraryViewer
|
|
799
|
+
index={viewerIndex}
|
|
800
|
+
onIndexChange={setViewerIndex}
|
|
801
|
+
onClose={() => setViewerIndex(null)}
|
|
802
|
+
onPick={pick}
|
|
803
|
+
/>
|
|
543
804
|
)}
|
|
544
805
|
</div>
|
|
545
806
|
)
|
|
546
807
|
}
|
|
547
808
|
|
|
809
|
+
/* ── The PICKER — ContentFilters chrome, path bar at the foot ───────────── */
|
|
810
|
+
|
|
548
811
|
const VIEW_OPTIONS = [
|
|
549
812
|
{ value: 'grid', icon: 'grid', label: 'Grid' },
|
|
550
813
|
{ value: 'list', icon: 'view-list', label: 'List' },
|
|
551
814
|
]
|
|
552
815
|
|
|
553
|
-
/*
|
|
554
|
-
*
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
{
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
816
|
+
/* The path at the FOOT of the picker card; the hidden-system count rides it —
|
|
817
|
+
* hiding 118 `.DS_Store` files without saying so is the same silent drop this
|
|
818
|
+
* component was filed for. */
|
|
819
|
+
function PathBar() {
|
|
820
|
+
const { stats } = useMediaLibrary()
|
|
821
|
+
return (
|
|
822
|
+
<div className="kol-media-pathbar">
|
|
823
|
+
<Icon name="folder" size={12} />
|
|
824
|
+
<Breadcrumb />
|
|
825
|
+
{stats.systemCount > 0 && (
|
|
826
|
+
<span className="kol-helper-12 text-meta ms-auto">
|
|
827
|
+
{stats.systemCount} system file{stats.systemCount === 1 ? '' : 's'} hidden
|
|
828
|
+
</span>
|
|
829
|
+
)}
|
|
830
|
+
</div>
|
|
831
|
+
)
|
|
832
|
+
}
|
|
561
833
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
834
|
+
function PickerBody({ items, viewMode, onOpen, onPick }) {
|
|
835
|
+
const { folders, flat, setPrefix, loading, error, more, pageSize, showMore } = useMediaLibrary()
|
|
836
|
+
if (error) return <p className="kol-helper-12 text-ui-error">Couldn’t load: {error}</p>
|
|
837
|
+
if (loading) return <p className="kol-helper-12 text-meta">Loading…</p>
|
|
838
|
+
const files = items.filter((r) => r.type !== 'folder')
|
|
839
|
+
const shownFolders = items.filter((r) => r.type === 'folder')
|
|
840
|
+
if (items.length === 0) return <p className="kol-helper-12 text-meta">Nothing here.</p>
|
|
841
|
+
return (
|
|
842
|
+
<div className="kol-media-scroll flex flex-col gap-3">
|
|
843
|
+
{shownFolders.length > 0 && (
|
|
844
|
+
<ul className="kol-media-list">
|
|
845
|
+
{shownFolders.map((f) => (
|
|
846
|
+
<FolderRow key={f.key} folder={f} struck={flat} onEnter={() => setPrefix(f.key)} />
|
|
847
|
+
))}
|
|
848
|
+
</ul>
|
|
849
|
+
)}
|
|
850
|
+
<FilesBody files={files.slice(0, files.length - Math.max(0, more))} viewMode={viewMode} onOpen={onOpen} onPick={onPick} />
|
|
851
|
+
{void folders}{void pageSize}{void showMore}
|
|
852
|
+
</div>
|
|
853
|
+
)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/* The picker's chrome — ContentFilters owns the animated search, the kind
|
|
857
|
+
* filter, the view toggle and the N-of-M count; sort rides its header slot. */
|
|
565
858
|
function LibraryChrome({ onOpen, onPick }) {
|
|
566
|
-
const {
|
|
859
|
+
const { folders, sorted, sort, sortBy } = useMediaLibrary()
|
|
567
860
|
const [viewMode, setViewMode] = useState('grid')
|
|
568
861
|
|
|
569
862
|
const items = useMemo(
|
|
570
|
-
() =>
|
|
571
|
-
...
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
[rows],
|
|
863
|
+
() => [
|
|
864
|
+
...folders.map((f) => ({ ...f, type: 'folder', name: f.label, kind: 'folder' })),
|
|
865
|
+
...sorted.map((o) => ({ ...o, type: 'file', name: o.displayKey })),
|
|
866
|
+
],
|
|
867
|
+
[folders, sorted],
|
|
576
868
|
)
|
|
869
|
+
const kinds = useMemo(() => [...new Set(sorted.map((o) => o.kind))].sort(), [sorted])
|
|
577
870
|
|
|
578
871
|
return (
|
|
579
872
|
<>
|
|
@@ -587,60 +880,19 @@ function LibraryChrome({ onOpen, onPick }) {
|
|
|
587
880
|
onViewModeChange={setViewMode}
|
|
588
881
|
viewModeOptions={VIEW_OPTIONS}
|
|
589
882
|
mutuallyExclusiveFilters={['kind']}
|
|
590
|
-
/* Derived — a hard-coded image/video/folder list is how the filter bar
|
|
591
|
-
* denied the existence of the audio and data the provider now keeps. */
|
|
592
883
|
filterGroups={[{ label: 'Kind', key: 'kind', values: ['folder', ...kinds] }]}
|
|
593
884
|
headerActions={
|
|
594
|
-
<SegmentedToggle
|
|
595
|
-
size="sm"
|
|
596
|
-
value={sort}
|
|
597
|
-
onChange={setSort}
|
|
598
|
-
options={SORTS}
|
|
599
|
-
ariaLabel="Sort by"
|
|
600
|
-
/>
|
|
885
|
+
<SegmentedToggle size="sm" value={sort.by} onChange={sortBy} options={SORT_OPTIONS} ariaLabel="Sort by" />
|
|
601
886
|
}
|
|
602
887
|
renderItem={(filtered, mode) => (
|
|
603
|
-
<
|
|
888
|
+
<PickerBody items={filtered} viewMode={mode} onOpen={onOpen} onPick={onPick} />
|
|
604
889
|
)}
|
|
605
890
|
/>
|
|
606
|
-
<PathBar
|
|
891
|
+
<PathBar />
|
|
607
892
|
</>
|
|
608
893
|
)
|
|
609
894
|
}
|
|
610
895
|
|
|
611
|
-
/* The lightbox is MediaViewer — the DS already has ONE fullscreen paged viewer
|
|
612
|
-
* and this is not a second one. Use / Copy URL ride its `actions` slot. */
|
|
613
|
-
function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
|
|
614
|
-
const { viewable, mediaUrl } = useMediaLibrary()
|
|
615
|
-
const [copied, copy] = useCopy()
|
|
616
|
-
|
|
617
|
-
/* Full-size in the lightbox — `key` is the thumbnail variant for folded sets. */
|
|
618
|
-
const media = viewable.map((o) => ({
|
|
619
|
-
url: mediaUrl(o.fullKey ?? o.key),
|
|
620
|
-
alt: fileName(o.key),
|
|
621
|
-
kind: o.kind === 'video' ? 'video' : 'image',
|
|
622
|
-
caption: `${o.displayName ?? fileName(o.key)} · ${formatSize(o.size)}`,
|
|
623
|
-
}))
|
|
624
|
-
|
|
625
|
-
return (
|
|
626
|
-
<MediaViewer
|
|
627
|
-
open={index !== null}
|
|
628
|
-
media={media}
|
|
629
|
-
index={index ?? 0}
|
|
630
|
-
onIndexChange={onIndexChange}
|
|
631
|
-
onClose={onClose}
|
|
632
|
-
actions={(item, i) => (
|
|
633
|
-
<>
|
|
634
|
-
{onPick && <Button size="sm" onClick={() => onPick(viewable[i])}>Use</Button>}
|
|
635
|
-
<Button variant="secondary" size="sm" onClick={() => copy(item.url)}>
|
|
636
|
-
{copied === item.url ? 'Copied' : 'Copy URL'}
|
|
637
|
-
</Button>
|
|
638
|
-
</>
|
|
639
|
-
)}
|
|
640
|
-
/>
|
|
641
|
-
)
|
|
642
|
-
}
|
|
643
|
-
|
|
644
896
|
function PickerShell({ onClose, onPick }) {
|
|
645
897
|
const { viewable, mediaUrl } = useMediaLibrary()
|
|
646
898
|
const [viewerIndex, setViewerIndex] = useState(null)
|
|
@@ -676,34 +928,37 @@ function PickerShell({ onClose, onPick }) {
|
|
|
676
928
|
/**
|
|
677
929
|
* MediaLibrary — ONE component, two variants. The user's ruling 2026-08-01:
|
|
678
930
|
* "arent different components, they are more like variants, same shit
|
|
679
|
-
* different viewing."
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
* Variant is CONTAINER GEOMETRY ONLY, the ThemeToggle precedent: everything
|
|
683
|
-
* else is a prop. `MediaBrowser` and `MediaPicker` survive below as thin
|
|
684
|
-
* aliases so no call site breaks.
|
|
931
|
+
* different viewing." `page` and `modal` differ in the shell around the body
|
|
932
|
+
* and in whether picking closes.
|
|
685
933
|
*
|
|
686
|
-
* @param {string} variant 'page' (in-flow,
|
|
934
|
+
* @param {string} variant 'page' (in-flow, FileList's render) | 'modal' (overlay picker)
|
|
687
935
|
* @param {boolean} open modal only — mounts the overlay
|
|
688
936
|
* @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
|
|
689
937
|
* @param {string|string[]} accept 'all' (default) = everything · one kind · an
|
|
690
938
|
* allow-list `['image','video']`
|
|
939
|
+
* @param {number} pageSize rows mounted before `Show N more` (60; 0 = all)
|
|
940
|
+
* @param {object} defaultSort `{ by, dir }` (date desc)
|
|
941
|
+
* @param {boolean} flat start in flat mode
|
|
691
942
|
* @param {Function} onClose modal only — Esc, backdrop, close button
|
|
692
|
-
* @param {Function} onSelect `(url, { contentType })`. In `modal` it also closes.
|
|
943
|
+
* @param {Function} onSelect `(url, { contentType, kind })`. In `modal` it also closes.
|
|
693
944
|
*/
|
|
694
945
|
export default function MediaLibrary({
|
|
695
946
|
variant = 'page',
|
|
696
947
|
open = true,
|
|
697
948
|
client,
|
|
698
949
|
accept = 'all',
|
|
950
|
+
pageSize = 60,
|
|
951
|
+
defaultSort = { by: 'date', dir: 'desc' },
|
|
952
|
+
flat = false,
|
|
699
953
|
onClose,
|
|
700
954
|
onSelect = null,
|
|
701
955
|
}) {
|
|
956
|
+
const opts = { client, accept, pageSize, defaultSort, flat }
|
|
702
957
|
if (variant === 'modal') {
|
|
703
958
|
if (!open) return null
|
|
704
|
-
return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />,
|
|
959
|
+
return withProvider(<PickerShell onClose={onClose} onPick={onSelect} />, opts)
|
|
705
960
|
}
|
|
706
|
-
return withProvider(<BrowserShell onSelect={onSelect} />,
|
|
961
|
+
return withProvider(<BrowserShell onSelect={onSelect} />, opts)
|
|
707
962
|
}
|
|
708
963
|
|
|
709
964
|
/** Alias — `MediaLibrary variant="modal"`. Kept so existing call sites and the
|
|
@@ -721,32 +976,8 @@ export function MediaPicker({ open, client, accept = 'all', onClose, onPick }) {
|
|
|
721
976
|
)
|
|
722
977
|
}
|
|
723
978
|
|
|
724
|
-
function BrowserShell({ onSelect }) {
|
|
725
|
-
const { viewable, mediaUrl } = useMediaLibrary()
|
|
726
|
-
const [viewerIndex, setViewerIndex] = useState(null)
|
|
727
|
-
|
|
728
|
-
const pick = onSelect
|
|
729
|
-
? (o) => onSelect(mediaUrl(o.fullKey ?? o.key), { contentType: o.contentType, kind: o.kind })
|
|
730
|
-
: undefined
|
|
731
|
-
|
|
732
|
-
return (
|
|
733
|
-
<div className="kol-media-browser">
|
|
734
|
-
<LibraryChrome onOpen={setViewerIndex} onPick={pick} />
|
|
735
|
-
|
|
736
|
-
{viewerIndex !== null && viewable[viewerIndex] && (
|
|
737
|
-
<LibraryViewer
|
|
738
|
-
index={viewerIndex}
|
|
739
|
-
onIndexChange={setViewerIndex}
|
|
740
|
-
onClose={() => setViewerIndex(null)}
|
|
741
|
-
onPick={pick}
|
|
742
|
-
/>
|
|
743
|
-
)}
|
|
744
|
-
</div>
|
|
745
|
-
)
|
|
746
|
-
}
|
|
747
|
-
|
|
748
979
|
/** Alias — `MediaLibrary variant="page"`. Without `onSelect` the actions offer
|
|
749
|
-
* Copy URL only, which is the read-only page a brand book wants. */
|
|
750
|
-
export function MediaBrowser({ client, accept = 'all', onSelect = null }) {
|
|
751
|
-
return <MediaLibrary variant="page" client={client} accept={accept} onSelect={onSelect} />
|
|
980
|
+
* Copy URL + download only, which is the read-only page a brand book wants. */
|
|
981
|
+
export function MediaBrowser({ client, accept = 'all', onSelect = null, ...rest }) {
|
|
982
|
+
return <MediaLibrary variant="page" client={client} accept={accept} onSelect={onSelect} {...rest} />
|
|
752
983
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import Button from '../atoms/Button.jsx'
|
|
2
|
+
import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
|
|
3
|
+
import ToggleSwitch from '../atoms/ToggleSwitch.jsx'
|
|
4
|
+
import ShellDrawer from '../molecules/ShellDrawer.jsx'
|
|
5
|
+
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SettingsPanel — a settings surface for the thing you are looking at: you
|
|
9
|
+
* change how a list looks WHILE looking at it, so it is a slide-over (or an
|
|
10
|
+
* overlay), never a route (SettingsPanel, kol-r2b2 2026-08-26 — the
|
|
11
|
+
* per-bucket display settings: kinds allow-list, structure, loading, layout).
|
|
12
|
+
*
|
|
13
|
+
* TWO PRESENTATIONS, ONE ANATOMY (the user's ask): `variant="drawer"` is a
|
|
14
|
+
* right-anchored sheet over a scrim, `variant="overlay"` the same panel
|
|
15
|
+
* centred. Only the shell differs; header · intro · sections · footer are the
|
|
16
|
+
* same nodes in both. Neither shell is built here — the drawer IS ShellDrawer
|
|
17
|
+
* (scrim, Escape, focus trap, scroll lock, focus return, the × control) and
|
|
18
|
+
* the overlay IS FullscreenOverlay (scrim, Escape, backdrop dismiss, scroll
|
|
19
|
+
* lock, the corner ×). The source had none of that: scrim click was the only
|
|
20
|
+
* exit, no Escape, no trap, no lock, and a hand-typed `rgba(0,0,0,0.6)` scrim
|
|
21
|
+
* where the DS already owned one.
|
|
22
|
+
*
|
|
23
|
+
* The CONTROLS are the DS controls, not the source's word-buttons: a switch
|
|
24
|
+
* row is `ToggleSwitch`, a choice row is `SegmentedToggle` — the source's
|
|
25
|
+
* segmented variant was flagged by the user as the thing to fix here, not to
|
|
26
|
+
* copy. Sections are the DS `Section` (pass `divided` on every one and the
|
|
27
|
+
* hairline lands between siblings on its own).
|
|
28
|
+
*
|
|
29
|
+
* Text casing is authored at the call site (no text-transform) — the source
|
|
30
|
+
* mixed `Show kinds` / `on` / `grid` in one panel; write them how they should read.
|
|
31
|
+
*
|
|
32
|
+
* @param {boolean} open mounted and shown (default true — the parent
|
|
33
|
+
* usually conditionally renders it)
|
|
34
|
+
* @param {'drawer'|'overlay'} variant presentation
|
|
35
|
+
* @param {number|string} width panel width (default 380)
|
|
36
|
+
* @param {string} title header line 1
|
|
37
|
+
* @param {string} subtitle header line 2 — the context, e.g. the bucket label
|
|
38
|
+
* @param {string} intro the one-line note under the header
|
|
39
|
+
* @param {Function} onClose × · scrim · Escape
|
|
40
|
+
* @param {ReactNode} footer the foot slot — `SettingsFooter` is the default shape
|
|
41
|
+
* @param {ReactNode} children the sections
|
|
42
|
+
*/
|
|
43
|
+
export default function SettingsPanel({
|
|
44
|
+
open = true,
|
|
45
|
+
variant = 'drawer',
|
|
46
|
+
width = 380,
|
|
47
|
+
title,
|
|
48
|
+
subtitle,
|
|
49
|
+
intro,
|
|
50
|
+
onClose,
|
|
51
|
+
footer,
|
|
52
|
+
children,
|
|
53
|
+
className = '',
|
|
54
|
+
}) {
|
|
55
|
+
const header = (
|
|
56
|
+
<div className="flex min-w-0 flex-col">
|
|
57
|
+
{title && <span className="kol-mono-12 text-emphasis">{title}</span>}
|
|
58
|
+
{subtitle && <span className="kol-mono-10 text-meta">{subtitle}</span>}
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
const body = (
|
|
62
|
+
<>
|
|
63
|
+
{intro && <p className="kol-mono-10 text-meta">{intro}</p>}
|
|
64
|
+
<div className="flex flex-col gap-5">{children}</div>
|
|
65
|
+
{footer && <div className="mt-2">{footer}</div>}
|
|
66
|
+
</>
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if (variant === 'overlay') {
|
|
70
|
+
if (!open) return null
|
|
71
|
+
return (
|
|
72
|
+
<FullscreenOverlay open onClose={onClose}>
|
|
73
|
+
{/* the corner × rides the sheet at --kol-spacing-3; the panel reserves
|
|
74
|
+
* that lane on top (the .kol-media-picker precedent) instead of the
|
|
75
|
+
* control moving */}
|
|
76
|
+
<div
|
|
77
|
+
className={`kol-overlay-panel flex flex-col gap-4 p-6 pt-12 ${className}`}
|
|
78
|
+
style={{ width: typeof width === 'number' ? `${width}px` : width, maxWidth: '100%', maxHeight: '85vh', overflowY: 'auto' }}
|
|
79
|
+
>
|
|
80
|
+
{header}
|
|
81
|
+
{body}
|
|
82
|
+
</div>
|
|
83
|
+
</FullscreenOverlay>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<ShellDrawer open={open} onClose={onClose} side="right" width={width} header={header} className={className}>
|
|
89
|
+
<div className="flex flex-col gap-4">{body}</div>
|
|
90
|
+
</ShellDrawer>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* SettingsRow — label + hint on the left, the control on the right. A GRID,
|
|
96
|
+
* not a flex pair: the source's `justify-between` let a long hint wrap UNDER
|
|
97
|
+
* the control and parked the control at the top of a two-line hint. Here the
|
|
98
|
+
* text column is `minmax(0, 1fr)` so it wraps inside itself, and the control
|
|
99
|
+
* centres on the row.
|
|
100
|
+
*/
|
|
101
|
+
export function SettingsRow({ label, hint, children }) {
|
|
102
|
+
return (
|
|
103
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-6 py-1">
|
|
104
|
+
<div className="flex min-w-0 flex-col">
|
|
105
|
+
<span className="kol-mono-12 text-body">{label}</span>
|
|
106
|
+
{hint && <span className="kol-mono-10 text-meta">{hint}</span>}
|
|
107
|
+
</div>
|
|
108
|
+
<div className="shrink-0">{children}</div>
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** SettingsSwitch — the row's on/off control: the DS ToggleSwitch, bare, sm.
|
|
114
|
+
* `disabledHint` rides `title` so a switch that cannot act says why. */
|
|
115
|
+
export function SettingsSwitch({ on = false, onChange, disabled = false, disabledHint, label }) {
|
|
116
|
+
return (
|
|
117
|
+
<ToggleSwitch
|
|
118
|
+
size="sm"
|
|
119
|
+
checked={on}
|
|
120
|
+
onChange={onChange}
|
|
121
|
+
disabled={disabled}
|
|
122
|
+
title={disabled ? disabledHint : undefined}
|
|
123
|
+
aria-label={label}
|
|
124
|
+
/>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** SettingsChoice — the row's one-of-N control: the DS SegmentedToggle, sm.
|
|
129
|
+
* Options are values or `{ value, label }`. */
|
|
130
|
+
export function SettingsChoice({ options = [], value, onChange, ariaLabel }) {
|
|
131
|
+
const opts = options.map((o) => (o != null && typeof o === 'object' ? o : { value: o, label: String(o) }))
|
|
132
|
+
return <SegmentedToggle size="sm" value={value} onChange={onChange} options={opts} ariaLabel={ariaLabel} />
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* The toggle-chip pattern — `.kol-control` sm, filled when on, meta ink when
|
|
136
|
+
* off (ViewToggle's text variant wears the same string; MediaLibrary's kind
|
|
137
|
+
* chips too). ponytail: a `ControlChip` atom is the upgrade if a third
|
|
138
|
+
* consumer types this. */
|
|
139
|
+
export const CHIP_CLS = 'kol-control kol-control-sm kol-mono-12'
|
|
140
|
+
export const chipCls = (on) => `${CHIP_CLS} ${on ? 'kol-control--filled' : 'text-meta hover:text-emphasis'}`
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* SettingsChipRow — a wrap of toggle chips with optional counts (an
|
|
144
|
+
* allow-list: every chip sets a default, never a gate).
|
|
145
|
+
* @param {Array} options [{ value, label, count? }]
|
|
146
|
+
* @param {Array|Set} selected the values that are on
|
|
147
|
+
* @param {Function} onToggle (value) => void
|
|
148
|
+
*/
|
|
149
|
+
export function SettingsChipRow({ options = [], selected = [], onToggle }) {
|
|
150
|
+
const on = selected instanceof Set ? selected : new Set(selected)
|
|
151
|
+
return (
|
|
152
|
+
<div className="flex flex-wrap gap-1">
|
|
153
|
+
{options.map((o) => (
|
|
154
|
+
<button
|
|
155
|
+
key={String(o.value)}
|
|
156
|
+
type="button"
|
|
157
|
+
aria-pressed={on.has(o.value)}
|
|
158
|
+
onClick={() => onToggle?.(o.value)}
|
|
159
|
+
className={chipCls(on.has(o.value))}
|
|
160
|
+
>
|
|
161
|
+
{o.label ?? String(o.value)}
|
|
162
|
+
{o.count > 0 && <span className="text-meta"> {o.count}</span>}
|
|
163
|
+
</button>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** SettingsFooter — the state word and the reset action. */
|
|
170
|
+
export function SettingsFooter({ customised = false, onReset, resetLabel = 'Reset to defaults' }) {
|
|
171
|
+
return (
|
|
172
|
+
<div className="flex items-center justify-between">
|
|
173
|
+
<span className="kol-mono-10 text-meta">{customised ? 'customised' : 'defaults'}</span>
|
|
174
|
+
{onReset && <Button variant="ghost" size="sm" onClick={onReset}>{resetLabel}</Button>}
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -12,18 +12,41 @@ import Button from '../atoms/Button.jsx'
|
|
|
12
12
|
* typographic multiplication sign standing in for a glyph the icon set has
|
|
13
13
|
* always shipped.
|
|
14
14
|
*/
|
|
15
|
+
const FOCUSABLE =
|
|
16
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
17
|
+
|
|
15
18
|
export default function FullscreenOverlay({ open, onClose, closeButton = true, children }) {
|
|
16
19
|
const sheetRef = useRef(null)
|
|
17
20
|
|
|
21
|
+
/* Escape closes; Tab is TRAPPED in the sheet (SettingsPanel, 2026-08-26 —
|
|
22
|
+
* the same trap ShellDrawer carries; before this a Tab from the overlay
|
|
23
|
+
* walked into the page underneath). Focus moves into the sheet on open and
|
|
24
|
+
* back to the opener on close. */
|
|
18
25
|
useEffect(() => {
|
|
19
26
|
if (!open) return
|
|
20
|
-
const onKey = (e) => {
|
|
27
|
+
const onKey = (e) => {
|
|
28
|
+
if (e.key === 'Escape') { onClose?.(); return }
|
|
29
|
+
if (e.key !== 'Tab') return
|
|
30
|
+
const sheet = sheetRef.current
|
|
31
|
+
if (!sheet) return
|
|
32
|
+
const nodes = sheet.querySelectorAll(FOCUSABLE)
|
|
33
|
+
if (!nodes.length) { e.preventDefault(); sheet.focus(); return }
|
|
34
|
+
const first = nodes[0]
|
|
35
|
+
const last = nodes[nodes.length - 1]
|
|
36
|
+
const active = document.activeElement
|
|
37
|
+
if (!sheet.contains(active)) { e.preventDefault(); first.focus() }
|
|
38
|
+
else if (e.shiftKey && (active === first || active === sheet)) { e.preventDefault(); last.focus() }
|
|
39
|
+
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
|
|
40
|
+
}
|
|
21
41
|
document.addEventListener('keydown', onKey)
|
|
22
42
|
const prev = document.body.style.overflow
|
|
23
43
|
document.body.style.overflow = 'hidden'
|
|
44
|
+
const prevFocus = document.activeElement
|
|
45
|
+
sheetRef.current?.focus()
|
|
24
46
|
return () => {
|
|
25
47
|
document.removeEventListener('keydown', onKey)
|
|
26
48
|
document.body.style.overflow = prev
|
|
49
|
+
if (prevFocus instanceof HTMLElement) prevFocus.focus()
|
|
27
50
|
}
|
|
28
51
|
}, [open, onClose])
|
|
29
52
|
|
|
@@ -40,7 +63,7 @@ export default function FullscreenOverlay({ open, onClose, closeButton = true, c
|
|
|
40
63
|
|
|
41
64
|
return (
|
|
42
65
|
<div className="kol-overlay" role="dialog" aria-modal="true" onMouseDown={onBackdropClick}>
|
|
43
|
-
<div ref={sheetRef} className="kol-overlay-sheet">
|
|
66
|
+
<div ref={sheetRef} tabIndex={-1} className="kol-overlay-sheet outline-none">
|
|
44
67
|
{closeButton && (
|
|
45
68
|
<Button
|
|
46
69
|
variant="outline"
|