@kolkrabbi/kol-component 0.228.0 → 0.229.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 +3 -2
- package/src/atoms/Button.jsx +7 -1
- package/src/atoms/Input.jsx +3 -2
- package/src/index.js +1 -0
- package/src/molecules/LabeledControl.jsx +7 -4
- package/src/molecules/MenuItem.jsx +9 -1
- package/src/molecules/Modal.jsx +1 -1
- package/src/molecules/PageHeader.jsx +14 -7
- package/src/molecules/SearchInput.jsx +7 -4
- package/src/organisms/ContentFilters.jsx +3 -10
- package/src/organisms/DocumentEditor.jsx +8 -7
- package/src/organisms/MediaLibrary.jsx +3 -5
- package/src/organisms/MediaLibraryExplorer.jsx +5 -1
- package/src/organisms/MediaLibraryPages.jsx +39 -16
- package/src/organisms/SettingsPanel.jsx +5 -2
- package/src/organisms/ShortcutsOverlay.jsx +8 -4
- package/src/utilities/ErrorBoundary.jsx +1 -1
- package/src/utilities/masthead.js +39 -0
- package/src/utilities/mediaSearch.js +41 -0
- package/src/utilities/searchItems.js +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.229.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",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"embla-carousel-react": "^8.6.0",
|
|
26
26
|
"pdfjs-dist": "^6.3.289",
|
|
27
27
|
"react-syntax-highlighter": "^16.1.1",
|
|
28
|
-
"@kolkrabbi/kol-markdown": "^0.1.
|
|
28
|
+
"@kolkrabbi/kol-markdown": "^0.1.1",
|
|
29
|
+
"@kolkrabbi/kol-search": "^0.2.0"
|
|
29
30
|
},
|
|
30
31
|
"peerDependencies": {
|
|
31
32
|
"@kolkrabbi/kol-icons": ">=0.22.0",
|
package/src/atoms/Button.jsx
CHANGED
|
@@ -81,8 +81,14 @@ const Button = ({
|
|
|
81
81
|
* grey) or its own bundle (nav · danger · accent); `nav` is ghost's chrome
|
|
82
82
|
* rung, oq-80 ink + aria-current — it had lived in the theme with no
|
|
83
83
|
* component able to emit it, the direct cause of the four-container header. */
|
|
84
|
+
/* An UNKNOWN variant is a typo, not a request for the inverted fill — it fell back to
|
|
85
|
+
* `kol-btn-secondary` (the text colour as fill) until 2026-09-29, so a misspelt variant
|
|
86
|
+
* shipped inverted. It now stamps nothing, like an unset variant: the wrapper's tone,
|
|
87
|
+
* else primary — and says so in dev. */
|
|
84
88
|
const KNOWN = ['primary', 'secondary', 'accent', 'outline', 'ghost', 'nav', 'danger', 'grey']
|
|
85
|
-
const
|
|
89
|
+
const known = !resolvedVariant || KNOWN.includes(resolvedVariant)
|
|
90
|
+
if (!known && import.meta.env.DEV) console.warn(`Button: unknown variant "${resolvedVariant}" — rendering the inherited tone (primary)`)
|
|
91
|
+
const variantClass = resolvedVariant && known ? `kol-btn-${resolvedVariant}` : ''
|
|
86
92
|
|
|
87
93
|
// Add size class — pairs the padding rule with its mono type class.
|
|
88
94
|
const sizeClass = size === 'sm'
|
package/src/atoms/Input.jsx
CHANGED
|
@@ -158,8 +158,9 @@ export default function Input({
|
|
|
158
158
|
* or <label> at the same kol-mono-N — Chromium computes input height
|
|
159
159
|
* from the font's ascender+descender (font-metric), not strictly from
|
|
160
160
|
* CSS line-height. Result: kol-control-sm ends up 26.5px instead of 26.
|
|
161
|
-
* h-4 / h-[18px] / h-[22px] match the kol-mono-12 / -14 / -16 line-heights
|
|
162
|
-
|
|
161
|
+
* h-4 / h-[18px] / h-[22px] match the kol-mono-12 / -14 / -16 line-heights; on touch every size is
|
|
162
|
+
* the 16 / 22 rung (D5, 2026-09-29 — see SearchInput) */
|
|
163
|
+
const heightCls = `${size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-[18px]' : 'h-[22px]'} pointer-coarse:h-[22px]`
|
|
163
164
|
|
|
164
165
|
const inputCls = [
|
|
165
166
|
'min-w-0 bg-transparent border-none outline-none text-auto',
|
package/src/index.js
CHANGED
|
@@ -152,6 +152,7 @@ export { default as EmblaNav } from './molecules/EmblaNav.jsx'
|
|
|
152
152
|
export { default as MobileTabBar, TABBAR_H } from './molecules/MobileTabBar.jsx'
|
|
153
153
|
export { default as ContentFilters } from './organisms/ContentFilters.jsx'
|
|
154
154
|
export { default as PageHeader } from './molecules/PageHeader.jsx'
|
|
155
|
+
export { MastheadContext, MASTHEADS, useMasthead, mastheadTitleClass } from './utilities/masthead.js'
|
|
155
156
|
export { default as ErrorBoundary } from './utilities/ErrorBoundary.jsx'
|
|
156
157
|
export { default as FeatureSplit } from './organisms/FeatureSplit.jsx'
|
|
157
158
|
export { default as FeaturedCarousel } from './organisms/FeaturedCarousel.jsx'
|
|
@@ -48,19 +48,22 @@ export default function LabeledControl({
|
|
|
48
48
|
/* `'auto'`: the label is the yielding cell — flex-1, truncating — and the
|
|
49
49
|
* control hugs. The control still carries min-w-0 + overflow-hidden so a
|
|
50
50
|
* combo wider than the whole column CLIPS at the column edge rather than
|
|
51
|
-
* painting over the neighbour (the invariant: no cell outside its column).
|
|
51
|
+
* painting over the neighbour (the invariant: no cell outside its column).
|
|
52
|
+
* BELOW `sm` IT STACKS (apps review, 2026-09-29): the yielding label yielded to
|
|
53
|
+
* nothing on a phone — a sentence-long combo took the whole row and the label
|
|
54
|
+
* showed as "C" or not at all. Label on its line, the value under it. */
|
|
52
55
|
const labelAuto = labelWidth === 'auto'
|
|
53
56
|
return (
|
|
54
|
-
<div className={`flex items-center gap-3 ${className}`}>
|
|
57
|
+
<div className={`flex ${labelAuto ? 'flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-3' : 'items-center gap-3'} ${className}`}>
|
|
55
58
|
{showLabel && (
|
|
56
59
|
<span
|
|
57
|
-
className={`kol-helper-10 tracking-widest text-meta ${labelAuto ? '
|
|
60
|
+
className={`kol-helper-10 tracking-widest text-meta ${labelAuto ? 'max-w-full min-w-0 truncate sm:flex-1' : 'shrink-0'}`}
|
|
58
61
|
style={labelAuto ? undefined : { width: labelWidth }}
|
|
59
62
|
>
|
|
60
63
|
{labelInner}
|
|
61
64
|
</span>
|
|
62
65
|
)}
|
|
63
|
-
<div className={labelAuto ? 'min-w-0 overflow-hidden' : 'flex-1 min-w-0'}>{children}</div>
|
|
66
|
+
<div className={labelAuto ? 'min-w-0 max-w-full sm:overflow-hidden' : 'flex-1 min-w-0'}>{children}</div>
|
|
64
67
|
</div>
|
|
65
68
|
)
|
|
66
69
|
}
|
|
@@ -19,6 +19,9 @@ import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
|
|
|
19
19
|
* `Dropdown` molecule instead — MenuItem is for action menus / popovers
|
|
20
20
|
* that hold arbitrary children.
|
|
21
21
|
*/
|
|
22
|
+
/* the ladder's tokens, so the touch rung (D5, 2026-09-29) grows the trigger with its row */
|
|
23
|
+
const TRIGGER_H = { xs: 'var(--kol-ctl-xs)', sm: 'var(--kol-ctl-sm)', md: 'var(--kol-ctl-md)', lg: 'var(--kol-ctl-lg)' }
|
|
24
|
+
|
|
22
25
|
export function MenuItem({
|
|
23
26
|
label,
|
|
24
27
|
children,
|
|
@@ -33,6 +36,10 @@ export function MenuItem({
|
|
|
33
36
|
* an affordance; neither reference draws one (ColumnBrowserMobileViews item
|
|
34
37
|
* 15, kol-r2b2 2026-09-04). Default keeps every existing call-site. */
|
|
35
38
|
caret = true,
|
|
39
|
+
/* THE TRIGGER IS ON THE LADDER (apps review 2026-09-29). It was a fixed `h-8` — 32px whatever sat
|
|
40
|
+
* beside it, so media's phone `···` stood 32 next to a 26 `sm` search field. `size` pins it to the
|
|
41
|
+
* control heights (01-foundations/09-sizes: 22 · 26 · 32 · 40); `md` is the 32 it always was. */
|
|
42
|
+
size = 'md',
|
|
36
43
|
}) {
|
|
37
44
|
const [open, setOpen] = useState(defaultOpen)
|
|
38
45
|
const popover = usePopover({
|
|
@@ -51,7 +58,8 @@ export function MenuItem({
|
|
|
51
58
|
ref={popover.refs.setReference}
|
|
52
59
|
{...popover.getReferenceProps()}
|
|
53
60
|
type="button"
|
|
54
|
-
className={`kol-menu-btn kol-helper-12 px-3
|
|
61
|
+
className={`kol-menu-btn kol-helper-12 px-3 inline-flex items-center gap-2 rounded text-body hover:text-emphasis transition-colors ${buttonClassName}`}
|
|
62
|
+
style={{ height: TRIGGER_H[size] ?? TRIGGER_H.md }}
|
|
55
63
|
>
|
|
56
64
|
<span>{label}</span>
|
|
57
65
|
{caret && (
|
package/src/molecules/Modal.jsx
CHANGED
|
@@ -119,7 +119,7 @@ function ModalView({ state, closeWith }) {
|
|
|
119
119
|
/>
|
|
120
120
|
)}
|
|
121
121
|
<div className="flex gap-2 justify-end">
|
|
122
|
-
{state.kind !== 'alert' && <Button
|
|
122
|
+
{state.kind !== 'alert' && <Button size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>}
|
|
123
123
|
<Button variant="primary" size="sm" onClick={submit}>{state.okLabel ?? 'OK'}</Button>
|
|
124
124
|
</div>
|
|
125
125
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import SectionText from './SectionText.jsx'
|
|
2
|
+
import { TITLE_ROLES, useMasthead } from '../utilities/masthead.js'
|
|
2
3
|
/**
|
|
3
4
|
* PageHeader — the page's masthead: an optional eyebrow, the title, and a
|
|
4
5
|
* sub-line.
|
|
@@ -62,19 +63,25 @@ import SectionText from './SectionText.jsx'
|
|
|
62
63
|
* with an `!important` on the margin — a DS text role re-implemented outside.
|
|
63
64
|
* @param {string} subtitleMaxWidth the lede's measure (e.g. '800px' or '60ch'), a prop
|
|
64
65
|
* instead of a consumer selector reaching inside
|
|
66
|
+
* @param {'display'|'mono'} masthead the APP's masthead (utilities/masthead.js, 2026-09-29): inside
|
|
67
|
+
* a Shell it comes from `AppShell masthead` and WINS over `voice`/`size`, and
|
|
68
|
+
* `display` drops the subtitle — one app, one header. Outside a Shell, unset =
|
|
69
|
+
* the props as given, so no site page moves.
|
|
65
70
|
* The bottom rhythm is `--kol-page-header-mb` (default 40px): inline, as before, but through a
|
|
66
71
|
* variable a consumer can re-point where an inline literal could only be `!important`-ed.
|
|
67
72
|
*/
|
|
68
73
|
/* the title role per size and voice — the one thing PageHeader knows that the
|
|
69
|
-
* base does not, because the base's ladder is the SECTION scale
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
mono: { sm: 'kol-mono-heading-03', md: 'kol-mono-display-03', lg: 'kol-mono-display-02' },
|
|
73
|
-
}
|
|
74
|
+
* base does not, because the base's ladder is the SECTION scale. Lives in
|
|
75
|
+
* utilities/masthead.js since 2026-09-29 so a tool's own title reads the same table. */
|
|
76
|
+
const TITLE = TITLE_ROLES
|
|
74
77
|
/* the ONE thing the registers disagree about */
|
|
75
78
|
const LEDE = { app: 'kol-mono-14', site: 'kol-sans-body-01' }
|
|
76
79
|
|
|
77
|
-
export default function PageHeader({ eyebrow, title, subtitle, actions, subtitleMaxWidth, size = 'md', voice = 'sans', register = 'app', titleClass, className = '' }) {
|
|
80
|
+
export default function PageHeader({ eyebrow, title, subtitle: ownSubtitle, actions, subtitleMaxWidth, size: ownSize = 'md', voice: ownVoice = 'sans', register = 'app', titleClass, masthead, className = '' }) {
|
|
81
|
+
const m = useMasthead(masthead)
|
|
82
|
+
const voice = m ? m.voice : ownVoice
|
|
83
|
+
const size = m ? m.size : ownSize
|
|
84
|
+
const subtitle = m && !m.subtitle ? undefined : ownSubtitle
|
|
78
85
|
const roles = TITLE[voice] ?? TITLE.sans
|
|
79
86
|
return (
|
|
80
87
|
/* the block owns its own rhythm — margins inline, never in a shared type
|
|
@@ -83,7 +90,7 @@ export default function PageHeader({ eyebrow, title, subtitle, actions, subtitle
|
|
|
83
90
|
eyebrow={eyebrow}
|
|
84
91
|
headline={title}
|
|
85
92
|
headlineAs="h1"
|
|
86
|
-
headlineClass={`text-fg-96 ${titleClass ?? roles[size] ?? roles.md}`}
|
|
93
|
+
headlineClass={`text-fg-96 ${titleClass ?? roles[size] ?? roles.md}${m?.upper ? ' uppercase' : ''}`}
|
|
87
94
|
body={subtitle}
|
|
88
95
|
bodyClass={`text-oq-64 ${LEDE[register] ?? LEDE.app} min-w-0`}
|
|
89
96
|
actions={actions}
|
|
@@ -103,8 +103,9 @@ export default function SearchInput({
|
|
|
103
103
|
if (expanding && isOpen) inputRef.current?.focus()
|
|
104
104
|
}, [expanding, isOpen])
|
|
105
105
|
|
|
106
|
-
/* the pinned squares, 01-foundations/09-sizes.md — 22 · 26 · 32 · 40
|
|
107
|
-
|
|
106
|
+
/* the pinned squares, 01-foundations/09-sizes.md — 22 · 26 · 32 · 40, read from the theme's
|
|
107
|
+
* `--kol-ctl-*` so the touch rung (D5, 2026-09-29) grows them with every other control */
|
|
108
|
+
const square = `var(--kol-ctl-${['xs', 'sm', 'md', 'lg'].includes(size) ? size : 'md'})`
|
|
108
109
|
/* THE OPEN FIELD'S HEIGHT IS ITS OWN KNOB, defaulting to the square.
|
|
109
110
|
*
|
|
110
111
|
* /work's pill is `h-9` open AND closed, sitting level with a 36px toggle —
|
|
@@ -209,8 +210,10 @@ export default function SearchInput({
|
|
|
209
210
|
|
|
210
211
|
/* Same height pin as Input: Chromium sizes an <input> from font metrics,
|
|
211
212
|
* not CSS line-height, so without this the shell lands ~0.5px tall.
|
|
212
|
-
* h-4 / h-[18px] match the kol-mono-12 / -14 line-heights.
|
|
213
|
-
|
|
213
|
+
* h-4 / h-[18px] match the kol-mono-12 / -14 line-heights. On TOUCH the input types 16 / 22 (the
|
|
214
|
+
* touch rung, D5 2026-09-29), and this utility out-ranked the theme's coarse rule — 16px text in a
|
|
215
|
+
* 16px line in a 26px box; `pointer-coarse:` carries the 22 so the box grows with its row. */
|
|
216
|
+
const heightCls = `${size === 'sm' ? 'h-4' : 'h-[18px]'} pointer-coarse:h-[22px]`
|
|
214
217
|
|
|
215
218
|
const inputCls = [
|
|
216
219
|
'min-w-0 flex-1 bg-transparent border-none outline-none text-auto',
|
|
@@ -7,6 +7,7 @@ import { glyphSize } from '../hooks/glyphLadders.js'
|
|
|
7
7
|
import SearchInput from '../molecules/SearchInput.jsx'
|
|
8
8
|
import IconFrame from '../atoms/IconFrame.jsx'
|
|
9
9
|
import { Tooltip } from '../utilities/Popover.jsx'
|
|
10
|
+
import { filterItems } from '../utilities/searchItems.js'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* ContentFilters — universal filter component for content grids.
|
|
@@ -214,16 +215,8 @@ const ContentFilters = ({
|
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
const filteredItems = useMemo(() => {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const q = searchText.toLowerCase()
|
|
220
|
-
result = result.filter((item) =>
|
|
221
|
-
searchKeys.some((key) => {
|
|
222
|
-
const val = item[key]
|
|
223
|
-
return val && String(val).toLowerCase().includes(q)
|
|
224
|
-
}),
|
|
225
|
-
)
|
|
226
|
-
}
|
|
218
|
+
/* the KOL engine, not a private substring match (apps review 2026-09-29) — utilities/searchItems */
|
|
219
|
+
let result = filterItems(items, searchText, { keys: searchKeys })
|
|
227
220
|
if (activeFilters.size === 0) return result
|
|
228
221
|
return result.filter((item) => {
|
|
229
222
|
let matches = true
|
|
@@ -32,7 +32,8 @@ import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
|
|
|
32
32
|
* @param {string} name the file's name (edit) or a starting name (new)
|
|
33
33
|
* @param {string} kind markdown · text · json · yaml · code · svg (edit); new picks it
|
|
34
34
|
* @param {string} text the saved text; `null` while it loads
|
|
35
|
-
* @param {number} savedAt epoch ms of the saved file — a draft older than this is ignored
|
|
35
|
+
* @param {number} savedAt epoch ms of the saved file — a draft older than this is ignored; `null` = never
|
|
36
|
+
* saved (a blank note, 2026-09-29) and the status says so instead of "Saved"
|
|
36
37
|
* @param {Object} draft `{ bucket, key }` — where the draft lives; omit for no drafts
|
|
37
38
|
* @param {Function} onSave `(text) => Promise` — edit mode
|
|
38
39
|
* @param {Function} onCreate `({ name, text }) => Promise` — new mode; the name carries the extension
|
|
@@ -42,7 +43,7 @@ import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
|
|
|
42
43
|
* @param {Function} onPickAsset `() => Promise<{ name, url, contentType } | null>` — a consumer's own picker instead
|
|
43
44
|
* @param {Function} onClose
|
|
44
45
|
* @param {boolean} inline render IN the page, filling its container, instead of over it (the notes
|
|
45
|
-
* page, media-
|
|
46
|
+
* page, media-hub 2026-09-26: the editor is the page's content there,
|
|
46
47
|
* not a window over a list). No scrim, no Escape-to-close; the X still closes
|
|
47
48
|
*/
|
|
48
49
|
const TYPES = [
|
|
@@ -94,10 +95,10 @@ export default function DocumentEditor({
|
|
|
94
95
|
}
|
|
95
96
|
if (text == null) return
|
|
96
97
|
const d = draft ? readDraft(draft.bucket, draft.key) : null
|
|
97
|
-
const useDraft = d && d.at > savedAt && d.text !== text
|
|
98
|
+
const useDraft = d && d.at > (savedAt ?? 0) && d.text !== text
|
|
98
99
|
setBase(text)
|
|
99
100
|
setValue(useDraft ? d.text : text); latest.current = useDraft ? d.text : text
|
|
100
|
-
setStatus(useDraft ? 'Draft restored — not saved to the file' : 'Saved')
|
|
101
|
+
setStatus(useDraft ? 'Draft restored — not saved to the file' : savedAt === null ? 'Not saved yet' : 'Saved')
|
|
101
102
|
if (d && !useDraft && draft) clearDraft(draft.bucket, draft.key)
|
|
102
103
|
}, [text]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
103
104
|
|
|
@@ -110,7 +111,7 @@ export default function DocumentEditor({
|
|
|
110
111
|
useEffect(() => () => flush(), []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
111
112
|
const change = (next) => {
|
|
112
113
|
setValue(next); latest.current = next
|
|
113
|
-
setStatus(isNew ? 'Not created yet' : next === base ? 'Saved' : 'Edited — not saved')
|
|
114
|
+
setStatus(isNew ? 'Not created yet' : next === base ? (savedAt === null ? 'Not saved yet' : 'Saved') : 'Edited — not saved')
|
|
114
115
|
clearTimeout(timer.current)
|
|
115
116
|
timer.current = setTimeout(flush, DRAFT_PAUSE)
|
|
116
117
|
}
|
|
@@ -197,7 +198,7 @@ export default function DocumentEditor({
|
|
|
197
198
|
<ViewToggle variant="text" viewMode={view} onViewChange={setView} options={views} />
|
|
198
199
|
{md && (onPickAsset || assets?.length > 0) && <Button variant="nav" size="sm" iconLeft="image" onClick={attach} aria-pressed={picking}>Attach</Button>}
|
|
199
200
|
{!isNew && value !== base && value != null && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
|
|
200
|
-
<Button
|
|
201
|
+
<Button size="sm" onClick={save} disabled={!dirty}>{isNew ? 'Create' : 'Save'}</Button>
|
|
201
202
|
</span>
|
|
202
203
|
)}>
|
|
203
204
|
{/* the window's own caps, inline — Tailwind does not generate arbitrary values from package source */}
|
|
@@ -229,7 +230,7 @@ export default function DocumentEditor({
|
|
|
229
230
|
)}
|
|
230
231
|
{value == null
|
|
231
232
|
? <p className="kol-mono-12 text-fg-48">Loading…</p>
|
|
232
|
-
: <Textarea variant="filled" size="
|
|
233
|
+
: <Textarea variant="filled" size="sm" rows={18} axis="vertical" className="w-full flex-1"
|
|
233
234
|
value={md ? parts.body : value} onChange={(e) => (md ? setBody(e.target.value) : change(e.target.value))}
|
|
234
235
|
spellCheck={kind === 'markdown' || kind === 'text'} aria-label="Document text"
|
|
235
236
|
placeholder={md ? 'Write in markdown — # heading, **bold**, - list' : ''} />}
|
|
@@ -16,6 +16,7 @@ import MediaViewer from './MediaViewer.jsx'
|
|
|
16
16
|
import { MediaLibraryBrowse, MediaLibraryLibrary } from './MediaLibraryPages.jsx'
|
|
17
17
|
import MediaLibraryExplorer from './MediaLibraryExplorer.jsx'
|
|
18
18
|
import { SettingsChipRow, chipCls } from './SettingsPanel.jsx'
|
|
19
|
+
import { filterMedia } from '../utilities/mediaSearch.js'
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* MediaLibrary — a browser over an object bucket, in two views over one
|
|
@@ -343,10 +344,7 @@ export function MediaLibraryProvider({
|
|
|
343
344
|
const kindCounts = files.reduce((acc, o) => { acc[o.kind] = (acc[o.kind] || 0) + 1; return acc }, {})
|
|
344
345
|
const kindsPresent = Object.keys(kindCounts).sort().map((k) => ({ value: k, label: KIND_LABEL[k] ?? k, count: kindCounts[k] }))
|
|
345
346
|
|
|
346
|
-
const
|
|
347
|
-
const filtered = files.filter(
|
|
348
|
-
(o) => (kinds.size === 0 || kinds.has(o.kind)) && (!q || o.displayKey.toLowerCase().includes(q)),
|
|
349
|
-
)
|
|
347
|
+
const filtered = filterMedia(files.filter((o) => kinds.size === 0 || kinds.has(o.kind)), search, { nameOf: (o) => o.displayKey })
|
|
350
348
|
const sorted = sortFiles(filtered, sort)
|
|
351
349
|
|
|
352
350
|
const page = pageSize || Infinity
|
|
@@ -783,7 +781,7 @@ function LibraryViewer({ index, onIndexChange, onClose, onPick }) {
|
|
|
783
781
|
actions={(item, i) => (
|
|
784
782
|
<>
|
|
785
783
|
{onPick && <Button size="sm" onClick={() => onPick(viewable[i])}>Use</Button>}
|
|
786
|
-
<Button
|
|
784
|
+
<Button size="sm" onClick={() => copy(item.url)}>
|
|
787
785
|
{copied === item.url ? 'Copied' : 'Copy URL'}
|
|
788
786
|
</Button>
|
|
789
787
|
</>
|
|
@@ -29,7 +29,10 @@ import useMediaQuery from '../hooks/useMediaQuery.js'
|
|
|
29
29
|
* Ignored while typing and with ⌘ / Ctrl / ⌥ held
|
|
30
30
|
* @param {boolean} phoneTabs below 768 the surface is three tabs — Browse · Files · Kinds (Kinds only
|
|
31
31
|
* with `onKinds`); a tab picks the view, Kinds opens the overview and leaves
|
|
32
|
-
* the surface where it was
|
|
32
|
+
* the surface where it was. OFF in the apps since 2026-09-29 (the `···` menu
|
|
33
|
+
* carries view, sort and File formats, as the Files app does); KEPT for a
|
|
34
|
+
* phone-first consumer whose browse surfaces are destinations of their own —
|
|
35
|
+
* a repo that routes Browse and Files as pages wants them one tap apart
|
|
33
36
|
* @param {Function} onKinds opens the app's file-formats overview (K, the Kinds tab)
|
|
34
37
|
*/
|
|
35
38
|
const TABS = [
|
|
@@ -88,6 +91,7 @@ export default function MediaLibraryExplorer({ view, onViewChange, defaultView,
|
|
|
88
91
|
<MediaLibraryBrowse
|
|
89
92
|
{...pageProps}
|
|
90
93
|
{...tabProps}
|
|
94
|
+
onKinds={onKinds}
|
|
91
95
|
view={current}
|
|
92
96
|
onViewChange={setView}
|
|
93
97
|
/>
|
|
@@ -18,6 +18,8 @@ import ContentRow from '../molecules/ContentRow.jsx'
|
|
|
18
18
|
import SortControls from '../molecules/SortControls.jsx'
|
|
19
19
|
import SearchInput from '../molecules/SearchInput.jsx'
|
|
20
20
|
import MobileTabBar, { TABBAR_H } from '../molecules/MobileTabBar.jsx'
|
|
21
|
+
import { filterMedia, rankMedia } from '../utilities/mediaSearch.js'
|
|
22
|
+
import { useMasthead, mastheadTitleClass } from '../utilities/masthead.js'
|
|
21
23
|
import { MenuItem, MenuDropdownItem, MenuDropdownDivider } from '../molecules/MenuItem.jsx'
|
|
22
24
|
import { Tooltip } from '../utilities/Popover.jsx'
|
|
23
25
|
import ContextMenu, { useContextMenu } from '../utilities/ContextMenu.jsx'
|
|
@@ -116,6 +118,7 @@ export const SETTINGS_BASE = {
|
|
|
116
118
|
filters: false, // the filter bar above the body; off until asked for
|
|
117
119
|
rowColumns: false, // row view's Date/Size columns + their header; off, a row is just its name
|
|
118
120
|
rowColumnWidths: { ...ROW_COL_DEFAULTS }, // px, dragged on the header's own dividers
|
|
121
|
+
countLine: 'auto', // 'auto' | 'on' | 'off' — the `N folders · N files · size` line under the body; auto = on at a desk, off on a phone or a touch device (apps review 2026-09-29)
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
/* THE FOUR VIEWS (the merge, user 2026-09-22: "they both just display files"). Columns and rows
|
|
@@ -684,7 +687,7 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
684
687
|
)
|
|
685
688
|
}
|
|
686
689
|
|
|
687
|
-
/* THE DISPLAY SETTINGS AS DATA (media-
|
|
690
|
+
/* THE DISPLAY SETTINGS AS DATA (media-hub, user 2026-09-26: "missing settings sidebar? … or are
|
|
688
691
|
* we saying the shell has settings different from media?"). The rows were hard-wired JSX inside this
|
|
689
692
|
* drawer, so an app page could not show them and a second settings system grew beside it. Now ONE
|
|
690
693
|
* builder: this drawer renders it, and an app renders the same rows on its settings page and its own
|
|
@@ -730,6 +733,8 @@ export function mediaSettingsSections({ settings, onChange, profile }) {
|
|
|
730
733
|
render: () => <SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} /> },
|
|
731
734
|
{ label: 'Direction', align: 'fill',
|
|
732
735
|
render: () => <SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} /> },
|
|
736
|
+
{ label: 'Count line', align: 'fill',
|
|
737
|
+
render: () => <SettingsChoice options={[{ value: 'auto', label: 'Auto' }, { value: 'on', label: 'On' }, { value: 'off', label: 'Off' }]} value={settings.countLine ?? 'auto'} onChange={(v) => set({ countLine: v })} /> },
|
|
733
738
|
] },
|
|
734
739
|
]
|
|
735
740
|
}
|
|
@@ -873,6 +878,8 @@ function TrashPanel({ trash, onClose, run, confirm }) {
|
|
|
873
878
|
}
|
|
874
879
|
|
|
875
880
|
function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta, writable, headerActions, onSettings, onTrash, onHome, headerTrailing }) {
|
|
881
|
+
/* the app's masthead voice when a Shell sets one (2026-09-29), else the display voice as before */
|
|
882
|
+
const titleCls = mastheadTitleClass(useMasthead(), 'kol-sans-display-03')
|
|
876
883
|
/* ONE BUCKET, NO DROPDOWN (user 2026-09-22) — a picker with one thing in it chooses nothing. */
|
|
877
884
|
const options = buckets.length > 1 ? [{ value: 'all', label: `${title} · all` }, ...buckets.map((b) => ({ value: b.id, label: b.label }))] : []
|
|
878
885
|
/* THE WORDMARK IS THE HOME BUTTON when `onHome` is given (user 2026-09-21: "it should reload,
|
|
@@ -883,10 +890,10 @@ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta
|
|
|
883
890
|
<header className="flex items-baseline justify-between gap-4">
|
|
884
891
|
{onHome ? (
|
|
885
892
|
<button type="button" onClick={onHome}
|
|
886
|
-
className=
|
|
893
|
+
className={`${titleCls} min-w-0 truncate text-left cursor-pointer transition-opacity hover:opacity-64`}
|
|
887
894
|
aria-label={`${title} — back to the top`}>{title}</button>
|
|
888
895
|
) : (
|
|
889
|
-
<h1 className=
|
|
896
|
+
<h1 className={`${titleCls} min-w-0 truncate`}>{title}</h1>
|
|
890
897
|
)}
|
|
891
898
|
<div className="flex items-center gap-2 min-w-0">
|
|
892
899
|
{options.length > 0 && <Dropdown className="min-w-0 max-w-[45vw] md:w-48 md:max-w-none" value={appRoot ? 'all' : bucketId} onChange={onBucket} options={options} />}
|
|
@@ -918,7 +925,7 @@ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta
|
|
|
918
925
|
)
|
|
919
926
|
}
|
|
920
927
|
|
|
921
|
-
/* `columnHeight: 'fill'` — THE BROWSER TAKES WHAT IS LEFT OF THE WINDOW (media-
|
|
928
|
+
/* `columnHeight: 'fill'` — THE BROWSER TAKES WHAT IS LEFT OF THE WINDOW (media-hub, user 2026-09-26:
|
|
922
929
|
* "why isnt the browser in media shell using available height like media does?"). apps/media had it
|
|
923
930
|
* as a hand-measured `calc(100dvh - 212px)` — its header, gaps and padding, counted once — so the same
|
|
924
931
|
* tool one shell over got the 528 base. Measured instead: the window, less the view's own top, less
|
|
@@ -1043,6 +1050,14 @@ export function MediaLibraryBrowse({
|
|
|
1043
1050
|
* the consumer's — a repo whose surfaces are routes wires its router here.
|
|
1044
1051
|
* No tabs, no pill, and the page is exactly what it was. */
|
|
1045
1052
|
tabs, activeTab, onTabChange,
|
|
1053
|
+
/* `onKinds` — opens the app's file-formats overview. Given, the phone's `···` menu carries it
|
|
1054
|
+
* under view and sort (apps review 2026-09-29: the Files app keeps everything in its `…`, and
|
|
1055
|
+
* the Kinds tab was the only reason the phone had a tab bar). */
|
|
1056
|
+
onKinds,
|
|
1057
|
+
/* `onOpenSettings` — the gear HANDS OVER to the host (apps review 2026-09-29: three settings in
|
|
1058
|
+
* media-hub, "two too many"). Given, the gear calls it and this page opens no drawer of its own —
|
|
1059
|
+
* inside a Hub the Hub's Settings is the one place. Absent, the gear opens the drawer as always. */
|
|
1060
|
+
onOpenSettings,
|
|
1046
1061
|
/* `bucketLevel` — keep title → bucket → folders with ONE bucket (kol-client-olina 2026-09-23).
|
|
1047
1062
|
* Absent, a one-bucket consumer collapses the level as ruled 2026-09-03. */
|
|
1048
1063
|
bucketLevel = false,
|
|
@@ -1230,7 +1245,7 @@ export function MediaLibraryBrowse({
|
|
|
1230
1245
|
const tooBig = (o.size ?? 0) > EDIT_CAP
|
|
1231
1246
|
return (
|
|
1232
1247
|
<div className="flex items-center gap-3">
|
|
1233
|
-
<Button
|
|
1248
|
+
<Button size="sm" iconLeft="edit" disabled={tooBig} onClick={() => openEditor(o)}>Edit</Button>
|
|
1234
1249
|
{tooBig && <span className="kol-mono-12 text-fg-48">Over 1 MB — too large to edit here</span>}
|
|
1235
1250
|
{!tooBig && draftKeys.has(o.key) && <span className="kol-mono-12 text-fg-48">Unsaved draft</span>}
|
|
1236
1251
|
</div>
|
|
@@ -1755,6 +1770,10 @@ export function MediaLibraryBrowse({
|
|
|
1755
1770
|
* stack there; the row view had no phone form and drew the desktop list and its preview pane
|
|
1756
1771
|
* squeezed into 390 — 35px of rows with no names. Same query, same breakpoint as the columns. */
|
|
1757
1772
|
const phone = useMediaQuery('(max-width: 767px)')
|
|
1773
|
+
/* THE COUNT LINE'S AUTO (apps review 2026-09-29): a desk reads it, a phone spends a line on it */
|
|
1774
|
+
const coarse = useMediaQuery('(pointer: coarse)')
|
|
1775
|
+
const countLine = settings.countLine ?? 'auto'
|
|
1776
|
+
const showCount = countLine === 'on' || (countLine === 'auto' && !phone && !coarse)
|
|
1758
1777
|
const setView = (v) => {
|
|
1759
1778
|
onViewChange?.(v)
|
|
1760
1779
|
setSettings({
|
|
@@ -1816,13 +1835,13 @@ export function MediaLibraryBrowse({
|
|
|
1816
1835
|
* when a file under it matches and ColumnBrowser's own partition does the
|
|
1817
1836
|
* rest. One view, rather than a flat results list nobody asked for. Sort
|
|
1818
1837
|
* orders the files the way the wall already sorts its own. */
|
|
1819
|
-
const q = query.trim()
|
|
1838
|
+
const q = query.trim()
|
|
1820
1839
|
/* SEGMENTS HIDE WHEN FOLDED, in every view (user 2026-09-23: *"we can just by default hide the
|
|
1821
1840
|
* segments, if they serve no practical purpose"*). A `.ts` chunk cannot play on its own — its
|
|
1822
1841
|
* stream's `.m3u8` beside it is the thing you open. The fold used to reach only the grid, so the
|
|
1823
1842
|
* columns and rows listed every chunk. */
|
|
1824
1843
|
const listed = useMemo(() => (settings.foldSegments ? objects.filter((o) => !isSegment(o.key)) : objects), [objects, settings.foldSegments])
|
|
1825
|
-
const searched = useMemo(() => (
|
|
1844
|
+
const searched = useMemo(() => filterMedia(listed, q), [listed, q])
|
|
1826
1845
|
const sortedObjects = useMemo(
|
|
1827
1846
|
() => sortObjects(searched, settings.sortBy, settings.sortDir),
|
|
1828
1847
|
[searched, settings.sortBy, settings.sortDir],
|
|
@@ -2205,10 +2224,7 @@ export function MediaLibraryBrowse({
|
|
|
2205
2224
|
/* The palette's own ruling (2026-08-01) is that Enter COMMITS the query rather than navigating
|
|
2206
2225
|
* to whatever happened to be first — so `onExpand` is where a committed query lands, and here
|
|
2207
2226
|
* that means the top hit. Arrow-then-Enter and a click go through `onSelect`, same jump. */
|
|
2208
|
-
const searchResults = (searchQuery.
|
|
2209
|
-
? objects.filter((o) => o.key.toLowerCase().includes(searchQuery.trim().toLowerCase()))
|
|
2210
|
-
: objects
|
|
2211
|
-
).slice(0, 50).map((o) => ({
|
|
2227
|
+
const searchResults = rankMedia(objects, searchQuery, { limit: 50 }).map((o) => ({
|
|
2212
2228
|
id: o.key,
|
|
2213
2229
|
label: o.key.split('/').pop(),
|
|
2214
2230
|
hint: o.key.slice(0, o.key.lastIndexOf('/') + 1) || 'the bucket root',
|
|
@@ -2230,7 +2246,7 @@ export function MediaLibraryBrowse({
|
|
|
2230
2246
|
if (isSystemFile(o.key)) return false
|
|
2231
2247
|
if ((q.tags ?? []).some((t) => !(o.tags ?? []).includes(t))) return false
|
|
2232
2248
|
if ((q.kinds ?? []).length && !q.kinds.includes(kindOf(o))) return false
|
|
2233
|
-
if (q.text && !o
|
|
2249
|
+
if (q.text && !filterMedia([o], q.text).length) return false
|
|
2234
2250
|
return true
|
|
2235
2251
|
}).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key })) : null
|
|
2236
2252
|
const body = (filtered) => (smartMatches
|
|
@@ -2244,7 +2260,7 @@ export function MediaLibraryBrowse({
|
|
|
2244
2260
|
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
|
|
2245
2261
|
onHome={goTitleRoot}
|
|
2246
2262
|
onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
|
|
2247
|
-
onSettings={() => setSettingsOpen(true)} />
|
|
2263
|
+
onSettings={onOpenSettings ?? (() => setSettingsOpen(true))} />
|
|
2248
2264
|
|
|
2249
2265
|
{/* THE BANNER SLOT — anything that must sit directly under the header and above the body.
|
|
2250
2266
|
* The upload drop zone is why it exists: a consumer rendering it AFTER the page put it
|
|
@@ -2368,6 +2384,7 @@ export function MediaLibraryBrowse({
|
|
|
2368
2384
|
label={<Icon name="more" size={16} />}
|
|
2369
2385
|
caret={false}
|
|
2370
2386
|
align="end"
|
|
2387
|
+
size="sm"
|
|
2371
2388
|
buttonClassName="shrink-0 px-2"
|
|
2372
2389
|
>
|
|
2373
2390
|
{({ close }) => (
|
|
@@ -2403,6 +2420,12 @@ export function MediaLibraryBrowse({
|
|
|
2403
2420
|
{opt.label}
|
|
2404
2421
|
</MenuDropdownItem>
|
|
2405
2422
|
))}
|
|
2423
|
+
{onKinds && (
|
|
2424
|
+
<>
|
|
2425
|
+
<MenuDropdownDivider />
|
|
2426
|
+
<MenuDropdownItem onClick={() => { onKinds(); close() }} shortcut="K">File formats</MenuDropdownItem>
|
|
2427
|
+
</>
|
|
2428
|
+
)}
|
|
2406
2429
|
</div>
|
|
2407
2430
|
)}
|
|
2408
2431
|
</MenuItem>
|
|
@@ -2486,7 +2509,7 @@ export function MediaLibraryBrowse({
|
|
|
2486
2509
|
* slider at its right end, bare: no glyph, no readout (Finder's). */}
|
|
2487
2510
|
{atTitleRoot && !single ? (
|
|
2488
2511
|
<div className="flex items-center justify-between gap-4 h-4">
|
|
2489
|
-
<p className="kol-mono-12 text-fg-48">
|
|
2512
|
+
<p className="kol-mono-12 text-fg-48" hidden={!showCount}>
|
|
2490
2513
|
{atTop ? '1 folder' : `${bucketList.length} ${bucketList.length === 1 ? 'bucket' : 'buckets'}`}
|
|
2491
2514
|
{folderTree && ` · ${Object.values(folderTree).reduce((n, t) => n + (t.files ?? 0), 0)} files · ${formatSize(Object.values(folderTree).reduce((n, t) => n + (t.bytes ?? 0), 0))}`}
|
|
2492
2515
|
</p>
|
|
@@ -2496,12 +2519,12 @@ export function MediaLibraryBrowse({
|
|
|
2496
2519
|
/* ONE LINE'S HEIGHT, like the other views' count line — the slider is 24px tall and made
|
|
2497
2520
|
* this row 8px taller than the fill budget, so the page scrolled by that much */
|
|
2498
2521
|
<div className="flex items-center justify-between gap-4 h-4">
|
|
2499
|
-
<p className="kol-mono-12 text-fg-48">
|
|
2522
|
+
<p className="kol-mono-12 text-fg-48" hidden={!showCount}>
|
|
2500
2523
|
{!settings.flat && folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}{wallFiles.length} {wallFiles.length === 1 ? 'file' : 'files'} · {formatSize(wallFiles.reduce((n, o) => n + (o.size ?? 0), 0))}
|
|
2501
2524
|
</p>
|
|
2502
2525
|
{sizeSlider}
|
|
2503
2526
|
</div>
|
|
2504
|
-
) : (
|
|
2527
|
+
) : showCount && (
|
|
2505
2528
|
<p className="kol-mono-12 text-fg-48">
|
|
2506
2529
|
{folders.length > 0 && `${folders.length} folder${folders.length > 1 ? 's' : ''} · `}
|
|
2507
2530
|
{rawFiles.length} {rawFiles.length === 1 ? 'file' : 'files'} · {formatSize(totalBytes)}
|
|
@@ -196,14 +196,17 @@ export const chipCls = (on) => `${CHIP_CLS} ${on ? 'kol-control--filled' : 'text
|
|
|
196
196
|
* @param {Array|Set} selected
|
|
197
197
|
* @param {Function} onToggle (value) => void
|
|
198
198
|
* @param {string} noun the trigger's noun (default 'kinds')
|
|
199
|
+
* @param {string} variant passed to the Dropdown like `SettingsChoice`'s — unset inherits the page's
|
|
200
|
+
* tone. It was hard-coded `primary` until 2026-09-29, so on a sunken settings
|
|
201
|
+
* page it was the one lighter dropdown in the section.
|
|
199
202
|
*/
|
|
200
|
-
export function SettingsMulti({ options = [], selected = [], onToggle, noun = 'kinds', tone = 'default', size = 'sm', className = '' }) {
|
|
203
|
+
export function SettingsMulti({ options = [], selected = [], onToggle, noun = 'kinds', tone = 'default', size = 'sm', variant, className = '' }) {
|
|
201
204
|
const on = selected instanceof Set ? selected : new Set(selected)
|
|
202
205
|
const opts = [
|
|
203
206
|
{ value: '__summary', label: `${on.size} of ${options.length} ${noun}` },
|
|
204
207
|
...options.map((o) => ({ value: o.value, label: `${on.has(o.value) ? '✓ ' : ''}${o.label ?? String(o.value)}` })),
|
|
205
208
|
]
|
|
206
|
-
return <Dropdown size={size} variant=
|
|
209
|
+
return <Dropdown size={size} variant={variant} tone={tone} value="__summary" options={opts} onChange={(v) => (v === '__summary' ? null : onToggle?.(v))} className={`w-full ${className}`.trim()} />
|
|
207
210
|
}
|
|
208
211
|
|
|
209
212
|
/**
|
|
@@ -46,13 +46,15 @@ import SettingsSections from './SettingsSections.jsx'
|
|
|
46
46
|
const isSectioned = (list) => Array.isArray(list?.[0]?.items)
|
|
47
47
|
|
|
48
48
|
/* A shortcut as a settings ROW: the label on the left, the combo as the row's
|
|
49
|
-
* value. `labelWidth="auto"` makes the label yield and the combo hug
|
|
50
|
-
*
|
|
49
|
+
* value. `labelWidth="auto"` makes the label yield and the combo hug. A combo
|
|
50
|
+
* holds on one line from `sm` up; below it, it wraps under its label (LabeledControl stacks) — media's
|
|
51
|
+
* combos are sentences ("Drag from empty space (rows: …"), and held on one line
|
|
52
|
+
* they pushed the sheet past both edges of a phone (apps review, 2026-09-29). */
|
|
51
53
|
const toRow = ({ label, combo, keys }) => ({
|
|
52
54
|
label,
|
|
53
55
|
align: 'fill',
|
|
54
56
|
labelWidth: 'auto',
|
|
55
|
-
value: <span className="text-fg-32 kol-helper-12 whitespace-nowrap">{combo ?? keys}</span>,
|
|
57
|
+
value: <span className="text-fg-32 kol-helper-12 sm:whitespace-nowrap">{combo ?? keys}</span>,
|
|
56
58
|
})
|
|
57
59
|
|
|
58
60
|
export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
|
|
@@ -86,7 +88,9 @@ export default function ShortcutsOverlay({ shortcuts = [], onClose }) {
|
|
|
86
88
|
* editor's is six sections — ran past both edges of the viewport with no way to reach the
|
|
87
89
|
* rest. Capped to the window less a margin, scrolling inside. */
|
|
88
90
|
className="kol-shortcuts-panel text-fg-64 kol-helper-12 bg-oq-04 flex flex-col gap-6 overflow-y-auto"
|
|
89
|
-
|
|
91
|
+
/* and the window's WIDTH too (apps review, 2026-09-29): nothing capped it, so the widest
|
|
92
|
+
* row set the sheet's width and a phone showed its middle third */
|
|
93
|
+
style={{ padding: 24, borderRadius: 4, maxHeight: 'calc(100dvh - 48px)', maxWidth: 'calc(100vw - 32px)' }}
|
|
90
94
|
onClick={(e) => e.stopPropagation()}
|
|
91
95
|
>
|
|
92
96
|
<SettingsSections
|
|
@@ -61,7 +61,7 @@ export default class ErrorBoundary extends Component {
|
|
|
61
61
|
|
|
62
62
|
<div className="flex gap-4 justify-center flex-wrap">
|
|
63
63
|
<Button variant="primary" onClick={this.handleReset}>Try again</Button>
|
|
64
|
-
<Button
|
|
64
|
+
<Button href={this.props.homeHref ?? '/'}>Go home</Button>
|
|
65
65
|
</div>
|
|
66
66
|
</div>
|
|
67
67
|
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* masthead — ONE APP, ONE MASTHEAD (apps review 2026-09-29, ruling D3). media-hub wore MEDIA in the
|
|
5
|
+
* display voice on Browse and a mono title + description on its Hub pages: two headers in one app,
|
|
6
|
+
* because each page picked its own. The voice is now the app's, set ONCE on the Shell
|
|
7
|
+
* (`AppShell masthead`) and read by every page inside it — `PageHeader` (and so `CatalogPage`,
|
|
8
|
+
* `HubHome`, `SettingsScaffold`) and the media tool's own title.
|
|
9
|
+
*
|
|
10
|
+
* display the default — "uppercase sans like media" (the user, 2026-09-29): the display voice,
|
|
11
|
+
* UPPERCASE as a role (03-typography § Casing — case rides the call site, not the class),
|
|
12
|
+
* no description line
|
|
13
|
+
* mono the Hub's older look — a mono title with its description under it
|
|
14
|
+
*
|
|
15
|
+
* Outside a Shell there is no context and every header renders exactly as its props say, so a
|
|
16
|
+
* site page is untouched. A page may still pass `masthead` itself (a Catalog with no Shell).
|
|
17
|
+
*/
|
|
18
|
+
export const MASTHEADS = {
|
|
19
|
+
display: { voice: 'sans', size: 'md', subtitle: false, upper: true },
|
|
20
|
+
mono: { voice: 'mono', size: 'sm', subtitle: true, upper: false },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* the title role per voice and size — PageHeader's ladder, here so a tool's own title reads it too */
|
|
24
|
+
export const TITLE_ROLES = {
|
|
25
|
+
sans: { sm: 'kol-sans-heading-03', md: 'kol-sans-display-03', lg: 'kol-sans-display-02' },
|
|
26
|
+
mono: { sm: 'kol-mono-heading-03', md: 'kol-mono-display-03', lg: 'kol-mono-display-02' },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const MastheadContext = createContext(null)
|
|
30
|
+
|
|
31
|
+
/** the masthead in effect — the page's own `masthead` prop, else the Shell's, else null */
|
|
32
|
+
export function useMasthead(own) {
|
|
33
|
+
const shell = useContext(MastheadContext)
|
|
34
|
+
const name = own ?? shell
|
|
35
|
+
return name && MASTHEADS[name] ? { name, ...MASTHEADS[name] } : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** the title class a masthead gives — for a tool that draws its own title (media's LibraryHeader) */
|
|
39
|
+
export const mastheadTitleClass = (m, fallback) => (m ? `${TITLE_ROLES[m.voice][m.size]}${m.upper ? ' uppercase' : ''}` : fallback)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mediaSearch — the media library's search, on the KOL engine (`@kolkrabbi/kol-search`).
|
|
3
|
+
*
|
|
4
|
+
* Four places in the media pages each ran their own `key.toLowerCase().includes(q)` — the browse
|
|
5
|
+
* filter, the ⌘K palette, the smart-folder text match, the picker's filter (apps review,
|
|
6
|
+
* 2026-09-29). One helper now, so the media search speaks the same language as every other KOL
|
|
7
|
+
* search: several words must ALL match, `-word` must not, `"a phrase"`, `tag:x` / `#x`,
|
|
8
|
+
* `kind:image` / `is:video`, `after:2026-09-01`. A plain substring still finds what it found.
|
|
9
|
+
*
|
|
10
|
+
* An object becomes an engine item: its file name is the title (ranked first), the whole key is a
|
|
11
|
+
* keyword (so a folder name in the path still matches), its tags and kind are facets, its upload
|
|
12
|
+
* date the date.
|
|
13
|
+
*/
|
|
14
|
+
import { createIndex, search } from '@kolkrabbi/kol-search'
|
|
15
|
+
import { kindOf } from './mediaKinds.js'
|
|
16
|
+
|
|
17
|
+
const toItem = (o, nameOf) => ({
|
|
18
|
+
id: o.key,
|
|
19
|
+
title: nameOf ? nameOf(o) : o.key.slice(o.key.lastIndexOf('/') + 1) || o.key,
|
|
20
|
+
keywords: [o.key],
|
|
21
|
+
tags: o.tags ?? [],
|
|
22
|
+
kind: o.kind ?? kindOf(o),
|
|
23
|
+
date: o.uploaded ? String(o.uploaded).slice(0, 10) : undefined,
|
|
24
|
+
o,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** ranked hits, best first — the palette's order. `nameOf(o)` overrides the title (a display key). */
|
|
28
|
+
export function rankMedia(objects, query, { nameOf, limit } = {}) {
|
|
29
|
+
const q = String(query ?? '').trim()
|
|
30
|
+
if (!q) return limit ? objects.slice(0, limit) : objects
|
|
31
|
+
const index = createIndex(objects.map((o) => toItem(o, nameOf)), { facets: ['kind', 'tags'], smart: ['kind'] })
|
|
32
|
+
return search(index, q, { limit }).results.map((r) => r.item.o)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** the matches in their ORIGINAL order — for views that sort on their own afterwards. */
|
|
36
|
+
export function filterMedia(objects, query, opts) {
|
|
37
|
+
const q = String(query ?? '').trim()
|
|
38
|
+
if (!q) return objects
|
|
39
|
+
const hit = new Set(rankMedia(objects, q, opts))
|
|
40
|
+
return objects.filter((o) => hit.has(o))
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* searchItems — any list of objects, searched by the KOL engine (`@kolkrabbi/kol-search`).
|
|
3
|
+
*
|
|
4
|
+
* `ContentFilters` ran `String(item[key]).toLowerCase().includes(q)` over its `searchKeys` — one more
|
|
5
|
+
* private engine (apps review 2026-09-29: *"one engine with tiers not endless individual engines each
|
|
6
|
+
* slightly different"*). Through this, every catalog searches the same way: several words must ALL
|
|
7
|
+
* match, `-word` must not, `"a phrase"`, `tag:x` / `#x` against the item's `tags`. A single word still
|
|
8
|
+
* finds everything the substring found.
|
|
9
|
+
*
|
|
10
|
+
* The FIRST search key is the title (ranked highest); the rest are keywords. The items come back in
|
|
11
|
+
* THEIR order — a catalog sorts itself.
|
|
12
|
+
*/
|
|
13
|
+
import { createIndex, search } from '@kolkrabbi/kol-search'
|
|
14
|
+
|
|
15
|
+
const list = (v) => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v])
|
|
16
|
+
|
|
17
|
+
export function filterItems(items, query, { keys = ['title'] } = {}) {
|
|
18
|
+
const q = String(query ?? '').trim()
|
|
19
|
+
if (!q) return items
|
|
20
|
+
const [first, ...rest] = keys
|
|
21
|
+
const index = createIndex(items.map((item, i) => ({
|
|
22
|
+
id: i,
|
|
23
|
+
title: String(item[first] ?? ''),
|
|
24
|
+
keywords: rest.flatMap((k) => list(item[k])).map(String),
|
|
25
|
+
tags: list(item.tags).map(String),
|
|
26
|
+
item,
|
|
27
|
+
})), { facets: ['tags'], smart: [] })
|
|
28
|
+
const hit = new Set(search(index, q).results.map((r) => r.item.item))
|
|
29
|
+
return items.filter((it) => hit.has(it))
|
|
30
|
+
}
|