@kolkrabbi/kol-component 0.219.0 → 0.221.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.221.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
|
@@ -152,7 +152,6 @@ export { default as ErrorBoundary } from './utilities/ErrorBoundary.jsx'
|
|
|
152
152
|
export { default as FeatureSplit } from './organisms/FeatureSplit.jsx'
|
|
153
153
|
export { default as FeaturedCarousel } from './organisms/FeaturedCarousel.jsx'
|
|
154
154
|
export { default as FeaturesCardSection } from './organisms/FeaturesCardSection.jsx'
|
|
155
|
-
export { default as FoundryCTA } from './organisms/FoundryCTA.jsx'
|
|
156
155
|
export { default as SectionHero, default as FullBleedHero } from './organisms/SectionHero.jsx'
|
|
157
156
|
export { default as SectionSplit } from './organisms/SectionSplit.jsx'
|
|
158
157
|
export { default as SectionCards } from './organisms/SectionCards.jsx'
|
|
@@ -21,7 +21,11 @@ export default function MediaTile({ preview, name, selected = false, markKey, on
|
|
|
21
21
|
return (
|
|
22
22
|
<div className={`kol-media-tile-item relative ${className}`.trim()} data-selected={selected || undefined} data-marquee-key={markKey}
|
|
23
23
|
role="button" tabIndex={0} aria-pressed={selected}
|
|
24
|
-
|
|
24
|
+
/* THE SECOND CLICK OPENS (kol-client-olina 2026-09-23: double-click on a folder tile did nothing).
|
|
25
|
+
* The browser counted the double click (`detail: 2`) and never fired `dblclick` on a folder tile,
|
|
26
|
+
* so the open hung off an event that did not arrive. The click's own count is the signal. */
|
|
27
|
+
onClick={(e) => { if (e.detail === 2 && onDoubleClick) { onDoubleClick(e); return } onClick?.(e) }}
|
|
28
|
+
onContextMenu={onContextMenu}
|
|
25
29
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onDoubleClick?.(e) } }}>
|
|
26
30
|
<div className="kol-media-tile-thumb">{preview}</div>
|
|
27
31
|
<span className="kol-media-tile-name">{name}</span>
|
|
@@ -325,9 +325,9 @@ function Row({
|
|
|
325
325
|
</span>
|
|
326
326
|
) : (
|
|
327
327
|
<span className="w-5 shrink-0 flex items-center justify-center text-oq-48">
|
|
328
|
-
{/*
|
|
329
|
-
|
|
330
|
-
<Icon name={icon} size={
|
|
328
|
+
{/* one glyph size for every row — a folder and a file side by side draw at the same size
|
|
329
|
+
(user 2026-09-25; the file glyph was 14 against the folder's 18) */}
|
|
330
|
+
<Icon name={icon} size={18} />
|
|
331
331
|
</span>
|
|
332
332
|
)}
|
|
333
333
|
|
|
@@ -462,6 +462,11 @@ export default function ColumnBrowser({
|
|
|
462
462
|
quickLook = null,
|
|
463
463
|
onQuickLook,
|
|
464
464
|
onPick,
|
|
465
|
+
/* `picked` — the file the CALLER holds picked (a key or an object; kol-client-olina 2026-09-25:
|
|
466
|
+
* a pick made in rows or grid was lost on the switch to columns, because this browser kept its
|
|
467
|
+
* own). Given, the browser opens on that file's level with its row selected and previewed, and
|
|
468
|
+
* follows it when it changes; absent (`undefined`), the pick is internal exactly as before. */
|
|
469
|
+
picked: pickedIn,
|
|
465
470
|
urlOf,
|
|
466
471
|
kindOf = defaultKindOf,
|
|
467
472
|
kindLabel = DEFAULT_KIND_LABEL,
|
|
@@ -559,6 +564,16 @@ export default function ColumnBrowser({
|
|
|
559
564
|
if (prefix !== level) { keepCursor.current = true; pickedByCollapse.current = true; onPrefix(level) }
|
|
560
565
|
}
|
|
561
566
|
const pickedByCollapse = useRef(false)
|
|
567
|
+
const pickedInKey = typeof pickedIn === 'string' ? pickedIn : pickedIn?.key ?? null
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
if (pickedIn === undefined || (picked?.key ?? null) === pickedInKey) return
|
|
570
|
+
const o = typeof pickedIn === 'string' ? objects.find((x) => x.key === pickedIn) : pickedIn
|
|
571
|
+
if (!o) { setPicked(null); return }
|
|
572
|
+
const level = o.key.slice(0, o.key.lastIndexOf('/') + 1)
|
|
573
|
+
setPicked(o)
|
|
574
|
+
setCursor(seedCursor(level))
|
|
575
|
+
if (prefix !== level) { keepCursor.current = true; pickedByCollapse.current = true; onPrefix(level) }
|
|
576
|
+
}, [pickedInKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
562
577
|
useEffect(() => { if (pickedByCollapse.current) { pickedByCollapse.current = false; return } if (picked) pick(null) }, [prefix]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
563
578
|
useEffect(() => { setExpanded((prev) => (prev.size ? new Set() : prev)) }, [prefix])
|
|
564
579
|
const levelsOf = (pfx) => {
|
|
@@ -580,6 +595,12 @@ export default function ColumnBrowser({
|
|
|
580
595
|
* internal moves land on the same spot, so nothing jumps. */
|
|
581
596
|
const seedCursor = (pfx) => {
|
|
582
597
|
const lv = levelsOf(pfx)
|
|
598
|
+
/* a caller's `picked` file at this level is where the keyboard starts — on the file, not on
|
|
599
|
+
* the folder that opened its column */
|
|
600
|
+
if (pickedInKey?.startsWith(pfx) && !pickedInKey.slice(pfx.length).includes('/')) {
|
|
601
|
+
const idx = itemsAt(pfx).findIndex((it) => it.type === 'file' && it.o.key === pickedInKey)
|
|
602
|
+
if (idx >= 0) return { col: lv.length - 1, idx }
|
|
603
|
+
}
|
|
583
604
|
if (lv.length < 2) return { col: 0, idx: 0 }
|
|
584
605
|
const col = lv.length - 2
|
|
585
606
|
const opened = lv[col + 1].slice(lv[col].length)
|
|
@@ -195,12 +195,19 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
|
|
|
195
195
|
|
|
196
196
|
/* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
|
|
197
197
|
|
|
198
|
-
/* The frame is square until the file reports its size, then snaps to its nearest preset.
|
|
199
|
-
|
|
200
|
-
|
|
198
|
+
/* The frame is square until the file reports its size, then snaps to its nearest preset. A photo
|
|
199
|
+
* near a preset fills it (`cover`, a few percent cropped); a vector or anything past the ladder's
|
|
200
|
+
* ends — a 2.8:1 wordmark, an 11:1 nav logo — shows whole (`contain`, kol-client-olina 2026-09-25). */
|
|
201
|
+
function ImageFrame({ src, vector }) {
|
|
202
|
+
const [frame, setFrame] = useState({ ratio: '1 / 1', contain: !!vector })
|
|
203
|
+
const onLoad = (e) => {
|
|
204
|
+
const { naturalWidth: w, naturalHeight: h } = e.target
|
|
205
|
+
const r = w && h ? w / h : 1
|
|
206
|
+
setFrame({ ratio: nearestRatio(w, h), contain: vector || r > 16 / 9 || r < 9 / 16 })
|
|
207
|
+
}
|
|
201
208
|
return (
|
|
202
|
-
<div className="w-full rounded overflow-hidden flex items-center justify-center" style={{ aspectRatio: ratio }}>
|
|
203
|
-
<img src={src} alt="" className=
|
|
209
|
+
<div className="w-full rounded overflow-hidden flex items-center justify-center" style={{ aspectRatio: frame.ratio }}>
|
|
210
|
+
<img src={src} alt="" className={`w-full h-full ${frame.contain ? 'object-contain' : 'object-cover'}`} loading="lazy" onLoad={onLoad} />
|
|
204
211
|
</div>
|
|
205
212
|
)
|
|
206
213
|
}
|
|
@@ -209,7 +216,7 @@ function ImageFrame({ src }) {
|
|
|
209
216
|
/* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
|
|
210
217
|
* disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
|
|
211
218
|
* that navigates — so nothing that already renders these moves. */
|
|
212
|
-
function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
|
|
219
|
+
function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
|
|
213
220
|
const [over, setOver] = useState(false)
|
|
214
221
|
/* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
|
|
215
222
|
* row highlights only while something is actually over it, so an accidental hover reads as
|
|
@@ -246,7 +253,7 @@ function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle
|
|
|
246
253
|
{/* THE FOLDER GLYPH IS THE ONE ICON LEFT, so it fills its box (user 2026-09-23: *"only folder
|
|
247
254
|
should use icon, and it should be bigger"*). Every other kind now renders its own bytes. */}
|
|
248
255
|
<span data-hit {...hitDrag} className="kol-row-hit flex items-center gap-3 min-w-0">
|
|
249
|
-
<span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48"><Icon name=
|
|
256
|
+
<span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48"><Icon name={icon} size={26} /></span>
|
|
250
257
|
{/* NO TRAILING SLASH (user 2026-09-22). `partition` hands folders back as `audio/` because a
|
|
251
258
|
slash is what marks one in a flat key space; that is storage's spelling, not a label, and
|
|
252
259
|
`ColumnBrowser` has always stripped it. The glyph says "folder". */}
|
|
@@ -326,7 +333,8 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
|
|
|
326
333
|
{/* the hit — icon and name, as in the folder row above */}
|
|
327
334
|
<span data-hit {...dragProps} className="kol-row-hit flex items-center gap-3 min-w-0">
|
|
328
335
|
<span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48 overflow-hidden rounded">
|
|
329
|
-
{
|
|
336
|
+
{/* a file with no thumbnail draws its glyph at the FOLDER's size — one icon column (user 2026-09-25) */}
|
|
337
|
+
{thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={26} />}
|
|
330
338
|
</span>
|
|
331
339
|
<span className="kol-mono-12 min-w-0 truncate text-fg-default">{o.displayKey ?? o.key.split('/').pop()}</span>
|
|
332
340
|
</span>
|
|
@@ -354,12 +362,21 @@ function FolderPreview({ path, objects, width, formatDate }) {
|
|
|
354
362
|
['Size', formatSize(bytes)],
|
|
355
363
|
...(last ? [['Newest', formatDate(last) || '—']] : []),
|
|
356
364
|
]
|
|
365
|
+
return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} />
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ONE PREVIEW FOR EVERYTHING THAT HOLDS THINGS — a folder, a bucket, the title (user 2026-09-25:
|
|
369
|
+
* selecting a bucket or MEDIA left the pane empty). The glyph, the name, the facts; only the
|
|
370
|
+
* glyph and the facts differ. */
|
|
371
|
+
function ContainerPreview({ name, icon, facts, width }) {
|
|
357
372
|
return (
|
|
358
373
|
<div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
|
|
359
|
-
|
|
360
|
-
|
|
374
|
+
{/* the folder fills its box with the tile's inset (user 2026-09-24: a 64px glyph floated in
|
|
375
|
+
a 300px square while the grid's folder tile fills its own) */}
|
|
376
|
+
<div className="w-full aspect-square bg-oq-04 rounded flex items-center justify-center p-[5%]">
|
|
377
|
+
<Icon name={icon} size="100%" className="text-oq-48" />
|
|
361
378
|
</div>
|
|
362
|
-
<p className="kol-mono-12 text-fg-default break-all">{
|
|
379
|
+
<p className="kol-mono-12 text-fg-default break-all">{name}</p>
|
|
363
380
|
<dl className="flex flex-col gap-1">
|
|
364
381
|
{facts.map(([k, v]) => (
|
|
365
382
|
<div key={k} className="flex justify-between gap-4 kol-mono-12">
|
|
@@ -474,7 +491,7 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
474
491
|
const [size, setSize] = useState(null)
|
|
475
492
|
const shownDims = dims && dims.key === o?.key ? dims : null
|
|
476
493
|
useEffect(() => {
|
|
477
|
-
const onKey = (e) => { if (e.key === 'ArrowLeft') onPrev(); if (e.key === 'ArrowRight') onNext() }
|
|
494
|
+
const onKey = (e) => { if (e.shiftKey) return; if (e.key === 'ArrowLeft') onPrev(); if (e.key === 'ArrowRight') onNext() }
|
|
478
495
|
window.addEventListener('keydown', onKey)
|
|
479
496
|
return () => window.removeEventListener('keydown', onKey)
|
|
480
497
|
}, [onPrev, onNext])
|
|
@@ -517,7 +534,12 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
517
534
|
<FullscreenOverlay open onClose={onClose} closeButton={false} scrim>
|
|
518
535
|
{isImage(o.contentType) ? (
|
|
519
536
|
<QuickLookFrame {...frame}>
|
|
520
|
-
|
|
537
|
+
{/* A VECTOR HAS NO PIXEL SIZE (user 2026-09-24: a 32px logo.svg opened as a 32px window). It
|
|
538
|
+
* is drawn in a 640 box — the window's max if that is smaller — and contained in it. */}
|
|
539
|
+
<img src={mediaUrl(o.key)} alt={o.displayKey}
|
|
540
|
+
className="kol-quicklook-media"
|
|
541
|
+
style={extOf(o.key) === 'svg' || o.contentType === 'image/svg+xml' ? { width: 'min(var(--kol-ql-max-w), 640px)', height: 'min(var(--kol-ql-media-h), 640px)' } : undefined}
|
|
542
|
+
onLoad={(e) => setDims({ key: o.key, w: e.target.naturalWidth, h: e.target.naturalHeight })} />
|
|
521
543
|
</QuickLookFrame>
|
|
522
544
|
) : isVideo(o.contentType) ? (
|
|
523
545
|
<VideoSheet key={o.key} src={mediaUrl(o.key)} poster={poster ? mediaUrl(poster) : undefined} onMeta={(m) => setDims({ key: o.key, ...m })} frame={frame} />
|
|
@@ -855,7 +877,7 @@ export function MediaLibraryBrowse({
|
|
|
855
877
|
}) {
|
|
856
878
|
const [ownPrefix, setOwnPrefix] = useState('')
|
|
857
879
|
const prefix = prefixProp ?? ownPrefix
|
|
858
|
-
const setPrefix = (v) => { if (prefixProp == null) setOwnPrefix(v); onPrefix?.(v) }
|
|
880
|
+
const setPrefix = (v) => { if (v) setAppRoot(false); if (prefixProp == null) setOwnPrefix(v); onPrefix?.(v) }
|
|
859
881
|
const [appRoot, setAppRoot] = useState(false)
|
|
860
882
|
const [ownBucket, setOwnBucket] = useState(bucket)
|
|
861
883
|
const bucketId = bucket ?? ownBucket
|
|
@@ -869,8 +891,22 @@ export function MediaLibraryBrowse({
|
|
|
869
891
|
* opens (user 2026-09-22, Finder's behaviour — "clicking shouldnt automatically open it"). The
|
|
870
892
|
* chevron still expands in place, which is the third thing and always was. */
|
|
871
893
|
const [pickedFolder, setPickedFolder] = useState(null)
|
|
872
|
-
const pickFile = (o) => { setPickedFolder(null); setPickedFile(o) }
|
|
873
|
-
const pickFolder = (path) => { setPickedFile(null); setPickedFolder(path) }
|
|
894
|
+
const pickFile = (o) => { setAppRoot(false); setPickedFolder(null); setPickedFile(o) }
|
|
895
|
+
const pickFolder = (path) => { setAppRoot(false); setPickedFile(null); setPickedFolder(path) }
|
|
896
|
+
/* AT THE TITLE ROOT IS DERIVED, NOT STORED (kol-client-olina 2026-09-23: rows and grid picked a
|
|
897
|
+
* file under `brand/logos/` and the crumb still read the title alone). `appRoot` was a second
|
|
898
|
+
* source of truth for the location, and only the columns ever cleared it. A path or a pick lives
|
|
899
|
+
* inside a bucket, so either one means you are not at the title — a stale flag can no longer
|
|
900
|
+
* hide where you are. Everything below reads this, never `appRoot`. */
|
|
901
|
+
const atTitleRoot = appRoot && !prefix && !pickedFile && !pickedFolder
|
|
902
|
+
/* ABOVE THE TITLE (user 2026-09-24: *"this is not the top root folder … in column view I can see
|
|
903
|
+
* the root MEDIA"*). Column 0 is a level whose one item is the title; rows and grid had no such
|
|
904
|
+
* level, so the bucket list was their top and the title never appeared as a thing. `appRoot` is
|
|
905
|
+
* `'top'` there — the same state, one level higher, derived the same way. */
|
|
906
|
+
const atTop = atTitleRoot && appRoot === 'top'
|
|
907
|
+
/* a BUCKET is a folder with another icon: a click highlights it, a double-click opens it */
|
|
908
|
+
const [pickedBucket, setPickedBucket] = useState(null)
|
|
909
|
+
const ROOT_KEY = '\u0000title'
|
|
874
910
|
/* IN THE ROWS, WHAT YOU PICK SETS WHERE YOU ARE (user 2026-09-23: *"that files folder becomes the
|
|
875
911
|
* default NOT the previous folder"*). The list is rooted at the top, so a pick can sit in any open
|
|
876
912
|
* folder; the current folder follows it to the pick's parent. Whatever the old path had open
|
|
@@ -1126,7 +1162,14 @@ export function MediaLibraryBrowse({
|
|
|
1126
1162
|
}
|
|
1127
1163
|
/* stepping into a folder drops the pick — the preview would otherwise describe something no
|
|
1128
1164
|
* longer in the list */
|
|
1129
|
-
|
|
1165
|
+
/* GOING UP SELECTS THE FOLDER YOU CAME FROM (user 2026-09-24: back from `01-shoots` landed on
|
|
1166
|
+
* `img/` with nothing selected and an empty preview — every file manager lights the child you
|
|
1167
|
+
* left). One rule for every way up — ⌘↑ and the crumbs both come through here. */
|
|
1168
|
+
const goFolder = (path) => {
|
|
1169
|
+
const child = path !== prefix && prefix.startsWith(path) ? `${path}${prefix.slice(path.length).split('/')[0]}/` : null
|
|
1170
|
+
setPickedFile(null); setPickedFolder(null); setPrefix(path)
|
|
1171
|
+
if (child) { setRowSelection(new Set([child])); setPickedFolder(child); selectAnchorRef.current = child; selectCursorRef.current = null }
|
|
1172
|
+
}
|
|
1130
1173
|
/* ONE "GO UP" FOR ALL THREE VIEWS (kol-client-olina 2026-09-23: *"3 different stops for 3
|
|
1131
1174
|
* modes … they should all go all the way to the shared root folder"*): parent folder → … → the
|
|
1132
1175
|
* bucket's top → the title root, which is what `onHome` does. With one bucket and no
|
|
@@ -1134,7 +1177,17 @@ export function MediaLibraryBrowse({
|
|
|
1134
1177
|
* the same path with ArrowLeft. */
|
|
1135
1178
|
const goUp = () => {
|
|
1136
1179
|
if (prefix) { goFolder(dirOf(prefix)); return }
|
|
1137
|
-
if (!single && !
|
|
1180
|
+
if (!single && !atTitleRoot) goTitleRoot()
|
|
1181
|
+
else if (!single && !atTop) goTop()
|
|
1182
|
+
}
|
|
1183
|
+
/* THE TITLE ROOT CLEARS EVERYTHING that would say you are somewhere else — a pick, a selection —
|
|
1184
|
+
* or the derivation above reads you as still inside the bucket */
|
|
1185
|
+
const goTop = () => { setAppRoot('top'); setPickedFile(null); setPickedFolder(null); setRowSelection(new Set()); setPickedBucket(ROOT_KEY); setPrefix('') }
|
|
1186
|
+
const goTitleRoot = () => { setAppRoot(true); setPickedFile(null); setPickedFolder(null); setPickedBucket(atTitleRoot ? null : (bucketMeta.id ?? bucketMeta.label)); setRowSelection(new Set()); setPrefix('') }
|
|
1187
|
+
/* opening a bucket from the title root, the step the columns' column 1 is */
|
|
1188
|
+
const openBucket = (b) => {
|
|
1189
|
+
setAppRoot(false); setPickedFile(null); setPickedFolder(null); setPickedBucket(null); setRowSelection(new Set())
|
|
1190
|
+
if (b.id != null && b.id !== bucketMeta.id) switchBucket(b.id, ''); else setPrefix('')
|
|
1138
1191
|
}
|
|
1139
1192
|
/* SPACE IS QUICK LOOK IN EVERY VIEW (user 2026-09-23: *"spacebar preview only works in column,
|
|
1140
1193
|
* fix that. CONSISTENCY"*). `ColumnBrowser` has owned that key since it shipped, so the columns
|
|
@@ -1185,6 +1238,8 @@ export function MediaLibraryBrowse({
|
|
|
1185
1238
|
}
|
|
1186
1239
|
const spaceQuickLook = () => {
|
|
1187
1240
|
const key = pickedFile?.key ?? pickedFolder ?? (rowSelection.size === 1 ? [...rowSelection][0] : null)
|
|
1241
|
+
/* a picked bucket or the title Quick-Looks like any folder (user 2026-09-25) */
|
|
1242
|
+
if (!key && previewRoot) { setQuickLookFolder(ROOT_KEY); return }
|
|
1188
1243
|
if (!key) return
|
|
1189
1244
|
/* A FOLDER QUICK-LOOKS TOO (user 2026-09-23: *"anything can be quick viewed, also folders"*) —
|
|
1190
1245
|
* the same pane the preview column draws, at overlay size. */
|
|
@@ -1212,7 +1267,14 @@ export function MediaLibraryBrowse({
|
|
|
1212
1267
|
const gridOrderRef = useRef([])
|
|
1213
1268
|
const arrowNav = (e) => {
|
|
1214
1269
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return false
|
|
1215
|
-
|
|
1270
|
+
/* ⇧+ARROWS KEEP SELECTING UNDER QUICK LOOK (user 2026-09-24), Finder's: the selection grows
|
|
1271
|
+
* underneath and the window pages through it */
|
|
1272
|
+
if (view === 'columns' || (quickLook && !e.shiftKey) || quickLookFolder || searchOpen) return false
|
|
1273
|
+
if (atTitleRoot && !single) {
|
|
1274
|
+
if (e.metaKey && e.key === 'ArrowUp') goUp()
|
|
1275
|
+
else if (e.metaKey && e.key === 'ArrowDown') { if (atTop) goTitleRoot(); else openBucket(bucketList.find((b) => (b.id ?? b.label) === pickedBucket) ?? bucketMeta) }
|
|
1276
|
+
return true
|
|
1277
|
+
}
|
|
1216
1278
|
const cur = selectCursorRef.current ?? selectAnchorRef.current ?? [...rowSelection].pop() ?? null
|
|
1217
1279
|
/* ⇧ + ARROW EXTENDS (user 2026-09-23: *"shift up down or left right … should also make
|
|
1218
1280
|
* selection"*): the anchor stays where the run began, the cursor moves, and the selection is
|
|
@@ -1239,18 +1301,29 @@ export function MediaLibraryBrowse({
|
|
|
1239
1301
|
/* BEFORE the empty-list bail-out: a folder that holds only subfolders has an empty wall, and
|
|
1240
1302
|
* ⌘↑ was swallowed there */
|
|
1241
1303
|
if (e.metaKey && e.key === 'ArrowUp') { goUp(); return true }
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1304
|
+
/* THE ARROWS WALK WHAT IS DRAWN (user 2026-09-24: they skipped every folder tile) — the
|
|
1305
|
+
* folders, then the files, in the order on screen. The list was the files alone, from before
|
|
1306
|
+
* the grid drew folders. */
|
|
1307
|
+
const files = gridOrderRef.current
|
|
1308
|
+
const items = [...(settings.flat ? [] : folders.map((f) => ({ key: prefix + f, folder: true }))), ...files.map((o) => ({ key: o.key, o }))]
|
|
1309
|
+
if (!items.length) return true
|
|
1310
|
+
const i = items.findIndex((it) => it.key === cur)
|
|
1311
|
+
if (e.metaKey && e.key === 'ArrowDown') {
|
|
1312
|
+
const it = items[i]
|
|
1313
|
+
if (it?.folder) goFolder(it.key)
|
|
1314
|
+
else if (it) openQuickLook({ files, index: Math.max(0, files.findIndex((f) => f.key === it.key)) })
|
|
1315
|
+
return true
|
|
1316
|
+
}
|
|
1246
1317
|
const box = gridMarquee.ref.current
|
|
1247
|
-
|
|
1318
|
+
/* the band lives on the pane now; the column count is the tile grid's, inside it */
|
|
1319
|
+
const tiles = box?.querySelector('.grid')
|
|
1320
|
+
const cols = tiles ? getComputedStyle(tiles).gridTemplateColumns.split(' ').filter(Boolean).length : 1
|
|
1248
1321
|
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key]
|
|
1249
|
-
const
|
|
1250
|
-
if (e.shiftKey && extend(
|
|
1251
|
-
setRowSelection(new Set([
|
|
1252
|
-
setPickedFolder(null); setPickedFile(o)
|
|
1253
|
-
reveal(box,
|
|
1322
|
+
const it = items[i === -1 ? 0 : Math.max(0, Math.min(items.length - 1, i + step))]
|
|
1323
|
+
if (e.shiftKey && extend(items.map((x) => x.key), it.key)) { reveal(box, it.key); return true }
|
|
1324
|
+
setRowSelection(new Set([it.key])); selectAnchorRef.current = it.key; selectCursorRef.current = null
|
|
1325
|
+
if (it.folder) { setPickedFile(null); setPickedFolder(it.key) } else { setPickedFolder(null); setPickedFile(it.o) }
|
|
1326
|
+
reveal(box, it.key)
|
|
1254
1327
|
return true
|
|
1255
1328
|
}
|
|
1256
1329
|
if (view === 'rows') {
|
|
@@ -1298,6 +1371,18 @@ export function MediaLibraryBrowse({
|
|
|
1298
1371
|
if (e.key === '/' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); setSearchOpen(true) }
|
|
1299
1372
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setSearchOpen(true) }
|
|
1300
1373
|
if (arrowNav(e)) { e.preventDefault(); return }
|
|
1374
|
+
/* ENTER OPENS THE SELECTED FOLDER, ⇧ENTER GOES TO THE PARENT (user 2026-09-24). The same
|
|
1375
|
+
* two moves as a double-click and ⌘↑, so the child you leave is still the one selected.
|
|
1376
|
+
* A focused tile has already opened itself on Enter (it calls preventDefault). */
|
|
1377
|
+
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.defaultPrevented && !quickLook && !quickLookFolder && !searchOpen) {
|
|
1378
|
+
if (e.shiftKey) { e.preventDefault(); goUp(); return }
|
|
1379
|
+
if (view !== 'columns') {
|
|
1380
|
+
const root = rootItems.find((it) => it.key === pickedBucket)
|
|
1381
|
+
const sel = rowSelection.size === 1 ? [...rowSelection][0] : pickedFolder
|
|
1382
|
+
if (root) { e.preventDefault(); root.open(); return }
|
|
1383
|
+
if (sel?.endsWith('/')) { e.preventDefault(); goFolder(sel); return }
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1301
1386
|
/* the columns keep their own space handler — theirs knows the column's cursor */
|
|
1302
1387
|
if (e.key === ' ' && view !== 'columns' && !searchOpen) {
|
|
1303
1388
|
e.preventDefault()
|
|
@@ -1312,6 +1397,16 @@ export function MediaLibraryBrowse({
|
|
|
1312
1397
|
|
|
1313
1398
|
const [quickLook, setQuickLook] = useState(null)
|
|
1314
1399
|
const [quickLookFolder, setQuickLookFolder] = useState(null)
|
|
1400
|
+
/* …and the open window takes the new selection as its set, on the file the cursor reached */
|
|
1401
|
+
useEffect(() => {
|
|
1402
|
+
if (!quickLook || rowSelection.size < 2) return
|
|
1403
|
+
const order = view === 'grid' ? gridOrderRef.current : sortedObjects
|
|
1404
|
+
const files = order.filter((o) => rowSelection.has(o.key) && !o.key.endsWith('/'))
|
|
1405
|
+
if (files.length < 2) return
|
|
1406
|
+
if (files.length === quickLook.files.length && files.every((f, i) => f.key === quickLook.files[i].key)) return
|
|
1407
|
+
const at = files.findIndex((f) => f.key === selectCursorRef.current)
|
|
1408
|
+
setQuickLook({ files, index: at === -1 ? 0 : at })
|
|
1409
|
+
}, [rowSelection]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1315
1410
|
const columnsRef = useRef(null)
|
|
1316
1411
|
/* `flat` IS THE WALL'S, NOT THIS PAGE'S (user 2026-09-22: "there is no option to switch to flat
|
|
1317
1412
|
* mode, bc its not in files view. this is row mode.. this makes no sense"). It reached here from
|
|
@@ -1346,6 +1441,14 @@ export function MediaLibraryBrowse({
|
|
|
1346
1441
|
const previewWidth = settings.columnWidths?.preview ?? 320
|
|
1347
1442
|
const previewFile = pickedFile && pickedFile.key.startsWith(prefix) ? pickedFile : null
|
|
1348
1443
|
const previewFolder = pickedFolder && pickedFolder.startsWith(prefix) ? pickedFolder : null
|
|
1444
|
+
/* a bucket's totals come from `folderTree` when the consumer passes one; the open bucket can
|
|
1445
|
+
* always count its own listing */
|
|
1446
|
+
const bucketTotals = (id) => {
|
|
1447
|
+
const t = folderTree?.[id]
|
|
1448
|
+
if (t && t.files != null) return { files: t.files, bytes: t.bytes ?? 0 }
|
|
1449
|
+
if (id === bucketMeta.id) return { files: objects.length, bytes: objects.reduce((n, o) => n + (o.size || 0), 0) }
|
|
1450
|
+
return null
|
|
1451
|
+
}
|
|
1349
1452
|
/* THE PREVIEW PANE, ONE FUNCTION FOR THE ROWS AND THE GRID (user 2026-09-23: *"why did you not
|
|
1350
1453
|
* treat this like column and row, with container padding + preview pane? CONSISTENCY"*). Several
|
|
1351
1454
|
* selected → the stack; a file → its preview; a folder → its summary; nothing → the reserved
|
|
@@ -1357,12 +1460,14 @@ export function MediaLibraryBrowse({
|
|
|
1357
1460
|
<ColumnPreview key={previewFile.key} o={previewFile} urlOf={(o) => mediaUrl(o.key)} kindOf={kindOf} kindLabel={KIND_LABEL}
|
|
1358
1461
|
formatSize={formatSize} formatDate={formatDate} width={previewWidth}
|
|
1359
1462
|
renderPreview={(o) => {
|
|
1360
|
-
if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} />
|
|
1463
|
+
if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} vector={o.contentType === 'image/svg+xml'} />
|
|
1361
1464
|
const poster = posterFor(o.key, keySet)
|
|
1362
1465
|
return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
1363
1466
|
}} />
|
|
1364
1467
|
) : previewFolder ? (
|
|
1365
1468
|
<FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} />
|
|
1469
|
+
) : previewRoot ? (
|
|
1470
|
+
<ContainerPreview key={previewRoot.key} name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={previewWidth} />
|
|
1366
1471
|
) : (
|
|
1367
1472
|
<div className="kol-column-browser-preview shrink-0" style={{ width: previewWidth }} />
|
|
1368
1473
|
)
|
|
@@ -1449,14 +1554,14 @@ export function MediaLibraryBrowse({
|
|
|
1449
1554
|
if (!root) return undefined
|
|
1450
1555
|
const id = requestAnimationFrame(() => root.scrollTo({ left: root.scrollWidth, behavior: 'smooth' }))
|
|
1451
1556
|
return () => cancelAnimationFrame(id)
|
|
1452
|
-
}, [folderView, prefix,
|
|
1557
|
+
}, [folderView, prefix, atTitleRoot, pickedFile, bucketMeta.id])
|
|
1453
1558
|
|
|
1454
1559
|
if (error) return <p className="kol-mono-12 text-ui-error">Error: {error}</p>
|
|
1455
1560
|
/* NOT SELECTABLE (user 2026-09-23, superseding 2026-09-22's selectable crumbs: *"instead of this
|
|
1456
1561
|
* highlight, could we make a hover click to copy path?"*). A drag across the line painted the
|
|
1457
1562
|
* browser's selection block over it; the path is one click on the current crumb now. */
|
|
1458
1563
|
const crumbCls = (active) => `cursor-pointer select-none transition-colors ${active ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`
|
|
1459
|
-
const fullPath = (tail = '') => [ROOT, ...(
|
|
1564
|
+
const fullPath = (tail = '') => [ROOT, ...(atTitleRoot ? [] : [label, ...crumbs]), ...(tail ? [tail] : [])].join('/')
|
|
1460
1565
|
|
|
1461
1566
|
/* ── THE WALL'S LIST, on the same surface (2026-09-22) ──────────────────────────────────────
|
|
1462
1567
|
* `flat` belongs to the FILE views and not to the tree (that ruling stands): a wall shows the
|
|
@@ -1481,6 +1586,34 @@ export function MediaLibraryBrowse({
|
|
|
1481
1586
|
* key space, or the subset the filter bar left standing. `wallPane` is the same two file views
|
|
1482
1587
|
* the Files page rendered, in a pane of the browser's own height so the page does not jump
|
|
1483
1588
|
* between views. */
|
|
1589
|
+
/* AT THE TITLE ROOT, ROWS AND GRID LIST THE BUCKETS (same ticket: they listed the bucket's
|
|
1590
|
+
* folders under a crumb that named the title). One row / tile per bucket; a click opens it. */
|
|
1591
|
+
const bucketList = buckets.length ? buckets : [bucketMeta]
|
|
1592
|
+
const titleLevel = atTitleRoot && !single
|
|
1593
|
+
/* what the root levels hold: above the title, the title as one folder; inside it, the buckets.
|
|
1594
|
+
* Each is a folder with its own icon — a click highlights it, a double-click opens it. */
|
|
1595
|
+
const previewRoot = (() => {
|
|
1596
|
+
if (!titleLevel || pickedBucket == null) return null
|
|
1597
|
+
if (atTop && pickedBucket === ROOT_KEY) {
|
|
1598
|
+
const all = bucketList.map((b) => bucketTotals(b.id)).filter(Boolean)
|
|
1599
|
+
return { key: ROOT_KEY, name: ROOT, icon: 'folder', facts: [
|
|
1600
|
+
['Kind', 'folder'],
|
|
1601
|
+
['Buckets', `${bucketList.length}`],
|
|
1602
|
+
...(all.length === bucketList.length ? [['Items', `${all.reduce((n, x) => n + x.files, 0)}`], ['Size', formatSize(all.reduce((n, x) => n + x.bytes, 0))]] : []),
|
|
1603
|
+
] }
|
|
1604
|
+
}
|
|
1605
|
+
const b = bucketList.find((x) => (x.id ?? x.label) === pickedBucket)
|
|
1606
|
+
if (!b) return null
|
|
1607
|
+
const tot = bucketTotals(b.id)
|
|
1608
|
+
return { key: pickedBucket, name: b.label || 'bucket', icon: 'database', facts: [
|
|
1609
|
+
['Kind', 'bucket'],
|
|
1610
|
+
...(tot ? [['Items', `${tot.files}`], ['Size', formatSize(tot.bytes)]] : []),
|
|
1611
|
+
...(b.writable != null ? [['Access', b.writable ? 'read · write' : 'read-only']] : []),
|
|
1612
|
+
] }
|
|
1613
|
+
})()
|
|
1614
|
+
const rootItems = !titleLevel ? [] : atTop
|
|
1615
|
+
? [{ key: ROOT_KEY, name: ROOT, icon: 'folder', open: goTitleRoot }]
|
|
1616
|
+
: bucketList.map((b) => ({ key: b.id ?? b.label, name: b.label || 'bucket', icon: 'database', open: () => openBucket(b) }))
|
|
1484
1617
|
const treeBody = (keep) => {
|
|
1485
1618
|
const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
|
|
1486
1619
|
return (
|
|
@@ -1488,13 +1621,13 @@ export function MediaLibraryBrowse({
|
|
|
1488
1621
|
<div ref={columnsRef} className="relative">
|
|
1489
1622
|
<ColumnBrowser
|
|
1490
1623
|
autoFocus={autoFocus}
|
|
1491
|
-
className={
|
|
1624
|
+
className={atTitleRoot ? 'is-root' : ''}
|
|
1492
1625
|
height={settings.columnHeight}
|
|
1493
1626
|
onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
|
|
1494
1627
|
columnWidths={settings.columnWidths}
|
|
1495
1628
|
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
1496
1629
|
objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
1497
|
-
prefix={single ? prefix : (
|
|
1630
|
+
prefix={single ? prefix : (atTitleRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
|
|
1498
1631
|
onPrefix={(v) => {
|
|
1499
1632
|
/* single: the browser's path IS the bucket path, no segment to strip */
|
|
1500
1633
|
if (single) { setAppRoot(false); setPrefix(v); return }
|
|
@@ -1505,6 +1638,7 @@ export function MediaLibraryBrowse({
|
|
|
1505
1638
|
const real = rest.join('/')
|
|
1506
1639
|
if (target && target.id !== bucketMeta.id) switchBucket(target.id, real); else setPrefix(real)
|
|
1507
1640
|
}}
|
|
1641
|
+
picked={pickedFile ? { ...pickedFile, key: `${VROOT}${pickedFile.key}` } : null}
|
|
1508
1642
|
onPick={(o) => setPickedFile(o ? { ...o, key: o.key.slice(VROOT.length) } : null)}
|
|
1509
1643
|
quickLook={quickLook && { ...quickLook, files: quickLook.files.map((o) => ({ ...o, key: `${VROOT}${o.key}` })) }}
|
|
1510
1644
|
onQuickLook={(q) => openQuickLook(q && { ...q, files: q.files.map((o) => ({ ...o, key: o.key.slice(VROOT.length) })) })}
|
|
@@ -1520,7 +1654,7 @@ export function MediaLibraryBrowse({
|
|
|
1520
1654
|
stackView={stackView ?? settings.stackView ?? 'list'}
|
|
1521
1655
|
renderPreview={(o) => {
|
|
1522
1656
|
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
1523
|
-
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
|
|
1657
|
+
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} vector={real.contentType === 'image/svg+xml'} />
|
|
1524
1658
|
const poster = posterFor(real.key, keySet)
|
|
1525
1659
|
return <KindPreview o={real} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
1526
1660
|
}}
|
|
@@ -1585,9 +1719,9 @@ export function MediaLibraryBrowse({
|
|
|
1585
1719
|
<div className={`flex-1 min-w-0 flex flex-col${showRowPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1586
1720
|
<ul ref={rowsMarquee.ref} {...rowsMarquee.props}
|
|
1587
1721
|
/* the background deselects, here as in the columns */
|
|
1588
|
-
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}
|
|
1722
|
+
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) } }}
|
|
1589
1723
|
className="relative kol-column-browser-column kol-row-browser flex-1 min-w-0 flex flex-col overflow-y-auto"
|
|
1590
|
-
onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
1724
|
+
onContextMenu={titleLevel ? undefined : (e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
1591
1725
|
onDragOver={(e) => {
|
|
1592
1726
|
const files = dropFilesTo && isFileDrag(e)
|
|
1593
1727
|
const d = !isFileDrag(e) && dragFor?.(prefix)
|
|
@@ -1609,6 +1743,11 @@ export function MediaLibraryBrowse({
|
|
|
1609
1743
|
d.onDrop(prefix)
|
|
1610
1744
|
}}>
|
|
1611
1745
|
{rowsMarquee.rect && <div className="kol-marquee" style={rowsMarquee.rect} />}
|
|
1746
|
+
{titleLevel ? rootItems.map((it) => (
|
|
1747
|
+
<FolderRow key={it.key} name={it.name} icon={it.icon} cols={rowCols}
|
|
1748
|
+
selected={pickedBucket === it.key}
|
|
1749
|
+
onClick={(e) => { e.stopPropagation(); setPickedBucket(it.key) }} onDoubleClick={it.open} />
|
|
1750
|
+
)) : <>
|
|
1612
1751
|
{rowLevel('', treeObjects).folders.map((f) => {
|
|
1613
1752
|
const path = f
|
|
1614
1753
|
const open = rowsExpanded.has(path)
|
|
@@ -1654,6 +1793,7 @@ export function MediaLibraryBrowse({
|
|
|
1654
1793
|
if (!selectRow(o.key, e, visibleRowOrder(treeObjects))) rowPickFile(o)
|
|
1655
1794
|
}} />
|
|
1656
1795
|
))}
|
|
1796
|
+
</>}
|
|
1657
1797
|
</ul>
|
|
1658
1798
|
</div>
|
|
1659
1799
|
{showRowPreview && previewPane()}
|
|
@@ -1669,15 +1809,34 @@ export function MediaLibraryBrowse({
|
|
|
1669
1809
|
* inside it, the same preview pane beside them, the same count line under it — with the size
|
|
1670
1810
|
* slider at its right end, Finder's. */
|
|
1671
1811
|
const tileSize = settings.tileSize ?? 180
|
|
1812
|
+
/* the tile size is the GRID's, not a level's (user 2026-09-24: the slider vanished at the root
|
|
1813
|
+
* levels) — one control, drawn wherever the grid is */
|
|
1814
|
+
const sizeSlider = (
|
|
1815
|
+
<input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
|
|
1816
|
+
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
1817
|
+
)
|
|
1672
1818
|
const wallPane = (files) => (
|
|
1673
1819
|
<div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
|
|
1674
1820
|
<div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1675
|
-
|
|
1821
|
+
{/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
|
|
1822
|
+
* there were tiles — the grid is as tall as its tiles, the pane is the whole frame). The rows'
|
|
1823
|
+
* band has always been on their full-height list; this is the grid catching up. */}
|
|
1824
|
+
<div ref={gridMarquee.ref} {...gridMarquee.props}
|
|
1825
|
+
className={`relative flex-1 min-w-0 overflow-y-auto p-4${showGridPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
1676
1826
|
/* THE CONTAINER IS A LEVEL, like a column (kol-client-olina 2026-09-23: *"context menu anywhere
|
|
1677
1827
|
* by right click"*). A tile's own menu stops the event first, so it still wins. */
|
|
1678
|
-
onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
1679
|
-
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}>
|
|
1680
|
-
|
|
1828
|
+
onContextMenu={titleLevel ? undefined : (e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
1829
|
+
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) } }}>
|
|
1830
|
+
{gridMarquee.rect && <div className="kol-marquee" style={gridMarquee.rect} />}
|
|
1831
|
+
{titleLevel ? (
|
|
1832
|
+
<div className="grid gap-x-3 gap-y-5" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${tileSize}px, 1fr))` }}>
|
|
1833
|
+
{rootItems.map((it) => (
|
|
1834
|
+
<MediaTile key={it.key} preview={<Icon name={it.icon} size="100%" className="text-oq-48" />} name={it.name}
|
|
1835
|
+
selected={pickedBucket === it.key}
|
|
1836
|
+
onClick={(e) => { e.stopPropagation(); setPickedBucket(it.key) }} onDoubleClick={it.open} />
|
|
1837
|
+
))}
|
|
1838
|
+
</div>
|
|
1839
|
+
) : <WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} onSorted={(list) => { gridOrderRef.current = list }}
|
|
1681
1840
|
/* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
|
|
1682
1841
|
* 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
|
|
1683
1842
|
* and no folders, as it was. */
|
|
@@ -1685,7 +1844,7 @@ export function MediaLibraryBrowse({
|
|
|
1685
1844
|
onOpenFolder={goFolder}
|
|
1686
1845
|
onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
|
|
1687
1846
|
onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
|
|
1688
|
-
onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) }}
|
|
1847
|
+
onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) }}
|
|
1689
1848
|
sortBy={settings.sortBy} sortDir={settings.sortDir}
|
|
1690
1849
|
pageSize={settings.pageSize} listId={`${prefix}|${view}|${settings.flat}|${refreshKey}|${bucketMeta.id}`}
|
|
1691
1850
|
mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} videoPreview={settings.videoPreview} formatDate={formatDate}
|
|
@@ -1698,7 +1857,7 @@ export function MediaLibraryBrowse({
|
|
|
1698
1857
|
onOpen={(o, files) => openQuickLook({ files, index: Math.max(0, files.findIndex((f) => f.key === o.key)) })}
|
|
1699
1858
|
/* COPY AND DOWNLOAD LIVE IN THE MENU AND QUICK LOOK (user 2026-09-23) — as in the rows and the
|
|
1700
1859
|
* columns. The tile carries no buttons. */
|
|
1701
|
-
onContextMenu={(e, o) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })} />
|
|
1860
|
+
onContextMenu={(e, o) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })} />}
|
|
1702
1861
|
</div>
|
|
1703
1862
|
{showGridPreview && previewPane()}
|
|
1704
1863
|
</div>
|
|
@@ -1732,9 +1891,9 @@ export function MediaLibraryBrowse({
|
|
|
1732
1891
|
|
|
1733
1892
|
return (
|
|
1734
1893
|
<div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
1735
|
-
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={
|
|
1736
|
-
onHome={
|
|
1737
|
-
onBucket={(v) => { if (v === 'all')
|
|
1894
|
+
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
|
|
1895
|
+
onHome={goTitleRoot}
|
|
1896
|
+
onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
|
|
1738
1897
|
onSettings={() => setSettingsOpen(true)} />
|
|
1739
1898
|
|
|
1740
1899
|
{/* THE BANNER SLOT — anything that must sit directly under the header and above the body.
|
|
@@ -1760,16 +1919,16 @@ export function MediaLibraryBrowse({
|
|
|
1760
1919
|
{/* the elided line — first segment, an ellipsis for anything between,
|
|
1761
1920
|
* and the current one. Below `md` only. */}
|
|
1762
1921
|
<div className="flex md:hidden items-center gap-2 kol-mono-12 min-w-0">
|
|
1763
|
-
<button className={crumbCls(
|
|
1764
|
-
{!
|
|
1922
|
+
<button className={crumbCls(atTitleRoot && !atTop)} onClick={single ? goTitleRoot : goTop}>{ROOT}</button>
|
|
1923
|
+
{!atTitleRoot && bucketMeta.id && crumbs.length > 1 && (
|
|
1765
1924
|
<><span className="text-oq-32">/</span><span className="text-oq-32">…</span></>
|
|
1766
1925
|
)}
|
|
1767
|
-
{!
|
|
1926
|
+
{!atTitleRoot && bucketMeta.id && (
|
|
1768
1927
|
<span className="flex items-center gap-2 min-w-0">
|
|
1769
1928
|
<span className="text-oq-32">/</span>
|
|
1770
1929
|
<button
|
|
1771
1930
|
className={`${crumbCls(true)} truncate`}
|
|
1772
|
-
onClick={() =>
|
|
1931
|
+
onClick={() => goFolder(crumbs.length ? crumbs.join('/') + '/' : '')}
|
|
1773
1932
|
>
|
|
1774
1933
|
{(crumbs[crumbs.length - 1] ?? label).toUpperCase()}
|
|
1775
1934
|
</button>
|
|
@@ -1777,33 +1936,48 @@ export function MediaLibraryBrowse({
|
|
|
1777
1936
|
)}
|
|
1778
1937
|
</div>
|
|
1779
1938
|
<div className="hidden md:flex items-center gap-2 kol-mono-12">
|
|
1780
|
-
<button className={crumbCls(
|
|
1781
|
-
{
|
|
1939
|
+
<button className={crumbCls(atTitleRoot && (!atTop || pickedBucket === ROOT_KEY))} onClick={single ? goTitleRoot : goTop}>{ROOT}</button>
|
|
1940
|
+
{/* A PICKED BUCKET ENDS THE CRUMB at the title level (user 2026-09-24: a selected folder
|
|
1941
|
+
* must show in the crumb, as a selected file does — a bucket is a folder) */}
|
|
1942
|
+
{titleLevel && !atTop && pickedBucket && rootItems.some((it) => it.key === pickedBucket) && (
|
|
1943
|
+
<span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
|
|
1944
|
+
<span className={crumbCls(true)}>{rootItems.find((it) => it.key === pickedBucket).name.toUpperCase()}</span>
|
|
1945
|
+
</span>
|
|
1946
|
+
)}
|
|
1947
|
+
{!atTitleRoot && bucketMeta.id && (
|
|
1782
1948
|
<span className="flex items-center gap-2">
|
|
1783
1949
|
<span className="text-oq-32 select-none">/</span>
|
|
1784
|
-
{crumbs.length === 0 && !
|
|
1950
|
+
{crumbs.length === 0 && !pickedFile && !pickedFolder
|
|
1785
1951
|
? <CopyCrumb className={crumbCls(true)} label={label.toUpperCase()} path={fullPath()} />
|
|
1786
|
-
: <button className={crumbCls(false)} onClick={() =>
|
|
1952
|
+
: <button className={crumbCls(false)} onClick={() => goFolder('')}>{label.toUpperCase()}</button>}
|
|
1787
1953
|
</span>
|
|
1788
1954
|
)}
|
|
1789
|
-
{!
|
|
1955
|
+
{!atTitleRoot && crumbs.map((seg, i) => {
|
|
1790
1956
|
const to = crumbs.slice(0, i + 1).join('/') + '/'
|
|
1791
1957
|
const last = i === crumbs.length - 1
|
|
1792
|
-
const current = last && !
|
|
1958
|
+
const current = last && !pickedFile && !pickedFolder
|
|
1793
1959
|
return (
|
|
1794
1960
|
<span key={to} className="flex items-center gap-2">
|
|
1795
1961
|
<span className="text-oq-32 select-none">/</span>
|
|
1796
1962
|
{current
|
|
1797
1963
|
? <CopyCrumb className={crumbCls(true)} label={seg.toUpperCase()} path={fullPath()} />
|
|
1798
|
-
: <button className={crumbCls(false)} onClick={() =>
|
|
1964
|
+
: <button className={crumbCls(false)} onClick={() => goFolder(to)}>{seg.toUpperCase()}</button>}
|
|
1799
1965
|
</span>
|
|
1800
1966
|
)
|
|
1801
1967
|
})}
|
|
1802
|
-
{
|
|
1968
|
+
{/* THE PICKED FILE ENDS THE CRUMB IN EVERY VIEW (kol-client-olina 2026-09-23 — it was the
|
|
1969
|
+
* columns' alone, so a file picked in rows showed its folder and never itself) */}
|
|
1970
|
+
{pickedFile && pickedFile.key.startsWith(prefix) && (
|
|
1803
1971
|
<span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
|
|
1804
1972
|
<CopyCrumb className={crumbCls(true)} label={pickedFile.key.slice(prefix.length).toUpperCase()} path={fullPath(pickedFile.key.slice(prefix.length))} />
|
|
1805
1973
|
</span>
|
|
1806
1974
|
)}
|
|
1975
|
+
{/* …and so does a picked folder */}
|
|
1976
|
+
{!pickedFile && pickedFolder && pickedFolder !== prefix && pickedFolder.startsWith(prefix) && (
|
|
1977
|
+
<span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
|
|
1978
|
+
<CopyCrumb className={crumbCls(true)} label={pickedFolder.slice(prefix.length).replace(/\/$/, '').toUpperCase()} path={fullPath(pickedFolder.slice(prefix.length).replace(/\/$/, ''))} />
|
|
1979
|
+
</span>
|
|
1980
|
+
)}
|
|
1807
1981
|
</div>
|
|
1808
1982
|
{/* the control cluster is desktop-only — below `md` this is the same
|
|
1809
1983
|
* setting the drawer already carries, and nothing in this row survives 390.
|
|
@@ -1898,7 +2072,9 @@ export function MediaLibraryBrowse({
|
|
|
1898
2072
|
)}
|
|
1899
2073
|
{quickLookFolder && (
|
|
1900
2074
|
<FullscreenOverlay open onClose={() => setQuickLookFolder(null)} scrim>
|
|
1901
|
-
|
|
2075
|
+
{quickLookFolder === ROOT_KEY
|
|
2076
|
+
? previewRoot && <ContainerPreview name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={420} />
|
|
2077
|
+
: <FolderPreview path={quickLookFolder} objects={objects} width={420} formatDate={formatDate} />}
|
|
1902
2078
|
</FullscreenOverlay>
|
|
1903
2079
|
)}
|
|
1904
2080
|
{quickLook && (
|
|
@@ -1938,20 +2114,23 @@ export function MediaLibraryBrowse({
|
|
|
1938
2114
|
* height again, and one shared `gap-3` puts both gaps level on its own. */}
|
|
1939
2115
|
{/* THE COUNT LINE IS EVERY VIEW'S (user 2026-09-23) — and in the grid it carries the tile-size
|
|
1940
2116
|
* slider at its right end, bare: no glyph, no readout (Finder's). */}
|
|
1941
|
-
{
|
|
2117
|
+
{atTitleRoot && !single ? (
|
|
2118
|
+
<div className="flex items-center justify-between gap-4 h-4">
|
|
2119
|
+
<p className="kol-mono-12 text-fg-48">
|
|
2120
|
+
{atTop ? '1 folder' : `${bucketList.length} ${bucketList.length === 1 ? 'bucket' : 'buckets'}`}
|
|
2121
|
+
{folderTree && ` · ${Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · ${formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}`}
|
|
2122
|
+
</p>
|
|
2123
|
+
{isWall && sizeSlider}
|
|
2124
|
+
</div>
|
|
2125
|
+
) : isWall ? (
|
|
1942
2126
|
/* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
|
|
1943
2127
|
* this row 8px taller than the fill budget, so the page scrolled by that much */
|
|
1944
2128
|
<div className="flex items-center justify-between gap-4 h-4">
|
|
1945
2129
|
<p className="kol-mono-12 text-fg-48">
|
|
1946
2130
|
{!settings.flat && folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}{wallFiles.length} {wallFiles.length === 1 ? 'file' : 'files'} · {formatSize(wallFiles.reduce((n, o) => n + (o.size ?? 0), 0))}
|
|
1947
2131
|
</p>
|
|
1948
|
-
|
|
1949
|
-
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
2132
|
+
{sizeSlider}
|
|
1950
2133
|
</div>
|
|
1951
|
-
) : appRoot && folderView === 'columns' && folderTree ? (
|
|
1952
|
-
<p className="kol-mono-12 text-fg-48">
|
|
1953
|
-
{buckets.length} buckets · {Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · {formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}
|
|
1954
|
-
</p>
|
|
1955
2134
|
) : (
|
|
1956
2135
|
<p className="kol-mono-12 text-fg-48">
|
|
1957
2136
|
{folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
|
|
@@ -2211,7 +2390,7 @@ export function WallBody({
|
|
|
2211
2390
|
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) onBackgroundClick?.() }}>
|
|
2212
2391
|
{marquee?.rect && <div className="kol-marquee" style={marquee.rect} />}
|
|
2213
2392
|
{folders.map((path) => (
|
|
2214
|
-
<MediaTile key={path} markKey={path} preview={<Icon name="folder" size=
|
|
2393
|
+
<MediaTile key={path} markKey={path} preview={<Icon name="folder" size="100%" className="text-oq-48" />}
|
|
2215
2394
|
name={path.replace(/\/$/, '').split('/').pop()} selected={isSelected(path)}
|
|
2216
2395
|
onContextMenu={onFolderContextMenu ? (e) => onFolderContextMenu(e, path) : undefined}
|
|
2217
2396
|
onDoubleClick={() => onOpenFolder?.(path)}
|
|
@@ -15,7 +15,7 @@ import { minHeightClass } from './sectionHeights.js'
|
|
|
15
15
|
* TWO VARIANTS. `editorial` (default): the two-column band above. `centered`
|
|
16
16
|
* (SectionHeroRound2, 2026-08-26): the quiet mid-page tier — a short rule, a
|
|
17
17
|
* heading, a mono line and Buttons, centred — which was `FoundryCTA`, a second
|
|
18
|
-
* way to render SectionText. FoundryCTA
|
|
18
|
+
* way to render SectionText. FoundryCTA was its alias until 2026-09-25, when it dropped.
|
|
19
19
|
*
|
|
20
20
|
* @param {'full'|'80'|'60'|string} [height='60'] min-height on the family's ladder — full = 100dvh,
|
|
21
21
|
* 80 = 70svh / 80vh, 60 = 50svh / 60vh (default), 40 = 35svh / 40vh; content stays vertically centred inside it
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import Button from '../atoms/Button.jsx'
|
|
2
|
-
import SectionCta from './SectionCta.jsx'
|
|
3
|
-
|
|
4
|
-
/* taxonomy-ok: nests component's Button */
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @deprecated 2026-08-26 — `FoundryCTA` is `SectionCta variant="centered"`
|
|
8
|
-
* (SectionHeroRound2: a second way to render SectionText). Same render; the
|
|
9
|
-
* `action` object(s) become Buttons. Alias kept; removed at the next major.
|
|
10
|
-
*
|
|
11
|
-
* @param {ReactNode} heading · description
|
|
12
|
-
* @param {Object|Array} action one {href, label, variant?, target?, rel?} or an array
|
|
13
|
-
* @param {Function} onNavigate (href, event) => void
|
|
14
|
-
*/
|
|
15
|
-
export default function FoundryCTA({ heading, description, action, onNavigate, className = '' }) {
|
|
16
|
-
const actions = (Array.isArray(action) ? action : [action]).filter(Boolean)
|
|
17
|
-
return (
|
|
18
|
-
<SectionCta
|
|
19
|
-
variant="centered"
|
|
20
|
-
headline={heading}
|
|
21
|
-
body={description}
|
|
22
|
-
className={className}
|
|
23
|
-
actions={actions.length > 0 ? actions.map((act, i) => (
|
|
24
|
-
<Button
|
|
25
|
-
key={i}
|
|
26
|
-
variant={act.variant === 'secondary' ? 'outline' : 'primary'}
|
|
27
|
-
size="md"
|
|
28
|
-
href={act.href}
|
|
29
|
-
target={act.target}
|
|
30
|
-
rel={act.rel}
|
|
31
|
-
onClick={onNavigate ? (e) => onNavigate(act.href, e) : undefined}
|
|
32
|
-
>
|
|
33
|
-
{act.label}
|
|
34
|
-
</Button>
|
|
35
|
-
)) : undefined}
|
|
36
|
-
/>
|
|
37
|
-
)
|
|
38
|
-
}
|