files: a node kind of their own, in the tree beside the notes

A file node points at one media row and owns it the way a document owns
what it embeds, so folder deletes, the media browser and exports keep
working. POST /api/nodes/{id}/files streams uploads into a folder;
imports turn non-markdown files into file nodes; exports put them at
their tree path. The browser previews images, video, audio and text,
offers the rest as a download, and takes files dropped on the list.

Migration 0010 widens the kind CHECK by editing the stored schema, as
SQLite documents for loosening a constraint: sqlx runs migrations in a
transaction, where foreign keys cannot be switched off for a rebuild.
This commit is contained in:
nvrl committed 2026-10-19 15:44:30 +02:00
1 parent 3d6944c860
commit 2103edc299
15 files changed
+808 -94

No files matched your search

+15 -1
View File
@@ -6,6 +6,7 @@
import Board from './lib/Board.svelte'
import CommandLine from './lib/CommandLine.svelte'
import DocumentPane from './lib/DocumentPane.svelte'
import FilePane from './lib/FilePane.svelte'
import Help from './lib/Help.svelte'
import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte'
@@ -29,6 +30,7 @@
let cmdline = $state<string | null>(null)
let cmdGlobal = $state(false)
let fileInput = $state<HTMLInputElement>()
let treeInput = $state<HTMLInputElement>()
let folderInput = $state<HTMLInputElement>()
let importInput = $state<HTMLInputElement>()
let shell = $state<HTMLDivElement>()
@@ -43,6 +45,7 @@
$effect(() => {
hooks.pickFiles = () => fileInput?.click()
hooks.pickTreeFiles = () => treeInput?.click()
hooks.pickFolder = () => folderInput?.click()
hooks.pickArchive = () => importInput?.click()
hooks.focusList = () => shell?.focus()
@@ -118,6 +121,14 @@
})
}
async function put(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (files.length === 0) return
await workspace.guard(() => workspace.putFiles(files))
}
/*
* A folder upload is the same multipart body as any other, except that each
* file carries the path it had inside the folder — which is what makes the
@@ -644,7 +655,9 @@
class:focused={!!workspace.open && workspace.pane === 'document'}
onpointerdown={() => workspace.open && (workspace.pane = 'document')}
>
{#if workspace.open}
{#if workspace.open?.kind === 'file'}
<FilePane />
{:else if workspace.open}
<DocumentPane />
{:else}
<div class="placeholder">
@@ -686,6 +699,7 @@
{/if}
<input class="hidden" type="file" multiple bind:this={fileInput} onchange={attach} />
<input class="hidden" type="file" multiple bind:this={treeInput} onchange={put} />
<!-- `webkitdirectory` is set from script: it is the one attribute browsers
agree on for picking a folder, and no typed attribute list carries it. -->
<input class="hidden" type="file" multiple bind:this={folderInput} onchange={importFolder} />