@kolkrabbi/kol-component 0.240.0 → 0.242.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.242.0",
|
|
4
4
|
"description": "The core component library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,8 +26,8 @@
|
|
|
26
26
|
"embla-carousel-react": "^8.6.0",
|
|
27
27
|
"pdfjs-dist": "^6.3.289",
|
|
28
28
|
"react-syntax-highlighter": "^16.1.1",
|
|
29
|
-
"@kolkrabbi/kol-
|
|
30
|
-
"@kolkrabbi/kol-
|
|
29
|
+
"@kolkrabbi/kol-markdown": "^0.1.3",
|
|
30
|
+
"@kolkrabbi/kol-search": "^0.3.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@kolkrabbi/kol-icons": ">=0.22.0",
|
|
@@ -3,8 +3,8 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
3
3
|
import ActionButton from '../molecules/ActionButton.jsx'
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
5
5
|
import Divider from '../atoms/Divider.jsx'
|
|
6
|
+
import Dropdown from '../molecules/Dropdown.jsx'
|
|
6
7
|
import Input from '../atoms/Input.jsx'
|
|
7
|
-
import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
|
|
8
8
|
import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
|
|
9
9
|
import ViewToggle from '../atoms/ViewToggle.jsx'
|
|
10
10
|
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
@@ -12,6 +12,7 @@ import { Tooltip } from '../utilities/Popover.jsx'
|
|
|
12
12
|
import ContentCard from '../molecules/ContentCard.jsx'
|
|
13
13
|
import ContentRow from '../molecules/ContentRow.jsx'
|
|
14
14
|
import ContentFilters from './ContentFilters.jsx'
|
|
15
|
+
import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
|
|
15
16
|
import MediaViewer from './MediaViewer.jsx'
|
|
16
17
|
import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
|
|
17
18
|
import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
|
|
@@ -136,6 +137,8 @@ function kindOf(o) {
|
|
|
136
137
|
* every stream it held. A picker asks for what it can pick: `['image','video']`. */
|
|
137
138
|
function acceptsKind(accept) {
|
|
138
139
|
if (!accept || accept === 'all') return () => true
|
|
140
|
+
/* a function reads the object itself — a vector-only picker wants `.svg`, which is `image` by kind */
|
|
141
|
+
if (typeof accept === 'function') return accept
|
|
139
142
|
const wanted = new Set(Array.isArray(accept) ? accept : [accept])
|
|
140
143
|
return (o) => wanted.has(o.kind)
|
|
141
144
|
}
|
|
@@ -298,6 +301,14 @@ export function MediaLibraryProvider({
|
|
|
298
301
|
* from the flat view you just left. Derived, not an effect. */
|
|
299
302
|
const [paging, setPaging] = useState({ id: null, visible: 0 })
|
|
300
303
|
|
|
304
|
+
/* THE STORE (2026-10-07, design-editor's picker folded into this one): a client with
|
|
305
|
+
* `buckets()` lists one at a time and the modal's header switches it — the browse page's
|
|
306
|
+
* rule, "a client with none is one bucket and no dropdown". Switching lands at the root:
|
|
307
|
+
* a prefix is one bucket's path. */
|
|
308
|
+
const buckets = useMemo(() => client?.buckets?.() ?? [], [client])
|
|
309
|
+
const [bucket, setBucketState] = useState(() => buckets[0]?.id ?? null)
|
|
310
|
+
const setBucket = (id) => { setBucketState(id); setPrefix('') }
|
|
311
|
+
|
|
301
312
|
useEffect(() => {
|
|
302
313
|
if (!client) return undefined
|
|
303
314
|
let cancelled = false
|
|
@@ -305,12 +316,12 @@ export function MediaLibraryProvider({
|
|
|
305
316
|
setLoading(true)
|
|
306
317
|
setError(null)
|
|
307
318
|
client
|
|
308
|
-
.listMedia('', { signal: controller.signal })
|
|
319
|
+
.listMedia('', { signal: controller.signal, bucket: bucket ?? undefined })
|
|
309
320
|
.then((objs) => { if (!cancelled) setObjects(objs) })
|
|
310
321
|
.catch((e) => { if (!cancelled && e.name !== 'AbortError') setError(e.message) })
|
|
311
322
|
.finally(() => { if (!cancelled) setLoading(false) })
|
|
312
323
|
return () => { cancelled = true; controller.abort() }
|
|
313
|
-
}, [client])
|
|
324
|
+
}, [client, bucket])
|
|
314
325
|
|
|
315
326
|
/* click an inactive field → ascending; click the active one → flip */
|
|
316
327
|
const sortBy = (by) =>
|
|
@@ -344,6 +355,10 @@ export function MediaLibraryProvider({
|
|
|
344
355
|
const kindCounts = files.reduce((acc, o) => { acc[o.kind] = (acc[o.kind] || 0) + 1; return acc }, {})
|
|
345
356
|
const kindsPresent = Object.keys(kindCounts).sort().map((k) => ({ value: k, label: KIND_LABEL[k] ?? k, count: kindCounts[k] }))
|
|
346
357
|
|
|
358
|
+
/* `q` is the paging key's and the stats' view of the search — the engine swap (kol-search,
|
|
359
|
+
* 2026-09-29) dropped its definition and the modal threw on open; found 2026-10-07 when the
|
|
360
|
+
* editor's doors moved onto it, since no consumer had opened this variant in between. */
|
|
361
|
+
const q = search.trim().toLowerCase()
|
|
347
362
|
const filtered = filterMedia(files.filter((o) => kinds.size === 0 || kinds.has(o.kind)), search, { nameOf: (o) => o.displayKey })
|
|
348
363
|
const sorted = sortFiles(filtered, sort)
|
|
349
364
|
|
|
@@ -396,10 +411,13 @@ export function MediaLibraryProvider({
|
|
|
396
411
|
viewable,
|
|
397
412
|
loading,
|
|
398
413
|
error,
|
|
399
|
-
|
|
414
|
+
bucket,
|
|
415
|
+
buckets,
|
|
416
|
+
setBucket,
|
|
417
|
+
mediaUrl: client?.mediaUrl ? (key) => client.mediaUrl(key, bucket ?? undefined) : (key) => key,
|
|
400
418
|
proxied: client?.proxied ?? ((url) => url),
|
|
401
419
|
}
|
|
402
|
-
}, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client])
|
|
420
|
+
}, [objects, prefix, flat, sort, search, kinds, paging, pageSize, loading, error, accept, client, bucket, buckets])
|
|
403
421
|
|
|
404
422
|
return <MediaLibraryContext.Provider value={value}>{children}</MediaLibraryContext.Provider>
|
|
405
423
|
}
|
|
@@ -880,7 +898,7 @@ function PickerBody({ items, viewMode, onOpen, onPick }) {
|
|
|
880
898
|
/* The picker's chrome — ContentFilters owns the animated search, the kind
|
|
881
899
|
* filter, the view toggle and the N-of-M count; sort rides its header slot. */
|
|
882
900
|
function LibraryChrome({ onOpen, onPick }) {
|
|
883
|
-
const { folders, sorted, sort, sortBy } = useMediaLibrary()
|
|
901
|
+
const { folders, sorted, sort, sortBy, bucket, buckets, setBucket } = useMediaLibrary()
|
|
884
902
|
const [viewMode, setViewMode] = useState('grid')
|
|
885
903
|
|
|
886
904
|
const items = useMemo(
|
|
@@ -891,6 +909,11 @@ function LibraryChrome({ onOpen, onPick }) {
|
|
|
891
909
|
[folders, sorted],
|
|
892
910
|
)
|
|
893
911
|
const kinds = useMemo(() => [...new Set(sorted.map((o) => o.kind))].sort(), [sorted])
|
|
912
|
+
/* THE STORE — the header at `md+`, a row above the list on a phone. `cls` on the control rather
|
|
913
|
+
* than a wrapper: a `display: contents` wrapper lost to the phone hide in one host. */
|
|
914
|
+
const store = (cls = '') => buckets.length > 1 && (
|
|
915
|
+
<Dropdown size="sm" className={`min-w-0 max-w-[45vw] md:w-48 md:max-w-none ${cls}`.trim()} options={buckets.map((b) => ({ value: b.id, label: b.label }))} value={bucket} onChange={setBucket} aria-label="Store" />
|
|
916
|
+
)
|
|
894
917
|
|
|
895
918
|
return (
|
|
896
919
|
<>
|
|
@@ -900,16 +923,40 @@ function LibraryChrome({ onOpen, onPick }) {
|
|
|
900
923
|
titleIcon="folder"
|
|
901
924
|
totalCount={items.length}
|
|
902
925
|
searchKeys={['name']}
|
|
903
|
-
viewMode={viewMode}
|
|
904
|
-
onViewModeChange={setViewMode}
|
|
905
|
-
viewModeOptions={VIEW_OPTIONS}
|
|
906
926
|
mutuallyExclusiveFilters={['kind']}
|
|
907
927
|
filterGroups={[{ label: 'Kind', key: 'kind', values: ['folder', ...kinds] }]}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
928
|
+
/* THE WALL'S ROW (2026-10-08, one header for the family): the view pair as icons on the row's
|
|
929
|
+
* trailing slot, the sort as the row below at `md+` — what `MediaLibraryLibrary` draws — and
|
|
930
|
+
* below `md` both fold into one `···`, the browse page's fold. It wore GRID · LIST as text and
|
|
931
|
+
* four sort cells in the header. The store (when the client has more than one) rides the
|
|
932
|
+
* header at `md+` and is a row above the list on a phone. Decided on the recommendation. */
|
|
933
|
+
headerActions={store('max-md:hidden')}
|
|
934
|
+
trailingActions={(
|
|
935
|
+
<>
|
|
936
|
+
<div className="hidden md:flex items-center gap-6"><ViewToggle viewMode={viewMode} onViewChange={setViewMode} variant="icon" options={VIEW_OPTIONS} /></div>
|
|
937
|
+
<div className="md:hidden">
|
|
938
|
+
<MenuItem label={<Icon name="more" size={16} />} caret={false} align="end" size="sm" buttonClassName="shrink-0 px-2">
|
|
939
|
+
{({ close }) => (
|
|
940
|
+
<div className="py-1 w-[200px]">
|
|
941
|
+
{VIEW_OPTIONS.map((opt) => (
|
|
942
|
+
<MenuDropdownItem key={opt.value} onClick={() => { setViewMode(opt.value); close() }} shortcut={viewMode === opt.value ? <Icon name="check" size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
|
|
943
|
+
))}
|
|
944
|
+
<MenuDropdownDivider />
|
|
945
|
+
{SORT_OPTIONS.map((opt) => (
|
|
946
|
+
<MenuDropdownItem key={opt.value} onClick={() => { sortBy(opt.value); close() }} shortcut={sort.by === opt.value ? <Icon name={sort.dir === 'desc' ? 'arrow-down' : 'arrow-up'} size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
|
|
947
|
+
))}
|
|
948
|
+
</div>
|
|
949
|
+
)}
|
|
950
|
+
</MenuItem>
|
|
951
|
+
</div>
|
|
952
|
+
</>
|
|
953
|
+
)}
|
|
954
|
+
belowActions={<div className="h-8 hidden md:flex items-center"><SortControls /></div>}
|
|
955
|
+
renderItem={(filtered) => (
|
|
956
|
+
<>
|
|
957
|
+
{buckets.length > 1 && <div className="md:hidden flex items-center gap-3 flex-wrap" style={{ marginBottom: 'var(--kol-spacing-3)' }}>{store()}</div>}
|
|
958
|
+
<PickerBody items={filtered} viewMode={viewMode} onOpen={onOpen} onPick={onPick} />
|
|
959
|
+
</>
|
|
913
960
|
)}
|
|
914
961
|
/>
|
|
915
962
|
<PathBar />
|
|
@@ -931,7 +978,9 @@ function PickerShell({ onClose, onPick }) {
|
|
|
931
978
|
{/* While the viewer is up it owns Escape — handing the picker a no-op
|
|
932
979
|
* close means one keypress steps back one level instead of exiting the
|
|
933
980
|
* whole picker, which is the behaviour the fxr fork hand-rolled. */}
|
|
934
|
-
|
|
981
|
+
{/* `scrim` — a modal OVER the page it was opened from, not an opaque surface in its place
|
|
982
|
+
* (user, 2026-10-07, on labs: "its not overlay, its complete full screen") */}
|
|
983
|
+
<FullscreenOverlay open scrim onClose={viewerIndex === null ? onClose : () => {}}>
|
|
935
984
|
<div className="kol-media-picker">
|
|
936
985
|
<LibraryChrome onOpen={setViewerIndex} onPick={pick} />
|
|
937
986
|
</div>
|
|
@@ -1077,6 +1077,10 @@ export function MediaLibraryBrowse({
|
|
|
1077
1077
|
/* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
|
|
1078
1078
|
* Absent, a one-bucket consumer collapses the level as ruled 2026-09-03. */
|
|
1079
1079
|
bucketLevel = false,
|
|
1080
|
+
/* `onPickFile(o | null)` — the selected file, reported as it changes (picker A, 2026-10-08: the
|
|
1081
|
+
* browse surface inside the modal card needs the selection for its Use button). Keys are
|
|
1082
|
+
* bucket-relative. Absent, nothing. */
|
|
1083
|
+
onPickFile,
|
|
1080
1084
|
}) {
|
|
1081
1085
|
const [ownPrefix, setOwnPrefix] = useState('')
|
|
1082
1086
|
const prefix = prefixProp ?? ownPrefix
|
|
@@ -1090,6 +1094,7 @@ export function MediaLibraryBrowse({
|
|
|
1090
1094
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
1091
1095
|
const [trashOpen, setTrashOpen] = useState(false)
|
|
1092
1096
|
const [pickedFile, setPickedFile] = useState(null)
|
|
1097
|
+
useEffect(() => { onPickFile?.(pickedFile) }, [pickedFile]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1093
1098
|
/* ROW VIEW PICKS, and only one at a time: a click selects and fills the preview, a DOUBLE click
|
|
1094
1099
|
* opens (user 2026-09-22, Finder's behaviour — "clicking shouldnt automatically open it"). The
|
|
1095
1100
|
* chevron still expands in place, which is the third thing and always was. */
|
|
@@ -2600,8 +2605,12 @@ export function MediaLibraryBrowse({
|
|
|
2600
2605
|
|
|
2601
2606
|
{/* ONE MENU FOR EVERY ROW. `useContextMenu` carries the payload from whichever row opened
|
|
2602
2607
|
* it, so this is a single instance rather than one per row, and the entries are built from
|
|
2603
|
-
* what `fileActions` actually supplies
|
|
2604
|
-
|
|
2608
|
+
* what `fileActions` actually supplies. IT MOUNTS ON A READ-ONLY BUCKET TOO (kol-website,
|
|
2609
|
+
* 2026-10-08: on B2 "I cant right click to get context modal … cant download assets"): Copy URL
|
|
2610
|
+
* and Download are read verbs — the client's URLs — and gating the whole menu on `canWrite`
|
|
2611
|
+
* took them away with the write verbs. `fa` is the write seam: empty when the bucket cannot
|
|
2612
|
+
* be written, so every write verb below folds away and the read verbs stay. */}
|
|
2613
|
+
{(() => { const fa = canWrite ? fileActions : {}; return (
|
|
2605
2614
|
<ContextMenu menu={menu}>
|
|
2606
2615
|
{(target) => {
|
|
2607
2616
|
if (!target) return null
|
|
@@ -2611,7 +2620,7 @@ export function MediaLibraryBrowse({
|
|
|
2611
2620
|
* not one of them — renaming five things to one name is not a thing — so it stays
|
|
2612
2621
|
* single and disappears from a multi-selection rather than lying about what it does. */
|
|
2613
2622
|
const many = (target.targets ?? []).length > 1 ? target.targets : null
|
|
2614
|
-
const custom = (
|
|
2623
|
+
const custom = (fa.items ?? []).filter((it) => !it.when || it.when(target))
|
|
2615
2624
|
const customItems = custom.length > 0 && (
|
|
2616
2625
|
<>
|
|
2617
2626
|
<MenuDropdownDivider />
|
|
@@ -2625,7 +2634,7 @@ export function MediaLibraryBrowse({
|
|
|
2625
2634
|
<>
|
|
2626
2635
|
<MenuDropdownItem disabled>{many.length} selected</MenuDropdownItem>
|
|
2627
2636
|
<MenuDropdownDivider />
|
|
2628
|
-
{
|
|
2637
|
+
{fa.move && (
|
|
2629
2638
|
<MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doBatch(many, 'move', 'Move')}>Move {many.length} to…</MenuDropdownItem>
|
|
2630
2639
|
)}
|
|
2631
2640
|
{many.every((p) => !p.endsWith('/')) && (
|
|
@@ -2635,7 +2644,7 @@ export function MediaLibraryBrowse({
|
|
|
2635
2644
|
{canTag && <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags(many)}>Add tags to {many.length}…</MenuDropdownItem>}
|
|
2636
2645
|
</>
|
|
2637
2646
|
)}
|
|
2638
|
-
{
|
|
2647
|
+
{fa.remove && (
|
|
2639
2648
|
<MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doBatch(many, 'remove', 'Delete')}>Delete {many.length}</MenuDropdownItem>
|
|
2640
2649
|
)}
|
|
2641
2650
|
{customItems}
|
|
@@ -2644,7 +2653,7 @@ export function MediaLibraryBrowse({
|
|
|
2644
2653
|
}
|
|
2645
2654
|
return (
|
|
2646
2655
|
<>
|
|
2647
|
-
{
|
|
2656
|
+
{fa.createFolder && (
|
|
2648
2657
|
<MenuDropdownItem iconLeft={<Icon name="folder" size={14} />} onClick={() => doCreateFolder(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2649
2658
|
New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
|
|
2650
2659
|
</MenuDropdownItem>
|
|
@@ -2655,7 +2664,7 @@ export function MediaLibraryBrowse({
|
|
|
2655
2664
|
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => newDocument(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2656
2665
|
New document…
|
|
2657
2666
|
</MenuDropdownItem>
|
|
2658
|
-
) :
|
|
2667
|
+
) : fa.createFile && (
|
|
2659
2668
|
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2660
2669
|
New file
|
|
2661
2670
|
</MenuDropdownItem>
|
|
@@ -2666,10 +2675,10 @@ export function MediaLibraryBrowse({
|
|
|
2666
2675
|
</MenuDropdownItem>
|
|
2667
2676
|
)}
|
|
2668
2677
|
{!isLevel && <MenuDropdownDivider />}
|
|
2669
|
-
{!isLevel &&
|
|
2678
|
+
{!isLevel && fa.rename && (
|
|
2670
2679
|
<MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => doRename(target.path, isFolder)}>Rename</MenuDropdownItem>
|
|
2671
2680
|
)}
|
|
2672
|
-
{!isLevel &&
|
|
2681
|
+
{!isLevel && fa.move && (
|
|
2673
2682
|
<MenuDropdownItem iconLeft={<Icon name="arrow-right" size={14} />} onClick={() => doMove(target.path)}>Move to…</MenuDropdownItem>
|
|
2674
2683
|
)}
|
|
2675
2684
|
{/* A FOLDER GETS COPY URL TOO (user 2026-09-23) — through the SAME `mediaUrl`
|
|
@@ -2693,7 +2702,7 @@ export function MediaLibraryBrowse({
|
|
|
2693
2702
|
{target.type === 'file' && canEdit && editKindOf(target.o ?? { key: target.path }) && (
|
|
2694
2703
|
<MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => openEditor(objects.find((o) => o.key === target.path) ?? { key: target.path })}>Edit</MenuDropdownItem>
|
|
2695
2704
|
)}
|
|
2696
|
-
{!isLevel &&
|
|
2705
|
+
{!isLevel && fa.remove && (
|
|
2697
2706
|
<>
|
|
2698
2707
|
<MenuDropdownDivider />
|
|
2699
2708
|
<MenuDropdownItem iconLeft={<Icon name="trash" size={14} />} onClick={() => doDelete(target.path, isFolder)}>Delete</MenuDropdownItem>
|
|
@@ -2704,7 +2713,7 @@ export function MediaLibraryBrowse({
|
|
|
2704
2713
|
)
|
|
2705
2714
|
}}
|
|
2706
2715
|
</ContextMenu>
|
|
2707
|
-
)}
|
|
2716
|
+
) })()}
|
|
2708
2717
|
{busyAction && <span className="sr-only" role="status">Working…</span>}
|
|
2709
2718
|
|
|
2710
2719
|
{/* THE TAB PILL (item 16) — floats over the list, so the list owes it
|
|
@@ -3112,8 +3121,31 @@ export function MediaLibraryLibrary({
|
|
|
3112
3121
|
layoutOptions={layoutOptions}
|
|
3113
3122
|
layout={layout}
|
|
3114
3123
|
onLayoutChange={setLayout}
|
|
3115
|
-
|
|
3116
|
-
|
|
3124
|
+
/* BELOW `md` THE VIEWS AND THE SORT FOLD INTO ONE `···` (2026-10-08, apps/media/library at 390:
|
|
3125
|
+
* the FILES row, the three view icons and NAME · DATE · SIZE · KIND stacked three rows over the
|
|
3126
|
+
* list) — the browse page's fold, item 15, so the two pages fold the same way. Decided on the
|
|
3127
|
+
* recommendation, for review. */
|
|
3128
|
+
trailingActions={(
|
|
3129
|
+
<>
|
|
3130
|
+
<div className="hidden md:flex items-center gap-6"><ViewToggle viewMode={layout} onViewChange={setLayout} variant="icon" options={LAYOUT_OPTIONS} /></div>
|
|
3131
|
+
<div className="md:hidden">
|
|
3132
|
+
<MenuItem label={<Icon name="more" size={16} />} caret={false} align="end" size="sm" buttonClassName="shrink-0 px-2">
|
|
3133
|
+
{({ close }) => (
|
|
3134
|
+
<div className="py-1 w-[200px]">
|
|
3135
|
+
{LAYOUT_OPTIONS.map((opt) => (
|
|
3136
|
+
<MenuDropdownItem key={opt.value} onClick={() => { setLayout(opt.value); close() }} shortcut={layout === opt.value ? <Icon name="check" size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
|
|
3137
|
+
))}
|
|
3138
|
+
<MenuDropdownDivider />
|
|
3139
|
+
{SORT_OPTIONS.map((opt) => (
|
|
3140
|
+
<MenuDropdownItem key={opt.value} onClick={() => { handleSort(opt.value); close() }} shortcut={sortBy === opt.value ? <Icon name={sortDir === 'desc' ? 'arrow-down' : 'arrow-up'} size={11} /> : undefined}>{opt.label}</MenuDropdownItem>
|
|
3141
|
+
))}
|
|
3142
|
+
</div>
|
|
3143
|
+
)}
|
|
3144
|
+
</MenuItem>
|
|
3145
|
+
</div>
|
|
3146
|
+
</>
|
|
3147
|
+
)}
|
|
3148
|
+
belowActions={layout === 'off' ? null : <div className="h-8 hidden md:flex items-center"><SortControls options={SORT_OPTIONS} sortBy={sortBy} sortDir={sortDir} onSort={handleSort} /></div>}
|
|
3117
3149
|
/* ONE WALL BODY, shared with the merged surface (2026-09-22) */
|
|
3118
3150
|
renderItem={(filtered) => (
|
|
3119
3151
|
<WallBody files={filtered} layout={layout} sortBy={sortBy} sortDir={sortDir} pageSize={pageSize} listId={listId}
|
|
@@ -25,9 +25,12 @@ export default function MediaTileGallery({ items = [], layout = 'stack', cols =
|
|
|
25
25
|
|
|
26
26
|
return (
|
|
27
27
|
<>
|
|
28
|
+
{/* `cols` is the count from `sm` up; below it the grid is two across (08-breakpoints rule 6 —
|
|
29
|
+
* grid collapse is canonical, first break sm). The count rides a variable so the theme's one
|
|
30
|
+
* phone rule can override it: an inline `gridTemplateColumns` could not be out-ranked. */}
|
|
28
31
|
<div
|
|
29
|
-
className={isGrid ? 'grid gap-4' : 'flex flex-col gap-4'}
|
|
30
|
-
style={isGrid ? {
|
|
32
|
+
className={isGrid ? 'kol-media-tiles grid gap-4' : 'flex flex-col gap-4'}
|
|
33
|
+
style={isGrid ? { '--kol-media-tiles-cols': cols } : undefined}
|
|
31
34
|
>
|
|
32
35
|
{items.map((item, i) => (
|
|
33
36
|
<figure key={i} className="min-w-0">
|
|
@@ -66,8 +66,13 @@ function ViewerStage({ media, index, onIndexChange }) {
|
|
|
66
66
|
return () => document.removeEventListener('keydown', onKey)
|
|
67
67
|
}, [emblaApi])
|
|
68
68
|
|
|
69
|
+
/* THE STAGE SETS ITS OWN WIDTH (2026-10-08, apps/media/viewer): FullscreenOverlay's sheet hugs
|
|
70
|
+
* its content, and the overlay is a grid whose one track is sized by that content — so `w-full`
|
|
71
|
+
* here resolved against nothing, the embla track grew to every slide side by side (six slides,
|
|
72
|
+
* 3888px at 1440) and the stage showed blank with the first image 1.6k off the left edge. The
|
|
73
|
+
* viewport minus the overlay's 24px lane each side is the width a fullscreen stage means. */
|
|
69
74
|
return (
|
|
70
|
-
<div className="relative w-
|
|
75
|
+
<div className="relative w-[calc(100vw-3rem)]">
|
|
71
76
|
<div className="overflow-hidden" ref={emblaRef}>
|
|
72
77
|
<div className="flex items-center">
|
|
73
78
|
{media.map((item, i) => (
|
|
@@ -122,8 +127,15 @@ export default function MediaViewer({ open, media = [], index = 0, onIndexChange
|
|
|
122
127
|
const actionRow = typeof actions === 'function' ? actions(media[index], index) : actions
|
|
123
128
|
|
|
124
129
|
return (
|
|
125
|
-
|
|
130
|
+
/* `scrim` (2026-10-08, apps/media/viewer): without it the overlay painted its opaque light
|
|
131
|
+
* surface, and the viewer's inverse ink — caption, the paging chips — drew light on light and
|
|
132
|
+
* read as "an image and nothing else". The chips (md and up) and the caption were always there. */
|
|
133
|
+
<FullscreenOverlay open scrim onClose={onClose}>
|
|
126
134
|
<ViewerStage media={media} index={index} onIndexChange={onIndexChange} />
|
|
135
|
+
{/* on a phone the chips are off and a swipe pages — the position says there is more */}
|
|
136
|
+
{media.length > 1 && (
|
|
137
|
+
<div className="md:hidden mt-3 text-center kol-helper-12 text-fg-inverse-64" aria-live="polite">{index + 1} / {media.length}</div>
|
|
138
|
+
)}
|
|
127
139
|
{actionRow && (
|
|
128
140
|
<div className="mt-4 flex items-center justify-center gap-2">{actionRow}</div>
|
|
129
141
|
)}
|