mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
ui: touch parity, saves that survive outages, edits that do not collide
PUT /api/nodes/{id} takes base_updated_at and answers 409 when the node
was saved since; the editor sends it, so a save from another tab, the
phone or the SFTP mount is no longer overwritten in silence. :w! and
:wq! now force the write.
An autosave the server could not take is kept and retried with backoff,
and at once on the online event; network failures say "the server
cannot be reached" rather than "Failed to fetch". At boot that is no
longer shown as a login screen. A session that expires mid-edit shows
the login with a note, and logging back in resumes the open draft.
Folder listings and the media list are read page by page, so more than
500 children are no longer cut off. Search results carry the space and
folder they sit in, and the overview's recent list includes files.
Embedding uploads report progress, like uploads into a folder.
On touch devices vim is off by default. Each row has an actions sheet
(rename, move, share, seal, delete), folders start with a .. row, and
the dock gains today's entry, upload, search all spaces, drop for a
marked node, help, and the board's list, reorder, rename and delete.
The agenda gets a done button per card and a button for today's entry.
Sharing a folder or a file is refused in the client. :mv .. in a search
moves up from the match's own folder. Files dropped outside the list or
editor no longer navigate the tab away. The public share view renders
/n/ links as plain text, and tells a network error from a dead link.
This commit is contained in:
1 parent
ee13ae44fa
commit
2733ac1a3a
15 files changed
+472
-74
No files matched your search
+19
-2
@@ -11,6 +11,7 @@
|
||||
import Login from './lib/Login.svelte'
|
||||
import Overview from './lib/Overview.svelte'
|
||||
import MediaBrowser from './lib/MediaBrowser.svelte'
|
||||
import Actions from './lib/Actions.svelte'
|
||||
import Prompt from './lib/Prompt.svelte'
|
||||
import SecretPrompt from './lib/SecretPrompt.svelte'
|
||||
import Rail from './lib/Rail.svelte'
|
||||
@@ -65,6 +66,10 @@
|
||||
cmdline = initial
|
||||
}
|
||||
hooks.openOverview = () => void openOverview()
|
||||
hooks.openGlobalSearch = () => {
|
||||
cmdGlobal = true
|
||||
cmdline = '/'
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
@@ -167,6 +172,18 @@
|
||||
await workspace.guard(() => workspace.importFolder(files, root))
|
||||
}
|
||||
|
||||
/*
|
||||
* Files let go anywhere but the list or the editor would make the browser
|
||||
* open them in place of the app. The places that take files say so
|
||||
* themselves; everywhere else refuses the drop, and says where it goes.
|
||||
*/
|
||||
function strayDrop(event: DragEvent) {
|
||||
if (event.defaultPrevented || !event.dataTransfer?.types.includes('Files')) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'none'
|
||||
if (event.type === 'drop') workspace.say('drop files on the list to put them in this folder, or into the editor to embed them', 'error')
|
||||
}
|
||||
|
||||
// What the empty pane offers, in the key/value shape of a status readout.
|
||||
// Keys are keycaps, so `o` and `O` cannot be read as the same key.
|
||||
const offers: [string[], string][] = [
|
||||
@@ -636,7 +653,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window {onkeydown} />
|
||||
<svelte:window {onkeydown} ondragover={strayDrop} ondrop={strayDrop} />
|
||||
|
||||
{#if shareToken}
|
||||
<Shared token={shareToken} shareKey={shareKey} />
|
||||
@@ -718,7 +735,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
||||
>
|
||||
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else}<MediaBrowser />{/if}
|
||||
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else}<MediaBrowser />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* What the keyboard does to the row under the cursor, as buttons: the way a
|
||||
* phone renames, moves, shares, seals or deletes a node. Every button runs
|
||||
* the same command a key would, on the same target, so the two can never
|
||||
* drift apart.
|
||||
*/
|
||||
import { isContainer } from './api'
|
||||
import { hooks, runCommand } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
const node = $derived(workspace.selected)
|
||||
|
||||
type Action = { label: string; run: () => unknown; danger?: boolean }
|
||||
|
||||
const actions = $derived.by((): Action[] => {
|
||||
if (!node) return []
|
||||
const sealed = node.file?.encrypted ?? false
|
||||
const holdsFiles = isContainer(node) || node.kind === 'file'
|
||||
const list: Action[] = [
|
||||
{ label: isContainer(node) ? 'open' : 'read', run: () => workspace.guard(() => workspace.openSelected()) },
|
||||
{ label: 'rename', run: () => hooks.openCommandLine(`:name ${node.name}`) },
|
||||
workspace.marked?.id === node.id
|
||||
? { label: 'put it back — not moving', run: () => workspace.mark() }
|
||||
: { label: 'move — then open a folder and drop it there', run: () => workspace.mark() },
|
||||
]
|
||||
if (node.kind === 'document') {
|
||||
list.push(
|
||||
node.shared
|
||||
? { label: 'stop sharing', run: () => runCommand('unshare') }
|
||||
: { label: 'share — copies the link', run: () => runCommand('share') },
|
||||
)
|
||||
if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('link') })
|
||||
}
|
||||
if (holdsFiles && node.kind !== 'space') {
|
||||
list.push(
|
||||
sealed
|
||||
? { label: 'take out of the vault', run: () => runCommand('decrypt') }
|
||||
: { label: node.kind === 'file' ? 'seal in the vault' : 'seal every file in it', run: () => runCommand('encrypt') },
|
||||
)
|
||||
}
|
||||
list.push({ label: 'delete', run: () => runCommand('delete'), danger: true })
|
||||
return list
|
||||
})
|
||||
|
||||
function act(action: Action) {
|
||||
workspace.overlay = 'none'
|
||||
// The list is what the commands act on while it has the focus.
|
||||
workspace.pane = 'list'
|
||||
void action.run()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sheet">
|
||||
<header>
|
||||
<span class="label rule"><span class="marker"></span><span class="title">{node ? node.name.trim() || node.slug : 'nothing'}</span></span>
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
<ul>
|
||||
{#each actions as action (action.label)}
|
||||
<li><button class:danger={action.danger} onclick={() => act(action)}>{action.label}</button></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
width: min(100%, 48ch);
|
||||
max-height: 100%;
|
||||
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
||||
border: 1px solid var(--surface-alt);
|
||||
border-radius: var(--frame-radius);
|
||||
padding: 18px 20px;
|
||||
animation: resolve var(--t-slow) var(--ease-snap) both;
|
||||
}
|
||||
header { display: flex; align-items: center; gap: 12px; }
|
||||
header .label { flex: 1; min-width: 0; }
|
||||
.title { color: var(--light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; }
|
||||
li button {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
}
|
||||
.danger { color: var(--alarm); }
|
||||
</style>
|
||||
@@ -7,7 +7,9 @@
|
||||
*/
|
||||
import type { AgendaCard } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay, formatWeekday } from './util'
|
||||
import { runCommand } from './commands'
|
||||
import Icon from './Icon.svelte'
|
||||
import { formatDay, formatWeekday, isTouchDevice } from './util'
|
||||
|
||||
let sheet = $state<HTMLDivElement>()
|
||||
|
||||
@@ -36,7 +38,11 @@
|
||||
{#if workspace.agenda.diary_today === false}
|
||||
<p class="diary">
|
||||
<span class="dot"></span> nothing in the diary today
|
||||
<span class="kbd">T</span> writes it
|
||||
{#if isTouchDevice}
|
||||
<button class="write" onclick={() => runCommand('today')}>write it</button>
|
||||
{:else}
|
||||
<span class="kbd">T</span> writes it
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -60,6 +66,12 @@
|
||||
<span class="due">{formatWeekday(card.due_at)} {formatDay(card.due_at).slice(5)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isTouchDevice}
|
||||
<!-- Space, for a thumb. -->
|
||||
<button class="finish" aria-label="done: {card.title}" onclick={() => workspace.guard(() => workspace.finishCard(card))}>
|
||||
<Icon name="check" size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -82,6 +94,9 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
li:has(.finish) { display: flex; align-items: center; }
|
||||
.finish { flex: none; display: grid; place-items: center; width: 44px; height: 40px; padding: 0; border: 0; background: none; color: var(--muted); }
|
||||
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -32,6 +32,15 @@
|
||||
doc: ['M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z', 'M14 3v5h5', 'M9 13h6', 'M9 17h4'],
|
||||
agenda: ['M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z', 'M12 7v5l3 2'],
|
||||
capture: ['M12 5v14', 'M5 12h14'],
|
||||
upload: ['M12 15V4', 'M7 9l5-5 5 5', 'M5 15v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3'],
|
||||
'search-all': ['M10 4a6 6 0 1 0 0 12a6 6 0 1 0 0-12z', 'M19 19l-4.5-4.5', 'M17 4h4', 'M19 2v4'],
|
||||
today: ['M5 6h14a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z', 'M4 10h16', 'M12 13v4', 'M10 15h4'],
|
||||
up: ['M12 19V5', 'M6 11l6-6 6 6'],
|
||||
down: ['M12 5v14', 'M6 13l6 6 6-6'],
|
||||
drop: ['M12 4v11', 'M7 10l5 5 5-5', 'M5 20h14'],
|
||||
rename: ['M4 20h4L19 9l-4-4L4 16z', 'M13 7l4 4'],
|
||||
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
||||
more: ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
|
||||
} satisfies Record<string, string[]>
|
||||
|
||||
export type IconName = keyof typeof PATHS
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
</div>
|
||||
|
||||
<p class="label rule">a private workspace · one user</p>
|
||||
{#if workspace.notice}<p class="notice">{workspace.notice}</p>{/if}
|
||||
|
||||
<label>
|
||||
<span class="label">login</span>
|
||||
@@ -64,6 +65,12 @@
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.notice {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--light-soft);
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
+29
-2
@@ -25,7 +25,7 @@
|
||||
|
||||
const groups = $derived.by((): Door[][] => {
|
||||
const cmd: Door = { icon: 'prompt', label: 'Command line', keys: [':'], run: () => hooks.openCommandLine(':') }
|
||||
const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help'), desk: true }
|
||||
const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help') }
|
||||
|
||||
if (workspace.view === 'board') {
|
||||
// The card verbs are doors too, so a phone — which has no H, L or
|
||||
@@ -34,7 +34,7 @@
|
||||
return [
|
||||
[
|
||||
{ icon: 'doc-plus', label: 'New card on this list', keys: ['o'], run: () => hooks.openCommandLine(':card ') },
|
||||
{ icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list '), desk: true },
|
||||
{ icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') },
|
||||
{ icon: 'list', label: 'Back to the tree', keys: ['b', 'q'], run: () => runCommand('board'), on: true },
|
||||
],
|
||||
[
|
||||
@@ -44,6 +44,22 @@
|
||||
{ icon: 'calendar', label: 'Due date', keys: ['D'], run: () => hooks.openCommandLine(':due '), dim: !card },
|
||||
{ icon: 'doc', label: "The card's notes", keys: ['Enter'], run: () => workspace.guard(() => workspace.openCard()), dim: !card },
|
||||
],
|
||||
[
|
||||
{ icon: 'up', label: 'Card up its list', keys: ['K'], run: () => workspace.guard(() => workspace.nudgeCard(-1)), dim: !card },
|
||||
{ icon: 'down', label: 'Card down its list', keys: ['J'], run: () => workspace.guard(() => workspace.nudgeCard(1)), dim: !card },
|
||||
{
|
||||
icon: 'rename',
|
||||
label: card ? 'Rename the card' : 'Rename the list',
|
||||
keys: ['R'],
|
||||
run: () => hooks.openCommandLine(card ? ':name ' : ':name! '),
|
||||
},
|
||||
{
|
||||
icon: 'trash',
|
||||
label: card ? 'Delete the card' : 'Delete the list',
|
||||
keys: card ? ['x'] : [':list!'],
|
||||
run: () => runCommand(card ? 'delete' : 'list!'),
|
||||
},
|
||||
],
|
||||
[cmd, help],
|
||||
]
|
||||
}
|
||||
@@ -59,8 +75,15 @@
|
||||
],
|
||||
[
|
||||
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: ['t'], run: () => runCommand('agenda') },
|
||||
{ icon: 'today', label: "Today's diary entry", keys: ['T'], run: () => runCommand('today') },
|
||||
{ icon: 'capture', label: 'Capture a card to the inbox', keys: ['c'], run: () => hooks.openCommandLine(':card ') },
|
||||
],
|
||||
[
|
||||
{ icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) },
|
||||
...(workspace.marked
|
||||
? [{ icon: 'drop' as const, label: `Drop ${workspace.marked.name.trim() || workspace.marked.slug} here`, keys: ['p'], run: () => workspace.guard(() => workspace.drop()), on: true }]
|
||||
: []),
|
||||
],
|
||||
[
|
||||
{ icon: 'grid', label: 'Every space', keys: ['w'], run: () => hooks.openOverview() },
|
||||
{
|
||||
@@ -72,6 +95,7 @@
|
||||
desk: true,
|
||||
},
|
||||
{ icon: 'search', label: 'Search this space', keys: ['/'], run: () => hooks.openCommandLine('/') },
|
||||
{ icon: 'search-all', label: 'Search every space', keys: ['Ctrl-P'], run: () => hooks.openGlobalSearch() },
|
||||
],
|
||||
[cmd, help],
|
||||
]
|
||||
@@ -230,6 +254,9 @@
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
}
|
||||
/* More doors than a narrow phone is wide: the row scrolls rather than squeezes. */
|
||||
.dock { overflow-x: auto; scrollbar-width: none; }
|
||||
.dock .door { flex: none; }
|
||||
.dock .door { width: 44px; height: 40px; border-radius: var(--radius-s); }
|
||||
.dock .door::before { display: none; }
|
||||
.dock .door:active { background: var(--surface-alt); }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* in it as two windows, the band along the bottom. No rail — a reader has
|
||||
* no tools — but the reading keys work: j/k, gg/G, Ctrl-d/u.
|
||||
*/
|
||||
import { api, type SharedDocument } from './api'
|
||||
import { api, unreachable, type SharedDocument } from './api'
|
||||
import Clock from './Clock.svelte'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import Outline from './Outline.svelte'
|
||||
@@ -18,7 +18,18 @@
|
||||
let scroller = $state<HTMLElement>()
|
||||
let article = $state<HTMLElement>()
|
||||
let position = $state('All')
|
||||
const html = $derived(doc ? renderMarkdown(doc.body, { token, key: shareKey }) : '')
|
||||
/*
|
||||
* A link to another document is an address inside the private workspace,
|
||||
* and leads a reader to its login page. Those links are kept as their text.
|
||||
*/
|
||||
const html = $derived(
|
||||
doc
|
||||
? renderMarkdown(doc.body, { token, key: shareKey }).replace(
|
||||
/<a href="\/n\/[^"]*"[^>]*>(.*?)<\/a>/g,
|
||||
'<span class="private-link" title="a page of the private workspace">$1</span>',
|
||||
)
|
||||
: '',
|
||||
)
|
||||
const title = $derived(doc?.name.trim() ?? '')
|
||||
const words = $derived(doc ? doc.body.split(/\s+/).filter(Boolean).length : 0)
|
||||
// The outline gets a window of its own only when there is something to outline.
|
||||
@@ -35,7 +46,11 @@
|
||||
doc = data
|
||||
document.title = data.name.trim() || `~/workspace ${formatStamp(data.created_at)}`
|
||||
})
|
||||
.catch(() => (error = 'this link is not valid, or the document is no longer shared.'))
|
||||
.catch((e) =>
|
||||
(error = unreachable(e)
|
||||
? 'the server cannot be reached — try again in a moment.'
|
||||
: 'this link is not valid, or the document is no longer shared.'),
|
||||
)
|
||||
})
|
||||
|
||||
/** Where the view is, the way vim's ruler says it: Top, Bot, All or a percentage. */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { isContainer, type NodeSummary } from './api'
|
||||
import Icon from './Icon.svelte'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatBytes, formatDay, formatTime, formatWeekday } from './util'
|
||||
import { formatBytes, formatDay, formatTime, formatWeekday, isTouchDevice } from './util'
|
||||
|
||||
let list = $state<HTMLUListElement>()
|
||||
/** Files are being dragged over the list, which takes them into this folder. */
|
||||
@@ -41,6 +41,15 @@
|
||||
workspace.cursor = index
|
||||
void workspace.guard(() => workspace.openSelected())
|
||||
}
|
||||
|
||||
/* A tap opens a row, so on a phone the other verbs need a door of their own. */
|
||||
function actions(index: number) {
|
||||
workspace.cursor = index
|
||||
workspace.overlay = 'actions'
|
||||
}
|
||||
|
||||
/* Inside a folder, a phone has no h key and no crumbs on the bar: this is the way up. */
|
||||
const nested = $derived(isTouchDevice && !workspace.query && workspace.path.length > 1)
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -72,6 +81,15 @@
|
||||
{/if}
|
||||
|
||||
<ul bind:this={list}>
|
||||
{#if nested}
|
||||
<li>
|
||||
<button class="row up" onclick={() => workspace.guard(() => workspace.up())}>
|
||||
<span class="dot"></span>
|
||||
<span class="title">..<span class="slash">/</span></span>
|
||||
<span class="aside">up</span>
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{#each workspace.nodes as node, index (node.id)}
|
||||
{@const previous = workspace.nodes[index - 1]}
|
||||
<!-- Day headings only make sense in a list that is a journal: documents,
|
||||
@@ -81,21 +99,33 @@
|
||||
<span><span class="day">{formatDay(node.created_at)}</span> {formatWeekday(node.created_at)}</span>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<li class="item">
|
||||
<button
|
||||
class="row"
|
||||
oncontextmenu={(event) => {
|
||||
event.preventDefault()
|
||||
actions(index)
|
||||
}}
|
||||
class:active={index === workspace.cursor}
|
||||
class:open={workspace.open?.id === node.id}
|
||||
data-index={index}
|
||||
onclick={() => pick(index)}
|
||||
>
|
||||
<span class="dot"></span>
|
||||
<span class="title">{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}</span>
|
||||
<span class="title">
|
||||
{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}
|
||||
{#if workspace.query && node.location}<span class="where">{node.location}</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}
|
||||
{#if node.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if}
|
||||
<span class="aside">{aside(node)}</span>
|
||||
</button>
|
||||
{#if isTouchDevice}
|
||||
<button class="more" aria-label="actions for {label(node)}" onclick={() => actions(index)}>
|
||||
<Icon name="more" size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
{:else}
|
||||
<li class="empty muted">
|
||||
@@ -208,6 +238,9 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
.slash { color: var(--muted); margin-left: 1px; }
|
||||
/* Where a search match sits, so two of the same name can be told apart. */
|
||||
.where { margin-left: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
|
||||
.row.active .where { color: var(--ink); }
|
||||
.aside { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.badge { flex: none; font-size: 11px; font-weight: 700; color: var(--light-dim); }
|
||||
.shared {
|
||||
@@ -220,6 +253,9 @@
|
||||
.sealed { flex: none; display: grid; color: var(--muted); }
|
||||
.row.active .sealed { color: var(--ink); }
|
||||
.empty { padding: 12px 10px; font-size: 12px; }
|
||||
.item { display: flex; align-items: center; }
|
||||
.more { flex: none; display: grid; place-items: center; width: 40px; height: 40px; padding: 0; border: 0; background: none; color: var(--muted); }
|
||||
.up .title { font-family: var(--font-mono); }
|
||||
|
||||
/* Roomier tap targets where there is no keyboard. */
|
||||
@media (pointer: coarse) {
|
||||
|
||||
+76
-34
@@ -32,6 +32,8 @@ export interface NodeSummary {
|
||||
share_token: string | null
|
||||
/** Files only. */
|
||||
file: FileInfo | null
|
||||
/** Search results only: where the match sits, `work/notes`. */
|
||||
location?: string
|
||||
}
|
||||
|
||||
/** One step of a node's path, the space first and the node itself last. */
|
||||
@@ -170,15 +172,25 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** No answer at all: offline, or the server is down. Status 0, as XHR has it. */
|
||||
export const unreachable = (error: unknown) =>
|
||||
error instanceof ApiError && (error.status === 0 || error.status === 502 || error.status === 503 || error.status === 504)
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers:
|
||||
init.body instanceof FormData
|
||||
? init.headers
|
||||
: { 'content-type': 'application/json', ...init.headers },
|
||||
})
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`/api${path}`, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers:
|
||||
init.body instanceof FormData
|
||||
? init.headers
|
||||
: { 'content-type': 'application/json', ...init.headers },
|
||||
})
|
||||
} catch {
|
||||
// fetch says "Failed to fetch" and nothing else; this says what it means.
|
||||
throw new ApiError(0, 'the server cannot be reached')
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let message = response.statusText
|
||||
@@ -195,6 +207,42 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
|
||||
const body = (data: unknown) => JSON.stringify(data)
|
||||
|
||||
/** The size of a page of a long list, the most the server hands out at once. */
|
||||
const PAGE = 2000
|
||||
|
||||
/** Every page of a list, read until one comes back short. */
|
||||
async function pages<T>(read: (limit: number, offset: number) => Promise<T[]>): Promise<T[]> {
|
||||
const all: T[] = []
|
||||
for (;;) {
|
||||
const page = await read(PAGE, all.length)
|
||||
all.push(...page)
|
||||
if (page.length < PAGE) return all
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Files up as multipart, by XHR rather than fetch, because a gigabyte going
|
||||
* up needs to say how far along it is.
|
||||
*/
|
||||
function send<T>(url: string, files: File[], progress?: (fraction: number) => void) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const form = new FormData()
|
||||
for (const file of files) form.append('file', file)
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', url)
|
||||
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 T)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<{ user: string }>('/me'),
|
||||
|
||||
@@ -212,13 +260,23 @@ export const api = {
|
||||
createSpace: (name: string, has_board = true) =>
|
||||
request<WorkspaceNode>('/spaces', { method: 'POST', body: body({ name, has_board }) }),
|
||||
|
||||
/** The children of a node, or — with `q` — a search across the whole tree. */
|
||||
listNodes: (params: { parent?: number; q?: string; space?: number }) => {
|
||||
/**
|
||||
* The children of a node, or — with `q` — a search across the whole tree.
|
||||
* A folder is read a page at a time until a page comes back short, so one
|
||||
* that grows past a page is listed whole rather than cut off; a search
|
||||
* stops at its first page, the best matches.
|
||||
*/
|
||||
listNodes: async (params: { parent?: number; q?: string; space?: number }) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.q) query.set('q', params.q)
|
||||
else if (params.parent !== undefined) query.set('parent', String(params.parent))
|
||||
if (params.q && params.space !== undefined) query.set('space', String(params.space))
|
||||
return request<NodeSummary[]>(`/nodes?${query}`)
|
||||
if (params.q) return request<NodeSummary[]>(`/nodes?${query}`)
|
||||
return pages((limit, offset) => {
|
||||
query.set('limit', String(limit))
|
||||
query.set('offset', String(offset))
|
||||
return request<NodeSummary[]>(`/nodes?${query}`)
|
||||
})
|
||||
},
|
||||
|
||||
getNode: (id: number) => request<WorkspaceNode>(`/nodes/${id}`),
|
||||
@@ -252,9 +310,10 @@ export const api = {
|
||||
created_at?: number
|
||||
}) => request<WorkspaceNode>('/nodes', { method: 'POST', body: body(input) }),
|
||||
|
||||
/** `base_updated_at`: refused with 409 if the node was saved since then. */
|
||||
updateNode: (
|
||||
id: number,
|
||||
input: { name: string; body: string; created_at?: number; has_board?: boolean },
|
||||
input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number },
|
||||
) => request<WorkspaceNode>(`/nodes/${id}`, { method: 'PUT', body: body(input) }),
|
||||
|
||||
deleteNode: (id: number) =>
|
||||
@@ -310,35 +369,18 @@ export const api = {
|
||||
moveCard: (id: number, list_id: number, position?: number) =>
|
||||
request<Card>(`/cards/${id}/move`, { method: 'POST', body: body({ list_id, position }) }),
|
||||
|
||||
listMedia: () => request<MediaFile[]>('/media'),
|
||||
listMedia: () => pages((limit, offset) => request<MediaFile[]>(`/media?limit=${limit}&offset=${offset}`)),
|
||||
|
||||
/**
|
||||
* 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[], encrypt: boolean, 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${encrypt ? '?encrypt=true' : ''}`)
|
||||
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)
|
||||
}),
|
||||
send<WorkspaceNode[]>(`/api/nodes/${parentId}/files${encrypt ? '?encrypt=true' : ''}`, files, progress),
|
||||
|
||||
upload: (files: File[]) => {
|
||||
const form = new FormData()
|
||||
for (const file of files) form.append('file', file)
|
||||
return request<MediaFile[]>('/media', { method: 'POST', body: form })
|
||||
},
|
||||
/** Files to embed in a document, with the same progress as `putFiles`. */
|
||||
upload: (files: File[], progress?: (fraction: number) => void) =>
|
||||
send<MediaFile[]>('/api/media', files, progress),
|
||||
|
||||
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
|
||||
|
||||
|
||||
+16
-5
@@ -22,6 +22,8 @@ export const hooks = {
|
||||
overviewColumns: (): number => 1,
|
||||
openOverview: () => {},
|
||||
openCommandLine: (_initial: string) => {},
|
||||
/** Ctrl-P: a search across every space. */
|
||||
openGlobalSearch: () => {},
|
||||
}
|
||||
|
||||
/** Help groups the command list under these headings, in this order. */
|
||||
@@ -121,7 +123,10 @@ export const commands: CommandSpec[] = [
|
||||
group: 'editing',
|
||||
help: 'save the open document',
|
||||
run: () => workspace.guard(() => workspace.save()),
|
||||
bang: { help: 'same as :w — the bang is accepted out of habit', run: () => workspace.guard(() => workspace.save()) },
|
||||
bang: {
|
||||
help: 'save even over a newer version saved somewhere else since this one was opened',
|
||||
run: () => workspace.guard(() => workspace.save({ force: true })),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'wq',
|
||||
@@ -134,9 +139,9 @@ export const commands: CommandSpec[] = [
|
||||
hooks.focusList()
|
||||
},
|
||||
bang: {
|
||||
help: 'same as :wq — the bang is accepted out of habit',
|
||||
help: 'save over a newer version, as :w! does, and leave the editor',
|
||||
run: async () => {
|
||||
await workspace.guard(() => workspace.save())
|
||||
await workspace.guard(() => workspace.save({ force: true }))
|
||||
workspace.editing = false
|
||||
hooks.focusList()
|
||||
},
|
||||
@@ -335,8 +340,14 @@ export const commands: CommandSpec[] = [
|
||||
const target = arg.trim()
|
||||
if (!target) return workspace.say('usage: :mv <folder|..>', 'error')
|
||||
|
||||
const destination =
|
||||
target === '..' ? workspace.path.at(-2)?.id : findFolder(target)?.id
|
||||
// `..` is up from where the node is, which in a search result is not
|
||||
// the folder being browsed.
|
||||
const up = async () => {
|
||||
if (node.parent_id === null) return undefined
|
||||
const parent = await api.getNode(node.parent_id)
|
||||
return parent.kind === 'space' ? undefined : (parent.parent_id ?? undefined)
|
||||
}
|
||||
const destination = target === '..' ? await up() : findFolder(target)?.id
|
||||
if (destination === undefined) return workspace.say(`no such folder: ${target}`, 'error')
|
||||
if (destination === node.id) return workspace.say('a folder cannot hold itself', 'error')
|
||||
await workspace.guard(() => workspace.moveNode(node.id, destination))
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
api,
|
||||
ApiError,
|
||||
isContainer,
|
||||
unreachable,
|
||||
type Agenda,
|
||||
type AgendaCard,
|
||||
type Board,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
type SpaceOverview,
|
||||
type VaultStatus,
|
||||
} from './api'
|
||||
import { formatBytes, sealedNote } from './util'
|
||||
import { formatBytes, isTouchDevice, sealedNote } from './util'
|
||||
|
||||
export type Pane = 'list' | 'document'
|
||||
/** The tree of documents, or the board of the space being browsed. */
|
||||
@@ -97,7 +98,7 @@ class Workspace {
|
||||
vimMode = $state('normal')
|
||||
flash = $state<Flash>(null)
|
||||
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda'>('none')
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions'>('none')
|
||||
|
||||
/*
|
||||
* What is due or in hand on every board, kept loaded all the time: the bar
|
||||
@@ -140,7 +141,14 @@ class Workspace {
|
||||
*/
|
||||
#spots: Record<number, Spot> = JSON.parse(localStorage.getItem('diary:spots') ?? '{}')
|
||||
|
||||
vimEnabled = $state(localStorage.getItem('diary:vim') !== 'off')
|
||||
/** Modal editing on a keyboard; a phone's editor is a plain text box. */
|
||||
vimEnabled = $state((localStorage.getItem('diary:vim') ?? (isTouchDevice ? 'off' : 'on')) !== 'off')
|
||||
|
||||
/** What the login screen has to say: why it is showing, if not for a first visit. */
|
||||
notice = $state('')
|
||||
|
||||
/** Saves that failed for want of a server wait this long before trying again. */
|
||||
#retryDelay = 0
|
||||
|
||||
/** Mirror vim yanks and deletes into the system clipboard, like `set clipboard=unnamed`. */
|
||||
clipboard = $state(localStorage.getItem('diary:clipboard') !== 'off')
|
||||
@@ -332,7 +340,30 @@ class Workspace {
|
||||
async #writeBack() {
|
||||
this.#autosave = null
|
||||
if (!this.open || !this.dirty) return
|
||||
await this.guard(() => this.save({ quiet: true }))
|
||||
try {
|
||||
await this.save({ quiet: true })
|
||||
if (this.#retryDelay > 0) this.say('saved — the server is back')
|
||||
this.#retryDelay = 0
|
||||
} catch (error) {
|
||||
if (!unreachable(error)) return this.guard(() => Promise.reject(error))
|
||||
// Nothing is lost — the draft is in localStorage — but it is not on the
|
||||
// server either, so it is tried again, less often the longer it fails.
|
||||
this.#retryDelay = Math.min(Math.max(this.#retryDelay * 2, 2000), 60_000)
|
||||
this.say(`not saved: the server cannot be reached — kept here, trying again in ${this.#retryDelay / 1000}s`, 'error')
|
||||
this.#autosave = setTimeout(() => void this.#writeBack(), this.#retryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
/** Back online: what is waiting goes now rather than at the next retry. */
|
||||
#watchConnection() {
|
||||
window.addEventListener('online', () => {
|
||||
if (!this.dirty) return
|
||||
if (this.#autosave) clearTimeout(this.#autosave)
|
||||
void this.#writeBack()
|
||||
})
|
||||
window.addEventListener('offline', () => {
|
||||
this.say('offline — what is typed is kept here, and saved once the server answers', 'error')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,16 +407,28 @@ class Workspace {
|
||||
return true
|
||||
}
|
||||
|
||||
#watching = false
|
||||
|
||||
async boot() {
|
||||
if (!this.#watching) {
|
||||
this.#watching = true
|
||||
this.#watchConnection()
|
||||
}
|
||||
try {
|
||||
const { user } = await api.me()
|
||||
this.user = user
|
||||
this.notice = ''
|
||||
await this.loadSpaces(Workspace.addressedPath())
|
||||
await this.loadAgenda()
|
||||
await this.loadVault()
|
||||
await this.openAgendaIfDue()
|
||||
} catch {
|
||||
} catch (error) {
|
||||
this.user = null
|
||||
// Down is not the same as logged out: say so, and keep asking.
|
||||
if (unreachable(error)) {
|
||||
this.notice = 'the server cannot be reached — trying again'
|
||||
setTimeout(() => void this.boot(), 5000)
|
||||
}
|
||||
} finally {
|
||||
this.booting = false
|
||||
}
|
||||
@@ -394,6 +437,14 @@ class Workspace {
|
||||
async login(username: string, password: string) {
|
||||
const { user } = await api.login(username, password)
|
||||
this.user = user
|
||||
this.notice = ''
|
||||
// The session ran out under an open workspace: everything is still here,
|
||||
// the draft included, so pick up where it stopped rather than start over.
|
||||
if (this.spaces.length > 0) {
|
||||
await this.refresh()
|
||||
if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true }))
|
||||
return
|
||||
}
|
||||
await this.loadSpaces(Workspace.addressedPath())
|
||||
await this.loadAgenda()
|
||||
await this.loadVault()
|
||||
@@ -637,12 +688,20 @@ class Workspace {
|
||||
this.say(`new space ${space.slug}`)
|
||||
}
|
||||
|
||||
async save({ quiet = false } = {}) {
|
||||
/**
|
||||
* Write the draft. Refused when the document was saved somewhere else
|
||||
* since it was opened here — another tab, the phone, the SFTP mount —
|
||||
* unless `force`, which is `:w!`.
|
||||
*/
|
||||
async save({ quiet = false, force = false } = {}) {
|
||||
if (!this.open) return
|
||||
// Snapshot what goes over the wire: an autosave races the typing that
|
||||
// triggered it, and anything typed since must stay marked unsaved.
|
||||
const sent = { ...this.draft, name: this.draft.name.trim() }
|
||||
const saved = await api.updateNode(this.open.id, sent)
|
||||
const saved = await api.updateNode(this.open.id, {
|
||||
...sent,
|
||||
base_updated_at: force ? undefined : this.open.updated_at,
|
||||
})
|
||||
|
||||
this.open = saved
|
||||
this.dirty =
|
||||
@@ -1098,6 +1157,7 @@ class Workspace {
|
||||
|
||||
async toggleShare(id: number) {
|
||||
const node = this.open?.id === id ? this.open : await api.getNode(id)
|
||||
if (node.kind !== 'document') return this.say('only a document can be shared', 'error')
|
||||
if (node.shared) {
|
||||
await api.unshare(id)
|
||||
this.say(`#${id} is private again`)
|
||||
@@ -1115,7 +1175,7 @@ class Workspace {
|
||||
* There is nothing to re-copy later — asking again mints a new link and
|
||||
* retires the old one.
|
||||
*/
|
||||
async mintShareLink(id: number) {
|
||||
async mintShareLink(id: number): Promise<{ url: string; sealed: number }> {
|
||||
const { path, sealed } = await api.share(id)
|
||||
const url = `${location.origin}${path}`
|
||||
await this.copy(url)
|
||||
@@ -1178,7 +1238,12 @@ class Workspace {
|
||||
}
|
||||
|
||||
async upload(files: File[]): Promise<MediaFile[]> {
|
||||
const uploaded = await api.upload(files)
|
||||
const what = files.length === 1 ? files[0].name : `${files.length} files`
|
||||
const total = files.reduce((sum, file) => sum + file.size, 0)
|
||||
this.say(`uploading ${what} (${formatBytes(total)}) …`)
|
||||
const uploaded = await api.upload(files, (fraction) =>
|
||||
this.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`),
|
||||
)
|
||||
this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`)
|
||||
return uploaded
|
||||
}
|
||||
@@ -1204,6 +1269,7 @@ class Workspace {
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
this.user = null
|
||||
this.notice = 'the session ran out — log in again, and carry on where you were'
|
||||
this.say('session expired — log in again', 'error')
|
||||
} else if (error instanceof ApiError && error.status === 423) {
|
||||
// It locked itself while nobody was looking.
|
||||
|
||||
Reference in new issue
Block a user