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

+22
View File
@@ -0,0 +1,22 @@
# The one and only account. Both are checked in constant time on login.
DIARY_USER=narl
DIARY_PASSWORD=change-me
# Signs session cookies. Rotate it to log every device out. 32+ random chars.
# Generate one with: openssl rand -base64 32
DIARY_SECRET=replace-with-32-random-characters
# Where the SQLite database and uploaded media live.
DIARY_DATA_DIR=./data
# Address to listen on. Put a TLS terminating reverse proxy in front of this.
DIARY_BIND=127.0.0.1:4242
# How long a login lasts, in days.
DIARY_SESSION_DAYS=30
# Largest single upload, in megabytes.
DIARY_MAX_UPLOAD_MB=64
# Set to 1 once the app is served over HTTPS, so the cookie gets `Secure`.
DIARY_SECURE_COOKIE=0
+12
View File
@@ -0,0 +1,12 @@
# rust
/target
# runtime state: sqlite db + uploaded media
/data
# frontend
/web/dist
/web/node_modules
# secrets
.env
Generated
+2433
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "narl-diary"
version = "0.1.0"
edition = "2021"
description = "A terminal-themed personal diary for the browser"
[dependencies]
axum = { version = "0.8", features = ["multipart", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "fs"] }
tower = "0.5"
tokio-util = { version = "0.7", features = ["io"] }
tower-http = { version = "0.6", features = ["trace", "compression-gzip", "limit"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "migrate", "macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
uuid = { version = "1", features = ["v4"] }
rand = "0.8"
hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
subtle = "2"
mime_guess = "2"
rust-embed = { version = "8", features = ["interpolate-folder-path"] }
regex = "1"
anyhow = "1"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
+24
View File
@@ -0,0 +1,24 @@
.PHONY: dev dev-api dev-web build run check clean
# Two terminals: `make dev-api` and `make dev-web` (http://127.0.0.1:4243).
dev-api:
cargo run
dev-web:
cd web && bun run dev
# Everything, as one self-contained release binary at target/release/narl-diary.
build:
cd web && bun install && bun run build
cargo build --release
run: build
./target/release/narl-diary
check:
cargo clippy --all-targets -- -D warnings
cd web && bun run check
clean:
cargo clean
rm -rf web/dist web/node_modules
+94
View File
@@ -0,0 +1,94 @@
# ~/diary
A private, terminal-themed diary that runs in the browser. One user, one SQLite
file, one binary.
- **Rust + axum** backend, **SQLite** storage, **Svelte 5** frontend.
- Markdown entries with full-text search (SQLite FTS5).
- Drag, drop or paste images, video and audio straight into an entry.
- Share any entry behind an unguessable link — revocable at any time.
- Real vim keybindings on the desktop (CodeMirror 6 + vim mode), plain taps on
mobile.
- The whole frontend is embedded in the release binary, so deploying is copying
one file.
## Quick start
```sh
cp .env.example .env # set DIARY_USER, DIARY_PASSWORD and DIARY_SECRET
make build # builds the frontend, then the release binary
./target/release/narl-diary
```
Then open <http://127.0.0.1:4242>.
`DIARY_SECRET` signs the session cookie; generate one with
`openssl rand -base64 32`. Changing it logs every device out. The database and
uploads live under `DIARY_DATA_DIR` (`./data` by default) — that directory is
the entire backup.
### Development
```sh
make dev-api # cargo run, serving the API on :4242
make dev-web # vite dev server on :4243, proxying /api to :4242
```
In debug builds the binary reads `web/dist` from disk, so `cargo run` alone also
works once the frontend has been built at least once.
## Keys
The desktop UI is modal. In the browse pane:
| key | action |
| --- | --- |
| `j` / `k` | move down / up |
| `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` clears it) |
| `s` / `y` | toggle sharing / copy the share link |
| `x`, `dd` | delete the entry |
| `:` | command line |
| `Ctrl-S` | save from anywhere |
| `?` | help |
Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual
mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are
`:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:title`, `:share`, `:link`,
`:upload`, `:media`, `:search`, `:set theme=green|amber|ice` and `:set novim`.
On a touch device modal editing is turned off and the same actions are buttons.
## Sharing
`:share` mints a 192-bit random token and copies `https://your-host/s/<token>`.
That page needs no session, renders the entry read-only, and serves only the
media files that entry currently embeds — dropping a picture out of a shared
entry immediately makes it unreachable through the link. `:unshare` destroys the
token, and a new `:share` mints a different one.
## API
Everything except the two share routes requires the session cookie.
| method | path | purpose |
| --- | --- | --- |
| `POST` | `/api/login`, `/api/logout` | session |
| `GET` | `/api/me` | current user |
| `GET`/`POST` | `/api/entries` | list (`?q=` searches) / create |
| `GET`/`PUT`/`DELETE` | `/api/entries/{id}` | read / update / delete |
| `POST`/`DELETE` | `/api/entries/{id}/share` | mint / revoke a share token |
| `GET`/`POST` | `/api/media` | list / upload (multipart) |
| `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file |
| `GET` | `/api/share/{token}` | public: read a shared entry |
| `GET` | `/api/share/{token}/media/{id}` | public: media inside a shared entry |
## Deploying
Put a TLS-terminating reverse proxy in front of it, point it at `DIARY_BIND`,
and set `DIARY_SECURE_COOKIE=1`. Nothing else is needed: no database server, no
runtime dependencies, no build tools on the host.
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
share_token TEXT UNIQUE
);
CREATE INDEX idx_entries_created_at ON entries (created_at DESC);
CREATE INDEX idx_entries_share_token ON entries (share_token);
CREATE TABLE media (
id TEXT PRIMARY KEY,
entry_id INTEGER REFERENCES entries (id) ON DELETE SET NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX idx_media_entry_id ON media (entry_id);
CREATE VIRTUAL TABLE entries_fts USING fts5 (
title, body, content = 'entries', content_rowid = 'id', tokenize = 'unicode61'
);
CREATE TRIGGER entries_ai AFTER INSERT ON entries BEGIN
INSERT INTO entries_fts (rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER entries_ad AFTER DELETE ON entries BEGIN
INSERT INTO entries_fts (entries_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER entries_au AFTER UPDATE ON entries BEGIN
INSERT INTO entries_fts (entries_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
INSERT INTO entries_fts (rowid, title, body) VALUES (new.id, new.title, new.body);
END;
+96
View File
@@ -0,0 +1,96 @@
use axum::{
extract::FromRequestParts,
http::{header, request::Parts},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::{config::Config, error::AppError, state::AppState};
pub const COOKIE_NAME: &str = "diary_session";
type HmacSha256 = Hmac<Sha256>;
fn sign(secret: &[u8], payload: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length");
mac.update(payload.as_bytes());
B64.encode(mac.finalize().into_bytes())
}
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no
/// server-side session store: rotating `DIARY_SECRET` invalidates every session.
pub fn issue_token(config: &Config) -> String {
let expires = crate::now() + config.session_days * 86_400;
let payload = B64.encode(expires.to_string());
let signature = sign(&config.secret, &payload);
format!("{payload}.{signature}")
}
pub fn verify_token(config: &Config, token: &str) -> bool {
let Some((payload, signature)) = token.split_once('.') else {
return false;
};
let expected = sign(&config.secret, payload);
if !bool::from(expected.as_bytes().ct_eq(signature.as_bytes())) {
return false;
}
let Ok(decoded) = B64.decode(payload) else {
return false;
};
let Ok(expires) = String::from_utf8_lossy(&decoded).parse::<i64>() else {
return false;
};
expires > crate::now()
}
pub fn credentials_match(config: &Config, username: &str, password: &str) -> bool {
let user_ok = username.as_bytes().ct_eq(config.username.as_bytes());
let pass_ok = password.as_bytes().ct_eq(config.password.as_bytes());
bool::from(user_ok & pass_ok)
}
pub fn session_cookie(config: &Config, token: &str) -> String {
let max_age = config.session_days * 86_400;
let secure = if config.secure_cookie { "; Secure" } else { "" };
format!(
"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
)
}
pub fn clear_cookie(config: &Config) -> String {
let secure = if config.secure_cookie { "; Secure" } else { "" };
format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}")
}
fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> {
parts
.headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|pair| pair.split_once('='))
.find(|(k, _)| k.trim() == name)
.map(|(_, v)| v.trim())
}
/// Extractor that rejects the request unless a valid session cookie is present.
pub struct Session;
impl FromRequestParts<AppState> for Session {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let token = cookie_value(parts, COOKIE_NAME).ok_or(AppError::Unauthorized)?;
if verify_token(&state.config, token) {
Ok(Session)
} else {
Err(AppError::Unauthorized)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
use std::{net::SocketAddr, path::PathBuf};
use anyhow::{Context, Result};
/// Runtime configuration. Everything comes from the environment (`.env` is
/// loaded on startup), because this is a deliberately single-user application.
#[derive(Debug, Clone)]
pub struct Config {
pub username: String,
pub password: String,
pub secret: Vec<u8>,
pub bind: SocketAddr,
pub data_dir: PathBuf,
pub session_days: i64,
pub max_upload_bytes: usize,
pub secure_cookie: bool,
}
fn var(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}
impl Config {
pub fn from_env() -> Result<Self> {
let username = var("DIARY_USER").context("DIARY_USER must be set (see .env.example)")?;
let password =
var("DIARY_PASSWORD").context("DIARY_PASSWORD must be set (see .env.example)")?;
let secret = var("DIARY_SECRET")
.context("DIARY_SECRET must be set (see .env.example)")?
.into_bytes();
if secret.len() < 16 {
anyhow::bail!("DIARY_SECRET must be at least 16 characters");
}
let bind = var("DIARY_BIND")
.unwrap_or_else(|| "127.0.0.1:4242".to_string())
.parse()
.context("DIARY_BIND must look like 127.0.0.1:4242")?;
let data_dir = PathBuf::from(var("DIARY_DATA_DIR").unwrap_or_else(|| "./data".into()));
let session_days = var("DIARY_SESSION_DAYS")
.map(|v| v.parse())
.transpose()
.context("DIARY_SESSION_DAYS must be a number")?
.unwrap_or(30);
let max_upload_bytes = var("DIARY_MAX_UPLOAD_MB")
.map(|v| v.parse::<usize>())
.transpose()
.context("DIARY_MAX_UPLOAD_MB must be a number")?
.unwrap_or(64)
* 1024
* 1024;
let secure_cookie = var("DIARY_SECURE_COOKIE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
Ok(Self {
username,
password,
secret,
bind,
data_dir,
session_days,
max_upload_bytes,
secure_cookie,
})
}
pub fn uploads_dir(&self) -> PathBuf {
self.data_dir.join("uploads")
}
}
+43
View File
@@ -0,0 +1,43 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
#[derive(Debug)]
pub enum AppError {
Unauthorized,
NotFound,
BadRequest(String),
Internal(anyhow::Error),
}
pub type AppResult<T> = Result<T, AppError>;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
AppError::Internal(e) => {
tracing::error!("internal error: {e:#}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
AppError::Internal(err.into())
}
}
+87
View File
@@ -0,0 +1,87 @@
mod auth;
mod config;
mod error;
mod routes;
mod state;
mod static_files;
use std::sync::Arc;
use anyhow::{Context, Result};
use axum::Router;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use crate::{config::Config, state::AppState};
/// Seconds since the Unix epoch. Every timestamp in the database uses this.
pub fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock is after 1970")
.as_secs() as i64
}
#[tokio::main]
async fn main() -> Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(EnvFilter::try_from_env("DIARY_LOG").unwrap_or_else(|_| EnvFilter::new("info")))
.with(tracing_subscriber::fmt::layer())
.init();
let config = Config::from_env()?;
tokio::fs::create_dir_all(config.uploads_dir())
.await
.with_context(|| format!("could not create {}", config.uploads_dir().display()))?;
let db = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(
SqliteConnectOptions::new()
.filename(config.data_dir.join("diary.db"))
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.foreign_keys(true),
)
.await
.context("could not open the diary database")?;
sqlx::migrate!("./migrations")
.run(&db)
.await
.context("could not apply database migrations")?;
let bind = config.bind;
let max_upload = config.max_upload_bytes;
let state = AppState {
db,
config: Arc::new(config),
};
let app = Router::new()
.nest("/api", routes::api_router())
.fallback(static_files::handler)
.layer(axum::extract::DefaultBodyLimit::max(max_upload + 1024 * 1024))
.layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http())
.with_state(state);
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("could not bind {bind}"))?;
tracing::info!("narl-diary listening on http://{bind}");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("shutting down");
}
+259
View File
@@ -0,0 +1,259 @@
use axum::{
extract::{Path, Query, State},
Json,
};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::media,
state::AppState,
};
#[derive(Serialize)]
pub struct EntrySummary {
pub id: i64,
pub title: String,
pub excerpt: String,
pub created_at: i64,
pub updated_at: i64,
pub shared: bool,
pub share_token: Option<String>,
}
#[derive(Serialize)]
pub struct Entry {
pub id: i64,
pub title: String,
pub body: String,
pub created_at: i64,
pub updated_at: i64,
pub shared: bool,
pub share_token: Option<String>,
}
#[derive(Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
#[serde(default)]
pub offset: Option<i64>,
}
#[derive(Deserialize)]
pub struct EntryInput {
#[serde(default)]
pub title: String,
#[serde(default)]
pub body: String,
/// Optional override so an entry can be backdated to the day it is about.
#[serde(default)]
pub created_at: Option<i64>,
}
/// A short, plain-ish preview of the body for the entry list.
fn excerpt(body: &str) -> String {
let flat: String = body
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with("![") && !line.starts_with("```"))
.map(|line| line.trim_start_matches(['#', '>', '-', '*', ' ']))
.collect::<Vec<_>>()
.join(" ");
let mut out: String = flat.chars().take(180).collect();
if flat.chars().count() > 180 {
out.push('…');
}
out
}
/// Turn free text into an FTS5 prefix query, discarding anything that could be
/// read as FTS syntax.
fn fts_query(raw: &str) -> Option<String> {
let terms: Vec<String> = raw
.split_whitespace()
.map(|term| term.replace(|c: char| !c.is_alphanumeric(), " "))
.flat_map(|term| {
term.split_whitespace()
.map(str::to_string)
.collect::<Vec<_>>()
})
.map(|term| format!("\"{term}\"*"))
.collect();
(!terms.is_empty()).then(|| terms.join(" AND "))
}
pub async fn list(
_: Session,
State(state): State<AppState>,
Query(params): Query<ListQuery>,
) -> AppResult<Json<Vec<EntrySummary>>> {
let limit = params.limit.unwrap_or(200).clamp(1, 1000);
let offset = params.offset.unwrap_or(0).max(0);
let search = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty());
let rows = match search.and_then(fts_query) {
Some(query) => {
sqlx::query(
"SELECT e.id, e.title, e.body, e.created_at, e.updated_at, e.share_token
FROM entries_fts
JOIN entries e ON e.id = entries_fts.rowid
WHERE entries_fts MATCH ?1
ORDER BY rank
LIMIT ?2 OFFSET ?3",
)
.bind(query)
.bind(limit)
.bind(offset)
.fetch_all(&state.db)
.await?
}
None => {
sqlx::query(
"SELECT id, title, body, created_at, updated_at, share_token
FROM entries
ORDER BY created_at DESC, id DESC
LIMIT ?1 OFFSET ?2",
)
.bind(limit)
.bind(offset)
.fetch_all(&state.db)
.await?
}
};
let entries = rows
.into_iter()
.map(|row| {
let body: String = row.get("body");
let share_token: Option<String> = row.get("share_token");
EntrySummary {
id: row.get("id"),
title: row.get("title"),
excerpt: excerpt(&body),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
shared: share_token.is_some(),
share_token,
}
})
.collect();
Ok(Json(entries))
}
pub async fn get(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Entry>> {
load(&state, id).await.map(Json)
}
pub async fn create(
_: Session,
State(state): State<AppState>,
Json(input): Json<EntryInput>,
) -> AppResult<Json<Entry>> {
let now = crate::now();
let created_at = input.created_at.unwrap_or(now);
let id: i64 = sqlx::query(
"INSERT INTO entries (title, body, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4)
RETURNING id",
)
.bind(&input.title)
.bind(&input.body)
.bind(created_at)
.bind(now)
.fetch_one(&state.db)
.await?
.get("id");
media::link_to_entry(&state.db, id, &input.body).await?;
load(&state, id).await.map(Json)
}
pub async fn update(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
Json(input): Json<EntryInput>,
) -> AppResult<Json<Entry>> {
let affected = sqlx::query(
"UPDATE entries
SET title = ?1, body = ?2, updated_at = ?3, created_at = COALESCE(?4, created_at)
WHERE id = ?5",
)
.bind(&input.title)
.bind(&input.body)
.bind(crate::now())
.bind(input.created_at)
.bind(id)
.execute(&state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound);
}
media::link_to_entry(&state.db, id, &input.body).await?;
load(&state, id).await.map(Json)
}
pub async fn remove(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
let media_ids: Vec<String> = sqlx::query("SELECT id FROM media WHERE entry_id = ?1")
.bind(id)
.fetch_all(&state.db)
.await?
.into_iter()
.map(|row| row.get("id"))
.collect();
let affected = sqlx::query("DELETE FROM entries WHERE id = ?1")
.bind(id)
.execute(&state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound);
}
for media_id in media_ids {
media::delete_media(&state, &media_id).await?;
}
Ok(Json(serde_json::json!({ "ok": true })))
}
async fn load(state: &AppState, id: i64) -> AppResult<Entry> {
let row = sqlx::query(
"SELECT id, title, body, created_at, updated_at, share_token FROM entries WHERE id = ?1",
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let share_token: Option<String> = row.get("share_token");
Ok(Entry {
id: row.get("id"),
title: row.get("title"),
body: row.get("body"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
shared: share_token.is_some(),
share_token,
})
}
+224
View File
@@ -0,0 +1,224 @@
use std::sync::LazyLock;
use axum::{
body::Body,
extract::{Multipart, Path, State},
http::header,
response::{IntoResponse, Response},
Json,
};
use regex::Regex;
use serde::Serialize;
use sqlx::{Row, SqlitePool};
use tokio_util::io::ReaderStream;
use uuid::Uuid;
use crate::{
auth::Session,
error::{AppError, AppResult},
state::AppState,
};
/// Media referenced from an entry body always looks like `/api/media/<uuid>`,
/// which is how an entry claims ownership of the files it embeds.
static MEDIA_REF: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"/api/media/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})")
.expect("static regex is valid")
});
#[derive(Serialize)]
pub struct Media {
pub id: String,
pub filename: String,
pub mime: String,
pub size: i64,
pub created_at: i64,
pub url: String,
pub entry_id: Option<i64>,
}
fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media {
let id: String = row.get("id");
Media {
url: format!("/api/media/{id}"),
id,
filename: row.get("filename"),
mime: row.get("mime"),
size: row.get("size"),
created_at: row.get("created_at"),
entry_id: row.get("entry_id"),
}
}
pub async fn upload(
_: Session,
State(state): State<AppState>,
mut multipart: Multipart,
) -> AppResult<Json<Vec<Media>>> {
let mut uploaded = Vec::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("malformed upload: {e}")))?
{
let filename = field
.file_name()
.map(str::to_string)
.unwrap_or_else(|| "upload".to_string());
let mime = field
.content_type()
.map(str::to_string)
.filter(|m| !m.is_empty())
.unwrap_or_else(|| {
mime_guess::from_path(&filename)
.first_or_octet_stream()
.to_string()
});
let data = field
.bytes()
.await
.map_err(|e| AppError::BadRequest(format!("could not read upload: {e}")))?;
if data.is_empty() {
continue;
}
if data.len() > state.config.max_upload_bytes {
return Err(AppError::BadRequest(format!(
"{filename} is larger than the {} MB limit",
state.config.max_upload_bytes / 1024 / 1024
)));
}
let id = Uuid::new_v4().to_string();
tokio::fs::write(state.config.uploads_dir().join(&id), &data).await?;
let row = sqlx::query(
"INSERT INTO media (id, filename, mime, size, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
RETURNING id, entry_id, filename, mime, size, created_at",
)
.bind(&id)
.bind(&filename)
.bind(&mime)
.bind(data.len() as i64)
.bind(crate::now())
.fetch_one(&state.db)
.await?;
uploaded.push(row_to_media(&row));
}
if uploaded.is_empty() {
return Err(AppError::BadRequest("no files in upload".into()));
}
Ok(Json(uploaded))
}
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
let rows = sqlx::query(
"SELECT id, entry_id, filename, mime, size, created_at
FROM media ORDER BY created_at DESC LIMIT 500",
)
.fetch_all(&state.db)
.await?;
Ok(Json(rows.iter().map(row_to_media).collect()))
}
pub async fn serve(
_: Session,
State(state): State<AppState>,
Path(id): Path<String>,
) -> AppResult<Response> {
stream_media(&state, &id).await
}
pub async fn remove(
_: Session,
State(state): State<AppState>,
Path(id): Path<String>,
) -> AppResult<Json<serde_json::Value>> {
delete_media(&state, &id).await?;
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Shared by the private and the public-share media handlers.
pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
let row = sqlx::query("SELECT mime, filename, size FROM media WHERE id = ?1")
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
let mime: String = row.get("mime");
let filename: String = row.get("filename");
let size: i64 = row.get("size");
let file = tokio::fs::File::open(state.config.uploads_dir().join(id))
.await
.map_err(|_| AppError::NotFound)?;
Ok((
[
(header::CONTENT_TYPE, mime),
(header::CONTENT_LENGTH, size.to_string()),
(header::CACHE_CONTROL, "private, max-age=31536000".into()),
(
header::CONTENT_DISPOSITION,
format!("inline; filename*=UTF-8''{}", urlencode(&filename)),
),
],
Body::from_stream(ReaderStream::new(file)),
)
.into_response())
}
pub async fn delete_media(state: &AppState, id: &str) -> AppResult<()> {
sqlx::query("DELETE FROM media WHERE id = ?1")
.bind(id)
.execute(&state.db)
.await?;
let path = state.config.uploads_dir().join(id);
if let Err(e) = tokio::fs::remove_file(&path).await {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(e.into());
}
}
Ok(())
}
/// Attach every media file the body embeds to this entry, and detach the ones
/// it no longer mentions so they stop being reachable through its share link.
pub async fn link_to_entry(db: &SqlitePool, entry_id: i64, body: &str) -> AppResult<()> {
let referenced: Vec<String> = MEDIA_REF
.captures_iter(body)
.map(|c| c[1].to_lowercase())
.collect();
sqlx::query("UPDATE media SET entry_id = NULL WHERE entry_id = ?1")
.bind(entry_id)
.execute(db)
.await?;
for id in referenced {
sqlx::query("UPDATE media SET entry_id = ?1 WHERE id = ?2")
.bind(entry_id)
.bind(id)
.execute(db)
.await?;
}
Ok(())
}
fn urlencode(value: &str) -> String {
value
.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
+28
View File
@@ -0,0 +1,28 @@
pub mod entries;
pub mod media;
pub mod session;
pub mod share;
use axum::{
routing::{delete, get, post, put},
Router,
};
use crate::state::AppState;
pub fn api_router() -> Router<AppState> {
Router::new()
.route("/me", get(session::me))
.route("/login", post(session::login))
.route("/logout", post(session::logout))
.route("/entries", get(entries::list).post(entries::create))
.route("/entries/{id}", get(entries::get))
.route("/entries/{id}", put(entries::update))
.route("/entries/{id}", delete(entries::remove))
.route("/entries/{id}/share", post(share::enable))
.route("/entries/{id}/share", delete(share::disable))
.route("/media", post(media::upload).get(media::list))
.route("/media/{id}", get(media::serve).delete(media::remove))
.route("/share/{token}", get(share::read))
.route("/share/{token}/media/{id}", get(share::serve_media))
}
+50
View File
@@ -0,0 +1,50 @@
use axum::{
extract::State,
http::{header, StatusCode},
response::IntoResponse,
Json,
};
use serde::Deserialize;
use serde_json::json;
use crate::{
auth::{self, Session},
error::{AppError, AppResult},
state::AppState,
};
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
pub async fn login(
State(state): State<AppState>,
Json(body): Json<LoginRequest>,
) -> AppResult<impl IntoResponse> {
if !auth::credentials_match(&state.config, &body.username, &body.password) {
return Err(AppError::BadRequest("invalid credentials".into()));
}
let token = auth::issue_token(&state.config);
Ok((
StatusCode::OK,
[(
header::SET_COOKIE,
auth::session_cookie(&state.config, &token),
)],
Json(json!({ "user": state.config.username })),
))
}
pub async fn logout(State(state): State<AppState>) -> impl IntoResponse {
(
StatusCode::OK,
[(header::SET_COOKIE, auth::clear_cookie(&state.config))],
Json(json!({ "ok": true })),
)
}
pub async fn me(_: Session, State(state): State<AppState>) -> impl IntoResponse {
Json(json!({ "user": state.config.username }))
}
+121
View File
@@ -0,0 +1,121 @@
use axum::{
extract::{Path, State},
response::Response,
Json,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
use rand::RngCore;
use serde::Serialize;
use serde_json::json;
use sqlx::Row;
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::media,
state::AppState,
};
#[derive(Serialize)]
pub struct SharedEntry {
pub title: String,
pub body: String,
pub created_at: i64,
pub updated_at: i64,
pub token: String,
}
fn new_token() -> String {
let mut bytes = [0u8; 24];
rand::thread_rng().fill_bytes(&mut bytes);
B64.encode(bytes)
}
pub async fn enable(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
let existing: Option<Option<String>> = sqlx::query("SELECT share_token FROM entries WHERE id = ?1")
.bind(id)
.fetch_optional(&state.db)
.await?
.map(|row| row.get("share_token"));
let token = match existing {
None => return Err(AppError::NotFound),
Some(Some(token)) => token,
Some(None) => {
let token = new_token();
sqlx::query("UPDATE entries SET share_token = ?1 WHERE id = ?2")
.bind(&token)
.bind(id)
.execute(&state.db)
.await?;
token
}
};
Ok(Json(json!({ "token": token, "path": format!("/s/{token}") })))
}
pub async fn disable(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
let affected = sqlx::query("UPDATE entries SET share_token = NULL WHERE id = ?1")
.bind(id)
.execute(&state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound);
}
Ok(Json(json!({ "ok": true })))
}
/// Public: no session required, the unguessable token is the credential.
pub async fn read(
State(state): State<AppState>,
Path(token): Path<String>,
) -> AppResult<Json<SharedEntry>> {
let row = sqlx::query(
"SELECT title, body, created_at, updated_at FROM entries WHERE share_token = ?1",
)
.bind(&token)
.fetch_optional(&state.db)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(SharedEntry {
title: row.get("title"),
body: row.get("body"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
token,
}))
}
/// Public: serves a media file only while it is embedded in a shared entry.
pub async fn serve_media(
State(state): State<AppState>,
Path((token, id)): Path<(String, String)>,
) -> AppResult<Response> {
let allowed = sqlx::query(
"SELECT 1 AS ok
FROM media m
JOIN entries e ON e.id = m.entry_id
WHERE m.id = ?1 AND e.share_token = ?2",
)
.bind(&id)
.bind(&token)
.fetch_optional(&state.db)
.await?
.is_some();
if !allowed {
return Err(AppError::NotFound);
}
media::stream_media(&state, &id).await
}
+11
View File
@@ -0,0 +1,11 @@
use std::sync::Arc;
use sqlx::SqlitePool;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
pub config: Arc<Config>,
}
+45
View File
@@ -0,0 +1,45 @@
use axum::{
http::{header, StatusCode, Uri},
response::{IntoResponse, Response},
};
use rust_embed::Embed;
/// The built Svelte app. In debug builds rust-embed reads from disk, so a
/// `vite build --watch` next to `cargo run` is enough for iteration.
#[derive(Embed)]
#[folder = "$CARGO_MANIFEST_DIR/web/dist"]
struct Assets;
pub async fn handler(uri: Uri) -> Response {
let path = uri.path().trim_start_matches('/');
match Assets::get(path) {
Some(file) => serve(path, file),
// Unknown paths are client-side routes; hand them the shell.
None => match Assets::get("index.html") {
Some(index) => serve("index.html", index),
None => (
StatusCode::NOT_FOUND,
"frontend not built - run `bun run build` in web/",
)
.into_response(),
},
}
}
fn serve(path: &str, file: rust_embed::EmbeddedFile) -> Response {
let mime = mime_guess::from_path(path).first_or_octet_stream();
// Vite fingerprints everything under /assets/, so it is safe to cache hard.
let cache = if path.starts_with("assets/") {
"public, max-age=31536000, immutable"
} else {
"no-cache"
};
(
[
(header::CONTENT_TYPE, mime.as_ref()),
(header::CACHE_CONTROL, cache),
],
file.data,
)
.into_response()
}
+277
View File
@@ -0,0 +1,277 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "narl-diary-web",
"dependencies": {
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-markdown": "^6.3.1",
"@codemirror/language": "^6.10.8",
"@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.2",
"@lezer/highlight": "^1.2.1",
"@replit/codemirror-vim": "^6.2.1",
"dompurify": "^3.2.4",
"marked": "^15.0.6",
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.0.11",
},
},
},
"packages": {
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
"@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="],
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="],
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
"@codemirror/lang-markdown": ["@codemirror/lang-markdown@6.5.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.7.1", "@codemirror/lang-html": "^6.0.0", "@codemirror/language": "^6.3.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0", "@lezer/common": "^1.2.1", "@lezer/markdown": "^1.0.0" } }, "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw=="],
"@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="],
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
"@codemirror/search": ["@codemirror/search@6.7.2", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA=="],
"@codemirror/state": ["@codemirror/state@6.7.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA=="],
"@codemirror/view": ["@codemirror/view@6.43.11", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/css": ["@lezer/css@1.3.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
"@lezer/html": ["@lezer/html@1.3.13", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg=="],
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
"@lezer/markdown": ["@lezer/markdown@1.7.2", "", { "dependencies": { "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0" } }, "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="],
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
"@replit/codemirror-vim": ["@replit/codemirror-vim@6.4.0", "", { "dependencies": { "@replit/codemirror-vim-core": "^0.1.0" }, "peerDependencies": { "@codemirror/commands": "6.x.x", "@codemirror/language": "6.x.x", "@codemirror/search": "6.x.x", "@codemirror/state": "6.x.x", "@codemirror/view": "6.x.x" } }, "sha512-t9UMDNhkmeAkl0uRbiJVotv97bGD6mf4GyJJoEbrjUqa/Pov0s9eL+4AaI0Xto3OGri17i9qwIpT0JbVlVXt8A=="],
"@replit/codemirror-vim-core": ["@replit/codemirror-vim-core@0.1.0", "", {}, "sha512-1i6EBKpcNfDKvTmTh6N6g9lL6udD5t+uFNh4JCqozRnVlvUGOps7h/QzS2ne4zcvPUjvApKpmcP7Grc3fNbZiQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.1", "", { "os": "android", "cpu": "arm" }, "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.1", "", { "os": "android", "cpu": "arm64" }, "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.1", "", { "os": "linux", "cpu": "arm" }, "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.1", "", { "os": "linux", "cpu": "none" }, "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.1", "", { "os": "linux", "cpu": "x64" }, "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.1", "", { "os": "none", "cpu": "arm64" }, "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.1", "", { "os": "win32", "cpu": "x64" }, "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.13", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ=="],
"@sveltejs/load-config": ["@sveltejs/load-config@0.2.3", "", {}, "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@5.1.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
"@tsconfig/svelte": ["@tsconfig/svelte@5.0.8", "", {}, "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"crelt": ["crelt@1.0.7", "", {}, "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"devalue": ["devalue@5.9.2", "", {}, "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w=="],
"dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.3.7", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"rollup": ["rollup@4.63.1", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.1", "@rollup/rollup-android-arm64": "4.63.1", "@rollup/rollup-darwin-arm64": "4.63.1", "@rollup/rollup-darwin-x64": "4.63.1", "@rollup/rollup-freebsd-arm64": "4.63.1", "@rollup/rollup-freebsd-x64": "4.63.1", "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", "@rollup/rollup-linux-arm-musleabihf": "4.63.1", "@rollup/rollup-linux-arm64-gnu": "4.63.1", "@rollup/rollup-linux-arm64-musl": "4.63.1", "@rollup/rollup-linux-loong64-gnu": "4.63.1", "@rollup/rollup-linux-loong64-musl": "4.63.1", "@rollup/rollup-linux-ppc64-gnu": "4.63.1", "@rollup/rollup-linux-ppc64-musl": "4.63.1", "@rollup/rollup-linux-riscv64-gnu": "4.63.1", "@rollup/rollup-linux-riscv64-musl": "4.63.1", "@rollup/rollup-linux-s390x-gnu": "4.63.1", "@rollup/rollup-linux-x64-gnu": "4.63.1", "@rollup/rollup-linux-x64-musl": "4.63.1", "@rollup/rollup-openbsd-x64": "4.63.1", "@rollup/rollup-openharmony-arm64": "4.63.1", "@rollup/rollup-win32-arm64-msvc": "4.63.1", "@rollup/rollup-win32-ia32-msvc": "4.63.1", "@rollup/rollup-win32-x64-gnu": "4.63.1", "@rollup/rollup-win32-x64-msvc": "4.63.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
"svelte": ["svelte@5.57.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg=="],
"svelte-check": ["svelte-check@4.7.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"zimmerframe": ["zimmerframe@1.1.5", "", {}, "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA=="],
}
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0b0e0d" />
<title>~/diary</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230b0e0d'/%3E%3Ctext x='16' y='22' font-family='monospace' font-size='16' fill='%2354f2a1' text-anchor='middle'%3E~%3C/text%3E%3C/svg%3E" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "narl-diary-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"svelte": "^5.19.0",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.0.11"
},
"dependencies": {
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-markdown": "^6.3.1",
"@codemirror/language": "^6.10.8",
"@codemirror/state": "^6.5.1",
"@codemirror/view": "^6.36.2",
"@lezer/highlight": "^1.2.1",
"@replit/codemirror-vim": "^6.2.1",
"dompurify": "^3.2.4",
"marked": "^15.0.6"
}
}
+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')! })
+3
View File
@@ -0,0 +1,3 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default { preprocess: vitePreprocess() }
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"strict": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
build: { target: 'es2022', chunkSizeWarningLimit: 900 },
server: {
port: 4243,
proxy: {
'/api': { target: 'http://127.0.0.1:4242', changeOrigin: false },
},
},
})