@kolkrabbi/kol-component 0.223.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 +1 -1
- package/src/index.js +3 -1
- package/src/organisms/DocumentEditor.jsx +279 -0
- package/src/organisms/MediaLibraryPages.jsx +246 -136
- package/src/utilities/frontmatter.js +47 -0
- package/src/utilities/localDrafts.js +50 -0
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'
|
|
@@ -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
|
+
}
|
|
@@ -32,6 +32,9 @@ import { formatLength } from '../molecules/AudioPreview.jsx'
|
|
|
32
32
|
import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
|
|
33
33
|
import { useModal } from '../molecules/Modal.jsx'
|
|
34
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'
|
|
35
38
|
import ShellSearchOverlay from './ShellSearchOverlay.jsx'
|
|
36
39
|
import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
|
|
37
40
|
import SettingsPanel, { LabeledControlSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
|
|
@@ -74,9 +77,11 @@ import { nearestRatio } from '../utilities/ratios.js'
|
|
|
74
77
|
* tags listed objects carry `tags: string[]`; `setTags(key, tags, bucket)` writes them —
|
|
75
78
|
* preview-pane chips, "Tags…" / "Add tags to N…" in the menu, a Tags filter
|
|
76
79
|
* group, a Tags column under Fields
|
|
77
|
-
*
|
|
78
|
-
* `
|
|
79
|
-
* the
|
|
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
|
|
80
85
|
* settings `loadSettings(bucket)` · `saveSettings(bucket, settings | null)` (null = reset) —
|
|
81
86
|
* used when the page is UNCONTROLLED; a `settings` prop still wins
|
|
82
87
|
*/
|
|
@@ -206,25 +211,37 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
|
|
|
206
211
|
if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, next)).catch(() => {})
|
|
207
212
|
onSettingsChange?.(next)
|
|
208
213
|
}
|
|
209
|
-
const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], error: null })
|
|
214
|
+
const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], folders: {}, error: null })
|
|
210
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}`
|
|
211
220
|
useEffect(() => {
|
|
212
221
|
if (!client) return undefined
|
|
213
222
|
let cancelled = false
|
|
214
223
|
const controller = new AbortController()
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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 }) })
|
|
218
232
|
return () => { cancelled = true; controller.abort() }
|
|
219
|
-
}, [client,
|
|
220
|
-
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
|
|
221
236
|
const objects = ready ? loaded.objects : (cache[bucketId] ?? [])
|
|
222
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 ?? {}) }))
|
|
223
240
|
const error = ready ? loaded.error : null
|
|
224
241
|
const mediaUrl = (key) => client?.mediaUrl?.(key, bucketId ?? undefined) ?? key
|
|
225
242
|
const downloadUrl = (key) => client?.downloadUrl?.(key, bucketId ?? undefined) ?? mediaUrl(key)
|
|
226
243
|
const writable = !!bucketMeta.writable && !!(client?.deleteObject || client?.renameObject)
|
|
227
|
-
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 }
|
|
228
245
|
}
|
|
229
246
|
|
|
230
247
|
/* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
|
|
@@ -250,7 +267,8 @@ function ImageFrame({ src, vector }) {
|
|
|
250
267
|
/* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
|
|
251
268
|
* one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
|
|
252
269
|
* without it, or on a read-only bucket, the chips show and nothing edits them. */
|
|
253
|
-
function TagEditor({ tags = [], onChange }) {
|
|
270
|
+
function TagEditor({ tags = [], onChange, suggestions = [] }) {
|
|
271
|
+
const listId = useMemo(() => `kol-tags-${Math.random().toString(36).slice(2, 8)}`, [])
|
|
254
272
|
const add = (raw) => {
|
|
255
273
|
const next = raw.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
256
274
|
if (next.length) onChange([...new Set([...tags, ...next])])
|
|
@@ -266,7 +284,13 @@ function TagEditor({ tags = [], onChange }) {
|
|
|
266
284
|
))}
|
|
267
285
|
</div>
|
|
268
286
|
)}
|
|
269
|
-
{
|
|
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
|
+
)}
|
|
270
294
|
</div>
|
|
271
295
|
)
|
|
272
296
|
}
|
|
@@ -418,7 +442,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
|
|
|
418
442
|
* The columns never needed one — stepping into a folder IS its preview there — so this is the row
|
|
419
443
|
* view's, and it is the page's rather than ColumnBrowser's because the counts come off the same
|
|
420
444
|
* `objects` the page already holds. Same shell as the file preview, so the pane never shifts. */
|
|
421
|
-
function FolderPreview({ path, objects, width, formatDate }) {
|
|
445
|
+
function FolderPreview({ path, objects, width, formatDate, details }) {
|
|
422
446
|
const inside = objects.filter((o) => o.key.startsWith(path))
|
|
423
447
|
const bytes = inside.reduce((n, o) => n + (o.size ?? 0), 0)
|
|
424
448
|
const last = inside.reduce((t, o) => (o.uploaded && o.uploaded > t ? o.uploaded : t), '')
|
|
@@ -428,13 +452,13 @@ function FolderPreview({ path, objects, width, formatDate }) {
|
|
|
428
452
|
['Size', formatSize(bytes)],
|
|
429
453
|
...(last ? [['Newest', formatDate(last) || '—']] : []),
|
|
430
454
|
]
|
|
431
|
-
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} />
|
|
432
456
|
}
|
|
433
457
|
|
|
434
458
|
/* ONE PREVIEW FOR EVERYTHING THAT HOLDS THINGS — a folder, a bucket, the title (user 2026-09-25:
|
|
435
459
|
* selecting a bucket or MEDIA left the pane empty). The glyph, the name, the facts; only the
|
|
436
460
|
* glyph and the facts differ. */
|
|
437
|
-
function ContainerPreview({ name, icon, facts, width }) {
|
|
461
|
+
function ContainerPreview({ name, icon, facts, width, details }) {
|
|
438
462
|
return (
|
|
439
463
|
<div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
|
|
440
464
|
{/* the folder fills its box with the tile's inset (user 2026-09-24: a 64px glyph floated in
|
|
@@ -451,6 +475,8 @@ function ContainerPreview({ name, icon, facts, width }) {
|
|
|
451
475
|
</div>
|
|
452
476
|
))}
|
|
453
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>}
|
|
454
480
|
</div>
|
|
455
481
|
)
|
|
456
482
|
}
|
|
@@ -530,96 +556,27 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
|
|
|
530
556
|
)
|
|
531
557
|
}
|
|
532
558
|
|
|
533
|
-
/* THE KINDS THAT OPEN AS TEXT — what
|
|
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. */
|
|
534
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)
|
|
535
565
|
|
|
536
|
-
/*
|
|
537
|
-
*
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
* on Save — a draft is never what anyone else sees. */
|
|
541
|
-
const DRAFT_PAUSE = 800
|
|
542
|
-
function FileEditor({ o, client, bucket, onClose, onSaved, onDraft }) {
|
|
543
|
-
const [base, setBase] = useState(null) // the saved file's text; null while loading
|
|
544
|
-
const [text, setText] = useState('')
|
|
545
|
-
const [status, setStatus] = useState('loading') // loading · saved · draft · editing · saving · error
|
|
546
|
-
const timer = useRef(null)
|
|
547
|
-
const latest = useRef('')
|
|
548
|
-
const baseRef = useRef(null) // `base` for the unmount flush, which would otherwise see the first render's
|
|
549
|
-
const keep = (b) => { baseRef.current = b; setBase(b) }
|
|
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)
|
|
550
570
|
useEffect(() => {
|
|
551
571
|
let live = true
|
|
552
|
-
client.readText(o.key, bucket).then((
|
|
553
|
-
if (!live) return
|
|
554
|
-
keep(file ?? '')
|
|
555
|
-
setText(draft ?? file ?? '')
|
|
556
|
-
latest.current = draft ?? file ?? ''
|
|
557
|
-
setStatus(draft != null ? 'draft' : 'saved')
|
|
558
|
-
}).catch((e) => live && setStatus(`error: ${e.message}`))
|
|
572
|
+
client.readText(o.key, bucket).then((r) => live && setText(r?.text ?? '')).catch(() => live && setText(''))
|
|
559
573
|
return () => { live = false }
|
|
560
574
|
}, [o.key]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
561
|
-
const flushDraft = () => {
|
|
562
|
-
if (timer.current == null) return undefined
|
|
563
|
-
clearTimeout(timer.current); timer.current = null
|
|
564
|
-
const t = latest.current
|
|
565
|
-
const clean = t === baseRef.current
|
|
566
|
-
return Promise.resolve(client.saveDraft?.(o.key, clean ? null : t, bucket))
|
|
567
|
-
.then(() => { setStatus(clean ? 'saved' : 'draft'); onDraft?.(o.key, !clean) })
|
|
568
|
-
.catch(() => {})
|
|
569
|
-
}
|
|
570
|
-
useEffect(() => () => { flushDraft() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
571
|
-
const edit = (t) => {
|
|
572
|
-
setText(t); latest.current = t; setStatus('editing')
|
|
573
|
-
clearTimeout(timer.current)
|
|
574
|
-
timer.current = setTimeout(flushDraft, DRAFT_PAUSE)
|
|
575
|
-
}
|
|
576
|
-
const save = async () => {
|
|
577
|
-
clearTimeout(timer.current); timer.current = null
|
|
578
|
-
setStatus('saving')
|
|
579
|
-
try {
|
|
580
|
-
const res = await client.writeText(o.key, latest.current, bucket)
|
|
581
|
-
keep(latest.current); setStatus('saved')
|
|
582
|
-
onSaved?.(o.key, res)
|
|
583
|
-
} catch (e) { setStatus(`error: ${e.message}`) }
|
|
584
|
-
}
|
|
585
|
-
const revert = async () => {
|
|
586
|
-
clearTimeout(timer.current); timer.current = null
|
|
587
|
-
await client.saveDraft?.(o.key, null, bucket)
|
|
588
|
-
setText(baseRef.current ?? ''); latest.current = baseRef.current ?? ''; setStatus('saved')
|
|
589
|
-
onSaved?.(o.key, null)
|
|
590
|
-
}
|
|
591
|
-
const dirty = base != null && text !== base
|
|
592
|
-
/* ⌘S anywhere while the editor is open — not only with the caret in the text */
|
|
593
|
-
const saveRef = useRef(null)
|
|
594
|
-
saveRef.current = () => { if (baseRef.current != null && latest.current !== baseRef.current) save() }
|
|
595
|
-
useEffect(() => {
|
|
596
|
-
const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveRef.current() } }
|
|
597
|
-
window.addEventListener('keydown', onKey)
|
|
598
|
-
return () => window.removeEventListener('keydown', onKey)
|
|
599
|
-
}, [])
|
|
600
|
-
const label = { loading: 'Loading…', saved: 'Saved', draft: 'Draft — not saved to the file', editing: 'Editing…', saving: 'Saving…' }[status] ?? status
|
|
601
575
|
return (
|
|
602
|
-
<
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
{dirty && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
|
|
607
|
-
<Button variant="secondary" size="sm" onClick={save} disabled={!dirty || status === 'saving'}>Save</Button>
|
|
608
|
-
</span>
|
|
609
|
-
)}>
|
|
610
|
-
<textarea
|
|
611
|
-
value={text}
|
|
612
|
-
onChange={(e) => edit(e.target.value)}
|
|
613
|
-
disabled={base == null}
|
|
614
|
-
spellCheck={false}
|
|
615
|
-
aria-label={`Edit ${o.key.split('/').pop()}`}
|
|
616
|
-
className="kol-mono-12 block bg-transparent text-fg-default p-4 resize-none outline-none"
|
|
617
|
-
/* inline: Tailwind does not generate arbitrary values from package source (see the SVG box
|
|
618
|
-
in MediaInspector) — the window's own caps */
|
|
619
|
-
style={{ width: 'min(900px, var(--kol-ql-max-w))', height: 'min(640px, var(--kol-ql-media-h))' }}
|
|
620
|
-
/>
|
|
621
|
-
</QuickLookFrame>
|
|
622
|
-
</FullscreenOverlay>
|
|
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} />
|
|
623
580
|
)
|
|
624
581
|
}
|
|
625
582
|
|
|
@@ -689,7 +646,7 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
689
646
|
* the header; bare at rest with a wash on hover, `sm` so the glyphs sit with the 12px text. */
|
|
690
647
|
actions: (
|
|
691
648
|
<span className="flex items-center gap-1">
|
|
692
|
-
{onEdit &&
|
|
649
|
+
{onEdit && editKindOf(o) && (o.size ?? 0) <= EDIT_CAP && (
|
|
693
650
|
<Tooltip label="Edit">
|
|
694
651
|
<Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
|
|
695
652
|
</Tooltip>
|
|
@@ -1001,6 +958,9 @@ export function MediaLibraryBrowse({
|
|
|
1001
958
|
* rendering what it rendered. */
|
|
1002
959
|
view: viewProp, onViewChange,
|
|
1003
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,
|
|
1004
964
|
/* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
|
|
1005
965
|
* optional and each async; whatever is supplied becomes a right-click menu entry and
|
|
1006
966
|
* anything absent simply is not offered. One object rather than four props because they
|
|
@@ -1148,49 +1108,151 @@ export function MediaLibraryBrowse({
|
|
|
1148
1108
|
const press = useLongPress()
|
|
1149
1109
|
const modal = useModal()
|
|
1150
1110
|
const canWrite = !!fileActions && writable
|
|
1151
|
-
/*
|
|
1152
|
-
*
|
|
1153
|
-
* lands at once; the next listing confirms it.
|
|
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)` */
|
|
1154
1125
|
const canTag = writable && typeof client?.setTags === 'function'
|
|
1155
|
-
const
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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 })
|
|
1159
1142
|
})
|
|
1160
|
-
|
|
1161
|
-
|
|
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` */
|
|
1162
1154
|
const canEdit = writable && typeof client?.readText === 'function' && typeof client?.writeText === 'function'
|
|
1163
|
-
const
|
|
1164
|
-
const
|
|
1165
|
-
|
|
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 }) }
|
|
1166
1169
|
}
|
|
1167
|
-
const
|
|
1168
|
-
|
|
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 */
|
|
1169
1200
|
const detailsFor = (o) => {
|
|
1170
|
-
const
|
|
1171
|
-
const
|
|
1172
|
-
const
|
|
1173
|
-
if (!
|
|
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
|
|
1174
1205
|
return (
|
|
1175
1206
|
<div className="flex flex-col gap-4">
|
|
1176
|
-
{
|
|
1207
|
+
{(edit || canFavourite) && (
|
|
1177
1208
|
<div className="flex items-center gap-3">
|
|
1178
|
-
|
|
1179
|
-
|
|
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}
|
|
1180
1216
|
</div>
|
|
1181
1217
|
)}
|
|
1182
|
-
{(canTag || tags.length > 0) && <TagEditor tags={tags} onChange={canTag ? (next) => saveTags(
|
|
1218
|
+
{(canTag || tags.length > 0) && <TagEditor tags={tags} suggestions={allTags} onChange={canTag ? (next) => saveTags(path, next) : undefined} />}
|
|
1183
1219
|
</div>
|
|
1184
1220
|
)
|
|
1185
1221
|
}
|
|
1186
1222
|
const doTags = async (keys) => {
|
|
1187
1223
|
const one = keys.length === 1
|
|
1188
1224
|
const current = one ? tagsOf(keys[0]).join(', ') : ''
|
|
1189
|
-
const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length}
|
|
1225
|
+
const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length} items, separated by commas:`, current, { okLabel: 'Save' })
|
|
1190
1226
|
if (answer == null) return
|
|
1191
1227
|
const typed = answer.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
1192
1228
|
for (const key of keys) await saveTags(key, one ? typed : [...new Set([...tagsOf(key), ...typed])])
|
|
1193
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
|
+
}
|
|
1194
1256
|
const [busyAction, setBusyAction] = useState(false)
|
|
1195
1257
|
|
|
1196
1258
|
const runAction = async (fn) => {
|
|
@@ -1214,7 +1276,9 @@ export function MediaLibraryBrowse({
|
|
|
1214
1276
|
const name = await modal.prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current, { okLabel: 'Rename' })
|
|
1215
1277
|
if (!name?.trim() || name === current) return
|
|
1216
1278
|
const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
|
|
1217
|
-
|
|
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) })
|
|
1218
1282
|
}
|
|
1219
1283
|
const doMove = async (path) => {
|
|
1220
1284
|
const dest = await modal.prompt(`Move into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
|
|
@@ -1227,6 +1291,7 @@ export function MediaLibraryBrowse({
|
|
|
1227
1291
|
* view: expanded in place in the rows, navigated into in the columns and the grid. */
|
|
1228
1292
|
const afterMove = (paths, dest) => {
|
|
1229
1293
|
const moved = paths.map((p) => `${dest}${p.replace(/\/$/, '').split('/').pop()}${p.endsWith('/') ? '/' : ''}`)
|
|
1294
|
+
paths.forEach((p, i) => moveDrafts(bucketKey, p, moved[i]))
|
|
1230
1295
|
setPickedFile(null); setPickedFolder(null)
|
|
1231
1296
|
setRowSelection(new Set(moved))
|
|
1232
1297
|
selectAnchorRef.current = moved[0] ?? null
|
|
@@ -1419,6 +1484,7 @@ export function MediaLibraryBrowse({
|
|
|
1419
1484
|
const inView = q.files.filter((o) => rowSelection.has(o.key))
|
|
1420
1485
|
const sel = inView.length === rowSelection.size ? inView : objects.filter((o) => rowSelection.has(o.key))
|
|
1421
1486
|
const current = q.files[q.index]
|
|
1487
|
+
if (current?.key) logEvent('opened', current.key)
|
|
1422
1488
|
if (sel.length > 1) {
|
|
1423
1489
|
const index = Math.max(0, sel.findIndex((o) => o.key === current?.key))
|
|
1424
1490
|
setQuickLook({ files: sel, index })
|
|
@@ -1681,7 +1747,7 @@ export function MediaLibraryBrowse({
|
|
|
1681
1747
|
return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
1682
1748
|
}} />
|
|
1683
1749
|
) : previewFolder ? (
|
|
1684
|
-
<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)} />
|
|
1685
1751
|
) : previewRoot ? (
|
|
1686
1752
|
<ContainerPreview key={previewRoot.key} name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={previewWidth} />
|
|
1687
1753
|
) : (
|
|
@@ -1797,6 +1863,9 @@ export function MediaLibraryBrowse({
|
|
|
1797
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 }))
|
|
1798
1864
|
const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
|
|
1799
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)
|
|
1800
1869
|
|
|
1801
1870
|
/* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
|
|
1802
1871
|
* `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
|
|
@@ -2058,7 +2127,7 @@ export function MediaLibraryBrowse({
|
|
|
2058
2127
|
/* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
|
|
2059
2128
|
* 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
|
|
2060
2129
|
* and no folders, as it was. */
|
|
2061
|
-
folders={settings.flat ? [] : folders.map((f) => prefix + f)}
|
|
2130
|
+
folders={settings.flat || smartMatches ? [] : folders.map((f) => prefix + f)}
|
|
2062
2131
|
onOpenFolder={goFolder}
|
|
2063
2132
|
onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
|
|
2064
2133
|
onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
|
|
@@ -2103,9 +2172,21 @@ export function MediaLibraryBrowse({
|
|
|
2103
2172
|
pickFile(o)
|
|
2104
2173
|
}
|
|
2105
2174
|
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
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))
|
|
2109
2190
|
|
|
2110
2191
|
return (
|
|
2111
2192
|
<div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
@@ -2297,12 +2378,15 @@ export function MediaLibraryBrowse({
|
|
|
2297
2378
|
)}
|
|
2298
2379
|
{quickLook && (
|
|
2299
2380
|
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
|
|
2300
|
-
onEdit={canEdit ? (f) => { closeQuickLook();
|
|
2381
|
+
onEdit={canEdit ? (f) => { closeQuickLook(); openEditor(f) } : undefined}
|
|
2301
2382
|
onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
|
|
2302
2383
|
)}
|
|
2303
|
-
{editing && (
|
|
2304
|
-
<
|
|
2305
|
-
|
|
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)} />
|
|
2306
2390
|
)}
|
|
2307
2391
|
{settingsOpen && (
|
|
2308
2392
|
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
@@ -2311,16 +2395,28 @@ export function MediaLibraryBrowse({
|
|
|
2311
2395
|
<TrashPanel trash={trash} onClose={() => setTrashOpen(false)} run={runAction} confirm={modal.confirm} />
|
|
2312
2396
|
)}
|
|
2313
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
|
+
)}
|
|
2314
2410
|
{/* THE FILTER BAR, above whichever body is mounted (2026-09-22). It filters FILES, so the
|
|
2315
2411
|
* tree is scoped to the keys it leaves and the wall renders them directly. Off by default
|
|
2316
2412
|
* — the funnel in the crumb row turns it on. */}
|
|
2317
2413
|
{filtersOn ? (
|
|
2318
2414
|
<ContentFilters
|
|
2319
|
-
items={
|
|
2415
|
+
items={filterItemsFav}
|
|
2320
2416
|
title="Files"
|
|
2321
2417
|
totalCount={filterItems.length}
|
|
2322
2418
|
searchKeys={['displayKey']}
|
|
2323
|
-
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : [])]}
|
|
2419
|
+
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : []), ...(anyStarred ? [{ label: 'Favourites', key: 'starred', values: ['favourite'] }] : [])]}
|
|
2324
2420
|
mutuallyExclusiveFilters={['kind']}
|
|
2325
2421
|
/* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
|
|
2326
2422
|
* its panel, and the bar's own funnel closes the bar — the same switch from either
|
|
@@ -2419,7 +2515,13 @@ export function MediaLibraryBrowse({
|
|
|
2419
2515
|
New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
|
|
2420
2516
|
</MenuDropdownItem>
|
|
2421
2517
|
)}
|
|
2422
|
-
{
|
|
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 && (
|
|
2423
2525
|
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2424
2526
|
New file
|
|
2425
2527
|
</MenuDropdownItem>
|
|
@@ -2446,9 +2548,17 @@ export function MediaLibraryBrowse({
|
|
|
2446
2548
|
{target.type === 'file' && (
|
|
2447
2549
|
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
|
|
2448
2550
|
)}
|
|
2449
|
-
{
|
|
2551
|
+
{!isLevel && canTag && (
|
|
2450
2552
|
<MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags([target.path])}>Tags…</MenuDropdownItem>
|
|
2451
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
|
+
)}
|
|
2452
2562
|
{!isLevel && fileActions.remove && (
|
|
2453
2563
|
<>
|
|
2454
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
|
+
}
|