@kolkrabbi/kol-component 0.218.0 → 0.220.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 +9 -3
- package/src/molecules/RowMenuButton.jsx +20 -0
- package/src/organisms/ColumnBrowser.jsx +20 -7
- package/src/organisms/MediaLibraryPages.jsx +275 -63
- 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.220.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,17 @@
|
|
|
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
|
+
/* THE SECOND CLICK OPENS (kol-client-olina 2026-09-23: double-click on a folder tile did nothing).
|
|
25
|
+
* The browser counted the double click (`detail: 2`) and never fired `dblclick` on a folder tile,
|
|
26
|
+
* so the open hung off an event that did not arrive. The click's own count is the signal. */
|
|
27
|
+
onClick={(e) => { if (e.detail === 2 && onDoubleClick) { onDoubleClick(e); return } onClick?.(e) }}
|
|
28
|
+
onContextMenu={onContextMenu}
|
|
24
29
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); onDoubleClick?.(e) } }}>
|
|
25
30
|
<div className="kol-media-tile-thumb">{preview}</div>
|
|
26
31
|
<span className="kol-media-tile-name">{name}</span>
|
|
32
|
+
<RowMenuButton variant="grey" onOpen={onContextMenu} className="absolute top-1 right-1" />
|
|
27
33
|
</div>
|
|
28
34
|
)
|
|
29
35
|
}
|
|
@@ -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'
|
|
@@ -324,9 +325,9 @@ function Row({
|
|
|
324
325
|
</span>
|
|
325
326
|
) : (
|
|
326
327
|
<span className="w-5 shrink-0 flex items-center justify-center text-oq-48">
|
|
327
|
-
{/*
|
|
328
|
-
|
|
329
|
-
<Icon name={icon} size={
|
|
328
|
+
{/* one glyph size for every row — a folder and a file side by side draw at the same size
|
|
329
|
+
(user 2026-09-25; the file glyph was 14 against the folder's 18) */}
|
|
330
|
+
<Icon name={icon} size={18} />
|
|
330
331
|
</span>
|
|
331
332
|
)}
|
|
332
333
|
|
|
@@ -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'
|
|
@@ -206,7 +209,7 @@ function ImageFrame({ src }) {
|
|
|
206
209
|
/* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
|
|
207
210
|
* disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
|
|
208
211
|
* that navigates — so nothing that already renders these moves. */
|
|
209
|
-
function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
|
|
212
|
+
function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, expanded, onToggle, meta, cols, onContextMenu, drag, dropFiles, selected, current, markKey }) {
|
|
210
213
|
const [over, setOver] = useState(false)
|
|
211
214
|
/* A FOLDER IS A DROP TARGET. `drag` carries the page's move verb and the path this row is; the
|
|
212
215
|
* row highlights only while something is actually over it, so an accidental hover reads as
|
|
@@ -243,7 +246,7 @@ function FolderRow({ name, onClick, onDoubleClick, depth = 0, expanded, onToggle
|
|
|
243
246
|
{/* THE FOLDER GLYPH IS THE ONE ICON LEFT, so it fills its box (user 2026-09-23: *"only folder
|
|
244
247
|
should use icon, and it should be bigger"*). Every other kind now renders its own bytes. */}
|
|
245
248
|
<span data-hit {...hitDrag} className="kol-row-hit flex items-center gap-3 min-w-0">
|
|
246
|
-
<span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48"><Icon name=
|
|
249
|
+
<span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48"><Icon name={icon} size={26} /></span>
|
|
247
250
|
{/* NO TRAILING SLASH (user 2026-09-22). `partition` hands folders back as `audio/` because a
|
|
248
251
|
slash is what marks one in a flat key space; that is storage's spelling, not a label, and
|
|
249
252
|
`ColumnBrowser` has always stripped it. The glyph says "folder". */}
|
|
@@ -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
|
}
|
|
@@ -322,7 +326,8 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
|
|
|
322
326
|
{/* the hit — icon and name, as in the folder row above */}
|
|
323
327
|
<span data-hit {...dragProps} className="kol-row-hit flex items-center gap-3 min-w-0">
|
|
324
328
|
<span className="w-8 h-8 shrink-0 flex items-center justify-center text-oq-48 overflow-hidden rounded">
|
|
325
|
-
{
|
|
329
|
+
{/* a file with no thumbnail draws its glyph at the FOLDER's size — one icon column (user 2026-09-25) */}
|
|
330
|
+
{thumb ?? <Icon name={kindOf(o) === 'image' ? 'image' : 'file'} size={26} />}
|
|
326
331
|
</span>
|
|
327
332
|
<span className="kol-mono-12 min-w-0 truncate text-fg-default">{o.displayKey ?? o.key.split('/').pop()}</span>
|
|
328
333
|
</span>
|
|
@@ -331,6 +336,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
|
|
|
331
336
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
|
|
332
337
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
|
|
333
338
|
</>}
|
|
339
|
+
<RowMenuButton onOpen={onContextMenu} className="-my-1 -mr-1" />
|
|
334
340
|
</li>
|
|
335
341
|
)
|
|
336
342
|
}
|
|
@@ -349,12 +355,21 @@ function FolderPreview({ path, objects, width, formatDate }) {
|
|
|
349
355
|
['Size', formatSize(bytes)],
|
|
350
356
|
...(last ? [['Newest', formatDate(last) || '—']] : []),
|
|
351
357
|
]
|
|
358
|
+
return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} />
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ONE PREVIEW FOR EVERYTHING THAT HOLDS THINGS — a folder, a bucket, the title (user 2026-09-25:
|
|
362
|
+
* selecting a bucket or MEDIA left the pane empty). The glyph, the name, the facts; only the
|
|
363
|
+
* glyph and the facts differ. */
|
|
364
|
+
function ContainerPreview({ name, icon, facts, width }) {
|
|
352
365
|
return (
|
|
353
366
|
<div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
|
|
354
|
-
|
|
355
|
-
|
|
367
|
+
{/* the folder fills its box with the tile's inset (user 2026-09-24: a 64px glyph floated in
|
|
368
|
+
a 300px square while the grid's folder tile fills its own) */}
|
|
369
|
+
<div className="w-full aspect-square bg-oq-04 rounded flex items-center justify-center p-[5%]">
|
|
370
|
+
<Icon name={icon} size="100%" className="text-oq-48" />
|
|
356
371
|
</div>
|
|
357
|
-
<p className="kol-mono-12 text-fg-default break-all">{
|
|
372
|
+
<p className="kol-mono-12 text-fg-default break-all">{name}</p>
|
|
358
373
|
<dl className="flex flex-col gap-1">
|
|
359
374
|
{facts.map(([k, v]) => (
|
|
360
375
|
<div key={k} className="flex justify-between gap-4 kol-mono-12">
|
|
@@ -469,7 +484,7 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
469
484
|
const [size, setSize] = useState(null)
|
|
470
485
|
const shownDims = dims && dims.key === o?.key ? dims : null
|
|
471
486
|
useEffect(() => {
|
|
472
|
-
const onKey = (e) => { if (e.key === 'ArrowLeft') onPrev(); if (e.key === 'ArrowRight') onNext() }
|
|
487
|
+
const onKey = (e) => { if (e.shiftKey) return; if (e.key === 'ArrowLeft') onPrev(); if (e.key === 'ArrowRight') onNext() }
|
|
473
488
|
window.addEventListener('keydown', onKey)
|
|
474
489
|
return () => window.removeEventListener('keydown', onKey)
|
|
475
490
|
}, [onPrev, onNext])
|
|
@@ -512,7 +527,12 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
512
527
|
<FullscreenOverlay open onClose={onClose} closeButton={false} scrim>
|
|
513
528
|
{isImage(o.contentType) ? (
|
|
514
529
|
<QuickLookFrame {...frame}>
|
|
515
|
-
|
|
530
|
+
{/* A VECTOR HAS NO PIXEL SIZE (user 2026-09-24: a 32px logo.svg opened as a 32px window). It
|
|
531
|
+
* is drawn in a 640 box — the window's max if that is smaller — and contained in it. */}
|
|
532
|
+
<img src={mediaUrl(o.key)} alt={o.displayKey}
|
|
533
|
+
className="kol-quicklook-media"
|
|
534
|
+
style={extOf(o.key) === 'svg' || o.contentType === 'image/svg+xml' ? { width: 'min(var(--kol-ql-max-w), 640px)', height: 'min(var(--kol-ql-media-h), 640px)' } : undefined}
|
|
535
|
+
onLoad={(e) => setDims({ key: o.key, w: e.target.naturalWidth, h: e.target.naturalHeight })} />
|
|
516
536
|
</QuickLookFrame>
|
|
517
537
|
) : isVideo(o.contentType) ? (
|
|
518
538
|
<VideoSheet key={o.key} src={mediaUrl(o.key)} poster={poster ? mediaUrl(poster) : undefined} onMeta={(m) => setDims({ key: o.key, ...m })} frame={frame} />
|
|
@@ -850,7 +870,7 @@ export function MediaLibraryBrowse({
|
|
|
850
870
|
}) {
|
|
851
871
|
const [ownPrefix, setOwnPrefix] = useState('')
|
|
852
872
|
const prefix = prefixProp ?? ownPrefix
|
|
853
|
-
const setPrefix = (v) => { if (prefixProp == null) setOwnPrefix(v); onPrefix?.(v) }
|
|
873
|
+
const setPrefix = (v) => { if (v) setAppRoot(false); if (prefixProp == null) setOwnPrefix(v); onPrefix?.(v) }
|
|
854
874
|
const [appRoot, setAppRoot] = useState(false)
|
|
855
875
|
const [ownBucket, setOwnBucket] = useState(bucket)
|
|
856
876
|
const bucketId = bucket ?? ownBucket
|
|
@@ -864,8 +884,22 @@ export function MediaLibraryBrowse({
|
|
|
864
884
|
* opens (user 2026-09-22, Finder's behaviour — "clicking shouldnt automatically open it"). The
|
|
865
885
|
* chevron still expands in place, which is the third thing and always was. */
|
|
866
886
|
const [pickedFolder, setPickedFolder] = useState(null)
|
|
867
|
-
const pickFile = (o) => { setPickedFolder(null); setPickedFile(o) }
|
|
868
|
-
const pickFolder = (path) => { setPickedFile(null); setPickedFolder(path) }
|
|
887
|
+
const pickFile = (o) => { setAppRoot(false); setPickedFolder(null); setPickedFile(o) }
|
|
888
|
+
const pickFolder = (path) => { setAppRoot(false); setPickedFile(null); setPickedFolder(path) }
|
|
889
|
+
/* AT THE TITLE ROOT IS DERIVED, NOT STORED (kol-client-olina 2026-09-23: rows and grid picked a
|
|
890
|
+
* file under `brand/logos/` and the crumb still read the title alone). `appRoot` was a second
|
|
891
|
+
* source of truth for the location, and only the columns ever cleared it. A path or a pick lives
|
|
892
|
+
* inside a bucket, so either one means you are not at the title — a stale flag can no longer
|
|
893
|
+
* hide where you are. Everything below reads this, never `appRoot`. */
|
|
894
|
+
const atTitleRoot = appRoot && !prefix && !pickedFile && !pickedFolder
|
|
895
|
+
/* ABOVE THE TITLE (user 2026-09-24: *"this is not the top root folder … in column view I can see
|
|
896
|
+
* the root MEDIA"*). Column 0 is a level whose one item is the title; rows and grid had no such
|
|
897
|
+
* level, so the bucket list was their top and the title never appeared as a thing. `appRoot` is
|
|
898
|
+
* `'top'` there — the same state, one level higher, derived the same way. */
|
|
899
|
+
const atTop = atTitleRoot && appRoot === 'top'
|
|
900
|
+
/* a BUCKET is a folder with another icon: a click highlights it, a double-click opens it */
|
|
901
|
+
const [pickedBucket, setPickedBucket] = useState(null)
|
|
902
|
+
const ROOT_KEY = '\u0000title'
|
|
869
903
|
/* IN THE ROWS, WHAT YOU PICK SETS WHERE YOU ARE (user 2026-09-23: *"that files folder becomes the
|
|
870
904
|
* default NOT the previous folder"*). The list is rooted at the top, so a pick can sit in any open
|
|
871
905
|
* folder; the current folder follows it to the pick's parent. Whatever the old path had open
|
|
@@ -931,6 +965,8 @@ export function MediaLibraryBrowse({
|
|
|
931
965
|
* that does not gets the native ones, exactly as before (kol-client-olina 2026-09-22: the
|
|
932
966
|
* browser's own grey prompt box was the last non-DS surface on media.olina-productions.com). */
|
|
933
967
|
const menu = useContextMenu()
|
|
968
|
+
/* touch has no right button: a held press on a row or tile opens the same menu (useLongPress) */
|
|
969
|
+
const press = useLongPress()
|
|
934
970
|
const modal = useModal()
|
|
935
971
|
const canWrite = !!fileActions && writable
|
|
936
972
|
const [busyAction, setBusyAction] = useState(false)
|
|
@@ -1119,7 +1155,33 @@ export function MediaLibraryBrowse({
|
|
|
1119
1155
|
}
|
|
1120
1156
|
/* stepping into a folder drops the pick — the preview would otherwise describe something no
|
|
1121
1157
|
* longer in the list */
|
|
1122
|
-
|
|
1158
|
+
/* GOING UP SELECTS THE FOLDER YOU CAME FROM (user 2026-09-24: back from `01-shoots` landed on
|
|
1159
|
+
* `img/` with nothing selected and an empty preview — every file manager lights the child you
|
|
1160
|
+
* left). One rule for every way up — ⌘↑ and the crumbs both come through here. */
|
|
1161
|
+
const goFolder = (path) => {
|
|
1162
|
+
const child = path !== prefix && prefix.startsWith(path) ? `${path}${prefix.slice(path.length).split('/')[0]}/` : null
|
|
1163
|
+
setPickedFile(null); setPickedFolder(null); setPrefix(path)
|
|
1164
|
+
if (child) { setRowSelection(new Set([child])); setPickedFolder(child); selectAnchorRef.current = child; selectCursorRef.current = null }
|
|
1165
|
+
}
|
|
1166
|
+
/* ONE "GO UP" FOR ALL THREE VIEWS (kol-client-olina 2026-09-23: *"3 different stops for 3
|
|
1167
|
+
* modes … they should all go all the way to the shared root folder"*): parent folder → … → the
|
|
1168
|
+
* bucket's top → the title root, which is what `onHome` does. With one bucket and no
|
|
1169
|
+
* `bucketLevel` there is no level above the bucket's top, so that is where it ends. Columns walk
|
|
1170
|
+
* the same path with ArrowLeft. */
|
|
1171
|
+
const goUp = () => {
|
|
1172
|
+
if (prefix) { goFolder(dirOf(prefix)); return }
|
|
1173
|
+
if (!single && !atTitleRoot) goTitleRoot()
|
|
1174
|
+
else if (!single && !atTop) goTop()
|
|
1175
|
+
}
|
|
1176
|
+
/* THE TITLE ROOT CLEARS EVERYTHING that would say you are somewhere else — a pick, a selection —
|
|
1177
|
+
* or the derivation above reads you as still inside the bucket */
|
|
1178
|
+
const goTop = () => { setAppRoot('top'); setPickedFile(null); setPickedFolder(null); setRowSelection(new Set()); setPickedBucket(ROOT_KEY); setPrefix('') }
|
|
1179
|
+
const goTitleRoot = () => { setAppRoot(true); setPickedFile(null); setPickedFolder(null); setPickedBucket(atTitleRoot ? null : (bucketMeta.id ?? bucketMeta.label)); setRowSelection(new Set()); setPrefix('') }
|
|
1180
|
+
/* opening a bucket from the title root, the step the columns' column 1 is */
|
|
1181
|
+
const openBucket = (b) => {
|
|
1182
|
+
setAppRoot(false); setPickedFile(null); setPickedFolder(null); setPickedBucket(null); setRowSelection(new Set())
|
|
1183
|
+
if (b.id != null && b.id !== bucketMeta.id) switchBucket(b.id, ''); else setPrefix('')
|
|
1184
|
+
}
|
|
1123
1185
|
/* SPACE IS QUICK LOOK IN EVERY VIEW (user 2026-09-23: *"spacebar preview only works in column,
|
|
1124
1186
|
* fix that. CONSISTENCY"*). `ColumnBrowser` has owned that key since it shipped, so the columns
|
|
1125
1187
|
* had it and the two views built later did not. The target is whatever is picked — the previewed
|
|
@@ -1169,6 +1231,8 @@ export function MediaLibraryBrowse({
|
|
|
1169
1231
|
}
|
|
1170
1232
|
const spaceQuickLook = () => {
|
|
1171
1233
|
const key = pickedFile?.key ?? pickedFolder ?? (rowSelection.size === 1 ? [...rowSelection][0] : null)
|
|
1234
|
+
/* a picked bucket or the title Quick-Looks like any folder (user 2026-09-25) */
|
|
1235
|
+
if (!key && previewRoot) { setQuickLookFolder(ROOT_KEY); return }
|
|
1172
1236
|
if (!key) return
|
|
1173
1237
|
/* A FOLDER QUICK-LOOKS TOO (user 2026-09-23: *"anything can be quick viewed, also folders"*) —
|
|
1174
1238
|
* the same pane the preview column draws, at overlay size. */
|
|
@@ -1196,7 +1260,14 @@ export function MediaLibraryBrowse({
|
|
|
1196
1260
|
const gridOrderRef = useRef([])
|
|
1197
1261
|
const arrowNav = (e) => {
|
|
1198
1262
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return false
|
|
1199
|
-
|
|
1263
|
+
/* ⇧+ARROWS KEEP SELECTING UNDER QUICK LOOK (user 2026-09-24), Finder's: the selection grows
|
|
1264
|
+
* underneath and the window pages through it */
|
|
1265
|
+
if (view === 'columns' || (quickLook && !e.shiftKey) || quickLookFolder || searchOpen) return false
|
|
1266
|
+
if (atTitleRoot && !single) {
|
|
1267
|
+
if (e.metaKey && e.key === 'ArrowUp') goUp()
|
|
1268
|
+
else if (e.metaKey && e.key === 'ArrowDown') { if (atTop) goTitleRoot(); else openBucket(bucketList.find((b) => (b.id ?? b.label) === pickedBucket) ?? bucketMeta) }
|
|
1269
|
+
return true
|
|
1270
|
+
}
|
|
1200
1271
|
const cur = selectCursorRef.current ?? selectAnchorRef.current ?? [...rowSelection].pop() ?? null
|
|
1201
1272
|
/* ⇧ + ARROW EXTENDS (user 2026-09-23: *"shift up down or left right … should also make
|
|
1202
1273
|
* selection"*): the anchor stays where the run began, the cursor moves, and the selection is
|
|
@@ -1220,19 +1291,32 @@ export function MediaLibraryBrowse({
|
|
|
1220
1291
|
if (el?.tabIndex >= 0) el.focus({ preventScroll: true })
|
|
1221
1292
|
}
|
|
1222
1293
|
if (view === 'grid') {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
if (e.metaKey && e.key === 'ArrowUp') {
|
|
1226
|
-
|
|
1227
|
-
|
|
1294
|
+
/* BEFORE the empty-list bail-out: a folder that holds only subfolders has an empty wall, and
|
|
1295
|
+
* ⌘↑ was swallowed there */
|
|
1296
|
+
if (e.metaKey && e.key === 'ArrowUp') { goUp(); return true }
|
|
1297
|
+
/* THE ARROWS WALK WHAT IS DRAWN (user 2026-09-24: they skipped every folder tile) — the
|
|
1298
|
+
* folders, then the files, in the order on screen. The list was the files alone, from before
|
|
1299
|
+
* the grid drew folders. */
|
|
1300
|
+
const files = gridOrderRef.current
|
|
1301
|
+
const items = [...(settings.flat ? [] : folders.map((f) => ({ key: prefix + f, folder: true }))), ...files.map((o) => ({ key: o.key, o }))]
|
|
1302
|
+
if (!items.length) return true
|
|
1303
|
+
const i = items.findIndex((it) => it.key === cur)
|
|
1304
|
+
if (e.metaKey && e.key === 'ArrowDown') {
|
|
1305
|
+
const it = items[i]
|
|
1306
|
+
if (it?.folder) goFolder(it.key)
|
|
1307
|
+
else if (it) openQuickLook({ files, index: Math.max(0, files.findIndex((f) => f.key === it.key)) })
|
|
1308
|
+
return true
|
|
1309
|
+
}
|
|
1228
1310
|
const box = gridMarquee.ref.current
|
|
1229
|
-
|
|
1311
|
+
/* the band lives on the pane now; the column count is the tile grid's, inside it */
|
|
1312
|
+
const tiles = box?.querySelector('.grid')
|
|
1313
|
+
const cols = tiles ? getComputedStyle(tiles).gridTemplateColumns.split(' ').filter(Boolean).length : 1
|
|
1230
1314
|
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -cols, ArrowDown: cols }[e.key]
|
|
1231
|
-
const
|
|
1232
|
-
if (e.shiftKey && extend(
|
|
1233
|
-
setRowSelection(new Set([
|
|
1234
|
-
setPickedFolder(null); setPickedFile(o)
|
|
1235
|
-
reveal(box,
|
|
1315
|
+
const it = items[i === -1 ? 0 : Math.max(0, Math.min(items.length - 1, i + step))]
|
|
1316
|
+
if (e.shiftKey && extend(items.map((x) => x.key), it.key)) { reveal(box, it.key); return true }
|
|
1317
|
+
setRowSelection(new Set([it.key])); selectAnchorRef.current = it.key; selectCursorRef.current = null
|
|
1318
|
+
if (it.folder) { setPickedFile(null); setPickedFolder(it.key) } else { setPickedFolder(null); setPickedFile(it.o) }
|
|
1319
|
+
reveal(box, it.key)
|
|
1236
1320
|
return true
|
|
1237
1321
|
}
|
|
1238
1322
|
if (view === 'rows') {
|
|
@@ -1246,7 +1330,7 @@ export function MediaLibraryBrowse({
|
|
|
1246
1330
|
}
|
|
1247
1331
|
const isFolder = cur?.endsWith('/')
|
|
1248
1332
|
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 }
|
|
1333
|
+
if (e.metaKey && e.key === 'ArrowUp') { const up = cur ? dirOf(cur) : dirOf(prefix); if (up) select(up); else goUp(); return true }
|
|
1250
1334
|
if (e.metaKey && e.key === 'ArrowDown') {
|
|
1251
1335
|
if (!cur) return true
|
|
1252
1336
|
if (isFolder) goFolder(cur)
|
|
@@ -1280,6 +1364,18 @@ export function MediaLibraryBrowse({
|
|
|
1280
1364
|
if (e.key === '/' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); setSearchOpen(true) }
|
|
1281
1365
|
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setSearchOpen(true) }
|
|
1282
1366
|
if (arrowNav(e)) { e.preventDefault(); return }
|
|
1367
|
+
/* ENTER OPENS THE SELECTED FOLDER, ⇧ENTER GOES TO THE PARENT (user 2026-09-24). The same
|
|
1368
|
+
* two moves as a double-click and ⌘↑, so the child you leave is still the one selected.
|
|
1369
|
+
* A focused tile has already opened itself on Enter (it calls preventDefault). */
|
|
1370
|
+
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.defaultPrevented && !quickLook && !quickLookFolder && !searchOpen) {
|
|
1371
|
+
if (e.shiftKey) { e.preventDefault(); goUp(); return }
|
|
1372
|
+
if (view !== 'columns') {
|
|
1373
|
+
const root = rootItems.find((it) => it.key === pickedBucket)
|
|
1374
|
+
const sel = rowSelection.size === 1 ? [...rowSelection][0] : pickedFolder
|
|
1375
|
+
if (root) { e.preventDefault(); root.open(); return }
|
|
1376
|
+
if (sel?.endsWith('/')) { e.preventDefault(); goFolder(sel); return }
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1283
1379
|
/* the columns keep their own space handler — theirs knows the column's cursor */
|
|
1284
1380
|
if (e.key === ' ' && view !== 'columns' && !searchOpen) {
|
|
1285
1381
|
e.preventDefault()
|
|
@@ -1294,6 +1390,16 @@ export function MediaLibraryBrowse({
|
|
|
1294
1390
|
|
|
1295
1391
|
const [quickLook, setQuickLook] = useState(null)
|
|
1296
1392
|
const [quickLookFolder, setQuickLookFolder] = useState(null)
|
|
1393
|
+
/* …and the open window takes the new selection as its set, on the file the cursor reached */
|
|
1394
|
+
useEffect(() => {
|
|
1395
|
+
if (!quickLook || rowSelection.size < 2) return
|
|
1396
|
+
const order = view === 'grid' ? gridOrderRef.current : sortedObjects
|
|
1397
|
+
const files = order.filter((o) => rowSelection.has(o.key) && !o.key.endsWith('/'))
|
|
1398
|
+
if (files.length < 2) return
|
|
1399
|
+
if (files.length === quickLook.files.length && files.every((f, i) => f.key === quickLook.files[i].key)) return
|
|
1400
|
+
const at = files.findIndex((f) => f.key === selectCursorRef.current)
|
|
1401
|
+
setQuickLook({ files, index: at === -1 ? 0 : at })
|
|
1402
|
+
}, [rowSelection]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1297
1403
|
const columnsRef = useRef(null)
|
|
1298
1404
|
/* `flat` IS THE WALL'S, NOT THIS PAGE'S (user 2026-09-22: "there is no option to switch to flat
|
|
1299
1405
|
* mode, bc its not in files view. this is row mode.. this makes no sense"). It reached here from
|
|
@@ -1306,6 +1412,10 @@ export function MediaLibraryBrowse({
|
|
|
1306
1412
|
const view = stored === 'list' ? 'rows' : stored
|
|
1307
1413
|
const isWall = view === 'grid'
|
|
1308
1414
|
const folderView = view === 'rows' ? 'rows' : 'columns'
|
|
1415
|
+
/* BELOW `md` BOTH TREE VIEWS ARE THE PHONE LIST. `ColumnBrowser` already forks to its one-level
|
|
1416
|
+
* stack there; the row view had no phone form and drew the desktop list and its preview pane
|
|
1417
|
+
* squeezed into 390 — 35px of rows with no names. Same query, same breakpoint as the columns. */
|
|
1418
|
+
const phone = useMediaQuery('(max-width: 767px)')
|
|
1309
1419
|
const setView = (v) => {
|
|
1310
1420
|
onViewChange?.(v)
|
|
1311
1421
|
setSettings({
|
|
@@ -1324,6 +1434,14 @@ export function MediaLibraryBrowse({
|
|
|
1324
1434
|
const previewWidth = settings.columnWidths?.preview ?? 320
|
|
1325
1435
|
const previewFile = pickedFile && pickedFile.key.startsWith(prefix) ? pickedFile : null
|
|
1326
1436
|
const previewFolder = pickedFolder && pickedFolder.startsWith(prefix) ? pickedFolder : null
|
|
1437
|
+
/* a bucket's totals come from `folderTree` when the consumer passes one; the open bucket can
|
|
1438
|
+
* always count its own listing */
|
|
1439
|
+
const bucketTotals = (id) => {
|
|
1440
|
+
const t = folderTree?.[id]
|
|
1441
|
+
if (t && t.files != null) return { files: t.files, bytes: t.bytes ?? 0 }
|
|
1442
|
+
if (id === bucketMeta.id) return { files: objects.length, bytes: objects.reduce((n, o) => n + (o.size || 0), 0) }
|
|
1443
|
+
return null
|
|
1444
|
+
}
|
|
1327
1445
|
/* THE PREVIEW PANE, ONE FUNCTION FOR THE ROWS AND THE GRID (user 2026-09-23: *"why did you not
|
|
1328
1446
|
* treat this like column and row, with container padding + preview pane? CONSISTENCY"*). Several
|
|
1329
1447
|
* selected → the stack; a file → its preview; a folder → its summary; nothing → the reserved
|
|
@@ -1341,6 +1459,8 @@ export function MediaLibraryBrowse({
|
|
|
1341
1459
|
}} />
|
|
1342
1460
|
) : previewFolder ? (
|
|
1343
1461
|
<FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} />
|
|
1462
|
+
) : previewRoot ? (
|
|
1463
|
+
<ContainerPreview key={previewRoot.key} name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={previewWidth} />
|
|
1344
1464
|
) : (
|
|
1345
1465
|
<div className="kol-column-browser-preview shrink-0" style={{ width: previewWidth }} />
|
|
1346
1466
|
)
|
|
@@ -1427,14 +1547,14 @@ export function MediaLibraryBrowse({
|
|
|
1427
1547
|
if (!root) return undefined
|
|
1428
1548
|
const id = requestAnimationFrame(() => root.scrollTo({ left: root.scrollWidth, behavior: 'smooth' }))
|
|
1429
1549
|
return () => cancelAnimationFrame(id)
|
|
1430
|
-
}, [folderView, prefix,
|
|
1550
|
+
}, [folderView, prefix, atTitleRoot, pickedFile, bucketMeta.id])
|
|
1431
1551
|
|
|
1432
1552
|
if (error) return <p className="kol-mono-12 text-ui-error">Error: {error}</p>
|
|
1433
1553
|
/* NOT SELECTABLE (user 2026-09-23, superseding 2026-09-22's selectable crumbs: *"instead of this
|
|
1434
1554
|
* highlight, could we make a hover click to copy path?"*). A drag across the line painted the
|
|
1435
1555
|
* browser's selection block over it; the path is one click on the current crumb now. */
|
|
1436
1556
|
const crumbCls = (active) => `cursor-pointer select-none transition-colors ${active ? 'text-oq-96' : 'text-oq-48 hover:text-oq-64'}`
|
|
1437
|
-
const fullPath = (tail = '') => [ROOT, ...(
|
|
1557
|
+
const fullPath = (tail = '') => [ROOT, ...(atTitleRoot ? [] : [label, ...crumbs]), ...(tail ? [tail] : [])].join('/')
|
|
1438
1558
|
|
|
1439
1559
|
/* ── THE WALL'S LIST, on the same surface (2026-09-22) ──────────────────────────────────────
|
|
1440
1560
|
* `flat` belongs to the FILE views and not to the tree (that ruling stands): a wall shows the
|
|
@@ -1459,20 +1579,48 @@ export function MediaLibraryBrowse({
|
|
|
1459
1579
|
* key space, or the subset the filter bar left standing. `wallPane` is the same two file views
|
|
1460
1580
|
* the Files page rendered, in a pane of the browser's own height so the page does not jump
|
|
1461
1581
|
* between views. */
|
|
1582
|
+
/* AT THE TITLE ROOT, ROWS AND GRID LIST THE BUCKETS (same ticket: they listed the bucket's
|
|
1583
|
+
* folders under a crumb that named the title). One row / tile per bucket; a click opens it. */
|
|
1584
|
+
const bucketList = buckets.length ? buckets : [bucketMeta]
|
|
1585
|
+
const titleLevel = atTitleRoot && !single
|
|
1586
|
+
/* what the root levels hold: above the title, the title as one folder; inside it, the buckets.
|
|
1587
|
+
* Each is a folder with its own icon — a click highlights it, a double-click opens it. */
|
|
1588
|
+
const previewRoot = (() => {
|
|
1589
|
+
if (!titleLevel || pickedBucket == null) return null
|
|
1590
|
+
if (atTop && pickedBucket === ROOT_KEY) {
|
|
1591
|
+
const all = bucketList.map((b) => bucketTotals(b.id)).filter(Boolean)
|
|
1592
|
+
return { key: ROOT_KEY, name: ROOT, icon: 'folder', facts: [
|
|
1593
|
+
['Kind', 'folder'],
|
|
1594
|
+
['Buckets', `${bucketList.length}`],
|
|
1595
|
+
...(all.length === bucketList.length ? [['Items', `${all.reduce((n, x) => n + x.files, 0)}`], ['Size', formatSize(all.reduce((n, x) => n + x.bytes, 0))]] : []),
|
|
1596
|
+
] }
|
|
1597
|
+
}
|
|
1598
|
+
const b = bucketList.find((x) => (x.id ?? x.label) === pickedBucket)
|
|
1599
|
+
if (!b) return null
|
|
1600
|
+
const tot = bucketTotals(b.id)
|
|
1601
|
+
return { key: pickedBucket, name: b.label || 'bucket', icon: 'database', facts: [
|
|
1602
|
+
['Kind', 'bucket'],
|
|
1603
|
+
...(tot ? [['Items', `${tot.files}`], ['Size', formatSize(tot.bytes)]] : []),
|
|
1604
|
+
...(b.writable != null ? [['Access', b.writable ? 'read · write' : 'read-only']] : []),
|
|
1605
|
+
] }
|
|
1606
|
+
})()
|
|
1607
|
+
const rootItems = !titleLevel ? [] : atTop
|
|
1608
|
+
? [{ key: ROOT_KEY, name: ROOT, icon: 'folder', open: goTitleRoot }]
|
|
1609
|
+
: bucketList.map((b) => ({ key: b.id ?? b.label, name: b.label || 'bucket', icon: 'database', open: () => openBucket(b) }))
|
|
1462
1610
|
const treeBody = (keep) => {
|
|
1463
1611
|
const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
|
|
1464
1612
|
return (
|
|
1465
|
-
folderView === 'columns' ? (
|
|
1613
|
+
folderView === 'columns' || phone ? (
|
|
1466
1614
|
<div ref={columnsRef} className="relative">
|
|
1467
1615
|
<ColumnBrowser
|
|
1468
1616
|
autoFocus={autoFocus}
|
|
1469
|
-
className={
|
|
1617
|
+
className={atTitleRoot ? 'is-root' : ''}
|
|
1470
1618
|
height={settings.columnHeight}
|
|
1471
1619
|
onHeightChange={(px) => setSettings({ ...settings, columnHeight: px })}
|
|
1472
1620
|
columnWidths={settings.columnWidths}
|
|
1473
1621
|
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
1474
1622
|
objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
1475
|
-
prefix={single ? prefix : (
|
|
1623
|
+
prefix={single ? prefix : (atTitleRoot ? `${ROOT}/` : `${VROOT}${prefix}`)}
|
|
1476
1624
|
onPrefix={(v) => {
|
|
1477
1625
|
/* single: the browser's path IS the bucket path, no segment to strip */
|
|
1478
1626
|
if (single) { setAppRoot(false); setPrefix(v); return }
|
|
@@ -1563,9 +1711,9 @@ export function MediaLibraryBrowse({
|
|
|
1563
1711
|
<div className={`flex-1 min-w-0 flex flex-col${showRowPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1564
1712
|
<ul ref={rowsMarquee.ref} {...rowsMarquee.props}
|
|
1565
1713
|
/* the background deselects, here as in the columns */
|
|
1566
|
-
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null) } }}
|
|
1714
|
+
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) } }}
|
|
1567
1715
|
className="relative kol-column-browser-column kol-row-browser flex-1 min-w-0 flex flex-col overflow-y-auto"
|
|
1568
|
-
onContextMenu={(e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
1716
|
+
onContextMenu={titleLevel ? undefined : (e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
1569
1717
|
onDragOver={(e) => {
|
|
1570
1718
|
const files = dropFilesTo && isFileDrag(e)
|
|
1571
1719
|
const d = !isFileDrag(e) && dragFor?.(prefix)
|
|
@@ -1587,6 +1735,11 @@ export function MediaLibraryBrowse({
|
|
|
1587
1735
|
d.onDrop(prefix)
|
|
1588
1736
|
}}>
|
|
1589
1737
|
{rowsMarquee.rect && <div className="kol-marquee" style={rowsMarquee.rect} />}
|
|
1738
|
+
{titleLevel ? rootItems.map((it) => (
|
|
1739
|
+
<FolderRow key={it.key} name={it.name} icon={it.icon} cols={rowCols}
|
|
1740
|
+
selected={pickedBucket === it.key}
|
|
1741
|
+
onClick={(e) => { e.stopPropagation(); setPickedBucket(it.key) }} onDoubleClick={it.open} />
|
|
1742
|
+
)) : <>
|
|
1590
1743
|
{rowLevel('', treeObjects).folders.map((f) => {
|
|
1591
1744
|
const path = f
|
|
1592
1745
|
const open = rowsExpanded.has(path)
|
|
@@ -1632,6 +1785,7 @@ export function MediaLibraryBrowse({
|
|
|
1632
1785
|
if (!selectRow(o.key, e, visibleRowOrder(treeObjects))) rowPickFile(o)
|
|
1633
1786
|
}} />
|
|
1634
1787
|
))}
|
|
1788
|
+
</>}
|
|
1635
1789
|
</ul>
|
|
1636
1790
|
</div>
|
|
1637
1791
|
{showRowPreview && previewPane()}
|
|
@@ -1647,13 +1801,42 @@ export function MediaLibraryBrowse({
|
|
|
1647
1801
|
* inside it, the same preview pane beside them, the same count line under it — with the size
|
|
1648
1802
|
* slider at its right end, Finder's. */
|
|
1649
1803
|
const tileSize = settings.tileSize ?? 180
|
|
1804
|
+
/* the tile size is the GRID's, not a level's (user 2026-09-24: the slider vanished at the root
|
|
1805
|
+
* levels) — one control, drawn wherever the grid is */
|
|
1806
|
+
const sizeSlider = (
|
|
1807
|
+
<input type="range" className="slider-black w-32 cursor-pointer" min={100} max={360} step={20} value={tileSize}
|
|
1808
|
+
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
1809
|
+
)
|
|
1650
1810
|
const wallPane = (files) => (
|
|
1651
1811
|
<div className="flex flex-col gap-2" style={{ height: settings.columnHeight ?? SETTINGS_BASE.columnHeight }}>
|
|
1652
1812
|
<div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1813
|
+
{/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
|
|
1814
|
+
* there were tiles — the grid is as tall as its tiles, the pane is the whole frame). The rows'
|
|
1815
|
+
* band has always been on their full-height list; this is the grid catching up. */}
|
|
1816
|
+
<div ref={gridMarquee.ref} {...gridMarquee.props}
|
|
1817
|
+
className={`relative flex-1 min-w-0 overflow-y-auto p-4${showGridPreview ? ' border-r' : ''}`} style={{ borderColor: 'var(--kol-oq-08)' }}
|
|
1818
|
+
/* THE CONTAINER IS A LEVEL, like a column (kol-client-olina 2026-09-23: *"context menu anywhere
|
|
1819
|
+
* by right click"*). A tile's own menu stops the event first, so it still wins. */
|
|
1820
|
+
onContextMenu={titleLevel ? undefined : (e) => menu.openAt(e, { type: 'level', path: prefix })}
|
|
1821
|
+
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) } }}>
|
|
1822
|
+
{gridMarquee.rect && <div className="kol-marquee" style={gridMarquee.rect} />}
|
|
1823
|
+
{titleLevel ? (
|
|
1824
|
+
<div className="grid gap-x-3 gap-y-5" style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${tileSize}px, 1fr))` }}>
|
|
1825
|
+
{rootItems.map((it) => (
|
|
1826
|
+
<MediaTile key={it.key} preview={<Icon name={it.icon} size="100%" className="text-oq-48" />} name={it.name}
|
|
1827
|
+
selected={pickedBucket === it.key}
|
|
1828
|
+
onClick={(e) => { e.stopPropagation(); setPickedBucket(it.key) }} onDoubleClick={it.open} />
|
|
1829
|
+
))}
|
|
1830
|
+
</div>
|
|
1831
|
+
) : <WallBody files={files} layout="grid" tiles="media" cardMin={tileSize} onSorted={(list) => { gridOrderRef.current = list }}
|
|
1832
|
+
/* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
|
|
1833
|
+
* 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
|
|
1834
|
+
* and no folders, as it was. */
|
|
1835
|
+
folders={settings.flat ? [] : folders.map((f) => prefix + f)}
|
|
1836
|
+
onOpenFolder={goFolder}
|
|
1837
|
+
onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
|
|
1838
|
+
onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
|
|
1839
|
+
onBackgroundClick={() => { setRowSelection(new Set()); setPickedFile(null); setPickedFolder(null); setPickedBucket(null) }}
|
|
1657
1840
|
sortBy={settings.sortBy} sortDir={settings.sortDir}
|
|
1658
1841
|
pageSize={settings.pageSize} listId={`${prefix}|${view}|${settings.flat}|${refreshKey}|${bucketMeta.id}`}
|
|
1659
1842
|
mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} videoPreview={settings.videoPreview} formatDate={formatDate}
|
|
@@ -1666,7 +1849,7 @@ export function MediaLibraryBrowse({
|
|
|
1666
1849
|
onOpen={(o, files) => openQuickLook({ files, index: Math.max(0, files.findIndex((f) => f.key === o.key)) })}
|
|
1667
1850
|
/* COPY AND DOWNLOAD LIVE IN THE MENU AND QUICK LOOK (user 2026-09-23) — as in the rows and the
|
|
1668
1851
|
* columns. The tile carries no buttons. */
|
|
1669
|
-
onContextMenu={(e, o) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })} />
|
|
1852
|
+
onContextMenu={(e, o) => menu.openAt(e, { type: 'file', path: o.key, o, targets: targetsFor(o.key) })} />}
|
|
1670
1853
|
</div>
|
|
1671
1854
|
{showGridPreview && previewPane()}
|
|
1672
1855
|
</div>
|
|
@@ -1699,10 +1882,10 @@ export function MediaLibraryBrowse({
|
|
|
1699
1882
|
: treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
|
|
1700
1883
|
|
|
1701
1884
|
return (
|
|
1702
|
-
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
1703
|
-
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={
|
|
1704
|
-
onHome={
|
|
1705
|
-
onBucket={(v) => { if (v === 'all')
|
|
1885
|
+
<div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
1886
|
+
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
|
|
1887
|
+
onHome={goTitleRoot}
|
|
1888
|
+
onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
|
|
1706
1889
|
onSettings={() => setSettingsOpen(true)} />
|
|
1707
1890
|
|
|
1708
1891
|
{/* THE BANNER SLOT — anything that must sit directly under the header and above the body.
|
|
@@ -1728,16 +1911,16 @@ export function MediaLibraryBrowse({
|
|
|
1728
1911
|
{/* the elided line — first segment, an ellipsis for anything between,
|
|
1729
1912
|
* and the current one. Below `md` only. */}
|
|
1730
1913
|
<div className="flex md:hidden items-center gap-2 kol-mono-12 min-w-0">
|
|
1731
|
-
<button className={crumbCls(
|
|
1732
|
-
{!
|
|
1914
|
+
<button className={crumbCls(atTitleRoot && !atTop)} onClick={single ? goTitleRoot : goTop}>{ROOT}</button>
|
|
1915
|
+
{!atTitleRoot && bucketMeta.id && crumbs.length > 1 && (
|
|
1733
1916
|
<><span className="text-oq-32">/</span><span className="text-oq-32">…</span></>
|
|
1734
1917
|
)}
|
|
1735
|
-
{!
|
|
1918
|
+
{!atTitleRoot && bucketMeta.id && (
|
|
1736
1919
|
<span className="flex items-center gap-2 min-w-0">
|
|
1737
1920
|
<span className="text-oq-32">/</span>
|
|
1738
1921
|
<button
|
|
1739
1922
|
className={`${crumbCls(true)} truncate`}
|
|
1740
|
-
onClick={() =>
|
|
1923
|
+
onClick={() => goFolder(crumbs.length ? crumbs.join('/') + '/' : '')}
|
|
1741
1924
|
>
|
|
1742
1925
|
{(crumbs[crumbs.length - 1] ?? label).toUpperCase()}
|
|
1743
1926
|
</button>
|
|
@@ -1745,33 +1928,48 @@ export function MediaLibraryBrowse({
|
|
|
1745
1928
|
)}
|
|
1746
1929
|
</div>
|
|
1747
1930
|
<div className="hidden md:flex items-center gap-2 kol-mono-12">
|
|
1748
|
-
<button className={crumbCls(
|
|
1749
|
-
{
|
|
1931
|
+
<button className={crumbCls(atTitleRoot && (!atTop || pickedBucket === ROOT_KEY))} onClick={single ? goTitleRoot : goTop}>{ROOT}</button>
|
|
1932
|
+
{/* A PICKED BUCKET ENDS THE CRUMB at the title level (user 2026-09-24: a selected folder
|
|
1933
|
+
* must show in the crumb, as a selected file does — a bucket is a folder) */}
|
|
1934
|
+
{titleLevel && !atTop && pickedBucket && rootItems.some((it) => it.key === pickedBucket) && (
|
|
1935
|
+
<span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
|
|
1936
|
+
<span className={crumbCls(true)}>{rootItems.find((it) => it.key === pickedBucket).name.toUpperCase()}</span>
|
|
1937
|
+
</span>
|
|
1938
|
+
)}
|
|
1939
|
+
{!atTitleRoot && bucketMeta.id && (
|
|
1750
1940
|
<span className="flex items-center gap-2">
|
|
1751
1941
|
<span className="text-oq-32 select-none">/</span>
|
|
1752
|
-
{crumbs.length === 0 && !
|
|
1942
|
+
{crumbs.length === 0 && !pickedFile && !pickedFolder
|
|
1753
1943
|
? <CopyCrumb className={crumbCls(true)} label={label.toUpperCase()} path={fullPath()} />
|
|
1754
|
-
: <button className={crumbCls(false)} onClick={() =>
|
|
1944
|
+
: <button className={crumbCls(false)} onClick={() => goFolder('')}>{label.toUpperCase()}</button>}
|
|
1755
1945
|
</span>
|
|
1756
1946
|
)}
|
|
1757
|
-
{!
|
|
1947
|
+
{!atTitleRoot && crumbs.map((seg, i) => {
|
|
1758
1948
|
const to = crumbs.slice(0, i + 1).join('/') + '/'
|
|
1759
1949
|
const last = i === crumbs.length - 1
|
|
1760
|
-
const current = last && !
|
|
1950
|
+
const current = last && !pickedFile && !pickedFolder
|
|
1761
1951
|
return (
|
|
1762
1952
|
<span key={to} className="flex items-center gap-2">
|
|
1763
1953
|
<span className="text-oq-32 select-none">/</span>
|
|
1764
1954
|
{current
|
|
1765
1955
|
? <CopyCrumb className={crumbCls(true)} label={seg.toUpperCase()} path={fullPath()} />
|
|
1766
|
-
: <button className={crumbCls(false)} onClick={() =>
|
|
1956
|
+
: <button className={crumbCls(false)} onClick={() => goFolder(to)}>{seg.toUpperCase()}</button>}
|
|
1767
1957
|
</span>
|
|
1768
1958
|
)
|
|
1769
1959
|
})}
|
|
1770
|
-
{
|
|
1960
|
+
{/* THE PICKED FILE ENDS THE CRUMB IN EVERY VIEW (kol-client-olina 2026-09-23 — it was the
|
|
1961
|
+
* columns' alone, so a file picked in rows showed its folder and never itself) */}
|
|
1962
|
+
{pickedFile && pickedFile.key.startsWith(prefix) && (
|
|
1771
1963
|
<span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
|
|
1772
1964
|
<CopyCrumb className={crumbCls(true)} label={pickedFile.key.slice(prefix.length).toUpperCase()} path={fullPath(pickedFile.key.slice(prefix.length))} />
|
|
1773
1965
|
</span>
|
|
1774
1966
|
)}
|
|
1967
|
+
{/* …and so does a picked folder */}
|
|
1968
|
+
{!pickedFile && pickedFolder && pickedFolder !== prefix && pickedFolder.startsWith(prefix) && (
|
|
1969
|
+
<span className="flex items-center gap-2"><span className="text-oq-32 select-none">/</span>
|
|
1970
|
+
<CopyCrumb className={crumbCls(true)} label={pickedFolder.slice(prefix.length).replace(/\/$/, '').toUpperCase()} path={fullPath(pickedFolder.slice(prefix.length).replace(/\/$/, ''))} />
|
|
1971
|
+
</span>
|
|
1972
|
+
)}
|
|
1775
1973
|
</div>
|
|
1776
1974
|
{/* the control cluster is desktop-only — below `md` this is the same
|
|
1777
1975
|
* setting the drawer already carries, and nothing in this row survives 390.
|
|
@@ -1866,7 +2064,9 @@ export function MediaLibraryBrowse({
|
|
|
1866
2064
|
)}
|
|
1867
2065
|
{quickLookFolder && (
|
|
1868
2066
|
<FullscreenOverlay open onClose={() => setQuickLookFolder(null)} scrim>
|
|
1869
|
-
|
|
2067
|
+
{quickLookFolder === ROOT_KEY
|
|
2068
|
+
? previewRoot && <ContainerPreview name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={420} />
|
|
2069
|
+
: <FolderPreview path={quickLookFolder} objects={objects} width={420} formatDate={formatDate} />}
|
|
1870
2070
|
</FullscreenOverlay>
|
|
1871
2071
|
)}
|
|
1872
2072
|
{quickLook && (
|
|
@@ -1906,20 +2106,23 @@ export function MediaLibraryBrowse({
|
|
|
1906
2106
|
* height again, and one shared `gap-3` puts both gaps level on its own. */}
|
|
1907
2107
|
{/* THE COUNT LINE IS EVERY VIEW'S (user 2026-09-23) — and in the grid it carries the tile-size
|
|
1908
2108
|
* slider at its right end, bare: no glyph, no readout (Finder's). */}
|
|
1909
|
-
{
|
|
2109
|
+
{atTitleRoot && !single ? (
|
|
2110
|
+
<div className="flex items-center justify-between gap-4 h-4">
|
|
2111
|
+
<p className="kol-mono-12 text-fg-48">
|
|
2112
|
+
{atTop ? '1 folder' : `${bucketList.length} ${bucketList.length === 1 ? 'bucket' : 'buckets'}`}
|
|
2113
|
+
{folderTree && ` · ${Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · ${formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}`}
|
|
2114
|
+
</p>
|
|
2115
|
+
{isWall && sizeSlider}
|
|
2116
|
+
</div>
|
|
2117
|
+
) : isWall ? (
|
|
1910
2118
|
/* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
|
|
1911
2119
|
* this row 8px taller than the fill budget, so the page scrolled by that much */
|
|
1912
2120
|
<div className="flex items-center justify-between gap-4 h-4">
|
|
1913
2121
|
<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))}
|
|
2122
|
+
{!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
2123
|
</p>
|
|
1916
|
-
|
|
1917
|
-
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
2124
|
+
{sizeSlider}
|
|
1918
2125
|
</div>
|
|
1919
|
-
) : appRoot && folderView === 'columns' && folderTree ? (
|
|
1920
|
-
<p className="kol-mono-12 text-fg-48">
|
|
1921
|
-
{buckets.length} buckets · {Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · {formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}
|
|
1922
|
-
</p>
|
|
1923
2126
|
) : (
|
|
1924
2127
|
<p className="kol-mono-12 text-fg-48">
|
|
1925
2128
|
{folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
|
|
@@ -2106,6 +2309,8 @@ export function WallBody({
|
|
|
2106
2309
|
files, layout = 'list', sortBy = 'name', sortDir = 'asc', pageSize = 200, listId = '', cardMin = 260,
|
|
2107
2310
|
mediaUrl, downloadUrl, keySet, videoPreview, formatDate = defaultFormatDate,
|
|
2108
2311
|
selected, selectMode = false, onToggleSelect, onSorted, onContextMenu, onPick,
|
|
2312
|
+
/* the media grid's folder tiles — see the page's `wallPane` */
|
|
2313
|
+
folders = [], onOpenFolder, onPickFolder, onFolderContextMenu,
|
|
2109
2314
|
/* ONE INSPECTOR (user 2026-09-23). Given `onOpen`, the wall hands the open UP and keeps no
|
|
2110
2315
|
* lightbox of its own — otherwise a page that also opens one (space, a row double-click) ends up
|
|
2111
2316
|
* with two stacked overlays of the same file. The standalone wall passes nothing and keeps its
|
|
@@ -2161,7 +2366,7 @@ export function WallBody({
|
|
|
2161
2366
|
const openAt = (o, idx) => (onOpen ? onOpen(o, sorted) : setLightboxIndex(idx))
|
|
2162
2367
|
return (
|
|
2163
2368
|
<div className="flex flex-col gap-3">
|
|
2164
|
-
{sorted.length === 0 ? null : layout !== 'grid' ? (
|
|
2369
|
+
{sorted.length === 0 && !(layout === 'grid' && tiles === 'media' && folders.length) ? null : layout !== 'grid' ? (
|
|
2165
2370
|
<div className="flex flex-col">
|
|
2166
2371
|
{shown.map((o, idx) => (
|
|
2167
2372
|
<ContentRow key={o.key} variant="default" media={renderThumb(o, null)} title={renderName(o)} date={formatDate(o.uploaded)} size={formatSize(o.size)}
|
|
@@ -2176,6 +2381,13 @@ export function WallBody({
|
|
|
2176
2381
|
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${cardMin}px, 1fr))` }}
|
|
2177
2382
|
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) onBackgroundClick?.() }}>
|
|
2178
2383
|
{marquee?.rect && <div className="kol-marquee" style={marquee.rect} />}
|
|
2384
|
+
{folders.map((path) => (
|
|
2385
|
+
<MediaTile key={path} markKey={path} preview={<Icon name="folder" size="100%" className="text-oq-48" />}
|
|
2386
|
+
name={path.replace(/\/$/, '').split('/').pop()} selected={isSelected(path)}
|
|
2387
|
+
onContextMenu={onFolderContextMenu ? (e) => onFolderContextMenu(e, path) : undefined}
|
|
2388
|
+
onDoubleClick={() => onOpenFolder?.(path)}
|
|
2389
|
+
onClick={onPickFolder ? (e) => onPickFolder(path, e) : undefined} />
|
|
2390
|
+
))}
|
|
2179
2391
|
{shown.map((o, idx) => (
|
|
2180
2392
|
<MediaTile key={o.key} markKey={o.key} preview={renderThumb(o, null)} name={renderName(o)} selected={isSelected(o.key)}
|
|
2181
2393
|
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>
|