@kolkrabbi/kol-component 0.317.3 → 0.317.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.317.
|
|
3
|
+
"version": "0.317.5",
|
|
4
4
|
"description": "The core component library",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@kolkrabbi/kol-icons": "^0.53.
|
|
41
|
+
"@kolkrabbi/kol-icons": "^0.53.5"
|
|
42
42
|
},
|
|
43
43
|
"files": [
|
|
44
44
|
"src",
|
|
@@ -34,7 +34,7 @@ import { RAIL_ROW } from '../hooks/railRow.js'
|
|
|
34
34
|
* Every variant but `stacked`, which has its phone bar
|
|
35
35
|
* @param {'underline'|'chip'|'text'|'stacked'} [variant='underline']
|
|
36
36
|
* @param {string} [look] @deprecated 2026-10-30 — the old spelling of `variant`; `variant` wins when both are passed
|
|
37
|
-
* @param {'mono'|'sans'} [type='mono'] `underline` only: the tab's type
|
|
37
|
+
* @param {'mono'|'sans'|'mono-light'} [type='mono'] `underline` only: the tab's type — `mono-light` is `sans`'s row on JetBrains Mono at 300 (his word, 2026-10-30)
|
|
38
38
|
* @param {boolean} [filled=false] - `chip` only: every chip shows at rest (Button `tab-filled`), so the row reads as choices
|
|
39
39
|
* underline — this row, as it always was; a tab's `icon` sits before its label. `type` picks its type: `mono`
|
|
40
40
|
* (default, the mono-12 row) or `sans` (the neutral sans at 300, a 20px glyph in a 40px row — the
|
|
@@ -274,7 +274,7 @@ function StackedTabs({ tabs, value, onChange, placement, shape = 'bar', collapse
|
|
|
274
274
|
function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, type }) {
|
|
275
275
|
/* `type="sans"` (2026-10-29, the user's ref — Adobe's tab strip): the neutral sans at 300 (his word, 2026-10-30), a 20px glyph at the
|
|
276
276
|
* set's 1.5 stroke and a 1px underline (his word, 2026-10-30), a 40px row (his word, 2026-10-30), 24 between tabs — review 8: *"the problem … was thickness and stroke"* */
|
|
277
|
-
const md = type === 'sans'
|
|
277
|
+
const md = type === 'sans' || type === 'mono-light'
|
|
278
278
|
const listRef = useRef(null)
|
|
279
279
|
const activeIdx = tabs.findIndex((t) => t.id === value)
|
|
280
280
|
|
|
@@ -308,15 +308,15 @@ function UnderlineTabs({ tabs, value, onChange, onClose, onMinimise, wrap, type
|
|
|
308
308
|
tabIndex={isActive || (activeIdx === -1 && i === 0) ? 0 : -1}
|
|
309
309
|
onClick={() => onChange?.(t.id)}
|
|
310
310
|
className={[
|
|
311
|
-
`group ${md ? 'kol-tab-sans kol-sans-body-02 font-light gap-2.5 border-b
|
|
311
|
+
`group ${md ? `${type === 'sans' ? 'kol-tab-sans' : 'kol-tab-mono-light'} kol-sans-body-02 font-light gap-2.5 border-b` : 'kol-mono-12 gap-2 border-b-2'} flex items-center cursor-pointer`,
|
|
312
312
|
isActive
|
|
313
|
-
? 'text-
|
|
314
|
-
: 'text-
|
|
313
|
+
? 'text-oq-96 border-fg'
|
|
314
|
+
: 'text-oq-48 hover:text-oq-80 border-transparent',
|
|
315
315
|
].join(' ')}
|
|
316
316
|
>
|
|
317
317
|
{/* a tab may carry its glyph before the label (2026-10-29, Adobe's Base color · Image · Color wheel) */}
|
|
318
318
|
{/* the glyph in OPAQUE ink (icons take oq, never fg) — full when active, meta otherwise, full under the pointer */}
|
|
319
|
-
{t.icon && <Icon name={t.icon} size={md ? 20 : 16} className={isActive ? 'text-oq-96' : 'text-oq-48 group-hover:text-oq-
|
|
319
|
+
{t.icon && <Icon name={t.icon} size={md ? 20 : 16} className={isActive ? 'text-oq-96' : 'text-oq-48 group-hover:text-oq-80'} />}
|
|
320
320
|
{t.label}
|
|
321
321
|
</button>
|
|
322
322
|
)
|
|
@@ -78,6 +78,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
|
78
78
|
* so a consumer hands back what it stored. A key with no column is ignored;
|
|
79
79
|
* any column it does not name falls back to the drag state, then `columnWidth`
|
|
80
80
|
* @param {Function} onColumnResize (index, px) => void — the column's index, or `'preview'`
|
|
81
|
+
* @param {'inline'|'dock'} previewPlacement the preview column after the last column (default), or docked at the right edge, the columns scrolling to its left; empty until something is shown
|
|
81
82
|
* @param {Function} onDropFiles (files, folderPath) => void — OS files dropped on a folder row or column; the consumer uploads
|
|
82
83
|
* @param {string} className extra classes on the browser
|
|
83
84
|
*
|
|
@@ -521,6 +522,14 @@ export default function ColumnBrowser({
|
|
|
521
522
|
/* `renderDetails(file)` — extra rows under the preview column's facts (tags, an Edit action);
|
|
522
523
|
* absent, the column is exactly what it was */
|
|
523
524
|
renderDetails,
|
|
525
|
+
/* `renderFolderPreview(level, width)` — the preview column when NO file is shown: the open
|
|
526
|
+
* folder's own pane (MediaLibraryInfoKey, kol-website 2026-10-30 — the media page's folder facts,
|
|
527
|
+
* as its rows draw them). Null for a level with nothing to say; absent, the column is empty. */
|
|
528
|
+
renderFolderPreview,
|
|
529
|
+
/* `previewPlacement` — where the preview column sits (ColumnPreviewDocked, kol-website 2026-10-30):
|
|
530
|
+
* `'inline'` the column after the last one, as ever; `'dock'` pinned at the right edge beside a
|
|
531
|
+
* self-scrolling strip of columns, the way the media page's Rows and Grid dock theirs. */
|
|
532
|
+
previewPlacement = 'inline',
|
|
524
533
|
height,
|
|
525
534
|
defaultHeight = 528,
|
|
526
535
|
onHeightChange,
|
|
@@ -798,6 +807,8 @@ export default function ColumnBrowser({
|
|
|
798
807
|
const levels = levelsOf(prefix) // '' → ['']; 'a/b/' → ['', 'a/', 'a/b/']
|
|
799
808
|
// While Quick Look is open the overlay's index is the truth for the highlight.
|
|
800
809
|
const shown = quickLook ? quickLook.files[quickLook.index] : picked
|
|
810
|
+
/* no file shown: the open folder's pane, when the caller has one for this level */
|
|
811
|
+
const folderPane = !shown && renderFolderPreview ? renderFolderPreview(prefix, widthOf('preview')) : null
|
|
801
812
|
|
|
802
813
|
/* ── THE STACK MODE (ColumnBrowserStackMode, kol-r2b2 2026-09-03) ─────────
|
|
803
814
|
*
|
|
@@ -838,7 +849,7 @@ export default function ColumnBrowser({
|
|
|
838
849
|
useEffect(() => {
|
|
839
850
|
const box = marquee.ref.current
|
|
840
851
|
if (box && box.scrollWidth > box.clientWidth) box.scrollTo({ left: box.scrollWidth, behavior: 'smooth' })
|
|
841
|
-
}, [prefix, picked?.key, selectedKeys?.size]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
852
|
+
}, [prefix, picked?.key, selectedKeys?.size, previewPlacement]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
842
853
|
|
|
843
854
|
if (stack) {
|
|
844
855
|
/* THE CURRENT LEVEL IS THE LIST (StackModeChromeAndAncestors, kol-r2b2
|
|
@@ -983,19 +994,16 @@ export default function ColumnBrowser({
|
|
|
983
994
|
)
|
|
984
995
|
}
|
|
985
996
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
{/* A CLICK ON THE BACKGROUND DESELECTS (user 2026-09-22) — the band's own closing click is
|
|
997
|
-
swallowed by the hook, so this only ever fires on a real click into empty space. */}
|
|
998
|
-
<div ref={marquee.ref} {...marquee.props} className="relative flex h-full overflow-x-auto"
|
|
997
|
+
/* the preview pane: several selected → the stack; a file → its preview; else the open folder's pane */
|
|
998
|
+
const dock = previewPlacement === 'dock'
|
|
999
|
+
const pane = selectedKeys?.size > 1
|
|
1000
|
+
? <SelectionPreview keys={[...selectedKeys]} objects={objects} urlOf={urlOf} kindOf={kindOf} formatSize={formatSize} formatDate={formatDate} width={widthOf('preview')} />
|
|
1001
|
+
: shown
|
|
1002
|
+
? <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} renderDetails={renderDetails} width={widthOf('preview')} />
|
|
1003
|
+
: folderPane
|
|
1004
|
+
/* the scrolling strip of columns; inline, the pane rides at its end */
|
|
1005
|
+
const strip = (cls, after) => (
|
|
1006
|
+
<div ref={marquee.ref} {...marquee.props} className={`relative flex h-full overflow-x-auto${cls}`}
|
|
999
1007
|
onClick={(e) => { if (!e.target.closest('[data-marquee-key]')) { pick(null); onSelectKeys?.([], false) } }}>
|
|
1000
1008
|
{marquee.rect && <div className="kol-marquee" style={marquee.rect} />}
|
|
1001
1009
|
{levels.map((level, k) => {
|
|
@@ -1088,18 +1096,39 @@ export default function ColumnBrowser({
|
|
|
1088
1096
|
</Fragment>
|
|
1089
1097
|
)
|
|
1090
1098
|
})}
|
|
1091
|
-
{
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1099
|
+
{after}
|
|
1100
|
+
</div>
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
return (
|
|
1104
|
+
<div
|
|
1105
|
+
ref={rootRef}
|
|
1106
|
+
tabIndex={0}
|
|
1107
|
+
onKeyDown={onKeyDown}
|
|
1108
|
+
// 12 rows of 44px by default (user ruling 2026-08-27), so the browser never jumps as columns change; each column scrolls.
|
|
1109
|
+
// The scroll box is the INNER flex row: the bottom handle is absolute on this root, so it spans the visible width, not the scrolled one.
|
|
1110
|
+
className={`kol-column-browser relative border rounded outline-none ${className}`.trim()}
|
|
1111
|
+
style={{ borderColor: 'var(--kol-oq-08)', height: h }}
|
|
1112
|
+
>
|
|
1113
|
+
{/* A CLICK ON THE BACKGROUND DESELECTS (user 2026-09-22) — the band's own closing click is
|
|
1114
|
+
swallowed by the hook, so this only ever fires on a real click into empty space. */}
|
|
1115
|
+
{dock ? (
|
|
1116
|
+
/* the strip tucks 1px under the pane's edge, so once the columns overflow the last one's edge
|
|
1117
|
+
* and the divider are one line (`.kol-column-browser-strip--dock`, kol-theme) */
|
|
1118
|
+
<div className="flex h-full">
|
|
1119
|
+
{strip(' kol-column-browser-strip--dock flex-1 min-w-0 -mr-px')}
|
|
1120
|
+
{/* the handle sits on the pane's LEFT edge, so a drag to the left widens it */}
|
|
1121
|
+
<ResizeHandle axis="x" onDrag={(dx) => resizeCol('preview')(-dx)} onEnd={endDrag} />
|
|
1122
|
+
<div className="relative flex shrink-0 border-l" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
1123
|
+
{pane || <div className="kol-column-browser-preview shrink-0" style={{ width: widthOf('preview') }} />}
|
|
1124
|
+
</div>
|
|
1125
|
+
</div>
|
|
1126
|
+
) : strip('', pane && (
|
|
1097
1127
|
<>
|
|
1098
|
-
|
|
1128
|
+
{pane}
|
|
1099
1129
|
<ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
|
|
1100
1130
|
</>
|
|
1101
|
-
)}
|
|
1102
|
-
</div>
|
|
1131
|
+
))}
|
|
1103
1132
|
{/* A FILL HEIGHT HAS NO GRABBER (user 2026-09-22). A CSS length says "take the room you
|
|
1104
1133
|
* are given"; dragging would fight it. A px height keeps its handle. */}
|
|
1105
1134
|
{typeof h !== 'string' && <ResizeHandle axis="y" onDrag={resizeH} onEnd={endDrag} />}
|
|
@@ -1109,7 +1138,7 @@ export default function ColumnBrowser({
|
|
|
1109
1138
|
|
|
1110
1139
|
/* The preview column, for the media page's ROW view (user 2026-09-22 — Finder's "Show preview"):
|
|
1111
1140
|
* one preview, the same pane beside rows as beside columns. Not in the barrel. */
|
|
1112
|
-
export { Preview, SelectionPreview }
|
|
1141
|
+
export { Preview, SelectionPreview, COL_ICON }
|
|
1113
1142
|
|
|
1114
1143
|
/* SelectionPreview — the preview pane for MORE THAN ONE selected item (user 2026-09-23, Finder's:
|
|
1115
1144
|
* *"preview of 3 overlapping files, 2 and 3 slightly rotated behind 1 … the info fields should say
|
|
@@ -42,7 +42,7 @@ import DocumentEditor from './DocumentEditor.jsx'
|
|
|
42
42
|
import { listDrafts, moveDrafts, DRAFTS_EVENT } from '../utilities/localDrafts.js'
|
|
43
43
|
import { parseFrontmatter } from '@kolkrabbi/kol-markdown'
|
|
44
44
|
import CommandOverlay from './CommandOverlay.jsx'
|
|
45
|
-
import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
|
|
45
|
+
import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview, COL_ICON } from './ColumnBrowser.jsx'
|
|
46
46
|
import SettingsPanel, { SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
|
|
47
47
|
import SettingsSections from './SettingsSections.jsx'
|
|
48
48
|
import { kindOf, extOf, KIND_LABEL, KINDS, DEFAULT_KINDS, isSystemFile, isSegment, groupSegments, groupVariants, posterFor, partition } from '../utilities/mediaKinds.js'
|
|
@@ -285,22 +285,28 @@ function ImageFrame({ src, vector, mode = 'native' }) {
|
|
|
285
285
|
)
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
/*
|
|
289
|
-
* not on AppHub, which binds its own)
|
|
290
|
-
*
|
|
291
|
-
|
|
288
|
+
/* THE PAGE'S OWN KEYS, ONE GUARD. `,` toggles the settings drawer (MediaPreviewNativeRatio,
|
|
289
|
+
* kol-website 2026-10-30: the media app is not on AppHub, which binds its own); `mod+i` opens Get
|
|
290
|
+
* Info (MediaLibraryInfoKey, kol-website 2026-10-30). AppHub's guard — no other modifiers, no
|
|
291
|
+
* repeat, not while typing. A bare key takes no modifier at all; `mod` is ⌘ or Ctrl. `on` false
|
|
292
|
+
* hands the key to the host (the gear's `onOpenSettings`), or holds it while an overlay is up. */
|
|
293
|
+
function useHotkey(combo, on, run) {
|
|
292
294
|
useEffect(() => {
|
|
293
|
-
if (!
|
|
295
|
+
if (!combo || !on) return undefined
|
|
296
|
+
const parts = combo.toLowerCase().split('+')
|
|
297
|
+
const key = parts.pop()
|
|
298
|
+
const mod = parts.includes('mod')
|
|
294
299
|
const onKey = (e) => {
|
|
295
|
-
if (!(e.key === key || (key === ',' && e.code === 'Comma')) || e.
|
|
300
|
+
if (!(e.key?.toLowerCase() === key || (key === ',' && e.code === 'Comma')) || e.altKey || e.repeat) return
|
|
301
|
+
if (mod ? !(e.metaKey || e.ctrlKey) : (e.metaKey || e.ctrlKey)) return
|
|
296
302
|
const el = e.target
|
|
297
303
|
if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
|
|
298
304
|
e.preventDefault()
|
|
299
|
-
|
|
305
|
+
run()
|
|
300
306
|
}
|
|
301
307
|
window.addEventListener('keydown', onKey)
|
|
302
308
|
return () => window.removeEventListener('keydown', onKey)
|
|
303
|
-
}, [
|
|
309
|
+
}, [combo, on, run])
|
|
304
310
|
}
|
|
305
311
|
|
|
306
312
|
|
|
@@ -530,6 +536,105 @@ function ContainerPreview({ name, icon, facts, width, details }) {
|
|
|
530
536
|
)
|
|
531
537
|
}
|
|
532
538
|
|
|
539
|
+
/* GET INFO (MediaGetInfoModal, kol-website 2026-10-30) — Finder's ⌘I body, one for a folder and a
|
|
540
|
+
* file, drawn inside Quick Look's window (`QuickLookFrame` in a `FullscreenOverlay`, as
|
|
541
|
+
* `MediaInspector` draws it): the head row (a glyph no taller than its lines, the name, the size,
|
|
542
|
+
* "Modified"), the tags, General as key/value rows, then a file's small preview. Never the pane's
|
|
543
|
+
* full-height plate. `general` is `[label, value]` pairs. */
|
|
544
|
+
function InfoBody({ icon, name, size, modified, tags, general, preview }) {
|
|
545
|
+
return (
|
|
546
|
+
<div className="flex-1 self-start w-[min(var(--kol-ql-max-w),420px)] max-h-[var(--kol-ql-media-h)] overflow-y-auto p-4 flex flex-col gap-4">
|
|
547
|
+
<div className="flex items-start gap-3">
|
|
548
|
+
<IconFrame name={icon} variant="ghost" size="sm" />
|
|
549
|
+
<div className="flex-1 min-w-0 flex flex-col gap-0.5 kol-mono-12">
|
|
550
|
+
<div className="flex items-baseline justify-between gap-4">
|
|
551
|
+
<span className="text-strong break-all">{name}</span>
|
|
552
|
+
{size && <span className="text-fg-default shrink-0">{size}</span>}
|
|
553
|
+
</div>
|
|
554
|
+
{modified && <span className="text-meta">Modified {modified}</span>}
|
|
555
|
+
</div>
|
|
556
|
+
</div>
|
|
557
|
+
{tags}
|
|
558
|
+
<dl className="flex flex-col gap-1">
|
|
559
|
+
{general.map(([k, v]) => (
|
|
560
|
+
<div key={k} className="flex justify-between gap-4 kol-mono-12">
|
|
561
|
+
<dt className="text-meta shrink-0">{k}</dt>
|
|
562
|
+
<dd className="text-fg-default text-right break-all">{v}</dd>
|
|
563
|
+
</div>
|
|
564
|
+
))}
|
|
565
|
+
</dl>
|
|
566
|
+
{preview}
|
|
567
|
+
</div>
|
|
568
|
+
)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/* the size as Finder's Get Info writes it: bytes, then readable, then the count for a folder */
|
|
572
|
+
const infoSize = (bytes, items) => `${bytes.toLocaleString('en-US')} bytes (${formatSize(bytes)})${items != null ? ` for ${items} item${items === 1 ? '' : 's'}` : ''}`
|
|
573
|
+
|
|
574
|
+
/* ⌘I's modal: a folder's or a file's Get Info in Quick Look's window. `path` is a folder (ends `/`) or
|
|
575
|
+
* a file key; `where` is the bucket's label, the head of the Where breadcrumb. */
|
|
576
|
+
function GetInfoModal({ path, objects, where, onClose, formatDate, tags, mediaUrl, downloadUrl, hasUrl = true, keySet }) {
|
|
577
|
+
const [dims, setDims] = useState(null)
|
|
578
|
+
const folder = path.endsWith('/')
|
|
579
|
+
const o = folder ? null : objects.find((x) => x.key === path)
|
|
580
|
+
const parts = path.replace(/\/$/, '').split('/')
|
|
581
|
+
const name = o ? (o.displayKey ?? o.displayName ?? parts[parts.length - 1]) : parts[parts.length - 1]
|
|
582
|
+
const crumb = [where, ...parts.slice(0, -1)].join(' › ')
|
|
583
|
+
let frame, body
|
|
584
|
+
if (folder) {
|
|
585
|
+
const inside = objects.filter((x) => x.key.startsWith(path))
|
|
586
|
+
const bytes = inside.reduce((n, x) => n + (x.size ?? 0), 0)
|
|
587
|
+
const last = inside.reduce((t, x) => (x.uploaded && x.uploaded > t ? x.uploaded : t), '')
|
|
588
|
+
frame = { title: name, meta: [`${inside.length} items`, formatSize(bytes)].join(' · ') }
|
|
589
|
+
body = <InfoBody icon="folder" name={name} size={formatSize(bytes)} modified={last ? formatDate(last) : null} tags={tags}
|
|
590
|
+
general={[['Kind', 'Folder'], ['Size', infoSize(bytes, inside.length)], ['Where', crumb], ...(last ? [['Newest', formatDate(last) || '—']] : [])]} />
|
|
591
|
+
} else {
|
|
592
|
+
if (!o) return null
|
|
593
|
+
const kind = kindOf(o)
|
|
594
|
+
const url = hasUrl ? mediaUrl(o.key) : null
|
|
595
|
+
const sized = isImage(o.contentType) || kind === 'video'
|
|
596
|
+
const timed = kind === 'video' || kind === 'audio'
|
|
597
|
+
const poster = kind === 'video' ? posterFor(o.key, keySet) : null
|
|
598
|
+
const onMeta = (e) => { const t = e.currentTarget; setDims({ w: t.videoWidth, h: t.videoHeight, len: t.duration }) }
|
|
599
|
+
frame = {
|
|
600
|
+
title: name,
|
|
601
|
+
meta: [formatSize(o.size), dims?.w && `${dims.w} × ${dims.h} px`, dims?.len && formatLength(dims.len)].filter(Boolean).join(' · '),
|
|
602
|
+
actions: url && (
|
|
603
|
+
<span className="flex items-center gap-1">
|
|
604
|
+
<CopyAction label="Copy URL" text={url} />
|
|
605
|
+
<Tooltip label="Download">
|
|
606
|
+
<Button variant="nav" size="sm" iconOnly="download" href={downloadUrl(o.key)} download={o.key.split('/').pop()} aria-label="Download" />
|
|
607
|
+
</Tooltip>
|
|
608
|
+
</span>
|
|
609
|
+
),
|
|
610
|
+
}
|
|
611
|
+
body = <InfoBody icon={COL_ICON[kind] || 'file'} name={name} size={formatSize(o.size)} modified={o.uploaded ? formatDate(o.uploaded) : null} tags={tags}
|
|
612
|
+
general={[
|
|
613
|
+
['Kind', KIND_LABEL[kind] || 'file'],
|
|
614
|
+
['Type', o.contentType || '—'],
|
|
615
|
+
['Size', infoSize(o.size ?? 0)],
|
|
616
|
+
['Where', crumb],
|
|
617
|
+
['Modified', formatDate(o.uploaded) || '—'],
|
|
618
|
+
...(sized ? [['Dimensions', dims?.w ? `${dims.w} × ${dims.h} px` : url ? '…' : '—']] : []),
|
|
619
|
+
...(timed ? [['Length', dims?.len ? formatLength(dims.len) : url ? '…' : '—']] : []),
|
|
620
|
+
...(url ? [['URL', <span key="u" className="inline-flex items-center gap-1">{url}<CopyAction label="Copy URL" text={url} /></span>]] : []),
|
|
621
|
+
]}
|
|
622
|
+
preview={url && (isImage(o.contentType) ? (
|
|
623
|
+
<img src={url} alt="" className="w-full h-auto rounded" onLoad={(e) => setDims({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })} />
|
|
624
|
+
) : kind === 'video' ? (
|
|
625
|
+
<video src={url} poster={poster ? mediaUrl(poster) : undefined} preload="metadata" controls className="w-full h-auto rounded" onLoadedMetadata={onMeta} />
|
|
626
|
+
) : kind === 'audio' ? (
|
|
627
|
+
/* no picture to show; loaded only for its length */
|
|
628
|
+
<audio src={url} preload="metadata" className="hidden" onLoadedMetadata={onMeta} />
|
|
629
|
+
) : null)} />
|
|
630
|
+
}
|
|
631
|
+
return (
|
|
632
|
+
<FullscreenOverlay open onClose={onClose} closeButton={false} scrim>
|
|
633
|
+
<QuickLookFrame {...frame} onClose={onClose}>{body}</QuickLookFrame>
|
|
634
|
+
</FullscreenOverlay>
|
|
635
|
+
)
|
|
636
|
+
}
|
|
637
|
+
|
|
533
638
|
/* THE COLUMN HEADER — Name · Date · Size, each one the sort control for its own column (user
|
|
534
639
|
* 2026-09-22). Unlabelled meta at the end of a row is a number nobody can read a column of; a
|
|
535
640
|
* header names them AND gives them the one behaviour a labelled column implies. Clicking the
|
|
@@ -1160,6 +1265,16 @@ export function MediaLibraryBrowse({
|
|
|
1160
1265
|
settingsKey = ',',
|
|
1161
1266
|
settingsOpen: settingsOpenProp,
|
|
1162
1267
|
onSettingsOpenChange,
|
|
1268
|
+
/* `infoKey` — Get Info on the pick, file or folder, in every view (MediaLibraryInfoKey, kol-website
|
|
1269
|
+
* 2026-10-30; Finder's ⌘I): the same overlay Space opens, Esc closes. `'mod+i'`; `false` for none.
|
|
1270
|
+
* `columnFolderInfo` — Columns: the open folder's facts pane in the preview column, as the rows
|
|
1271
|
+
* draw a picked folder's; `false` leaves the column empty, as before. */
|
|
1272
|
+
infoKey = 'mod+i',
|
|
1273
|
+
columnFolderInfo = true,
|
|
1274
|
+
/* `previewPlacement` — Columns: the preview pane docked at the right edge as Rows and Grid dock
|
|
1275
|
+
* theirs (`'dock'`, ColumnPreviewDocked, kol-website 2026-10-30), or the column after the last
|
|
1276
|
+
* one (`'inline'`, as before). Handed to ColumnBrowser. */
|
|
1277
|
+
previewPlacement = 'dock',
|
|
1163
1278
|
}) {
|
|
1164
1279
|
const [ownPrefix, setOwnPrefix] = useState('')
|
|
1165
1280
|
const prefix = prefixProp ?? ownPrefix
|
|
@@ -1177,7 +1292,7 @@ export function MediaLibraryBrowse({
|
|
|
1177
1292
|
const [ownSettingsOpen, setOwnSettingsOpen] = useState(false)
|
|
1178
1293
|
const settingsOpen = settingsOpenProp ?? ownSettingsOpen
|
|
1179
1294
|
const setSettingsOpen = (v) => { if (settingsOpenProp == null) setOwnSettingsOpen(v); onSettingsOpenChange?.(v) }
|
|
1180
|
-
|
|
1295
|
+
useHotkey(settingsKey, !onOpenSettings, () => setSettingsOpen(!settingsOpen))
|
|
1181
1296
|
const [trashOpen, setTrashOpen] = useState(false)
|
|
1182
1297
|
const [pickedFile, setPickedFile] = useState(null)
|
|
1183
1298
|
useEffect(() => { onPickFile?.(pickedFile) }, [pickedFile]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
@@ -1679,14 +1794,18 @@ export function MediaLibraryBrowse({
|
|
|
1679
1794
|
setPickedFolder(null); setPickedFile(o)
|
|
1680
1795
|
}
|
|
1681
1796
|
}
|
|
1682
|
-
const spaceQuickLook = () => {
|
|
1683
|
-
|
|
1797
|
+
const spaceQuickLook = (info) => {
|
|
1798
|
+
/* in the columns a folder is picked by being OPEN — the deepest level (MediaLibraryInfoKey: ⌘I
|
|
1799
|
+
* is the only caller there; the columns keep their own Space) */
|
|
1800
|
+
const key = pickedFile?.key ?? (view === 'columns' ? (prefix || null) : pickedFolder ?? (rowSelection.size === 1 ? [...rowSelection][0] : null))
|
|
1684
1801
|
/* a picked bucket or the title Quick-Looks like any folder (user 2026-09-25) */
|
|
1685
|
-
if (!key && previewRoot) {
|
|
1802
|
+
if (!key && previewRoot) { setInfoFor(ROOT_KEY); return }
|
|
1686
1803
|
if (!key) return
|
|
1687
1804
|
/* A FOLDER QUICK-LOOKS TOO (user 2026-09-23: *"anything can be quick viewed, also folders"*) —
|
|
1688
1805
|
* the same pane the preview column draws, at overlay size. */
|
|
1689
|
-
if (key.endsWith('/')) {
|
|
1806
|
+
if (key.endsWith('/')) { setInfoFor(key); return }
|
|
1807
|
+
/* ⌘I on a file is its Get Info, not the media viewer (MediaGetInfoModal) */
|
|
1808
|
+
if (info === true) { setInfoFor(key); return }
|
|
1690
1809
|
const files = isWall
|
|
1691
1810
|
? wallFiles
|
|
1692
1811
|
: rowLevel(key.slice(0, key.lastIndexOf('/') + 1), sortedObjects).files
|
|
@@ -1715,7 +1834,7 @@ export function MediaLibraryBrowse({
|
|
|
1715
1834
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return false
|
|
1716
1835
|
/* ⇧+ARROWS KEEP SELECTING UNDER QUICK LOOK (user 2026-09-24), Finder's: the selection grows
|
|
1717
1836
|
* underneath and the window pages through it */
|
|
1718
|
-
if (view === 'columns' || (quickLook && !e.shiftKey) ||
|
|
1837
|
+
if (view === 'columns' || (quickLook && !e.shiftKey) || infoFor || searchOpen) return false
|
|
1719
1838
|
if (atTitleRoot && !single) {
|
|
1720
1839
|
if (e.metaKey && e.key === 'ArrowUp') goUp()
|
|
1721
1840
|
else if (e.metaKey && e.key === 'ArrowDown') { if (atTop) goTitleRoot(); else openBucket(bucketList.find((b) => (b.id ?? b.label) === pickedBucket) ?? bucketMeta) }
|
|
@@ -1820,7 +1939,7 @@ export function MediaLibraryBrowse({
|
|
|
1820
1939
|
/* ENTER OPENS THE SELECTED FOLDER, ⇧ENTER GOES TO THE PARENT (user 2026-09-24). The same
|
|
1821
1940
|
* two moves as a double-click and ⌘↑, so the child you leave is still the one selected.
|
|
1822
1941
|
* A focused tile has already opened itself on Enter (it calls preventDefault). */
|
|
1823
|
-
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.defaultPrevented && !quickLook && !
|
|
1942
|
+
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.defaultPrevented && !quickLook && !infoFor && !searchOpen) {
|
|
1824
1943
|
if (e.shiftKey) { e.preventDefault(); goUp(); return }
|
|
1825
1944
|
if (view !== 'columns') {
|
|
1826
1945
|
const root = rootItems.find((it) => it.key === pickedBucket)
|
|
@@ -1833,7 +1952,7 @@ export function MediaLibraryBrowse({
|
|
|
1833
1952
|
if (e.key === ' ' && view !== 'columns' && !searchOpen) {
|
|
1834
1953
|
e.preventDefault()
|
|
1835
1954
|
if (quickLook) closeQuickLook()
|
|
1836
|
-
else if (
|
|
1955
|
+
else if (infoFor) setInfoFor(null)
|
|
1837
1956
|
else spaceQuickLook()
|
|
1838
1957
|
}
|
|
1839
1958
|
}
|
|
@@ -1842,7 +1961,10 @@ export function MediaLibraryBrowse({
|
|
|
1842
1961
|
})
|
|
1843
1962
|
|
|
1844
1963
|
const [quickLook, setQuickLook] = useState(null)
|
|
1845
|
-
const [
|
|
1964
|
+
const [infoFor, setInfoFor] = useState(null)
|
|
1965
|
+
/* ⌘I — GET INFO (MediaLibraryInfoKey; MediaGetInfoModal: the Get Info modal, file or folder),
|
|
1966
|
+
* from every view; ⌘I again or Esc closes it; inert over Quick Look and the search modal */
|
|
1967
|
+
useHotkey(infoKey, !quickLook && !searchOpen, () => (infoFor ? setInfoFor(null) : spaceQuickLook(true)))
|
|
1846
1968
|
/* …and the open window takes the new selection as its set, on the file the cursor reached */
|
|
1847
1969
|
useEffect(() => {
|
|
1848
1970
|
if (!quickLook || rowSelection.size < 2) return
|
|
@@ -2107,6 +2229,7 @@ export function MediaLibraryBrowse({
|
|
|
2107
2229
|
height={colH}
|
|
2108
2230
|
onHeightChange={onColH}
|
|
2109
2231
|
rowSize={rowSize}
|
|
2232
|
+
previewPlacement={previewPlacement}
|
|
2110
2233
|
columnWidths={settings.columnWidths}
|
|
2111
2234
|
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
2112
2235
|
objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
@@ -2136,6 +2259,15 @@ export function MediaLibraryBrowse({
|
|
|
2136
2259
|
owns it through settings (item 15) */
|
|
2137
2260
|
stackView={stackView ?? settings.stackView ?? 'list'}
|
|
2138
2261
|
renderDetails={(o) => detailsFor({ ...o, key: o.key.slice(VROOT.length) })}
|
|
2262
|
+
/* THE OPEN FOLDER'S FACTS IN THE PREVIEW COLUMN (MediaLibraryInfoKey, kol-website
|
|
2263
|
+
* 2026-10-30: picking a folder showed nothing beside the list) — the rows' pane, drawn
|
|
2264
|
+
* where the columns put a file's. A level above the bucket (the title, a bucket) has
|
|
2265
|
+
* no pane here, as before. */
|
|
2266
|
+
renderFolderPreview={columnFolderInfo ? (level, width) => {
|
|
2267
|
+
const real = unroot(level)
|
|
2268
|
+
if (!real || (VROOT && real === level)) return null
|
|
2269
|
+
return <FolderPreview key={real} path={real} objects={objects} width={width} formatDate={formatDate} details={detailsFor(real)} />
|
|
2270
|
+
} : undefined}
|
|
2139
2271
|
renderPreview={(o) => {
|
|
2140
2272
|
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
2141
2273
|
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} vector={real.contentType === 'image/svg+xml'} mode={previewFrame} />
|
|
@@ -2604,13 +2736,21 @@ export function MediaLibraryBrowse({
|
|
|
2604
2736
|
onOpenResults={(q) => { setSettings({ ...settings, filters: true }); setBarQuery(q) }}
|
|
2605
2737
|
/>
|
|
2606
2738
|
)}
|
|
2607
|
-
{
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2739
|
+
{/* GET INFO (MediaGetInfoModal, kol-website 2026-10-30): ⌘I, and Space on a folder, open Finder's
|
|
2740
|
+
* Get Info in Quick Look's window — never the pane's plate at overlay size */}
|
|
2741
|
+
{infoFor && (infoFor === ROOT_KEY ? (
|
|
2742
|
+
<FullscreenOverlay open onClose={() => setInfoFor(null)} closeButton={false} scrim>
|
|
2743
|
+
{previewRoot && (
|
|
2744
|
+
<QuickLookFrame title={previewRoot.name} onClose={() => setInfoFor(null)}>
|
|
2745
|
+
<InfoBody icon={previewRoot.icon} name={previewRoot.name} general={previewRoot.facts} />
|
|
2746
|
+
</QuickLookFrame>
|
|
2747
|
+
)}
|
|
2612
2748
|
</FullscreenOverlay>
|
|
2613
|
-
)
|
|
2749
|
+
) : (
|
|
2750
|
+
<GetInfoModal path={infoFor} objects={objects} where={bucketMeta.label || 'bucket'} onClose={() => setInfoFor(null)} formatDate={formatDate}
|
|
2751
|
+
tags={(canTag || tagsOf(infoFor).length > 0) && <TagEditor tags={tagsOf(infoFor)} suggestions={allTags} onChange={canTag ? (next) => saveTags(infoFor, next) : undefined} />}
|
|
2752
|
+
mediaUrl={mediaUrl} downloadUrl={downloadUrl} hasUrl={hasUrl} keySet={keySet} />
|
|
2753
|
+
))}
|
|
2614
2754
|
{quickLook && (
|
|
2615
2755
|
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} hasUrl={hasUrl} keySet={keySet} showNav={rowSelection.size > 1}
|
|
2616
2756
|
onEdit={canEdit ? (f) => { closeQuickLook(); openEditor(f) } : undefined}
|
|
@@ -3065,7 +3205,7 @@ export function MediaLibraryLibrary({
|
|
|
3065
3205
|
const [ownSettingsOpen, setOwnSettingsOpen] = useState(false)
|
|
3066
3206
|
const settingsOpen = settingsOpenProp ?? ownSettingsOpen
|
|
3067
3207
|
const setSettingsOpen = (v) => { if (settingsOpenProp == null) setOwnSettingsOpen(v); onSettingsOpenChange?.(v) }
|
|
3068
|
-
|
|
3208
|
+
useHotkey(settingsKey, header, () => setSettingsOpen(!settingsOpen))
|
|
3069
3209
|
const [editingKey, setEditingKey] = useState(null)
|
|
3070
3210
|
const [editingValue, setEditingValue] = useState('')
|
|
3071
3211
|
const [renaming, setRenaming] = useState(false)
|