@kolkrabbi/kol-component 0.218.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 +47 -6
- 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
|
}
|
|
@@ -931,6 +936,8 @@ export function MediaLibraryBrowse({
|
|
|
931
936
|
* that does not gets the native ones, exactly as before (kol-client-olina 2026-09-22: the
|
|
932
937
|
* browser's own grey prompt box was the last non-DS surface on media.olina-productions.com). */
|
|
933
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()
|
|
934
941
|
const modal = useModal()
|
|
935
942
|
const canWrite = !!fileActions && writable
|
|
936
943
|
const [busyAction, setBusyAction] = useState(false)
|
|
@@ -1120,6 +1127,15 @@ export function MediaLibraryBrowse({
|
|
|
1120
1127
|
/* stepping into a folder drops the pick — the preview would otherwise describe something no
|
|
1121
1128
|
* longer in the list */
|
|
1122
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
|
+
}
|
|
1123
1139
|
/* SPACE IS QUICK LOOK IN EVERY VIEW (user 2026-09-23: *"spacebar preview only works in column,
|
|
1124
1140
|
* fix that. CONSISTENCY"*). `ColumnBrowser` has owned that key since it shipped, so the columns
|
|
1125
1141
|
* had it and the two views built later did not. The target is whatever is picked — the previewed
|
|
@@ -1220,9 +1236,11 @@ export function MediaLibraryBrowse({
|
|
|
1220
1236
|
if (el?.tabIndex >= 0) el.focus({ preventScroll: true })
|
|
1221
1237
|
}
|
|
1222
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 }
|
|
1223
1242
|
const list = gridOrderRef.current
|
|
1224
1243
|
if (!list.length) return true
|
|
1225
|
-
if (e.metaKey && e.key === 'ArrowUp') { if (prefix) goFolder(dirOf(prefix)); return true }
|
|
1226
1244
|
const i = list.findIndex((f) => f.key === cur)
|
|
1227
1245
|
if (e.metaKey && e.key === 'ArrowDown') { if (i !== -1) openQuickLook({ files: list, index: i }); return true }
|
|
1228
1246
|
const box = gridMarquee.ref.current
|
|
@@ -1246,7 +1264,7 @@ export function MediaLibraryBrowse({
|
|
|
1246
1264
|
}
|
|
1247
1265
|
const isFolder = cur?.endsWith('/')
|
|
1248
1266
|
const open = isFolder && rowsExpanded.has(cur)
|
|
1249
|
-
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 }
|
|
1250
1268
|
if (e.metaKey && e.key === 'ArrowDown') {
|
|
1251
1269
|
if (!cur) return true
|
|
1252
1270
|
if (isFolder) goFolder(cur)
|
|
@@ -1306,6 +1324,10 @@ export function MediaLibraryBrowse({
|
|
|
1306
1324
|
const view = stored === 'list' ? 'rows' : stored
|
|
1307
1325
|
const isWall = view === 'grid'
|
|
1308
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)')
|
|
1309
1331
|
const setView = (v) => {
|
|
1310
1332
|
onViewChange?.(v)
|
|
1311
1333
|
setSettings({
|
|
@@ -1462,7 +1484,7 @@ export function MediaLibraryBrowse({
|
|
|
1462
1484
|
const treeBody = (keep) => {
|
|
1463
1485
|
const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
|
|
1464
1486
|
return (
|
|
1465
|
-
folderView === 'columns' ? (
|
|
1487
|
+
folderView === 'columns' || phone ? (
|
|
1466
1488
|
<div ref={columnsRef} className="relative">
|
|
1467
1489
|
<ColumnBrowser
|
|
1468
1490
|
autoFocus={autoFocus}
|
|
@@ -1651,8 +1673,18 @@ export function MediaLibraryBrowse({
|
|
|
1651
1673
|
<div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
|
|
1652
1674
|
<div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1653
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 })}
|
|
1654
1679
|
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}>
|
|
1655
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) })}
|
|
1656
1688
|
onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) }}
|
|
1657
1689
|
sortBy={settings.sortBy} sortDir={settings.sortDir}
|
|
1658
1690
|
pageSize={settings.pageSize} listId={`${prefix}|${view}|${settings.flat}|${refreshKey}|${bucketMeta.id}`}
|
|
@@ -1699,7 +1731,7 @@ export function MediaLibraryBrowse({
|
|
|
1699
1731
|
: treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
|
|
1700
1732
|
|
|
1701
1733
|
return (
|
|
1702
|
-
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
1734
|
+
<div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
1703
1735
|
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={appRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
|
|
1704
1736
|
onHome={() => { setAppRoot(true); setPickedFile(null); setPrefix('') }}
|
|
1705
1737
|
onBucket={(v) => { if (v === 'all') { setAppRoot(true); setPrefix('') } else { setAppRoot(false); switchBucket(v) } }}
|
|
@@ -1911,7 +1943,7 @@ export function MediaLibraryBrowse({
|
|
|
1911
1943
|
* this row 8px taller than the fill budget, so the page scrolled by that much */
|
|
1912
1944
|
<div className="flex items-center justify-between gap-4 h-4">
|
|
1913
1945
|
<p className="kol-mono-12 text-fg-48">
|
|
1914
|
-
{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))}
|
|
1915
1947
|
</p>
|
|
1916
1948
|
<input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
|
|
1917
1949
|
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
@@ -2106,6 +2138,8 @@ export function WallBody({
|
|
|
2106
2138
|
files, layout = 'list', sortBy = 'name', sortDir = 'asc', pageSize = 200, listId = '', cardMin = 260,
|
|
2107
2139
|
mediaUrl, downloadUrl, keySet, videoPreview, formatDate = defaultFormatDate,
|
|
2108
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,
|
|
2109
2143
|
/* ONE INSPECTOR (user 2026-09-23). Given `onOpen`, the wall hands the open UP and keeps no
|
|
2110
2144
|
* lightbox of its own — otherwise a page that also opens one (space, a row double-click) ends up
|
|
2111
2145
|
* with two stacked overlays of the same file. The standalone wall passes nothing and keeps its
|
|
@@ -2161,7 +2195,7 @@ export function WallBody({
|
|
|
2161
2195
|
const openAt = (o, idx) => (onOpen ? onOpen(o, sorted) : setLightboxIndex(idx))
|
|
2162
2196
|
return (
|
|
2163
2197
|
<div className="flex flex-col gap-3">
|
|
2164
|
-
{sorted.length === 0 ? null : layout !== 'grid' ? (
|
|
2198
|
+
{sorted.length === 0 && !(layout === 'grid' && tiles === 'media' && folders.length) ? null : layout !== 'grid' ? (
|
|
2165
2199
|
<div className="flex flex-col">
|
|
2166
2200
|
{shown.map((o, idx) => (
|
|
2167
2201
|
<ContentRow key={o.key} variant="default" media={renderThumb(o, null)} title={renderName(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
|
|
@@ -2176,6 +2210,13 @@ export function WallBody({
|
|
|
2176
2210
|
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
|
|
2177
2211
|
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) onBackgroundClick?.() }}>
|
|
2178
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
|
+
))}
|
|
2179
2220
|
{shown.map((o, idx) => (
|
|
2180
2221
|
<MediaTile key={o.key} markKey={o.key} preview={renderThumb(o, null)} name={renderName(o)} selected={isSelected(o.key)}
|
|
2181
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>
|