@kolkrabbi/kol-component 0.97.2 → 0.98.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/atoms/Tag.jsx +3 -1
- package/src/index.js +2 -0
- package/src/molecules/KindPreview.jsx +93 -0
- package/src/molecules/ShellDrawer.jsx +1 -1
- package/src/organisms/ColumnBrowser.jsx +49 -25
- package/src/organisms/SettingsPanel.jsx +21 -14
- package/src/utilities/mediaKinds.js +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.98.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",
|
package/src/atoms/Tag.jsx
CHANGED
|
@@ -53,7 +53,8 @@ export default function Tag({
|
|
|
53
53
|
icon,
|
|
54
54
|
onRemove,
|
|
55
55
|
onClick,
|
|
56
|
-
className = ''
|
|
56
|
+
className = '',
|
|
57
|
+
...props
|
|
57
58
|
}) {
|
|
58
59
|
const isInteractive = !!(onClick || onRemove)
|
|
59
60
|
const Element = isInteractive ? 'button' : 'span'
|
|
@@ -92,6 +93,7 @@ export default function Tag({
|
|
|
92
93
|
|
|
93
94
|
return (
|
|
94
95
|
<Element
|
|
96
|
+
{...props}
|
|
95
97
|
type={isInteractive ? 'button' : undefined}
|
|
96
98
|
className={classes}
|
|
97
99
|
onClick={onClick}
|
package/src/index.js
CHANGED
|
@@ -141,6 +141,8 @@ export { default as SettingsPanel, SettingsRow, SettingsSwitch, SettingsChoice,
|
|
|
141
141
|
export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
|
|
142
142
|
export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
|
|
143
143
|
export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
|
|
144
|
+
export { default as KindPreview } from './molecules/KindPreview.jsx'
|
|
145
|
+
export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL } from './utilities/mediaKinds.js'
|
|
144
146
|
export { default as RecordManager } from './organisms/RecordManager.jsx'
|
|
145
147
|
export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
|
|
146
148
|
export { default as Table } from './organisms/Table.jsx'
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import AudioPlayer from '../atoms/AudioPlayer.jsx'
|
|
3
|
+
import HlsVideo from '../atoms/HlsVideo.jsx'
|
|
4
|
+
import CodeBlock from './CodeBlock.jsx'
|
|
5
|
+
import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
|
|
6
|
+
import { kindOf as defaultKindOf, extOf as defaultExtOf, KIND_LABEL } from '../utilities/mediaKinds.js'
|
|
7
|
+
|
|
8
|
+
/* taxonomy-ok: molecule — nests the DS media atoms + CodeBlock (relative). */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* KindPreview — a preview for any kind of file: kol-r2b2's `KindPreview.jsx`,
|
|
12
|
+
* promoted 2026-08-27 (SettingsPanelChromeAndColumnPreview). Before it, anything
|
|
13
|
+
* that was not an image or a video showed a grey box with the word "text".
|
|
14
|
+
* HLS → `HlsVideo` (inert — the DS's background-video atom, preview only),
|
|
15
|
+
* audio → `AudioPlayer`, text / code → `CodeBlock` (language by extension;
|
|
16
|
+
* markdown as code — the DS has no markdown renderer in this tier), the rest
|
|
17
|
+
* → `AssetPlaceholder`. Images are the caller's (ColumnBrowser keeps its own
|
|
18
|
+
* `<img>` so it can read the dimensions). Text fetches cap at `textLimit`.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} o the object — `{ key, contentType?, displayKey?, segmentCount? }`
|
|
21
|
+
* @param {Function} urlOf (o) => string — the object's public URL (default: `o.url`)
|
|
22
|
+
* @param {string} poster a poster URL for HLS (the sibling image)
|
|
23
|
+
* @param {Function} kindOf · extOf classification seams (defaults: the DS mediaKinds)
|
|
24
|
+
* @param {Object} kindLabel kind → label
|
|
25
|
+
* @param {number} textLimit bytes of text fetched for a preview (default 200 KB)
|
|
26
|
+
*/
|
|
27
|
+
const TEXT_LIMIT = 200 * 1024
|
|
28
|
+
const LANG = {
|
|
29
|
+
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
|
|
30
|
+
ts: 'typescript', tsx: 'typescript', json: 'json', yaml: 'yaml', yml: 'yaml',
|
|
31
|
+
css: 'css', html: 'html', sh: 'bash', py: 'python', md: 'markdown',
|
|
32
|
+
csv: 'text', tsv: 'text', pgn: 'text', txt: 'text', xml: 'xml',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function useTextContent(url, enabled, limit) {
|
|
36
|
+
const [result, setResult] = useState({ url: null, text: '', error: null, truncated: false })
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!enabled || !url) return undefined
|
|
39
|
+
let cancelled = false
|
|
40
|
+
const controller = new AbortController()
|
|
41
|
+
fetch(url, { signal: controller.signal })
|
|
42
|
+
.then(async (res) => {
|
|
43
|
+
if (!res.ok) throw new Error(`${res.status}`)
|
|
44
|
+
const size = Number(res.headers.get('content-length') || 0)
|
|
45
|
+
const body = await res.text()
|
|
46
|
+
if (cancelled) return
|
|
47
|
+
const truncated = body.length > limit || size > limit
|
|
48
|
+
setResult({ url, text: truncated ? body.slice(0, limit) : body, error: null, truncated })
|
|
49
|
+
})
|
|
50
|
+
.catch((e) => { if (!cancelled && e.name !== 'AbortError') setResult({ url, text: '', error: e.message, truncated: false }) })
|
|
51
|
+
return () => { cancelled = true; controller.abort() }
|
|
52
|
+
}, [url, enabled, limit])
|
|
53
|
+
return { ...result, loading: enabled && result.url !== url }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default function KindPreview({ o, urlOf = (x) => x.url, poster, kindOf = defaultKindOf, extOf = defaultExtOf, kindLabel = KIND_LABEL, textLimit = TEXT_LIMIT }) {
|
|
57
|
+
const kind = kindOf(o)
|
|
58
|
+
const url = urlOf(o)
|
|
59
|
+
const ext = extOf(o.key)
|
|
60
|
+
const name = o.displayKey ?? o.key
|
|
61
|
+
const isText = kind === 'text' || kind === 'code'
|
|
62
|
+
const { loading, text, error, truncated } = useTextContent(url, isText, textLimit)
|
|
63
|
+
|
|
64
|
+
if (kind === 'playlist') {
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex flex-col items-center gap-2">
|
|
67
|
+
<HlsVideo src={url} poster={poster} className="max-w-full max-h-[70vh] rounded" />
|
|
68
|
+
<span className="kol-mono-12 text-meta">HLS stream · playback is preview-only, no controls</span>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
if (kind === 'audio') {
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex justify-center p-8">
|
|
75
|
+
<AudioPlayer src={url} label={name} className="items-center w-[420px] max-w-full" />
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
if (isText) {
|
|
80
|
+
if (loading) return <span className="kol-mono-12 text-meta">Loading…</span>
|
|
81
|
+
if (error) return <span className="kol-mono-12 text-ui-error">Couldn’t load: {error}</span>
|
|
82
|
+
return (
|
|
83
|
+
<div className="max-w-[80ch] max-h-[78vh] overflow-y-auto">
|
|
84
|
+
<CodeBlock code={text} language={LANG[ext] || 'text'} filename={name} />
|
|
85
|
+
{truncated && <p className="kol-mono-12 text-meta">truncated at {textLimit / 1024} KB</p>}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
if (kind === 'segments') {
|
|
90
|
+
return <AssetPlaceholder category="HLS" name={`${o.segmentCount} segments`} note="STREAM CHUNKS" className="w-[420px] max-w-full" />
|
|
91
|
+
}
|
|
92
|
+
return <AssetPlaceholder category={kindLabel[kind] || 'file'} name={name} note={ext ? ext.toUpperCase() : 'FILE'} className="w-[420px] max-w-full" />
|
|
93
|
+
}
|
|
@@ -151,7 +151,7 @@ export default function ShellDrawer({
|
|
|
151
151
|
tabIndex={-1}
|
|
152
152
|
className={`fixed inset-y-0 z-[200] flex max-w-full flex-col bg-surface-primary px-4 py-4 outline-none md:px-5 lg:px-6 ${backdrop ? 'shadow-2xl' : ''} ${
|
|
153
153
|
side === 'right' ? 'right-0 border-l' : 'left-0 border-r'
|
|
154
|
-
} border-
|
|
154
|
+
} border-oq-08 ${width == null ? 'w-full' : ''} ${motionPanel} ${className}`}
|
|
155
155
|
style={width != null ? { width: typeof width === 'number' ? `${width}px` : width } : undefined}
|
|
156
156
|
>
|
|
157
157
|
{/* closeSide="start": the reference sets the × glyph ~9px deeper than
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
+
import KindPreview from '../molecules/KindPreview.jsx'
|
|
4
|
+
import { kindOf as dsKindOf, KIND_LABEL as DS_KIND_LABEL } from '../utilities/mediaKinds.js'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* ColumnBrowser — Finder-style Miller columns over a flat key space (kol-r2b2's
|
|
@@ -41,17 +43,13 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
41
43
|
* @param {Object} kindLabel kind → label shown when there is no visual preview
|
|
42
44
|
* @param {Function} formatSize (bytes) => string
|
|
43
45
|
* @param {Function} partition (objects, level) => { folders: string[], files: object[] }
|
|
46
|
+
* @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay); without it images render the organism's <img>, everything else the DS KindPreview
|
|
44
47
|
* @param {string} className extra classes on the browser
|
|
45
48
|
*/
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (ct.startsWith('video/')) return 'video'
|
|
51
|
-
if (ct.startsWith('audio/')) return 'audio'
|
|
52
|
-
return 'file'
|
|
53
|
-
}
|
|
54
|
-
const DEFAULT_KIND_LABEL = { image: 'image', video: 'video', audio: 'audio', file: 'file' }
|
|
50
|
+
/* the DS kinds (mediaKinds — kol-r2b2's classification, promoted 2026-08-27) */
|
|
51
|
+
const defaultKindOf = dsKindOf
|
|
52
|
+
const DEFAULT_KIND_LABEL = DS_KIND_LABEL
|
|
55
53
|
const defaultFormatSize = (bytes) => {
|
|
56
54
|
if (bytes == null) return '—'
|
|
57
55
|
if (bytes < 1024) return `${bytes} B`
|
|
@@ -74,7 +72,7 @@ const defaultPartition = (objects, prefix) => {
|
|
|
74
72
|
const isImage = (o) => (o.contentType || '').startsWith('image/')
|
|
75
73
|
|
|
76
74
|
/* kol-icons has no `audio` glyph yet — audio rows wear `file` until it does */
|
|
77
|
-
const COL_ICON = { image: 'image', video: 'video', audio: 'file' }
|
|
75
|
+
const COL_ICON = { image: 'image', video: 'video', audio: 'file', playlist: 'video' }
|
|
78
76
|
|
|
79
77
|
function Row({ icon, label, active, cursor = false, trailing, onClick, muted = false }) {
|
|
80
78
|
return (
|
|
@@ -97,7 +95,7 @@ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = f
|
|
|
97
95
|
)
|
|
98
96
|
}
|
|
99
97
|
|
|
100
|
-
function Preview({ o, urlOf, kindOf, kindLabel, formatSize }) {
|
|
98
|
+
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, renderPreview }) {
|
|
101
99
|
// Pixel size comes from the loaded image itself — the bucket stores none.
|
|
102
100
|
const [dims, setDims] = useState(null)
|
|
103
101
|
const src = isImage(o) ? urlOf?.(o) : null
|
|
@@ -110,8 +108,12 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize }) {
|
|
|
110
108
|
]
|
|
111
109
|
return (
|
|
112
110
|
<div className="kol-column-browser-preview w-[320px] shrink-0 overflow-y-auto p-4 flex flex-col gap-4">
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
{/* the media frame: an image is the organism's own <img> (it reads the
|
|
112
|
+
* dimensions); anything else is `renderPreview(o)` or the DS KindPreview
|
|
113
|
+
* (video · audio · code · text — SettingsPanelChromeAndColumnPreview,
|
|
114
|
+
* 2026-08-27). Dimensions also come off any <img> a custom node loads. */}
|
|
115
|
+
{src && !renderPreview ? (
|
|
116
|
+
<div className="w-full aspect-square bg-fg-04 rounded flex items-center justify-center overflow-hidden">
|
|
115
117
|
<img
|
|
116
118
|
src={src}
|
|
117
119
|
alt=""
|
|
@@ -119,10 +121,15 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize }) {
|
|
|
119
121
|
loading="lazy"
|
|
120
122
|
onLoad={(e) => setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight })}
|
|
121
123
|
/>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
</div>
|
|
125
|
+
) : (
|
|
126
|
+
<div
|
|
127
|
+
className="kol-column-browser-media w-full min-h-[160px] max-h-[60vh] overflow-auto rounded bg-fg-04 flex items-center justify-center"
|
|
128
|
+
onLoadCapture={(e) => { if (e.target?.tagName === 'IMG') setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight }) }}
|
|
129
|
+
>
|
|
130
|
+
{renderPreview ? renderPreview(o) : <KindPreview o={o} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} />}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
126
133
|
<p className="kol-mono-12 text-fg-default break-all">{o.displayKey ?? o.key}</p>
|
|
127
134
|
<dl className="flex flex-col gap-1">
|
|
128
135
|
{facts.map(([k, v]) => (
|
|
@@ -148,6 +155,7 @@ export default function ColumnBrowser({
|
|
|
148
155
|
kindLabel = DEFAULT_KIND_LABEL,
|
|
149
156
|
formatSize = defaultFormatSize,
|
|
150
157
|
partition = defaultPartition,
|
|
158
|
+
renderPreview,
|
|
151
159
|
className = '',
|
|
152
160
|
}) {
|
|
153
161
|
const [picked, setPicked] = useState(null)
|
|
@@ -156,14 +164,6 @@ export default function ColumnBrowser({
|
|
|
156
164
|
* outside prefix change fires null */
|
|
157
165
|
const pick = (o) => { setPicked(o); onPick?.(o) }
|
|
158
166
|
useEffect(() => { if (picked) pick(null) }, [prefix]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
159
|
-
// Keyboard cursor: which column, which row. Clicks keep it in sync.
|
|
160
|
-
const [cursor, setCursor] = useState({ col: 0, idx: 0 })
|
|
161
|
-
/* the cursor is NOT drawn until the keyboard is used (ColumnBrowserCursorStart,
|
|
162
|
-
* kol-r2b2 2026-08-27): at rest it sat on row 0 beside the open folder and
|
|
163
|
-
* read as a second selection. Arrows arm it; a click seeds it. */
|
|
164
|
-
const [cursorActive, setCursorActive] = useState(false)
|
|
165
|
-
const rootRef = useRef(null)
|
|
166
|
-
|
|
167
167
|
const levelsOf = (pfx) => {
|
|
168
168
|
const out = ['']
|
|
169
169
|
if (pfx) {
|
|
@@ -176,6 +176,27 @@ export default function ColumnBrowser({
|
|
|
176
176
|
const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
|
|
177
177
|
return [...folders.map((f) => ({ type: 'folder', name: f })), ...files.map((o) => ({ type: 'file', o }))]
|
|
178
178
|
}
|
|
179
|
+
// Keyboard cursor: which column, which row. Clicks keep it in sync.
|
|
180
|
+
/* SEEDED on the open folder of the deepest column that has one (Finder's
|
|
181
|
+
* start — ColumnBrowserCursorSeed, kol-r2b2 2026-08-27): with a deep `prefix`
|
|
182
|
+
* the first ↓ used to act in column 0. Re-seeded when `prefix` changes; the
|
|
183
|
+
* internal moves land on the same spot, so nothing jumps. */
|
|
184
|
+
const seedCursor = (pfx) => {
|
|
185
|
+
const lv = levelsOf(pfx)
|
|
186
|
+
if (lv.length < 2) return { col: 0, idx: 0 }
|
|
187
|
+
const col = lv.length - 2
|
|
188
|
+
const opened = lv[col + 1].slice(lv[col].length)
|
|
189
|
+
const idx = itemsAt(lv[col]).findIndex((it) => it.type === 'folder' && it.name === opened)
|
|
190
|
+
return { col, idx: Math.max(0, idx) }
|
|
191
|
+
}
|
|
192
|
+
const [cursor, setCursor] = useState(() => seedCursor(prefix))
|
|
193
|
+
useEffect(() => { setCursor(seedCursor(prefix)) }, [prefix, objects.length]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
194
|
+
/* the cursor is NOT drawn until the keyboard is used (ColumnBrowserCursorStart,
|
|
195
|
+
* kol-r2b2 2026-08-27): at rest it sat on row 0 beside the open folder and
|
|
196
|
+
* read as a second selection. Arrows arm it; a click seeds it. */
|
|
197
|
+
const [cursorActive, setCursorActive] = useState(false)
|
|
198
|
+
const rootRef = useRef(null)
|
|
199
|
+
|
|
179
200
|
const land = (level, item) => {
|
|
180
201
|
if (!item) return
|
|
181
202
|
if (item.type === 'folder') { pick(null); onPrefix(level + item.name) }
|
|
@@ -198,6 +219,9 @@ export default function ColumnBrowser({
|
|
|
198
219
|
const items = itemsAt(lv[col])
|
|
199
220
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
200
221
|
const idx = Math.max(0, Math.min(items.length - 1, cursor.idx + (e.key === 'ArrowDown' ? 1 : -1)))
|
|
222
|
+
/* an arrow that did not move the cursor does nothing — re-landing on an
|
|
223
|
+
* open folder re-fired onPrefix (ColumnBrowserCursorSeed) */
|
|
224
|
+
if (idx === cursor.idx && col === cursor.col) return
|
|
201
225
|
setCursor({ col, idx })
|
|
202
226
|
land(lv[col], items[idx])
|
|
203
227
|
} else if (e.key === 'ArrowRight') {
|
|
@@ -294,7 +318,7 @@ export default function ColumnBrowser({
|
|
|
294
318
|
</ul>
|
|
295
319
|
)
|
|
296
320
|
})}
|
|
297
|
-
{shown && <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} />}
|
|
321
|
+
{shown && <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} renderPreview={renderPreview} />}
|
|
298
322
|
</div>
|
|
299
323
|
)
|
|
300
324
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Button from '../atoms/Button.jsx'
|
|
2
|
+
import Tag from '../atoms/Tag.jsx'
|
|
2
3
|
import SegmentedToggle from '../atoms/SegmentedToggle.jsx'
|
|
3
4
|
import ToggleSwitch from '../atoms/ToggleSwitch.jsx'
|
|
4
5
|
import ShellDrawer from '../molecules/ShellDrawer.jsx'
|
|
@@ -54,13 +55,17 @@ export default function SettingsPanel({
|
|
|
54
55
|
}) {
|
|
55
56
|
const header = (
|
|
56
57
|
<div className="flex min-w-0 flex-col">
|
|
57
|
-
{
|
|
58
|
-
|
|
58
|
+
{/* THE APP REGISTER (SettingsPanelChromeAndColumnPreview, kol-r2b2 2026-08-27 —
|
|
59
|
+
* user: "all wrong font styles"): the collection beside the panel is the
|
|
60
|
+
* reference — helper-14 uppercase titles, the eyebrow for section labels,
|
|
61
|
+
* mono-12 rows and hints. No mono-10 anywhere. */}
|
|
62
|
+
{title && <span className="kol-helper-14 uppercase text-emphasis">{title}</span>}
|
|
63
|
+
{subtitle && <span className="kol-mono-12 text-meta">{subtitle}</span>}
|
|
59
64
|
</div>
|
|
60
65
|
)
|
|
61
66
|
const body = (
|
|
62
67
|
<>
|
|
63
|
-
{intro && <p className="kol-mono-
|
|
68
|
+
{intro && <p className="kol-mono-12 text-meta">{intro}</p>}
|
|
64
69
|
<div className="flex flex-col gap-5">{children}</div>
|
|
65
70
|
{footer && <div className="mt-2">{footer}</div>}
|
|
66
71
|
</>
|
|
@@ -102,8 +107,8 @@ export function SettingsRow({ label, hint, children }) {
|
|
|
102
107
|
return (
|
|
103
108
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-6 py-1">
|
|
104
109
|
<div className="flex min-w-0 flex-col">
|
|
105
|
-
<span className="kol-mono-12 text-
|
|
106
|
-
{hint && <span className="kol-mono-
|
|
110
|
+
<span className="kol-mono-12 text-emphasis">{label}</span>
|
|
111
|
+
{hint && <span className="kol-mono-12 text-meta">{hint}</span>}
|
|
107
112
|
</div>
|
|
108
113
|
<div className="shrink-0">{children}</div>
|
|
109
114
|
</div>
|
|
@@ -132,10 +137,10 @@ export function SettingsChoice({ options = [], value, onChange, ariaLabel }) {
|
|
|
132
137
|
return <SegmentedToggle size="sm" value={value} onChange={onChange} options={opts} ariaLabel={ariaLabel} />
|
|
133
138
|
}
|
|
134
139
|
|
|
135
|
-
/* The
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
140
|
+
/* The chips are the DS `Tag` ("the tag ticket" — SettingsPanelChromeAndColumnPreview,
|
|
141
|
+
* user 2026-08-27: same chrome as Tag everywhere else, not a third look): `md`
|
|
142
|
+
* (helper-12), `inverse` when on, `secondary` when off, as a button. The old
|
|
143
|
+
* `.kol-control` string stays exported for consumers that typed it. */
|
|
139
144
|
export const CHIP_CLS = 'kol-control kol-control-sm kol-mono-12'
|
|
140
145
|
export const chipCls = (on) => `${CHIP_CLS} ${on ? 'kol-control--filled' : 'text-meta hover:text-emphasis'}`
|
|
141
146
|
|
|
@@ -151,16 +156,18 @@ export function SettingsChipRow({ options = [], selected = [], onToggle }) {
|
|
|
151
156
|
return (
|
|
152
157
|
<div className="flex flex-wrap gap-1">
|
|
153
158
|
{options.map((o) => (
|
|
154
|
-
<
|
|
159
|
+
<Tag
|
|
155
160
|
key={String(o.value)}
|
|
156
|
-
|
|
161
|
+
size="md"
|
|
162
|
+
hash={false}
|
|
163
|
+
variant={on.has(o.value) ? 'inverse' : 'secondary'}
|
|
164
|
+
active={on.has(o.value)}
|
|
157
165
|
aria-pressed={on.has(o.value)}
|
|
158
166
|
onClick={() => onToggle?.(o.value)}
|
|
159
|
-
className={chipCls(on.has(o.value))}
|
|
160
167
|
>
|
|
161
168
|
{o.label ?? String(o.value)}
|
|
162
169
|
{o.count > 0 && <span className="text-meta"> {o.count}</span>}
|
|
163
|
-
</
|
|
170
|
+
</Tag>
|
|
164
171
|
))}
|
|
165
172
|
</div>
|
|
166
173
|
)
|
|
@@ -170,7 +177,7 @@ export function SettingsChipRow({ options = [], selected = [], onToggle }) {
|
|
|
170
177
|
export function SettingsFooter({ customised = false, onReset, resetLabel = 'Reset to defaults' }) {
|
|
171
178
|
return (
|
|
172
179
|
<div className="flex items-center justify-between">
|
|
173
|
-
<span className="kol-mono-
|
|
180
|
+
<span className="kol-mono-12 text-meta">{customised ? 'customised' : 'defaults'}</span>
|
|
174
181
|
{onReset && <Button variant="ghost" size="sm" onClick={onReset}>{resetLabel}</Button>}
|
|
175
182
|
</div>
|
|
176
183
|
)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Media classification — kol-r2b2's `lib/media.js` kinds, promoted 2026-08-27
|
|
2
|
+
* (SettingsPanelChromeAndColumnPreview) so ColumnBrowser and KindPreview
|
|
3
|
+
* classify the same way the app does. contentType is unreliable for text /
|
|
4
|
+
* code (B2 hands back application/octet-stream), so the extension decides. */
|
|
5
|
+
|
|
6
|
+
const EXT_KINDS = {
|
|
7
|
+
json: 'text', yaml: 'text', yml: 'text', txt: 'text', md: 'text', csv: 'text',
|
|
8
|
+
tsv: 'text', pgn: 'text', xml: 'text', svg: 'image',
|
|
9
|
+
js: 'code', mjs: 'code', cjs: 'code', ts: 'code', jsx: 'code', tsx: 'code',
|
|
10
|
+
css: 'code', html: 'code', sh: 'code', py: 'code',
|
|
11
|
+
m3u8: 'playlist',
|
|
12
|
+
woff: 'font', woff2: 'font', ttf: 'font', otf: 'font',
|
|
13
|
+
zip: 'archive', gz: 'archive', tar: 'archive', rar: 'archive', '7z': 'archive',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function extOf(key = '') {
|
|
17
|
+
const base = key.slice(key.lastIndexOf('/') + 1)
|
|
18
|
+
const dot = base.lastIndexOf('.')
|
|
19
|
+
return dot === -1 ? '' : base.slice(dot + 1).toLowerCase()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SYSTEM_NAMES = new Set(['.ds_store', 'thumbs.db', 'desktop.ini', '.bzempty'])
|
|
23
|
+
export function isSystemFile(key = '') {
|
|
24
|
+
return SYSTEM_NAMES.has(key.slice(key.lastIndexOf('/') + 1).toLowerCase())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function kindOf(o) {
|
|
28
|
+
if (o.forcedKind) return o.forcedKind
|
|
29
|
+
if (isSystemFile(o.key)) return 'system'
|
|
30
|
+
const ct = o.contentType || ''
|
|
31
|
+
if (ct.startsWith('image/')) return 'image'
|
|
32
|
+
if (ct.startsWith('video/')) return 'video'
|
|
33
|
+
if (ct.startsWith('audio/')) return 'audio'
|
|
34
|
+
const ext = extOf(o.key)
|
|
35
|
+
if (EXT_KINDS[ext]) return EXT_KINDS[ext]
|
|
36
|
+
if (ct.startsWith('text/')) return 'text'
|
|
37
|
+
if (ct.startsWith('font/')) return 'font'
|
|
38
|
+
if (ct === 'application/json') return 'text'
|
|
39
|
+
return 'other'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const KINDS = ['image', 'video', 'audio', 'text', 'code', 'playlist', 'font', 'archive', 'other']
|
|
43
|
+
|
|
44
|
+
export const KIND_LABEL = {
|
|
45
|
+
image: 'image', video: 'video', audio: 'audio', text: 'text', code: 'code',
|
|
46
|
+
playlist: 'HLS', font: 'font', archive: 'archive', segments: 'HLS segments',
|
|
47
|
+
system: 'system', other: 'file',
|
|
48
|
+
}
|