@kolkrabbi/kol-component 0.215.0 → 0.217.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 +5 -4
- package/src/atoms/ActionButton.jsx +10 -9
- package/src/atoms/FileIcon.jsx +31 -0
- package/src/atoms/ViewToggle.jsx +35 -17
- package/src/hooks/useMarquee.js +93 -0
- package/src/index.js +5 -0
- package/src/molecules/AudioPreview.jsx +4 -2
- package/src/molecules/AudioSheet.jsx +22 -36
- package/src/molecules/CodeBlock.jsx +4 -2
- package/src/molecules/ContentCard.jsx +13 -0
- package/src/molecules/ContentRow.jsx +9 -0
- package/src/molecules/DocFrontmatter.jsx +1 -1
- package/src/molecules/KindPreview.jsx +91 -5
- package/src/molecules/MediaTile.jsx +29 -0
- package/src/molecules/Modal.jsx +25 -12
- package/src/molecules/PdfPage.jsx +110 -0
- package/src/molecules/PlaybackBar.jsx +16 -8
- package/src/molecules/QuickLookFrame.jsx +88 -0
- package/src/molecules/ShellDrawer.jsx +10 -4
- package/src/molecules/Stepper.jsx +2 -2
- package/src/molecules/VideoSheet.jsx +12 -11
- package/src/organisms/ColumnBrowser.jsx +236 -27
- package/src/organisms/ContentFilters.jsx +29 -13
- package/src/organisms/MediaLibrary.jsx +25 -12
- package/src/organisms/MediaLibraryExplorer.jsx +31 -0
- package/src/organisms/MediaLibraryPages.jsx +1662 -198
- package/src/organisms/SettingsPanel.jsx +16 -6
- package/src/utilities/ContextMenu.jsx +80 -0
- package/src/utilities/FullscreenOverlay.jsx +17 -3
- package/src/utilities/Popover.jsx +11 -4
- package/src/utilities/id3.js +3 -0
- package/src/utilities/layerStack.js +18 -0
- package/src/utilities/mediaKinds.js +10 -4
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "KOL design-system components
|
|
3
|
+
"version": "0.217.0",
|
|
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",
|
|
7
7
|
"main": "./src/index.js",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@floating-ui/react": "^0.27.19",
|
|
25
25
|
"embla-carousel-react": "^8.6.0",
|
|
26
|
+
"pdfjs-dist": "^6.3.289",
|
|
26
27
|
"react-syntax-highlighter": "^16.1.1"
|
|
27
28
|
},
|
|
28
29
|
"peerDependencies": {
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
35
36
|
},
|
|
36
37
|
"devDependencies": {
|
|
37
|
-
"@kolkrabbi/kol-icons": "
|
|
38
|
+
"@kolkrabbi/kol-icons": "^0.27.1"
|
|
38
39
|
},
|
|
39
40
|
"files": [
|
|
40
41
|
"src",
|
|
@@ -56,4 +57,4 @@
|
|
|
56
57
|
"access": "public"
|
|
57
58
|
},
|
|
58
59
|
"sideEffects": false
|
|
59
|
-
}
|
|
60
|
+
}
|
|
@@ -3,6 +3,7 @@ import { DURATION } from '../utilities/motion.js'
|
|
|
3
3
|
import gsap from 'gsap'
|
|
4
4
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
5
5
|
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
6
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* ActionButton — an icon control that CONFIRMS what it did (2026-08-15 user
|
|
@@ -179,16 +180,16 @@ export default function ActionButton({
|
|
|
179
180
|
|
|
180
181
|
/* The element follows the affordance — IconFrame's contract, same reasoning:
|
|
181
182
|
* a link must be a real <a> for middle-click, focus order and screen readers. */
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
return (
|
|
190
|
-
<button type="button" className={cls} onClick={handle} aria-label={aria} title={aria} {...rest}>
|
|
183
|
+
const control = href ? (
|
|
184
|
+
<a className={cls} href={href} onClick={handle} aria-label={aria} {...rest}>
|
|
185
|
+
{glyph}
|
|
186
|
+
</a>
|
|
187
|
+
) : (
|
|
188
|
+
<button type="button" className={cls} onClick={handle} aria-label={aria} {...rest}>
|
|
191
189
|
{glyph}
|
|
192
190
|
</button>
|
|
193
191
|
)
|
|
192
|
+
/* The label shows as the DS Tooltip, not a native `title` (kol-client-olina 2026-09-22) — and
|
|
193
|
+
* it follows the swap, so a Copy URL reads "Copied" while the check is up. */
|
|
194
|
+
return aria ? <Tooltip label={aria}>{control}</Tooltip> : control
|
|
194
195
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FileIcon — the file as a page: folded corner, the kind's glyph, the extension under it.
|
|
5
|
+
* Finder's generic document icon (user 2026-09-23: *"a generic preview for that like wav and
|
|
6
|
+
* the type below that finder has"*). What a file shows when it has nothing of its own to draw:
|
|
7
|
+
* audio without cover art, an empty file, a kind no renderer handles.
|
|
8
|
+
*
|
|
9
|
+
* Drawn in one SVG so the label scales with the page — the box sets the size, never the type.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} ext the extension, shown as authored upper-case (`WAV`)
|
|
12
|
+
* @param {string} glyph a kol-icons name drawn on the page, or none
|
|
13
|
+
* @param {string} className sizes the icon; it keeps its own 100:128 ratio
|
|
14
|
+
*/
|
|
15
|
+
export default function FileIcon({ ext, glyph, className = '' }) {
|
|
16
|
+
const label = ext ? String(ext).toUpperCase() : ''
|
|
17
|
+
/* a long extension (GITIGNORE) is FITTED to the page, never cut — "GITIG" named nothing */
|
|
18
|
+
const fit = label.length > 5 ? { textLength: 76, lengthAdjust: 'spacingAndGlyphs' } : {}
|
|
19
|
+
return (
|
|
20
|
+
<svg className={`kol-file-icon ${className}`.trim()} viewBox="0 0 100 128" role="img" aria-label={label ? `${label} file` : 'File'}>
|
|
21
|
+
<path className="kol-file-icon-page" d="M8 2h58l32 32v86a6 6 0 0 1-6 6H8a6 6 0 0 1-6-6V8a6 6 0 0 1 6-6z" />
|
|
22
|
+
<path className="kol-file-icon-fold" d="M66 2v26a6 6 0 0 0 6 6h26z" />
|
|
23
|
+
{glyph && (
|
|
24
|
+
<foreignObject x="30" y={label ? 42 : 52} width="40" height="40">
|
|
25
|
+
<Icon name={glyph} size={40} className="kol-file-icon-glyph" />
|
|
26
|
+
</foreignObject>
|
|
27
|
+
)}
|
|
28
|
+
{label && <text className="kol-file-icon-ext" x="50" y="108" textAnchor="middle" {...fit}>{label}</text>}
|
|
29
|
+
</svg>
|
|
30
|
+
)
|
|
31
|
+
}
|
package/src/atoms/ViewToggle.jsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { Fragment } from 'react'
|
|
1
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
2
3
|
import { toneClass } from '../utilities/tone.js'
|
|
4
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* ViewToggle — control for switching between view modes.
|
|
@@ -63,7 +65,6 @@ const ViewToggle = ({
|
|
|
63
65
|
onClick={() => onViewChange(next)}
|
|
64
66
|
className={cls}
|
|
65
67
|
aria-pressed={isOn}
|
|
66
|
-
title={isOn ? onOpt.label : offOpt.label}
|
|
67
68
|
>
|
|
68
69
|
<span className="grid">
|
|
69
70
|
<span className={`col-start-1 row-start-1 ${isOn ? '' : 'invisible'}`}>{onOpt.label}</span>
|
|
@@ -100,22 +101,39 @@ const ViewToggle = ({
|
|
|
100
101
|
|
|
101
102
|
return (
|
|
102
103
|
<div className={containerClasses}>
|
|
103
|
-
{options.map((option) =>
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
104
|
+
{options.map((option) => {
|
|
105
|
+
/* AN OPTION CAN BE ITS OWN BUTTON (2026-09-23): `onClick` (+ optional `pressed`) makes it an
|
|
106
|
+
* independent action in the same well — the media pages' filter and search, which sit beside
|
|
107
|
+
* the view switch and must be the same height and chip, not a second hand-built strip. */
|
|
108
|
+
const active = option.onClick ? !!option.pressed : viewMode === option.value
|
|
109
|
+
const button = (
|
|
110
|
+
<button
|
|
111
|
+
key={option.value}
|
|
112
|
+
onClick={option.onClick ?? (() => onViewChange(option.value))}
|
|
113
|
+
className={buttonClasses(active)}
|
|
114
|
+
aria-label={option.label}
|
|
115
|
+
aria-pressed={option.onClick && option.pressed == null ? undefined : active}
|
|
116
|
+
>
|
|
117
|
+
{isIconVariant && option.icon ? (
|
|
118
|
+
<Icon name={option.icon} size={14} variant={iconVariant} />
|
|
119
|
+
) : (
|
|
120
|
+
option.label
|
|
121
|
+
)}
|
|
122
|
+
</button>
|
|
123
|
+
)
|
|
124
|
+
/* THE DS TOOLTIP, NOT `title` (native-title-tooltips-in-ds-components, kol-client-olina
|
|
125
|
+
* 2026-09-22). Only an ICON option gets one — a text option already shows its label, and
|
|
126
|
+
* a tooltip repeating it is noise; the native `title` did exactly that. */
|
|
127
|
+
const item = isIconVariant && option.icon
|
|
128
|
+
? <Tooltip key={option.value} label={option.label}>{button}</Tooltip>
|
|
129
|
+
: button
|
|
130
|
+
/* `dividerBefore` (2026-09-23): a hairline in the well between two options that are not the
|
|
131
|
+
* same kind of thing — filter and search are two independent actions, and two bare chips
|
|
132
|
+
* side by side read as one control. A light hairline (oq-16, opaque, flips with the theme) — the dark one vanished. */
|
|
133
|
+
return isIconVariant && option.dividerBefore
|
|
134
|
+
? <Fragment key={option.value}><span aria-hidden="true" className="w-px h-4 shrink-0 bg-oq-16" />{item}</Fragment>
|
|
135
|
+
: item
|
|
136
|
+
})}
|
|
119
137
|
</div>
|
|
120
138
|
)
|
|
121
139
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useMarquee — drag a band across a list and select what it touches, Finder's
|
|
5
|
+
* rubber band (user 2026-09-22: *"at least in column and row a drag to
|
|
6
|
+
* highlight/select multiple items"*).
|
|
7
|
+
*
|
|
8
|
+
* The container is whatever gets `props`; every selectable child carries
|
|
9
|
+
* `data-marquee-key`. The band is drawn by the caller from `rect` — one
|
|
10
|
+
* absolutely-positioned div inside a `relative` container — so the hook owns
|
|
11
|
+
* the geometry and the DOM stays the caller's.
|
|
12
|
+
*
|
|
13
|
+
* A drag that never leaves its starting pixel is a CLICK and is left alone:
|
|
14
|
+
* the threshold is what keeps row clicks, double-clicks and the context menu
|
|
15
|
+
* working. Modifier-drag adds to the selection instead of replacing it, which
|
|
16
|
+
* is the one thing every file manager agrees on.
|
|
17
|
+
*
|
|
18
|
+
* @param {Function} onSelect (keys: string[], additive: boolean) => void
|
|
19
|
+
* @param {boolean} enabled false → the container behaves exactly as before
|
|
20
|
+
* @returns {{ ref, rect, props }} rect is `{left, top, width, height}` in container space, or null
|
|
21
|
+
*/
|
|
22
|
+
const THRESHOLD = 4
|
|
23
|
+
|
|
24
|
+
export default function useMarquee({ onSelect, enabled = true } = {}) {
|
|
25
|
+
const ref = useRef(null)
|
|
26
|
+
const start = useRef(null)
|
|
27
|
+
/* A finished band ends in a `click` on the container, and the container's own click is
|
|
28
|
+
* "you clicked the background, so deselect" — which would undo the band the moment it landed.
|
|
29
|
+
* The flag swallows exactly that one click. */
|
|
30
|
+
const dragged = useRef(false)
|
|
31
|
+
const [rect, setRect] = useState(null)
|
|
32
|
+
|
|
33
|
+
const onPointerDown = useCallback((e) => {
|
|
34
|
+
if (!enabled || e.button !== 0) return
|
|
35
|
+
const box = ref.current
|
|
36
|
+
if (!box) return
|
|
37
|
+
/* A DRAG THAT BEGINS ON A ROW IS NOT A MARQUEE — it is a row drag (move to a folder), which
|
|
38
|
+
* this must not steal. The band starts on the list's own background. */
|
|
39
|
+
/* …unless the row says only part of it is the item (`data-hit-zone`, the list view's rows —
|
|
40
|
+
* Finder's: the file ends where its name ends). Then only a press on `[data-hit]` is a row. */
|
|
41
|
+
const keyEl = e.target.closest?.('[data-marquee-key]')
|
|
42
|
+
if (keyEl && (!keyEl.hasAttribute('data-hit-zone') || e.target.closest('[data-hit]'))) return
|
|
43
|
+
const origin = box.getBoundingClientRect()
|
|
44
|
+
start.current = {
|
|
45
|
+
x: e.clientX - origin.left + box.scrollLeft,
|
|
46
|
+
y: e.clientY - origin.top + box.scrollTop,
|
|
47
|
+
additive: e.metaKey || e.ctrlKey || e.shiftKey,
|
|
48
|
+
moved: false,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const move = (ev) => {
|
|
52
|
+
const s = start.current
|
|
53
|
+
if (!s) return
|
|
54
|
+
const now = box.getBoundingClientRect()
|
|
55
|
+
const x = ev.clientX - now.left + box.scrollLeft
|
|
56
|
+
const y = ev.clientY - now.top + box.scrollTop
|
|
57
|
+
if (!s.moved && Math.abs(x - s.x) < THRESHOLD && Math.abs(y - s.y) < THRESHOLD) return
|
|
58
|
+
s.moved = true
|
|
59
|
+
const band = { left: Math.min(s.x, x), top: Math.min(s.y, y), width: Math.abs(x - s.x), height: Math.abs(y - s.y) }
|
|
60
|
+
setRect(band)
|
|
61
|
+
const keys = []
|
|
62
|
+
for (const el of box.querySelectorAll('[data-marquee-key]')) {
|
|
63
|
+
const r = el.getBoundingClientRect()
|
|
64
|
+
const top = r.top - now.top + box.scrollTop
|
|
65
|
+
const bottom = top + r.height
|
|
66
|
+
const left = r.left - now.left + box.scrollLeft
|
|
67
|
+
const right = left + r.width
|
|
68
|
+
if (bottom > band.top && top < band.top + band.height && right > band.left && left < band.left + band.width) {
|
|
69
|
+
keys.push(el.getAttribute('data-marquee-key'))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
onSelect?.(keys, s.additive)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const up = () => {
|
|
76
|
+
window.removeEventListener('pointermove', move)
|
|
77
|
+
window.removeEventListener('pointerup', up)
|
|
78
|
+
dragged.current = !!start.current?.moved
|
|
79
|
+
start.current = null
|
|
80
|
+
setRect(null)
|
|
81
|
+
}
|
|
82
|
+
window.addEventListener('pointermove', move)
|
|
83
|
+
window.addEventListener('pointerup', up)
|
|
84
|
+
}, [enabled, onSelect])
|
|
85
|
+
|
|
86
|
+
const onClickCapture = useCallback((e) => {
|
|
87
|
+
if (!dragged.current) return
|
|
88
|
+
dragged.current = false
|
|
89
|
+
e.stopPropagation()
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
return { ref, rect, props: { onPointerDown, onClickCapture } }
|
|
93
|
+
}
|
package/src/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { default as Badge } from './atoms/Badge.jsx'
|
|
|
24
24
|
export { default as Button } from './atoms/Button.jsx'
|
|
25
25
|
export { default as CloseButton } from './utilities/CloseButton.jsx'
|
|
26
26
|
export { default as ActionButton } from './atoms/ActionButton.jsx'
|
|
27
|
+
export { default as FileIcon } from './atoms/FileIcon.jsx'
|
|
27
28
|
export { default as SizeOrDownload } from './atoms/SizeOrDownload.jsx'
|
|
28
29
|
export { default as SortHeader } from './atoms/SortHeader.jsx'
|
|
29
30
|
export { default as SortControls } from './molecules/SortControls.jsx'
|
|
@@ -48,6 +49,7 @@ export { default as LabeledControl } from './molecules/LabeledControl.jsx'
|
|
|
48
49
|
export { default as OverlayGlassPanel } from './utilities/OverlayGlassPanel.jsx'
|
|
49
50
|
export { default as Pill } from './atoms/Pill.jsx'
|
|
50
51
|
export { usePopover, PopoverPanel, Tooltip } from './utilities/Popover.jsx'
|
|
52
|
+
export { default as ContextMenu, useContextMenu } from './utilities/ContextMenu.jsx'
|
|
51
53
|
export { default as ProsePreview } from './utilities/ProsePreview.jsx'
|
|
52
54
|
export { default as QuantityInput } from './molecules/QuantityInput.jsx'
|
|
53
55
|
export { default as RotaryDial } from './atoms/RotaryDial.jsx'
|
|
@@ -164,6 +166,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
|
|
|
164
166
|
* rendered both mobile viewer views and was module-private, so every consumer
|
|
165
167
|
* needing a phone viewer hand-rolled one. */
|
|
166
168
|
export { MediaInspector } from './organisms/MediaLibraryPages.jsx'
|
|
169
|
+
export { default as MediaLibraryExplorer } from './organisms/MediaLibraryExplorer.jsx'
|
|
167
170
|
export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
|
|
168
171
|
export { default as MediaViewer } from './organisms/MediaViewer.jsx'
|
|
169
172
|
export { default as SettingsPanel, LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
|
|
@@ -178,6 +181,8 @@ export { default as AudioPreview, AudioTile, VideoTile, formatLength } from './m
|
|
|
178
181
|
export { default as VideoSheet } from './molecules/VideoSheet.jsx'
|
|
179
182
|
export { default as PlaybackBar } from './molecules/PlaybackBar.jsx'
|
|
180
183
|
export { default as AudioSheet } from './molecules/AudioSheet.jsx'
|
|
184
|
+
export { default as QuickLookFrame } from './molecules/QuickLookFrame.jsx'
|
|
185
|
+
export { default as MediaTile } from './molecules/MediaTile.jsx'
|
|
181
186
|
export { default as DocPage } from './molecules/DocPage.jsx'
|
|
182
187
|
export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
|
|
183
188
|
export { default as ProfileCard } from './molecules/ProfileCard.jsx'
|
|
@@ -27,6 +27,8 @@ import { readCover } from '../utilities/id3.js';
|
|
|
27
27
|
* @param {string} poster VideoTile — the poster URL
|
|
28
28
|
* @param {Function} onDuration AudioPreview — (seconds) => void once metadata lands
|
|
29
29
|
* @param {string} className AudioPreview — wrapper classes (the width is the consumer's)
|
|
30
|
+
* @param {ReactNode} fallback AudioTile — shown when the file carries no cover art (2026-09-23:
|
|
31
|
+
* it was an empty square, so a WAV read as a missing file)
|
|
30
32
|
*/
|
|
31
33
|
export const formatLength = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
|
32
34
|
const fmt = formatLength;
|
|
@@ -49,14 +51,14 @@ function PlayDisc({ playing, onClick }) {
|
|
|
49
51
|
);
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
export function AudioTile({ src }) {
|
|
54
|
+
export function AudioTile({ src, fallback = null }) {
|
|
53
55
|
const ref = useRef(null);
|
|
54
56
|
const [playing, setPlaying] = useState(false);
|
|
55
57
|
const cover = useCover(src);
|
|
56
58
|
return (
|
|
57
59
|
<div className="kol-media-tile relative w-full aspect-square rounded overflow-hidden flex items-center justify-center">
|
|
58
60
|
<audio ref={ref} src={src} preload="metadata" onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} />
|
|
59
|
-
{cover
|
|
61
|
+
{cover ? <img src={cover} alt="" className="absolute inset-0 w-full h-full object-cover" /> : fallback && <div className="absolute inset-0 flex items-center justify-center">{fallback}</div>}
|
|
60
62
|
<PlayDisc playing={playing} onClick={() => (playing ? ref.current.pause() : ref.current.play())} />
|
|
61
63
|
</div>
|
|
62
64
|
);
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
|
-
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
2
|
import usePlayback from '../hooks/usePlayback.js'
|
|
4
3
|
import PlaybackBar, { clock } from './PlaybackBar.jsx'
|
|
4
|
+
import QuickLookFrame from './QuickLookFrame.jsx'
|
|
5
|
+
import FileIcon from '../atoms/FileIcon.jsx'
|
|
5
6
|
import { readCover } from '../utilities/id3.js'
|
|
6
7
|
|
|
7
|
-
/* taxonomy-ok: molecule — nests PlaybackBar (relative) +
|
|
8
|
+
/* taxonomy-ok: molecule — nests PlaybackBar + QuickLookFrame (relative) + FileIcon (atom). */
|
|
8
9
|
|
|
9
10
|
function useCover(src) {
|
|
10
11
|
const [cover, setCover] = useState(null)
|
|
@@ -17,46 +18,31 @@ function useCover(src) {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* AudioSheet — audio in the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* sheet the QuickTime window — a dark plate (`bg-fg-ab-88`, `min(100vw − 10rem, 1000px)`),
|
|
26
|
-
* the cover square left, `Time: mm:ss` beside it, the bar flush along the bottom
|
|
27
|
-
*
|
|
28
|
-
* Artwork = the file's embedded ID3 `APIC` cover (`readCover`); no cover → a light
|
|
29
|
-
* square carrying the `music-note` glyph, the reference's. `autoPlay`; `onDuration`
|
|
30
|
-
* for the overlay's facts. The `10rem` in the widths is the overlay's arrow gutter.
|
|
21
|
+
* AudioSheet — audio in the Quick Look window, Finder's layout (user 2026-09-23, the reference
|
|
22
|
+
* shot): the artwork square left, `Time: mm:ss` beside it, the QuickTime bar docked in the
|
|
23
|
+
* window's footer. Artwork = the file's embedded ID3 `APIC` cover (`readCover`); no cover →
|
|
24
|
+
* `FileIcon`, the same page the tile shows. The `cover` / `sheet` variants are gone — there is
|
|
25
|
+
* one window now, and it is the sheet.
|
|
31
26
|
*
|
|
32
27
|
* @param {string} src the audio URL
|
|
28
|
+
* @param {string} ext the extension, for the no-cover icon
|
|
33
29
|
* @param {Function} onDuration (seconds) => void once metadata lands
|
|
34
|
-
* @param {'
|
|
30
|
+
* @param {Object} frame the window's header — `QuickLookFrame`'s title · meta · actions · onClose
|
|
35
31
|
*/
|
|
36
|
-
export default function AudioSheet({ src, onDuration,
|
|
32
|
+
export default function AudioSheet({ src, ext, onDuration, frame }) {
|
|
37
33
|
const { ref, handlers, bar } = usePlayback((el) => onDuration?.(el.duration))
|
|
38
34
|
const cover = useCover(src)
|
|
39
|
-
const audio = <audio ref={ref} src={src} autoPlay preload="metadata" {...handlers} />
|
|
40
|
-
const art = cover
|
|
41
|
-
? <img src={cover} alt="" className="w-full h-full object-cover" />
|
|
42
|
-
: <div className="w-full h-full flex items-center justify-center bg-ab-white text-fg-ab-32"><Icon name="music-note" size={64} /></div>
|
|
43
|
-
|
|
44
|
-
if (variant === 'sheet') return (
|
|
45
|
-
<div className="relative w-[min(100vw_-_10rem,1000px)] rounded overflow-hidden bg-fg-ab-88">
|
|
46
|
-
{audio}
|
|
47
|
-
<div className="flex items-center gap-16 p-10 pb-24">
|
|
48
|
-
<div className="w-[min(50vh,380px)] aspect-square shrink-0 overflow-hidden">{art}</div>
|
|
49
|
-
<span className="kol-mono-16 text-ab-white"><span className="opacity-48">Time: </span><b className="opacity-80">{clock(bar.duration)}</b></span>
|
|
50
|
-
</div>
|
|
51
|
-
<PlaybackBar {...bar} place="left-0 right-0 bottom-0" />
|
|
52
|
-
</div>
|
|
53
|
-
)
|
|
54
|
-
|
|
55
35
|
return (
|
|
56
|
-
<
|
|
57
|
-
{
|
|
58
|
-
<div className="
|
|
59
|
-
|
|
60
|
-
|
|
36
|
+
<QuickLookFrame {...frame} footer={<PlaybackBar {...bar} docked />}>
|
|
37
|
+
<audio ref={ref} src={src} preload="metadata" {...handlers} />
|
|
38
|
+
<div className="kol-quicklook-audio">
|
|
39
|
+
<div className="kol-quicklook-art">
|
|
40
|
+
{cover
|
|
41
|
+
? <img src={cover} alt="" className="w-full h-full object-cover" />
|
|
42
|
+
: <FileIcon ext={ext} glyph="music-note" className="w-[52%]" />}
|
|
43
|
+
</div>
|
|
44
|
+
<span className="kol-mono-16 text-meta">Time: <b className="text-strong">{clock(bar.duration)}</b></span>
|
|
45
|
+
</div>
|
|
46
|
+
</QuickLookFrame>
|
|
61
47
|
)
|
|
62
48
|
}
|
|
@@ -77,7 +77,7 @@ const syntaxTheme = (foregroundToken = 80) => ({
|
|
|
77
77
|
* for anything else. `md` is those exact values, named; `sm` is one step down
|
|
78
78
|
* on both axes. Size is INDEPENDENT of `bare`: bare removes the frame, size
|
|
79
79
|
* sets the box, and a bare block still has one. */
|
|
80
|
-
export default function CodeBlock({ children, code: codeProp, language: languageProp, filename: filenameProp, value, bare = false, size = 'md' }) {
|
|
80
|
+
export default function CodeBlock({ children, code: codeProp, language: languageProp, filename: filenameProp, value, bare = false, size = 'md', copy = true }) {
|
|
81
81
|
const code = String(value?.code ?? codeProp ?? children ?? '')
|
|
82
82
|
const language = value?.language ?? languageProp ?? 'text'
|
|
83
83
|
const filename = value?.filename ?? filenameProp
|
|
@@ -118,7 +118,9 @@ export default function CodeBlock({ children, code: codeProp, language: language
|
|
|
118
118
|
>
|
|
119
119
|
{code}
|
|
120
120
|
</SyntaxHighlighter>
|
|
121
|
-
|
|
121
|
+
{/* `copy={false}` (2026-09-23): a PREVIEW of a file is not a snippet to lift — the file's
|
|
122
|
+
* own copy action lives on its card (user: *"thats a feature in grid mode"*). */}
|
|
123
|
+
{copy && <CopyButton text={code} className="kol-frame-control" />}
|
|
122
124
|
</div>
|
|
123
125
|
</div>
|
|
124
126
|
)
|
|
@@ -185,6 +185,12 @@ export default function ContentCard({
|
|
|
185
185
|
expandedContent,
|
|
186
186
|
selected = false,
|
|
187
187
|
onClick,
|
|
188
|
+
/* RIGHT-CLICK AND DOUBLE-CLICK RIDE ALONG (user 2026-09-23: *"where are the right click options
|
|
189
|
+
* in grid view … CONSISTENCY"*). A card that takes a click takes the other two gestures the same
|
|
190
|
+
* surface offers on its rows — the file wall's tiles are the same objects its rows are, and a
|
|
191
|
+
* menu that exists on one and not the other is the inconsistency, not a missing feature. */
|
|
192
|
+
onContextMenu,
|
|
193
|
+
onDoubleClick,
|
|
188
194
|
href,
|
|
189
195
|
onNavigate,
|
|
190
196
|
className = '',
|
|
@@ -455,6 +461,13 @@ export default function ContentCard({
|
|
|
455
461
|
return (
|
|
456
462
|
<article
|
|
457
463
|
onClick={onClick}
|
|
464
|
+
onContextMenu={onContextMenu}
|
|
465
|
+
onDoubleClick={onDoubleClick}
|
|
466
|
+
/* SELECTED HAS TO SHOW (user 2026-09-23: a click in the grid "should select/highlight").
|
|
467
|
+
* `selected` only flipped a border colour on the variants that HAVE a border, and the file
|
|
468
|
+
* wall's cards have none — so the prop was true and the card looked untouched. The
|
|
469
|
+
* attribute is the hook; the theme paints it, once, for every variant. */
|
|
470
|
+
data-selected={selected || undefined}
|
|
458
471
|
role="button"
|
|
459
472
|
tabIndex={0}
|
|
460
473
|
onKeyDown={(e) => {
|
|
@@ -158,6 +158,12 @@ export default function ContentRow({
|
|
|
158
158
|
ratioAxis = 'width',
|
|
159
159
|
selected = false,
|
|
160
160
|
onClick,
|
|
161
|
+
/* RIGHT-CLICK AND DOUBLE-CLICK RIDE ALONG (user 2026-09-23: *"where are the right click options
|
|
162
|
+
* in grid view … CONSISTENCY"*). A card that takes a click takes the other two gestures the same
|
|
163
|
+
* surface offers on its rows — the file wall's tiles are the same objects its rows are, and a
|
|
164
|
+
* menu that exists on one and not the other is the inconsistency, not a missing feature. */
|
|
165
|
+
onContextMenu,
|
|
166
|
+
onDoubleClick,
|
|
161
167
|
href,
|
|
162
168
|
onNavigate,
|
|
163
169
|
tagVariant,
|
|
@@ -308,6 +314,9 @@ export default function ContentRow({
|
|
|
308
314
|
return (
|
|
309
315
|
<div
|
|
310
316
|
onClick={onClick}
|
|
317
|
+
onContextMenu={onContextMenu}
|
|
318
|
+
onDoubleClick={onDoubleClick}
|
|
319
|
+
data-selected={selected || undefined}
|
|
311
320
|
role="button"
|
|
312
321
|
tabIndex={0}
|
|
313
322
|
onKeyDown={(e) => {
|
|
@@ -68,7 +68,7 @@ export default function DocFrontmatter({ metadata }) {
|
|
|
68
68
|
return (
|
|
69
69
|
<div key={key} className="flex items-baseline gap-4 py-1">
|
|
70
70
|
<span className="flex items-center gap-2 min-w-[120px] kol-helper-12 text-meta">
|
|
71
|
-
{icon && <Icon name={icon} size={14} />}
|
|
71
|
+
{icon && <Icon name={icon} size={14} className="text-oq-48" />}
|
|
72
72
|
{FIELD_LABELS[key] ?? humanise(key)}
|
|
73
73
|
</span>
|
|
74
74
|
<span className="flex-1 min-w-0 [overflow-wrap:anywhere] kol-mono-12 text-strong">
|
|
@@ -3,6 +3,8 @@ import { AudioTile, VideoTile } from './AudioPreview.jsx'
|
|
|
3
3
|
import HlsVideo from '../atoms/HlsVideo.jsx'
|
|
4
4
|
import CodeBlock from './CodeBlock.jsx'
|
|
5
5
|
import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
6
|
+
import FileIcon from '../atoms/FileIcon.jsx'
|
|
7
|
+
import PdfPage, { PdfDocument } from './PdfPage.jsx'
|
|
6
8
|
import { kindOf as defaultKindOf, extOf as defaultExtOf, KIND_LABEL } from '../utilities/mediaKinds.js'
|
|
7
9
|
import markdownToHtml from '../utilities/markdownToHtml.js'
|
|
8
10
|
import { parseFrontmatter } from '../utilities/frontmatter.js'
|
|
@@ -24,7 +26,7 @@ import DocPage from './DocPage.jsx'
|
|
|
24
26
|
* `DocPage` (DocPageAndKindShowcase 2026-08-27: the overlay's A-series page, the
|
|
25
27
|
* column's zoomed frame; markdown's frontmatter rendered above the prose from
|
|
26
28
|
* the same fetch), the rest
|
|
27
|
-
* → `
|
|
29
|
+
* → `FileIcon` (the generic document). Images are the caller's (ColumnBrowser keeps its own
|
|
28
30
|
* `<img>` so it can read the dimensions). Text fetches cap at `textLimit`.
|
|
29
31
|
*
|
|
30
32
|
* @param {object} o the object — `{ key, contentType?, displayKey?, segmentCount? }`
|
|
@@ -40,6 +42,8 @@ import DocPage from './DocPage.jsx'
|
|
|
40
42
|
* @param {number} textLimit bytes of text fetched for a preview (default 200 KB)
|
|
41
43
|
*/
|
|
42
44
|
const TEXT_LIMIT = 200 * 1024
|
|
45
|
+
/* The glyph a FileIcon carries per kind. A kind not listed gets the bare page + extension. */
|
|
46
|
+
export const KIND_GLYPH = { audio: 'music-note', video: 'video', image: 'image', code: 'code', json: 'code', yaml: 'code', font: 'type' }
|
|
43
47
|
const LANG = {
|
|
44
48
|
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
45
49
|
ts: 'typescript', tsx: 'typescript', json: 'json', yaml: 'yaml', yml: 'yaml',
|
|
@@ -68,7 +72,63 @@ function useTextContent(url, enabled, limit) {
|
|
|
68
72
|
return { ...result, loading: enabled && result.url !== url }
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
|
|
75
|
+
/* A FONT SHOWS ITSELF (user 2026-09-23: *"Font needs a preview, we have a big font based collection
|
|
76
|
+
* here"*). The browser loads the file with `FontFace` and the specimen sets it: `Aa`, then the
|
|
77
|
+
* alphabet and the figures. Scoped to a generated family so it can never collide with the app's
|
|
78
|
+
* type, and removed on unmount. Lifted from the media app's overview so the pane and Quick Look
|
|
79
|
+
* get it too — no `kol-foundry` dependency, which sits above this package. */
|
|
80
|
+
function useFontFace(src) {
|
|
81
|
+
const [family, setFamily] = useState({ src: null, name: null, failed: false })
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
let live = true
|
|
84
|
+
let h = 0
|
|
85
|
+
for (let i = 0; i < src.length; i += 1) h = (h * 31 + src.charCodeAt(i)) | 0
|
|
86
|
+
const name = `kol-font-preview-${Math.abs(h)}`
|
|
87
|
+
const face = new FontFace(name, `url(${JSON.stringify(src)})`)
|
|
88
|
+
face.load()
|
|
89
|
+
.then((loaded) => { if (!live) return; document.fonts.add(loaded); setFamily({ src, name, failed: false }) })
|
|
90
|
+
.catch(() => { if (live) setFamily({ src, name: null, failed: true }) })
|
|
91
|
+
return () => { live = false; document.fonts.delete(face) }
|
|
92
|
+
}, [src])
|
|
93
|
+
return family.src === src ? family : { name: null, failed: false }
|
|
94
|
+
}
|
|
95
|
+
function FontSpecimen({ src, fit, fallback }) {
|
|
96
|
+
const { name, failed } = useFontFace(src)
|
|
97
|
+
if (failed) return fallback
|
|
98
|
+
if (!name) return <span className="kol-mono-12 text-meta">Loading the face…</span>
|
|
99
|
+
const big = fit === 'sheet'
|
|
100
|
+
return (
|
|
101
|
+
<div className={`kol-font-specimen${big ? ' kol-font-specimen--sheet' : ''}`} style={{ fontFamily: `"${name}"` }}>
|
|
102
|
+
<span className="kol-font-specimen-aa">Aa</span>
|
|
103
|
+
<span className="kol-font-specimen-set">ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789</span>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* A PDF IS A DOCUMENT AND SHOWS ITS FIRST PAGE (user 2026-09-23: *"pdf is not showing any
|
|
109
|
+
* preview … this is all mapped in finder dropbox google drive"*). Every one of those renders page
|
|
110
|
+
* one; we drew a placeholder card. `PdfPage` draws it with pdf.js.
|
|
111
|
+
*
|
|
112
|
+
* THE SIZE GATE is the honest part: pdf.js pulls the whole file to paint one page, which against
|
|
113
|
+
* a live bucket is a 40MB download for a thumbnail. Past the limit the file icon stands. */
|
|
114
|
+
const PDF_LIMIT = 8 * 1024 * 1024
|
|
115
|
+
|
|
116
|
+
export default function KindPreview({
|
|
117
|
+
o, text: textProp, kind: kindProp, urlOf = (x) => x.url, poster, kindOf = defaultKindOf,
|
|
118
|
+
extOf = defaultExtOf, kindLabel = KIND_LABEL, textLimit = TEXT_LIMIT,
|
|
119
|
+
/* ONE RENDERER, THREE FITS (user 2026-09-23: *"theres just a bunch of different views, why? you
|
|
120
|
+
* know what consistency means?"*). Five surfaces each patched this component with their own CSS
|
|
121
|
+
* or bypassed it — the pane, the wall tile, the row thumb, the kind overview and the overlay. The
|
|
122
|
+
* fit says how much room there is; the LADDER below stays one ladder.
|
|
123
|
+
*
|
|
124
|
+
* pane — the column preview and the overlay: captions, full controls
|
|
125
|
+
* tile — a card in the wall or the overview: the render, no prose beside it
|
|
126
|
+
* thumb — a 44px box: no embed, no player; whatever paints instantly or nothing
|
|
127
|
+
* sheet — the body of the Quick Look window: every page of a PDF with a thumbnail rail
|
|
128
|
+
*/
|
|
129
|
+
fit = 'pane',
|
|
130
|
+
pdfLimit = PDF_LIMIT,
|
|
131
|
+
}) {
|
|
72
132
|
/* CONTENT IN HAND, OR A URL TO FETCH (content-card-needs-no-cover,
|
|
73
133
|
* kol-client-olina 2026-09-04; shape ruled by kol-r2b2, whose component this
|
|
74
134
|
* is). A markdown note in a D1 row has no file and no URL behind it, so the
|
|
@@ -101,7 +161,17 @@ export default function KindPreview({ o, text: textProp, kind: kindProp, urlOf =
|
|
|
101
161
|
? { loading: false, error: null, text: String(textProp).slice(0, textLimit), truncated: String(textProp).length > textLimit }
|
|
102
162
|
: fetched
|
|
103
163
|
|
|
164
|
+
/* NOTHING TO DRAW → THE FILE AS A PAGE (user 2026-09-23). An empty file, audio without artwork
|
|
165
|
+
* and a kind no renderer handles all show the same generic document, sized by the fit. */
|
|
166
|
+
const icon = (
|
|
167
|
+
<FileIcon ext={ext} glyph={KIND_GLYPH[kind]}
|
|
168
|
+
className={fit === 'thumb' ? 'h-full w-auto' : fit === 'tile' ? 'w-[38%] max-w-[160px]' : fit === 'sheet' ? 'w-[200px] m-16' : 'w-[40%] max-w-[180px] my-6'} />
|
|
169
|
+
)
|
|
170
|
+
if (o.size === 0) return <div className="w-full h-full flex items-center justify-center">{icon}</div>
|
|
171
|
+
|
|
104
172
|
if (kind === 'playlist') {
|
|
173
|
+
/* the caption is pane chrome — in a tile it is a line of prose over the picture */
|
|
174
|
+
if (fit !== 'pane') return <HlsVideo src={url} poster={poster} className="w-full h-full object-cover" />
|
|
105
175
|
return (
|
|
106
176
|
<div className="flex flex-col items-center gap-2">
|
|
107
177
|
<HlsVideo src={url} poster={poster} className="max-w-full max-h-[70vh] rounded" />
|
|
@@ -110,7 +180,7 @@ export default function KindPreview({ o, text: textProp, kind: kindProp, urlOf =
|
|
|
110
180
|
)
|
|
111
181
|
}
|
|
112
182
|
if (kind === 'video') return <VideoTile src={url} poster={poster} />
|
|
113
|
-
if (kind === 'audio') return <AudioTile src={url} />
|
|
183
|
+
if (kind === 'audio') return <AudioTile src={url} fallback={icon} />
|
|
114
184
|
if (isText) {
|
|
115
185
|
if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
|
|
116
186
|
if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
|
|
@@ -126,13 +196,29 @@ export default function KindPreview({ o, text: textProp, kind: kindProp, urlOf =
|
|
|
126
196
|
const language = kind === 'json' ? 'json' : kind === 'yaml' ? 'yaml' : LANG[ext] || 'text'
|
|
127
197
|
return (
|
|
128
198
|
<DocPage>
|
|
129
|
-
<CodeBlock code={text} language={language} filename={name} />
|
|
199
|
+
<CodeBlock code={text} language={language} filename={name} copy={false} />
|
|
130
200
|
{truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
|
|
131
201
|
</DocPage>
|
|
132
202
|
)
|
|
133
203
|
}
|
|
204
|
+
/* PDF — drawn by pdf.js at every fit but `thumb` (a 44px box cannot show a page and the
|
|
205
|
+
* download is the same size either way): page one in a tile or the pane, every page with a
|
|
206
|
+
* thumbnail rail in the Quick Look window — Finder's layout. The browser's `<embed>` is gone:
|
|
207
|
+
* it is a viewer, and Chrome paints its toolbar over a tile whatever the URL fragment says.
|
|
208
|
+
* Only for a URL a browser can fetch: a placeholder scheme (a fixture, an unresolved key) gets
|
|
209
|
+
* the icon, not a console error and an empty frame. The size gate stands — the whole file is
|
|
210
|
+
* pulled to paint one page. */
|
|
211
|
+
const servableUrl = /^(https?:|data:|blob:|\/)/.test(String(url ?? ''))
|
|
212
|
+
if (ext === 'pdf' && servableUrl && fit !== 'thumb' && (o.size ?? 0) <= pdfLimit) {
|
|
213
|
+
if (fit === 'sheet') return <PdfDocument src={url} fallback={icon} />
|
|
214
|
+
return (
|
|
215
|
+
<PdfPage src={url} fallback={icon}
|
|
216
|
+
className={fit === 'tile' ? 'w-full h-full object-cover object-top' : 'w-full h-auto rounded'} />
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
if (kind === 'font' && servableUrl && fit !== 'thumb') return <FontSpecimen src={url} fit={fit} fallback={icon} />
|
|
134
220
|
if (kind === 'segments') {
|
|
135
221
|
return <AssetPlaceholder category="HLS" name={`${o.segmentCount} segments`} note="STREAM CHUNKS" className="w-[420px] max-w-full" />
|
|
136
222
|
}
|
|
137
|
-
return <
|
|
223
|
+
return <div className="w-full h-full flex items-center justify-center">{icon}</div>
|
|
138
224
|
}
|