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>