@kolkrabbi/kol-component 0.38.0 → 0.40.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 +3 -2
- package/src/atoms/Avatar.jsx +34 -2
- package/src/organisms/MediaLibrary.jsx +253 -43
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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",
|
|
7
|
+
"sideEffects": false,
|
|
7
8
|
"main": "./src/index.js",
|
|
8
9
|
"module": "./src/index.js",
|
|
9
10
|
"exports": {
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
"@floating-ui/react": "^0.27.19",
|
|
30
31
|
"embla-carousel-react": "^8.6.0",
|
|
31
32
|
"react-syntax-highlighter": "^16.1.1",
|
|
32
|
-
"@kolkrabbi/kol-icons": "^0.
|
|
33
|
+
"@kolkrabbi/kol-icons": "^0.16.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
36
|
"framer-motion": "^12.0.0",
|
package/src/atoms/Avatar.jsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
1
3
|
const SIZE_MAP = {
|
|
2
4
|
sm: 'w-8 h-8 kol-helper-12',
|
|
3
5
|
md: 'w-10 h-10 kol-helper-14',
|
|
@@ -5,11 +7,41 @@ const SIZE_MAP = {
|
|
|
5
7
|
xl: 'w-24 h-24 kol-helper-20',
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Avatar — the initials disc, or a photo at the same geometry.
|
|
12
|
+
*
|
|
13
|
+
* `src` was added when the ArticleHeader reconciliation (2026-08-15) found the
|
|
14
|
+
* consumer hand-rolling `<img className="w-12 h-12 rounded-full object-cover">`
|
|
15
|
+
* beside a grey-circle fallback, because the atom did initials only. The size
|
|
16
|
+
* ladder is the atom's to own — a caller writing its own w-/h- pair is how a
|
|
17
|
+
* fifth avatar size gets invented. Falls back to the initial on a broken src,
|
|
18
|
+
* so a dead photo URL degrades to the disc instead of a torn-image glyph.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} initial glyph shown when there is no photo
|
|
21
|
+
* @param {string} [src] resolved image src — the consumer resolves it, this
|
|
22
|
+
* atom never builds a URL
|
|
23
|
+
* @param {string} [alt=''] photo alt text
|
|
24
|
+
* @param {'sm'|'md'|'lg'|'xl'} [size='sm']
|
|
25
|
+
*/
|
|
26
|
+
export default function Avatar({ initial, src, alt = '', size = 'sm', className = '' }) {
|
|
9
27
|
const sizeCls = SIZE_MAP[size] ?? SIZE_MAP.sm
|
|
28
|
+
const [failed, setFailed] = useState(false)
|
|
29
|
+
const shell = `kol-avatar rounded-full bg-surface-secondary shrink-0 ${sizeCls} ${className}`
|
|
30
|
+
|
|
31
|
+
if (src && !failed) {
|
|
32
|
+
return (
|
|
33
|
+
<img
|
|
34
|
+
src={src}
|
|
35
|
+
alt={alt}
|
|
36
|
+
className={`${shell} object-cover`}
|
|
37
|
+
onError={() => setFailed(true)}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
10
42
|
return (
|
|
11
43
|
<span
|
|
12
|
-
className={
|
|
44
|
+
className={`${shell} inline-flex items-center justify-center text-emphasis font-narrow font-semibold`}
|
|
13
45
|
>
|
|
14
46
|
{initial}
|
|
15
47
|
</span>
|
|
@@ -57,11 +57,152 @@ function formatSize(bytes) {
|
|
|
57
57
|
const fileName = (key) => key.slice(key.lastIndexOf('/') + 1)
|
|
58
58
|
const folderOf = (key) => key.slice(0, key.lastIndexOf('/') + 1)
|
|
59
59
|
|
|
60
|
-
/* A video
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
/* A video with neither poster nor sibling still paints an empty box until
|
|
61
|
+
* played — a time fragment makes the browser seek and paint frame one. Only
|
|
62
|
+
* the fallback now: `pairPosters` finds a real poster where one exists. */
|
|
63
63
|
const posterSrc = (url) => `${url}#t=0.1`
|
|
64
64
|
|
|
65
|
+
/* ── Kind, and the four rules a real bucket forces ─────────────────────────
|
|
66
|
+
* Every rule below exists because the unfiltered list was unusable over the
|
|
67
|
+
* live 3443-object bucket, not because it read tidier. Measurements from
|
|
68
|
+
* lobby/media-library-non-av-blindness (kol-r2b2, 2026-08-15), which tested
|
|
69
|
+
* each one against that data. */
|
|
70
|
+
|
|
71
|
+
/* B2 and R2 hand back `application/octet-stream` for .json, .pgn, .m3u8 and
|
|
72
|
+
* .woff2, so contentType cannot be the primary signal — extension wins, the
|
|
73
|
+
* header is the fallback. `.ts` is an HLS segment here, never TypeScript: this
|
|
74
|
+
* reads object buckets, not source trees. */
|
|
75
|
+
const EXT_KIND = {
|
|
76
|
+
jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', webp: 'image',
|
|
77
|
+
avif: 'image', svg: 'image', bmp: 'image', ico: 'image', tif: 'image',
|
|
78
|
+
tiff: 'image', heic: 'image',
|
|
79
|
+
mp4: 'video', mov: 'video', webm: 'video', m4v: 'video', avi: 'video',
|
|
80
|
+
mkv: 'video', ts: 'video',
|
|
81
|
+
mp3: 'audio', wav: 'audio', ogg: 'audio', flac: 'audio', aac: 'audio',
|
|
82
|
+
m4a: 'audio', aiff: 'audio',
|
|
83
|
+
m3u8: 'playlist',
|
|
84
|
+
json: 'text', yaml: 'text', yml: 'text', csv: 'text', txt: 'text',
|
|
85
|
+
md: 'text', pgn: 'text', xml: 'text', srt: 'text', vtt: 'text',
|
|
86
|
+
js: 'code', mjs: 'code', cjs: 'code', jsx: 'code', tsx: 'code',
|
|
87
|
+
css: 'code', html: 'code', py: 'code', sh: 'code',
|
|
88
|
+
woff: 'font', woff2: 'font', ttf: 'font', otf: 'font', eot: 'font',
|
|
89
|
+
zip: 'archive', tar: 'archive', gz: 'archive', rar: 'archive', '7z': 'archive',
|
|
90
|
+
pdf: 'text',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* One per folder, in the way of everything — hidden, never silently dropped:
|
|
94
|
+
* the count is reported at the foot. */
|
|
95
|
+
const SYSTEM_NAMES = new Set(['.DS_Store', '.bzEmpty', 'Thumbs.db', 'desktop.ini', '.gitkeep'])
|
|
96
|
+
|
|
97
|
+
const extOf = (key) => {
|
|
98
|
+
const base = fileName(key)
|
|
99
|
+
const dot = base.lastIndexOf('.')
|
|
100
|
+
return dot > 0 ? base.slice(dot + 1).toLowerCase() : ''
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function kindOf(o) {
|
|
104
|
+
if (SYSTEM_NAMES.has(fileName(o.key))) return 'system'
|
|
105
|
+
const byExt = EXT_KIND[extOf(o.key)]
|
|
106
|
+
if (byExt) return byExt
|
|
107
|
+
if (isImage(o.contentType)) return 'image'
|
|
108
|
+
if (isVideo(o.contentType)) return 'video'
|
|
109
|
+
if (o.contentType?.startsWith('audio/')) return 'audio'
|
|
110
|
+
if (o.contentType?.startsWith('text/')) return 'text'
|
|
111
|
+
return 'other'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* `accept` WIDENS, it never gates. 'all' — the default, and what every browse
|
|
115
|
+
* consumer passes — means EVERYTHING. It used to mean "image or video", which
|
|
116
|
+
* discarded 332 objects of the reference bucket before anything downstream
|
|
117
|
+
* could see them; a brand book embedding this saw a library quietly missing
|
|
118
|
+
* every stream it held. A picker asks for what it can pick: `['image','video']`. */
|
|
119
|
+
function acceptsKind(accept) {
|
|
120
|
+
if (!accept || accept === 'all') return () => true
|
|
121
|
+
const wanted = new Set(Array.isArray(accept) ? accept : [accept])
|
|
122
|
+
return (o) => wanted.has(o.kind)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* 2012 `segment_NNN.ts` files are ONE stream. Counted raw they took the
|
|
126
|
+
* bucket's video tally to 2051 instead of 39, and filled the grid with 2.7 GB
|
|
127
|
+
* of unopenable fragments. Fold per folder onto the first segment — a real key,
|
|
128
|
+
* so the row still resolves and still sits in its own folder. */
|
|
129
|
+
const SEGMENT = /segment[_-]?\d+\.ts$/i
|
|
130
|
+
|
|
131
|
+
function foldHlsSegments(list) {
|
|
132
|
+
const streams = new Map()
|
|
133
|
+
const rest = []
|
|
134
|
+
for (const o of list) {
|
|
135
|
+
if (!SEGMENT.test(fileName(o.key))) { rest.push(o); continue }
|
|
136
|
+
const dir = folderOf(o.key)
|
|
137
|
+
const seen = streams.get(dir)
|
|
138
|
+
if (seen) { seen.count += 1; seen.size += o.size ?? 0 }
|
|
139
|
+
else streams.set(dir, { first: o, count: 1, size: o.size ?? 0 })
|
|
140
|
+
}
|
|
141
|
+
for (const [dir, s] of streams) {
|
|
142
|
+
rest.push({
|
|
143
|
+
...s.first,
|
|
144
|
+
displayName: `${fileName(dir.slice(0, -1))} · ${s.count} segments`,
|
|
145
|
+
size: s.size,
|
|
146
|
+
segments: s.count,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
return rest
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Art prints ship as one picture in four widths (`name-566.jpg` … `-2840.jpg`).
|
|
153
|
+
* Left alone that is 604 rows for 197 pictures, and the grid pulls the 2840px
|
|
154
|
+
* file to paint a 200px tile — 718 KB where 27 KB does. Collapse each set to
|
|
155
|
+
* one row: `key` (what the thumb loads) is the SMALLEST, `fullKey` (what
|
|
156
|
+
* download hands over) the largest. Guards: images only, width ≥ 100 so
|
|
157
|
+
* `2017-03.json` is not read as a variant, and sets of ≥2 only. */
|
|
158
|
+
const VARIANT = /^(.+)-(\d{2,5})$/
|
|
159
|
+
|
|
160
|
+
function foldResolutionSets(list) {
|
|
161
|
+
const sets = new Map()
|
|
162
|
+
const rest = []
|
|
163
|
+
|
|
164
|
+
for (const o of list) {
|
|
165
|
+
const base = fileName(o.key)
|
|
166
|
+
const dot = base.lastIndexOf('.')
|
|
167
|
+
const stem = dot > 0 ? base.slice(0, dot) : base
|
|
168
|
+
const match = o.kind === 'image' ? VARIANT.exec(stem) : null
|
|
169
|
+
if (!match || Number(match[2]) < 100) { rest.push(o); continue }
|
|
170
|
+
const id = `${folderOf(o.key)}${match[1]}`
|
|
171
|
+
const set = sets.get(id)
|
|
172
|
+
if (set) set.push({ o, width: Number(match[2]) })
|
|
173
|
+
else sets.set(id, [{ o, width: Number(match[2]) }])
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const [id, variants] of sets) {
|
|
177
|
+
if (variants.length < 2) { rest.push(variants[0].o); continue }
|
|
178
|
+
const byWidth = [...variants].sort((a, b) => a.width - b.width)
|
|
179
|
+
rest.push({
|
|
180
|
+
...byWidth[0].o,
|
|
181
|
+
fullKey: byWidth[byWidth.length - 1].o.key,
|
|
182
|
+
displayName: `${fileName(id)} · ${byWidth.length} sizes`,
|
|
183
|
+
size: variants.reduce((n, v) => n + (v.o.size ?? 0), 0),
|
|
184
|
+
variants: byWidth.map((v) => v.o.key),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
return rest
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* Every video in the vault ships a sibling `<name>.png`. Using it as the poster
|
|
191
|
+
* means `preload="none"` still paints a frame; without one the browser fetches
|
|
192
|
+
* the video itself just to show frame one, which over a 20.4 GB bucket is the
|
|
193
|
+
* single most expensive thing this component does. */
|
|
194
|
+
const POSTER_EXT = ['png', 'jpg', 'jpeg', 'webp']
|
|
195
|
+
|
|
196
|
+
function pairPosters(list) {
|
|
197
|
+
const images = new Set(list.filter((o) => o.kind === 'image').map((o) => o.key))
|
|
198
|
+
return list.map((o) => {
|
|
199
|
+
if (o.kind !== 'video') return o
|
|
200
|
+
const stem = o.key.slice(0, o.key.lastIndexOf('.'))
|
|
201
|
+
const poster = POSTER_EXT.map((e) => `${stem}.${e}`).find((k) => images.has(k))
|
|
202
|
+
return poster ? { ...o, poster } : o
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
65
206
|
/**
|
|
66
207
|
* Flatten the bucket's flat key list into ONE ordered row list, folders and
|
|
67
208
|
* files interleaved, honouring which folders are open. The list endpoint
|
|
@@ -104,7 +245,7 @@ function buildRows(objects, expanded, sort) {
|
|
|
104
245
|
if (expanded.has(f)) walk(f, depth + 1)
|
|
105
246
|
}
|
|
106
247
|
for (const o of sorted(childrenOf.get(prefix) ?? [])) {
|
|
107
|
-
rows.push({ type: 'file', depth, ...o, displayKey: fileName(o.key) })
|
|
248
|
+
rows.push({ type: 'file', depth, ...o, displayKey: o.displayName ?? fileName(o.key) })
|
|
108
249
|
}
|
|
109
250
|
}
|
|
110
251
|
walk('', 0)
|
|
@@ -116,7 +257,10 @@ function buildRows(objects, expanded, sort) {
|
|
|
116
257
|
* derivation, the open-folder set and the sort key.
|
|
117
258
|
*
|
|
118
259
|
* @param {object} client `{ listMedia, mediaUrl, proxied? }` — required
|
|
119
|
-
* @param {string} accept
|
|
260
|
+
* @param {string|string[]} accept 'all' (default) = everything · one kind ·
|
|
261
|
+
* or an allow-list, `['image','video']`, which is what a picker wants.
|
|
262
|
+
* Kinds: image · video · audio · text · code · playlist · font · archive ·
|
|
263
|
+
* other. Browsing never filters by default.
|
|
120
264
|
*/
|
|
121
265
|
export function MediaLibraryProvider({ client, accept = 'all', children }) {
|
|
122
266
|
const [objects, setObjects] = useState([])
|
|
@@ -148,16 +292,28 @@ export function MediaLibraryProvider({ client, accept = 'all', children }) {
|
|
|
148
292
|
})
|
|
149
293
|
|
|
150
294
|
const value = useMemo(() => {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
295
|
+
const annotated = objects.map((o) => ({ ...o, kind: kindOf(o) }))
|
|
296
|
+
const systemCount = annotated.reduce((n, o) => n + (o.kind === 'system' ? 1 : 0), 0)
|
|
297
|
+
|
|
298
|
+
/* Fold before pairing: a poster must be matched against real image keys,
|
|
299
|
+
* and resolution sets must be collapsed after that or the poster's own
|
|
300
|
+
* width suffix would swallow it. */
|
|
301
|
+
const visible = foldResolutionSets(
|
|
302
|
+
pairPosters(foldHlsSegments(annotated.filter((o) => o.kind !== 'system'))),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const kept = visible.filter(acceptsKind(accept))
|
|
306
|
+
/* 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 `files`. */
|
|
308
|
+
const viewable = kept.filter((o) => o.kind === 'image' || o.kind === 'video')
|
|
155
309
|
|
|
156
|
-
const kept = objects.filter(wanted)
|
|
157
310
|
return {
|
|
158
311
|
objects: kept,
|
|
159
312
|
rows: buildRows(kept, expanded, sort),
|
|
160
313
|
files: kept,
|
|
314
|
+
viewable,
|
|
315
|
+
kinds: [...new Set(kept.map((o) => o.kind))].sort(),
|
|
316
|
+
systemCount,
|
|
161
317
|
expanded,
|
|
162
318
|
toggleFolder,
|
|
163
319
|
sort,
|
|
@@ -224,23 +380,48 @@ function useCopy() {
|
|
|
224
380
|
* The loading strategy is deliberately unchanged (lobby/MediaLibraryVideoFallback:
|
|
225
381
|
* both candidate strategies failed the same way headless, so that measurement
|
|
226
382
|
* discriminates nothing). This layer needs no decoder to be correct. */
|
|
383
|
+
const KIND_ICON = {
|
|
384
|
+
audio: 'frequency',
|
|
385
|
+
playlist: 'video',
|
|
386
|
+
text: 'file',
|
|
387
|
+
code: 'code',
|
|
388
|
+
font: 'type',
|
|
389
|
+
archive: 'layers',
|
|
390
|
+
other: 'file',
|
|
391
|
+
}
|
|
392
|
+
|
|
227
393
|
function Thumb({ row, mediaUrl }) {
|
|
228
394
|
const [painted, setPainted] = useState(false)
|
|
229
395
|
|
|
230
|
-
if (
|
|
396
|
+
if (row.kind === 'image') {
|
|
231
397
|
return <img src={mediaUrl(row.key)} alt="" loading="lazy" className="w-full h-full object-cover" />
|
|
232
398
|
}
|
|
233
399
|
|
|
400
|
+
/* Everything the browser cannot paint gets the same treatment a video's
|
|
401
|
+
* resting state gets — a kind glyph and the filename, rather than an <img>
|
|
402
|
+
* pointed at a .json and the broken-image chrome that follows. */
|
|
403
|
+
if (row.kind !== 'video') {
|
|
404
|
+
return (
|
|
405
|
+
<div className="kol-media-thumb">
|
|
406
|
+
<span className="kol-media-thumb-fallback" data-painted={false}>
|
|
407
|
+
<Icon name={KIND_ICON[row.kind] ?? 'file'} size={20} />
|
|
408
|
+
<span className="kol-mono-12">{fileName(row.key)}</span>
|
|
409
|
+
</span>
|
|
410
|
+
</div>
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
234
414
|
return (
|
|
235
415
|
<div className="kol-media-thumb">
|
|
236
|
-
<span className="kol-media-thumb-fallback" data-painted={painted}>
|
|
416
|
+
<span className="kol-media-thumb-fallback" data-painted={painted || !!row.poster}>
|
|
237
417
|
<Icon name="play" size={20} />
|
|
238
418
|
<span className="kol-mono-12">{fileName(row.key)}</span>
|
|
239
419
|
</span>
|
|
240
420
|
<video
|
|
241
|
-
src={posterSrc(mediaUrl(row.key))}
|
|
421
|
+
src={row.poster ? mediaUrl(row.key) : posterSrc(mediaUrl(row.key))}
|
|
422
|
+
poster={row.poster ? mediaUrl(row.poster) : undefined}
|
|
242
423
|
muted
|
|
243
|
-
preload=
|
|
424
|
+
preload={row.poster ? 'none' : 'metadata'}
|
|
244
425
|
onLoadedData={() => setPainted(true)}
|
|
245
426
|
className="relative w-full h-full object-cover"
|
|
246
427
|
/>
|
|
@@ -251,7 +432,7 @@ function Thumb({ row, mediaUrl }) {
|
|
|
251
432
|
/* Folders, then the tiles or rows. Shared by both views — the modal shell and
|
|
252
433
|
* the pick action are the ONLY differences between them. */
|
|
253
434
|
function LibraryBody({ rows, viewMode, onOpen, onPick }) {
|
|
254
|
-
const { expanded, toggleFolder, mediaUrl, loading, error } = useMediaLibrary()
|
|
435
|
+
const { expanded, toggleFolder, mediaUrl, loading, error, viewable } = useMediaLibrary()
|
|
255
436
|
const [copied, copy] = useCopy()
|
|
256
437
|
|
|
257
438
|
if (error) return <p className="kol-helper-12 text-ui-error">Couldn’t load: {error}</p>
|
|
@@ -259,13 +440,21 @@ function LibraryBody({ rows, viewMode, onOpen, onPick }) {
|
|
|
259
440
|
if (rows.length === 0) return <p className="kol-helper-12 text-meta">Nothing here.</p>
|
|
260
441
|
|
|
261
442
|
const files = rows.filter((r) => r.type === 'file')
|
|
262
|
-
|
|
443
|
+
/* Index into `viewable`, which is what the lightbox pages — indexing into the
|
|
444
|
+
* filtered rows meant a search narrowing the grid opened the wrong file. */
|
|
445
|
+
const openerFor = (row) => {
|
|
446
|
+
const i = viewable.findIndex((f) => f.key === row.key)
|
|
447
|
+
return i < 0 ? undefined : () => onOpen(i)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* Copy hands over the full-size variant, not the thumbnail the tile loaded. */
|
|
451
|
+
const urlFor = (row) => mediaUrl(row.fullKey ?? row.key)
|
|
263
452
|
|
|
264
453
|
const actionsFor = (row) => (
|
|
265
454
|
<div className="flex items-center gap-2">
|
|
266
455
|
{onPick && <Button size="sm" onClick={() => onPick(row)}>Use</Button>}
|
|
267
|
-
<Button variant="secondary" size="sm" onClick={() => copy(
|
|
268
|
-
{copied ===
|
|
456
|
+
<Button variant="secondary" size="sm" onClick={() => copy(urlFor(row))}>
|
|
457
|
+
{copied === urlFor(row) ? 'Copied' : 'Copy URL'}
|
|
269
458
|
</Button>
|
|
270
459
|
</div>
|
|
271
460
|
)
|
|
@@ -281,9 +470,13 @@ function LibraryBody({ rows, viewMode, onOpen, onPick }) {
|
|
|
281
470
|
<MediaRow
|
|
282
471
|
thumb={<Thumb row={row} mediaUrl={mediaUrl} />}
|
|
283
472
|
name={
|
|
284
|
-
|
|
285
|
-
{row
|
|
286
|
-
|
|
473
|
+
openerFor(row) ? (
|
|
474
|
+
<button type="button" className="kol-mono-12 text-emphasis" onClick={openerFor(row)}>
|
|
475
|
+
{row.displayKey}
|
|
476
|
+
</button>
|
|
477
|
+
) : (
|
|
478
|
+
<span className="kol-mono-12 text-emphasis">{row.displayKey}</span>
|
|
479
|
+
)
|
|
287
480
|
}
|
|
288
481
|
date={row.uploaded ? String(row.uploaded).slice(0, 10) : ''}
|
|
289
482
|
size={formatSize(row.size)}
|
|
@@ -304,17 +497,21 @@ function LibraryBody({ rows, viewMode, onOpen, onPick }) {
|
|
|
304
497
|
))}
|
|
305
498
|
</ul>
|
|
306
499
|
<ul className="kol-media-grid">
|
|
307
|
-
{files.map((row
|
|
500
|
+
{files.map((row) => (
|
|
308
501
|
<MediaCard
|
|
309
502
|
key={row.key}
|
|
310
503
|
thumb={
|
|
311
|
-
<div
|
|
504
|
+
<div
|
|
505
|
+
className={openerFor(row) ? 'w-full h-full cursor-pointer' : 'w-full h-full'}
|
|
506
|
+
onClick={openerFor(row)}
|
|
507
|
+
>
|
|
312
508
|
<Thumb row={row} mediaUrl={mediaUrl} />
|
|
313
509
|
</div>
|
|
314
510
|
}
|
|
315
511
|
name={<p className="kol-mono-12 text-emphasis truncate">{row.displayKey}</p>}
|
|
316
512
|
meta={`${formatSize(row.size)}${row.uploaded ? ` · ${String(row.uploaded).slice(0, 10)}` : ''}`}
|
|
317
|
-
|
|
513
|
+
/* The set's largest variant, not the thumbnail the tile painted. */
|
|
514
|
+
downloadHref={mediaUrl(row.fullKey ?? row.key)}
|
|
318
515
|
actions={actionsFor(row)}
|
|
319
516
|
/>
|
|
320
517
|
))}
|
|
@@ -323,8 +520,10 @@ function LibraryBody({ rows, viewMode, onOpen, onPick }) {
|
|
|
323
520
|
)
|
|
324
521
|
}
|
|
325
522
|
|
|
326
|
-
/* Finder puts the path at the window FOOT, not stacked above the content.
|
|
327
|
-
|
|
523
|
+
/* Finder puts the path at the window FOOT, not stacked above the content. The
|
|
524
|
+
* hidden-system count rides the same bar — hiding 118 `.DS_Store` files without
|
|
525
|
+
* saying so is the same silent drop this component was filed for. */
|
|
526
|
+
function PathBar({ rows, systemCount }) {
|
|
328
527
|
const open = rows.filter((r) => r.type === 'folder' && r.depth > 0)
|
|
329
528
|
const trail = open.length ? open[open.length - 1].key.replace(/\/$/, '').split('/') : []
|
|
330
529
|
return (
|
|
@@ -337,6 +536,11 @@ function PathBar({ rows }) {
|
|
|
337
536
|
<span className="kol-helper-12 text-meta">{seg}</span>
|
|
338
537
|
</span>
|
|
339
538
|
))}
|
|
539
|
+
{systemCount > 0 && (
|
|
540
|
+
<span className="kol-helper-12 text-meta ms-auto">
|
|
541
|
+
{systemCount} system file{systemCount === 1 ? '' : 's'} hidden
|
|
542
|
+
</span>
|
|
543
|
+
)}
|
|
340
544
|
</div>
|
|
341
545
|
)
|
|
342
546
|
}
|
|
@@ -359,14 +563,14 @@ const SORTS = [
|
|
|
359
563
|
* view toggle and the N-of-M count. It was hand-rolled as a static <Input> on
|
|
360
564
|
* the first pass while this organism sat one import away. */
|
|
361
565
|
function LibraryChrome({ onOpen, onPick }) {
|
|
362
|
-
const { rows, sort, setSort } = useMediaLibrary()
|
|
566
|
+
const { rows, sort, setSort, kinds, systemCount } = useMediaLibrary()
|
|
363
567
|
const [viewMode, setViewMode] = useState('grid')
|
|
364
568
|
|
|
365
569
|
const items = useMemo(
|
|
366
570
|
() => rows.map((r) => ({
|
|
367
571
|
...r,
|
|
368
572
|
name: r.type === 'folder' ? r.label : r.displayKey,
|
|
369
|
-
kind: r.type === 'folder' ? 'folder' :
|
|
573
|
+
kind: r.type === 'folder' ? 'folder' : r.kind,
|
|
370
574
|
})),
|
|
371
575
|
[rows],
|
|
372
576
|
)
|
|
@@ -383,7 +587,9 @@ function LibraryChrome({ onOpen, onPick }) {
|
|
|
383
587
|
onViewModeChange={setViewMode}
|
|
384
588
|
viewModeOptions={VIEW_OPTIONS}
|
|
385
589
|
mutuallyExclusiveFilters={['kind']}
|
|
386
|
-
|
|
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
|
+
filterGroups={[{ label: 'Kind', key: 'kind', values: ['folder', ...kinds] }]}
|
|
387
593
|
headerActions={
|
|
388
594
|
<SegmentedToggle
|
|
389
595
|
size="sm"
|
|
@@ -397,7 +603,7 @@ function LibraryChrome({ onOpen, onPick }) {
|
|
|
397
603
|
<LibraryBody rows={filtered} viewMode={mode} onOpen={onOpen} onPick={onPick} />
|
|
398
604
|
)}
|
|
399
605
|
/>
|
|
400
|
-
<PathBar rows={rows} />
|
|
606
|
+
<PathBar rows={rows} systemCount={systemCount} />
|
|
401
607
|
</>
|
|
402
608
|
)
|
|
403
609
|
}
|
|
@@ -405,14 +611,15 @@ function LibraryChrome({ onOpen, onPick }) {
|
|
|
405
611
|
/* The lightbox is MediaViewer — the DS already has ONE fullscreen paged viewer
|
|
406
612
|
* and this is not a second one. Use / Copy URL ride its `actions` slot. */
|
|
407
613
|
function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
|
|
408
|
-
const {
|
|
614
|
+
const { viewable, mediaUrl } = useMediaLibrary()
|
|
409
615
|
const [copied, copy] = useCopy()
|
|
410
616
|
|
|
411
|
-
|
|
412
|
-
|
|
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),
|
|
413
620
|
alt: fileName(o.key),
|
|
414
|
-
kind:
|
|
415
|
-
caption: `${fileName(o.key)} · ${formatSize(o.size)}`,
|
|
621
|
+
kind: o.kind === 'video' ? 'video' : 'image',
|
|
622
|
+
caption: `${o.displayName ?? fileName(o.key)} · ${formatSize(o.size)}`,
|
|
416
623
|
}))
|
|
417
624
|
|
|
418
625
|
return (
|
|
@@ -424,7 +631,7 @@ function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
|
|
|
424
631
|
onClose={onClose}
|
|
425
632
|
actions={(item, i) => (
|
|
426
633
|
<>
|
|
427
|
-
{onPick && <Button size="sm" onClick={() => onPick(
|
|
634
|
+
{onPick && <Button size="sm" onClick={() => onPick(viewable[i])}>Use</Button>}
|
|
428
635
|
<Button variant="secondary" size="sm" onClick={() => copy(item.url)}>
|
|
429
636
|
{copied === item.url ? 'Copied' : 'Copy URL'}
|
|
430
637
|
</Button>
|
|
@@ -435,11 +642,11 @@ function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
|
|
|
435
642
|
}
|
|
436
643
|
|
|
437
644
|
function PickerShell({ onClose, onPick }) {
|
|
438
|
-
const {
|
|
645
|
+
const { viewable, mediaUrl } = useMediaLibrary()
|
|
439
646
|
const [viewerIndex, setViewerIndex] = useState(null)
|
|
440
647
|
|
|
441
648
|
const pick = (o) => {
|
|
442
|
-
onPick?.(mediaUrl(o.key), { contentType: o.contentType })
|
|
649
|
+
onPick?.(mediaUrl(o.fullKey ?? o.key), { contentType: o.contentType, kind: o.kind })
|
|
443
650
|
onClose?.()
|
|
444
651
|
}
|
|
445
652
|
|
|
@@ -454,7 +661,7 @@ function PickerShell({ onClose, onPick }) {
|
|
|
454
661
|
</div>
|
|
455
662
|
</FullscreenOverlay>
|
|
456
663
|
|
|
457
|
-
{viewerIndex !== null &&
|
|
664
|
+
{viewerIndex !== null && viewable[viewerIndex] && (
|
|
458
665
|
<LibraryViewer
|
|
459
666
|
index={viewerIndex}
|
|
460
667
|
onIndexChange={setViewerIndex}
|
|
@@ -479,7 +686,8 @@ function PickerShell({ onClose, onPick }) {
|
|
|
479
686
|
* @param {string} variant 'page' (in-flow, fills its box) | 'modal' (overlay)
|
|
480
687
|
* @param {boolean} open modal only — mounts the overlay
|
|
481
688
|
* @param {object} client `{ listMedia, mediaUrl, proxied? }`; omit inside a provider
|
|
482
|
-
* @param {string}
|
|
689
|
+
* @param {string|string[]} accept 'all' (default) = everything · one kind · an
|
|
690
|
+
* allow-list `['image','video']`
|
|
483
691
|
* @param {Function} onClose modal only — Esc, backdrop, close button
|
|
484
692
|
* @param {Function} onSelect `(url, { contentType })`. In `modal` it also closes.
|
|
485
693
|
*/
|
|
@@ -514,16 +722,18 @@ export function MediaPicker({ open, client, accept = 'all', onClose, onPick }) {
|
|
|
514
722
|
}
|
|
515
723
|
|
|
516
724
|
function BrowserShell({ onSelect }) {
|
|
517
|
-
const {
|
|
725
|
+
const { viewable, mediaUrl } = useMediaLibrary()
|
|
518
726
|
const [viewerIndex, setViewerIndex] = useState(null)
|
|
519
727
|
|
|
520
|
-
const pick = onSelect
|
|
728
|
+
const pick = onSelect
|
|
729
|
+
? (o) => onSelect(mediaUrl(o.fullKey ?? o.key), { contentType: o.contentType, kind: o.kind })
|
|
730
|
+
: undefined
|
|
521
731
|
|
|
522
732
|
return (
|
|
523
733
|
<div className="kol-media-browser">
|
|
524
734
|
<LibraryChrome onOpen={setViewerIndex} onPick={pick} />
|
|
525
735
|
|
|
526
|
-
{viewerIndex !== null &&
|
|
736
|
+
{viewerIndex !== null && viewable[viewerIndex] && (
|
|
527
737
|
<LibraryViewer
|
|
528
738
|
index={viewerIndex}
|
|
529
739
|
onIndexChange={setViewerIndex}
|