Terminal-themed markdown diary

Single-user diary served as one Rust binary with the Svelte frontend
embedded.

- axum 0.8 + SQLite (sqlx), migrations run at startup
- stateless HMAC session cookie; credentials from .env, compared in
  constant time
- FTS5 full-text search, user input rewritten into quoted prefix terms
- media uploads linked to entries by scanning bodies for /api/media/<id>,
  which scopes share-token media access and entry deletion
- unguessable share links (192-bit token) serving a public read-only page
- vim keybindings via CodeMirror plus a modal browse layer and : command
  line; disabled on touch devices
This commit is contained in:
nvrl committed 2026-09-05 05:39:25 +02:00
commit 4002a35305
41 files changed
+6195

No files matched your search

+270
View File
@@ -0,0 +1,270 @@
<script lang="ts">
import { hooks, runCommand } from './lib/commands'
import { embedSnippet } from './lib/markdown'
import CommandLine from './lib/CommandLine.svelte'
import EntryPane from './lib/EntryPane.svelte'
import Help from './lib/Help.svelte'
import Login from './lib/Login.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte'
import Shared from './lib/Shared.svelte'
import Sidebar from './lib/Sidebar.svelte'
import StatusBar from './lib/StatusBar.svelte'
import { diary } from './lib/store.svelte'
const shareToken = location.pathname.startsWith('/s/')
? decodeURIComponent(location.pathname.slice(3))
: null
let cmdline = $state<string | null>(null)
let fileInput = $state<HTMLInputElement>()
let shell = $state<HTMLDivElement>()
let pending = $state('')
if (!shareToken) void diary.boot()
$effect(() => {
document.documentElement.dataset.theme = diary.theme
})
$effect(() => {
hooks.pickFiles = () => fileInput?.click()
hooks.focusList = () => shell?.focus()
hooks.openCommandLine = (initial) => (cmdline = initial)
})
// Guard against losing a half-written entry to a stray refresh.
$effect(() => {
if (!diary.dirty) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
})
async function attach(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
if (files.length === 0) return
await diary.guard(async () => {
const uploaded = await diary.upload(files)
hooks.insertText(uploaded.map(embedSnippet).join('\n\n'))
})
}
function isTypingTarget(target: EventTarget | null) {
const node = target as HTMLElement | null
return (
!!node &&
(node.tagName === 'INPUT' ||
node.tagName === 'TEXTAREA' ||
node.isContentEditable ||
!!node.closest?.('.cm-editor'))
)
}
function onkeydown(event: KeyboardEvent) {
if (event.key === 's' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
if (diary.open) void diary.guard(() => diary.save())
return
}
if (event.key === 'Escape') {
if (diary.overlay !== 'none') {
diary.overlay = 'none'
return
}
if (cmdline !== null) {
cmdline = null
shell?.focus()
return
}
}
if (cmdline !== null || isTypingTarget(event.target)) return
if (event.ctrlKey || event.metaKey || event.altKey) return
const key = event.key
const previous = pending
pending = ''
if (diary.overlay !== 'none') {
if (key === 'q' || key === '?') diary.overlay = 'none'
return
}
// Two-key sequences: gg and dd.
if (previous === 'g' && key === 'g') {
event.preventDefault()
diary.cursor = 0
return
}
if (previous === 'd' && key === 'd') {
event.preventDefault()
void runCommand('d')
return
}
if (key === 'g' || key === 'd') {
pending = key
return
}
switch (key) {
case 'j':
case 'ArrowDown':
event.preventDefault()
diary.move(1)
break
case 'k':
case 'ArrowUp':
event.preventDefault()
diary.move(-1)
break
case 'G':
event.preventDefault()
diary.cursor = Math.max(diary.entries.length - 1, 0)
break
case 'Enter':
case 'l':
case 'ArrowRight':
event.preventDefault()
void diary.guard(() => diary.openSelected())
break
case 'o':
event.preventDefault()
void runCommand('new')
break
case 'i':
case 'a':
event.preventDefault()
void runCommand('edit')
break
case 'h':
case 'ArrowLeft':
case 'q':
case 'Escape':
event.preventDefault()
if (diary.open) void runCommand('q')
break
case 'x':
event.preventDefault()
void runCommand('d')
break
case 's':
event.preventDefault()
void runCommand(diary.selected?.shared ? 'unshare' : 'share')
break
case 'y':
event.preventDefault()
void runCommand('link')
break
case 'n':
event.preventDefault()
if (diary.query) void diary.guard(() => diary.search(''))
break
case '/':
event.preventDefault()
cmdline = '/'
break
case ':':
event.preventDefault()
cmdline = ':'
break
case '?':
event.preventDefault()
void runCommand('help')
break
}
}
</script>
<svelte:window {onkeydown} />
{#if shareToken}
<Shared token={shareToken} />
{:else if diary.booting}
<div class="boot faint">booting ~/diary…</div>
{:else if !diary.user}
<Login />
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="shell" bind:this={shell} tabindex="-1">
<div class="panes" class:reading={!!diary.open}>
<div class="list"><Sidebar /></div>
<div class="entry">
{#if diary.open}
<EntryPane />
{:else}
<div class="placeholder faint">
<pre>{` ┌─────────────────────────────┐
│ nothing open │
│ │
│ o write a new entry │
│ j/k browse, Enter opens │
│ / search everything │
│ ? help │
└─────────────────────────────┘`}</pre>
</div>
{/if}
</div>
</div>
<StatusBar />
{#if cmdline !== null}
<CommandLine initial={cmdline} close={() => (cmdline = null)} />
{/if}
{#if diary.overlay !== 'none'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="overlay" onclick={(e) => e.target === e.currentTarget && (diary.overlay = 'none')}>
{#if diary.overlay === 'help'}<Help />{:else}<MediaBrowser />{/if}
</div>
{/if}
</div>
{/if}
<input class="hidden" type="file" multiple bind:this={fileInput} onchange={attach} />
<style>
.boot { display: grid; place-items: center; height: 100%; }
.shell {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
outline: none;
}
.panes {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(28ch, 34ch) 1fr;
}
.list, .entry { min-width: 0; overflow: hidden; }
.placeholder { display: grid; place-items: center; height: 100%; }
.placeholder pre { margin: 0; font-size: 12px; line-height: 1.5; }
.overlay {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 24px;
background: color-mix(in srgb, var(--bg) 86%, transparent);
}
.hidden { display: none; }
@media (max-width: 760px) {
.panes { grid-template-columns: 1fr; }
.panes .entry { display: none; }
.panes.reading .list { display: none; }
.panes.reading .entry { display: block; }
}
</style>
+179
View File
@@ -0,0 +1,179 @@
/* ── palette ─────────────────────────────────────────────────────────────── */
:root {
--bg: #0b0e0d;
--bg-alt: #101513;
--bg-lift: #161d1a;
--line: #1e2b26;
--fg: #cfe3d9;
--fg-dim: #6f8a7f;
--fg-faint: #3f5049;
--accent: #54f2a1;
--accent-dim: #2f9c69;
--warn: #f2c14e;
--error: #ff6b6b;
--select: #1c3a2c;
--font: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
--radius: 2px;
color-scheme: dark;
}
[data-theme='amber'] {
--bg: #0e0b06;
--bg-alt: #151009;
--bg-lift: #1d160c;
--line: #2e2413;
--fg: #e8d5ae;
--fg-dim: #8d7550;
--fg-faint: #4d3f28;
--accent: #ffb454;
--accent-dim: #b57a25;
--select: #3a2a10;
}
[data-theme='ice'] {
--bg: #080b10;
--bg-alt: #0d1219;
--bg-lift: #121a24;
--line: #1c2836;
--fg: #cddbe8;
--fg-dim: #6d8399;
--fg-faint: #3d4f60;
--accent: #7cc7ff;
--accent-dim: #3d84b8;
--select: #17324a;
}
/* ── base ────────────────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
overflow: hidden;
overscroll-behavior: none;
}
#app { height: 100%; }
a { color: var(--accent); text-decoration: none; border-bottom: 1px solid var(--accent-dim); }
a:hover { background: var(--select); }
button {
font: inherit;
color: var(--fg-dim);
background: transparent;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 2px 8px;
cursor: pointer;
}
button:hover { color: var(--accent); border-color: var(--accent-dim); }
button:focus-visible, input:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }
input, textarea {
font: inherit;
color: var(--fg);
background: var(--bg-alt);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 4px 8px;
}
input::placeholder { color: var(--fg-faint); }
::selection { background: var(--select); color: var(--fg); }
/* thin, unobtrusive scrollbars everywhere */
* { scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: var(--line); }
*::-webkit-scrollbar-track { background: transparent; }
.dim { color: var(--fg-dim); }
.faint { color: var(--fg-faint); }
.accent { color: var(--accent); }
.error { color: var(--error); }
/* ── rendered markdown ───────────────────────────────────────────────────── */
.md { max-width: 78ch; }
.md > :first-child { margin-top: 0; }
.md h1, .md h2, .md h3, .md h4 {
color: var(--accent);
font-size: 1em;
font-weight: 700;
margin: 1.6em 0 0.6em;
letter-spacing: 0.02em;
}
.md h1::before { content: '# '; color: var(--fg-faint); }
.md h2::before { content: '## '; color: var(--fg-faint); }
.md h3::before { content: '### '; color: var(--fg-faint); }
.md h4::before { content: '#### '; color: var(--fg-faint); }
.md p { margin: 0 0 1em; }
.md ul, .md ol { margin: 0 0 1em; padding-left: 2ch; }
.md li { margin: 0.15em 0; }
.md ul { list-style: none; }
.md ul > li::before { content: '- '; color: var(--fg-faint); margin-left: -2ch; }
.md blockquote {
margin: 0 0 1em;
padding-left: 1.5ch;
border-left: 2px solid var(--accent-dim);
color: var(--fg-dim);
}
.md code {
background: var(--bg-lift);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 4px;
font-size: 0.92em;
}
.md pre {
background: var(--bg-alt);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 10px 12px;
overflow-x: auto;
margin: 0 0 1em;
}
.md pre code { background: none; border: 0; padding: 0; }
.md hr { border: 0; border-top: 1px dashed var(--line); margin: 2em 0; }
.md img, .md video {
max-width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius);
display: block;
margin: 0 0 1em;
}
.md audio { width: 100%; margin: 0 0 1em; }
.md table { border-collapse: collapse; margin: 0 0 1em; display: block; overflow-x: auto; }
.md th, .md td { border: 1px solid var(--line); padding: 3px 8px; text-align: left; }
.md th { color: var(--accent); }
.md input[type='checkbox'] { accent-color: var(--accent-dim); }
/* ── codemirror ──────────────────────────────────────────────────────────── */
.cm-editor { height: 100%; background: transparent; }
.cm-editor.cm-focused { outline: none; }
.cm-scroller {
font-family: var(--font) !important;
font-size: 14px;
line-height: 1.6;
padding-bottom: 40vh;
}
.cm-content { caret-color: var(--accent); }
.cm-gutters { background: transparent; border: 0; color: var(--fg-faint); }
.cm-activeLineGutter { background: transparent; color: var(--fg-dim); }
.cm-activeLine { background: rgb(255 255 255 / 2.5%); }
.cm-selectionBackground, .cm-content ::selection { background: var(--select) !important; }
.cm-fat-cursor { background: var(--accent) !important; opacity: 1 !important; }
.cm-animate-fat-cursor { background: var(--accent) !important; }
.cm-panels { background: var(--bg-alt); color: var(--fg); border-color: var(--line); }
.cm-vim-panel { padding: 2px 8px; background: var(--bg-alt); }
.cm-vim-panel input { background: transparent; border: 0; color: var(--accent); }
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { untrack } from 'svelte'
import { completions, runCommand, hooks } from './commands'
import { diary } from './store.svelte'
interface Props {
initial: string
close: () => void
}
const { initial, close }: Props = $props()
let value = $state(untrack(() => initial))
let input = $state<HTMLInputElement>()
const isSearch = $derived(value.startsWith('/'))
const matches = $derived(isSearch ? [] : completions(value).slice(0, 8))
$effect(() => {
input?.focus()
})
// A search is live: the list filters as you type, like `incsearch`.
$effect(() => {
if (!isSearch) return
const query = value.slice(1)
const timer = setTimeout(() => void diary.guard(() => diary.search(query)), 120)
return () => clearTimeout(timer)
})
async function submit(event: SubmitEvent) {
event.preventDefault()
const line = value
close()
if (isSearch) {
hooks.focusList()
return
}
await runCommand(line)
}
function onkeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
close()
hooks.focusList()
}
if (event.key === 'Tab' && matches.length > 0) {
event.preventDefault()
value = `:${matches[0].name} `
}
}
</script>
<div class="cmdline">
{#if matches.length > 0 && value.replace(/^:/, '').length > 0}
<ul class="completions">
{#each matches as spec (spec.name)}
<li>
<button onclick={() => (value = `:${spec.name} `, input?.focus())}>
<span class="accent">:{spec.name}</span>
{#if spec.args}<span class="faint">{spec.args}</span>{/if}
<span class="dim">{spec.help}</span>
</button>
</li>
{/each}
</ul>
{/if}
<form onsubmit={submit}>
<input bind:this={input} bind:value {onkeydown} spellcheck="false" autocomplete="off" />
</form>
</div>
<style>
.cmdline {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
}
form { display: flex; }
input {
flex: 1;
height: 24px;
background: var(--bg);
border: 0;
border-top: 1px solid var(--accent-dim);
border-radius: 0;
color: var(--accent);
padding: 0 8px;
font-size: 13px;
}
.completions {
list-style: none;
margin: 0;
padding: 4px 0;
background: var(--bg-alt);
border-top: 1px solid var(--line);
max-height: 40vh;
overflow-y: auto;
}
.completions button {
display: flex;
gap: 8px;
width: 100%;
border: 0;
border-radius: 0;
text-align: left;
padding: 1px 10px;
font-size: 12px;
}
.completions button:hover { background: var(--select); }
</style>
+178
View File
@@ -0,0 +1,178 @@
<script lang="ts">
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { HighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language'
import { EditorState } from '@codemirror/state'
import {
EditorView,
drawSelection,
dropCursor,
highlightActiveLine,
keymap,
placeholder,
} from '@codemirror/view'
import { tags } from '@lezer/highlight'
import { Vim, getCM, vim } from '@replit/codemirror-vim'
import { onDestroy, onMount } from 'svelte'
import { commands, hooks, runCommand } from './commands'
import { embedSnippet } from './markdown'
import { diary } from './store.svelte'
let host = $state<HTMLDivElement>()
let view: EditorView | undefined
const markdownHighlight = HighlightStyle.define([
{ tag: tags.heading, color: 'var(--accent)', fontWeight: '700' },
{ tag: tags.strong, color: 'var(--fg)', fontWeight: '700' },
{ tag: tags.emphasis, color: 'var(--fg)', fontStyle: 'italic' },
{ tag: tags.link, color: 'var(--accent)' },
{ tag: tags.url, color: 'var(--accent-dim)' },
{ tag: tags.monospace, color: 'var(--warn)' },
{ tag: tags.quote, color: 'var(--fg-dim)', fontStyle: 'italic' },
{ tag: tags.list, color: 'var(--accent-dim)' },
{ tag: tags.contentSeparator, color: 'var(--fg-faint)' },
{ tag: tags.processingInstruction, color: 'var(--fg-faint)' },
])
const editorTheme = EditorView.theme({
'&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' },
'.cm-content': { padding: '12px 16px', maxWidth: '90ch' },
'.cm-line': { padding: '0' },
})
/** Route every `:command` typed inside the editor through the shared handler. */
function bridgeExCommands() {
for (const spec of commands) {
const names = [spec.name, ...(spec.aliases ?? [])]
for (const name of names) {
if (name.includes('!')) continue
Vim.defineEx(name, name, (_cm: unknown, params: { argString?: string }) => {
const arg = (params.argString ?? '').trim()
// vim parses `:q!` as command `q` with the bang left in the arguments.
void runCommand(arg === '!' ? `${spec.name}!` : `${spec.name} ${arg}`)
})
}
}
}
/**
* Media goes on its own line after the one the cursor is on, so attaching a
* file never cuts a sentence in half.
*/
function insertAtCursor(text: string) {
if (!view) return
const line = view.state.doc.lineAt(view.state.selection.main.head)
const blank = line.text.trim() === ''
const at = blank ? line.from : line.to
const insert = blank ? `${text}\n` : `\n\n${text}\n`
view.dispatch({
changes: { from: at, to: blank ? line.to : at, insert },
selection: { anchor: at + insert.length },
scrollIntoView: true,
})
view.focus()
}
async function uploadFiles(files: File[]) {
if (files.length === 0) return
await diary.guard(async () => {
const uploaded = await diary.upload(files)
insertAtCursor(uploaded.map(embedSnippet).join('\n\n'))
})
}
onMount(() => {
bridgeExCommands()
const extensions = [
...(diary.vimEnabled ? [vim({ status: false })] : []),
history(),
drawSelection(),
dropCursor(),
indentOnInput(),
highlightActiveLine(),
EditorState.allowMultipleSelections.of(true),
markdown({ base: markdownLanguage, codeLanguages: [] }),
syntaxHighlighting(markdownHighlight),
EditorView.lineWrapping,
placeholder('dear diary…'),
editorTheme,
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => (void diary.guard(() => diary.save()), true),
},
...defaultKeymap,
...historyKeymap,
indentWithTab,
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
diary.draft.body = update.state.doc.toString()
diary.dirty = true
}
}),
EditorView.domEventHandlers({
paste: (event) => {
const files = Array.from(event.clipboardData?.files ?? [])
if (files.length === 0) return false
event.preventDefault()
void uploadFiles(files)
return true
},
drop: (event) => {
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length === 0) return false
event.preventDefault()
void uploadFiles(files)
return true
},
}),
]
view = new EditorView({
state: EditorState.create({ doc: diary.draft.body, extensions }),
parent: host!,
})
hooks.focusEditor = () => view?.focus()
hooks.insertText = insertAtCursor
view.focus()
const cm = diary.vimEnabled ? getCM(view) : null
if (cm) {
// Keep the status line's mode indicator in step with the editor.
const emitter = cm as unknown as {
on?: (event: string, handler: (e: { mode: string }) => void) => void
}
emitter.on?.('vim-mode-change', (event) => {
diary.vimMode = event.mode
})
// A fresh entry starts in insert mode; an existing one starts in normal
// mode, the way opening a file in vim does.
if (diary.enterInsert) {
diary.enterInsert = false
Vim.handleKey(cm, 'i', 'diary')
}
}
})
onDestroy(() => {
view?.destroy()
view = undefined
hooks.focusEditor = () => {}
hooks.insertText = () => {}
diary.vimMode = 'normal'
})
</script>
<div class="editor" bind:this={host}></div>
<style>
.editor {
height: 100%;
overflow: hidden;
}
</style>
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import { runCommand } from './commands'
import { renderMarkdown } from './markdown'
import { diary } from './store.svelte'
import { formatStamp, formatWeekday, isTouchDevice, relative } from './util'
const entry = $derived(diary.open!)
const html = $derived(renderMarkdown(diary.editing ? diary.draft.body : entry.body))
const words = $derived(
(diary.editing ? diary.draft.body : entry.body).split(/\s+/).filter(Boolean).length,
)
const shareUrl = $derived(entry.share_token ? `${location.origin}/s/${entry.share_token}` : null)
</script>
<section class="pane">
<header>
<div class="meta">
<span class="accent">entry:{entry.id}</span>
<span class="faint">{formatStamp(diary.draft.created_at)} {formatWeekday(diary.draft.created_at)}</span>
<span class="faint">·</span>
<span class="faint">edited {relative(entry.updated_at)}</span>
<span class="faint">·</span>
<span class="faint">{words} words</span>
{#if diary.dirty}<span class="warn">[+]</span>{/if}
</div>
<div class="actions">
{#if isTouchDevice}
<button onclick={() => runCommand('q')}>← list</button>
{/if}
{#if diary.editing}
<button onclick={() => runCommand('w')}>:w</button>
<button onclick={() => runCommand('upload')}>attach</button>
<button onclick={() => runCommand('wq')}>done</button>
{:else}
<button onclick={() => runCommand('e')}>edit</button>
{/if}
<button class:on={entry.shared} onclick={() => runCommand(entry.shared ? 'unshare' : 'share')}>
{entry.shared ? 'unshare' : 'share'}
</button>
<button onclick={() => runCommand('d')}>delete</button>
<button onclick={() => runCommand('q')}>close</button>
</div>
</header>
{#if shareUrl}
<div class="sharebar">
<span class="accent">◉ public</span>
<a href={shareUrl} target="_blank" rel="noreferrer">{shareUrl}</a>
<button onclick={() => runCommand('link')}>copy</button>
</div>
{/if}
{#if diary.editing}
<input
class="title"
placeholder="title (optional)"
bind:value={diary.draft.title}
oninput={() => (diary.dirty = true)}
onkeydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
document.querySelector<HTMLElement>('.cm-content')?.focus()
}
}}
/>
<div class="body">
{#key entry.id}
{#await import('./Editor.svelte')}
<div class="loading faint">loading editor…</div>
{:then module}
{@const Editor = module.default}
<Editor />
{/await}
{/key}
</div>
{#if isTouchDevice}
<div class="mobilebar">
<button onclick={() => runCommand('upload')}>+ media</button>
<button onclick={() => runCommand('w')}>save</button>
<button onclick={() => runCommand('wq')}>done</button>
</div>
{/if}
{:else}
<div class="body reading">
<article class="md">
{#if entry.title.trim()}<h1 class="entrytitle">{entry.title}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html html}
{#if !entry.body.trim()}
<p class="faint">this entry is empty — press <span class="accent">i</span> to write.</p>
{/if}
</article>
</div>
{/if}
</section>
<style>
.pane {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
}
header {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-bottom: 1px solid var(--line);
flex: none;
}
.meta { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 12px; }
.warn { color: var(--warn); }
.actions { display: flex; gap: 4px; flex-wrap: wrap; }
.actions .on { color: var(--accent); border-color: var(--accent-dim); }
.sharebar {
display: flex;
gap: 8px;
align-items: center;
padding: 4px 12px;
background: var(--bg-lift);
border-bottom: 1px solid var(--line);
font-size: 12px;
overflow-x: auto;
white-space: nowrap;
flex: none;
}
.title {
flex: none;
margin: 8px 12px 0;
background: transparent;
border: 0;
border-bottom: 1px dashed var(--line);
border-radius: 0;
color: var(--accent);
font-weight: 700;
padding: 2px 4px;
}
.loading { padding: 16px 20px; }
.body { flex: 1; min-height: 0; overflow: hidden; }
.reading { overflow-y: auto; padding: 16px 20px 30vh; }
.entrytitle { margin-top: 0 !important; }
.entrytitle::before { content: none !important; }
.mobilebar {
display: flex;
gap: 6px;
padding: 6px 12px calc(6px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: var(--bg-alt);
flex: none;
}
.mobilebar button { flex: 1; padding: 8px; }
</style>
+86
View File
@@ -0,0 +1,86 @@
<script lang="ts">
import { commands } from './commands'
import { diary } from './store.svelte'
const keys: [string, string][] = [
['j / k · ↓ ↑', 'move down / up the entry list'],
['gg / G', 'first / last entry'],
['Enter · l', 'open the highlighted entry'],
['o', 'new entry, straight into insert mode'],
['i · a', 'edit the open entry'],
['Esc · h · q', 'back out to the list'],
['/', 'full-text search'],
['n', 'clear the search'],
['s', 'toggle sharing for the entry'],
['y', 'copy the share link'],
['x · dd', 'delete the entry (asks first)'],
[': ', 'command line'],
['Ctrl-S', 'save, from anywhere'],
['?', 'this screen'],
]
</script>
<div class="sheet">
<header>
<span class="accent">:help</span>
<button onclick={() => (diary.overlay = 'none')}>esc</button>
</header>
<div class="cols">
<section>
<h2>keys</h2>
<dl>
{#each keys as [key, description] (key)}
<dt>{key}</dt>
<dd>{description}</dd>
{/each}
</dl>
</section>
<section>
<h2>commands</h2>
<dl>
{#each commands as spec (spec.name)}
<dt>
:{spec.name}{#if spec.aliases?.length}<span class="faint"> ({spec.aliases.join(' ')})</span>{/if}
{#if spec.args}<span class="faint"> {spec.args}</span>{/if}
</dt>
<dd>{spec.help}</dd>
{/each}
</dl>
</section>
</div>
<footer class="faint">
inside the editor the full vim keymap is live — hjkl, w/b, dd, ciw, visual mode, macros, and
<span class="accent">:w</span> to write. drop or paste an image, video or audio file into the
editor to attach it.
</footer>
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 12px;
width: min(100%, 90ch);
max-height: 100%;
background: var(--bg-alt);
border: 1px solid var(--accent-dim);
border-radius: var(--radius);
padding: 14px 16px;
overflow-y: auto;
}
header { display: flex; justify-content: space-between; align-items: center; }
.cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(30ch, 1fr)); gap: 20px; }
h2 { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.1em; margin: 0 0 6px; }
dl { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: 2px 12px; font-size: 12px; }
dt { color: var(--accent); white-space: nowrap; }
dd { margin: 0; color: var(--fg-dim); }
footer { font-size: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import { diary } from './store.svelte'
let username = $state('')
let password = $state('')
let error = $state('')
let busy = $state(false)
let field = $state<HTMLInputElement>()
$effect(() => {
field?.focus()
})
async function submit(event: SubmitEvent) {
event.preventDefault()
busy = true
error = ''
try {
await diary.login(username, password)
} catch (e) {
error = e instanceof Error ? e.message : 'login failed'
password = ''
} finally {
busy = false
}
}
</script>
<main>
<form onsubmit={submit}>
<pre class="banner">{`╭──────────────────────────────────╮
│ ~/diary │
╰──────────────────────────────────╯`}</pre>
<p class="faint">a private log. one user. one machine.</p>
<label>
<span class="accent">login:</span>
<input bind:this={field} bind:value={username} autocomplete="username" spellcheck="false" />
</label>
<label>
<span class="accent">password:</span>
<input type="password" bind:value={password} autocomplete="current-password" />
</label>
<div class="row">
<button type="submit" disabled={busy}>{busy ? 'authenticating…' : 'enter'}</button>
{#if error}<span class="error">{error}</span>{/if}
</div>
</form>
</main>
<style>
main {
display: grid;
place-items: center;
height: 100%;
padding: 24px;
}
form {
display: flex;
flex-direction: column;
gap: 12px;
width: min(100%, 46ch);
background: var(--bg-alt);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
}
.banner {
color: var(--accent);
font-size: 13px;
line-height: 1.3;
margin: 0;
overflow-x: auto;
}
p { margin: 0 0 4px; font-size: 12px; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
.row { display: flex; align-items: center; gap: 12px; }
.row button { padding: 6px 16px; }
</style>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import { api } from './api'
import { hooks } from './commands'
import { embedSnippet } from './markdown'
import { diary } from './store.svelte'
import { formatBytes, formatStamp } from './util'
async function remove(id: string, filename: string) {
if (!confirm(`delete ${filename}? entries that embed it will break.`)) return
await diary.guard(async () => {
await api.deleteMedia(id)
await diary.loadMedia()
})
}
</script>
<div class="sheet">
<header>
<span class="accent">:media</span>
<span class="faint">{diary.media.length} files</span>
<button onclick={() => (diary.overlay = 'none')}>esc</button>
</header>
<ul>
{#each diary.media as file (file.id)}
<li>
<div class="thumb">
{#if file.mime.startsWith('image/')}
<img src={file.url} alt={file.filename} loading="lazy" />
{:else}
<span class="faint">{file.mime.split('/')[0]}</span>
{/if}
</div>
<div class="info">
<span class="name">{file.filename}</span>
<span class="faint">
{formatBytes(file.size)} · {formatStamp(file.created_at)} ·
{file.entry_id ? `entry:${file.entry_id}` : 'unattached'}
</span>
</div>
<div class="actions">
{#if diary.editing}
<button
onclick={() => {
hooks.insertText(embedSnippet(file))
diary.overlay = 'none'
}}>insert</button
>
{/if}
<button onclick={() => diary.copy(location.origin + file.url)}>copy url</button>
<button onclick={() => remove(file.id, file.filename)}>rm</button>
</div>
</li>
{:else}
<li class="faint">nothing uploaded yet — attach a file from the editor.</li>
{/each}
</ul>
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 10px;
width: min(100%, 90ch);
max-height: 100%;
background: var(--bg-alt);
border: 1px solid var(--accent-dim);
border-radius: var(--radius);
padding: 14px 16px;
overflow: hidden;
}
header { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
li { display: flex; align-items: center; gap: 12px; padding: 4px; border-bottom: 1px solid var(--line); }
.thumb {
width: 48px;
height: 36px;
flex: none;
display: grid;
place-items: center;
background: var(--bg);
border: 1px solid var(--line);
overflow: hidden;
font-size: 10px;
}
.thumb img { width: 100%; height: 100%; object-fit: cover; }
.info { display: flex; flex-direction: column; min-width: 0; flex: 1; font-size: 12px; }
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.actions { display: flex; gap: 4px; flex: none; }
</style>
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import { api, type SharedEntry } from './api'
import { renderMarkdown } from './markdown'
import { formatStamp, formatWeekday } from './util'
const { token }: { token: string } = $props()
let entry = $state<SharedEntry | null>(null)
let error = $state('')
$effect(() => {
api
.readShared(token)
.then((data) => {
entry = data
document.title = data.title.trim() || `~/diary ${formatStamp(data.created_at)}`
})
.catch(() => (error = 'this link is not valid, or the entry is no longer shared.'))
})
</script>
<main>
{#if error}
<p class="error">{error}</p>
{:else if entry}
<article class="md">
<div class="meta faint">
{formatStamp(entry.created_at)} {formatWeekday(entry.created_at)}
<span class="tag">shared entry</span>
</div>
{#if entry.title.trim()}<h1>{entry.title}</h1>{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
{@html renderMarkdown(entry.body, token)}
</article>
<footer class="faint">— written in ~/diary</footer>
{:else}
<p class="faint">loading…</p>
{/if}
</main>
<style>
main {
height: 100%;
overflow-y: auto;
padding: 40px 20px 20vh;
display: flex;
flex-direction: column;
align-items: center;
}
article { width: min(100%, 78ch); }
.meta { display: flex; gap: 10px; align-items: center; font-size: 12px; margin-bottom: 12px; }
.tag {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0 6px;
color: var(--accent-dim);
}
h1 { margin-top: 0 !important; }
h1::before { content: none !important; }
footer { width: min(100%, 78ch); margin-top: 40px; font-size: 12px; }
p { width: min(100%, 78ch); }
</style>
+156
View File
@@ -0,0 +1,156 @@
<script lang="ts">
import type { EntrySummary } from './api'
import { hooks, runCommand } from './commands'
import { diary } from './store.svelte'
import { formatDay, formatTime, formatWeekday } from './util'
let list = $state<HTMLUListElement>()
// Keep the cursor line visible while navigating with j/k.
$effect(() => {
const index = diary.cursor
const node = list?.querySelector<HTMLElement>(`[data-index="${index}"]`)
node?.scrollIntoView({ block: 'nearest' })
})
function label(entry: EntrySummary) {
return entry.title.trim() || entry.excerpt.trim() || '(empty)'
}
function pick(index: number) {
diary.cursor = index
void diary.guard(() => diary.openSelected())
}
</script>
<div class="sidebar">
<div class="head">
<span class="accent">~/diary</span>
<span class="faint count">
{diary.entries.length} {diary.entries.length === 1 ? 'entry' : 'entries'}
</span>
<span class="tools">
<button title="new entry (o)" onclick={() => runCommand('new')}>+</button>
<button title="search (/)" onclick={() => hooks.openCommandLine('/')}>/</button>
<button title="command line (:)" onclick={() => hooks.openCommandLine(':')}>:</button>
<button title="help (?)" onclick={() => runCommand('help')}>?</button>
</span>
</div>
{#if diary.query}
<div class="filter">
<span class="faint">/</span>{diary.query}
<button title="clear search" onclick={() => diary.guard(() => diary.search(''))}>esc</button>
</div>
{/if}
<ul bind:this={list}>
{#each diary.entries as entry, index (entry.id)}
{@const previous = diary.entries[index - 1]}
{#if !diary.query && (!previous || formatDay(previous.created_at) !== formatDay(entry.created_at))}
<li class="daybreak">
<span>{formatDay(entry.created_at)}</span>
<span class="faint">{formatWeekday(entry.created_at)}</span>
</li>
{/if}
<li>
<button
class="row"
class:active={index === diary.cursor}
class:open={diary.open?.id === entry.id}
data-index={index}
onclick={() => pick(index)}
>
<span class="caret">{index === diary.cursor ? '>' : ' '}</span>
<span class="time">{formatTime(entry.created_at)}</span>
<span class="title">{label(entry)}</span>
{#if entry.shared}<span class="shared" title="shared">◉</span>{/if}
</button>
</li>
{:else}
<li class="empty faint">
{diary.query ? 'no matches' : 'no entries yet — press o to write one'}
</li>
{/each}
</ul>
</div>
<style>
.sidebar {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-alt);
border-right: 1px solid var(--line);
min-width: 0;
}
.head,
.filter {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-bottom: 1px solid var(--line);
flex: none;
}
.filter {
color: var(--warn);
overflow: hidden;
white-space: nowrap;
}
ul {
list-style: none;
margin: 0;
padding: 4px 0 24px;
overflow-y: auto;
flex: 1;
}
.daybreak {
display: flex;
gap: 8px;
padding: 10px 10px 2px;
color: var(--fg-dim);
font-size: 12px;
position: sticky;
top: 0;
background: var(--bg-alt);
}
.row {
display: flex;
align-items: baseline;
gap: 8px;
width: 100%;
border: 0;
border-radius: 0;
padding: 2px 10px;
text-align: left;
color: var(--fg-dim);
}
.row:hover { background: var(--bg-lift); color: var(--fg); }
.row.open { color: var(--fg); }
.row.active { background: var(--select); color: var(--fg); }
.caret { color: var(--accent); flex: none; width: 1ch; }
.time { color: var(--fg-faint); flex: none; font-size: 12px; }
.title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.shared { color: var(--accent); flex: none; }
.empty { padding: 12px 10px; }
.count { flex: 1; text-align: right; overflow: hidden; white-space: nowrap; }
.tools { display: flex; gap: 2px; flex: none; }
.tools button { padding: 0 6px; line-height: 18px; }
/* Roomier tap targets where there is no keyboard. */
@media (pointer: coarse) {
.row { padding: 7px 10px; }
.tools button { padding: 4px 10px; }
.count { display: none; }
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import { diary } from './store.svelte'
import { isTouchDevice } from './util'
const mode = $derived(
diary.editing ? (diary.vimEnabled ? diary.vimMode.toUpperCase() : 'EDIT') : 'BROWSE',
)
const file = $derived(
diary.open ? `entry:${diary.open.id}${diary.dirty ? ' [+]' : ''}` : '[no entry]',
)
const lines = $derived(diary.editing ? diary.draft.body.split('\n').length : null)
</script>
<footer>
<span class="mode" class:insert={mode === 'INSERT'} class:visual={mode.startsWith('VISUAL')}>
{mode}
</span>
<span class="file">{file}</span>
<span class="msg" class:error={diary.flash?.kind === 'error'}>
{diary.flash?.text ?? ''}
</span>
{#if lines !== null}<span class="faint">{lines}L</span>{/if}
<span class="faint hint">
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
</span>
<span class="user accent">{diary.user}</span>
</footer>
<style>
footer {
display: flex;
align-items: center;
gap: 12px;
padding: 0 8px 0 0;
height: 24px;
background: var(--bg-lift);
border-top: 1px solid var(--line);
font-size: 12px;
flex: none;
white-space: nowrap;
overflow: hidden;
}
.mode {
background: var(--accent-dim);
color: var(--bg);
font-weight: 700;
padding: 0 10px;
height: 100%;
display: flex;
align-items: center;
letter-spacing: 0.08em;
}
.mode.insert { background: var(--accent); }
.mode.visual { background: var(--warn); }
.file { color: var(--fg-dim); flex: none; }
.msg { flex: 1; overflow: hidden; text-overflow: ellipsis; color: var(--fg); }
.msg.error { color: var(--error); }
.user { flex: none; }
@media (max-width: 720px) {
.hint { display: none; }
}
</style>
+109
View File
@@ -0,0 +1,109 @@
export interface EntrySummary {
id: number
title: string
excerpt: string
created_at: number
updated_at: number
shared: boolean
share_token: string | null
}
export interface Entry {
id: number
title: string
body: string
created_at: number
updated_at: number
shared: boolean
share_token: string | null
}
export interface SharedEntry {
title: string
body: string
created_at: number
updated_at: number
token: string
}
export interface MediaFile {
id: string
filename: string
mime: string
size: number
created_at: number
url: string
entry_id: number | null
}
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message)
}
}
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 },
})
if (!response.ok) {
let message = response.statusText
try {
message = (await response.json()).error ?? message
} catch {
/* the body was not JSON; the status text will do */
}
throw new ApiError(response.status, message)
}
return response.status === 204 ? (undefined as T) : await response.json()
}
const body = (data: unknown) => JSON.stringify(data)
export const api = {
me: () => request<{ user: string }>('/me'),
login: (username: string, password: string) =>
request<{ user: string }>('/login', { method: 'POST', body: body({ username, password }) }),
logout: () => request<{ ok: true }>('/logout', { method: 'POST' }),
listEntries: (q = '') =>
request<EntrySummary[]>(`/entries${q ? `?q=${encodeURIComponent(q)}` : ''}`),
getEntry: (id: number) => request<Entry>(`/entries/${id}`),
createEntry: (input: { title: string; body: string; created_at?: number }) =>
request<Entry>('/entries', { method: 'POST', body: body(input) }),
updateEntry: (id: number, input: { title: string; body: string; created_at?: number }) =>
request<Entry>(`/entries/${id}`, { method: 'PUT', body: body(input) }),
deleteEntry: (id: number) => request<{ ok: true }>(`/entries/${id}`, { method: 'DELETE' }),
share: (id: number) => request<{ token: string; path: string }>(`/entries/${id}/share`, { method: 'POST' }),
unshare: (id: number) => request<{ ok: true }>(`/entries/${id}/share`, { method: 'DELETE' }),
listMedia: () => request<MediaFile[]>('/media'),
upload: (files: File[]) => {
const form = new FormData()
for (const file of files) form.append('file', file)
return request<MediaFile[]>('/media', { method: 'POST', body: form })
},
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
readShared: (token: string) => request<SharedEntry>(`/share/${encodeURIComponent(token)}`),
}
+262
View File
@@ -0,0 +1,262 @@
import { diary } from './store.svelte'
import { parseDay } from './util'
/** Filled in by App.svelte so ex-commands can reach the DOM-bound bits. */
export const hooks = {
pickFiles: () => {},
focusEditor: () => {},
focusList: () => {},
insertText: (_text: string) => {},
openCommandLine: (_initial: string) => {},
}
export interface CommandSpec {
name: string
aliases?: string[]
args?: string
help: string
run: (arg: string) => void | Promise<void>
}
const requireOpen = () => {
if (!diary.open) {
diary.say('no entry open', 'error')
return null
}
return diary.open
}
export const commands: CommandSpec[] = [
{
name: 'write',
aliases: ['w'],
help: 'save the open entry',
run: () => diary.guard(() => diary.save()),
},
{
name: 'wq',
aliases: ['x'],
help: 'save and leave insert/editor mode',
run: async () => {
await diary.guard(() => diary.save())
diary.editing = false
hooks.focusList()
},
},
{
name: 'quit',
aliases: ['q'],
help: 'close the editor, or the entry when already reading',
run: () => {
if (diary.dirty) {
diary.say('unsaved changes — :w to write, :q! to discard', 'error')
return
}
if (diary.editing) diary.editing = false
else {
diary.open = null
diary.pane = 'list'
}
hooks.focusList()
},
},
{
name: 'q!',
help: 'discard changes and close the editor',
run: async () => {
const open = diary.open
diary.editing = false
diary.dirty = false
if (open) await diary.guard(() => diary.openEntry(open.id))
hooks.focusList()
},
},
{
name: 'new',
aliases: ['n', 'o'],
args: '[yyyy-mm-dd]',
help: 'start a new entry, optionally dated',
run: async (arg) => {
await diary.guard(async () => {
await diary.createEntry()
if (arg) {
const at = parseDay(arg)
if (at === null) return diary.say(`not a date: ${arg}`, 'error')
diary.draft.created_at = at
diary.dirty = true
await diary.save()
}
})
hooks.focusEditor()
},
},
{
name: 'edit',
aliases: ['e'],
args: '[id]',
help: 'edit the open entry, or open an entry by id',
run: async (arg) => {
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
if (!id || Number.isNaN(id)) return diary.say('usage: :e <id>', 'error')
await diary.guard(() => diary.openEntry(id, true))
hooks.focusEditor()
},
},
{
name: 'delete',
aliases: ['d', 'rm'],
args: '[id]',
help: 'delete an entry (asks first)',
run: async (arg) => {
const id = arg ? Number(arg) : diary.open?.id ?? diary.selected?.id
if (!id || Number.isNaN(id)) return diary.say('nothing to delete', 'error')
if (!confirm(`delete entry #${id}? this cannot be undone.`)) return
await diary.guard(() => diary.deleteEntry(id))
},
},
{
name: 'title',
args: '<text>',
help: 'set the title of the open entry',
run: async (arg) => {
if (!requireOpen()) return
diary.draft.title = arg
diary.dirty = true
await diary.guard(() => diary.save())
},
},
{
name: 'date',
args: '<yyyy-mm-dd>',
help: 're-date the open entry',
run: async (arg) => {
if (!requireOpen()) return
const at = parseDay(arg)
if (at === null) return diary.say('usage: :date 2026-09-05', 'error')
diary.draft.created_at = at
diary.dirty = true
await diary.guard(() => diary.save())
},
},
{
name: 'share',
help: 'publish the entry behind an unguessable link and copy it',
run: async () => {
const id = diary.open?.id ?? diary.selected?.id
if (!id) return diary.say('no entry selected', 'error')
const entry = diary.open?.id === id ? diary.open : diary.selected
if (entry?.shared) return diary.say('already shared — :link to copy, :unshare to revoke')
await diary.guard(() => diary.toggleShare(id))
},
},
{
name: 'unshare',
help: 'revoke the share link',
run: async () => {
const id = diary.open?.id ?? diary.selected?.id
if (!id) return diary.say('no entry selected', 'error')
const entry = diary.open?.id === id ? diary.open : diary.selected
if (!entry?.shared) return diary.say('entry is not shared')
await diary.guard(() => diary.toggleShare(id))
},
},
{
name: 'link',
help: 'copy the share link of the current entry',
run: async () => {
const token = diary.open?.share_token ?? diary.selected?.share_token
if (!token) return diary.say('entry is not shared — :share first', 'error')
const url = `${location.origin}/s/${token}`
diary.say((await diary.copy(url)) ? `copied ${url}` : url)
},
},
{
name: 'upload',
aliases: ['up'],
help: 'pick files to attach at the cursor',
run: () => hooks.pickFiles(),
},
{
name: 'media',
help: 'browse everything you have uploaded',
run: () => diary.guard(async () => {
await diary.loadMedia()
diary.overlay = 'media'
}),
},
{
name: 'search',
aliases: ['se'],
args: '[text]',
help: 'full-text search (empty clears)',
run: (arg) => diary.guard(() => diary.search(arg)),
},
{
name: 'set',
args: '<option>',
help: 'theme=green|amber|ice, vim, novim',
run: (arg) => {
const option = arg.trim()
if (option === 'vim') return diary.setVim(true), diary.say('vim keys on')
if (option === 'novim') return diary.setVim(false), diary.say('vim keys off')
const theme = /^theme=(green|amber|ice)$/.exec(option)?.[1]
if (theme) return diary.setTheme(theme as 'green' | 'amber' | 'ice'), diary.say(`theme ${theme}`)
diary.say(`unknown option: ${option}`, 'error')
},
},
{
name: 'help',
aliases: ['h'],
help: 'show the key and command reference',
run: () => {
diary.overlay = diary.overlay === 'help' ? 'none' : 'help'
},
},
{
name: 'logout',
help: 'end the session',
run: () => diary.guard(() => diary.logout()),
},
]
const lookup = new Map<string, CommandSpec>()
for (const spec of commands) {
lookup.set(spec.name, spec)
for (const alias of spec.aliases ?? []) lookup.set(alias, spec)
}
/** Run an ex-command line such as `w`, `date 2026-01-01` or `set theme=amber`. */
export async function runCommand(line: string): Promise<void> {
const input = line.trim().replace(/^:/, '')
if (!input) return
if (input.startsWith('/')) {
await diary.guard(() => diary.search(input.slice(1)))
return
}
const [head, ...rest] = input.split(/\s+/)
const arg = rest.join(' ')
// `:12` jumps to an entry by id, the way `:12` jumps to a line in vim.
if (/^\d+$/.test(head)) {
await diary.guard(() => diary.openEntry(Number(head)))
return
}
const spec = lookup.get(head)
if (!spec) {
diary.say(`E492: not an editor command: ${head}`, 'error')
return
}
await spec.run(arg)
}
/** Command names for the `:` completion menu. */
export function completions(prefix: string): CommandSpec[] {
const head = prefix.trim().replace(/^:/, '').split(/\s+/)[0] ?? ''
if (!head) return commands
return commands.filter(
(spec) => spec.name.startsWith(head) || spec.aliases?.some((a) => a.startsWith(head)),
)
}
+31
View File
@@ -0,0 +1,31 @@
import DOMPurify from 'dompurify'
import { marked } from 'marked'
marked.setOptions({ gfm: true, breaks: true })
/**
* Render entry markdown to sanitised HTML. When a share token is given, media
* URLs are rewritten to the public route so the page works for a reader who has
* no session.
*/
export function renderMarkdown(source: string, shareToken?: string): string {
const md = shareToken
? source.replaceAll('/api/media/', `/api/share/${encodeURIComponent(shareToken)}/media/`)
: source
const html = marked.parse(md, { async: false }) as string
return DOMPurify.sanitize(html, {
ADD_TAGS: ['video', 'audio', 'source'],
ADD_ATTR: ['controls', 'target', 'loading', 'playsinline', 'preload'],
})
}
/** The markdown snippet that embeds a freshly uploaded file. */
export function embedSnippet(file: { url: string; filename: string; mime: string }): string {
const name = file.filename.replaceAll(']', '')
if (file.mime.startsWith('image/')) return `![${name}](${file.url})`
if (file.mime.startsWith('video/')) return `<video src="${file.url}" controls playsinline></video>`
if (file.mime.startsWith('audio/')) return `<audio src="${file.url}" controls></audio>`
return `[${name}](${file.url})`
}
+205
View File
@@ -0,0 +1,205 @@
import { api, ApiError, type Entry, type EntrySummary, type MediaFile } from './api'
export type Pane = 'list' | 'entry'
export type Flash = { text: string; kind: 'info' | 'error' } | null
/**
* The whole application state. It is deliberately one object: the keyboard
* layer needs to see everything at once to decide what a key means.
*/
class Diary {
user = $state<string | null>(null)
booting = $state(true)
entries = $state<EntrySummary[]>([])
cursor = $state(0)
query = $state('')
open = $state<Entry | null>(null)
draft = $state({ title: '', body: '', created_at: 0 })
editing = $state(false)
/** Set when the editor should open in insert mode rather than normal mode. */
enterInsert = $state(false)
dirty = $state(false)
pane = $state<Pane>('list')
vimMode = $state('normal')
flash = $state<Flash>(null)
overlay = $state<'none' | 'help' | 'media'>('none')
media = $state<MediaFile[]>([])
theme = $state<'green' | 'amber' | 'ice'>(
(localStorage.getItem('diary:theme') as 'green' | 'amber' | 'ice') ?? 'green',
)
vimEnabled = $state(localStorage.getItem('diary:vim') !== 'off')
get selected(): EntrySummary | undefined {
return this.entries[this.cursor]
}
say(text: string, kind: 'info' | 'error' = 'info') {
this.flash = { text, kind }
if (kind === 'info') {
const current = this.flash
setTimeout(() => {
if (this.flash === current) this.flash = null
}, 4000)
}
}
async boot() {
try {
const { user } = await api.me()
this.user = user
await this.refresh()
} catch {
this.user = null
} finally {
this.booting = false
}
}
async login(username: string, password: string) {
const { user } = await api.login(username, password)
this.user = user
await this.refresh()
}
async logout() {
await api.logout()
this.user = null
this.entries = []
this.open = null
this.editing = false
}
async refresh() {
const keepId = this.selected?.id
this.entries = await api.listEntries(this.query)
const index = this.entries.findIndex((e) => e.id === keepId)
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.entries.length - 1, 0))
}
async search(query: string) {
this.query = query
this.cursor = 0
await this.refresh()
}
move(delta: number) {
if (this.entries.length === 0) return
this.cursor = Math.min(Math.max(this.cursor + delta, 0), this.entries.length - 1)
}
async openEntry(id: number, edit = false) {
const entry = await api.getEntry(id)
this.open = entry
this.draft = { title: entry.title, body: entry.body, created_at: entry.created_at }
this.dirty = false
this.editing = edit
this.pane = 'entry'
const index = this.entries.findIndex((e) => e.id === id)
if (index >= 0) this.cursor = index
}
async openSelected(edit = false) {
const selected = this.selected
if (selected) await this.openEntry(selected.id, edit)
}
/** A new entry is created immediately so uploads have something to attach to. */
async createEntry() {
const entry = await api.createEntry({ title: '', body: '' })
this.query = ''
await this.refresh()
await this.openEntry(entry.id, true)
this.enterInsert = true
this.say(`new entry #${entry.id}`)
}
async save() {
if (!this.open) return
const saved = await api.updateEntry(this.open.id, {
title: this.draft.title.trim(),
body: this.draft.body,
created_at: this.draft.created_at,
})
this.open = saved
this.dirty = false
await this.refresh()
this.say(`"entry:${saved.id}" ${saved.body.split('\n').length}L written`)
}
async deleteEntry(id: number) {
await api.deleteEntry(id)
if (this.open?.id === id) {
this.open = null
this.editing = false
this.pane = 'list'
}
await this.refresh()
this.say(`entry #${id} deleted`)
}
async toggleShare(id: number) {
const entry = this.open?.id === id ? this.open : await api.getEntry(id)
if (entry.shared) {
await api.unshare(id)
this.say(`entry #${id} is private again`)
} else {
const { token } = await api.share(id)
const url = `${location.origin}/s/${token}`
await this.copy(url)
this.say(`shared → ${url} (copied)`)
}
if (this.open?.id === id) this.open = await api.getEntry(id)
await this.refresh()
}
async copy(text: string) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
return false
}
}
async upload(files: File[]): Promise<MediaFile[]> {
const uploaded = await api.upload(files)
this.say(`uploaded ${uploaded.length} file${uploaded.length === 1 ? '' : 's'}`)
return uploaded
}
async loadMedia() {
this.media = await api.listMedia()
}
setTheme(theme: 'green' | 'amber' | 'ice') {
this.theme = theme
localStorage.setItem('diary:theme', theme)
}
setVim(enabled: boolean) {
this.vimEnabled = enabled
localStorage.setItem('diary:vim', enabled ? 'on' : 'off')
}
/** Surface a failed API call in the status line instead of the console. */
async guard(action: () => Promise<unknown>) {
try {
await action()
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
this.user = null
this.say('session expired — log in again', 'error')
} else {
this.say(error instanceof Error ? error.message : String(error), 'error')
}
}
}
}
export const diary = new Diary()
+63
View File
@@ -0,0 +1,63 @@
const pad = (n: number) => String(n).padStart(2, '0')
export function toDate(seconds: number): Date {
return new Date(seconds * 1000)
}
/** `2026-09-05` — the diary is organised by day, so this is the primary label. */
export function formatDay(seconds: number): string {
const d = toDate(seconds)
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
export function formatTime(seconds: number): string {
const d = toDate(seconds)
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function formatStamp(seconds: number): string {
return `${formatDay(seconds)} ${formatTime(seconds)}`
}
export function formatWeekday(seconds: number): string {
return toDate(seconds).toLocaleDateString(undefined, { weekday: 'short' }).toLowerCase()
}
export function relative(seconds: number): string {
const delta = Math.floor(Date.now() / 1000) - seconds
if (delta < 60) return 'just now'
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`
if (delta < 86400 * 30) return `${Math.floor(delta / 86400)}d ago`
return formatDay(seconds)
}
export function formatBytes(bytes: number): string {
const units = ['B', 'K', 'M', 'G']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)}${units[unit]}`
}
/** Parse the `YYYY-MM-DD` argument of `:date` into a Unix timestamp at noon. */
export function parseDay(input: string): number | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input.trim())
if (!match) return null
const [, y, m, d] = match
const date = new Date(Number(y), Number(m) - 1, Number(d), 12, 0, 0)
return Number.isNaN(date.getTime()) ? null : Math.floor(date.getTime() / 1000)
}
/**
* True on phones and tablets, where modal editing would be a burden. Both
* checks are needed: a touchscreen laptop reports touch points but still has a
* fine primary pointer, and headless browsers report `hover: none`.
*/
export const isTouchDevice =
typeof window !== 'undefined' &&
navigator.maxTouchPoints > 0 &&
window.matchMedia('(pointer: coarse)').matches
+5
View File
@@ -0,0 +1,5 @@
import { mount } from 'svelte'
import App from './App.svelte'
import './app.css'
export default mount(App, { target: document.getElementById('app')! })