mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
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:
1 parent
3d6944c860
commit
2103edc299
15 files changed
+808
-94
No files matched your search
@@ -0,0 +1,221 @@
|
||||
<script lang="ts">
|
||||
import { hooks, runCommand, type ScrollAmount } from './commands'
|
||||
import Icon from './Icon.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatBytes, formatStamp, relative, scrollPage } from './util'
|
||||
|
||||
const node = $derived(workspace.open!)
|
||||
const file = $derived(node.file!)
|
||||
const kind = $derived(file.mime.split('/')[0])
|
||||
/* What the browser can show by itself; everything else is a download. */
|
||||
const viewable = $derived(file.mime !== 'application/octet-stream')
|
||||
|
||||
/*
|
||||
* Plain text is shown inline, but only its beginning: a log file can run to
|
||||
* gigabytes, and the download has the rest.
|
||||
*/
|
||||
const TEXT_PREVIEW = 256 * 1024
|
||||
let text = $state<string | null>(null)
|
||||
$effect(() => {
|
||||
text = null
|
||||
if (file.mime !== 'text/plain') return
|
||||
const url = file.url
|
||||
void head(url, TEXT_PREVIEW).then((body) => {
|
||||
if (body !== null && url === file.url) text = body
|
||||
})
|
||||
})
|
||||
|
||||
/** The first `limit` bytes of a download, as text; the rest is never fetched. */
|
||||
async function head(url: string, limit: number): Promise<string | null> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok || !response.body) return null
|
||||
const reader = response.body.getReader()
|
||||
const bytes = new Uint8Array(limit)
|
||||
let size = 0
|
||||
while (size < limit) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const take = value.subarray(0, limit - size)
|
||||
bytes.set(take, size)
|
||||
size += take.length
|
||||
}
|
||||
void reader.cancel()
|
||||
return new TextDecoder().decode(bytes.subarray(0, size))
|
||||
}
|
||||
|
||||
let reader = $state<HTMLElement>()
|
||||
|
||||
// Same arrangement as the document pane: j/k scroll whatever is shown here.
|
||||
$effect(() => {
|
||||
const scroll = (amount: ScrollAmount) => {
|
||||
if (!reader) return false
|
||||
scrollPage(reader, amount)
|
||||
return true
|
||||
}
|
||||
hooks.scrollReader = scroll
|
||||
return () => {
|
||||
if (hooks.scrollReader === scroll) hooks.scrollReader = () => false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<section class="pane">
|
||||
<header>
|
||||
<div class="crumb label rule" title="{workspace.here}/{node.slug}">
|
||||
<button class="back" aria-label="back to the list" onclick={() => runCommand('q')}>
|
||||
<Icon name="back" size={16} />
|
||||
</button>
|
||||
<span class="marker"></span>
|
||||
<span class="path">{workspace.here}/{node.slug}</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<dl class="meta">
|
||||
<dt>size</dt>
|
||||
<dd>{formatBytes(file.size)}</dd>
|
||||
<dt>type</dt>
|
||||
<dd>{file.mime}</dd>
|
||||
<dt>uploaded</dt>
|
||||
<dd title={formatStamp(node.created_at)}>{relative(node.created_at)}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
{#if viewable}
|
||||
<a class="button" href={file.url} target="_blank" rel="noopener">open</a>
|
||||
{/if}
|
||||
<a class="button" href={file.url} download={node.name}>download</a>
|
||||
<button class="danger" onclick={() => runCommand('d')}>delete</button>
|
||||
<button class="close" onclick={() => runCommand('q')}>close</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="body" bind:this={reader}>
|
||||
<h1 class="name">{node.name}</h1>
|
||||
{#key file.url}
|
||||
{#if kind === 'image'}
|
||||
<img src={file.url} alt={node.name} />
|
||||
{:else if kind === 'video'}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={file.url} controls playsinline preload="metadata"></video>
|
||||
{:else if kind === 'audio'}
|
||||
<audio src={file.url} controls preload="metadata"></audio>
|
||||
{:else if text !== null}
|
||||
<pre>{text}</pre>
|
||||
{#if file.size > TEXT_PREVIEW}
|
||||
<p class="muted">the first {formatBytes(TEXT_PREVIEW)} of {formatBytes(file.size)} — download it for the rest.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="muted">
|
||||
no preview for {file.mime} —
|
||||
{#if viewable}<a href={file.url} target="_blank" rel="noopener">open it</a> or{/if}
|
||||
<a href={file.url} download={node.name}>download it</a>.
|
||||
</p>
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 16px 24px 12px 28px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.crumb { min-width: 0; white-space: nowrap; }
|
||||
.path {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--subtle);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
min-width: 0;
|
||||
}
|
||||
.meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.meta dd { margin: 0 10px 0 0; color: var(--fg); }
|
||||
|
||||
.actions { display: flex; gap: 4px; flex-wrap: wrap; flex: none; }
|
||||
.actions .danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); }
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 18px 28px 30vh;
|
||||
}
|
||||
.name {
|
||||
margin: 0 0 18px;
|
||||
color: var(--light);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
img, video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
border-radius: var(--radius-m);
|
||||
}
|
||||
audio { width: 100%; max-width: 46rem; }
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--rim);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--surface);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.back { display: none; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
header { padding: 10px 14px 10px 8px; gap: 8px; }
|
||||
.crumb { gap: 8px; }
|
||||
.back {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: var(--light);
|
||||
}
|
||||
.row { flex-direction: column; align-items: stretch; gap: 10px; padding-left: 6px; }
|
||||
.actions { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; }
|
||||
.actions :is(button, .button) { height: 36px; }
|
||||
.actions .close { display: none; }
|
||||
.body { padding: 14px 16px 30vh; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { NodeSummary } from './api'
|
||||
import { isContainer, type NodeSummary } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay, formatTime, formatWeekday } from './util'
|
||||
import { formatBytes, formatDay, formatTime, formatWeekday } from './util'
|
||||
|
||||
let list = $state<HTMLUListElement>()
|
||||
/** Files are being dragged over the list, which takes them into this folder. */
|
||||
let dropping = $state(false)
|
||||
|
||||
// Keep the cursor line visible while navigating with j/k.
|
||||
$effect(() => {
|
||||
@@ -16,9 +18,23 @@
|
||||
return node.name.trim() || node.excerpt.trim() || node.slug
|
||||
}
|
||||
|
||||
/* A folder is listed by what is in it; a document by when it was written. */
|
||||
/* A folder is listed by what is in it, a document by when it was written, a file by its size. */
|
||||
const aside = (node: NodeSummary) =>
|
||||
node.kind === 'document' ? formatTime(node.created_at) : `${node.child_count}`
|
||||
node.file
|
||||
? formatBytes(node.file.size)
|
||||
: node.kind === 'document'
|
||||
? formatTime(node.created_at)
|
||||
: `${node.child_count}`
|
||||
|
||||
const carriesFiles = (event: DragEvent) => event.dataTransfer?.types.includes('Files') ?? false
|
||||
|
||||
function drop(event: DragEvent) {
|
||||
dropping = false
|
||||
const files = Array.from(event.dataTransfer?.files ?? [])
|
||||
if (files.length === 0 || workspace.query) return
|
||||
event.preventDefault()
|
||||
void workspace.guard(() => workspace.putFiles(files))
|
||||
}
|
||||
|
||||
function pick(index: number) {
|
||||
workspace.cursor = index
|
||||
@@ -26,7 +42,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sidebar">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="sidebar"
|
||||
class:dropping
|
||||
ondragover={(event) => {
|
||||
if (!carriesFiles(event) || workspace.query) return
|
||||
event.preventDefault()
|
||||
dropping = true
|
||||
}}
|
||||
ondragleave={(event) => {
|
||||
if (!(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node | null)) dropping = false
|
||||
}}
|
||||
ondrop={drop}
|
||||
>
|
||||
<div class="head label rule">
|
||||
<span class="marker"></span>
|
||||
<span class="name">{workspace.space?.slug ?? 'diary'}</span>
|
||||
@@ -60,7 +89,7 @@
|
||||
onclick={() => pick(index)}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
<span class="title">{label(node)}{#if node.kind !== 'document'}<span class="slash">/</span>{/if}</span>
|
||||
<span class="title">{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}</span>
|
||||
{#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if}
|
||||
{#if node.shared}<span class="shared" title="shared"></span>{/if}
|
||||
<span class="aside">{aside(node)}</span>
|
||||
@@ -71,7 +100,7 @@
|
||||
{#if workspace.query}
|
||||
no matches
|
||||
{:else}
|
||||
nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder
|
||||
nothing here yet — <span class="kbd">o</span> writes, <span class="kbd">⇧ O</span> makes a folder, files dropped here land here
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
@@ -86,6 +115,9 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Files held over the list: the whole pane is the target. */
|
||||
.sidebar.dropping { outline: 1px dashed var(--light-dim); outline-offset: -6px; }
|
||||
|
||||
.head {
|
||||
flex: none;
|
||||
padding: 16px 16px 10px 18px;
|
||||
|
||||
+37
-2
@@ -1,5 +1,16 @@
|
||||
/** What a node is: a top-level space, a folder inside one, or a document. */
|
||||
export type NodeKind = 'space' | 'folder' | 'document'
|
||||
/** What a node is: a top-level space, a folder inside one, a document, or a file. */
|
||||
export type NodeKind = 'space' | 'folder' | 'document' | 'file'
|
||||
|
||||
/** Whether a node can hold other nodes — the ones `l` walks into. */
|
||||
export const isContainer = (node: { kind: NodeKind }) => node.kind === 'space' || node.kind === 'folder'
|
||||
|
||||
/** The bytes behind a file node. */
|
||||
export interface FileInfo {
|
||||
media_id: string
|
||||
mime: string
|
||||
size: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface NodeSummary {
|
||||
id: number
|
||||
@@ -15,6 +26,8 @@ export interface NodeSummary {
|
||||
updated_at: number
|
||||
shared: boolean
|
||||
share_token: string | null
|
||||
/** Files only. */
|
||||
file: FileInfo | null
|
||||
}
|
||||
|
||||
/** One step of a node's path, the space first and the node itself last. */
|
||||
@@ -268,6 +281,28 @@ export const api = {
|
||||
|
||||
listMedia: () => request<MediaFile[]>('/media'),
|
||||
|
||||
/**
|
||||
* Upload files into a space or folder as file nodes. XHR rather than fetch,
|
||||
* because a gigabyte going up needs to say how far along it is.
|
||||
*/
|
||||
putFiles: (parentId: number, files: File[], progress?: (fraction: number) => void) =>
|
||||
new Promise<WorkspaceNode[]>((resolve, reject) => {
|
||||
const form = new FormData()
|
||||
for (const file of files) form.append('file', file)
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', `/api/nodes/${parentId}/files`)
|
||||
xhr.responseType = 'json'
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) progress?.(event.loaded / event.total)
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response as WorkspaceNode[])
|
||||
else reject(new ApiError(xhr.status, xhr.response?.error ?? xhr.statusText))
|
||||
}
|
||||
xhr.onerror = () => reject(new ApiError(0, 'the upload did not reach the server'))
|
||||
xhr.send(form)
|
||||
}),
|
||||
|
||||
upload: (files: File[]) => {
|
||||
const form = new FormData()
|
||||
for (const file of files) form.append('file', file)
|
||||
|
||||
+10
-5
@@ -1,4 +1,4 @@
|
||||
import { api, type BackupStatus } from './api'
|
||||
import { api, isContainer, type BackupStatus } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay, parseDay, parseDue, type ScrollAmount } from './util'
|
||||
|
||||
@@ -7,6 +7,8 @@ export type { ScrollAmount }
|
||||
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
|
||||
export const hooks = {
|
||||
pickFiles: () => {},
|
||||
/** Files for the folder being browsed, rather than for the document. */
|
||||
pickTreeFiles: () => {},
|
||||
pickFolder: () => {},
|
||||
pickArchive: () => {},
|
||||
focusEditor: () => {},
|
||||
@@ -56,7 +58,7 @@ const findFolder = (target: string) => {
|
||||
const wanted = target.toLowerCase()
|
||||
return workspace.nodes.find(
|
||||
(node) =>
|
||||
node.kind !== 'document' && (node.slug === wanted || node.name.toLowerCase() === wanted),
|
||||
isContainer(node) && (node.slug === wanted || node.name.toLowerCase() === wanted),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -382,6 +384,9 @@ export const commands: CommandSpec[] = [
|
||||
help: 'edit the open document, or open a node by id',
|
||||
run: async (arg) => {
|
||||
const node = arg ? undefined : workspace.target
|
||||
if (node?.kind === 'file') {
|
||||
return workspace.say(`${node.slug} is a file — nothing to write in, :name renames it`, 'error')
|
||||
}
|
||||
if (node && node.kind !== 'document') {
|
||||
return workspace.say(`${node.slug} is a ${node.kind} — l opens it`, 'error')
|
||||
}
|
||||
@@ -455,7 +460,7 @@ export const commands: CommandSpec[] = [
|
||||
const node = workspace.open?.id === id ? workspace.open : workspace.nodes.find((n) => n.id === id)
|
||||
const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}`
|
||||
const what =
|
||||
node && node.kind !== 'document'
|
||||
node && isContainer(node)
|
||||
? `delete the ${node.kind} ${name} and everything in it?`
|
||||
: `delete ${name}?`
|
||||
if (!(await workspace.ask(`${what} this cannot be undone.`))) return
|
||||
@@ -616,8 +621,8 @@ export const commands: CommandSpec[] = [
|
||||
name: 'upload',
|
||||
aliases: ['up'],
|
||||
group: 'editing',
|
||||
help: 'pick files to attach at the cursor',
|
||||
run: () => hooks.pickFiles(),
|
||||
help: 'while writing, attach files at the cursor — otherwise put files into this folder',
|
||||
run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles()),
|
||||
},
|
||||
{
|
||||
name: 'media',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
isContainer,
|
||||
type Agenda,
|
||||
type AgendaCard,
|
||||
type Board,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
type NodeSummary,
|
||||
type SpaceOverview,
|
||||
} from './api'
|
||||
import { formatBytes } from './util'
|
||||
|
||||
export type Pane = 'list' | 'document'
|
||||
/** The tree of documents, or the board of the space being browsed. */
|
||||
@@ -369,7 +371,7 @@ class Workspace {
|
||||
/** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
|
||||
async openPath(path: string) {
|
||||
const node = await api.resolvePath(path)
|
||||
if (node.kind === 'document') {
|
||||
if (!isContainer(node)) {
|
||||
if (node.parent_id !== null) await this.goTo(node.parent_id)
|
||||
await this.openNode(node.id)
|
||||
} else {
|
||||
@@ -503,7 +505,7 @@ class Workspace {
|
||||
if (this.open?.id !== id) await this.flush()
|
||||
|
||||
const node = await api.getNode(id)
|
||||
if (node.kind !== 'document') {
|
||||
if (isContainer(node)) {
|
||||
await this.goTo(node.id)
|
||||
return
|
||||
}
|
||||
@@ -511,7 +513,8 @@ class Workspace {
|
||||
this.open = node
|
||||
this.draft = { name: node.name, body: node.body, created_at: node.created_at }
|
||||
this.dirty = false
|
||||
this.editing = edit
|
||||
// A file has a name and nothing to write in.
|
||||
this.editing = edit && node.kind === 'document'
|
||||
this.pane = 'document'
|
||||
this.#syncUrl(node.path)
|
||||
const index = this.nodes.findIndex((n) => n.id === id)
|
||||
@@ -529,7 +532,7 @@ class Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
/** What Enter does: descend into a folder, open a document. */
|
||||
/** What Enter does: descend into a folder, open a document or a file. */
|
||||
async openSelected(edit = false) {
|
||||
const selected = this.selected
|
||||
if (!selected) return
|
||||
@@ -538,8 +541,8 @@ class Workspace {
|
||||
const node = await api.getNode(selected.id)
|
||||
return this.openPath(node.path.map((crumb) => crumb.slug).join('/'))
|
||||
}
|
||||
if (selected.kind === 'document') await this.openNode(selected.id, edit)
|
||||
else await this.goTo(selected.id)
|
||||
if (isContainer(selected)) await this.goTo(selected.id)
|
||||
else await this.openNode(selected.id, edit)
|
||||
}
|
||||
|
||||
/** A new document is created immediately so uploads have something to attach to. */
|
||||
@@ -1090,6 +1093,26 @@ class Workspace {
|
||||
this.say(`imported ${parts.join(', ')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload files into the folder being browsed, as nodes of their own. The
|
||||
* status line counts up while they go, because a large one takes a while.
|
||||
*/
|
||||
async putFiles(files: File[]) {
|
||||
const parent = this.parentId
|
||||
if (parent === null) return
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0)
|
||||
const what = files.length === 1 ? files[0].name : `${files.length} files`
|
||||
this.say(`uploading ${what} (${formatBytes(total)}) …`)
|
||||
|
||||
const created = await api.putFiles(parent, files, (fraction) =>
|
||||
this.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`),
|
||||
)
|
||||
this.query = ''
|
||||
this.globalSearch = false
|
||||
await this.refresh(created[0]?.id)
|
||||
this.say(created.length === 1 ? `uploaded ${this.here}/${created[0].slug}` : `uploaded ${created.length} files`)
|
||||
}
|
||||
|
||||
async upload(files: File[]): Promise<MediaFile[]> {
|
||||
const uploaded = await api.upload(files)
|
||||
this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`)
|
||||
|
||||
Reference in new issue
Block a user