@kolkrabbi/kol-component 0.211.0 → 0.213.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.213.0",
|
|
4
4
|
"description": "KOL design-system components \u2014 atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/index.js
CHANGED
|
@@ -143,6 +143,7 @@ export { default as Carousel } from './molecules/Carousel.jsx'
|
|
|
143
143
|
* stage reaches for it instead of re-typing the button markup, which is how the
|
|
144
144
|
* three in-package copies (and kol-website's CarouselNavigation) happened. */
|
|
145
145
|
export { default as EmblaNav } from './molecules/EmblaNav.jsx'
|
|
146
|
+
export { default as MobileTabBar, TABBAR_H } from './molecules/MobileTabBar.jsx'
|
|
146
147
|
export { default as ContentFilters } from './organisms/ContentFilters.jsx'
|
|
147
148
|
export { default as PageHeader } from './molecules/PageHeader.jsx'
|
|
148
149
|
export { default as ErrorBoundary } from './utilities/ErrorBoundary.jsx'
|
|
@@ -27,6 +27,12 @@ export function MenuItem({
|
|
|
27
27
|
panelStyle,
|
|
28
28
|
buttonClassName = '',
|
|
29
29
|
defaultOpen = false,
|
|
30
|
+
/* THE CARET IS NOT ALWAYS RIGHT. The trigger draws `label ▾`, which is the
|
|
31
|
+
* shape of a named menu — File, Sort. An ICON trigger (`···`, a gear) is
|
|
32
|
+
* already complete and a caret beside it reads as a second glyph rather than
|
|
33
|
+
* an affordance; neither reference draws one (ColumnBrowserMobileViews item
|
|
34
|
+
* 15, kol-r2b2 2026-09-04). Default keeps every existing call-site. */
|
|
35
|
+
caret = true,
|
|
30
36
|
}) {
|
|
31
37
|
const [open, setOpen] = useState(defaultOpen)
|
|
32
38
|
const popover = usePopover({
|
|
@@ -48,11 +54,13 @@ export function MenuItem({
|
|
|
48
54
|
className={`kol-helper-12 px-3 h-8 inline-flex items-center gap-2 rounded text-body hover:text-emphasis transition-colors ${buttonClassName}`}
|
|
49
55
|
>
|
|
50
56
|
<span>{label}</span>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
57
|
+
{caret && (
|
|
58
|
+
<Icon
|
|
59
|
+
name="chevron-down"
|
|
60
|
+
size={10}
|
|
61
|
+
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 200ms' }}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
56
64
|
</button>
|
|
57
65
|
{/* w-max: floats size to CONTENT, never to the containing block —
|
|
58
66
|
* same family law as ShapeDropdown's panel (2026-08-09 review). */}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MobileTabBar — the floating bottom tab pill, below `md` only.
|
|
5
|
+
*
|
|
6
|
+
* Both references end the same way: neither iOS Files nor Dropbox stacks two
|
|
7
|
+
* full-height surfaces on a phone. Each floats a pill at the bottom and gives
|
|
8
|
+
* every surface a tab — Files `Recents · Shared · Browse`, Dropbox `Home ·
|
|
9
|
+
* Files · Photos · Account` (ColumnBrowserMobileViews §5, kol-r2b2, user-ruled
|
|
10
|
+
* 2026-09-03). It is what answers the question `ColumnBrowserStackMode` left
|
|
11
|
+
* open: the library wall does NOT stay stacked under the browser.
|
|
12
|
+
*
|
|
13
|
+
* WHAT A TAB MEANS IS THE CONSUMER'S. This ships the pill — the shape, the
|
|
14
|
+
* float, the states, the breakpoint — and takes a list. A repo whose surfaces
|
|
15
|
+
* are routes wires it to its router; one whose surfaces are variants swaps a
|
|
16
|
+
* variant. The DS deciding that a media library has exactly three surfaces
|
|
17
|
+
* called Browse, Files and Kinds is the kind of guess that makes an organism
|
|
18
|
+
* un-reusable.
|
|
19
|
+
*
|
|
20
|
+
* FLOATS, not a layout row: it sits over the list on `position: fixed` so the
|
|
21
|
+
* list scrolls under it, which is what both references do. The consumer owes
|
|
22
|
+
* the list bottom padding — `--kol-tabbar-h` is published for exactly that, so
|
|
23
|
+
* nobody hardcodes 56.
|
|
24
|
+
*
|
|
25
|
+
* @param {Array<{value: string, label: string, icon?: string}>} tabs
|
|
26
|
+
* @param {string} value the active tab
|
|
27
|
+
* @param {Function} onChange (value) => void
|
|
28
|
+
* @param {string} className extra classes on the pill
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export const TABBAR_H = 56
|
|
32
|
+
|
|
33
|
+
export default function MobileTabBar({ tabs = [], value, onChange, className = '' }) {
|
|
34
|
+
if (tabs.length === 0) return null
|
|
35
|
+
return (
|
|
36
|
+
<nav
|
|
37
|
+
className={`kol-mobile-tabbar md:hidden fixed inset-x-0 bottom-0 z-[var(--kol-z-sticky)] flex items-stretch ${className}`.trim()}
|
|
38
|
+
style={{
|
|
39
|
+
height: TABBAR_H,
|
|
40
|
+
/* the pill's own ground, not the page's — it floats OVER content, so a
|
|
41
|
+
* transparent bar would show rows sliding through the labels */
|
|
42
|
+
background: 'var(--kol-surface-primary)',
|
|
43
|
+
borderTop: '1px solid var(--kol-oq-08)',
|
|
44
|
+
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
|
45
|
+
}}
|
|
46
|
+
aria-label="Sections"
|
|
47
|
+
>
|
|
48
|
+
{tabs.map((t) => {
|
|
49
|
+
const active = t.value === value
|
|
50
|
+
return (
|
|
51
|
+
<button
|
|
52
|
+
key={t.value}
|
|
53
|
+
type="button"
|
|
54
|
+
onClick={() => onChange?.(t.value)}
|
|
55
|
+
aria-current={active ? 'page' : undefined}
|
|
56
|
+
className="flex-1 min-w-0 flex flex-col items-center justify-center gap-1 cursor-pointer"
|
|
57
|
+
style={{ background: 'transparent', border: 'none', color: active ? 'var(--kol-fg-default)' : 'var(--kol-fg-48)' }}
|
|
58
|
+
>
|
|
59
|
+
{t.icon && <Icon name={t.icon} size={18} />}
|
|
60
|
+
{/* helper is single-line chrome, which a tab label always is */}
|
|
61
|
+
<span className="kol-helper-10 truncate max-w-full px-1">{t.label}</span>
|
|
62
|
+
</button>
|
|
63
|
+
)
|
|
64
|
+
})}
|
|
65
|
+
</nav>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -42,6 +42,8 @@ import IconFrame from '../atoms/IconFrame.jsx'
|
|
|
42
42
|
* `headerActions` is the left group beside search and was never that)
|
|
43
43
|
* @param {Function} props.onFilterChange — optional callback when filters change
|
|
44
44
|
* @param {Array} props.mutuallyExclusiveFilters — filter keys that should be mutually exclusive
|
|
45
|
+
* @param {string[]} props.initialFilters — chips active on FIRST paint, as `"<groupKey>:<value>"`
|
|
46
|
+
* (e.g. `['kind:preset']`). Initial only: the component owns the set after that
|
|
45
47
|
* @param {Array} props.customFilterKeys — filter keys handled by renderItem, not by ContentFilters
|
|
46
48
|
* @param {ElementType} props.iconComponent — icon seam (defaults to DS Icon; needs `filter` + `search`)
|
|
47
49
|
*
|
|
@@ -74,6 +76,7 @@ const ContentFilters = ({
|
|
|
74
76
|
totalCount,
|
|
75
77
|
titleIcon,
|
|
76
78
|
filterGroups = [],
|
|
79
|
+
initialFilters,
|
|
77
80
|
renderItem,
|
|
78
81
|
viewModeOptions,
|
|
79
82
|
viewMode: viewModeProp,
|
|
@@ -147,7 +150,14 @@ const ContentFilters = ({
|
|
|
147
150
|
/* Icon seam — consumers on a local icon shelf pass their own component
|
|
148
151
|
* rather than being forced onto the DS set. Needs `filter` + `search`. */
|
|
149
152
|
const IconSeam = iconComponent || Icon
|
|
150
|
-
|
|
153
|
+
/* SEEDED, not controlled (FilesDialog, kol-fxr 2026-09-04). A consumer that
|
|
154
|
+
* opens onto one chip — the files dialog opens on `preset` — had no way to
|
|
155
|
+
* say so: the set started empty and the first paint showed everything. An
|
|
156
|
+
* INITIAL value, not a controlled prop, because the filters are this
|
|
157
|
+
* component's own state everywhere else and a half-controlled set is two
|
|
158
|
+
* sources of truth. Entries are `"<groupKey>:<value>"`, the same strings the
|
|
159
|
+
* chips toggle. */
|
|
160
|
+
const [activeFilters, setActiveFilters] = useState(() => new Set(initialFilters))
|
|
151
161
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
152
162
|
const [internalViewMode, setInternalViewMode] = useState(defaultViewMode ?? viewModeOptions?.[0]?.value)
|
|
153
163
|
const viewMode = viewModeProp !== undefined ? viewModeProp : internalViewMode
|
|
@@ -13,6 +13,9 @@ import Dropdown from '../molecules/Dropdown.jsx'
|
|
|
13
13
|
import ContentCard from '../molecules/ContentCard.jsx'
|
|
14
14
|
import ContentRow from '../molecules/ContentRow.jsx'
|
|
15
15
|
import SortControls from '../molecules/SortControls.jsx'
|
|
16
|
+
import SearchInput from '../molecules/SearchInput.jsx'
|
|
17
|
+
import MobileTabBar, { TABBAR_H } from '../molecules/MobileTabBar.jsx'
|
|
18
|
+
import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
|
|
16
19
|
import KindPreview from '../molecules/KindPreview.jsx'
|
|
17
20
|
import AudioSheet from '../molecules/AudioSheet.jsx'
|
|
18
21
|
import VideoSheet from '../molecules/VideoSheet.jsx'
|
|
@@ -81,6 +84,13 @@ const FOLDER_VIEW_OPTIONS = [
|
|
|
81
84
|
{ value: 'rows', label: 'Rows', icon: 'view-list' },
|
|
82
85
|
{ value: 'columns', label: 'Columns', icon: 'columns' },
|
|
83
86
|
]
|
|
87
|
+
/* the stack view the `···` switches (item 15) — the same two ColumnBrowser
|
|
88
|
+
* carries below `md`, named as the references name them */
|
|
89
|
+
const STACK_VIEW_OPTIONS = [
|
|
90
|
+
{ value: 'list', label: 'List' },
|
|
91
|
+
{ value: 'grid', label: 'Icons' },
|
|
92
|
+
]
|
|
93
|
+
|
|
84
94
|
const SORT_OPTIONS = [
|
|
85
95
|
{ value: 'name', label: 'Name' },
|
|
86
96
|
{ value: 'date', label: 'Date' },
|
|
@@ -328,6 +338,33 @@ const profileOf = (objects, rawFiles, systemCount) => ({
|
|
|
328
338
|
})
|
|
329
339
|
|
|
330
340
|
/* ══ BROWSE — folder / files ═══════════════════════════════════════════════ */
|
|
341
|
+
/* THE `···`'s SORT, pure and module-level so it is reachable by a check
|
|
342
|
+
* (ColumnBrowserMobileViews item 15). Sorting was desktop-only chrome until
|
|
343
|
+
* now — item 7 of the previous ticket hid the control cluster below `md` and
|
|
344
|
+
* put nothing in its place, so on a phone sort could not be reached at all.
|
|
345
|
+
*
|
|
346
|
+
* NAME IS THE TIEBREAK IN EVERY MODE. Two files of equal size, or a bucket
|
|
347
|
+
* whose objects carry no `uploaded`, otherwise come back in whatever order the
|
|
348
|
+
* comparator happened to walk — a list that reshuffles between renders for no
|
|
349
|
+
* reason the user did anything to cause. The tiebreak is deliberately NOT
|
|
350
|
+
* reversed by `sortDir`: descending by size still reads A before B inside a
|
|
351
|
+
* tie, which is what every file manager does. */
|
|
352
|
+
function sortObjects(objects, sortBy = 'name', sortDir = 'asc') {
|
|
353
|
+
const nameOf = (o) => o.key.slice(o.key.lastIndexOf('/') + 1).toLowerCase()
|
|
354
|
+
const val = {
|
|
355
|
+
name: nameOf,
|
|
356
|
+
date: (o) => o.uploaded ?? '',
|
|
357
|
+
size: (o) => o.size ?? 0,
|
|
358
|
+
kind: (o) => String(kindOf(o)),
|
|
359
|
+
}[sortBy] ?? nameOf
|
|
360
|
+
const dir = sortDir === 'desc' ? -1 : 1
|
|
361
|
+
return [...objects].sort((m, n) => {
|
|
362
|
+
const x = val(m), y = val(n)
|
|
363
|
+
if (x === y) return nameOf(m) < nameOf(n) ? -1 : 1
|
|
364
|
+
return (x < y ? -1 : 1) * dir
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
331
368
|
export function MediaLibraryBrowse({
|
|
332
369
|
client, title = 'MEDIA', bucket, onBucketChange, prefix: prefixProp, onPrefix, defaults, settings: settingsProp, onSettingsChange,
|
|
333
370
|
folderTree, headerActions, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '',
|
|
@@ -342,6 +379,10 @@ export function MediaLibraryBrowse({
|
|
|
342
379
|
* real opinion about it (it owns `height`, the widths and `partition`, so
|
|
343
380
|
* those are deliberately absent from this list). */
|
|
344
381
|
thumbnailFor, folderMeta, formatDate, stackView,
|
|
382
|
+
/* THE TAB PILL'S LIST (ColumnBrowserMobileViews item 16). What a tab MEANS is
|
|
383
|
+
* the consumer's — a repo whose surfaces are routes wires its router here.
|
|
384
|
+
* No tabs, no pill, and the page is exactly what it was. */
|
|
385
|
+
tabs, activeTab, onTabChange,
|
|
345
386
|
}) {
|
|
346
387
|
const [ownPrefix, setOwnPrefix] = useState('')
|
|
347
388
|
const prefix = prefixProp ?? ownPrefix
|
|
@@ -357,8 +398,25 @@ export function MediaLibraryBrowse({
|
|
|
357
398
|
const [quickLook, setQuickLook] = useState(null)
|
|
358
399
|
const columnsRef = useRef(null)
|
|
359
400
|
const { folderView = 'columns', flat } = settings
|
|
401
|
+
/* PINNED SEARCH + `···` (item 15), below `md` only. Item 7 of the previous
|
|
402
|
+
* ticket hid the desktop control cluster and put nothing in its place, so
|
|
403
|
+
* SORT became unreachable on a phone; both references carry exactly these two
|
|
404
|
+
* controls above the list. Search is page state, not a setting — a query is
|
|
405
|
+
* something you are doing, not something you have configured. */
|
|
406
|
+
const [query, setQuery] = useState('')
|
|
407
|
+
|
|
408
|
+
/* Search filters the KEY SPACE, so the tree still navigates: a path survives
|
|
409
|
+
* when a file under it matches and ColumnBrowser's own partition does the
|
|
410
|
+
* rest. One view, rather than a flat results list nobody asked for. Sort
|
|
411
|
+
* orders the files the way the wall already sorts its own. */
|
|
412
|
+
const q = query.trim().toLowerCase()
|
|
413
|
+
const searched = useMemo(() => (q ? objects.filter((o) => o.key.toLowerCase().includes(q)) : objects), [objects, q])
|
|
414
|
+
const sortedObjects = useMemo(
|
|
415
|
+
() => sortObjects(searched, settings.sortBy, settings.sortDir),
|
|
416
|
+
[searched, settings.sortBy, settings.sortDir],
|
|
417
|
+
)
|
|
360
418
|
|
|
361
|
-
const scoped = prefix ?
|
|
419
|
+
const scoped = prefix ? sortedObjects.filter((o) => o.key.startsWith(prefix)) : sortedObjects
|
|
362
420
|
const { folders, files: dirFiles } = partition(scoped, prefix)
|
|
363
421
|
const keySet = new Set(objects.map((o) => o.key))
|
|
364
422
|
const levelFiles = flat ? scoped.map((o) => ({ ...o, displayKey: prefix ? o.key.slice(prefix.length) : o.key })) : dirFiles
|
|
@@ -474,6 +532,66 @@ export function MediaLibraryBrowse({
|
|
|
474
532
|
</div>
|
|
475
533
|
</div>
|
|
476
534
|
|
|
535
|
+
{/* PINNED ABOVE THE LIST, below `md` (item 15). Search sits here and not
|
|
536
|
+
inside the ContentFilters wall, because the wall is a DIFFERENT
|
|
537
|
+
SURFACE — on a phone it is a tab away, so a control living in it is
|
|
538
|
+
a control you cannot reach from the thing you are searching. */}
|
|
539
|
+
<div className="flex md:hidden items-center gap-2">
|
|
540
|
+
<SearchInput
|
|
541
|
+
value={query}
|
|
542
|
+
onChange={(e) => setQuery(e.target.value ?? '')}
|
|
543
|
+
onClear={() => setQuery('')}
|
|
544
|
+
placeholder="Search this bucket"
|
|
545
|
+
size="sm"
|
|
546
|
+
className="flex-1 min-w-0"
|
|
547
|
+
/>
|
|
548
|
+
{/* `···` — view mode and sort, the two the desktop cluster carries and
|
|
549
|
+
item 7 left with nowhere to go. `caret={false}`: an icon trigger is
|
|
550
|
+
already complete, and neither reference draws a chevron on it. */}
|
|
551
|
+
<MenuItem
|
|
552
|
+
label={<Icon name="more" size={16} />}
|
|
553
|
+
caret={false}
|
|
554
|
+
align="end"
|
|
555
|
+
buttonClassName="shrink-0 px-2"
|
|
556
|
+
>
|
|
557
|
+
{({ close }) => (
|
|
558
|
+
<div className="py-1 w-[200px]">
|
|
559
|
+
{STACK_VIEW_OPTIONS.map((opt) => (
|
|
560
|
+
<MenuDropdownItem
|
|
561
|
+
key={opt.value}
|
|
562
|
+
onClick={() => { setSettings({ ...settings, stackView: opt.value }); close() }}
|
|
563
|
+
shortcut={(settings.stackView ?? 'list') === opt.value ? <Icon name="check" size={11} /> : undefined}
|
|
564
|
+
>
|
|
565
|
+
{opt.label}
|
|
566
|
+
</MenuDropdownItem>
|
|
567
|
+
))}
|
|
568
|
+
<MenuDropdownDivider />
|
|
569
|
+
{SORT_OPTIONS.map((opt) => (
|
|
570
|
+
<MenuDropdownItem
|
|
571
|
+
key={opt.value}
|
|
572
|
+
/* tapping the ACTIVE key flips the direction, which is how
|
|
573
|
+
both references let you reverse without a second control */
|
|
574
|
+
onClick={() => {
|
|
575
|
+
const same = (settings.sortBy ?? 'name') === opt.value
|
|
576
|
+
setSettings({
|
|
577
|
+
...settings,
|
|
578
|
+
sortBy: opt.value,
|
|
579
|
+
sortDir: same && settings.sortDir !== 'desc' ? 'desc' : 'asc',
|
|
580
|
+
})
|
|
581
|
+
close()
|
|
582
|
+
}}
|
|
583
|
+
shortcut={(settings.sortBy ?? 'name') === opt.value
|
|
584
|
+
? <Icon name={settings.sortDir === 'desc' ? 'arrow-up' : 'arrow-down'} size={11} />
|
|
585
|
+
: undefined}
|
|
586
|
+
>
|
|
587
|
+
{opt.label}
|
|
588
|
+
</MenuDropdownItem>
|
|
589
|
+
))}
|
|
590
|
+
</div>
|
|
591
|
+
)}
|
|
592
|
+
</MenuItem>
|
|
593
|
+
</div>
|
|
594
|
+
|
|
477
595
|
{quickLook && (
|
|
478
596
|
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={() => setQuickLook(null)} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet}
|
|
479
597
|
onPrev={() => setQuickLook((q) => ({ ...q, index: (q.index - 1 + q.files.length) % q.files.length }))}
|
|
@@ -483,6 +601,17 @@ export function MediaLibraryBrowse({
|
|
|
483
601
|
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
484
602
|
)}
|
|
485
603
|
|
|
604
|
+
{/* THE TAB PILL (item 16) — floats over the list, so the list owes it
|
|
605
|
+
room or its last row sits under the bar forever. Below `md` only;
|
|
606
|
+
above it the 2026-08-26 one-view ruling stands and nothing here
|
|
607
|
+
renders. */}
|
|
608
|
+
{tabs?.length > 0 && (
|
|
609
|
+
<>
|
|
610
|
+
<div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
|
|
611
|
+
<MobileTabBar tabs={tabs} value={activeTab} onChange={onTabChange} />
|
|
612
|
+
</>
|
|
613
|
+
)}
|
|
614
|
+
|
|
486
615
|
{folderView === 'columns' ? (
|
|
487
616
|
<div ref={columnsRef} className="relative">
|
|
488
617
|
<ColumnBrowser
|
|
@@ -492,7 +621,7 @@ export function MediaLibraryBrowse({
|
|
|
492
621
|
onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
|
|
493
622
|
columnWidths={settings.columnWidths}
|
|
494
623
|
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
495
|
-
objects={
|
|
624
|
+
objects={sortedObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
496
625
|
prefix={single ? prefix : (appRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
|
|
497
626
|
onPrefix={(v) => {
|
|
498
627
|
/* single: the browser's path IS the bucket path, no segment to strip */
|
|
@@ -514,7 +643,9 @@ export function MediaLibraryBrowse({
|
|
|
514
643
|
formatDate={formatDate}
|
|
515
644
|
thumbnailFor={thumbnailFor}
|
|
516
645
|
folderMeta={folderMeta}
|
|
517
|
-
|
|
646
|
+
/* the PROP wins when a consumer passes one; otherwise the `···`
|
|
647
|
+
owns it through settings (item 15) */
|
|
648
|
+
stackView={stackView ?? settings.stackView ?? 'list'}
|
|
518
649
|
renderPreview={(o) => {
|
|
519
650
|
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
520
651
|
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} />
|
|
@@ -558,6 +689,9 @@ export function MediaLibraryBrowse({
|
|
|
558
689
|
export function MediaLibraryLibrary({
|
|
559
690
|
client, title = 'MEDIA', bucket, onBucketChange, prefix = '', defaults, settings: settingsProp, onSettingsChange,
|
|
560
691
|
headerActions, refreshKey, header = true, stats = true, settingsFooter, className = '',
|
|
692
|
+
/* the same pill the browse page takes — the wall is one of the surfaces it
|
|
693
|
+
switches between, so it has to carry it too (item 16) */
|
|
694
|
+
tabs, activeTab, onTabChange,
|
|
561
695
|
}) {
|
|
562
696
|
const [ownBucket, setOwnBucket] = useState(bucket)
|
|
563
697
|
const bucketId = bucket ?? ownBucket
|
|
@@ -798,6 +932,15 @@ export function MediaLibraryLibrary({
|
|
|
798
932
|
)
|
|
799
933
|
}}
|
|
800
934
|
/>
|
|
935
|
+
|
|
936
|
+
{/* the pill again — the wall is one of the surfaces it switches, so it
|
|
937
|
+
carries the same bar and owes it the same room (item 16) */}
|
|
938
|
+
{tabs?.length > 0 && (
|
|
939
|
+
<>
|
|
940
|
+
<div className="md:hidden" aria-hidden="true" style={{ height: TABBAR_H }} />
|
|
941
|
+
<MobileTabBar tabs={tabs} value={activeTab} onChange={onTabChange} />
|
|
942
|
+
</>
|
|
943
|
+
)}
|
|
801
944
|
</div>
|
|
802
945
|
)
|
|
803
946
|
}
|