@kolkrabbi/kol-component 0.223.0 → 0.225.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/atoms/ColorSwatch.jsx +6 -2
- package/src/atoms/SelectionOverlay.jsx +3 -1
- package/src/index.js +4 -2
- package/src/molecules/ColorInputRow.jsx +3 -2
- package/src/molecules/Dropdown.jsx +10 -5
- package/src/molecules/SectionText.jsx +5 -1
- package/src/molecules/ShapeDropdown.jsx +3 -2
- package/src/molecules/SplitToolButton.jsx +3 -2
- package/src/molecules/SwatchControls.jsx +3 -1
- package/src/organisms/Canvas.jsx +5 -2
- package/src/organisms/DocumentEditor.jsx +282 -0
- package/src/organisms/KeyframeEditor.jsx +2 -1
- package/src/organisms/LayerStack.jsx +11 -6
- package/src/organisms/MediaLibrary.jsx +1 -1
- package/src/organisms/MediaLibraryExplorer.jsx +67 -3
- package/src/organisms/MediaLibraryPages.jsx +358 -197
- package/src/organisms/SettingsPanel.jsx +5 -1
- package/src/organisms/SettingsSections.jsx +2 -1
- package/src/organisms/SpectrumGrid.jsx +3 -2
- package/src/organisms/TimelineDock.jsx +6 -3
- package/src/organisms/ToolPalette.jsx +6 -3
- package/src/utilities/Popover.jsx +61 -11
- package/src/utilities/QuadrantSync.jsx +3 -1
- package/src/utilities/frontmatter.js +47 -0
- package/src/utilities/localDrafts.js +50 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import formatSize from '../utilities/formatSize.js'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
@@ -32,9 +32,13 @@ 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
|
-
import SettingsPanel, {
|
|
40
|
+
import SettingsPanel, { SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
|
|
41
|
+
import SettingsSections from './SettingsSections.jsx'
|
|
38
42
|
import { kindOf, extOf, KIND_LABEL, KINDS, DEFAULT_KINDS, isSystemFile, isSegment, groupSegments, groupVariants, posterFor, partition } from '../utilities/mediaKinds.js'
|
|
39
43
|
import { nearestRatio } from '../utilities/ratios.js'
|
|
40
44
|
|
|
@@ -74,9 +78,11 @@ import { nearestRatio } from '../utilities/ratios.js'
|
|
|
74
78
|
* tags listed objects carry `tags: string[]`; `setTags(key, tags, bucket)` writes them —
|
|
75
79
|
* preview-pane chips, "Tags…" / "Add tags to N…" in the menu, a Tags filter
|
|
76
80
|
* group, a Tags column under Fields
|
|
77
|
-
*
|
|
78
|
-
* `
|
|
79
|
-
* the
|
|
81
|
+
* documents `readText(key, bucket) → { text }` · `writeText(key, text, bucket)` ·
|
|
82
|
+
* `createFile(key, bucket, text?)` — Edit in the pane and in Quick Look, New document
|
|
83
|
+
* in the menu (markdown gets a fields form over its frontmatter; svg a live picture).
|
|
84
|
+
* DRAFTS ARE NOT THE CLIENT'S: browser memory (`utilities/localDrafts`), user ruling
|
|
85
|
+
* 2026-09-26 — only what must outlive a device goes to the database
|
|
80
86
|
* settings `loadSettings(bucket)` · `saveSettings(bucket, settings | null)` (null = reset) —
|
|
81
87
|
* used when the page is UNCONTROLLED; a `settings` prop still wins
|
|
82
88
|
*/
|
|
@@ -103,7 +109,7 @@ export const SETTINGS_BASE = {
|
|
|
103
109
|
folderView: 'columns', // 'rows' | 'columns'
|
|
104
110
|
sortBy: 'name',
|
|
105
111
|
sortDir: 'asc',
|
|
106
|
-
columnHeight: 528, // px,
|
|
112
|
+
columnHeight: 528, // px, any CSS length (forwarded to ColumnBrowser `height` untouched), or 'fill' — what is left of the window
|
|
107
113
|
columnWidths: {},
|
|
108
114
|
rowPreview: true, // row view's preview pane beside the list (Finder's "Show preview")
|
|
109
115
|
view: 'columns', // 'columns' | 'rows' | 'grid' | 'list' — the ONE view state (2026-09-22)
|
|
@@ -206,25 +212,37 @@ function useBucketLibrary({ client, bucket, defaults, settings: controlled, onSe
|
|
|
206
212
|
if (remote) Promise.resolve(client.saveSettings?.(bucketId ?? undefined, next)).catch(() => {})
|
|
207
213
|
onSettingsChange?.(next)
|
|
208
214
|
}
|
|
209
|
-
const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], error: null })
|
|
215
|
+
const [loaded, setLoaded] = useState({ key: null, bucket: null, objects: [], folders: {}, error: null })
|
|
210
216
|
const [cache, setCache] = useState({})
|
|
217
|
+
/* `reload()` — a write the PAGE made itself (a new document, a tag on a folder) re-lists without
|
|
218
|
+
* waiting for the consumer to bump `refreshKey` */
|
|
219
|
+
const [nonce, setNonce] = useState(0)
|
|
220
|
+
const loadKey = `${refreshKey}.${nonce}`
|
|
211
221
|
useEffect(() => {
|
|
212
222
|
if (!client) return undefined
|
|
213
223
|
let cancelled = false
|
|
214
224
|
const controller = new AbortController()
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
/* the folder rows (`folderInfo`, a D1 read) come beside the listing: tags and favourites a
|
|
226
|
+
* folder carries, which a key-prefix bucket has nowhere to keep */
|
|
227
|
+
Promise.all([
|
|
228
|
+
client.listMedia('', { signal: controller.signal, bucket: bucketId ?? undefined }),
|
|
229
|
+
Promise.resolve(client.folderInfo?.(bucketId ?? undefined)).catch(() => ({})),
|
|
230
|
+
])
|
|
231
|
+
.then(([objs, folders]) => { if (cancelled) return; setLoaded({ key: loadKey, bucket: bucketId, objects: objs, folders: folders ?? {}, error: null }); setCache((c) => ({ ...c, [bucketId]: objs })) })
|
|
232
|
+
.catch((e) => { if (!cancelled && e.name !== 'AbortError') setLoaded({ key: loadKey, bucket: bucketId, objects: [], folders: {}, error: e.message }) })
|
|
218
233
|
return () => { cancelled = true; controller.abort() }
|
|
219
|
-
}, [client,
|
|
220
|
-
const
|
|
234
|
+
}, [client, loadKey, bucketId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
235
|
+
const reload = () => setNonce((n) => n + 1)
|
|
236
|
+
const ready = loaded.key === loadKey && loaded.bucket === bucketId
|
|
221
237
|
const objects = ready ? loaded.objects : (cache[bucketId] ?? [])
|
|
222
238
|
const setObjects = (fn) => setLoaded((prev) => ({ ...prev, objects: fn(prev.objects) }))
|
|
239
|
+
const folderInfo = loaded.folders ?? {}
|
|
240
|
+
const setFolderInfo = (fn) => setLoaded((prev) => ({ ...prev, folders: fn(prev.folders ?? {}) }))
|
|
223
241
|
const error = ready ? loaded.error : null
|
|
224
242
|
const mediaUrl = (key) => client?.mediaUrl?.(key, bucketId ?? undefined) ?? key
|
|
225
243
|
const downloadUrl = (key) => client?.downloadUrl?.(key, bucketId ?? undefined) ?? mediaUrl(key)
|
|
226
244
|
const writable = !!bucketMeta.writable && !!(client?.deleteObject || client?.renameObject)
|
|
227
|
-
return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, error, ready, mediaUrl, downloadUrl, writable }
|
|
245
|
+
return { buckets, bucketMeta, bucketId, settings, setSettings, objects, setObjects, folderInfo, setFolderInfo, reload, error, ready, mediaUrl, downloadUrl, writable }
|
|
228
246
|
}
|
|
229
247
|
|
|
230
248
|
/* ── shared pieces, kol-r2b2's ─────────────────────────────────────────────── */
|
|
@@ -250,7 +268,8 @@ function ImageFrame({ src, vector }) {
|
|
|
250
268
|
/* TAGS IN THE PREVIEW PANE (the media D1 pass, 2026-09-25). The file's tags as removable chips and
|
|
251
269
|
* one field that adds on Enter — comma-separated adds several. Tags are the CLIENT's (`setTags`);
|
|
252
270
|
* without it, or on a read-only bucket, the chips show and nothing edits them. */
|
|
253
|
-
function TagEditor({ tags = [], onChange }) {
|
|
271
|
+
function TagEditor({ tags = [], onChange, suggestions = [] }) {
|
|
272
|
+
const listId = useMemo(() => `kol-tags-${Math.random().toString(36).slice(2, 8)}`, [])
|
|
254
273
|
const add = (raw) => {
|
|
255
274
|
const next = raw.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
256
275
|
if (next.length) onChange([...new Set([...tags, ...next])])
|
|
@@ -266,7 +285,13 @@ function TagEditor({ tags = [], onChange }) {
|
|
|
266
285
|
))}
|
|
267
286
|
</div>
|
|
268
287
|
)}
|
|
269
|
-
{
|
|
288
|
+
{/* the bucket's own tags as suggestions — native autocomplete, so a typo does not fork the vocabulary */}
|
|
289
|
+
{onChange && <Input size="xs" value="" placeholder="Add a tag" aria-label="Add a tag" onCommit={add} list={listId} />}
|
|
290
|
+
{onChange && (
|
|
291
|
+
<datalist id={listId}>
|
|
292
|
+
{suggestions.filter((t) => !tags.includes(t)).map((t) => <option key={t} value={t} />)}
|
|
293
|
+
</datalist>
|
|
294
|
+
)}
|
|
270
295
|
</div>
|
|
271
296
|
)
|
|
272
297
|
}
|
|
@@ -418,7 +443,7 @@ function FileRow({ o, onClick, onDoubleClick, depth = 0, formatDate, thumb, cols
|
|
|
418
443
|
* The columns never needed one — stepping into a folder IS its preview there — so this is the row
|
|
419
444
|
* view's, and it is the page's rather than ColumnBrowser's because the counts come off the same
|
|
420
445
|
* `objects` the page already holds. Same shell as the file preview, so the pane never shifts. */
|
|
421
|
-
function FolderPreview({ path, objects, width, formatDate }) {
|
|
446
|
+
function FolderPreview({ path, objects, width, formatDate, details }) {
|
|
422
447
|
const inside = objects.filter((o) => o.key.startsWith(path))
|
|
423
448
|
const bytes = inside.reduce((n, o) => n + (o.size ?? 0), 0)
|
|
424
449
|
const last = inside.reduce((t, o) => (o.uploaded && o.uploaded > t ? o.uploaded : t), '')
|
|
@@ -428,13 +453,13 @@ function FolderPreview({ path, objects, width, formatDate }) {
|
|
|
428
453
|
['Size', formatSize(bytes)],
|
|
429
454
|
...(last ? [['Newest', formatDate(last) || '—']] : []),
|
|
430
455
|
]
|
|
431
|
-
return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} />
|
|
456
|
+
return <ContainerPreview name={path.replace(/\/$/, '').split('/').pop()} icon="folder" facts={facts} width={width} details={details} />
|
|
432
457
|
}
|
|
433
458
|
|
|
434
459
|
/* ONE PREVIEW FOR EVERYTHING THAT HOLDS THINGS — a folder, a bucket, the title (user 2026-09-25:
|
|
435
460
|
* selecting a bucket or MEDIA left the pane empty). The glyph, the name, the facts; only the
|
|
436
461
|
* glyph and the facts differ. */
|
|
437
|
-
function ContainerPreview({ name, icon, facts, width }) {
|
|
462
|
+
function ContainerPreview({ name, icon, facts, width, details }) {
|
|
438
463
|
return (
|
|
439
464
|
<div className="kol-column-browser-preview shrink-0 overflow-y-auto p-4 flex flex-col gap-4" style={{ width }}>
|
|
440
465
|
{/* the folder fills its box with the tile's inset (user 2026-09-24: a 64px glyph floated in
|
|
@@ -451,6 +476,8 @@ function ContainerPreview({ name, icon, facts, width }) {
|
|
|
451
476
|
</div>
|
|
452
477
|
))}
|
|
453
478
|
</dl>
|
|
479
|
+
{/* a folder's own rows — favourite, tags (media D1 plan v2) — an island, like the file pane's */}
|
|
480
|
+
{details && <div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>{details}</div>}
|
|
454
481
|
</div>
|
|
455
482
|
)
|
|
456
483
|
}
|
|
@@ -530,96 +557,27 @@ function RowHeader({ sortBy = 'name', sortDir = 'asc', onSort, width, cols, onRe
|
|
|
530
557
|
)
|
|
531
558
|
}
|
|
532
559
|
|
|
533
|
-
/* THE KINDS THAT OPEN AS TEXT — what
|
|
560
|
+
/* THE KINDS THAT OPEN AS TEXT — what the document editor takes (media D1 plan v2, 2026-09-26).
|
|
561
|
+
* An SVG is text too: it opens with its picture beside it. Above EDIT_CAP a file is not loaded into
|
|
562
|
+
* an editor at all — a textarea holding megabytes is a frozen tab, not an edit. */
|
|
534
563
|
const TEXT_KINDS = new Set(['markdown', 'json', 'yaml', 'text', 'code'])
|
|
564
|
+
const EDIT_CAP = 1_048_576
|
|
565
|
+
const editKindOf = (o) => (o.contentType === 'image/svg+xml' || extOf(o.key) === 'svg' ? 'svg' : TEXT_KINDS.has(kindOf(o)) ? kindOf(o) : null)
|
|
535
566
|
|
|
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) }
|
|
567
|
+
/* The editor for a file that exists: reads its text through the client, saves through it. Drafts
|
|
568
|
+
* are the DocumentEditor's (browser memory), keyed by bucket + key. */
|
|
569
|
+
function FileEditorHost({ o, client, bucket, onClose, onSaved, assets }) {
|
|
570
|
+
const [text, setText] = useState(null)
|
|
550
571
|
useEffect(() => {
|
|
551
572
|
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}`))
|
|
573
|
+
client.readText(o.key, bucket).then((r) => live && setText(r?.text ?? '')).catch(() => live && setText(''))
|
|
559
574
|
return () => { live = false }
|
|
560
575
|
}, [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
576
|
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>
|
|
577
|
+
<DocumentEditor name={o.displayKey ?? o.key.split('/').pop()} kind={editKindOf(o)} text={text}
|
|
578
|
+
savedAt={o.uploaded ? Date.parse(o.uploaded) : 0} draft={{ bucket, key: o.key }} assets={assets}
|
|
579
|
+
onSave={async (next) => { const r = await client.writeText(o.key, next, bucket); onSaved?.(o.key, r, next) }}
|
|
580
|
+
onClose={onClose} />
|
|
623
581
|
)
|
|
624
582
|
}
|
|
625
583
|
|
|
@@ -689,7 +647,7 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
689
647
|
* the header; bare at rest with a wash on hover, `sm` so the glyphs sit with the 12px text. */
|
|
690
648
|
actions: (
|
|
691
649
|
<span className="flex items-center gap-1">
|
|
692
|
-
{onEdit &&
|
|
650
|
+
{onEdit && editKindOf(o) && (o.size ?? 0) <= EDIT_CAP && (
|
|
693
651
|
<Tooltip label="Edit">
|
|
694
652
|
<Button variant="nav" size="sm" iconOnly="edit" onClick={() => onEdit(o)} aria-label="Edit" />
|
|
695
653
|
</Tooltip>
|
|
@@ -726,61 +684,62 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
726
684
|
)
|
|
727
685
|
}
|
|
728
686
|
|
|
729
|
-
/*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
|
|
687
|
+
/* THE DISPLAY SETTINGS AS DATA (media-shell, user 2026-09-26: "missing settings sidebar? … or are
|
|
688
|
+
* we saying the shell has settings different from media?"). The rows were hard-wired JSX inside this
|
|
689
|
+
* drawer, so an app page could not show them and a second settings system grew beside it. Now ONE
|
|
690
|
+
* builder: this drawer renders it, and an app renders the same rows on its settings page and its own
|
|
691
|
+
* drawer (kol-shell `HubSettings sections`). `settings` is the page's per-bucket settings object,
|
|
692
|
+
* `onChange(next)` sets it, `profile` (what the bucket holds) drives the "nothing to fold" states —
|
|
693
|
+
* omit it where the listing is not at hand and those two switches stay enabled with plain hints.
|
|
694
|
+
* Every control sets a default, never a gate. */
|
|
695
|
+
export function mediaSettingsSections({ settings, onChange, profile }) {
|
|
733
696
|
const set = (patch) => onChange({ ...settings, ...patch })
|
|
734
|
-
const
|
|
735
|
-
const
|
|
736
|
-
const
|
|
697
|
+
const kinds = settings.kinds ?? []
|
|
698
|
+
const toggleKind = (k) => set({ kinds: kinds.includes(k) ? kinds.filter((x) => x !== k) : [...kinds, k] })
|
|
699
|
+
const noVariants = profile?.variantSets === 0
|
|
700
|
+
const noSegments = profile?.segments === 0
|
|
701
|
+
return [
|
|
702
|
+
{ label: 'Structure', rowGap: 1, rows: [
|
|
703
|
+
{ label: 'Columns', hint: 'Finder-style columns instead of folder rows',
|
|
704
|
+
render: () => <SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} /> },
|
|
705
|
+
{ label: 'Preview', hint: 'the picked file beside the rows',
|
|
706
|
+
render: () => <SettingsSwitch label="Preview" on={settings.rowPreview ?? true} onChange={(v) => set({ rowPreview: v })} /> },
|
|
707
|
+
/* NOT "Columns" — that switch above means Finder's column browser. These are the row view's
|
|
708
|
+
* date/size FIELDS, which is what the user calls them. */
|
|
709
|
+
{ label: 'Fields', hint: 'date and size beside each row, with a sort header',
|
|
710
|
+
render: () => <SettingsSwitch label="Fields" on={!!settings.rowColumns} onChange={(v) => set({ rowColumns: v })} /> },
|
|
711
|
+
{ label: 'Flat', hint: 'ignore folders, show the whole subtree',
|
|
712
|
+
render: () => <SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} /> },
|
|
713
|
+
{ label: 'Group resolutions', hint: noVariants ? 'no resolution sets in this bucket' : profile ? `${profile.variantSets} sets — previews the smallest file` : 'previews the smallest file of a set',
|
|
714
|
+
render: () => <SettingsSwitch label="Group resolutions" on={settings.groupVariants} onChange={(v) => set({ groupVariants: v })} disabled={noVariants} disabledHint="nothing to group here" /> },
|
|
715
|
+
{ label: 'Fold HLS segments', hint: noSegments ? 'no segments in this bucket' : profile ? `${profile.segments} segments into stream rows` : 'segments into stream rows',
|
|
716
|
+
render: () => <SettingsSwitch label="Fold HLS segments" on={settings.foldSegments} onChange={(v) => set({ foldSegments: v })} disabled={noSegments} disabledHint="nothing to fold here" /> },
|
|
717
|
+
] },
|
|
718
|
+
{ label: 'Loading', rows: [
|
|
719
|
+
{ label: 'Kinds', align: 'fill',
|
|
720
|
+
render: () => <SettingsMulti options={ALL_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] || k }))} selected={kinds} onToggle={toggleKind} noun="kinds" /> },
|
|
721
|
+
{ label: 'Page size', hint: 'entries mounted at once', align: 'fill',
|
|
722
|
+
render: () => <SettingsChoice options={[{ value: 100, label: '100' }, { value: 200, label: '200' }, { value: 500, label: '500' }, { value: 0, label: 'All' }]} value={settings.pageSize} onChange={(v) => set({ pageSize: v })} /> },
|
|
723
|
+
{ label: 'Video preview', hint: 'poster uses the sibling image; autoload fetches the file', align: 'fill',
|
|
724
|
+
render: () => <SettingsChoice options={[{ value: 'poster', label: 'Poster' }, { value: 'none', label: 'None' }, { value: 'autoload', label: 'Autoload' }]} value={settings.videoPreview} onChange={(v) => set({ videoPreview: v })} /> },
|
|
725
|
+
] },
|
|
726
|
+
{ label: 'Layout', rows: [
|
|
727
|
+
{ label: 'View', align: 'fill',
|
|
728
|
+
render: () => <SettingsChoice options={[{ value: 'off', label: 'Off' }, { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} value={settings.layout} onChange={(v) => set({ layout: v })} /> },
|
|
729
|
+
{ label: 'Sort', align: 'fill',
|
|
730
|
+
render: () => <SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} /> },
|
|
731
|
+
{ label: 'Direction', align: 'fill',
|
|
732
|
+
render: () => <SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} /> },
|
|
733
|
+
] },
|
|
734
|
+
]
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/* Display settings for the ACTIVE bucket — kol-r2b2's drawer wiring on the DS organism (the
|
|
738
|
+
* composition the user locked 2026-08-27), drawn from `mediaSettingsSections`. */
|
|
739
|
+
function MediaSettings({ settings, onChange, onReset, onClose, profile, settingsFooter }) {
|
|
737
740
|
return (
|
|
738
741
|
<SettingsPanel variant="drawer" title="Display settings" onClose={onClose} footer={<SettingsFooter onReset={onReset} resetLabel="Reset preferences">{settingsFooter}</SettingsFooter>}>
|
|
739
|
-
<
|
|
740
|
-
<SettingsRow label="Columns" hint="Finder-style columns instead of folder rows">
|
|
741
|
-
<SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} />
|
|
742
|
-
</SettingsRow>
|
|
743
|
-
<SettingsRow label="Preview" hint="the picked file beside the rows">
|
|
744
|
-
<SettingsSwitch label="Preview" on={settings.rowPreview ?? true} onChange={(v) => set({ rowPreview: v })} />
|
|
745
|
-
</SettingsRow>
|
|
746
|
-
{/* NOT "Columns" — that switch three rows up means Finder's column browser. These are the
|
|
747
|
-
row view's date/size FIELDS, which is what the user calls them. */}
|
|
748
|
-
<SettingsRow label="Fields" hint="date and size beside each row, with a sort header">
|
|
749
|
-
<SettingsSwitch label="Fields" on={!!settings.rowColumns} onChange={(v) => set({ rowColumns: v })} />
|
|
750
|
-
</SettingsRow>
|
|
751
|
-
<SettingsRow label="Flat" hint="ignore folders, show the whole subtree">
|
|
752
|
-
<SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} />
|
|
753
|
-
</SettingsRow>
|
|
754
|
-
<SettingsRow label="Group resolutions" hint={noVariants ? 'no resolution sets in this bucket' : `${profile.variantSets} sets — previews the smallest file`}>
|
|
755
|
-
<SettingsSwitch label="Group resolutions" on={settings.groupVariants} onChange={(v) => set({ groupVariants: v })} disabled={noVariants} disabledHint="nothing to group here" />
|
|
756
|
-
</SettingsRow>
|
|
757
|
-
<SettingsRow label="Fold HLS segments" hint={noSegments ? 'no segments in this bucket' : `${profile.segments} segments into stream rows`}>
|
|
758
|
-
<SettingsSwitch label="Fold HLS segments" on={settings.foldSegments} onChange={(v) => set({ foldSegments: v })} disabled={noSegments} disabledHint="nothing to fold here" />
|
|
759
|
-
</SettingsRow>
|
|
760
|
-
</LabeledControlSection>
|
|
761
|
-
<LabeledControlSection label="Loading" divided>
|
|
762
|
-
<SettingsRow label="Kinds" align="fill">
|
|
763
|
-
<SettingsMulti options={ALL_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] || k }))} selected={settings.kinds} onToggle={toggleKind} noun="kinds" />
|
|
764
|
-
</SettingsRow>
|
|
765
|
-
<SettingsRow label="Page size" hint="entries mounted at once" align="fill">
|
|
766
|
-
<SettingsChoice options={[{ value: 100, label: '100' }, { value: 200, label: '200' }, { value: 500, label: '500' }, { value: 0, label: 'All' }]} value={settings.pageSize} onChange={(v) => set({ pageSize: v })} />
|
|
767
|
-
</SettingsRow>
|
|
768
|
-
<SettingsRow label="Video preview" hint="poster uses the sibling image; autoload fetches the file" align="fill">
|
|
769
|
-
<SettingsChoice options={[{ value: 'poster', label: 'Poster' }, { value: 'none', label: 'None' }, { value: 'autoload', label: 'Autoload' }]} value={settings.videoPreview} onChange={(v) => set({ videoPreview: v })} />
|
|
770
|
-
</SettingsRow>
|
|
771
|
-
</LabeledControlSection>
|
|
772
|
-
<LabeledControlSection label="Layout" divided>
|
|
773
|
-
<SettingsRow label="View" align="fill">
|
|
774
|
-
<SettingsChoice options={[{ value: 'off', label: 'Off' }, { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} value={settings.layout} onChange={(v) => set({ layout: v })} />
|
|
775
|
-
</SettingsRow>
|
|
776
|
-
<SettingsRow label="Sort" align="fill">
|
|
777
|
-
<SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} />
|
|
778
|
-
</SettingsRow>
|
|
779
|
-
<SettingsRow label="Direction" align="fill">
|
|
780
|
-
<SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} />
|
|
781
|
-
</SettingsRow>
|
|
782
|
-
</LabeledControlSection>
|
|
783
|
-
{void bucketMeta}
|
|
742
|
+
<SettingsSections sections={mediaSettingsSections({ settings, onChange, profile })} divided />
|
|
784
743
|
</SettingsPanel>
|
|
785
744
|
)
|
|
786
745
|
}
|
|
@@ -959,6 +918,44 @@ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta
|
|
|
959
918
|
)
|
|
960
919
|
}
|
|
961
920
|
|
|
921
|
+
/* `columnHeight: 'fill'` — THE BROWSER TAKES WHAT IS LEFT OF THE WINDOW (media-shell, user 2026-09-26:
|
|
922
|
+
* "why isnt the browser in media shell using available height like media does?"). apps/media had it
|
|
923
|
+
* as a hand-measured `calc(100dvh - 212px)` — its header, gaps and padding, counted once — so the same
|
|
924
|
+
* tool one shell over got the 528 base. Measured instead: the window, less the view's own top, less
|
|
925
|
+
* what the page draws under it (the count line), less the bottom padding / border / margin of every
|
|
926
|
+
* box around it. Follows a resize and any change above it. Floor 240 so a short window still browses. */
|
|
927
|
+
/* `view` is the ELEMENT (state, via a callback ref), not a ref object: the view mounts after the data
|
|
928
|
+
* loads, and in a fixed-height page (kol-shell PageShell) nothing else resizes to wake the observer —
|
|
929
|
+
* a ref read once at mount stayed null and the browser sat at the 528 base. */
|
|
930
|
+
function useFillHeight(on, view, pageEl) {
|
|
931
|
+
const [h, setH] = useState(null)
|
|
932
|
+
useLayoutEffect(() => {
|
|
933
|
+
if (!on || !view) return undefined
|
|
934
|
+
const measure = () => {
|
|
935
|
+
const page = pageEl.current
|
|
936
|
+
if (!page) return
|
|
937
|
+
const r = view.getBoundingClientRect()
|
|
938
|
+
/* what the page DRAWS under the view — its content's bottom, not its box's: a page stretched to
|
|
939
|
+
* fill its container (flex-1) would count its own empty space as "below" and freeze the height */
|
|
940
|
+
const contentBottom = Math.max(r.bottom, ...[...page.children].map((c) => c.getBoundingClientRect().bottom))
|
|
941
|
+
let below = contentBottom - r.bottom
|
|
942
|
+
for (let el = page; el && el !== document.body; el = el.parentElement) {
|
|
943
|
+
const cs = getComputedStyle(el)
|
|
944
|
+
below += parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth) + (el === page ? 0 : parseFloat(cs.marginBottom))
|
|
945
|
+
}
|
|
946
|
+
const next = Math.max(240, Math.floor(window.innerHeight - r.top - below))
|
|
947
|
+
setH((p) => (p === next ? p : next))
|
|
948
|
+
}
|
|
949
|
+
measure()
|
|
950
|
+
const ro = new ResizeObserver(measure)
|
|
951
|
+
ro.observe(document.body)
|
|
952
|
+
if (pageEl.current) ro.observe(pageEl.current)
|
|
953
|
+
window.addEventListener('resize', measure)
|
|
954
|
+
return () => { ro.disconnect(); window.removeEventListener('resize', measure) }
|
|
955
|
+
}, [on, view]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
956
|
+
return h
|
|
957
|
+
}
|
|
958
|
+
|
|
962
959
|
/* the profile — what the bucket holds; drives the chips and the settings' "nothing to fold" states */
|
|
963
960
|
const profileOf = (objects, rawFiles, systemCount) => ({
|
|
964
961
|
variantSets: groupVariants(objects.map((o) => ({ ...o, displayKey: o.key }))).filter((g) => g.variants).length,
|
|
@@ -1001,6 +998,9 @@ export function MediaLibraryBrowse({
|
|
|
1001
998
|
* rendering what it rendered. */
|
|
1002
999
|
view: viewProp, onViewChange,
|
|
1003
1000
|
folderTree, headerActions, headerTrailing, refreshKey, onOpen, autoFocus = false, settingsFooter, className = '', banner,
|
|
1001
|
+
/* `smartFolder` — a smart folder's id to open ON (a Home tile linking straight to one); the chips
|
|
1002
|
+
* take over from there (media D1 plan v2) */
|
|
1003
|
+
smartFolder,
|
|
1004
1004
|
/* THE FILE VERBS, as ONE seam (2026-09-21). `{ createFolder, rename, move, remove }`, each
|
|
1005
1005
|
* optional and each async; whatever is supplied becomes a right-click menu entry and
|
|
1006
1006
|
* anything absent simply is not offered. One object rather than four props because they
|
|
@@ -1148,49 +1148,151 @@ export function MediaLibraryBrowse({
|
|
|
1148
1148
|
const press = useLongPress()
|
|
1149
1149
|
const modal = useModal()
|
|
1150
1150
|
const canWrite = !!fileActions && writable
|
|
1151
|
-
/*
|
|
1152
|
-
*
|
|
1153
|
-
* lands at once; the next listing confirms it.
|
|
1151
|
+
/* ── WHAT THE DATABASE BESIDE THE BUCKET ADDS (media D1 plan v2, 2026-09-26) ──────────────────
|
|
1152
|
+
* Every verb optional; each feature below is off when its verb is missing. Writes patch the list
|
|
1153
|
+
* in place so the change lands at once; the next listing confirms it. A path ending in `/` is a
|
|
1154
|
+
* folder — its rows come from `folderInfo`, a file's ride the listing. */
|
|
1155
|
+
const bucketKey = bucketMeta.id ?? undefined
|
|
1156
|
+
const folderRows = lib.folderInfo
|
|
1157
|
+
const patchPath = (path, patch) => {
|
|
1158
|
+
if (path.endsWith('/')) lib.setFolderInfo((f) => ({ ...f, [path]: { tags: [], favourite: false, ...(f[path] ?? {}), ...patch } }))
|
|
1159
|
+
else {
|
|
1160
|
+
lib.setObjects((list) => list.map((o) => (o.key === path ? { ...o, ...patch } : o)))
|
|
1161
|
+
setPickedFile((f) => (f?.key === path ? { ...f, ...patch } : f))
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/* TAGS — `setTags(path, tags, bucket)` */
|
|
1154
1165
|
const canTag = writable && typeof client?.setTags === 'function'
|
|
1155
|
-
const
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1166
|
+
const tagsOf = (path) => (path.endsWith('/') ? folderRows[path]?.tags : objects.find((o) => o.key === path)?.tags) ?? []
|
|
1167
|
+
/* every tag in the bucket, for the add field's suggestions — a typo forks the vocabulary */
|
|
1168
|
+
/* the rows view grows a Tags column when there is anything to show or set */
|
|
1169
|
+
const hasTags = canTag || objects.some((o) => o.tags?.length)
|
|
1170
|
+
const allTags = useMemo(() => [...new Set([...objects.flatMap((o) => o.tags ?? []), ...Object.values(folderRows).flatMap((f) => f.tags ?? [])])].sort(), [objects, folderRows])
|
|
1171
|
+
const saveTags = (path, tags) => runAction(async () => {
|
|
1172
|
+
const r = await client.setTags(path, tags, bucketKey)
|
|
1173
|
+
patchPath(path, { tags: r?.tags ?? tags })
|
|
1159
1174
|
})
|
|
1160
|
-
|
|
1161
|
-
|
|
1175
|
+
/* FAVOURITES — `setFavourite(path, on, bucket)` */
|
|
1176
|
+
const canFavourite = writable && typeof client?.setFavourite === 'function'
|
|
1177
|
+
const isFavourite = (path) => !!(path.endsWith('/') ? folderRows[path]?.favourite : objects.find((o) => o.key === path)?.favourite)
|
|
1178
|
+
const toggleFavourite = (path) => runAction(async () => {
|
|
1179
|
+
const on = !isFavourite(path)
|
|
1180
|
+
await client.setFavourite(path, on, bucketKey)
|
|
1181
|
+
patchPath(path, { favourite: on })
|
|
1182
|
+
})
|
|
1183
|
+
/* THE EVENT LOG recents are read from — intentions only (opened, edited, created) */
|
|
1184
|
+
const logEvent = (kind, path) => { Promise.resolve(client?.logEvent?.(kind, path, bucketKey)).catch(() => {}) }
|
|
1185
|
+
/* DRAFTS are browser memory (the user's ruling) — read here only to mark a file that has one */
|
|
1186
|
+
const [draftKeys, setDraftKeys] = useState(() => new Set(listDrafts(bucketKey).map((d) => d.key)))
|
|
1187
|
+
useEffect(() => {
|
|
1188
|
+
const read = () => setDraftKeys(new Set(listDrafts(bucketKey).map((d) => d.key)))
|
|
1189
|
+
read()
|
|
1190
|
+
window.addEventListener(DRAFTS_EVENT, read)
|
|
1191
|
+
return () => window.removeEventListener(DRAFTS_EVENT, read)
|
|
1192
|
+
}, [bucketKey])
|
|
1193
|
+
/* DOCUMENTS — `readText` / `writeText` edit; a new one is the consumer's `createFile` + `writeText` */
|
|
1162
1194
|
const canEdit = writable && typeof client?.readText === 'function' && typeof client?.writeText === 'function'
|
|
1163
|
-
const
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1195
|
+
const canNewDoc = canEdit && canWrite && !!fileActions.createFile
|
|
1196
|
+
const [editing, setEditing] = useState(null) // { mode: 'edit', o } | { mode: 'new', folder }
|
|
1197
|
+
const openEditor = (o) => { setEditing({ mode: 'edit', o }); logEvent('opened', o.key) }
|
|
1198
|
+
const newDocument = (folder) => setEditing({ mode: 'new', folder })
|
|
1199
|
+
/* A MARKDOWN FILE'S FRONTMATTER TAGS ARE ITS TAGS — merged into the database's on every save, so
|
|
1200
|
+
* a tag written in the document is a tag you can filter by. Merged, never replaced: a tag set in
|
|
1201
|
+
* the pane and not in the document stays. */
|
|
1202
|
+
const syncFrontmatterTags = async (key, text) => {
|
|
1203
|
+
if (!canTag || !/\.(md|markdown)$/i.test(key)) return
|
|
1204
|
+
const fm = parseFrontmatter(text ?? '').tags
|
|
1205
|
+
const typed = (Array.isArray(fm) ? fm : typeof fm === 'string' && fm ? fm.split(',') : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
|
1206
|
+
const current = objects.find((o) => o.key === key)?.tags ?? []
|
|
1207
|
+
const next = [...new Set([...current, ...typed])]
|
|
1208
|
+
if (next.length !== current.length) { await client.setTags(key, next, bucketKey); patchPath(key, { tags: next }) }
|
|
1166
1209
|
}
|
|
1167
|
-
const
|
|
1168
|
-
|
|
1210
|
+
const afterEdit = (key, res, text) => {
|
|
1211
|
+
if (res?.size != null) lib.setObjects((list) => list.map((o) => (o.key === key ? { ...o, size: res.size, uploaded: new Date().toISOString() } : o)))
|
|
1212
|
+
syncFrontmatterTags(key, text).catch(() => {})
|
|
1213
|
+
}
|
|
1214
|
+
const createDocument = async ({ name, text }) => {
|
|
1215
|
+
const key = `${editing.folder}${name}`
|
|
1216
|
+
if (objects.some((o) => o.key === key)) throw new Error(`${name} already exists here`)
|
|
1217
|
+
await fileActions.createFile(key)
|
|
1218
|
+
await client.writeText(key, text, bucketKey)
|
|
1219
|
+
const fm = /\.(md|markdown)$/i.test(key) ? parseFrontmatter(text).tags : null
|
|
1220
|
+
const typed = (Array.isArray(fm) ? fm : []).map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
|
1221
|
+
if (canTag && typed.length) await client.setTags(key, typed, bucketKey)
|
|
1222
|
+
lib.reload()
|
|
1223
|
+
setEditing(null)
|
|
1224
|
+
}
|
|
1225
|
+
/* what a markdown document can attach: the bucket's own files, linked by their public URL */
|
|
1226
|
+
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 })) : []
|
|
1227
|
+
const editBlock = (o) => {
|
|
1228
|
+
const kind = editKindOf(o)
|
|
1229
|
+
if (!canEdit || !kind) return null
|
|
1230
|
+
const tooBig = (o.size ?? 0) > EDIT_CAP
|
|
1231
|
+
return (
|
|
1232
|
+
<div className="flex items-center gap-3">
|
|
1233
|
+
<Button variant="secondary" size="sm" iconLeft="edit" disabled={tooBig} onClick={() => openEditor(o)}>Edit</Button>
|
|
1234
|
+
{tooBig && <span className="kol-mono-12 text-fg-48">Over 1 MB — too large to edit here</span>}
|
|
1235
|
+
{!tooBig && draftKeys.has(o.key) && <span className="kol-mono-12 text-fg-48">Unsaved draft</span>}
|
|
1236
|
+
</div>
|
|
1237
|
+
)
|
|
1238
|
+
}
|
|
1239
|
+
/* what the preview pane adds under a file's or a folder's facts, in every view */
|
|
1169
1240
|
const detailsFor = (o) => {
|
|
1170
|
-
const
|
|
1171
|
-
const
|
|
1172
|
-
const
|
|
1173
|
-
if (!
|
|
1241
|
+
const path = o.key ?? o
|
|
1242
|
+
const tags = tagsOf(path)
|
|
1243
|
+
const edit = typeof o === 'object' ? editBlock(o) : null
|
|
1244
|
+
if (!edit && !canTag && !canFavourite && !tags.length) return null
|
|
1174
1245
|
return (
|
|
1175
1246
|
<div className="flex flex-col gap-4">
|
|
1176
|
-
{
|
|
1247
|
+
{(edit || canFavourite) && (
|
|
1177
1248
|
<div className="flex items-center gap-3">
|
|
1178
|
-
|
|
1179
|
-
|
|
1249
|
+
{canFavourite && (
|
|
1250
|
+
<Tooltip label={isFavourite(path) ? 'Remove from favourites' : 'Add to favourites'}>
|
|
1251
|
+
<Button variant="nav" size="sm" iconOnly={isFavourite(path) ? 'star-solid' : 'star'} onClick={() => toggleFavourite(path)}
|
|
1252
|
+
aria-label={isFavourite(path) ? 'Remove from favourites' : 'Add to favourites'} aria-pressed={isFavourite(path)} />
|
|
1253
|
+
</Tooltip>
|
|
1254
|
+
)}
|
|
1255
|
+
{edit}
|
|
1180
1256
|
</div>
|
|
1181
1257
|
)}
|
|
1182
|
-
{(canTag || tags.length > 0) && <TagEditor tags={tags} onChange={canTag ? (next) => saveTags(
|
|
1258
|
+
{(canTag || tags.length > 0) && <TagEditor tags={tags} suggestions={allTags} onChange={canTag ? (next) => saveTags(path, next) : undefined} />}
|
|
1183
1259
|
</div>
|
|
1184
1260
|
)
|
|
1185
1261
|
}
|
|
1186
1262
|
const doTags = async (keys) => {
|
|
1187
1263
|
const one = keys.length === 1
|
|
1188
1264
|
const current = one ? tagsOf(keys[0]).join(', ') : ''
|
|
1189
|
-
const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length}
|
|
1265
|
+
const answer = await modal.prompt(one ? 'Tags, separated by commas:' : `Add tags to ${keys.length} items, separated by commas:`, current, { okLabel: 'Save' })
|
|
1190
1266
|
if (answer == null) return
|
|
1191
1267
|
const typed = answer.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
1192
1268
|
for (const key of keys) await saveTags(key, one ? typed : [...new Set([...tagsOf(key), ...typed])])
|
|
1193
1269
|
}
|
|
1270
|
+
/* SMART FOLDERS — `smartFolders(bucket)` · `saveSmartFolder(sf, bucket)` · `deleteSmartFolder(id)` */
|
|
1271
|
+
const canSmart = typeof client?.smartFolders === 'function'
|
|
1272
|
+
const [smartFolders, setSmartFolders] = useState([])
|
|
1273
|
+
const [activeSmart, setActiveSmart] = useState(null)
|
|
1274
|
+
const loadSmart = () => { if (canSmart) Promise.resolve(client.smartFolders(bucketKey)).then((l) => setSmartFolders(l ?? [])).catch(() => {}) }
|
|
1275
|
+
useEffect(() => { setActiveSmart(null); loadSmart() }, [bucketKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1276
|
+
useEffect(() => { if (smartFolder && smartFolders.some((f) => f.id === smartFolder)) setActiveSmart(smartFolder) }, [smartFolder, smartFolders])
|
|
1277
|
+
const activeSmartFolder = smartFolders.find((f) => f.id === activeSmart) ?? null
|
|
1278
|
+
const saveSmart = async () => {
|
|
1279
|
+
const name = await modal.prompt('Smart folder name:', '', { okLabel: 'Next' })
|
|
1280
|
+
if (!name?.trim()) return
|
|
1281
|
+
const tags = await modal.prompt('Tags every file must carry, separated by commas (blank for any):', '', { okLabel: 'Next' })
|
|
1282
|
+
if (tags == null) return
|
|
1283
|
+
const kinds = await modal.prompt('Kinds, separated by commas — image, video, audio, markdown, text, json, pdf… (blank for all):', '', { okLabel: 'Save' })
|
|
1284
|
+
if (kinds == null) return
|
|
1285
|
+
const split = (v) => v.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
|
1286
|
+
runAction(async () => {
|
|
1287
|
+
const row = await client.saveSmartFolder({ name: name.trim(), query: { tags: split(tags), kinds: split(kinds), text: '' } }, bucketKey)
|
|
1288
|
+
loadSmart()
|
|
1289
|
+
if (row?.id) setActiveSmart(row.id)
|
|
1290
|
+
})
|
|
1291
|
+
}
|
|
1292
|
+
const deleteSmart = async (sf) => {
|
|
1293
|
+
if (!(await modal.confirm(`Delete the smart folder "${sf.name}"? The files stay where they are.`, { okLabel: 'Delete' }))) return
|
|
1294
|
+
runAction(async () => { await client.deleteSmartFolder(sf.id, bucketKey); if (activeSmart === sf.id) setActiveSmart(null); loadSmart() })
|
|
1295
|
+
}
|
|
1194
1296
|
const [busyAction, setBusyAction] = useState(false)
|
|
1195
1297
|
|
|
1196
1298
|
const runAction = async (fn) => {
|
|
@@ -1214,7 +1316,9 @@ export function MediaLibraryBrowse({
|
|
|
1214
1316
|
const name = await modal.prompt(`Rename ${isFolder ? 'folder' : 'file'}:`, current, { okLabel: 'Rename' })
|
|
1215
1317
|
if (!name?.trim() || name === current) return
|
|
1216
1318
|
const parent = path.replace(/\/$/, '').slice(0, path.replace(/\/$/, '').length - current.length)
|
|
1217
|
-
|
|
1319
|
+
const to = `${parent}${name.trim()}${isFolder ? '/' : ''}`
|
|
1320
|
+
/* a draft is keyed by path — it follows the file (or everything under the folder) */
|
|
1321
|
+
runAction(async () => { await fileActions.rename(path, to); moveDrafts(bucketKey, path, to) })
|
|
1218
1322
|
}
|
|
1219
1323
|
const doMove = async (path) => {
|
|
1220
1324
|
const dest = await modal.prompt(`Move into which folder? (blank = bucket root)`, prefix, { okLabel: 'Move' })
|
|
@@ -1227,6 +1331,7 @@ export function MediaLibraryBrowse({
|
|
|
1227
1331
|
* view: expanded in place in the rows, navigated into in the columns and the grid. */
|
|
1228
1332
|
const afterMove = (paths, dest) => {
|
|
1229
1333
|
const moved = paths.map((p) => `${dest}${p.replace(/\/$/, '').split('/').pop()}${p.endsWith('/') ? '/' : ''}`)
|
|
1334
|
+
paths.forEach((p, i) => moveDrafts(bucketKey, p, moved[i]))
|
|
1230
1335
|
setPickedFile(null); setPickedFolder(null)
|
|
1231
1336
|
setRowSelection(new Set(moved))
|
|
1232
1337
|
selectAnchorRef.current = moved[0] ?? null
|
|
@@ -1419,6 +1524,7 @@ export function MediaLibraryBrowse({
|
|
|
1419
1524
|
const inView = q.files.filter((o) => rowSelection.has(o.key))
|
|
1420
1525
|
const sel = inView.length === rowSelection.size ? inView : objects.filter((o) => rowSelection.has(o.key))
|
|
1421
1526
|
const current = q.files[q.index]
|
|
1527
|
+
if (current?.key) logEvent('opened', current.key)
|
|
1422
1528
|
if (sel.length > 1) {
|
|
1423
1529
|
const index = Math.max(0, sel.findIndex((o) => o.key === current?.key))
|
|
1424
1530
|
setQuickLook({ files: sel, index })
|
|
@@ -1623,6 +1729,17 @@ export function MediaLibraryBrowse({
|
|
|
1623
1729
|
setQuickLook({ files, index: at === -1 ? 0 : at })
|
|
1624
1730
|
}, [rowSelection]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1625
1731
|
const columnsRef = useRef(null)
|
|
1732
|
+
/* the view box (whichever view is mounted) and the page root — what `fill` measures */
|
|
1733
|
+
const [viewEl, viewRef] = useState(null)
|
|
1734
|
+
/* stable, so the columns box is not re-attached (null, then itself) on every render */
|
|
1735
|
+
const columnsBox = useCallback((el) => { columnsRef.current = el; viewRef(el) }, [])
|
|
1736
|
+
const pageRef = useRef(null)
|
|
1737
|
+
const fill = settings.columnHeight === 'fill'
|
|
1738
|
+
const fillH = useFillHeight(fill, viewEl, pageRef)
|
|
1739
|
+
/* a drag in fill mode is the SESSION's, never saved — one height on every reload (apps/media's ruling) */
|
|
1740
|
+
const [dragH, setDragH] = useState(null)
|
|
1741
|
+
const colH = fill ? (dragH ?? fillH ?? SETTINGS_BASE.columnHeight) : (settings.columnHeight ?? SETTINGS_BASE.columnHeight)
|
|
1742
|
+
const onColH = (px) => (fill ? setDragH(px) : setSettings({ ...settings, columnHeight: px }))
|
|
1626
1743
|
/* `flat` IS THE WALL'S, NOT THIS PAGE'S (user 2026-09-22: "there is no option to switch to flat
|
|
1627
1744
|
* mode, bc its not in files view. this is row mode.. this makes no sense"). It reached here from
|
|
1628
1745
|
* kol-r2b2's single surface and did two things that cannot be defended in a TREE: it struck every
|
|
@@ -1681,7 +1798,7 @@ export function MediaLibraryBrowse({
|
|
|
1681
1798
|
return <KindPreview o={o} urlOf={(x) => mediaUrl(x.key)} poster={poster ? mediaUrl(poster) : undefined} kindOf={kindOf} kindLabel={KIND_LABEL} />
|
|
1682
1799
|
}} />
|
|
1683
1800
|
) : previewFolder ? (
|
|
1684
|
-
<FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} />
|
|
1801
|
+
<FolderPreview key={previewFolder} path={previewFolder} objects={objects} width={previewWidth} formatDate={formatDate} details={detailsFor(previewFolder)} />
|
|
1685
1802
|
) : previewRoot ? (
|
|
1686
1803
|
<ContainerPreview key={previewRoot.key} name={previewRoot.name} icon={previewRoot.icon} facts={previewRoot.facts} width={previewWidth} />
|
|
1687
1804
|
) : (
|
|
@@ -1797,6 +1914,9 @@ export function MediaLibraryBrowse({
|
|
|
1797
1914
|
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
1915
|
const filterKinds = [...new Set(filterItems.map((o) => o.kind))].sort()
|
|
1799
1916
|
const filterTags = [...new Set(filterItems.flatMap((o) => o.tags ?? []))].sort()
|
|
1917
|
+
/* favourites as a one-chip group: ContentFilters matches a value, so a starred file carries one */
|
|
1918
|
+
const filterItemsFav = filterItems.map((o) => ({ ...o, starred: o.favourite ? 'favourite' : '' }))
|
|
1919
|
+
const anyStarred = filterItemsFav.some((o) => o.starred)
|
|
1800
1920
|
|
|
1801
1921
|
/* ── THE BODY, one of four (2026-09-22) ────────────────────────────────────────────────────
|
|
1802
1922
|
* `treeBody` draws the columns or the rows over whatever object list it is handed — the whole
|
|
@@ -1835,12 +1955,12 @@ export function MediaLibraryBrowse({
|
|
|
1835
1955
|
const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
|
|
1836
1956
|
return (
|
|
1837
1957
|
folderView === 'columns' || phone ? (
|
|
1838
|
-
<div ref={
|
|
1958
|
+
<div ref={columnsBox} className="relative">
|
|
1839
1959
|
<ColumnBrowser
|
|
1840
1960
|
autoFocus={autoFocus}
|
|
1841
1961
|
className={atTitleRoot ? 'is-root' : ''}
|
|
1842
|
-
height={
|
|
1843
|
-
onHeightChange={
|
|
1962
|
+
height={colH}
|
|
1963
|
+
onHeightChange={onColH}
|
|
1844
1964
|
columnWidths={settings.columnWidths}
|
|
1845
1965
|
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
1846
1966
|
objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
@@ -1924,7 +2044,7 @@ export function MediaLibraryBrowse({
|
|
|
1924
2044
|
* the same line — the count line under them jumps otherwise, and a consumer's fill height
|
|
1925
2045
|
* is ONE value for both — so the header comes out of this view's budget rather than being
|
|
1926
2046
|
* added to it. The pane takes what is left. */
|
|
1927
|
-
<div className="flex flex-col gap-2" style={{ height:
|
|
2047
|
+
<div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
|
|
1928
2048
|
{/* the header labels the LIST, so it spans the list and stops where the preview starts */}
|
|
1929
2049
|
{rowCols && (
|
|
1930
2050
|
<RowHeader sortBy={settings.sortBy} sortDir={settings.sortDir} width={showRowPreview ? `calc(100% - ${previewWidth}px)` : '100%'}
|
|
@@ -2034,7 +2154,7 @@ export function MediaLibraryBrowse({
|
|
|
2034
2154
|
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
2035
2155
|
)
|
|
2036
2156
|
const wallPane = (files) => (
|
|
2037
|
-
<div className="flex flex-col gap-2" style={{ height:
|
|
2157
|
+
<div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
|
|
2038
2158
|
<div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
2039
2159
|
{/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
|
|
2040
2160
|
* there were tiles — the grid is as tall as its tiles, the pane is the whole frame). The rows'
|
|
@@ -2058,7 +2178,7 @@ export function MediaLibraryBrowse({
|
|
|
2058
2178
|
/* THE GRID DRAWS THE LEVEL'S SUBFOLDERS, ahead of the files (same source: kol-client-olina
|
|
2059
2179
|
* 2026-09-23 — `projects/` read `0 files` over a blank pane). `flat` is the subtree's files
|
|
2060
2180
|
* and no folders, as it was. */
|
|
2061
|
-
folders={settings.flat ? [] : folders.map((f) => prefix + f)}
|
|
2181
|
+
folders={settings.flat || smartMatches ? [] : folders.map((f) => prefix + f)}
|
|
2062
2182
|
onOpenFolder={goFolder}
|
|
2063
2183
|
onPickFolder={(path, e) => { if (selectRow(path, e, [...folders.map((f) => prefix + f), ...files.map((f) => f.key)])) return; rowPickFolder(path) }}
|
|
2064
2184
|
onFolderContextMenu={(e, path) => menu.openAt(e, { type: 'folder', path, targets: targetsFor(path) })}
|
|
@@ -2103,12 +2223,24 @@ export function MediaLibraryBrowse({
|
|
|
2103
2223
|
pickFile(o)
|
|
2104
2224
|
}
|
|
2105
2225
|
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2226
|
+
/* A SMART FOLDER IS A SAVED QUERY (media D1 plan v2) — tags it must carry, kinds it may be, text
|
|
2227
|
+
* in its path — shown flat, every match wherever it lives, as Finder's smart folders are. */
|
|
2228
|
+
const smartMatches = activeSmartFolder ? objects.filter((o) => {
|
|
2229
|
+
const q = activeSmartFolder.query ?? {}
|
|
2230
|
+
if (isSystemFile(o.key)) return false
|
|
2231
|
+
if ((q.tags ?? []).some((t) => !(o.tags ?? []).includes(t))) return false
|
|
2232
|
+
if ((q.kinds ?? []).length && !q.kinds.includes(kindOf(o))) return false
|
|
2233
|
+
if (q.text && !o.key.toLowerCase().includes(q.text.toLowerCase())) return false
|
|
2234
|
+
return true
|
|
2235
|
+
}).map((o) => ({ ...o, kind: kindOf(o), poster: posterFor(o.key, keySet), displayKey: o.key })) : null
|
|
2236
|
+
const body = (filtered) => (smartMatches
|
|
2237
|
+
? wallPane(filtered ?? smartMatches)
|
|
2238
|
+
: isWall
|
|
2239
|
+
? wallPane(filtered ?? wallFiles)
|
|
2240
|
+
: treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
|
|
2109
2241
|
|
|
2110
2242
|
return (
|
|
2111
|
-
<div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
2243
|
+
<div ref={pageRef} {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
2112
2244
|
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
|
|
2113
2245
|
onHome={goTitleRoot}
|
|
2114
2246
|
onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
|
|
@@ -2297,12 +2429,15 @@ export function MediaLibraryBrowse({
|
|
|
2297
2429
|
)}
|
|
2298
2430
|
{quickLook && (
|
|
2299
2431
|
<MediaInspector files={quickLook.files} index={quickLook.index} onClose={closeQuickLook} mediaUrl={mediaUrl} downloadUrl={downloadUrl} keySet={keySet} showNav={rowSelection.size > 1}
|
|
2300
|
-
onEdit={canEdit ? (f) => { closeQuickLook();
|
|
2432
|
+
onEdit={canEdit ? (f) => { closeQuickLook(); openEditor(f) } : undefined}
|
|
2301
2433
|
onPrev={() => stepQuickLook(-1)} onNext={() => stepQuickLook(1)} />
|
|
2302
2434
|
)}
|
|
2303
|
-
{editing && (
|
|
2304
|
-
<
|
|
2305
|
-
|
|
2435
|
+
{editing?.mode === 'edit' && (
|
|
2436
|
+
<FileEditorHost o={editing.o} client={client} bucket={bucketKey} onClose={() => setEditing(null)} onSaved={afterEdit} assets={attachables} />
|
|
2437
|
+
)}
|
|
2438
|
+
{editing?.mode === 'new' && (
|
|
2439
|
+
<DocumentEditor mode="new" folder={editing.folder} draft={{ bucket: bucketKey, key: `${editing.folder}.new-document` }} assets={attachables}
|
|
2440
|
+
onCreate={createDocument} onClose={() => setEditing(null)} />
|
|
2306
2441
|
)}
|
|
2307
2442
|
{settingsOpen && (
|
|
2308
2443
|
<MediaSettings bucketMeta={bucketMeta} settings={settings} profile={profile} onChange={setSettings} onReset={() => setSettings(null)} onClose={() => setSettingsOpen(false)} settingsFooter={settingsFooter} />
|
|
@@ -2311,16 +2446,28 @@ export function MediaLibraryBrowse({
|
|
|
2311
2446
|
<TrashPanel trash={trash} onClose={() => setTrashOpen(false)} run={runAction} confirm={modal.confirm} />
|
|
2312
2447
|
)}
|
|
2313
2448
|
|
|
2449
|
+
{/* SMART FOLDERS — one chip each; a chip shows its matches flat in place of the body */}
|
|
2450
|
+
{canSmart && (smartFolders.length > 0 || canWrite) && !atTitleRoot && (
|
|
2451
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
2452
|
+
<span className="kol-mono-12 text-fg-48">Smart folders</span>
|
|
2453
|
+
{smartFolders.map((sf) => (
|
|
2454
|
+
<Tag key={sf.id} variant="secondary" size="sm" hash={false} active={activeSmart === sf.id}
|
|
2455
|
+
onClick={() => setActiveSmart((a) => (a === sf.id ? null : sf.id))}
|
|
2456
|
+
onRemove={canWrite ? () => deleteSmart(sf) : undefined}>{sf.name}</Tag>
|
|
2457
|
+
))}
|
|
2458
|
+
{canWrite && <Button variant="nav" size="xs" iconLeft="plus" onClick={saveSmart}>New smart folder</Button>}
|
|
2459
|
+
</div>
|
|
2460
|
+
)}
|
|
2314
2461
|
{/* THE FILTER BAR, above whichever body is mounted (2026-09-22). It filters FILES, so the
|
|
2315
2462
|
* tree is scoped to the keys it leaves and the wall renders them directly. Off by default
|
|
2316
2463
|
* — the funnel in the crumb row turns it on. */}
|
|
2317
2464
|
{filtersOn ? (
|
|
2318
2465
|
<ContentFilters
|
|
2319
|
-
items={
|
|
2466
|
+
items={filterItemsFav}
|
|
2320
2467
|
title="Files"
|
|
2321
2468
|
totalCount={filterItems.length}
|
|
2322
2469
|
searchKeys={['displayKey']}
|
|
2323
|
-
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : [])]}
|
|
2470
|
+
filterGroups={[{ label: 'Kind', key: 'kind', values: filterKinds }, ...(filterTags.length ? [{ label: 'Tags', key: 'tags', values: filterTags }] : []), ...(anyStarred ? [{ label: 'Favourites', key: 'starred', values: ['favourite'] }] : [])]}
|
|
2324
2471
|
mutuallyExclusiveFilters={['kind']}
|
|
2325
2472
|
/* ONE FUNNEL, NOT TWO (2026-09-22): the crumb row's funnel mounts this bar AND opens
|
|
2326
2473
|
* its panel, and the bar's own funnel closes the bar — the same switch from either
|
|
@@ -2419,7 +2566,13 @@ export function MediaLibraryBrowse({
|
|
|
2419
2566
|
New folder{isFolder ? ` in ${target.path.replace(/\/$/, '').split('/').pop()}` : ''}
|
|
2420
2567
|
</MenuDropdownItem>
|
|
2421
2568
|
)}
|
|
2422
|
-
{
|
|
2569
|
+
{/* NEW DOCUMENT is the writing page (a name, a type, fields for markdown); New file stays for
|
|
2570
|
+
a client that cannot write text — it makes the empty file and nothing else */}
|
|
2571
|
+
{canNewDoc ? (
|
|
2572
|
+
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => newDocument(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2573
|
+
New document…
|
|
2574
|
+
</MenuDropdownItem>
|
|
2575
|
+
) : fileActions.createFile && (
|
|
2423
2576
|
<MenuDropdownItem iconLeft={<Icon name="file" size={14} />} onClick={() => doCreateFile(isFolder ? target.path : (isLevel ? target.path : prefix))}>
|
|
2424
2577
|
New file
|
|
2425
2578
|
</MenuDropdownItem>
|
|
@@ -2446,9 +2599,17 @@ export function MediaLibraryBrowse({
|
|
|
2446
2599
|
{target.type === 'file' && (
|
|
2447
2600
|
<MenuDropdownItem iconLeft={<Icon name="download" size={14} />} onClick={() => doDownload([target.path])}>Download</MenuDropdownItem>
|
|
2448
2601
|
)}
|
|
2449
|
-
{
|
|
2602
|
+
{!isLevel && canTag && (
|
|
2450
2603
|
<MenuDropdownItem iconLeft={<Icon name="hash-01" size={14} />} onClick={() => doTags([target.path])}>Tags…</MenuDropdownItem>
|
|
2451
2604
|
)}
|
|
2605
|
+
{!isLevel && canFavourite && (
|
|
2606
|
+
<MenuDropdownItem iconLeft={<Icon name={isFavourite(target.path) ? 'star-solid' : 'star'} size={14} />} onClick={() => toggleFavourite(target.path)}>
|
|
2607
|
+
{isFavourite(target.path) ? 'Remove from favourites' : 'Add to favourites'}
|
|
2608
|
+
</MenuDropdownItem>
|
|
2609
|
+
)}
|
|
2610
|
+
{target.type === 'file' && canEdit && editKindOf(target.o ?? { key: target.path }) && (
|
|
2611
|
+
<MenuDropdownItem iconLeft={<Icon name="edit" size={14} />} onClick={() => openEditor(objects.find((o) => o.key === target.path) ?? { key: target.path })}>Edit</MenuDropdownItem>
|
|
2612
|
+
)}
|
|
2452
2613
|
{!isLevel && fileActions.remove && (
|
|
2453
2614
|
<>
|
|
2454
2615
|
<MenuDropdownDivider />
|