mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 17:07:00 +01:00
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:
commit
4002a35305
41 files changed
+6195
No files matched your search
@@ -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
@@ -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); }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)}`),
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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 ``
|
||||
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})`
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
import './app.css'
|
||||
|
||||
export default mount(App, { target: document.getElementById('app')! })
|
||||
Reference in new issue
Block a user