diff --git a/.env.example b/.env.example index a019ff5..57b9feb 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ WORKSPACE_SECURE_COOKIE=0 # ` in the app, then `sshfs -P anything@host:/ mnt/` # (the username is ignored — only the key decides who gets in). # +# On the mount, `mkdir` at the root makes a space and anywhere else a folder; +# a new `.md` file is a new document. A leading `# Heading` is the document's +# title. Editor temp, swap and backup files are kept in memory for the session +# only, and renaming one onto a `.md` name saves it into that document. +# # Under Docker, bind 0.0.0.0 (not 127.0.0.1) as the app listens inside the # container — the host-side address in compose.yml's `ports:` is what # actually restricts who can reach it. diff --git a/README.md b/README.md index 66664a3..b1758d9 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ The desktop UI is modal. In the browse pane: 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`, `:name`, `:share`, `:link`, -`:upload`, `:media`, `:search`, `:backup`, `:set theme=mocha|green|amber|ice` and +`:upload`, `:media`, `:search`, `:backup` and `:set novim`. The tree has its own: `:space [name]` switches (`:space! ` makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir ` makes a folder, `:cd ` walks it and `:mv ` moves what is diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 20536f5..fb5aaf6 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -300,10 +300,18 @@ pub async fn create_space( return Err(AppError::BadRequest("a space needs a name".into())); } - let slug = unique_slug(&state.db, None, &slugify(name), None).await?; + let id = insert_space(&state.db, name, input.has_board.unwrap_or(true)).await?; + state.backup.signal(); + load(&state, id).await.map(Json) +} + +/// A new space, last in the switcher. Shared by the API and by `mkdir` at the +/// root of the SFTP mount, which is why it takes a pool and not the state. +pub async fn insert_space(db: &SqlitePool, name: &str, has_board: bool) -> AppResult { + let slug = unique_slug(db, None, &slugify(name), None).await?; let now = crate::now(); let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM nodes WHERE parent_id IS NULL") - .fetch_one(&state.db) + .fetch_one(db) .await? .get("next"); @@ -315,14 +323,13 @@ pub async fn create_space( .bind(name) .bind(&slug) .bind(position) - .bind(i64::from(input.has_board.unwrap_or(true))) + .bind(i64::from(has_board)) .bind(now) - .fetch_one(&state.db) + .fetch_one(db) .await? .get("id"); - state.backup.signal(); - load(&state, id).await.map(Json) + Ok(id) } pub async fn list( diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs index 436d5ba..6ed1389 100644 --- a/src/sftp/fs.rs +++ b/src/sftp/fs.rs @@ -1,35 +1,64 @@ //! The virtual filesystem an SFTP client sees: `///.../.md`. -//! Directories are spaces and folders; the only files are documents, always -//! `.md`. There is no on-disk mirror — every call reads or writes `nodes` -//! directly, so a save here is a save in the browser too. +//! Directories are spaces and folders; the files that persist are documents, +//! always `.md`. There is no on-disk mirror — every call reads or writes +//! `nodes` directly, so a save here is a save in the browser too. +//! +//! A document reads as `# ` followed by its body, the same shape the +//! export writes, and a leading heading written back becomes the title again. +//! +//! Anything else an editor writes next to a document — vim's `.swp` and `4913` +//! probe, a `foo.md~` backup, a `.tmp` it renames over the original — lives in +//! memory for the length of the session. Renaming one of those onto a `.md` +//! name is what saves it into the workspace. -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use russh_sftp::protocol::{ File, FileAttributes, Handle, Name, OpenFlags, StatusCode, Version, }; -use sqlx::Row; +use sqlx::{Row, SqlitePool}; -use crate::{routes::media, routes::nodes, state::AppState}; +use crate::{backup::Backup, routes::media, routes::nodes, state::AppState}; type SftpResult<T> = Result<T, StatusCode>; -/// A node as this filesystem sees it: enough to answer `stat` and to list a -/// parent directory without a second query. -struct Entry { - id: i64, - kind: String, - slug: String, - updated_at: i64, - size: i64, +/// The largest file this filesystem holds, document or scratch. Everything is +/// buffered in memory until it is closed, so there has to be a ceiling. +const MAX_FILE: usize = 16 * 1024 * 1024; + +/// The byte size of a document as [`render_doc`] lays it out, computed in SQL +/// so a directory listing does not have to fetch every body. +const DOC_SIZE: &str = "length(CAST(body AS BLOB)) + + CASE WHEN trim(name) <> '' THEN length(CAST(trim(name) AS BLOB)) + 4 ELSE 0 END"; + +/// What a path names. +enum Target { + /// `/`, which holds the spaces and nothing else. + Root, + /// A space or a folder. + Dir { + id: i64, + kind: String, + slug: String, + updated_at: i64, + }, + /// A document, `<slug>.md`. + Doc(Doc), + /// A session-only file, keyed by its normalised path. + Scratch(String), } -fn filename(entry: &Entry) -> String { - if entry.kind == nodes::DOCUMENT { - format!("{}.md", entry.slug) - } else { - entry.slug.clone() - } +struct Doc { + id: i64, + name: String, + body: String, + updated_at: i64, +} + +/// A file that is not a document: kept for as long as the session lasts. +struct Scratch { + data: Vec<u8>, + mtime: i64, } fn dir_attrs(mtime: i64) -> FileAttributes { @@ -52,71 +81,122 @@ fn file_attrs(size: u64, mtime: i64) -> FileAttributes { attrs } -fn attrs_of(entry: &Entry) -> FileAttributes { - if entry.kind == nodes::DOCUMENT { - file_attrs(entry.size as u64, entry.updated_at) - } else { - dir_attrs(entry.updated_at) - } -} - -/// A path split into segments, `/` and `.` collapsed away. `readlink`, -/// symlinks and anything OpenSSH probes for are simply not implemented — -/// there are none of those here. +/// A path split into segments, `/` and `.` collapsed away and `..` taking the +/// previous segment with it. `..` at the root stays at the root. fn segments(path: &str) -> Vec<&str> { - path.split('/').filter(|s| !s.is_empty() && *s != ".").collect() + let mut segs = Vec::new(); + for seg in path.split('/') { + match seg { + "" | "." => {} + ".." => { + segs.pop(); + } + seg => segs.push(seg), + } + } + segs } -/// A document's slug is its filename without the trailing `.md`. A filename -/// that does not end in `.md` never resolves — this filesystem holds no other -/// kind of file yet. +/// The one spelling of a path that scratch files are keyed by. +fn normalize(path: &str) -> String { + format!("/{}", segments(path).join("/")) +} + +/// Split a path into the directory that must already exist and the trailing +/// segment, still unresolved. +fn split_path(path: &str) -> (Vec<&str>, Option<&str>) { + let mut segs = segments(path); + let leaf = segs.pop(); + (segs, leaf) +} + +/// A document's slug is its filename without the trailing `.md`. A dotfile is +/// never a document, whatever it ends in: that is where editors hide the +/// files they write beside one (`.notes.md.swp`, `.#notes.md`). fn doc_slug(filename: &str) -> Option<&str> { + if filename.starts_with('.') { + return None; + } filename.strip_suffix(".md").filter(|base| !base.is_empty()) } -impl Default for Entry { - fn default() -> Self { - Entry { - id: 0, - kind: nodes::SPACE.to_string(), - slug: String::new(), - updated_at: 0, - size: 0, - } +/// A document as a file: its title as a heading, then its body. A document +/// with no title is just its body. +fn render_doc(name: &str, body: &str) -> String { + match name.trim() { + "" => body.to_string(), + title => format!("# {title}\n\n{body}"), } } +/// The inverse of [`render_doc`]: a leading `# heading` is the title, the +/// rest is the body exactly as written, trailing newline and all. Without a +/// heading there is no title to take — the caller decides what to keep. +fn split_heading(text: &str) -> (Option<String>, String) { + let (first, rest) = text.split_once('\n').unwrap_or((text, "")); + match first.trim().strip_prefix("# ").map(str::trim) { + Some(title) if !title.is_empty() => ( + Some(title.to_string()), + rest.trim_start_matches(['\r', '\n']).to_string(), + ), + _ => (None, text.to_string()), + } +} + +fn write_at(buf: &mut Vec<u8>, offset: u64, data: &[u8]) -> SftpResult<()> { + let offset = offset as usize; + let end = offset + data.len(); + if end > MAX_FILE { + return Err(StatusCode::Failure); + } + if buf.len() < end { + buf.resize(end, 0); + } + buf[offset..end].copy_from_slice(data); + Ok(()) +} + enum OpenHandle { /// Directory listing, already fetched: `readdir` just drains it. - Dir(Vec<Entry>), - /// A document's body, fetched once at `open`. Read-only handles never + Dir(Vec<File>), + /// A file's contents, fetched once at `open`. Read-only handles never /// touch the database again after this. Read(Vec<u8>), - /// A document being written. `id` is `None` for a file that does not - /// exist yet — it is only created in `nodes` when the handle closes, - /// so an editor's write-then-fsync-then-close never leaves a half - /// document with the wrong name behind. - Write { - id: Option<i64>, - parent_id: i64, - slug: String, - name: String, - buf: Vec<u8>, - }, + /// A document being written. The node already exists — it is created at + /// `open`, so a client that stats the file before closing it finds it — + /// and the buffer is saved into it when the handle closes. + Write { id: i64, buf: Vec<u8> }, + /// A scratch file being written, stored back under `path` on close. + Scratch { path: String, buf: Vec<u8> }, } pub struct SpaceFs { - state: AppState, + db: SqlitePool, + /// `None` only in tests, where there is nothing to mirror to. + backup: Option<Arc<Backup>>, handles: HashMap<String, OpenHandle>, next_handle: u64, + scratch: HashMap<String, Scratch>, } impl SpaceFs { pub fn new(state: AppState) -> Self { + Self::with_db(state.db, Some(state.backup)) + } + + fn with_db(db: SqlitePool, backup: Option<Arc<Backup>>) -> Self { Self { - state, + db, + backup, handles: HashMap::new(), next_handle: 0, + scratch: HashMap::new(), + } + } + + fn changed(&self) { + if let Some(backup) = &self.backup { + backup.signal(); } } @@ -127,110 +207,213 @@ impl SpaceFs { key } - /// Resolve a path of directory segments to a node id, `None` meaning the - /// root — which holds the spaces and nothing else. - async fn resolve_dir(&self, segs: &[&str]) -> SftpResult<Option<i64>> { - let mut current: Option<i64> = None; - for seg in segs { + /// A space or folder directly under `parent`, by slug — or by the slug its + /// name would get, so `mkdir "My Folder"` can be stat-ed by that name. + async fn dir_in(&self, parent: Option<i64>, leaf: &str) -> SftpResult<Option<Target>> { + let slugged = nodes::slugify(leaf); + for slug in [leaf, slugged.as_str()] { let row = sqlx::query( - "SELECT id FROM nodes WHERE parent_id IS ?1 AND slug = ?2 AND kind <> 'document'", - ) - .bind(current) - .bind(seg) - .fetch_optional(&self.state.db) - .await - .map_err(|_| StatusCode::Failure)? - .ok_or(StatusCode::NoSuchFile)?; - current = Some(row.get("id")); - } - Ok(current) - } - - /// Split a path into the directory that must already exist and the - /// trailing segment, still unresolved. - fn split_path(path: &str) -> (Vec<&str>, Option<&str>) { - let mut segs = segments(path); - let leaf = segs.pop(); - (segs, leaf) - } - - async fn entry_at(&self, path: &str) -> SftpResult<Entry> { - let segs = segments(path); - if segs.is_empty() { - return Ok(Entry::default()); - } - let (dir, leaf) = Self::split_path(path); - let Some(leaf) = leaf else { - return Ok(Entry::default()); - }; - let parent = self.resolve_dir(&dir).await; - - // A directory: space or folder, matched by slug under its parent. - if let Ok(parent_id) = parent { - let dir_row = sqlx::query( "SELECT id, kind, slug, updated_at FROM nodes WHERE parent_id IS ?1 AND slug = ?2 AND kind <> 'document'", ) - .bind(parent_id) - .bind(leaf) - .fetch_optional(&self.state.db) + .bind(parent) + .bind(slug) + .fetch_optional(&self.db) .await .map_err(|_| StatusCode::Failure)?; - if let Some(row) = dir_row { - return Ok(Entry { + if let Some(row) = row { + return Ok(Some(Target::Dir { id: row.get("id"), kind: row.get("kind"), slug: row.get("slug"), updated_at: row.get("updated_at"), - size: 0, - }); - } - - if let Some(slug) = doc_slug(leaf) { - let doc_row = sqlx::query( - "SELECT id, slug, updated_at, length(body) AS size FROM nodes - WHERE parent_id IS ?1 AND slug = ?2 AND kind = 'document'", - ) - .bind(parent_id) - .bind(slug) - .fetch_optional(&self.state.db) - .await - .map_err(|_| StatusCode::Failure)?; - if let Some(row) = doc_row { - return Ok(Entry { - id: row.get("id"), - kind: nodes::DOCUMENT.to_string(), - slug: row.get("slug"), - updated_at: row.get("updated_at"), - size: row.get("size"), - }); - } + })); } } + Ok(None) + } + /// A document under `parent` named by `leaf`. As with directories, a + /// file created as `My Notes.md` lands as `my-notes.md` and still answers + /// to the name it was created with. + async fn doc_in(&self, parent: i64, leaf: &str) -> SftpResult<Option<Doc>> { + let Some(stem) = doc_slug(leaf) else { + return Ok(None); + }; + let slugged = nodes::slugify(stem); + for slug in [stem, slugged.as_str()] { + let row = sqlx::query( + "SELECT id, name, body, updated_at FROM nodes + WHERE parent_id = ?1 AND slug = ?2 AND kind = 'document'", + ) + .bind(parent) + .bind(slug) + .fetch_optional(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; + if let Some(row) = row { + return Ok(Some(Doc { + id: row.get("id"), + name: row.get("name"), + body: row.get("body"), + updated_at: row.get("updated_at"), + })); + } + } + Ok(None) + } + + /// Resolve a path of directory segments to a node id, `None` meaning the + /// root. + async fn resolve_dir(&self, segs: &[&str]) -> SftpResult<Option<i64>> { + let mut current: Option<i64> = None; + for seg in segs { + match self.dir_in(current, seg).await? { + Some(Target::Dir { id, .. }) => current = Some(id), + _ => return Err(StatusCode::NoSuchFile), + } + } + Ok(current) + } + + async fn target(&self, path: &str) -> SftpResult<Target> { + let (dir, leaf) = split_path(path); + let Some(leaf) = leaf else { + return Ok(Target::Root); + }; + let parent = self.resolve_dir(&dir).await?; + if let Some(found) = self.dir_in(parent, leaf).await? { + return Ok(found); + } + // The root holds spaces and nothing else. + let Some(parent) = parent else { + return Err(StatusCode::NoSuchFile); + }; + if let Some(doc) = self.doc_in(parent, leaf).await? { + return Ok(Target::Doc(doc)); + } + let key = normalize(path); + if self.scratch.contains_key(&key) { + return Ok(Target::Scratch(key)); + } Err(StatusCode::NoSuchFile) } - async fn children(&self, parent_id: Option<i64>) -> SftpResult<Vec<Entry>> { - let rows = sqlx::query( - "SELECT id, kind, slug, updated_at, length(body) AS size FROM nodes - WHERE parent_id IS ?1 ORDER BY kind, slug", + /// Like [`target`](Self::target), with "nothing there" as `None` rather + /// than an error — what `rename` and `mkdir` need to ask. + async fn target_opt(&self, path: &str) -> SftpResult<Option<Target>> { + match self.target(path).await { + Ok(found) => Ok(Some(found)), + Err(StatusCode::NoSuchFile) => Ok(None), + Err(e) => Err(e), + } + } + + fn attrs_of(&self, target: &Target) -> FileAttributes { + match target { + Target::Root => dir_attrs(crate::now()), + Target::Dir { updated_at, .. } => dir_attrs(*updated_at), + Target::Doc(doc) => file_attrs( + render_doc(&doc.name, &doc.body).len() as u64, + doc.updated_at, + ), + Target::Scratch(key) => { + let file = &self.scratch[key]; + file_attrs(file.data.len() as u64, file.mtime) + } + } + } + + /// An empty document under `parent`, named after the file it was created + /// as until a heading says otherwise. + async fn create_doc(&self, parent: i64, stem: &str) -> SftpResult<i64> { + let slug = nodes::unique_slug(&self.db, Some(parent), &nodes::slugify(stem), None) + .await + .map_err(|_| StatusCode::Failure)?; + let id: i64 = sqlx::query( + "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) + VALUES (?1, 'document', ?2, ?3, '', ?4, ?4) RETURNING id", ) - .bind(parent_id) - .fetch_all(&self.state.db) + .bind(parent) + .bind(stem) + .bind(&slug) + .bind(crate::now()) + .fetch_one(&self.db) + .await + .map_err(|_| StatusCode::Failure)? + .get("id"); + self.changed(); + Ok(id) + } + + /// Save a file's bytes into a document. A leading heading renames it; + /// without one the document keeps the title it had. + async fn save_doc(&self, id: i64, bytes: Vec<u8>) -> SftpResult<()> { + let text = String::from_utf8(bytes).map_err(|_| StatusCode::Failure)?; + let (title, body) = split_heading(&text); + sqlx::query( + "UPDATE nodes SET name = COALESCE(?1, name), body = ?2, updated_at = ?3 WHERE id = ?4", + ) + .bind(title) + .bind(&body) + .bind(crate::now()) + .bind(id) + .execute(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; + media::link_to_node(&self.db, id, &body) + .await + .map_err(|_| StatusCode::Failure)?; + self.changed(); + Ok(()) + } + + async fn delete_node(&self, id: i64) -> SftpResult<()> { + sqlx::query("DELETE FROM nodes WHERE id = ?1") + .bind(id) + .execute(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; + self.changed(); + Ok(()) + } + + async fn listing(&self, path: &str) -> SftpResult<Vec<File>> { + let segs = segments(path); + let parent = self.resolve_dir(&segs).await?; + let rows = sqlx::query(&format!( + "SELECT kind, slug, updated_at, {DOC_SIZE} AS size FROM nodes + WHERE parent_id IS ?1 ORDER BY kind, slug" + )) + .bind(parent) + .fetch_all(&self.db) .await .map_err(|_| StatusCode::Failure)?; - Ok(rows + let mut files: Vec<File> = rows .iter() - .map(|row| Entry { - id: row.get("id"), - kind: row.get("kind"), - slug: row.get("slug"), - updated_at: row.get("updated_at"), - size: row.try_get("size").unwrap_or(0), + .map(|row| { + let kind: String = row.get("kind"); + let slug: String = row.get("slug"); + let mtime: i64 = row.get("updated_at"); + if kind == nodes::DOCUMENT { + let size: i64 = row.try_get("size").unwrap_or(0); + File::new(format!("{slug}.md"), file_attrs(size as u64, mtime)) + } else { + File::new(slug, dir_attrs(mtime)) + } }) - .collect()) + .collect(); + + let dir = normalize(path); + for (key, file) in &self.scratch { + if let Some((parent, name)) = key.rsplit_once('/') { + if parent == dir || (parent.is_empty() && dir == "/") { + files.push(File::new(name, file_attrs(file.data.len() as u64, file.mtime))); + } + } + } + Ok(files) } } @@ -250,10 +433,9 @@ impl russh_sftp::server::Handler for SpaceFs { } async fn realpath(&mut self, id: u32, path: String) -> SftpResult<Name> { - let clean = segments(&path).join("/"); Ok(Name { id, - files: vec![File::dummy(format!("/{clean}"))], + files: vec![File::dummy(normalize(&path))], }) } @@ -262,49 +444,90 @@ impl russh_sftp::server::Handler for SpaceFs { } async fn stat(&mut self, id: u32, path: String) -> SftpResult<russh_sftp::protocol::Attrs> { - let entry = self.entry_at(&path).await?; + let target = self.target(&path).await?; Ok(russh_sftp::protocol::Attrs { id, - attrs: attrs_of(&entry), + attrs: self.attrs_of(&target), }) } async fn fstat(&mut self, id: u32, handle: String) -> SftpResult<russh_sftp::protocol::Attrs> { - match self.handles.get(&handle) { - Some(OpenHandle::Read(data)) => Ok(russh_sftp::protocol::Attrs { - id, - attrs: file_attrs(data.len() as u64, crate::now()), - }), - Some(OpenHandle::Write { buf, .. }) => Ok(russh_sftp::protocol::Attrs { - id, - attrs: file_attrs(buf.len() as u64, crate::now()), - }), - Some(OpenHandle::Dir(_)) => Ok(russh_sftp::protocol::Attrs { - id, - attrs: dir_attrs(crate::now()), - }), + let attrs = match self.handles.get(&handle) { + Some(OpenHandle::Read(data)) => file_attrs(data.len() as u64, crate::now()), + Some(OpenHandle::Write { buf, .. } | OpenHandle::Scratch { buf, .. }) => { + file_attrs(buf.len() as u64, crate::now()) + } + Some(OpenHandle::Dir(_)) => dir_attrs(crate::now()), + None => return Err(StatusCode::Failure), + }; + Ok(russh_sftp::protocol::Attrs { id, attrs }) + } + + /// Permissions and times have nowhere to live here, so they are accepted + /// and dropped; a size truncates, because editors truncate this way. + async fn setstat( + &mut self, + id: u32, + path: String, + attrs: FileAttributes, + ) -> SftpResult<russh_sftp::protocol::Status> { + let target = self.target(&path).await?; + if let Some(size) = attrs.size { + let size = size as usize; + if size > MAX_FILE { + return Err(StatusCode::Failure); + } + match target { + Target::Doc(doc) => { + let mut bytes = render_doc(&doc.name, &doc.body).into_bytes(); + bytes.resize(size, 0); + self.save_doc(doc.id, bytes).await?; + } + Target::Scratch(key) => { + if let Some(file) = self.scratch.get_mut(&key) { + file.data.resize(size, 0); + file.mtime = crate::now(); + } + } + Target::Root | Target::Dir { .. } => {} + } + } + Ok(ok_status(id)) + } + + async fn fsetstat( + &mut self, + id: u32, + handle: String, + attrs: FileAttributes, + ) -> SftpResult<russh_sftp::protocol::Status> { + match self.handles.get_mut(&handle) { + Some(OpenHandle::Write { buf, .. } | OpenHandle::Scratch { buf, .. }) => { + if let Some(size) = attrs.size { + if size as usize > MAX_FILE { + return Err(StatusCode::Failure); + } + buf.resize(size as usize, 0); + } + Ok(ok_status(id)) + } + Some(_) => Ok(ok_status(id)), None => Err(StatusCode::Failure), } } async fn opendir(&mut self, id: u32, path: String) -> SftpResult<Handle> { - let segs = segments(&path); - let parent_id = self.resolve_dir(&segs).await?; - let entries = self.children(parent_id).await?; - let handle = self.allocate(OpenHandle::Dir(entries)); - let _ = id; + let files = self.listing(&path).await?; + let handle = self.allocate(OpenHandle::Dir(files)); Ok(Handle { id, handle }) } async fn readdir(&mut self, id: u32, handle: String) -> SftpResult<Name> { match self.handles.get_mut(&handle) { - Some(OpenHandle::Dir(entries)) if !entries.is_empty() => { - let files = entries - .drain(..) - .map(|entry| File::new(filename(&entry), attrs_of(&entry))) - .collect(); - Ok(Name { id, files }) - } + Some(OpenHandle::Dir(files)) if !files.is_empty() => Ok(Name { + id, + files: std::mem::take(files), + }), Some(OpenHandle::Dir(_)) => Err(StatusCode::Eof), _ => Err(StatusCode::Failure), } @@ -317,50 +540,69 @@ impl russh_sftp::server::Handler for SpaceFs { pflags: OpenFlags, _attrs: FileAttributes, ) -> SftpResult<Handle> { - let (dir, leaf) = Self::split_path(&filename); + let writing = pflags.contains(OpenFlags::WRITE); + let (dir, leaf) = split_path(&filename); let leaf = leaf.ok_or(StatusCode::PermissionDenied)?; - let slug = doc_slug(leaf).ok_or(StatusCode::PermissionDenied)?; - let parent_id = self.resolve_dir(&dir).await?; - - let existing = sqlx::query( - "SELECT id, body FROM nodes WHERE parent_id IS ?1 AND slug = ?2 AND kind = 'document'", - ) - .bind(parent_id) - .bind(slug) - .fetch_optional(&self.state.db) - .await - .map_err(|_| StatusCode::Failure)?; - - if pflags.contains(OpenFlags::WRITE) { - if existing.is_none() && !pflags.contains(OpenFlags::CREATE) { - return Err(StatusCode::NoSuchFile); - } - if existing.is_some() && pflags.contains(OpenFlags::EXCLUDE) { - return Err(StatusCode::Failure); - } - let Some(parent_id) = parent_id else { - // A document cannot live loose at the root — only spaces do. - return Err(StatusCode::PermissionDenied); - }; - let buf = match &existing { - Some(row) if !pflags.contains(OpenFlags::TRUNCATE) => { - row.get::<String, _>("body").into_bytes() - } - _ => Vec::new(), - }; - let handle = self.allocate(OpenHandle::Write { - id: existing.map(|row| row.get("id")), - parent_id, - slug: slug.to_string(), - name: slug.to_string(), - buf, + let parent = self.resolve_dir(&dir).await?; + let Some(parent) = parent else { + // A file cannot live loose at the root — only spaces do. + return Err(if writing { + StatusCode::PermissionDenied + } else { + StatusCode::NoSuchFile }); - return Ok(Handle { id, handle }); + }; + if self.dir_in(Some(parent), leaf).await?.is_some() { + return Err(StatusCode::Failure); } - let row = existing.ok_or(StatusCode::NoSuchFile)?; - let body: String = row.get("body"); - let handle = self.allocate(OpenHandle::Read(body.into_bytes())); + let handle = match doc_slug(leaf) { + Some(stem) => { + let existing = self.doc_in(parent, leaf).await?; + if writing { + if existing.is_none() && !pflags.contains(OpenFlags::CREATE) { + return Err(StatusCode::NoSuchFile); + } + if existing.is_some() && pflags.contains(OpenFlags::EXCLUDE) { + return Err(StatusCode::Failure); + } + let (id, buf) = match existing { + Some(doc) if pflags.contains(OpenFlags::TRUNCATE) => (doc.id, Vec::new()), + Some(doc) => (doc.id, render_doc(&doc.name, &doc.body).into_bytes()), + None => (self.create_doc(parent, stem).await?, Vec::new()), + }; + OpenHandle::Write { id, buf } + } else { + let doc = existing.ok_or(StatusCode::NoSuchFile)?; + OpenHandle::Read(render_doc(&doc.name, &doc.body).into_bytes()) + } + } + None => { + let key = normalize(&filename); + let existing = self.scratch.get(&key).map(|file| file.data.clone()); + if writing { + if existing.is_none() && !pflags.contains(OpenFlags::CREATE) { + return Err(StatusCode::NoSuchFile); + } + if existing.is_some() && pflags.contains(OpenFlags::EXCLUDE) { + return Err(StatusCode::Failure); + } + let buf = match existing { + Some(data) if !pflags.contains(OpenFlags::TRUNCATE) => data, + _ => Vec::new(), + }; + self.scratch.entry(key.clone()).or_insert_with(|| Scratch { + data: Vec::new(), + mtime: crate::now(), + }); + OpenHandle::Scratch { path: key, buf } + } else { + OpenHandle::Read(existing.ok_or(StatusCode::NoSuchFile)?) + } + } + }; + + let handle = self.allocate(handle); Ok(Handle { id, handle }) } @@ -371,8 +613,14 @@ impl russh_sftp::server::Handler for SpaceFs { offset: u64, len: u32, ) -> SftpResult<russh_sftp::protocol::Data> { + // sshfs opens read-write and reads back through the same handle, so a + // write handle reads its own buffer. let data = match self.handles.get(&handle) { - Some(OpenHandle::Read(data)) => data, + Some( + OpenHandle::Read(data) + | OpenHandle::Write { buf: data, .. } + | OpenHandle::Scratch { buf: data, .. }, + ) => data, _ => return Err(StatusCode::Failure), }; let offset = offset as usize; @@ -394,16 +642,8 @@ impl russh_sftp::server::Handler for SpaceFs { data: Vec<u8>, ) -> SftpResult<russh_sftp::protocol::Status> { match self.handles.get_mut(&handle) { - Some(OpenHandle::Write { buf, .. }) => { - let offset = offset as usize; - if buf.len() < offset { - buf.resize(offset, 0); - } - let end = offset + data.len(); - if buf.len() < end { - buf.resize(end, 0); - } - buf[offset..end].copy_from_slice(&data); + Some(OpenHandle::Write { buf, .. } | OpenHandle::Scratch { buf, .. }) => { + write_at(buf, offset, &data)?; Ok(ok_status(id)) } _ => Err(StatusCode::Failure), @@ -411,159 +651,224 @@ impl russh_sftp::server::Handler for SpaceFs { } async fn close(&mut self, id: u32, handle: String) -> SftpResult<russh_sftp::protocol::Status> { - let Some(open) = self.handles.remove(&handle) else { - return Err(StatusCode::Failure); - }; - - let OpenHandle::Write { id: node_id, parent_id, slug, name, buf } = open else { - return Ok(ok_status(id)); - }; - - let body = String::from_utf8(buf).map_err(|_| StatusCode::Failure)?; - let now = crate::now(); - - match node_id { - Some(node_id) => { - sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3") - .bind(&body) - .bind(now) - .bind(node_id) - .execute(&self.state.db) - .await - .map_err(|_| StatusCode::Failure)?; - media::link_to_node(&self.state.db, node_id, &body) - .await - .ok(); - } - None => { - let slug = nodes::unique_slug(&self.state.db, Some(parent_id), &slug, None) - .await - .map_err(|_| StatusCode::Failure)?; - let new_id: i64 = sqlx::query( - "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) - VALUES (?1, 'document', ?2, ?3, ?4, ?5, ?5) RETURNING id", - ) - .bind(parent_id) - .bind(&name) - .bind(&slug) - .bind(&body) - .bind(now) - .fetch_one(&self.state.db) - .await - .map_err(|_| StatusCode::Failure)? - .get("id"); - media::link_to_node(&self.state.db, new_id, &body).await.ok(); + match self.handles.remove(&handle) { + Some(OpenHandle::Write { id: node, buf }) => self.save_doc(node, buf).await?, + Some(OpenHandle::Scratch { path, buf }) => { + // Removed while open: like an unlinked file, it is simply gone. + if let Some(file) = self.scratch.get_mut(&path) { + file.data = buf; + file.mtime = crate::now(); + } } + Some(OpenHandle::Read(_) | OpenHandle::Dir(_)) => {} + None => return Err(StatusCode::Failure), } - - self.state.backup.signal(); Ok(ok_status(id)) } async fn remove(&mut self, id: u32, filename: String) -> SftpResult<russh_sftp::protocol::Status> { - let entry = self.entry_at(&filename).await?; - if entry.kind != nodes::DOCUMENT { - return Err(StatusCode::PermissionDenied); + match self.target(&filename).await? { + Target::Doc(doc) => self.delete_node(doc.id).await?, + Target::Scratch(key) => { + self.scratch.remove(&key); + } + Target::Root | Target::Dir { .. } => return Err(StatusCode::PermissionDenied), } - sqlx::query("DELETE FROM nodes WHERE id = ?1") - .bind(entry.id) - .execute(&self.state.db) - .await - .map_err(|_| StatusCode::Failure)?; - self.state.backup.signal(); Ok(ok_status(id)) } + /// A directory at the root is a new space; anywhere else it is a folder. async fn mkdir( &mut self, id: u32, path: String, _attrs: FileAttributes, ) -> SftpResult<russh_sftp::protocol::Status> { - let (dir, leaf) = Self::split_path(&path); + let (dir, leaf) = split_path(&path); let name = leaf.ok_or(StatusCode::PermissionDenied)?; - let Some(parent_id) = self.resolve_dir(&dir).await? else { - // A new space is made in the browser, not by mounting the root - // and mkdir-ing into it — the root has no `has_board` to ask about. - return Err(StatusCode::PermissionDenied); + if self.target_opt(&path).await?.is_some() { + return Err(StatusCode::Failure); + } + let Some(parent) = self.resolve_dir(&dir).await? else { + nodes::insert_space(&self.db, name, true) + .await + .map_err(|_| StatusCode::Failure)?; + self.changed(); + return Ok(ok_status(id)); }; - let slug = nodes::unique_slug(&self.state.db, Some(parent_id), &nodes::slugify(name), None) + let slug = nodes::unique_slug(&self.db, Some(parent), &nodes::slugify(name), None) .await .map_err(|_| StatusCode::Failure)?; - let now = crate::now(); sqlx::query( "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at) VALUES (?1, 'folder', ?2, ?3, ?4, ?4)", ) - .bind(parent_id) + .bind(parent) .bind(name) .bind(&slug) - .bind(now) - .execute(&self.state.db) + .bind(crate::now()) + .execute(&self.db) .await .map_err(|_| StatusCode::Failure)?; - self.state.backup.signal(); + self.changed(); Ok(ok_status(id)) } async fn rmdir(&mut self, id: u32, path: String) -> SftpResult<russh_sftp::protocol::Status> { - let entry = self.entry_at(&path).await?; - if entry.kind == nodes::DOCUMENT { + let Target::Dir { id: node, .. } = self.target(&path).await? else { return Err(StatusCode::PermissionDenied); - } - let children = self.children(Some(entry.id)).await?; - if !children.is_empty() { + }; + let children: i64 = sqlx::query("SELECT count(*) AS n FROM nodes WHERE parent_id = ?1") + .bind(node) + .fetch_one(&self.db) + .await + .map_err(|_| StatusCode::Failure)? + .get("n"); + if children > 0 { return Err(StatusCode::Failure); } - sqlx::query("DELETE FROM nodes WHERE id = ?1") - .bind(entry.id) - .execute(&self.state.db) - .await - .map_err(|_| StatusCode::Failure)?; - self.state.backup.signal(); + let prefix = format!("{}/", normalize(&path)); + self.scratch.retain(|key, _| !key.starts_with(&prefix)); + self.delete_node(node).await?; Ok(ok_status(id)) } + /// Renaming onto an existing file replaces it, as `rename(2)` does — that + /// is how editors save atomically. Directories are never replaced. async fn rename( &mut self, id: u32, oldpath: String, newpath: String, ) -> SftpResult<russh_sftp::protocol::Status> { - let entry = self.entry_at(&oldpath).await?; - let (new_dir, new_leaf) = Self::split_path(&newpath); - let new_name = match entry.kind.as_str() { - k if k == nodes::DOCUMENT => new_leaf - .and_then(doc_slug) - .ok_or(StatusCode::PermissionDenied)?, - _ => new_leaf.ok_or(StatusCode::PermissionDenied)?, - }; + let source = self.target(&oldpath).await?; + let (new_dir, new_leaf) = split_path(&newpath); + let new_leaf = new_leaf.ok_or(StatusCode::PermissionDenied)?; let new_parent = self.resolve_dir(&new_dir).await?; - let Some(new_parent) = new_parent else { - return Err(StatusCode::PermissionDenied); - }; - let slug = nodes::unique_slug( - &self.state.db, - Some(new_parent), - &nodes::slugify(new_name), - Some(entry.id), - ) - .await - .map_err(|_| StatusCode::Failure)?; + let dest = self.target_opt(&newpath).await?; + let now = crate::now(); - sqlx::query( - "UPDATE nodes SET parent_id = ?1, name = ?2, slug = ?3, updated_at = ?4 WHERE id = ?5", - ) - .bind(new_parent) - .bind(new_name) - .bind(&slug) - .bind(crate::now()) - .bind(entry.id) - .execute(&self.state.db) - .await - .map_err(|_| StatusCode::Failure)?; + match source { + Target::Root => return Err(StatusCode::PermissionDenied), + + Target::Dir { id: node, kind, slug, .. } => { + match dest { + Some(Target::Dir { id: other, .. }) if other == node => {} + Some(_) => return Err(StatusCode::Failure), + None => {} + } + // Spaces stay at the root and nothing else goes there, so a + // rename never makes a nested space or a loose folder. + match (kind.as_str(), new_parent) { + (nodes::SPACE, None) => {} + (nodes::SPACE, Some(_)) | (_, None) => { + return Err(StatusCode::PermissionDenied) + } + (_, Some(parent)) => { + let below = nodes::descendants(&self.db, node) + .await + .map_err(|_| StatusCode::Failure)?; + if below.contains(&parent) { + return Err(StatusCode::Failure); + } + } + } + let new_slug = + nodes::unique_slug(&self.db, new_parent, &nodes::slugify(new_leaf), Some(node)) + .await + .map_err(|_| StatusCode::Failure)?; + // A move that keeps the name keeps the title too. + let name = (new_leaf != slug).then_some(new_leaf); + sqlx::query( + "UPDATE nodes SET parent_id = ?1, name = COALESCE(?2, name), slug = ?3, + updated_at = ?4 WHERE id = ?5", + ) + .bind(new_parent) + .bind(name) + .bind(&new_slug) + .bind(now) + .bind(node) + .execute(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; + self.changed(); + } + + Target::Doc(doc) => { + let Some(new_parent) = new_parent else { + return Err(StatusCode::PermissionDenied); + }; + match (doc_slug(new_leaf), dest) { + (_, Some(Target::Dir { .. })) => return Err(StatusCode::Failure), + // Onto another document: that one takes this one's text + // and keeps its own id, so its share link survives. + (Some(_), Some(Target::Doc(other))) if other.id != doc.id => { + self.save_doc(other.id, render_doc(&doc.name, &doc.body).into_bytes()) + .await?; + self.delete_node(doc.id).await?; + } + (Some(stem), _) => { + let new_slug = nodes::unique_slug( + &self.db, + Some(new_parent), + &nodes::slugify(stem), + Some(doc.id), + ) + .await + .map_err(|_| StatusCode::Failure)?; + sqlx::query( + "UPDATE nodes SET parent_id = ?1, slug = ?2, updated_at = ?3 WHERE id = ?4", + ) + .bind(new_parent) + .bind(&new_slug) + .bind(now) + .bind(doc.id) + .execute(&self.db) + .await + .map_err(|_| StatusCode::Failure)?; + self.changed(); + } + // Onto a name that is not a document: an editor moving the + // original aside as a backup before writing the new one. + // The backup is a copy; the document stays where it is, so + // the write that follows lands in the same node. + (None, _) => { + self.scratch.insert( + normalize(&newpath), + Scratch { + data: render_doc(&doc.name, &doc.body).into_bytes(), + mtime: now, + }, + ); + } + } + } + + Target::Scratch(key) => { + let Some(new_parent) = new_parent else { + return Err(StatusCode::PermissionDenied); + }; + match (doc_slug(new_leaf), dest) { + (_, Some(Target::Dir { .. })) => return Err(StatusCode::Failure), + // The atomic save: a temp file renamed over the document. + (Some(stem), dest) => { + let data = self.scratch[&key].data.clone(); + let node = match dest { + Some(Target::Doc(other)) => other.id, + _ => self.create_doc(new_parent, stem).await?, + }; + self.save_doc(node, data).await?; + self.scratch.remove(&key); + } + (None, _) => { + if let Some(file) = self.scratch.remove(&key) { + self.scratch.insert(normalize(&newpath), file); + } + } + } + } + } - self.state.backup.signal(); Ok(ok_status(id)) } } @@ -576,3 +881,193 @@ fn ok_status(id: u32) -> russh_sftp::protocol::Status { language_tag: "en-US".to_string(), } } + +#[cfg(test)] +mod tests { + use russh_sftp::{ + protocol::{FileAttributes, OpenFlags, StatusCode}, + server::Handler, + }; + use sqlx::sqlite::SqlitePoolOptions; + + use super::{doc_slug, normalize, render_doc, segments, split_heading, SpaceFs}; + + #[test] + fn dot_dot_climbs_and_stops_at_the_root() { + assert_eq!(segments("/proj/notes/../ideas/./a.md"), ["proj", "ideas", "a.md"]); + assert_eq!(normalize("/../../proj"), "/proj"); + assert_eq!(normalize(""), "/"); + } + + #[test] + fn only_plain_md_files_are_documents() { + assert_eq!(doc_slug("notes.md"), Some("notes")); + assert_eq!(doc_slug(".notes.md"), None); + assert_eq!(doc_slug("notes.md~"), None); + assert_eq!(doc_slug(".notes.md.swp"), None); + assert_eq!(doc_slug("4913"), None); + assert_eq!(doc_slug(".md"), None); + } + + #[test] + fn a_document_survives_the_round_trip_through_a_file() { + let file = render_doc("Was ist EEBUS?", "text\n\nmore\n"); + assert_eq!(file, "# Was ist EEBUS?\n\ntext\n\nmore\n"); + assert_eq!( + split_heading(&file), + (Some("Was ist EEBUS?".into()), "text\n\nmore\n".into()) + ); + assert_eq!(render_doc(" ", "body"), "body"); + assert_eq!(split_heading("#nope\nbody"), (None, "#nope\nbody".into())); + } + + async fn fs() -> SpaceFs { + // One connection: every connection to `:memory:` is its own database. + let db = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("an in-memory database"); + sqlx::migrate!("./migrations").run(&db).await.expect("migrations apply"); + SpaceFs::with_db(db, None) + } + + const CREATE: OpenFlags = OpenFlags::WRITE.union(OpenFlags::CREATE).union(OpenFlags::TRUNCATE); + + async fn put(fs: &mut SpaceFs, path: &str, text: &str) { + let handle = fs + .open(0, path.into(), CREATE, FileAttributes::empty()) + .await + .expect("open for writing") + .handle; + fs.write(0, handle.clone(), 0, text.as_bytes().to_vec()).await.expect("write"); + fs.close(0, handle).await.expect("close"); + } + + async fn get(fs: &mut SpaceFs, path: &str) -> String { + let handle = fs + .open(0, path.into(), OpenFlags::READ, FileAttributes::empty()) + .await + .expect("open for reading") + .handle; + let data = fs.read(0, handle.clone(), 0, u32::MAX).await.map(|d| d.data).unwrap_or_default(); + fs.close(0, handle).await.expect("close"); + String::from_utf8(data).expect("utf-8") + } + + async fn node(fs: &SpaceFs, slug: &str) -> (i64, String, String) { + let row: (i64, String, String) = + sqlx::query_as("SELECT id, name, body FROM nodes WHERE slug = ?1") + .bind(slug) + .fetch_one(&fs.db) + .await + .expect("the node exists"); + row + } + + #[tokio::test] + async fn mkdir_at_the_root_makes_a_space_and_a_document_lands_in_it() { + let mut fs = fs().await; + fs.mkdir(0, "/Side Project".into(), FileAttributes::empty()).await.expect("mkdir"); + let kind: String = sqlx::query_scalar("SELECT kind FROM nodes WHERE slug = 'side-project'") + .fetch_one(&fs.db) + .await + .expect("the space exists"); + assert_eq!(kind, "space"); + + // Both the name it was made with and the slug it got resolve. + fs.stat(0, "/Side Project".into()).await.expect("stat by name"); + fs.mkdir(0, "/side-project/Ideas".into(), FileAttributes::empty()).await.expect("mkdir"); + assert_eq!( + fs.mkdir(0, "/side-project/ideas".into(), FileAttributes::empty()).await.err(), + Some(StatusCode::Failure) + ); + + put(&mut fs, "/side-project/ideas/My Notes.md", "# First thoughts\n\nhello\n").await; + let (_, name, body) = node(&fs, "my-notes").await; + assert_eq!(name, "First thoughts"); + assert_eq!(body, "hello\n"); + fs.stat(0, "/side-project/ideas/My Notes.md".into()).await.expect("stat by name"); + + let text = get(&mut fs, "/side-project/ideas/my-notes.md").await; + assert_eq!(text, "# First thoughts\n\nhello\n"); + let size = fs + .stat(0, "/side-project/ideas/my-notes.md".into()) + .await + .expect("stat") + .attrs + .size; + assert_eq!(size, Some(text.len() as u64)); + } + + #[tokio::test] + async fn a_vim_save_keeps_the_same_document() { + let mut fs = fs().await; + fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir"); + put(&mut fs, "/proj/plan.md", "# Plan\n\nv1\n").await; + let (id, ..) = node(&fs, "plan").await; + + // The sequence vim runs with `backupcopy=auto` on a mount. + put(&mut fs, "/proj/4913", "").await; + fs.remove(0, "/proj/4913".into()).await.expect("remove probe"); + put(&mut fs, "/proj/.plan.md.swp", "swap").await; + fs.rename(0, "/proj/plan.md".into(), "/proj/plan.md~".into()).await.expect("backup"); + put(&mut fs, "/proj/plan.md", "# Plan\n\nv2\n").await; + fs.remove(0, "/proj/plan.md~".into()).await.expect("drop backup"); + fs.remove(0, "/proj/.plan.md.swp".into()).await.expect("drop swap"); + + let (after, name, body) = node(&fs, "plan").await; + assert_eq!((after, name.as_str(), body.as_str()), (id, "Plan", "v2\n")); + let docs: i64 = sqlx::query_scalar("SELECT count(*) FROM nodes WHERE kind = 'document'") + .fetch_one(&fs.db) + .await + .expect("count"); + assert_eq!(docs, 1); + } + + #[tokio::test] + async fn a_temp_file_renamed_over_a_document_replaces_its_text() { + let mut fs = fs().await; + fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir"); + put(&mut fs, "/proj/plan.md", "# Plan\n\nv1\n").await; + let (id, ..) = node(&fs, "plan").await; + + put(&mut fs, "/proj/plan.md.tmp", "# Renamed\n\nv2\n").await; + let listed = fs.listing("/proj").await.expect("listing"); + assert!(listed.iter().any(|f| f.filename == "plan.md.tmp")); + + fs.rename(0, "/proj/plan.md.tmp".into(), "/proj/plan.md".into()).await.expect("rename"); + let (after, name, body) = node(&fs, "plan").await; + assert_eq!((after, name.as_str(), body.as_str()), (id, "Renamed", "v2\n")); + assert!(fs.scratch.is_empty()); + } + + #[tokio::test] + async fn spaces_rename_at_the_root_and_folders_cannot_swallow_themselves() { + let mut fs = fs().await; + fs.mkdir(0, "/proj".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.mkdir(0, "/proj/a".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.mkdir(0, "/proj/a/b".into(), FileAttributes::empty()).await.expect("mkdir"); + + fs.rename(0, "/proj".into(), "/Gig".into()).await.expect("rename space"); + let (_, name, _) = node(&fs, "gig").await; + assert_eq!(name, "Gig"); + + assert_eq!( + fs.rename(0, "/gig/a".into(), "/gig/a/b/a".into()).await.err(), + Some(StatusCode::Failure) + ); + assert_eq!( + fs.rename(0, "/gig".into(), "/gig/a/gig".into()).await.err(), + Some(StatusCode::PermissionDenied) + ); + assert_eq!( + fs.rename(0, "/gig/a".into(), "/a".into()).await.err(), + Some(StatusCode::PermissionDenied) + ); + + fs.mkdir(0, "/empty".into(), FileAttributes::empty()).await.expect("mkdir"); + fs.rmdir(0, "/empty".into()).await.expect("an empty space can go"); + assert_eq!(fs.rmdir(0, "/gig".into()).await.err(), Some(StatusCode::Failure)); + } +} diff --git a/web/bun.lock b/web/bun.lock index 0537629..7eec19b 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -10,6 +10,8 @@ "@codemirror/language": "^6.10.8", "@codemirror/state": "^6.5.1", "@codemirror/view": "^6.36.2", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/noto-sans": "^5.3.0", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.2.1", "dompurify": "^3.2.4", @@ -100,6 +102,10 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], + + "@fontsource-variable/noto-sans": ["@fontsource-variable/noto-sans@5.3.0", "", {}, "sha512-e3ZuxzY9AL2psXPZlhwhnYrHUHv6PMoyK9MQ7croF0CUgURLqu9EMjW7q2FgVLwij1tjLsIHQrnwQ8aUcg4BiA=="], + "@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=="], diff --git a/web/index.html b/web/index.html index 1f2aece..f86b110 100644 --- a/web/index.html +++ b/web/index.html @@ -4,9 +4,9 @@ <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" /> + <meta name="theme-color" content="#0c0d0b" /> <title>~/workspace - +
diff --git a/web/package.json b/web/package.json index c657b08..832e774 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,8 @@ "@codemirror/language": "^6.10.8", "@codemirror/state": "^6.5.1", "@codemirror/view": "^6.36.2", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/noto-sans": "^5.3.0", "@lezer/highlight": "^1.2.1", "@replit/codemirror-vim": "^6.2.1", "dompurify": "^3.2.4", diff --git a/web/src/App.svelte b/web/src/App.svelte index 3254ccb..602679c 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -1,12 +1,14 @@ + +
+ + + + + + + + + + + + + user + {workspace.user} + + + +
+ + diff --git a/web/src/lib/Board.svelte b/web/src/lib/Board.svelte index ea73db8..7198ef9 100644 --- a/web/src/lib/Board.svelte +++ b/web/src/lib/Board.svelte @@ -5,8 +5,6 @@ * this file only draws and — for a pointer — moves the two cursors. */ import type { Card } from './api' - import { hooks, runCommand } from './commands' - import SpaceBar from './SpaceBar.svelte' import { workspace } from './store.svelte' import { formatDay } from './util' @@ -39,28 +37,22 @@
-
- ~/{workspace.space?.slug ?? ''}/board - +
+ + {workspace.space?.slug ?? ''} + board + {workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards - - - - - - -
- -
{#each workspace.board?.lists ?? [] as list, listIndex (list.id)}
-
+
+ {list.name} - {list.cards.length} + {list.cards.length}
    @@ -75,11 +67,11 @@ ondblclick={() => workspace.guard(() => workspace.openCard())} > - {card.done_at === null ? '[ ]' : '[x]'} - {card.title} + + {card.title} {#if card.due_at !== null || card.node_id !== null} - + {#if card.due_at !== null} due {formatDay(card.due_at)} {/if} @@ -91,13 +83,13 @@ {:else} -
  • no cards — o adds one
  • +
  • no cards — o adds one
  • {/each}
{:else} -
- no lists yet — [+] above, or :list <name>, makes a column. then o, or +, adds a card to it. +
+ no lists yet — :list <name> makes a column. then o adds a card to it.
{/each}
@@ -112,57 +104,54 @@ } .head { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - border-bottom: 1px solid var(--line); flex: none; + padding: 16px 20px 10px 22px; + white-space: nowrap; } - - .count { flex: 1; text-align: right; white-space: nowrap; } - .tools { display: flex; gap: 2px; flex: none; } - .tools button { padding: 0 6px; line-height: 18px; } + .head .marker { background: var(--light); } + .head .name { color: var(--light); } .columns { flex: 1; min-height: 0; display: flex; align-items: stretch; - gap: 0; + gap: 10px; + padding: 4px 14px 14px; overflow: auto; } .column { display: flex; flex-direction: column; - min-width: 26ch; + min-width: 28ch; flex: 1 1 0; - max-width: 44ch; - border-right: 1px solid var(--line); + max-width: 46ch; min-height: 0; + border-radius: var(--radius-m); + background: color-mix(in oklab, var(--surface) 70%, transparent); + border: 1px solid transparent; + transition: border-color var(--t-base) var(--ease-glide), background-color var(--t-base) var(--ease-glide); } + /* The list the cursor is on: its marker lights and its edge shows. */ + .column.on { border-color: var(--rim); background: var(--surface); } + .column header { display: flex; - justify-content: space-between; - gap: 8px; - padding: 6px 10px; - border-bottom: 1px solid var(--line); - color: var(--fg-dim); - position: sticky; - top: 0; - background: var(--bg); + align-items: center; + gap: 10px; + padding: 12px 14px 8px; } - - /* The list the cursor is on, marked the way the sidebar marks its space. */ - .column.on header .name { color: var(--accent); font-weight: 700; } - .column.on { background: var(--bg-alt); } + .column header .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .column.on header .marker { background: var(--light); } + .column.on header .name { color: var(--light); } + .n { font-variant-numeric: tabular-nums; } ul { list-style: none; margin: 0; - padding: 4px 0 24px; + padding: 2px 6px 10px; overflow-y: auto; flex: 1; } @@ -171,31 +160,47 @@ display: flex; flex-direction: column; align-items: flex-start; - gap: 2px; + gap: 3px; width: 100%; + margin: 1px 0; border: 0; - border-radius: 0; - padding: 4px 10px; + border-radius: var(--radius-s); + padding: 7px 10px; text-align: left; - color: var(--fg-dim); white-space: normal; + font-size: 13px; + letter-spacing: 0; + text-transform: none; + color: var(--fg); } - .card:hover { background: var(--bg-lift); color: var(--fg); } - .card.active { background: var(--select); color: var(--fg); } - .card.done .title { text-decoration: line-through; color: var(--fg-faint); } + .card:hover { background: var(--surface-alt); color: var(--light); border-color: transparent; } + .card:active { transform: none; } + .card.active, .card:focus-visible { background: var(--light); color: var(--ink); } + .card.active :is(.meta, .today, .overdue, .doc) { color: var(--ink); } + .card.active .tick { border-color: var(--ink); } + .card.active .tick.ticked { background: var(--ink); } + .card.done .text { text-decoration: line-through; color: var(--muted); } + .card.done.active .text { color: var(--ink); } - .tick { color: var(--accent); } - .title { display: block; } - .meta { display: flex; gap: 8px; font-size: 12px; padding-left: 4ch; } - .meta .today { color: var(--warn); } - .meta .overdue { color: var(--error); } + .title { display: flex; align-items: baseline; gap: 9px; font-family: var(--font-sans); font-size: 14px; } + .tick { + width: 9px; + height: 9px; + flex: none; + border: 1px solid var(--outline); + border-radius: 2px; + transform: translateY(1px); + } + .tick.ticked { background: var(--light-dim); border-color: var(--light-dim); } + .meta { display: flex; gap: 10px; font-size: 11px; padding-left: 18px; color: var(--muted); } + .meta .today { color: var(--light); } + .meta .overdue { color: var(--alarm); } .doc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 24ch; } - .empty { padding: 12px 10px; } + .empty { padding: 12px 10px; font-size: 12px; } @media (pointer: coarse) { - .card { padding: 8px 10px; } - .tools button { padding: 4px 10px; } + .card { padding: 10px; } } diff --git a/web/src/lib/CommandLine.svelte b/web/src/lib/CommandLine.svelte index 786643b..293e4c4 100644 --- a/web/src/lib/CommandLine.svelte +++ b/web/src/lib/CommandLine.svelte @@ -57,9 +57,9 @@ {#each matches as spec (spec.name)}
  • {/each} @@ -72,47 +72,60 @@
    diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte index 00e2eb1..ead5674 100644 --- a/web/src/lib/DocumentPane.svelte +++ b/web/src/lib/DocumentPane.svelte @@ -66,19 +66,21 @@
    -
    - {workspace.here}/{doc.slug} - {#if workspace.dirty}[+]{/if} +
    + + {workspace.here}/{doc.slug} + {#if workspace.dirty}modified{/if}
    -
    - {formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)} - · - edited {relative(doc.updated_at)} - · - {words} words -
    +
    +
    written
    +
    {formatStamp(workspace.draft.created_at)} {formatWeekday(workspace.draft.created_at)}
    +
    edited
    +
    {relative(doc.updated_at)}
    +
    words
    +
    {words}
    +
    {#if isTouchDevice} @@ -94,7 +96,7 @@ - +
    @@ -102,8 +104,9 @@ {#if sharePath}
    - ◉ public - {sharePath} + + public + {sharePath}
    {/if} @@ -111,7 +114,7 @@ {#if workspace.editing} workspace.touch()} onkeydown={(event) => { @@ -124,7 +127,7 @@
    {#key doc.id} {#await import('./Editor.svelte')} -
    loading editor…
    +
    loading editor_
    {:then module} {@const Editor = module.default} @@ -141,12 +144,12 @@ {:else}
    -
    +
    {#if doc.name.trim()}

    {doc.name}

    {/if} {@html html} {#if !doc.body.trim()} -

    this document is empty — press i to write.

    +

    this document is empty — press i to write.

    {/if}
    @@ -164,74 +167,114 @@ header { display: flex; flex-direction: column; - gap: 4px; - padding: 6px 12px; - border-bottom: 1px solid var(--line); + gap: 10px; + padding: 16px 24px 12px 28px; flex: none; } - .crumb { - display: flex; - align-items: baseline; - gap: 6px; + .crumb { min-width: 0; white-space: nowrap; } + .crumb .marker.live { background: var(--light); } + .path { min-width: 0; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; + color: var(--subtle); + letter-spacing: 0.08em; + text-transform: none; } + .dirty { flex: none; color: var(--light); } .row { display: flex; - gap: 8px; + gap: 12px; align-items: center; justify-content: space-between; min-width: 0; } - .meta { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; font-size: 12px; min-width: 0; } - .warn { color: var(--warn); flex: none; } + .meta { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + margin: 0; + font-size: 11px; + min-width: 0; + } + .meta dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; } + .meta dd { margin: 0 10px 0 0; color: var(--fg); } + .actions { display: flex; gap: 4px; flex-wrap: wrap; flex: none; } - .actions .on { color: var(--accent); border-color: var(--accent-dim); } + .actions .on { color: var(--light); border-color: var(--light-dim); } + .actions .danger:hover { background: var(--alarm); border-color: var(--alarm); color: var(--ink); } .sharebar { display: flex; - gap: 8px; + gap: 10px; align-items: center; - padding: 4px 12px; - background: var(--bg-lift); - border-bottom: 1px solid var(--line); + margin: 0 24px 8px 28px; + padding: 6px 6px 6px 12px; + border: 1px solid var(--rim); + border-radius: var(--radius-m); + background: var(--surface); font-size: 12px; overflow-x: auto; white-space: nowrap; flex: none; } + .ring { + width: 8px; + height: 8px; + flex: none; + border: 1.5px solid var(--light); + border-radius: 999px; + } + .public { color: var(--light); } + .sharebar .link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--muted); } .title { flex: none; - margin: 8px 12px 0; + margin: 8px 24px 4px 28px; + padding: 2px 0 6px; background: transparent; border: 0; - border-bottom: 1px dashed var(--line); + border-bottom: 1px solid var(--overlay); border-radius: 0; - color: var(--accent); - font-weight: 700; - padding: 2px 4px; + color: var(--light); + font-family: var(--font-sans); + font-size: 24px; + font-weight: 300; } + .title:focus { border-bottom-color: var(--light-dim); } - .loading { padding: 16px 20px; } + .loading { padding: 16px 28px; } .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; } + .reading { overflow-y: auto; padding: 18px 28px 30vh; } + .entrytitle { margin-top: 0 !important; font-size: 2.1em !important; } + + .key { + font-family: var(--font-mono); + font-size: 12px; + padding: 0 5px; + border-radius: 4px; + background: var(--light); + color: var(--ink); + } .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); + padding: 8px 12px calc(8px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--rim); flex: none; } .mobilebar button { flex: 1; padding: 8px; } + + @media (max-width: 760px) { + header { padding: 14px 16px 10px; } + .row { flex-direction: column; align-items: flex-start; } + .sharebar { margin: 0 16px 8px; } + .title { margin: 8px 16px 4px; } + .reading { padding: 14px 16px 30vh; } + } diff --git a/web/src/lib/Editor.svelte b/web/src/lib/Editor.svelte index 9f06efe..be7f198 100644 --- a/web/src/lib/Editor.svelte +++ b/web/src/lib/Editor.svelte @@ -27,16 +27,16 @@ 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)' }, + { tag: tags.heading, color: 'var(--light)', fontWeight: '600' }, + { tag: tags.strong, color: 'var(--light)', fontWeight: '700' }, + { tag: tags.emphasis, color: 'var(--subtle)', fontStyle: 'italic' }, + { tag: tags.link, color: 'var(--light)', textDecoration: 'underline', textDecorationColor: 'var(--overlay)' }, + { tag: tags.url, color: 'var(--light-dim)' }, + { tag: tags.monospace, color: 'var(--light-soft)', backgroundColor: 'var(--surface)' }, + { tag: tags.quote, color: 'var(--subtle)', fontStyle: 'italic' }, + { tag: tags.list, color: 'var(--light-dim)' }, + { tag: tags.contentSeparator, color: 'var(--muted)' }, + { tag: tags.processingInstruction, color: 'var(--muted)' }, ]) /* @@ -49,23 +49,23 @@ const editorTheme = EditorView.theme( { '&': { color: 'var(--fg)', backgroundColor: 'transparent', height: '100%' }, - '.cm-content': { padding: '12px 16px', maxWidth: '90ch' }, + '.cm-content': { padding: '12px 28px', maxWidth: '90ch' }, '.cm-line': { padding: '0' }, - '.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--accent)' }, + '.cm-cursor, .cm-dropCursor': { borderLeft: '2px solid var(--light)' }, '.cm-panels': { - backgroundColor: 'var(--bg-alt)', + backgroundColor: 'var(--hull)', color: 'var(--fg)', - borderColor: 'var(--line)', + borderColor: 'var(--rim)', }, - '.cm-panels-bottom': { borderTop: '1px solid var(--line)' }, + '.cm-panels-bottom': { borderTop: '1px solid var(--rim)' }, /* * The `:` prompt is a bare text node in the panel; the input follows it. * The input is an inline-block whose baseline sits below the text node's, * so the two are laid out as flex items instead of aligned by baseline. */ '.cm-vim-panel': { - padding: '2px 8px', - color: 'var(--accent)', + padding: '4px 12px', + color: 'var(--light)', display: 'flex', alignItems: 'center', }, @@ -73,8 +73,8 @@ fontFamily: 'inherit', fontSize: 'inherit', lineHeight: 'inherit', - color: 'var(--accent)', - caretColor: 'var(--accent)', + color: 'var(--light)', + caretColor: 'var(--light)', flex: '1', }, }, diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index ae7b7b9..2376ed4 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -60,8 +60,7 @@
    - :help - everything is a key or a `:` command + help everything is a key or a `:` command
    @@ -92,7 +91,7 @@
    {#each specs as spec (spec.name)}
    - {signature(spec)}{#if spec.args}{spec.args}{/if} + {signature(spec)}{#if spec.args}{spec.args}{/if}
    {spec.help}
    {#if spec.bang} @@ -105,59 +104,69 @@ {/each}
    -
    +
    inside the editor the full vim keymap is live — hjkl, w/b, dd, ciw, visual mode, macros, and - :w to write. drop or paste an image, video or audio file into the - editor to attach it. :12 jumps straight to entry 12. yanks and + :w to write. drop or paste an image, video or audio file into the + editor to attach it. :12 jumps straight to entry 12. yanks and deletes are copied to the system clipboard — - :set noclipboard keeps them in vim's registers. + :set noclipboard keeps them in vim's registers.
    diff --git a/web/src/lib/Login.svelte b/web/src/lib/Login.svelte index 2284a06..d3631ca 100644 --- a/web/src/lib/Login.svelte +++ b/web/src/lib/Login.svelte @@ -35,26 +35,30 @@
    -
    - + +
    + +

    workspace

    +
    -

    a private workspace. one user. one machine.

    +

    a private workspace · one user

    - - {#if error}{error}{/if} + + {#if error}{error}{/if}
    @@ -65,31 +69,70 @@ place-items: center; height: 100%; padding: 24px; + background: + radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%), + var(--base); } 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; + gap: 16px; + width: min(100%, 44ch); + background: color-mix(in oklab, var(--hull) 97%, transparent); + border: 1px solid var(--surface-alt); + border-radius: var(--frame-radius); + padding: 28px; } - .banner { - color: var(--accent); - font-size: 13px; - line-height: 1.3; - margin: 0; - overflow-x: auto; + .brand { display: flex; align-items: center; gap: 14px; } + h1 { margin: 0; font-size: 26px; text-shadow: 0 2px 24px var(--ink); } + + /* The ring-and-dot mark, larger than in the bar. */ + .mark { + position: relative; + width: 18px; + height: 18px; + flex: none; + border: 2px solid var(--light); + border-radius: 999px; + } + .mark::after { + content: ''; + position: absolute; + inset: 3px; + border-radius: 999px; + background: var(--light); } - p { margin: 0 0 4px; font-size: 12px; } + p { margin: 0 0 4px; font-size: 10px; } - label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; } + label { display: flex; flex-direction: column; gap: 6px; } + label .label { font-size: 10px; } + input { height: 36px; } - .row { display: flex; align-items: center; gap: 12px; } - .row button { padding: 6px 16px; } + .row { display: flex; align-items: center; gap: 14px; margin-top: 4px; } + + /* The desktop's action button: a hairline in light, a dot, a spaced label. */ + .enter { + display: flex; + align-items: center; + gap: 10px; + height: 44px; + padding: 0 20px 0 16px; + font-size: 11px; + letter-spacing: 0.22em; + color: var(--light); + border-color: color-mix(in oklab, var(--light) 55%, transparent); + } + .dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + opacity: 0.35; + transition: opacity var(--t-base) var(--ease-glide); + } + .enter:hover .dot, .enter:focus-visible .dot { opacity: 1; } + .alarm { font-size: 12px; } diff --git a/web/src/lib/MediaBrowser.svelte b/web/src/lib/MediaBrowser.svelte index 29928c9..5d93340 100644 --- a/web/src/lib/MediaBrowser.svelte +++ b/web/src/lib/MediaBrowser.svelte @@ -21,8 +21,7 @@
    - :media - {workspace.media.length} files + media {workspace.media.length} files
    @@ -33,13 +32,13 @@ {#if file.mime.startsWith('image/')} {file.filename} {:else} - {file.mime.split('/')[0]} + {file.mime.split('/')[0]} {/if}
    {file.filename} - + {formatBytes(file.size)} · {formatStamp(file.created_at)} · {usage(file.node_ids)} @@ -55,50 +54,65 @@ > {/if} - +
    {:else} -
  • nothing uploaded yet — attach a file from the editor.
  • +
  • nothing uploaded yet — attach a file from the editor.
  • {/each}
    diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte new file mode 100644 index 0000000..2ae2d7b --- /dev/null +++ b/web/src/lib/Rail.svelte @@ -0,0 +1,124 @@ + + + + + diff --git a/web/src/lib/Shared.svelte b/web/src/lib/Shared.svelte index 5f53aa4..1bec036 100644 --- a/web/src/lib/Shared.svelte +++ b/web/src/lib/Shared.svelte @@ -25,20 +25,21 @@
    {#if error} -

    {error}

    +

    {error}

    {:else if doc} -
    -
    - {formatStamp(doc.created_at)} {formatWeekday(doc.created_at)} +
    +
    + + {formatStamp(doc.created_at)} {formatWeekday(doc.created_at)} shared document
    {#if doc.name.trim()}

    {doc.name}

    {/if} {@html renderMarkdown(doc.body, { token, key: shareKey })}
    -
    — written in ~/workspace
    +
    — written in ~/workspace
    {:else} -

    loading…

    +

    loading_

    {/if}
    @@ -46,26 +47,24 @@ main { height: 100%; overflow-y: auto; - padding: 40px 20px 20vh; + padding: 56px 20px 20vh; display: flex; flex-direction: column; align-items: center; + background: + radial-gradient(120% 70% at 30% 0%, #141713 0%, transparent 60%), + var(--base); } - article { width: min(100%, 78ch); } + article { width: min(100%, 72ch); } - .meta { display: flex; gap: 10px; align-items: center; font-size: 12px; margin-bottom: 12px; } + .meta { margin-bottom: 28px; font-size: 10px; } + .meta .marker { background: var(--light); } - .tag { - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 0 6px; - color: var(--accent-dim); - } + .tag { color: var(--light); } - h1 { margin-top: 0 !important; } - h1::before { content: none !important; } + h1 { margin-top: 0 !important; font-size: 2.1em !important; } - footer { width: min(100%, 78ch); margin-top: 40px; font-size: 12px; } - p { width: min(100%, 78ch); } + footer { width: min(100%, 72ch); margin-top: 56px; font-size: 10px; } + p { width: min(100%, 72ch); } diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 82113c5..128de1b 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -1,7 +1,5 @@