@kolkrabbi/kol-component 0.217.0 → 0.219.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/hooks/useLongPress.js +41 -0
- package/src/index.js +2 -0
- package/src/molecules/KindPreview.jsx +7 -2
- package/src/molecules/MediaTile.jsx +4 -2
- package/src/molecules/RowMenuButton.jsx +20 -0
- package/src/organisms/ColumnBrowser.jsx +17 -4
- package/src/organisms/MediaLibraryPages.jsx +55 -7
- package/src/utilities/ContextMenu.jsx +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.219.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",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useLongPress — the touch half of right-click. iOS never fires `contextmenu` from a held press,
|
|
5
|
+
* so a container spreads these props and a held touch dispatches that event itself, at the finger,
|
|
6
|
+
* on whatever was pressed. Every `onContextMenu` inside — and the payload it builds — works
|
|
7
|
+
* unchanged, with no per-row wiring.
|
|
8
|
+
*
|
|
9
|
+
* const press = useLongPress()
|
|
10
|
+
* <div {...press}> …rows and tiles with onContextMenu… </div>
|
|
11
|
+
*
|
|
12
|
+
* Touch only (a mouse and a pen have a right button). It acts only when a handler took the event
|
|
13
|
+
* (`preventDefault`, which is what `useContextMenu().openAt` does): a held press on anything else
|
|
14
|
+
* stays a press, and its click is not swallowed. When one did, the click that follows the lift is
|
|
15
|
+
* swallowed so the row underneath does not also select or open. Moving past `slop` px is a scroll
|
|
16
|
+
* or a drag and cancels it. A trusted `contextmenu` after ours (Android fires one) is dropped.
|
|
17
|
+
*/
|
|
18
|
+
export default function useLongPress({ ms = 450, slop = 10 } = {}) {
|
|
19
|
+
const s = useRef({ timer: 0, x: 0, y: 0, target: null, fired: false })
|
|
20
|
+
const cancel = () => { clearTimeout(s.current.timer); s.current.timer = 0 }
|
|
21
|
+
return {
|
|
22
|
+
/* iOS draws its own callout over a held image — the thumbnails are images */
|
|
23
|
+
style: { WebkitTouchCallout: 'none' },
|
|
24
|
+
onPointerDown: (e) => {
|
|
25
|
+
const t = s.current
|
|
26
|
+
cancel()
|
|
27
|
+
t.fired = false
|
|
28
|
+
if (e.pointerType !== 'touch' || !e.isPrimary || e.target.closest('input,textarea,select')) return
|
|
29
|
+
t.x = e.clientX; t.y = e.clientY; t.target = e.target
|
|
30
|
+
t.timer = setTimeout(() => {
|
|
31
|
+
t.timer = 0
|
|
32
|
+
t.fired = !t.target.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: t.x, clientY: t.y, button: 2 }))
|
|
33
|
+
}, ms)
|
|
34
|
+
},
|
|
35
|
+
onPointerMove: (e) => { const t = s.current; if (t.timer && Math.hypot(e.clientX - t.x, e.clientY - t.y) > slop) cancel() },
|
|
36
|
+
onPointerUp: cancel,
|
|
37
|
+
onPointerCancel: cancel,
|
|
38
|
+
onClickCapture: (e) => { if (s.current.fired) { s.current.fired = false; e.preventDefault(); e.stopPropagation() } },
|
|
39
|
+
onContextMenuCapture: (e) => { if (s.current.fired && e.nativeEvent.isTrusted) { e.preventDefault(); e.stopPropagation() } },
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.js
CHANGED
|
@@ -183,6 +183,7 @@ export { default as PlaybackBar } from './molecules/PlaybackBar.jsx'
|
|
|
183
183
|
export { default as AudioSheet } from './molecules/AudioSheet.jsx'
|
|
184
184
|
export { default as QuickLookFrame } from './molecules/QuickLookFrame.jsx'
|
|
185
185
|
export { default as MediaTile } from './molecules/MediaTile.jsx'
|
|
186
|
+
export { default as RowMenuButton } from './molecules/RowMenuButton.jsx'
|
|
186
187
|
export { default as DocPage } from './molecules/DocPage.jsx'
|
|
187
188
|
export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
|
|
188
189
|
export { default as ProfileCard } from './molecules/ProfileCard.jsx'
|
|
@@ -221,6 +222,7 @@ export { default as useScrollSpy } from './hooks/useScrollSpy.js'
|
|
|
221
222
|
export { default as useTilt } from './hooks/useTilt.js'
|
|
222
223
|
export { default as usePlayback } from './hooks/usePlayback.js'
|
|
223
224
|
export { default as useCoarsePointer } from './hooks/useCoarsePointer.js'
|
|
225
|
+
export { default as useLongPress } from './hooks/useLongPress.js'
|
|
224
226
|
export { default as useInViewAttention } from './hooks/useInViewAttention.js'
|
|
225
227
|
export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
|
|
226
228
|
export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
|
|
@@ -182,11 +182,16 @@ export default function KindPreview({
|
|
|
182
182
|
if (kind === 'video') return <VideoTile src={url} poster={poster} />
|
|
183
183
|
if (kind === 'audio') return <AudioTile src={url} fallback={icon} />
|
|
184
184
|
if (isText) {
|
|
185
|
+
/* A THUMBNAIL IS A PICTURE of the document, not the document: a README's links stayed live
|
|
186
|
+
* inside a grid tile, and tapping the tile navigated the app away. `inert` takes the whole page
|
|
187
|
+
* out of hit-testing and the tab order, so the tap lands on the tile. The pane and the sheet
|
|
188
|
+
* are for reading, and keep their links. */
|
|
189
|
+
const picture = (node) => (fit === 'tile' || fit === 'thumb' ? <div inert className="contents">{node}</div> : node)
|
|
185
190
|
if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
|
|
186
191
|
if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
|
|
187
192
|
if (kind === 'markdown') {
|
|
188
193
|
const meta = parseFrontmatter(text)
|
|
189
|
-
return (
|
|
194
|
+
return picture(
|
|
190
195
|
<DocPage frontmatter={Object.keys(meta).length ? meta : null}>
|
|
191
196
|
<div className="kol-prose" dangerouslySetInnerHTML={{ __html: markdownToHtml(text) }} />
|
|
192
197
|
{truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
|
|
@@ -194,7 +199,7 @@ export default function KindPreview({
|
|
|
194
199
|
)
|
|
195
200
|
}
|
|
196
201
|
const language = kind === 'json' ? 'json' : kind === 'yaml' ? 'yaml' : LANG[ext] || 'text'
|
|
197
|
-
return (
|
|
202
|
+
return picture(
|
|
198
203
|
<DocPage>
|
|
199
204
|
<CodeBlock code={text} language={language} filename={name} copy={false} />
|
|
200
205
|
{truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/* taxonomy-ok: molecule — lays out a caller's preview node and a name;
|
|
1
|
+
/* taxonomy-ok: molecule — lays out a caller's preview node and a name; its one DS part is the touch `···`. */
|
|
2
|
+
import RowMenuButton from './RowMenuButton.jsx'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* MediaTile — one file in the grid view, Finder's icon view (user 2026-09-23: *"skip the container
|
|
@@ -18,12 +19,13 @@
|
|
|
18
19
|
*/
|
|
19
20
|
export default function MediaTile({ preview, name, selected = false, markKey, onClick, onDoubleClick, onContextMenu, className = '' }) {
|
|
20
21
|
return (
|
|
21
|
-
<div className={`kol-media-tile-item ${className}`.trim()} data-selected={selected || undefined} data-marquee-key={markKey}
|
|
22
|
+
<div className={`kol-media-tile-item relative ${className}`.trim()} data-selected={selected || undefined} data-marquee-key={markKey}
|
|
22
23
|
role="button" tabIndex={0} aria-pressed={selected}
|
|
23
24
|
onClick={onClick} onDoubleClick={onDoubleClick} onContextMenu={onContextMenu}
|
|
24
25
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onDoubleClick?.(e) } }}>
|
|
25
26
|
<div className="kol-media-tile-thumb">{preview}</div>
|
|
26
27
|
<span className="kol-media-tile-name">{name}</span>
|
|
28
|
+
<RowMenuButton variant="grey" onOpen={onContextMenu} className="absolute top-1 right-1" />
|
|
27
29
|
</div>
|
|
28
30
|
)
|
|
29
31
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/* taxonomy-ok: molecule — one Button behind the coarse-pointer gate; the menu it opens is the caller's. */
|
|
2
|
+
import Button from '../atoms/Button.jsx'
|
|
3
|
+
import useCoarsePointer from '../hooks/useCoarsePointer.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RowMenuButton — the `···` that stands in for right-click on a touch device (media D-touch,
|
|
7
|
+
* 2026-09-23). A finger has no right button, and a held press (`useLongPress`) is invisible until
|
|
8
|
+
* you know it, so on `pointer: coarse` a row or tile that has a context menu wears this. It calls
|
|
9
|
+
* the SAME handler right-click calls, with the tap as the event, so the payload, the selection
|
|
10
|
+
* rules and the menu are the row's own; `openAt` stops the tap, so the row underneath does not
|
|
11
|
+
* also select or open. Renders nothing on a fine pointer, or without a handler.
|
|
12
|
+
*
|
|
13
|
+
* @param {(e: Event) => void} onOpen the row's `onContextMenu`
|
|
14
|
+
* @param {string} variant `ghost` on a row; `grey` — an opaque plate — over a picture
|
|
15
|
+
*/
|
|
16
|
+
export default function RowMenuButton({ onOpen, variant = 'ghost', className = '' }) {
|
|
17
|
+
const coarse = useCoarsePointer()
|
|
18
|
+
if (!coarse || !onOpen) return null
|
|
19
|
+
return <Button variant={variant} size="md" iconOnly="more" aria-label="More actions" className={`shrink-0 ${className}`.trim()} onClick={onOpen} />
|
|
20
|
+
}
|
|
@@ -2,6 +2,7 @@ import { Fragment, useEffect, useRef, useState } from 'react'
|
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import KindPreview, { KIND_GLYPH } from '../molecules/KindPreview.jsx'
|
|
4
4
|
import FileIcon from '../atoms/FileIcon.jsx'
|
|
5
|
+
import RowMenuButton from '../molecules/RowMenuButton.jsx'
|
|
5
6
|
import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
6
7
|
import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
|
|
7
8
|
import useGrabEdge from '../hooks/useGrabEdge.js'
|
|
@@ -342,6 +343,9 @@ function Row({
|
|
|
342
343
|
|
|
343
344
|
{/* ZONE 4 */}
|
|
344
345
|
{zones ? <span className="shrink-0 inline-flex justify-end" style={{ width: 20 }}>{trailing}</span> : trailing}
|
|
346
|
+
{/* on touch the row's menu has a face — see RowMenuButton; the negative margins keep the row's
|
|
347
|
+
height and inset what they were without it */}
|
|
348
|
+
<RowMenuButton onOpen={onContextMenu} className="-my-2 -mr-2" />
|
|
345
349
|
</li>
|
|
346
350
|
)
|
|
347
351
|
}
|
|
@@ -807,11 +811,14 @@ export default function ColumnBrowser({
|
|
|
807
811
|
{rows.filter((r) => r.kind !== 'empty').map((r) => {
|
|
808
812
|
const isFolder = r.kind === 'folder'
|
|
809
813
|
const o = r.o
|
|
814
|
+
const menu = onRowContextMenu && ((e) => onRowContextMenu(e, isFolder ? { type: 'folder', path: r.level + r.name } : { type: 'file', path: o.key, o }))
|
|
810
815
|
return (
|
|
816
|
+
/* the wrapper exists so the `···` is a SIBLING of the tile, not a button inside one */
|
|
817
|
+
<div key={r.key} className="relative min-w-0">
|
|
811
818
|
<button
|
|
812
|
-
key={r.key}
|
|
813
819
|
type="button"
|
|
814
|
-
className={`kol-column-browser-tile${!isFolder && shown?.key === o.key ? ' is-selected' : ''}`}
|
|
820
|
+
className={`kol-column-browser-tile w-full${!isFolder && shown?.key === o.key ? ' is-selected' : ''}`}
|
|
821
|
+
onContextMenu={menu}
|
|
815
822
|
onClick={() => {
|
|
816
823
|
if (isFolder) { onPrefix(r.level + r.name); return }
|
|
817
824
|
const files = itemsAt(r.level ?? '').filter((it) => it.type === 'file').map((it) => it.o)
|
|
@@ -830,6 +837,8 @@ export default function ColumnBrowser({
|
|
|
830
837
|
{isFolder ? folderMeta?.(r.level + r.name, 'grid') : (o.size != null ? formatSize(o.size) : '')}
|
|
831
838
|
</span>
|
|
832
839
|
</button>
|
|
840
|
+
<RowMenuButton variant="grey" onOpen={menu} className="absolute top-1 right-1" />
|
|
841
|
+
</div>
|
|
833
842
|
)
|
|
834
843
|
})}
|
|
835
844
|
</div>
|
|
@@ -858,6 +867,9 @@ export default function ColumnBrowser({
|
|
|
858
867
|
* level; past the indent cap it has nothing left to offer, so
|
|
859
868
|
* the row is the only way down */
|
|
860
869
|
onDisclose={r.depth < INDENT_CAP ? () => toggleExpanded(r.key) : undefined}
|
|
870
|
+
/* THE ROW'S OWN MENU. Only the <ul> carried one, so a menu opened on a phone row
|
|
871
|
+
* targeted the LEVEL — the rename and delete the row was asking for were not there. */
|
|
872
|
+
onContextMenu={onRowContextMenu && ((e) => onRowContextMenu(e, { type: 'folder', path: r.level + r.name }))}
|
|
861
873
|
onClick={() => onPrefix(r.level + r.name)}
|
|
862
874
|
/>
|
|
863
875
|
) : (
|
|
@@ -870,6 +882,7 @@ export default function ColumnBrowser({
|
|
|
870
882
|
indent={r.depth}
|
|
871
883
|
meta={metaOf(r.o)}
|
|
872
884
|
active={shown?.key === r.o.key}
|
|
885
|
+
onContextMenu={onRowContextMenu && ((e) => onRowContextMenu(e, { type: 'file', path: r.o.key, o: r.o }))}
|
|
873
886
|
/* A FILE IS A PUSH, NOT A PREVIEW PANE (item 6): there is no
|
|
874
887
|
* column for the preview to sit beside at this width, so the
|
|
875
888
|
* tap opens the full-screen inspector the consumer already
|
|
@@ -957,7 +970,7 @@ export default function ColumnBrowser({
|
|
|
957
970
|
active={f === activeFolder || isPicked(level + f)}
|
|
958
971
|
cursor={cursorActive && cursor.col === k && cursor.idx === i}
|
|
959
972
|
trailing={<Icon name="chevron-right" size={12} className="text-oq-32" />}
|
|
960
|
-
onContextMenu={(e) => onRowContextMenu
|
|
973
|
+
onContextMenu={onRowContextMenu && ((e) => onRowContextMenu(e, { type: 'folder', path: level + f }))}
|
|
961
974
|
drop={dragFor?.(level + f)}
|
|
962
975
|
dropFiles={onDropFiles ? (list) => onDropFiles(list, level + f) : undefined}
|
|
963
976
|
onClick={(e) => { if (onSelectClick?.(level + f, e, colKeys)) return; setCursor({ col: k, idx: i }); setCursorActive(true); rootRef.current?.focus(); pick(null); onPrefix(level + f) }}
|
|
@@ -971,7 +984,7 @@ export default function ColumnBrowser({
|
|
|
971
984
|
markKey={o.key}
|
|
972
985
|
active={shown?.key === o.key || isPicked(o.key)}
|
|
973
986
|
cursor={cursorActive && cursor.col === k && cursor.idx === folders.length + i}
|
|
974
|
-
onContextMenu={(e) => onRowContextMenu
|
|
987
|
+
onContextMenu={onRowContextMenu && ((e) => onRowContextMenu(e, { type: 'file', path: o.key, o }))}
|
|
975
988
|
drop={dragFor?.(o.key)}
|
|
976
989
|
onClick={(e) => { if (onSelectClick?.(o.key, e, colKeys)) return; setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
|
|
977
990
|
/>
|
|
@@ -12,6 +12,7 @@ import ViewToggle from '../atoms/ViewToggle.jsx'
|
|
|
12
12
|
import Dropdown from '../molecules/Dropdown.jsx'
|
|
13
13
|
import ContentCard from '../molecules/ContentCard.jsx'
|
|
14
14
|
import MediaTile from '../molecules/MediaTile.jsx'
|
|
15
|
+
import RowMenuButton from '../molecules/RowMenuButton.jsx'
|
|
15
16
|
import ContentRow from '../molecules/ContentRow.jsx'
|
|
16
17
|
import SortControls from '../molecules/SortControls.jsx'
|
|
17
18
|
import SearchInput from '../molecules/SearchInput.jsx'
|
|
@@ -20,6 +21,8 @@ import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/Me
|
|
|
20
21
|
import { Tooltip } from '../utilities/Popover.jsx'
|
|
21
22
|
import ContextMenu, { useContextMenu } from '../utilities/ContextMenu.jsx'
|
|
22
23
|
import useMarquee from '../hooks/useMarquee.js'
|
|
24
|
+
import useLongPress from '../hooks/useLongPress.js'
|
|
25
|
+
import useMediaQuery from '../hooks/useMediaQuery.js'
|
|
23
26
|
import KindPreview from '../molecules/KindPreview.jsx'
|
|
24
27
|
import AudioSheet from '../molecules/AudioSheet.jsx'
|
|
25
28
|
import VideoSheet from '../molecules/VideoSheet.jsx'
|
|
@@ -257,6 +260,7 @@ function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle
|
|
|
257
260
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{meta}</span>
|
|
258
261
|
<span className="kol-mono-12 text-fg-24 shrink-0 truncate" style={{ width: cols.size }}>—</span>
|
|
259
262
|
</>}
|
|
263
|
+
<RowMenuButton onOpen={onContextMenu} className="-my-1 -mr-1" />
|
|
260
264
|
</li>
|
|
261
265
|
)
|
|
262
266
|
}
|
|
@@ -331,6 +335,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
|
|
|
331
335
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
|
|
332
336
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
|
|
333
337
|
</>}
|
|
338
|
+
<RowMenuButton onOpen={onContextMenu} className="-my-1 -mr-1" />
|
|
334
339
|
</li>
|
|
335
340
|
)
|
|
336
341
|
}
|
|
@@ -844,6 +849,9 @@ export function MediaLibraryBrowse({
|
|
|
844
849
|
* the consumer's — a repo whose surfaces are routes wires its router here.
|
|
845
850
|
* No tabs, no pill, and the page is exactly what it was. */
|
|
846
851
|
tabs, activeTab, onTabChange,
|
|
852
|
+
/* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
|
|
853
|
+
* Absent, a one-bucket consumer collapses the level as ruled 2026-09-03. */
|
|
854
|
+
bucketLevel = false,
|
|
847
855
|
}) {
|
|
848
856
|
const [ownPrefix, setOwnPrefix] = useState('')
|
|
849
857
|
const prefix = prefixProp ?? ownPrefix
|
|
@@ -928,6 +936,8 @@ export function MediaLibraryBrowse({
|
|
|
928
936
|
* that does not gets the native ones, exactly as before (kol-client-olina 2026-09-22: the
|
|
929
937
|
* browser's own grey prompt box was the last non-DS surface on media.olina-productions.com). */
|
|
930
938
|
const menu = useContextMenu()
|
|
939
|
+
/* touch has no right button: a held press on a row or tile opens the same menu (useLongPress) */
|
|
940
|
+
const press = useLongPress()
|
|
931
941
|
const modal = useModal()
|
|
932
942
|
const canWrite = !!fileActions && writable
|
|
933
943
|
const [busyAction, setBusyAction] = useState(false)
|
|
@@ -1117,6 +1127,15 @@ export function MediaLibraryBrowse({
|
|
|
1117
1127
|
/* stepping into a folder drops the pick — the preview would otherwise describe something no
|
|
1118
1128
|
* longer in the list */
|
|
1119
1129
|
const goFolder = (path) => { setPickedFile(null); setPickedFolder(null); setPrefix(path) }
|
|
1130
|
+
/* ONE "GO UP" FOR ALL THREE VIEWS (kol-client-olina 2026-09-23: *"3 different stops for 3
|
|
1131
|
+
* modes … they should all go all the way to the shared root folder"*): parent folder → … → the
|
|
1132
|
+
* bucket's top → the title root, which is what `onHome` does. With one bucket and no
|
|
1133
|
+
* `bucketLevel` there is no level above the bucket's top, so that is where it ends. Columns walk
|
|
1134
|
+
* the same path with ArrowLeft. */
|
|
1135
|
+
const goUp = () => {
|
|
1136
|
+
if (prefix) { goFolder(dirOf(prefix)); return }
|
|
1137
|
+
if (!single && !appRoot) { setAppRoot(true); setPickedFile(null); setPickedFolder(null) }
|
|
1138
|
+
}
|
|
1120
1139
|
/* SPACE IS QUICK LOOK IN EVERY VIEW (user 2026-09-23: *"spacebar preview only works in column,
|
|
1121
1140
|
* fix that. CONSISTENCY"*). `ColumnBrowser` has owned that key since it shipped, so the columns
|
|
1122
1141
|
* had it and the two views built later did not. The target is whatever is picked — the previewed
|
|
@@ -1217,9 +1236,11 @@ export function MediaLibraryBrowse({
|
|
|
1217
1236
|
if (el?.tabIndex >= 0) el.focus({ preventScroll: true })
|
|
1218
1237
|
}
|
|
1219
1238
|
if (view === 'grid') {
|
|
1239
|
+
/* BEFORE the empty-list bail-out: a folder that holds only subfolders has an empty wall, and
|
|
1240
|
+
* ⌘↑ was swallowed there */
|
|
1241
|
+
if (e.metaKey && e.key === 'ArrowUp') { goUp(); return true }
|
|
1220
1242
|
const list = gridOrderRef.current
|
|
1221
1243
|
if (!list.length) return true
|
|
1222
|
-
if (e.metaKey && e.key === 'ArrowUp') { if (prefix) goFolder(dirOf(prefix)); return true }
|
|
1223
1244
|
const i = list.findIndex((f) => f.key === cur)
|
|
1224
1245
|
if (e.metaKey && e.key === 'ArrowDown') { if (i !== -1) openQuickLook({ files: list, index: i }); return true }
|
|
1225
1246
|
const box = gridMarquee.ref.current
|
|
@@ -1243,7 +1264,7 @@ export function MediaLibraryBrowse({
|
|
|
1243
1264
|
}
|
|
1244
1265
|
const isFolder = cur?.endsWith('/')
|
|
1245
1266
|
const open = isFolder && rowsExpanded.has(cur)
|
|
1246
|
-
if (e.metaKey && e.key === 'ArrowUp') { const up = cur ? dirOf(cur) : dirOf(prefix); if (up) select(up); return true }
|
|
1267
|
+
if (e.metaKey && e.key === 'ArrowUp') { const up = cur ? dirOf(cur) : dirOf(prefix); if (up) select(up); else goUp(); return true }
|
|
1247
1268
|
if (e.metaKey && e.key === 'ArrowDown') {
|
|
1248
1269
|
if (!cur) return true
|
|
1249
1270
|
if (isFolder) goFolder(cur)
|
|
@@ -1303,6 +1324,10 @@ export function MediaLibraryBrowse({
|
|
|
1303
1324
|
const view = stored === 'list' ? 'rows' : stored
|
|
1304
1325
|
const isWall = view === 'grid'
|
|
1305
1326
|
const folderView = view === 'rows' ? 'rows' : 'columns'
|
|
1327
|
+
/* BELOW `md` BOTH TREE VIEWS ARE THE PHONE LIST. `ColumnBrowser` already forks to its one-level
|
|
1328
|
+
* stack there; the row view had no phone form and drew the desktop list and its preview pane
|
|
1329
|
+
* squeezed into 390 — 35px of rows with no names. Same query, same breakpoint as the columns. */
|
|
1330
|
+
const phone = useMediaQuery('(max-width: 767px)')
|
|
1306
1331
|
const setView = (v) => {
|
|
1307
1332
|
onViewChange?.(v)
|
|
1308
1333
|
setSettings({
|
|
@@ -1395,7 +1420,7 @@ export function MediaLibraryBrowse({
|
|
|
1395
1420
|
* to reach a folder. The virtual root collapses to the empty string, which
|
|
1396
1421
|
* makes every `slice(VROOT.length)` below a no-op and leaves column 0 as the
|
|
1397
1422
|
* bucket's own folders. Multi-bucket browse is untouched. */
|
|
1398
|
-
const single = buckets.length <= 1
|
|
1423
|
+
const single = !bucketLevel && buckets.length <= 1
|
|
1399
1424
|
const ROOT = title
|
|
1400
1425
|
const label = bucketMeta.label || 'bucket'
|
|
1401
1426
|
const VROOT = single ? '' : `${ROOT}/${label}/`
|
|
@@ -1459,7 +1484,7 @@ export function MediaLibraryBrowse({
|
|
|
1459
1484
|
const treeBody = (keep) => {
|
|
1460
1485
|
const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
|
|
1461
1486
|
return (
|
|
1462
|
-
folderView === 'columns' ? (
|
|
1487
|
+
folderView === 'columns' || phone ? (
|
|
1463
1488
|
<div ref={columnsRef} className="relative">
|
|
1464
1489
|
<ColumnBrowser
|
|
1465
1490
|
autoFocus={autoFocus}
|
|
@@ -1648,8 +1673,18 @@ export function MediaLibraryBrowse({
|
|
|
1648
1673
|
<div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
|
|
1649
1674
|
<div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1650
1675
|
<div className={`flex-1 min-w-0 overflow-y-auto p-4${showGridPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
1676
|
+
/* THE CONTAINER IS A LEVEL, like a column (kol-client-olina 2026-09-23: *"context menu anywhere
|
|
1677
|
+
* 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 })}
|
|
1651
1679
|
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}>
|
|
1652
1680
|
<WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} marquee={gridMarquee} onSorted={(list) => { gridOrderRef.current = list }}
|
|
1681
|
+
/* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
|
|
1682
|
+
* 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
|
|
1683
|
+
* and no folders, as it was. */
|
|
1684
|
+
folders={settings.flat ? [] : folders.map((f) => prefix + f)}
|
|
1685
|
+
onOpenFolder={goFolder}
|
|
1686
|
+
onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
|
|
1687
|
+
onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
|
|
1653
1688
|
onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) }}
|
|
1654
1689
|
sortBy={settings.sortBy} sortDir={settings.sortDir}
|
|
1655
1690
|
pageSize={settings.pageSize} listId={`${prefix}|${view}|${settings.flat}|${refreshKey}|${bucketMeta.id}`}
|
|
@@ -1696,7 +1731,7 @@ export function MediaLibraryBrowse({
|
|
|
1696
1731
|
: treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
|
|
1697
1732
|
|
|
1698
1733
|
return (
|
|
1699
|
-
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
1734
|
+
<div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
1700
1735
|
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
|
|
1701
1736
|
onHome={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}
|
|
1702
1737
|
onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
|
|
@@ -1908,7 +1943,7 @@ export function MediaLibraryBrowse({
|
|
|
1908
1943
|
* this row 8px taller than the fill budget, so the page scrolled by that much */
|
|
1909
1944
|
<div className="flex items-center justify-between gap-4 h-4">
|
|
1910
1945
|
<p className="kol-mono-12 text-fg-48">
|
|
1911
|
-
{wallFiles.length} {wallFiles.length === 1 ? 'file' : 'files'} · {formatSize(wallFiles.reduce((n, o) => n + (o.size ?? 0), 0))}
|
|
1946
|
+
{!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))}
|
|
1912
1947
|
</p>
|
|
1913
1948
|
<input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
|
|
1914
1949
|
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
@@ -1921,7 +1956,11 @@ export function MediaLibraryBrowse({
|
|
|
1921
1956
|
<p className="kol-mono-12 text-fg-48">
|
|
1922
1957
|
{folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
|
|
1923
1958
|
{rawFiles.length} {rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
|
|
1959
|
+
{/* THE GREY TAIL TOTALS WHERE YOU STAND (kol-client-olina 2026-09-23): the bucket at the
|
|
1960
|
+
* root, the current folder recursively below it. Hidden when it would repeat the
|
|
1961
|
+
* bright part — a leaf has nothing beneath it to add. */}
|
|
1924
1962
|
{!prefix && rawFiles.length !== bucketFiles && <span className="text-fg-32">{' · bucket: '}{bucketFiles} files · {formatSize(bucketBytes)}</span>}
|
|
1963
|
+
{prefix && scoped.length !== levelFiles.length && <span className="text-fg-32">{` · in ${crumbs.at(-1)}: `}{scoped.length} files · {formatSize(scoped.reduce((n, o) => n + (o.size || 0), 0))}</span>}
|
|
1925
1964
|
{systemCount > 0 && <span className="text-fg-32">{' · '}{systemCount} system files hidden</span>}
|
|
1926
1965
|
</p>
|
|
1927
1966
|
)}
|
|
@@ -2099,6 +2138,8 @@ export function WallBody({
|
|
|
2099
2138
|
files, layout = 'list', sortBy = 'name', sortDir = 'asc', pageSize = 200, listId = '', cardMin = 260,
|
|
2100
2139
|
mediaUrl, downloadUrl, keySet, videoPreview, formatDate = defaultFormatDate,
|
|
2101
2140
|
selected, selectMode = false, onToggleSelect, onSorted, onContextMenu, onPick,
|
|
2141
|
+
/* the media grid's folder tiles — see the page's `wallPane` */
|
|
2142
|
+
folders = [], onOpenFolder, onPickFolder, onFolderContextMenu,
|
|
2102
2143
|
/* ONE INSPECTOR (user 2026-09-23). Given `onOpen`, the wall hands the open UP and keeps no
|
|
2103
2144
|
* lightbox of its own — otherwise a page that also opens one (space, a row double-click) ends up
|
|
2104
2145
|
* with two stacked overlays of the same file. The standalone wall passes nothing and keeps its
|
|
@@ -2154,7 +2195,7 @@ export function WallBody({
|
|
|
2154
2195
|
const openAt = (o, idx) => (onOpen ? onOpen(o, sorted) : setLightboxIndex(idx))
|
|
2155
2196
|
return (
|
|
2156
2197
|
<div className="flex flex-col gap-3">
|
|
2157
|
-
{sorted.length === 0 ? null : layout !== 'grid' ? (
|
|
2198
|
+
{sorted.length === 0 && !(layout === 'grid' && tiles === 'media' && folders.length) ? null : layout !== 'grid' ? (
|
|
2158
2199
|
<div className="flex flex-col">
|
|
2159
2200
|
{shown.map((o, idx) => (
|
|
2160
2201
|
<ContentRow key={o.key} variant="default" media={renderThumb(o, null)} title={renderName(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
|
|
@@ -2169,6 +2210,13 @@ export function WallBody({
|
|
|
2169
2210
|
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
|
|
2170
2211
|
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) onBackgroundClick?.() }}>
|
|
2171
2212
|
{marquee?.rect && <div className="kol-marquee" style={marquee.rect} />}
|
|
2213
|
+
{folders.map((path) => (
|
|
2214
|
+
<MediaTile key={path} markKey={path} preview={<Icon name="folder" size={56} className="text-oq-48" />}
|
|
2215
|
+
name={path.replace(/\/$/, '').split('/').pop()} selected={isSelected(path)}
|
|
2216
|
+
onContextMenu={onFolderContextMenu ? (e) => onFolderContextMenu(e, path) : undefined}
|
|
2217
|
+
onDoubleClick={() => onOpenFolder?.(path)}
|
|
2218
|
+
onClick={onPickFolder ? (e) => onPickFolder(path, e) : undefined} />
|
|
2219
|
+
))}
|
|
2172
2220
|
{shown.map((o, idx) => (
|
|
2173
2221
|
<MediaTile key={o.key} markKey={o.key} preview={renderThumb(o, null)} name={renderName(o)} selected={isSelected(o.key)}
|
|
2174
2222
|
onContextMenu={onContextMenu ? (e) => onContextMenu(e, o) : undefined}
|
|
@@ -72,7 +72,10 @@ export default function ContextMenu({ menu, children, className = '' }) {
|
|
|
72
72
|
{/* Any click inside closes — a menu item that leaves the menu open after
|
|
73
73
|
* acting is the one interaction people report as broken. Items keep their
|
|
74
74
|
* own onClick; this runs after it on the way up. */}
|
|
75
|
-
|
|
75
|
+
{/* A COLUMN, not a block: the items are `inline-flex`, so in a block they are one line of
|
|
76
|
+
* inline boxes to the float's shrink-to-fit, and the menu took the width of every label
|
|
77
|
+
* side by side — 510px for seven short verbs, past the edge of a phone. */}
|
|
78
|
+
<div onClick={menu.close} role="none" className="flex flex-col">
|
|
76
79
|
{typeof children === 'function' ? children(menu.payload) : children}
|
|
77
80
|
</div>
|
|
78
81
|
</PopoverPanel>
|