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