@kolkrabbi/kol-component 0.222.0 → 0.224.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.224.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/index.js
CHANGED
|
@@ -121,6 +121,7 @@ export { default as SpecList } from './molecules/SpecList.jsx'
|
|
|
121
121
|
export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './organisms/SpectrumControls.jsx'
|
|
122
122
|
export { default as SplitToolButton } from './molecules/SplitToolButton.jsx'
|
|
123
123
|
export { default as ToolPalette } from './organisms/ToolPalette.jsx'
|
|
124
|
+
export { default as DocumentEditor } from './organisms/DocumentEditor.jsx'
|
|
124
125
|
export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
|
|
125
126
|
export { default as TabsRow } from './molecules/TabsRow.jsx'
|
|
126
127
|
|
|
@@ -187,7 +188,8 @@ export { default as RowMenuButton } from './molecules/RowMenuButton.jsx'
|
|
|
187
188
|
export { default as DocPage } from './molecules/DocPage.jsx'
|
|
188
189
|
export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
|
|
189
190
|
export { default as ProfileCard } from './molecules/ProfileCard.jsx'
|
|
190
|
-
export { parseFrontmatter } from './utilities/frontmatter.js'
|
|
191
|
+
export { parseFrontmatter, splitFrontmatter, joinFrontmatter } from './utilities/frontmatter.js'
|
|
192
|
+
export { readDraft, writeDraft, clearDraft, listDrafts, moveDrafts, DRAFTS_EVENT } from './utilities/localDrafts.js'
|
|
191
193
|
export { readCover } from './utilities/id3.js'
|
|
192
194
|
export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegment, groupSegments, groupVariants, posterFor, partition } from './utilities/mediaKinds.js'
|
|
193
195
|
export { nearestRatio, RATIOS } from './utilities/ratios.js'
|
|
@@ -63,6 +63,7 @@ import { GRAB_COLUMN } from '../utilities/motion.js'
|
|
|
63
63
|
* @param {Function} formatDate (isoString) => string — the row/preview date, ISO date-only by default
|
|
64
64
|
* @param {Function} partition (objects, level) => { folders: string[], files: object[] }
|
|
65
65
|
* @param {Function} renderPreview (file) => ReactNode — replaces the preview column's media frame (the facts stay — Dimensions and Length are read off whatever <img> / <video> / <audio> the node loads); without it images render the organism's <img>, everything else the DS KindPreview
|
|
66
|
+
* @param {Function} renderDetails (file) => ReactNode — rows under the preview column's facts (tags, an Edit action); absent, nothing is added
|
|
66
67
|
* @param {number|string} height controlled height — px as a number, or ANY CSS length (`'100%'`, `'60vh'`,
|
|
67
68
|
* `'calc(100dvh - 240px)'`) so the browser can fill the space it is given
|
|
68
69
|
* (kol-client-olina 2026-09-22); a CSS length draws no height grabber;
|
|
@@ -260,10 +261,16 @@ function Row({
|
|
|
260
261
|
onDragStart: drop ? (e) => { e.stopPropagation(); drop.onDragStart(e, drop.path) } : undefined,
|
|
261
262
|
onDragOver: (e) => { if (takesFiles(e) || (drop && !isFileDrag(e) && drop.canDrop(drop.path))) { e.preventDefault(); setOver(true) } },
|
|
262
263
|
onDragLeave: () => setOver(false),
|
|
264
|
+
/* A ROW THAT CANNOT TAKE THE DROP LETS IT BUBBLE (the file-onto-file bug, 2026-09-25): the
|
|
265
|
+
* enclosing column / list allows the drag, so the drop still fires HERE, and moving into a
|
|
266
|
+
* FILE's key renamed the dragged file. Unguarded, it skipped `canDrop`; now the container
|
|
267
|
+
* takes it — dropped on a file, it lands in that file's folder, as Finder does. */
|
|
263
268
|
onDrop: (e) => {
|
|
264
|
-
|
|
265
|
-
if (takesFiles(e)) dropFiles(e.dataTransfer.files)
|
|
266
|
-
|
|
269
|
+
setOver(false)
|
|
270
|
+
if (takesFiles(e)) { e.preventDefault(); e.stopPropagation(); dropFiles(e.dataTransfer.files); return }
|
|
271
|
+
if (!drop || isFileDrag(e) || !drop.canDrop(drop.path)) return
|
|
272
|
+
e.preventDefault(); e.stopPropagation()
|
|
273
|
+
drop.onDrop(drop.path)
|
|
267
274
|
},
|
|
268
275
|
} : {}
|
|
269
276
|
return (
|
|
@@ -350,7 +357,7 @@ function Row({
|
|
|
350
357
|
)
|
|
351
358
|
}
|
|
352
359
|
|
|
353
|
-
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, width }) {
|
|
360
|
+
function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPreview, renderDetails, width }) {
|
|
354
361
|
// Pixel size and length come from the loaded media itself — the bucket stores
|
|
355
362
|
// none. `{ w, h }` off an <img> load, `{ w, h, len }` off a <video>'s and
|
|
356
363
|
// `{ len }` off an <audio>'s loadedmetadata (ColumnBrowserMediaFacts, kol-r2b2
|
|
@@ -408,6 +415,14 @@ function Preview({ o, urlOf, kindOf, kindLabel, formatSize, formatDate, renderPr
|
|
|
408
415
|
</div>
|
|
409
416
|
))}
|
|
410
417
|
</dl>
|
|
418
|
+
{/* `renderDetails(o)` — the consumer's rows under the facts (the media D1 pass: tags, Edit) */}
|
|
419
|
+
{/* its own island: a click or a drag in here is not a click on the browser's background,
|
|
420
|
+
which deselects (a tag field would unpick the file it edits) */}
|
|
421
|
+
{renderDetails && (
|
|
422
|
+
<div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
|
|
423
|
+
{renderDetails(o)}
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
411
426
|
</div>
|
|
412
427
|
)
|
|
413
428
|
}
|
|
@@ -474,6 +489,9 @@ export default function ColumnBrowser({
|
|
|
474
489
|
formatDate = defaultFormatDate,
|
|
475
490
|
partition = defaultPartition,
|
|
476
491
|
renderPreview,
|
|
492
|
+
/* `renderDetails(file)` — extra rows under the preview column's facts (tags, an Edit action);
|
|
493
|
+
* absent, the column is exactly what it was */
|
|
494
|
+
renderDetails,
|
|
477
495
|
height,
|
|
478
496
|
defaultHeight = 528,
|
|
479
497
|
onHeightChange,
|
|
@@ -648,6 +666,9 @@ export default function ColumnBrowser({
|
|
|
648
666
|
|
|
649
667
|
const shiftAnchor = useRef(null)
|
|
650
668
|
const onKeyDown = (e) => {
|
|
669
|
+
/* typing in a field inside the browser (the preview's tag field) is not navigation */
|
|
670
|
+
const el = e.target
|
|
671
|
+
if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
|
|
651
672
|
/* ⌘↑ / ⌘↓ (user 2026-09-23: *"all modes should 'command up down' to move up and down parent
|
|
652
673
|
* child"*) — Finder's: ⌘↑ is ←, back to the enclosing folder; ⌘↓ opens what the cursor is on,
|
|
653
674
|
* → for a folder and Quick Look for a file. */
|
|
@@ -1029,7 +1050,7 @@ export default function ColumnBrowser({
|
|
|
1029
1050
|
</>
|
|
1030
1051
|
) : shown && (
|
|
1031
1052
|
<>
|
|
1032
|
-
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} width={widthOf('preview')} />
|
|
1053
|
+
<Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} formatDate={formatDate} renderPreview={renderPreview} renderDetails={renderDetails} width={widthOf('preview')} />
|
|
1033
1054
|
<ResizeHandle axis="x" onDrag={resizeCol('preview')} onEnd={endDrag} />
|
|
1034
1055
|
</>
|
|
1035
1056
|
)}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/* taxonomy-ok: organism — composes QuickLookFrame, FullscreenOverlay, Input, Textarea, ViewToggle, Button and KindPreview into one editing surface. */
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
import Button from '../atoms/Button.jsx'
|
|
4
|
+
import Input from '../atoms/Input.jsx'
|
|
5
|
+
import Textarea from '../atoms/Textarea.jsx'
|
|
6
|
+
import ViewToggle from '../atoms/ViewToggle.jsx'
|
|
7
|
+
import QuickLookFrame from '../molecules/QuickLookFrame.jsx'
|
|
8
|
+
import KindPreview from '../molecules/KindPreview.jsx'
|
|
9
|
+
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
10
|
+
import { splitFrontmatter, joinFrontmatter } from '../utilities/frontmatter.js'
|
|
11
|
+
import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* DocumentEditor — write a text file: open one, or make a new one (media D1 plan v2, 2026-09-26).
|
|
15
|
+
*
|
|
16
|
+
* The concept is kol-olina's brand notes page (NoteEdit): a title, a markdown body, Write / Preview,
|
|
17
|
+
* attachments from the bucket, a page for writing something NEW rather than a button hidden in a
|
|
18
|
+
* list. Here the document is a FILE in the bucket, so it takes any text kind, and a markdown file's
|
|
19
|
+
* frontmatter gets a form instead of a YAML fence to hand-edit.
|
|
20
|
+
*
|
|
21
|
+
* markdown a FIELDS form (title · description · date · tags suggested, any key allowed) over
|
|
22
|
+
* the body; Write · Split · Preview; Attach inserts a bucket file as a link / image
|
|
23
|
+
* svg text with a live picture beside it
|
|
24
|
+
* the rest text (json · yaml · csv · txt · code) with Split / Preview through KindPreview
|
|
25
|
+
*
|
|
26
|
+
* TWO TIERS, as NoteEdit had them: the DRAFT is browser memory (`localDrafts`, every pause, no
|
|
27
|
+
* network — the user's ruling), the SAVE is explicit (`onSave` / ⌘S). A draft is restored only if it
|
|
28
|
+
* is newer than the saved file (`savedAt`), so a file saved since is never overwritten by an old
|
|
29
|
+
* draft. Revert drops the draft.
|
|
30
|
+
*
|
|
31
|
+
* @param {'edit'|'new'} mode `new` shows the name + type row and calls `onCreate`
|
|
32
|
+
* @param {string} name the file's name (edit) or a starting name (new)
|
|
33
|
+
* @param {string} kind markdown · text · json · yaml · code · svg (edit); new picks it
|
|
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
|
|
36
|
+
* @param {Object} draft `{ bucket, key }` — where the draft lives; omit for no drafts
|
|
37
|
+
* @param {Function} onSave `(text) => Promise` — edit mode
|
|
38
|
+
* @param {Function} onCreate `({ name, text }) => Promise` — new mode; the name carries the extension
|
|
39
|
+
* @param {string} folder new mode: where the file will be made (shown, not edited)
|
|
40
|
+
* @param {Array} assets `[{ key, name, url, contentType }]` — the files markdown's Attach offers; a
|
|
41
|
+
* searchable list inside the editor inserts the pick at the caret
|
|
42
|
+
* @param {Function} onPickAsset `() => Promise<{ name, url, contentType } | null>` — a consumer's own picker instead
|
|
43
|
+
* @param {Function} onClose
|
|
44
|
+
*/
|
|
45
|
+
const TYPES = [
|
|
46
|
+
{ value: 'md', label: 'MD', kind: 'markdown' },
|
|
47
|
+
{ value: 'txt', label: 'TXT', kind: 'text' },
|
|
48
|
+
{ value: 'json', label: 'JSON', kind: 'json' },
|
|
49
|
+
{ value: 'yaml', label: 'YAML', kind: 'yaml' },
|
|
50
|
+
{ value: 'csv', label: 'CSV', kind: 'text' },
|
|
51
|
+
]
|
|
52
|
+
const today = () => new Date().toISOString().slice(0, 10)
|
|
53
|
+
const STARTER = {
|
|
54
|
+
md: (title) => joinFrontmatter([['title', title], ['date', today()], ['tags', []]], `\n# ${title}\n\n`),
|
|
55
|
+
txt: () => '',
|
|
56
|
+
json: () => '{\n \n}\n',
|
|
57
|
+
yaml: () => '',
|
|
58
|
+
csv: () => 'name,value\n',
|
|
59
|
+
}
|
|
60
|
+
/* the keys a document usually carries, offered as one-click rows — any other key is typed */
|
|
61
|
+
const SUGGESTED = ['title', 'description', 'date', 'tags']
|
|
62
|
+
const DRAFT_PAUSE = 400 // browser memory is free — a short pause, not D1's write budget
|
|
63
|
+
|
|
64
|
+
export default function DocumentEditor({
|
|
65
|
+
mode = 'edit', name: nameProp = '', kind: kindProp, text, savedAt = 0, draft,
|
|
66
|
+
onSave, onCreate, folder = '', assets, onPickAsset, onClose,
|
|
67
|
+
}) {
|
|
68
|
+
const [picking, setPicking] = useState(false)
|
|
69
|
+
const [pickQuery, setPickQuery] = useState('')
|
|
70
|
+
const isNew = mode === 'new'
|
|
71
|
+
const [name, setName] = useState(isNew ? (nameProp || 'untitled') : nameProp)
|
|
72
|
+
const [type, setType] = useState('md')
|
|
73
|
+
const kind = isNew ? TYPES.find((t) => t.value === type).kind : kindProp
|
|
74
|
+
const [value, setValue] = useState(null)
|
|
75
|
+
const [base, setBase] = useState(null)
|
|
76
|
+
const [status, setStatus] = useState(isNew ? 'Not created yet' : 'Loading…')
|
|
77
|
+
const [view, setView] = useState(kind === 'markdown' || kind === 'svg' ? 'split' : 'write')
|
|
78
|
+
const timer = useRef(null)
|
|
79
|
+
const bodyRef = useRef(null)
|
|
80
|
+
const latest = useRef('')
|
|
81
|
+
|
|
82
|
+
/* load: the saved text, or a newer draft; a new document starts from its type's starter */
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (isNew) {
|
|
85
|
+
const d = draft ? readDraft(draft.bucket, draft.key) : null
|
|
86
|
+
const start = d?.text ?? STARTER.md(name)
|
|
87
|
+
setValue(start); latest.current = start; setBase('')
|
|
88
|
+
if (d) setStatus('Draft restored')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (text == null) return
|
|
92
|
+
const d = draft ? readDraft(draft.bucket, draft.key) : null
|
|
93
|
+
const useDraft = d && d.at > savedAt && d.text !== text
|
|
94
|
+
setBase(text)
|
|
95
|
+
setValue(useDraft ? d.text : text); latest.current = useDraft ? d.text : text
|
|
96
|
+
setStatus(useDraft ? 'Draft restored — not saved to the file' : 'Saved')
|
|
97
|
+
if (d && !useDraft && draft) clearDraft(draft.bucket, draft.key)
|
|
98
|
+
}, [text]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
99
|
+
|
|
100
|
+
const flush = () => {
|
|
101
|
+
if (timer.current == null || !draft) return
|
|
102
|
+
clearTimeout(timer.current); timer.current = null
|
|
103
|
+
if (latest.current === base && !isNew) clearDraft(draft.bucket, draft.key)
|
|
104
|
+
else writeDraft(draft.bucket, draft.key, latest.current)
|
|
105
|
+
}
|
|
106
|
+
useEffect(() => () => flush(), []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
107
|
+
const change = (next) => {
|
|
108
|
+
setValue(next); latest.current = next
|
|
109
|
+
setStatus(isNew ? 'Not created yet' : next === base ? 'Saved' : 'Edited — not saved')
|
|
110
|
+
clearTimeout(timer.current)
|
|
111
|
+
timer.current = setTimeout(flush, DRAFT_PAUSE)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const dirty = value != null && (isNew || value !== base)
|
|
115
|
+
const save = async () => {
|
|
116
|
+
if (!dirty) return
|
|
117
|
+
clearTimeout(timer.current); timer.current = null
|
|
118
|
+
setStatus(isNew ? 'Creating…' : 'Saving…')
|
|
119
|
+
try {
|
|
120
|
+
if (isNew) {
|
|
121
|
+
const file = name.trim().replace(/\.[^./]+$/, '') || 'untitled'
|
|
122
|
+
await onCreate?.({ name: `${file}.${type}`, text: latest.current })
|
|
123
|
+
} else {
|
|
124
|
+
await onSave?.(latest.current)
|
|
125
|
+
setBase(latest.current)
|
|
126
|
+
}
|
|
127
|
+
if (draft) clearDraft(draft.bucket, draft.key)
|
|
128
|
+
setStatus('Saved')
|
|
129
|
+
} catch (e) { setStatus(`Not saved: ${e.message}`) }
|
|
130
|
+
}
|
|
131
|
+
const revert = () => {
|
|
132
|
+
clearTimeout(timer.current); timer.current = null
|
|
133
|
+
if (draft) clearDraft(draft.bucket, draft.key)
|
|
134
|
+
const back = isNew ? STARTER[type](name) : base
|
|
135
|
+
setValue(back); latest.current = back; setStatus(isNew ? 'Not created yet' : 'Saved')
|
|
136
|
+
}
|
|
137
|
+
/* ⌘S anywhere while the editor is open */
|
|
138
|
+
const saveRef = useRef(save)
|
|
139
|
+
saveRef.current = save
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveRef.current() } }
|
|
142
|
+
window.addEventListener('keydown', onKey)
|
|
143
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
144
|
+
}, [])
|
|
145
|
+
|
|
146
|
+
/* markdown: the fields form and the body are one text, split for editing and joined on change */
|
|
147
|
+
const md = kind === 'markdown'
|
|
148
|
+
const parts = useMemo(() => (md && value != null ? splitFrontmatter(value) : null), [md, value])
|
|
149
|
+
const setFields = (fields) => change(joinFrontmatter(fields, parts.body))
|
|
150
|
+
const setBody = (body) => change(parts.has || parts.fields.length ? joinFrontmatter(parts.fields, body) : body)
|
|
151
|
+
|
|
152
|
+
const insertAsset = (a) => {
|
|
153
|
+
const link = (a.contentType ?? '').startsWith('image/') ? `` : `[${a.name}](${a.url})`
|
|
154
|
+
const el = bodyRef.current?.querySelector('textarea')
|
|
155
|
+
const body = parts.body
|
|
156
|
+
const at = el ? el.selectionStart : body.length
|
|
157
|
+
setBody(`${body.slice(0, at)}${link}${body.slice(at)}`)
|
|
158
|
+
setPicking(false)
|
|
159
|
+
}
|
|
160
|
+
const attach = async () => {
|
|
161
|
+
if (!onPickAsset) { setPicking((p) => !p); return }
|
|
162
|
+
const a = await onPickAsset()
|
|
163
|
+
if (a) insertAsset(a)
|
|
164
|
+
}
|
|
165
|
+
/* images first — Attach is mostly "put this picture in the document" */
|
|
166
|
+
const pickList = (assets ?? [])
|
|
167
|
+
.filter((a) => !pickQuery || a.key.toLowerCase().includes(pickQuery.toLowerCase()))
|
|
168
|
+
.sort((a, b) => Number(!(a.contentType ?? '').startsWith('image/')) - Number(!(b.contentType ?? '').startsWith('image/')) || a.key.localeCompare(b.key))
|
|
169
|
+
.slice(0, 60)
|
|
170
|
+
|
|
171
|
+
/* THE NAME CARRIES THE TITLE while the title is still the starter's — the first thing typed names
|
|
172
|
+
* both, as NoteEdit's title named the note; once the title is edited it is left alone */
|
|
173
|
+
const rename = (next) => {
|
|
174
|
+
const was = name
|
|
175
|
+
setName(next)
|
|
176
|
+
if (!md || !parts) return
|
|
177
|
+
const title = parts.fields.find(([k]) => k.toLowerCase() === 'title')
|
|
178
|
+
if (!title || title[1] !== was) return
|
|
179
|
+
const heading = `# ${was}`
|
|
180
|
+
const body = parts.body.startsWith(`\n${heading}`) || parts.body.startsWith(heading) ? parts.body.replace(heading, `# ${next}`) : parts.body
|
|
181
|
+
change(joinFrontmatter(parts.fields.map(([k, v]) => (k === title[0] ? [k, next] : [k, v])), body))
|
|
182
|
+
}
|
|
183
|
+
const close = () => { flush(); onClose?.() }
|
|
184
|
+
const views = [{ value: 'write', label: 'Write' }, { value: 'split', label: 'Split' }, { value: 'preview', label: 'Preview' }]
|
|
185
|
+
const preview = value == null ? null : kind === 'svg'
|
|
186
|
+
? <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(value)}`} alt="" className="max-w-full max-h-full object-contain m-auto" />
|
|
187
|
+
: <KindPreview o={{ key: name || 'document' }} text={value} kind={kind} />
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<FullscreenOverlay open onClose={close} closeButton={false} scrim>
|
|
191
|
+
<QuickLookFrame title={isNew ? `New document${folder ? ` in ${folder}` : ''}` : name} meta={status} onClose={close}
|
|
192
|
+
actions={(
|
|
193
|
+
<span className="flex items-center gap-2">
|
|
194
|
+
<ViewToggle variant="text" viewMode={view} onViewChange={setView} options={views} />
|
|
195
|
+
{md && (onPickAsset || assets?.length > 0) && <Button variant="nav" size="sm" iconLeft="image" onClick={attach} aria-pressed={picking}>Attach</Button>}
|
|
196
|
+
{!isNew && value !== base && value != null && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
|
|
197
|
+
<Button variant="secondary" size="sm" onClick={save} disabled={!dirty}>{isNew ? 'Create' : 'Save'}</Button>
|
|
198
|
+
</span>
|
|
199
|
+
)}>
|
|
200
|
+
{/* the window's own caps, inline — Tailwind does not generate arbitrary values from package source */}
|
|
201
|
+
<div className="kol-doc-editor flex flex-col" style={{ width: 'min(1200px, var(--kol-ql-max-w))', height: 'min(760px, var(--kol-ql-media-h))' }}>
|
|
202
|
+
{isNew && (
|
|
203
|
+
<div className="flex flex-wrap items-center gap-3 p-4 border-b" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
204
|
+
<Input variant="filled" size="sm" value={name} aria-label="File name" placeholder="File name"
|
|
205
|
+
onChange={(e) => rename(e.target.value)} className="min-w-0 flex-1" />
|
|
206
|
+
<ViewToggle variant="text" viewMode={type} options={TYPES.map(({ value: v, label }) => ({ value: v, label }))}
|
|
207
|
+
onViewChange={(t) => { setType(t); const s = STARTER[t](name); setValue(s); latest.current = s; setView(t === 'md' ? 'split' : 'write') }} />
|
|
208
|
+
</div>
|
|
209
|
+
)}
|
|
210
|
+
<div className="flex flex-1 min-h-0">
|
|
211
|
+
{view !== 'preview' && (
|
|
212
|
+
<div ref={bodyRef} className="flex flex-col gap-4 p-4 min-w-0 overflow-y-auto" style={{ flex: '1 1 0' }}>
|
|
213
|
+
{md && parts && <FieldsForm fields={parts.fields} onChange={setFields} />}
|
|
214
|
+
{picking && (
|
|
215
|
+
<div className="flex flex-col gap-2 border rounded p-2" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
216
|
+
<Input variant="filled" size="xs" value={pickQuery} placeholder="Find a file to attach" aria-label="Find a file to attach"
|
|
217
|
+
onChange={(e) => setPickQuery(e.target.value)} autoFocus />
|
|
218
|
+
<div className="flex flex-col overflow-y-auto" style={{ maxHeight: 200 }}>
|
|
219
|
+
{pickList.length === 0 && <span className="kol-mono-12 text-fg-48 p-2">No file matches.</span>}
|
|
220
|
+
{pickList.map((a) => (
|
|
221
|
+
<button key={a.key} type="button" onClick={() => insertAsset(a)}
|
|
222
|
+
className="kol-mono-12 text-left px-2 py-1 rounded text-fg-default hover:bg-oq-04 truncate">{a.key}</button>
|
|
223
|
+
))}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
{value == null
|
|
228
|
+
? <p className="kol-mono-12 text-fg-48">Loading…</p>
|
|
229
|
+
: <Textarea variant="filled" size="md" rows={18} axis="vertical" className="w-full flex-1 kol-mono-12"
|
|
230
|
+
value={md ? parts.body : value} onChange={(e) => (md ? setBody(e.target.value) : change(e.target.value))}
|
|
231
|
+
spellCheck={kind === 'markdown' || kind === 'text'} aria-label="Document text"
|
|
232
|
+
placeholder={md ? 'Write in markdown — # heading, **bold**, - list' : ''} />}
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
{view !== 'write' && (
|
|
236
|
+
<div className="p-4 min-w-0 overflow-auto border-l" style={{ flex: '1 1 0', borderColor: 'var(--kol-oq-08)' }}>
|
|
237
|
+
{preview}
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
</QuickLookFrame>
|
|
243
|
+
</FullscreenOverlay>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* THE FRONTMATTER AS FIELDS. One row per key; a list value (tags) is typed comma-separated. The
|
|
248
|
+
* suggested keys a document has not got yet are one click away; any other key is typed. */
|
|
249
|
+
function FieldsForm({ fields, onChange }) {
|
|
250
|
+
const set = (i, key, v) => onChange(fields.map((f, j) => (j === i ? [key, v] : f)))
|
|
251
|
+
const missing = SUGGESTED.filter((s) => !fields.some(([k]) => k.toLowerCase() === s))
|
|
252
|
+
return (
|
|
253
|
+
<div className="flex flex-col gap-2">
|
|
254
|
+
<span className="kol-mono-12 text-fg-48">Fields</span>
|
|
255
|
+
{fields.map(([key, v], i) => (
|
|
256
|
+
<div key={i} className="flex items-center gap-2">
|
|
257
|
+
<Input variant="filled" size="xs" value={key} aria-label="Field name" className="w-28 shrink-0"
|
|
258
|
+
onChange={(e) => set(i, e.target.value, v)} />
|
|
259
|
+
{Array.isArray(v) || key.toLowerCase() === 'tags' ? (
|
|
260
|
+
/* a list commits on Enter / blur — re-deriving it per keystroke would eat the comma you just typed */
|
|
261
|
+
<Input variant="filled" size="xs" className="flex-1 min-w-0" aria-label={`${key || 'Field'} value`}
|
|
262
|
+
value={Array.isArray(v) ? v.join(', ') : v} placeholder="comma, separated"
|
|
263
|
+
onCommit={(raw) => set(i, key, raw.split(',').map((t) => t.trim()).filter(Boolean))} />
|
|
264
|
+
) : (
|
|
265
|
+
<Input variant="filled" size="xs" className="flex-1 min-w-0" aria-label={`${key || 'Field'} value`}
|
|
266
|
+
value={v} onChange={(e) => set(i, key, e.target.value)} />
|
|
267
|
+
)}
|
|
268
|
+
<Button variant="nav" size="xs" iconOnly="x" aria-label={`Remove ${key}`} onClick={() => onChange(fields.filter((_, j) => j !== i))} />
|
|
269
|
+
</div>
|
|
270
|
+
))}
|
|
271
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
272
|
+
{missing.map((s) => (
|
|
273
|
+
<Button key={s} variant="nav" size="xs" iconLeft="plus" onClick={() => onChange([...fields, [s, s === 'tags' ? [] : s === 'date' ? today() : '']])}>{s}</Button>
|
|
274
|
+
))}
|
|
275
|
+
<Button variant="nav" size="xs" iconLeft="plus" onClick={() => onChange([...fields, ['', '']])}>Field</Button>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
@@ -4,6 +4,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
5
5
|
import Divider from '../atoms/Divider.jsx'
|
|
6
6
|
import Input from '../atoms/Input.jsx'
|
|
7
|
+
import Tag from '../atoms/Tag.jsx'
|
|
7
8
|
import IconFrame from '../atoms/IconFrame.jsx'
|
|
8
9
|
import ActionButton from '../atoms/ActionButton.jsx'
|
|
9
10
|
import SizeOrDownload from '../atoms/SizeOrDownload.jsx'
|
|
@@ -31,6 +32,9 @@ import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
|
31
32
|
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
32
33
|
import { useModal } from '../molecules/Modal.jsx'
|
|
33
34
|
import ContentFilters from './ContentFilters.jsx'
|
|
35
|
+
import DocumentEditor from './DocumentEditor.jsx'
|
|
36
|
+
import { listDrafts, moveDrafts, DRAFTS_EVENT } from '../utilities/localDrafts.js'
|
|
37
|
+
import { parseFrontmatter } from '../utilities/frontmatter.js'
|
|
34
38
|
import ShellSearchOverlay from './ShellSearchOverlay.jsx'
|
|
35
39
|
import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
|
|
36
40
|
import SettingsPanel, { LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
|
|
@@ -66,6 +70,20 @@ import { nearestRatio } from '../utilities/ratios.js'
|
|
|
66
70
|
* `client.buckets()` (kol-media-client ≥0.2.0); a client with none is one bucket
|
|
67
71
|
* and no dropdown. Settings are kol-r2b2's per-bucket model (`SETTINGS_BASE`),
|
|
68
72
|
* controlled through `settings` / `onSettingsChange` so a consumer persists them.
|
|
73
|
+
*
|
|
74
|
+
* WHAT A DATABASE BESIDE THE BUCKET ADDS (the media D1 pass, 2026-09-25 — kol-olina keeps these
|
|
75
|
+
* in Cloudflare D1). Every verb is optional; a client without one hides that feature and nothing
|
|
76
|
+
* else changes. Keys are the page's own (bucket keys), `bucket` is the bucket id.
|
|
77
|
+
* tags listed objects carry `tags: string[]`; `setTags(key, tags, bucket)` writes them —
|
|
78
|
+
* preview-pane chips, "Tags…" / "Add tags to N…" in the menu, a Tags filter
|
|
79
|
+
* group, a Tags column under Fields
|
|
80
|
+
* documents `readText(key, bucket) → { text }` · `writeText(key, text, bucket)` ·
|
|
81
|
+
* `createFile(key, bucket, text?)` — Edit in the pane and in Quick Look, New document
|
|
82
|
+
* in the menu (markdown gets a fields form over its frontmatter; svg a live picture).
|
|
83
|
+
* DRAFTS ARE NOT THE CLIENT'S: browser memory (`utilities/localDrafts`), user ruling
|
|
84
|
+
* 2026-09-26 — only what must outlive a device goes to the database
|
|
85
|
+
* settings `loadSettings(bucket)` · `saveSettings(bucket, settings | null)` (null = reset) —
|
|
86
|
+
* used when the page is UNCONTROLLED; a `settings` prop still wins
|
|
69
87
|
*/
|
|
70
88
|
|
|
71
89
|
/* THE ROW VIEW'S COLUMNS (user 2026-09-22). OFF by default and `rowColumns` turns them on: with
|
|
@@ -165,32 +183,65 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
|
|
|
165
183
|
const buckets = useMemo(() => client?.buckets?.() ?? [], [client])
|
|
166
184
|
const bucketMeta = buckets.find((b) => b.id === bucket) ?? buckets[0] ?? { id: null, label: '', writable: false }
|
|
167
185
|
const bucketId = bucketMeta.id
|
|
168
|
-
const
|
|
186
|
+
const base = () => ({ ...SETTINGS_BASE, ...(defaults?.[bucketId] ?? defaults ?? {}) })
|
|
187
|
+
const [own, setOwn] = useState(base)
|
|
188
|
+
/* PERSONALISATION (the media D1 pass, 2026-09-25): an uncontrolled page whose client offers
|
|
189
|
+
* `loadSettings(bucket)` / `saveSettings(bucket, settings | null)` keeps each person's view
|
|
190
|
+
* settings THERE — kol-olina's D1 — instead of for this tab only. `null` is reset. A `settings`
|
|
191
|
+
* prop still wins: a consumer that owns them is left alone, exactly as before. */
|
|
192
|
+
const remote = !controlled && typeof client?.loadSettings === 'function'
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!remote) return undefined
|
|
195
|
+
let live = true
|
|
196
|
+
Promise.resolve(client.loadSettings(bucketId ?? undefined))
|
|
197
|
+
.then((got) => { if (live) setOwn({ ...base(), ...(got ?? {}) }) })
|
|
198
|
+
.catch(() => {})
|
|
199
|
+
return () => { live = false }
|
|
200
|
+
}, [remote, client, bucketId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
169
201
|
const settings = controlled ?? own
|
|
170
202
|
const setSettings = (next) => {
|
|
171
|
-
if (next === null) {
|
|
203
|
+
if (next === null) {
|
|
204
|
+
const back = base()
|
|
205
|
+
if (!controlled) setOwn(back)
|
|
206
|
+
if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, null)).then((got) => got && setOwn({ ...back, ...got })).catch(() => {})
|
|
207
|
+
onSettingsChange?.(back)
|
|
208
|
+
return
|
|
209
|
+
}
|
|
172
210
|
if (!controlled) setOwn(next)
|
|
211
|
+
if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, next)).catch(() => {})
|
|
173
212
|
onSettingsChange?.(next)
|
|
174
213
|
}
|
|
175
|
-
const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], error: null })
|
|
214
|
+
const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], folders: {}, error: null })
|
|
176
215
|
const [cache, setCache] = useState({})
|
|
216
|
+
/* `reload()` — a write the PAGE made itself (a new document, a tag on a folder) re-lists without
|
|
217
|
+
* waiting for the consumer to bump `refreshKey` */
|
|
218
|
+
const [nonce, setNonce] = useState(0)
|
|
219
|
+
const loadKey = `${refreshKey}.${nonce}`
|
|
177
220
|
useEffect(() => {
|
|
178
221
|
if (!client) return undefined
|
|
179
222
|
let cancelled = false
|
|
180
223
|
const controller = new AbortController()
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
224
|
+
/* the folder rows (`folderInfo`, a D1 read) come beside the listing: tags and favourites a
|
|
225
|
+
* folder carries, which a key-prefix bucket has nowhere to keep */
|
|
226
|
+
Promise.all([
|
|
227
|
+
client.listMedia('', { signal: controller.signal, bucket: bucketId ?? undefined }),
|
|
228
|
+
Promise.resolve(client.folderInfo?.(bucketId ?? undefined)).catch(() => ({})),
|
|
229
|
+
])
|
|
230
|
+
.then(([objs, folders]) => { if (cancelled) return; setLoaded({ key: loadKey, bucket: bucketId, objects: objs, folders: folders ?? {}, error: null }); setCache((c) => ({ ...c, [bucketId]: objs })) })
|
|
231
|
+
.catch((e) => { if (!cancelled && e.name !== 'AbortError') setLoaded({ key: loadKey, bucket: bucketId, objects: [], folders: {}, error: e.message }) })
|
|
184
232
|
return () => { cancelled = true; controller.abort() }
|
|
185
|
-
}, [client,
|
|
186
|
-
const
|
|
233
|
+
}, [client, loadKey, bucketId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
234
|
+
const reload = () => setNonce((n) => n + 1)
|
|
235
|
+
const ready = loaded.key === loadKey && loaded.bucket === bucketId
|
|
187
236
|
const objects = ready ? loaded.objects : (cache[bucketId] ?? [])
|
|
188
237
|
const setObjects = (fn) => setLoaded((prev) => ({ ...prev, objects: fn(prev.objects) }))
|
|
238
|
+
const folderInfo = loaded.folders ?? {}
|
|
239
|
+
const setFolderInfo = (fn) => setLoaded((prev) => ({ ...prev, folders: fn(prev.folders ?? {}) }))
|
|
189
240
|
const error = ready ? loaded.error : null
|
|
190
241
|
const mediaUrl = (key) => client?.mediaUrl?.(key, bucketId ?? undefined) ?? key
|
|
191
242
|
const downloadUrl = (key) => client?.downloadUrl?.(key, bucketId ?? undefined) ?? mediaUrl(key)
|
|
192
243
|
const writable = !!bucketMeta.writable && !!(client?.deleteObject || client?.renameObject)
|
|
193
|
-
return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, error, ready, mediaUrl, downloadUrl, writable }
|
|
244
|
+
return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, folderInfo, setFolderInfo, reload, error, ready, mediaUrl, downloadUrl, writable }
|
|
194
245
|
}
|
|
195
246
|
|
|
196
247
|
/* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
|
|
@@ -213,6 +264,37 @@ function ImageFrame({ src, vector }) {
|
|
|
213
264
|
}
|
|
214
265
|
|
|
215
266
|
|
|
267
|
+
/* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
|
|
268
|
+
* one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
|
|
269
|
+
* without it, or on a read-only bucket, the chips show and nothing edits them. */
|
|
270
|
+
function TagEditor({ tags = [], onChange, suggestions = [] }) {
|
|
271
|
+
const listId = useMemo(() => `kol-tags-${Math.random().toString(36).slice(2, 8)}`, [])
|
|
272
|
+
const add = (raw) => {
|
|
273
|
+
const next = raw.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
274
|
+
if (next.length) onChange([...new Set([...tags, ...next])])
|
|
275
|
+
}
|
|
276
|
+
if (!onChange && !tags.length) return null
|
|
277
|
+
return (
|
|
278
|
+
<div className="flex flex-col gap-2">
|
|
279
|
+
<span className="kol-mono-12 text-fg-48">Tags</span>
|
|
280
|
+
{tags.length > 0 && (
|
|
281
|
+
<div className="flex flex-wrap gap-1">
|
|
282
|
+
{tags.map((t) => (
|
|
283
|
+
<Tag key={t} variant="secondary" size="sm" onRemove={onChange ? () => onChange(tags.filter((x) => x !== t)) : undefined}>{t}</Tag>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
{/* the bucket's own tags as suggestions — native autocomplete, so a typo does not fork the vocabulary */}
|
|
288
|
+
{onChange && <Input size="xs" value="" placeholder="Add a tag" aria-label="Add a tag" onCommit={add} list={listId} />}
|
|
289
|
+
{onChange && (
|
|
290
|
+
<datalist id={listId}>
|
|
291
|
+
{suggestions.filter((t) => !tags.includes(t)).map((t) => <option key={t} value={t} />)}
|
|
292
|
+
</datalist>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
216
298
|
/* `depth` indents a row under the folder it was expanded from; `expanded`/`onToggle` draw the
|
|
217
299
|
* disclosure twisty. A folder row without `onToggle` keeps the old behaviour exactly — one chevron
|
|
218
300
|
* that navigates — so nothing that already renders these moves. */
|
|
@@ -232,10 +314,16 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
|
|
|
232
314
|
const dropProps = drag || dropFiles ? {
|
|
233
315
|
onDragOver: (e) => { if (takesFiles(e) || (drag && !isFileDrag(e) && drag.canDrop(drag.path))) { e.preventDefault(); setOver(true) } },
|
|
234
316
|
onDragLeave: () => setOver(false),
|
|
317
|
+
/* A ROW THAT CANNOT TAKE THE DROP LETS IT BUBBLE (the file-onto-file bug, 2026-09-25): the
|
|
318
|
+
* enclosing column / list allows the drag, so the drop still fires HERE, and moving into a
|
|
319
|
+
* FILE's key renamed the dragged file. Unguarded, it skipped `canDrop`; now the container
|
|
320
|
+
* takes it — dropped on a file, it lands in that file's folder, as Finder does. */
|
|
235
321
|
onDrop: (e) => {
|
|
236
|
-
|
|
237
|
-
if (takesFiles(e)) dropFiles(e.dataTransfer.files)
|
|
238
|
-
|
|
322
|
+
setOver(false)
|
|
323
|
+
if (takesFiles(e)) { e.preventDefault(); e.stopPropagation(); dropFiles(e.dataTransfer.files); return }
|
|
324
|
+
if (!drag || isFileDrag(e) || !drag.canDrop(drag.path)) return
|
|
325
|
+
e.preventDefault(); e.stopPropagation()
|
|
326
|
+
drag.onDrop(drag.path)
|
|
239
327
|
},
|
|
240
328
|
} : {}
|
|
241
329
|
return (
|
|
@@ -264,6 +352,7 @@ function FolderRow({ name, icon = 'folder', onClick, onDoubleClick, depth = 0, e
|
|
|
264
352
|
line up or the header above them is a lie. The trailing chevron went with them: it
|
|
265
353
|
promised a click that opens, and a click now SELECTS (opening is a double-click). */}
|
|
266
354
|
{cols && <>
|
|
355
|
+
{cols.tags != null && <span className="shrink-0" style={{ width: cols.tags }} />}
|
|
267
356
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{meta}</span>
|
|
268
357
|
<span className="kol-mono-12 text-fg-24 shrink-0 truncate" style={{ width: cols.size }}>—</span>
|
|
269
358
|
</>}
|
|
@@ -340,6 +429,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
|
|
|
340
429
|
</span>
|
|
341
430
|
<span className="flex-1" />
|
|
342
431
|
{cols && <>
|
|
432
|
+
{cols.tags != null && <span className="kol-mono-12 text-fg-48 shrink-0 truncate" style={{ width: cols.tags }}>{(o.tags ?? []).map((t) => `#${t}`).join(' ')}</span>}
|
|
343
433
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.date }}>{formatDate?.(o.uploaded)}</span>
|
|
344
434
|
<span className="kol-mono-12 text-fg-32 shrink-0 truncate" style={{ width: cols.size }}>{formatSize(o.size)}</span>
|
|
345
435
|
</>}
|
|
@@ -352,7 +442,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
|
|
|
352
442
|
* The columns never needed one — stepping into a folder IS its preview there — so this is the row
|
|
353
443
|
* view's, and it is the page's rather than ColumnBrowser's because the counts come off the same
|
|
354
444
|
* `objects` the page already holds. Same shell as the file preview, so the pane never shifts. */
|
|
355
|
-
function FolderPreview({ path, objects, width, formatDate }) {
|
|
445
|
+
function FolderPreview({ path, objects, width, formatDate, details }) {
|
|
356
446
|
const inside = objects.filter((o) => o.key.startsWith(path))
|
|
357
447
|
const bytes = inside.reduce((n, o) => n + (o.size ?? 0), 0)
|
|
358
448
|
const last = inside.reduce((t, o) => (o.uploaded && o.uploaded > t ? o.uploaded : t), '')
|
|
@@ -362,13 +452,13 @@ function FolderPreview({ path, objects, width, formatDate }) {
|
|
|
362
452
|
['Size', formatSize(bytes)],
|
|
363
453
|
...(last ? [['Newest', formatDate(last) || '—']] : []),
|
|
364
454
|
]
|
|
365
|
-
return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} />
|
|
455
|
+
return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} details={details} />
|
|
366
456
|
}
|
|
367
457
|
|
|
368
458
|
/* ONE PREVIEW FOR EVERYTHING THAT HOLDS THINGS — a folder, a bucket, the title (user 2026-09-25:
|
|
369
459
|
* selecting a bucket or MEDIA left the pane empty). The glyph, the name, the facts; only the
|
|
370
460
|
* glyph and the facts differ. */
|
|
371
|
-
function ContainerPreview({ name, icon, facts, width }) {
|
|
461
|
+
function ContainerPreview({ name, icon, facts, width, details }) {
|
|
372
462
|
return (
|
|
373
463
|
<div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
|
|
374
464
|
{/* the folder fills its box with the tile's inset (user 2026-09-24: a 64px glyph floated in
|
|
@@ -385,6 +475,8 @@ function ContainerPreview({ name, icon, facts, width }) {
|
|
|
385
475
|
</div>
|
|
386
476
|
))}
|
|
387
477
|
</dl>
|
|
478
|
+
{/* a folder's own rows — favourite, tags (media D1 plan v2) — an island, like the file pane's */}
|
|
479
|
+
{details && <div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>{details}</div>}
|
|
388
480
|
</div>
|
|
389
481
|
)
|
|
390
482
|
}
|
|
@@ -451,12 +543,43 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
|
|
|
451
543
|
<span className="w-4 shrink-0" aria-hidden="true" />
|
|
452
544
|
<span className="w-8 shrink-0" aria-hidden="true" />
|
|
453
545
|
<span className="flex-1 min-w-0">{label('name', 'Name')}</span>
|
|
546
|
+
{/* Tags does not sort — a file has several — so its header is a plain label */}
|
|
547
|
+
{cols.tags != null && (
|
|
548
|
+
<div className="relative shrink-0 border-l" style={{ width: cols.tags, borderColor: 'var(--kol-oq-08)' }}>
|
|
549
|
+
{handle('tags')}
|
|
550
|
+
<span className="kol-helper-12 text-fg-48">Tags</span>
|
|
551
|
+
</div>
|
|
552
|
+
)}
|
|
454
553
|
{column('date', 'Date')}
|
|
455
554
|
{column('size', 'Size')}
|
|
456
555
|
</div>
|
|
457
556
|
)
|
|
458
557
|
}
|
|
459
558
|
|
|
559
|
+
/* THE KINDS THAT OPEN AS TEXT — what the document editor takes (media D1 plan v2, 2026-09-26).
|
|
560
|
+
* An SVG is text too: it opens with its picture beside it. Above EDIT_CAP a file is not loaded into
|
|
561
|
+
* an editor at all — a textarea holding megabytes is a frozen tab, not an edit. */
|
|
562
|
+
const TEXT_KINDS = new Set(['markdown', 'json', 'yaml', 'text', 'code'])
|
|
563
|
+
const EDIT_CAP = 1_048_576
|
|
564
|
+
const editKindOf = (o) => (o.contentType === 'image/svg+xml' || extOf(o.key) === 'svg' ? 'svg' : TEXT_KINDS.has(kindOf(o)) ? kindOf(o) : null)
|
|
565
|
+
|
|
566
|
+
/* The editor for a file that exists: reads its text through the client, saves through it. Drafts
|
|
567
|
+
* are the DocumentEditor's (browser memory), keyed by bucket + key. */
|
|
568
|
+
function FileEditorHost({ o, client, bucket, onClose, onSaved, assets }) {
|
|
569
|
+
const [text, setText] = useState(null)
|
|
570
|
+
useEffect(() => {
|
|
571
|
+
let live = true
|
|
572
|
+
client.readText(o.key, bucket).then((r) => live && setText(r?.text ?? '')).catch(() => live && setText(''))
|
|
573
|
+
return () => { live = false }
|
|
574
|
+
}, [o.key]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
575
|
+
return (
|
|
576
|
+
<DocumentEditor name={o.displayKey ?? o.key.split('/').pop()} kind={editKindOf(o)} text={text}
|
|
577
|
+
savedAt={o.uploaded ? Date.parse(o.uploaded) : 0} draft={{ bucket, key: o.key }} assets={assets}
|
|
578
|
+
onSave={async (next) => { const r = await client.writeText(o.key, next, bucket); onSaved?.(o.key, r, next) }}
|
|
579
|
+
onClose={onClose} />
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
460
583
|
/**
|
|
461
584
|
* MediaInspector — the full-screen viewer for one file in a set: image, video
|
|
462
585
|
* (scrubbable, audio on), audio, or a document page, in one `QuickLookFrame`, with
|
|
@@ -482,8 +605,9 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
|
|
|
482
605
|
* @param {Function} downloadUrl - `(key) => url` for the download control
|
|
483
606
|
* @param {Set|Object} keySet - The bucket's key set, used to find a video's poster
|
|
484
607
|
* @param {boolean} showNav - ‹ › and n / N in the header (default on); off when not paging a selection
|
|
608
|
+
* @param {Function} onEdit - `(file) => void` — an Edit action in the header for a text file (markdown · json · yaml · text · code); absent, no Edit
|
|
485
609
|
*/
|
|
486
|
-
export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true }) {
|
|
610
|
+
export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl, downloadUrl, keySet, showNav = true, onEdit }) {
|
|
487
611
|
const o = files[index]
|
|
488
612
|
const [dims, setDims] = useState(null)
|
|
489
613
|
/* the window's size survives paging — Finder keeps it — so it lives here, not in the frame,
|
|
@@ -522,6 +646,11 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
522
646
|
* the header; bare at rest with a wash on hover, `sm` so the glyphs sit with the 12px text. */
|
|
523
647
|
actions: (
|
|
524
648
|
<span className="flex items-center gap-1">
|
|
649
|
+
{onEdit && editKindOf(o) && (o.size ?? 0) <= EDIT_CAP && (
|
|
650
|
+
<Tooltip label="Edit">
|
|
651
|
+
<Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
|
|
652
|
+
</Tooltip>
|
|
653
|
+
)}
|
|
525
654
|
<CopyAction label="Copy URL" text={mediaUrl(o.key)} />
|
|
526
655
|
<Tooltip label="Download">
|
|
527
656
|
<Button variant="nav" size="sm" iconOnly="download" href={downloadUrl(o.key)} download={o.key.split('/').pop()} aria-label="Download" />
|
|
@@ -829,6 +958,9 @@ export function MediaLibraryBrowse({
|
|
|
829
958
|
* rendering what it rendered. */
|
|
830
959
|
view: viewProp, onViewChange,
|
|
831
960
|
folderTree, headerActions, headerTrailing, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '', banner,
|
|
961
|
+
/* `smartFolder` — a smart folder's id to open ON (a Home tile linking straight to one); the chips
|
|
962
|
+
* take over from there (media D1 plan v2) */
|
|
963
|
+
smartFolder,
|
|
832
964
|
/* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
|
|
833
965
|
* optional and each async; whatever is supplied becomes a right-click menu entry and
|
|
834
966
|
* anything absent simply is not offered. One object rather than four props because they
|
|
@@ -976,6 +1108,151 @@ export function MediaLibraryBrowse({
|
|
|
976
1108
|
const press = useLongPress()
|
|
977
1109
|
const modal = useModal()
|
|
978
1110
|
const canWrite = !!fileActions && writable
|
|
1111
|
+
/* ── WHAT THE DATABASE BESIDE THE BUCKET ADDS (media D1 plan v2, 2026-09-26) ──────────────────
|
|
1112
|
+
* Every verb optional; each feature below is off when its verb is missing. Writes patch the list
|
|
1113
|
+
* in place so the change lands at once; the next listing confirms it. A path ending in `/` is a
|
|
1114
|
+
* folder — its rows come from `folderInfo`, a file's ride the listing. */
|
|
1115
|
+
const bucketKey = bucketMeta.id ?? undefined
|
|
1116
|
+
const folderRows = lib.folderInfo
|
|
1117
|
+
const patchPath = (path, patch) => {
|
|
1118
|
+
if (path.endsWith('/')) lib.setFolderInfo((f) => ({ ...f, [path]: { tags: [], favourite: false, ...(f[path] ?? {}), ...patch } }))
|
|
1119
|
+
else {
|
|
1120
|
+
lib.setObjects((list) => list.map((o) => (o.key === path ? { ...o, ...patch } : o)))
|
|
1121
|
+
setPickedFile((f) => (f?.key === path ? { ...f, ...patch } : f))
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/* TAGS — `setTags(path, tags, bucket)` */
|
|
1125
|
+
const canTag = writable && typeof client?.setTags === 'function'
|
|
1126
|
+
const tagsOf = (path) => (path.endsWith('/') ? folderRows[path]?.tags : objects.find((o) => o.key === path)?.tags) ?? []
|
|
1127
|
+
/* every tag in the bucket, for the add field's suggestions — a typo forks the vocabulary */
|
|
1128
|
+
/* the rows view grows a Tags column when there is anything to show or set */
|
|
1129
|
+
const hasTags = canTag || objects.some((o) => o.tags?.length)
|
|
1130
|
+
const allTags = useMemo(() => [...new Set([...objects.flatMap((o) => o.tags ?? []), ...Object.values(folderRows).flatMap((f) => f.tags ?? [])])].sort(), [objects, folderRows])
|
|
1131
|
+
const saveTags = (path, tags) => runAction(async () => {
|
|
1132
|
+
const r = await client.setTags(path, tags, bucketKey)
|
|
1133
|
+
patchPath(path, { tags: r?.tags ?? tags })
|
|
1134
|
+
})
|
|
1135
|
+
/* FAVOURITES — `setFavourite(path, on, bucket)` */
|
|
1136
|
+
const canFavourite = writable && typeof client?.setFavourite === 'function'
|
|
1137
|
+
const isFavourite = (path) => !!(path.endsWith('/') ? folderRows[path]?.favourite : objects.find((o) => o.key === path)?.favourite)
|
|
1138
|
+
const toggleFavourite = (path) => runAction(async () => {
|
|
1139
|
+
const on = !isFavourite(path)
|
|
1140
|
+
await client.setFavourite(path, on, bucketKey)
|
|
1141
|
+
patchPath(path, { favourite: on })
|
|
1142
|
+
})
|
|
1143
|
+
/* THE EVENT LOG recents are read from — intentions only (opened, edited, created) */
|
|
1144
|
+
const logEvent = (kind, path) => { Promise.resolve(client?.logEvent?.(kind, path, bucketKey)).catch(() => {}) }
|
|
1145
|
+
/* DRAFTS are browser memory (the user's ruling) — read here only to mark a file that has one */
|
|
1146
|
+
const [draftKeys, setDraftKeys] = useState(() => new Set(listDrafts(bucketKey).map((d) => d.key)))
|
|
1147
|
+
useEffect(() => {
|
|
1148
|
+
const read = () => setDraftKeys(new Set(listDrafts(bucketKey).map((d) => d.key)))
|
|
1149
|
+
read()
|
|
1150
|
+
window.addEventListener(DRAFTS_EVENT, read)
|
|
1151
|
+
return () => window.removeEventListener(DRAFTS_EVENT, read)
|
|
1152
|
+
}, [bucketKey])
|
|
1153
|
+
/* DOCUMENTS — `readText` / `writeText` edit; a new one is the consumer's `createFile` + `writeText` */
|
|
1154
|
+
const canEdit = writable && typeof client?.readText === 'function' && typeof client?.writeText === 'function'
|
|
1155
|
+
const canNewDoc = canEdit && canWrite && !!fileActions.createFile
|
|
1156
|
+
const [editing, setEditing] = useState(null) // { mode: 'edit', o } | { mode: 'new', folder }
|
|
1157
|
+
const openEditor = (o) => { setEditing({ mode: 'edit', o }); logEvent('opened', o.key) }
|
|
1158
|
+
const newDocument = (folder) => setEditing({ mode: 'new', folder })
|
|
1159
|
+
/* A MARKDOWN FILE'S FRONTMATTER TAGS ARE ITS TAGS — merged into the database's on every save, so
|
|
1160
|
+
* a tag written in the document is a tag you can filter by. Merged, never replaced: a tag set in
|
|
1161
|
+
* the pane and not in the document stays. */
|
|
1162
|
+
const syncFrontmatterTags = async (key, text) => {
|
|
1163
|
+
if (!canTag || !/\.(md|markdown)$/i.test(key)) return
|
|
1164
|
+
const fm = parseFrontmatter(text ?? '').tags
|
|
1165
|
+
const typed = (Array.isArray(fm) ? fm : typeof fm === 'string' && fm ? fm.split(',') : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
|
1166
|
+
const current = objects.find((o) => o.key === key)?.tags ?? []
|
|
1167
|
+
const next = [...new Set([...current, ...typed])]
|
|
1168
|
+
if (next.length !== current.length) { await client.setTags(key, next, bucketKey); patchPath(key, { tags: next }) }
|
|
1169
|
+
}
|
|
1170
|
+
const afterEdit = (key, res, text) => {
|
|
1171
|
+
if (res?.size != null) lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, size: res.size, uploaded: new Date().toISOString() } : o)))
|
|
1172
|
+
syncFrontmatterTags(key, text).catch(() => {})
|
|
1173
|
+
}
|
|
1174
|
+
const createDocument = async ({ name, text }) => {
|
|
1175
|
+
const key = `${editing.folder}${name}`
|
|
1176
|
+
if (objects.some((o) => o.key === key)) throw new Error(`${name} already exists here`)
|
|
1177
|
+
await fileActions.createFile(key)
|
|
1178
|
+
await client.writeText(key, text, bucketKey)
|
|
1179
|
+
const fm = /\.(md|markdown)$/i.test(key) ? parseFrontmatter(text).tags : null
|
|
1180
|
+
const typed = (Array.isArray(fm) ? fm : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
|
1181
|
+
if (canTag && typed.length) await client.setTags(key, typed, bucketKey)
|
|
1182
|
+
lib.reload()
|
|
1183
|
+
setEditing(null)
|
|
1184
|
+
}
|
|
1185
|
+
/* what a markdown document can attach: the bucket's own files, linked by their public URL */
|
|
1186
|
+
const attachables = editing ? objects.filter((o) => !isSystemFile(o.key)).map((o) => ({ key: o.key, name: o.key.split('/').pop(), url: mediaUrl(o.key), contentType: o.contentType })) : []
|
|
1187
|
+
const editBlock = (o) => {
|
|
1188
|
+
const kind = editKindOf(o)
|
|
1189
|
+
if (!canEdit || !kind) return null
|
|
1190
|
+
const tooBig = (o.size ?? 0) > EDIT_CAP
|
|
1191
|
+
return (
|
|
1192
|
+
<div className="flex items-center gap-3">
|
|
1193
|
+
<Button variant="secondary" size="sm" iconLeft="edit" disabled={tooBig} onClick={() => openEditor(o)}>Edit</Button>
|
|
1194
|
+
{tooBig && <span className="kol-mono-12 text-fg-48">Over 1 MB — too large to edit here</span>}
|
|
1195
|
+
{!tooBig && draftKeys.has(o.key) && <span className="kol-mono-12 text-fg-48">Unsaved draft</span>}
|
|
1196
|
+
</div>
|
|
1197
|
+
)
|
|
1198
|
+
}
|
|
1199
|
+
/* what the preview pane adds under a file's or a folder's facts, in every view */
|
|
1200
|
+
const detailsFor = (o) => {
|
|
1201
|
+
const path = o.key ?? o
|
|
1202
|
+
const tags = tagsOf(path)
|
|
1203
|
+
const edit = typeof o === 'object' ? editBlock(o) : null
|
|
1204
|
+
if (!edit && !canTag && !canFavourite && !tags.length) return null
|
|
1205
|
+
return (
|
|
1206
|
+
<div className="flex flex-col gap-4">
|
|
1207
|
+
{(edit || canFavourite) && (
|
|
1208
|
+
<div className="flex items-center gap-3">
|
|
1209
|
+
{canFavourite && (
|
|
1210
|
+
<Tooltip label={isFavourite(path) ? 'Remove from favourites' : 'Add to favourites'}>
|
|
1211
|
+
<Button variant="nav" size="sm" iconOnly={isFavourite(path) ? 'star-solid' : 'star'} onClick={() => toggleFavourite(path)}
|
|
1212
|
+
aria-label={isFavourite(path) ? 'Remove from favourites' : 'Add to favourites'} aria-pressed={isFavourite(path)} />
|
|
1213
|
+
</Tooltip>
|
|
1214
|
+
)}
|
|
1215
|
+
{edit}
|
|
1216
|
+
</div>
|
|
1217
|
+
)}
|
|
1218
|
+
{(canTag || tags.length > 0) && <TagEditor tags={tags} suggestions={allTags} onChange={canTag ? (next) => saveTags(path, next) : undefined} />}
|
|
1219
|
+
</div>
|
|
1220
|
+
)
|
|
1221
|
+
}
|
|
1222
|
+
const doTags = async (keys) => {
|
|
1223
|
+
const one = keys.length === 1
|
|
1224
|
+
const current = one ? tagsOf(keys[0]).join(', ') : ''
|
|
1225
|
+
const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length} items, separated by commas:`, current, { okLabel: 'Save' })
|
|
1226
|
+
if (answer == null) return
|
|
1227
|
+
const typed = answer.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
1228
|
+
for (const key of keys) await saveTags(key, one ? typed : [...new Set([...tagsOf(key), ...typed])])
|
|
1229
|
+
}
|
|
1230
|
+
/* SMART FOLDERS — `smartFolders(bucket)` · `saveSmartFolder(sf, bucket)` · `deleteSmartFolder(id)` */
|
|
1231
|
+
const canSmart = typeof client?.smartFolders === 'function'
|
|
1232
|
+
const [smartFolders, setSmartFolders] = useState([])
|
|
1233
|
+
const [activeSmart, setActiveSmart] = useState(null)
|
|
1234
|
+
const loadSmart = () => { if (canSmart) Promise.resolve(client.smartFolders(bucketKey)).then((l) => setSmartFolders(l ?? [])).catch(() => {}) }
|
|
1235
|
+
useEffect(() => { setActiveSmart(null); loadSmart() }, [bucketKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1236
|
+
useEffect(() => { if (smartFolder && smartFolders.some((f) => f.id === smartFolder)) setActiveSmart(smartFolder) }, [smartFolder, smartFolders])
|
|
1237
|
+
const activeSmartFolder = smartFolders.find((f) => f.id === activeSmart) ?? null
|
|
1238
|
+
const saveSmart = async () => {
|
|
1239
|
+
const name = await modal.prompt('Smart folder name:', '', { okLabel: 'Next' })
|
|
1240
|
+
if (!name?.trim()) return
|
|
1241
|
+
const tags = await modal.prompt('Tags every file must carry, separated by commas (blank for any):', '', { okLabel: 'Next' })
|
|
1242
|
+
if (tags == null) return
|
|
1243
|
+
const kinds = await modal.prompt('Kinds, separated by commas — image, video, audio, markdown, text, json, pdf… (blank for all):', '', { okLabel: 'Save' })
|
|
1244
|
+
if (kinds == null) return
|
|
1245
|
+
const split = (v) => v.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
1246
|
+
runAction(async () => {
|
|
1247
|
+
const row = await client.saveSmartFolder({ name: name.trim(), query: { tags: split(tags), kinds: split(kinds), text: '' } }, bucketKey)
|
|
1248
|
+
loadSmart()
|
|
1249
|
+
if (row?.id) setActiveSmart(row.id)
|
|
1250
|
+
})
|
|
1251
|
+
}
|
|
1252
|
+
const deleteSmart = async (sf) => {
|
|
1253
|
+
if (!(await modal.confirm(`Delete the smart folder "${sf.name}"? The files stay where they are.`, { okLabel: 'Delete' }))) return
|
|
1254
|
+
runAction(async () => { await client.deleteSmartFolder(sf.id, bucketKey); if (activeSmart === sf.id) setActiveSmart(null); loadSmart() })
|
|
1255
|
+
}
|
|
979
1256
|
const [busyAction, setBusyAction] = useState(false)
|
|
980
1257
|
|
|
981
1258
|
const runAction = async (fn) => {
|
|
@@ -999,7 +1276,9 @@ export function MediaLibraryBrowse({
|
|
|
999
1276
|
const name = await modal.prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current, { okLabel: 'Rename' })
|
|
1000
1277
|
if (!name?.trim() || name === current) return
|
|
1001
1278
|
const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
|
|
1002
|
-
|
|
1279
|
+
const to = `${parent}${name.trim()}${isFolder ? '/' : ''}`
|
|
1280
|
+
/* a draft is keyed by path — it follows the file (or everything under the folder) */
|
|
1281
|
+
runAction(async () => { await fileActions.rename(path, to); moveDrafts(bucketKey, path, to) })
|
|
1003
1282
|
}
|
|
1004
1283
|
const doMove = async (path) => {
|
|
1005
1284
|
const dest = await modal.prompt(`Move into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
|
|
@@ -1012,6 +1291,7 @@ export function MediaLibraryBrowse({
|
|
|
1012
1291
|
* view: expanded in place in the rows, navigated into in the columns and the grid. */
|
|
1013
1292
|
const afterMove = (paths, dest) => {
|
|
1014
1293
|
const moved = paths.map((p) => `${dest}${p.replace(/\/$/, '').split('/').pop()}${p.endsWith('/') ? '/' : ''}`)
|
|
1294
|
+
paths.forEach((p, i) => moveDrafts(bucketKey, p, moved[i]))
|
|
1015
1295
|
setPickedFile(null); setPickedFolder(null)
|
|
1016
1296
|
setRowSelection(new Set(moved))
|
|
1017
1297
|
selectAnchorRef.current = moved[0] ?? null
|
|
@@ -1204,6 +1484,7 @@ export function MediaLibraryBrowse({
|
|
|
1204
1484
|
const inView = q.files.filter((o) => rowSelection.has(o.key))
|
|
1205
1485
|
const sel = inView.length === rowSelection.size ? inView : objects.filter((o) => rowSelection.has(o.key))
|
|
1206
1486
|
const current = q.files[q.index]
|
|
1487
|
+
if (current?.key) logEvent('opened', current.key)
|
|
1207
1488
|
if (sel.length > 1) {
|
|
1208
1489
|
const index = Math.max(0, sel.findIndex((o) => o.key === current?.key))
|
|
1209
1490
|
setQuickLook({ files: sel, index })
|
|
@@ -1437,7 +1718,7 @@ export function MediaLibraryBrowse({
|
|
|
1437
1718
|
const showRowPreview = folderView === 'rows' && (settings.rowPreview ?? true)
|
|
1438
1719
|
const showGridPreview = view === 'grid' && (settings.rowPreview ?? true)
|
|
1439
1720
|
/* OFF by default: a row is a name, and the facts live in the preview (user 2026-09-22) */
|
|
1440
|
-
const rowCols = settings.rowColumns ? rowColWidths(settings) : null
|
|
1721
|
+
const rowCols = settings.rowColumns ? { ...rowColWidths(settings), ...(hasTags && { tags: settings.rowColumnWidths?.tags ?? 140 }) } : null
|
|
1441
1722
|
const previewWidth = settings.columnWidths?.preview ?? 320
|
|
1442
1723
|
const previewFile = pickedFile && pickedFile.key.startsWith(prefix) ? pickedFile : null
|
|
1443
1724
|
const previewFolder = pickedFolder && pickedFolder.startsWith(prefix) ? pickedFolder : null
|
|
@@ -1459,13 +1740,14 @@ export function MediaLibraryBrowse({
|
|
|
1459
1740
|
) : previewFile ? (
|
|
1460
1741
|
<ColumnPreview key={previewFile.key} o={previewFile} urlOf={(o) => mediaUrl(o.key)} kindOf={kindOf} kindLabel={KIND_LABEL}
|
|
1461
1742
|
formatSize={formatSize} formatDate={formatDate} width={previewWidth}
|
|
1743
|
+
renderDetails={detailsFor}
|
|
1462
1744
|
renderPreview={(o) => {
|
|
1463
1745
|
if (isImage(o.contentType)) return <ImageFrame src={mediaUrl(o.key)} vector={o.contentType === 'image/svg+xml'} />
|
|
1464
1746
|
const poster = posterFor(o.key, keySet)
|
|
1465
1747
|
return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
1466
1748
|
}} />
|
|
1467
1749
|
) : previewFolder ? (
|
|
1468
|
-
<FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} />
|
|
1750
|
+
<FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} details={detailsFor(previewFolder)} />
|
|
1469
1751
|
) : previewRoot ? (
|
|
1470
1752
|
<ContainerPreview key={previewRoot.key} name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={previewWidth} />
|
|
1471
1753
|
) : (
|
|
@@ -1580,6 +1862,10 @@ export function MediaLibraryBrowse({
|
|
|
1580
1862
|
* serves both: the wall renders it, the tree is scoped to the keys in it */
|
|
1581
1863
|
const filterItems = isWall ? wallFiles : scoped.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, kind: kindOf(o), displayKey: prefix ? o.key.slice(prefix.length) : o.key }))
|
|
1582
1864
|
const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
|
|
1865
|
+
const filterTags = [...new Set(filterItems.flatMap((o) => o.tags ?? []))].sort()
|
|
1866
|
+
/* favourites as a one-chip group: ContentFilters matches a value, so a starred file carries one */
|
|
1867
|
+
const filterItemsFav = filterItems.map((o) => ({ ...o, starred: o.favourite ? 'favourite' : '' }))
|
|
1868
|
+
const anyStarred = filterItemsFav.some((o) => o.starred)
|
|
1583
1869
|
|
|
1584
1870
|
/* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
|
|
1585
1871
|
* `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
|
|
@@ -1652,6 +1938,7 @@ export function MediaLibraryBrowse({
|
|
|
1652
1938
|
/* the PROP wins when a consumer passes one; otherwise the `···`
|
|
1653
1939
|
owns it through settings (item 15) */
|
|
1654
1940
|
stackView={stackView ?? settings.stackView ?? 'list'}
|
|
1941
|
+
renderDetails={(o) => detailsFor({ ...o, key: o.key.slice(VROOT.length) })}
|
|
1655
1942
|
renderPreview={(o) => {
|
|
1656
1943
|
const real = { ...o, key: o.key.slice(VROOT.length) }
|
|
1657
1944
|
if (isImage(real.contentType)) return <ImageFrame src={mediaUrl(real.key)} vector={real.contentType === 'image/svg+xml'} />
|
|
@@ -1840,7 +2127,7 @@ export function MediaLibraryBrowse({
|
|
|
1840
2127
|
/* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
|
|
1841
2128
|
* 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
|
|
1842
2129
|
* and no folders, as it was. */
|
|
1843
|
-
folders={settings.flat ? [] : folders.map((f) => prefix + f)}
|
|
2130
|
+
folders={settings.flat || smartMatches ? [] : folders.map((f) => prefix + f)}
|
|
1844
2131
|
onOpenFolder={goFolder}
|
|
1845
2132
|
onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
|
|
1846
2133
|
onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
|
|
@@ -1885,9 +2172,21 @@ export function MediaLibraryBrowse({
|
|
|
1885
2172
|
pickFile(o)
|
|
1886
2173
|
}
|
|
1887
2174
|
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
2175
|
+
/* A SMART FOLDER IS A SAVED QUERY (media D1 plan v2) — tags it must carry, kinds it may be, text
|
|
2176
|
+
* in its path — shown flat, every match wherever it lives, as Finder's smart folders are. */
|
|
2177
|
+
const smartMatches = activeSmartFolder ? objects.filter((o) => {
|
|
2178
|
+
const q = activeSmartFolder.query ?? {}
|
|
2179
|
+
if (isSystemFile(o.key)) return false
|
|
2180
|
+
if ((q.tags ?? []).some((t) => !(o.tags ?? []).includes(t))) return false
|
|
2181
|
+
if ((q.kinds ?? []).length && !q.kinds.includes(kindOf(o))) return false
|
|
2182
|
+
if (q.text && !o.key.toLowerCase().includes(q.text.toLowerCase())) return false
|
|
2183
|
+
return true
|
|
2184
|
+
}).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key })) : null
|
|
2185
|
+
const body = (filtered) => (smartMatches
|
|
2186
|
+
? wallPane(filtered ?? smartMatches)
|
|
2187
|
+
: isWall
|
|
2188
|
+
? wallPane(filtered ?? wallFiles)
|
|
2189
|
+
: treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
|
|
1891
2190
|
|
|
1892
2191
|
return (
|
|
1893
2192
|
<div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
@@ -2079,8 +2378,16 @@ export function MediaLibraryBrowse({
|
|
|
2079
2378
|
)}
|
|
2080
2379
|
{quickLook && (
|
|
2081
2380
|
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
|
|
2381
|
+
onEdit={canEdit ? (f) => { closeQuickLook(); openEditor(f) } : undefined}
|
|
2082
2382
|
onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
|
|
2083
2383
|
)}
|
|
2384
|
+
{editing?.mode === 'edit' && (
|
|
2385
|
+
<FileEditorHost o={editing.o} client={client} bucket={bucketKey} onClose={() => setEditing(null)} onSaved={afterEdit} assets={attachables} />
|
|
2386
|
+
)}
|
|
2387
|
+
{editing?.mode === 'new' && (
|
|
2388
|
+
<DocumentEditor mode="new" folder={editing.folder} draft={{ bucket: bucketKey, key: `${editing.folder}.new-document` }} assets={attachables}
|
|
2389
|
+
onCreate={createDocument} onClose={() => setEditing(null)} />
|
|
2390
|
+
)}
|
|
2084
2391
|
{settingsOpen && (
|
|
2085
2392
|
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
2086
2393
|
)}
|
|
@@ -2088,16 +2395,28 @@ export function MediaLibraryBrowse({
|
|
|
2088
2395
|
<TrashPanel trash={trash} onClose={() => setTrashOpen(false)} run={runAction} confirm={modal.confirm} />
|
|
2089
2396
|
)}
|
|
2090
2397
|
|
|
2398
|
+
{/* SMART FOLDERS — one chip each; a chip shows its matches flat in place of the body */}
|
|
2399
|
+
{canSmart && (smartFolders.length > 0 || canWrite) && !atTitleRoot && (
|
|
2400
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
2401
|
+
<span className="kol-mono-12 text-fg-48">Smart folders</span>
|
|
2402
|
+
{smartFolders.map((sf) => (
|
|
2403
|
+
<Tag key={sf.id} variant="secondary" size="sm" hash={false} active={activeSmart === sf.id}
|
|
2404
|
+
onClick={() => setActiveSmart((a) => (a === sf.id ? null : sf.id))}
|
|
2405
|
+
onRemove={canWrite ? () => deleteSmart(sf) : undefined}>{sf.name}</Tag>
|
|
2406
|
+
))}
|
|
2407
|
+
{canWrite && <Button variant="nav" size="xs" iconLeft="plus" onClick={saveSmart}>New smart folder</Button>}
|
|
2408
|
+
</div>
|
|
2409
|
+
)}
|
|
2091
2410
|
{/* THE FILTER BAR, above whichever body is mounted (2026-09-22). It filters FILES, so the
|
|
2092
2411
|
* tree is scoped to the keys it leaves and the wall renders them directly. Off by default
|
|
2093
2412
|
* — the funnel in the crumb row turns it on. */}
|
|
2094
2413
|
{filtersOn ? (
|
|
2095
2414
|
<ContentFilters
|
|
2096
|
-
items={
|
|
2415
|
+
items={filterItemsFav}
|
|
2097
2416
|
title="Files"
|
|
2098
2417
|
totalCount={filterItems.length}
|
|
2099
2418
|
searchKeys={['displayKey']}
|
|
2100
|
-
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }]}
|
|
2419
|
+
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : []), ...(anyStarred ? [{ label: 'Favourites', key: 'starred', values: ['favourite'] }] : [])]}
|
|
2101
2420
|
mutuallyExclusiveFilters={['kind']}
|
|
2102
2421
|
/* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
|
|
2103
2422
|
* its panel, and the bar's own funnel closes the bar — the same switch from either
|
|
@@ -2179,6 +2498,7 @@ export function MediaLibraryBrowse({
|
|
|
2179
2498
|
<>
|
|
2180
2499
|
<MenuDropdownItem iconLeft={<Icon name="copy" size={14} />} onClick={() => doCopyUrl(many)}>Copy {many.length} URLs</MenuDropdownItem>
|
|
2181
2500
|
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload(many)}>Download {many.length}</MenuDropdownItem>
|
|
2501
|
+
{canTag && <MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags(many)}>Add tags to {many.length}…</MenuDropdownItem>}
|
|
2182
2502
|
</>
|
|
2183
2503
|
)}
|
|
2184
2504
|
{fileActions.remove && (
|
|
@@ -2195,7 +2515,13 @@ export function MediaLibraryBrowse({
|
|
|
2195
2515
|
New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
|
|
2196
2516
|
</MenuDropdownItem>
|
|
2197
2517
|
)}
|
|
2198
|
-
{
|
|
2518
|
+
{/* NEW DOCUMENT is the writing page (a name, a type, fields for markdown); New file stays for
|
|
2519
|
+
a client that cannot write text — it makes the empty file and nothing else */}
|
|
2520
|
+
{canNewDoc ? (
|
|
2521
|
+
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => newDocument(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2522
|
+
New document…
|
|
2523
|
+
</MenuDropdownItem>
|
|
2524
|
+
) : fileActions.createFile && (
|
|
2199
2525
|
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2200
2526
|
New file
|
|
2201
2527
|
</MenuDropdownItem>
|
|
@@ -2222,6 +2548,17 @@ export function MediaLibraryBrowse({
|
|
|
2222
2548
|
{target.type === 'file' && (
|
|
2223
2549
|
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
|
|
2224
2550
|
)}
|
|
2551
|
+
{!isLevel && canTag && (
|
|
2552
|
+
<MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags([target.path])}>Tags…</MenuDropdownItem>
|
|
2553
|
+
)}
|
|
2554
|
+
{!isLevel && canFavourite && (
|
|
2555
|
+
<MenuDropdownItem iconLeft={<Icon name={isFavourite(target.path) ? 'star-solid' : 'star'} size={14} />} onClick={() => toggleFavourite(target.path)}>
|
|
2556
|
+
{isFavourite(target.path) ? 'Remove from favourites' : 'Add to favourites'}
|
|
2557
|
+
</MenuDropdownItem>
|
|
2558
|
+
)}
|
|
2559
|
+
{target.type === 'file' && canEdit && editKindOf(target.o ?? { key: target.path }) && (
|
|
2560
|
+
<MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => openEditor(objects.find((o) => o.key === target.path) ?? { key: target.path })}>Edit</MenuDropdownItem>
|
|
2561
|
+
)}
|
|
2225
2562
|
{!isLevel && fileActions.remove && (
|
|
2226
2563
|
<>
|
|
2227
2564
|
<MenuDropdownDivider />
|
|
@@ -27,3 +27,50 @@ export function parseFrontmatter(raw) {
|
|
|
27
27
|
}
|
|
28
28
|
return metadata
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* splitFrontmatter / joinFrontmatter — the EDITOR's pair (DocumentEditor, 2026-09-26). Where
|
|
33
|
+
* `parseFrontmatter` reads for display (keys lowercased, lists flattened), these round-trip a file:
|
|
34
|
+
* keys keep their case and their order, a list stays a list, and the body is untouched — so opening
|
|
35
|
+
* and saving a document without touching the form changes nothing but what was edited.
|
|
36
|
+
*
|
|
37
|
+
* `fields` is `[[key, value]]`, value a string or an array of strings.
|
|
38
|
+
*/
|
|
39
|
+
const unquote = (v) => {
|
|
40
|
+
if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) { try { return JSON.parse(v) } catch { return v.slice(1, -1) } }
|
|
41
|
+
if (v.length > 1 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'")
|
|
42
|
+
return v
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function splitFrontmatter(raw = '') {
|
|
46
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
|
|
47
|
+
if (!match) return { has: false, fields: [], body: raw }
|
|
48
|
+
const fields = []
|
|
49
|
+
const lines = match[1].split(/\r?\n/)
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const kv = lines[i].match(/^([A-Za-z_][\w -]*):\s*(.*)$/)
|
|
52
|
+
if (!kv) continue
|
|
53
|
+
let value = kv[2].trim()
|
|
54
|
+
if (!value) {
|
|
55
|
+
const items = []
|
|
56
|
+
while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) { i++; items.push(lines[i].replace(/^\s+-\s+/, '').trim()) }
|
|
57
|
+
value = items.length ? items : ''
|
|
58
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
59
|
+
value = value.slice(1, -1).split(',').map((t) => unquote(t.trim())).filter(Boolean)
|
|
60
|
+
} else {
|
|
61
|
+
value = unquote(value)
|
|
62
|
+
}
|
|
63
|
+
fields.push([kv[1], value])
|
|
64
|
+
}
|
|
65
|
+
return { has: true, fields, body: raw.slice(match[0].length) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* bare when YAML reads it back as the same string; quoted otherwise (a colon, a leading #, quotes) */
|
|
69
|
+
const yamlScalar = (v) => (/^[\w./@+-][\w ./@+,-]*$/.test(v) || v === '' ? v : JSON.stringify(v))
|
|
70
|
+
|
|
71
|
+
export function joinFrontmatter(fields, body = '') {
|
|
72
|
+
const rows = fields.filter(([key]) => String(key).trim())
|
|
73
|
+
if (!rows.length) return body
|
|
74
|
+
const yaml = rows.map(([key, v]) => (Array.isArray(v) ? `${key}: [${v.map(yamlScalar).join(', ')}]` : `${key}: ${yamlScalar(String(v ?? ''))}`)).join('\n')
|
|
75
|
+
return `---\n${yaml}\n---\n${body.startsWith('\n') ? body.slice(1) : body}`
|
|
76
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localDrafts — a text file's unsaved edit, in BROWSER memory (media D1 plan v2, user ruling
|
|
3
|
+
* 2026-09-26: "draft saves can rely on browser memory … only what's needed goes to D1"). A draft is
|
|
4
|
+
* work in progress on one device; the database holds what must outlive the device. So the DS keeps
|
|
5
|
+
* drafts itself, keyed by bucket + file key, and no client needs a draft verb.
|
|
6
|
+
*
|
|
7
|
+
* Every write announces itself on `window` (`kol-media-drafts`), so a list elsewhere on the page
|
|
8
|
+
* (a Home shelf, the "unsaved draft" mark) re-reads without prop drilling. Storage can be missing
|
|
9
|
+
* or full (private mode, a quota) — every call degrades to "no draft", never to a throw.
|
|
10
|
+
*/
|
|
11
|
+
const PREFIX = 'kol-media-draft:'
|
|
12
|
+
const EVENT = 'kol-media-drafts'
|
|
13
|
+
const store = () => { try { return globalThis.localStorage ?? null } catch { return null } }
|
|
14
|
+
const k = (bucket, key) => `${PREFIX}${bucket ?? ''}:${key}`
|
|
15
|
+
const announce = () => { try { globalThis.dispatchEvent?.(new Event(EVENT)) } catch { /* no window */ } }
|
|
16
|
+
|
|
17
|
+
export const DRAFTS_EVENT = EVENT
|
|
18
|
+
|
|
19
|
+
/** `{ text, at }` (at = epoch ms) or null */
|
|
20
|
+
export function readDraft(bucket, key) {
|
|
21
|
+
try { const raw = store()?.getItem(k(bucket, key)); return raw ? JSON.parse(raw) : null } catch { return null }
|
|
22
|
+
}
|
|
23
|
+
export function writeDraft(bucket, key, text) {
|
|
24
|
+
try { store()?.setItem(k(bucket, key), JSON.stringify({ text, at: Date.now() })); announce() } catch { /* quota */ }
|
|
25
|
+
}
|
|
26
|
+
export function clearDraft(bucket, key) {
|
|
27
|
+
try { store()?.removeItem(k(bucket, key)); announce() } catch { /* nothing to clear */ }
|
|
28
|
+
}
|
|
29
|
+
/** every draft in a bucket, newest first — `[{ key, at, size }]` */
|
|
30
|
+
export function listDrafts(bucket) {
|
|
31
|
+
const s = store()
|
|
32
|
+
if (!s) return []
|
|
33
|
+
const head = k(bucket, '')
|
|
34
|
+
const out = []
|
|
35
|
+
for (let i = 0; i < s.length; i++) {
|
|
36
|
+
const name = s.key(i)
|
|
37
|
+
if (!name?.startsWith(head)) continue
|
|
38
|
+
try { const d = JSON.parse(s.getItem(name)); out.push({ key: name.slice(head.length), at: d.at, size: d.text?.length ?? 0 }) } catch { /* a foreign value */ }
|
|
39
|
+
}
|
|
40
|
+
return out.sort((a, b) => b.at - a.at)
|
|
41
|
+
}
|
|
42
|
+
/** a file or a folder moved: its drafts follow (a folder is a path ending in `/`) */
|
|
43
|
+
export function moveDrafts(bucket, from, to) {
|
|
44
|
+
const moved = listDrafts(bucket).filter((d) => d.key === from || (from.endsWith('/') && d.key.startsWith(from)))
|
|
45
|
+
for (const d of moved) {
|
|
46
|
+
const draft = readDraft(bucket, d.key)
|
|
47
|
+
try { store()?.removeItem(k(bucket, d.key)); store()?.setItem(k(bucket, to + d.key.slice(from.length)), JSON.stringify(draft)) } catch { /* quota */ }
|
|
48
|
+
}
|
|
49
|
+
if (moved.length) announce()
|
|
50
|
+
}
|