From db953de67ad6443959653bb3833bb73f18315f8a Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:48:58 +0200 Subject: [PATCH 1/3] feat(core): hierarchical tag API - Tag/TagNode types, create_tag, list_tag_tree, assign_entry_tag, remove_entry_tag, entries_for_tag - Tag-filtered search returns child entries (no root restriction) - parent_entry_uid added to EntrySummary - 8 new tests --- crates/archivr-core/src/archive.rs | 416 +++++++++++++++++++++++++++- crates/archivr-core/src/database.rs | 114 +++++++- 2 files changed, 511 insertions(+), 19 deletions(-) diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 7a5f18e..be14c24 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -25,6 +25,7 @@ pub struct EntrySummary { pub original_url: Option, pub artifact_count: i64, pub total_artifact_bytes: i64, + pub parent_entry_uid: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] @@ -57,6 +58,20 @@ pub struct RunSummary { pub error_summary: Option, } +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct Tag { + pub tag_uid: String, + pub name: String, + pub slug: String, + pub full_path: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct TagNode { + pub tag: Tag, + pub children: Vec, +} + pub fn find_archive_path_from(start: &Path) -> Result> { let mut dir = start.to_path_buf(); loop { @@ -167,7 +182,8 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result Result>>()?; @@ -319,11 +336,13 @@ pub struct SearchEntriesQuery { pub after: Option, /// e.archived_at < before (exclusive, ISO 8601) pub before: Option, + /// Tag full_path filter; includes all entries (root + child) matching the tag subtree + pub tag: Option, } /// Parses a raw search string into a [`SearchEntriesQuery`]. /// -/// Recognized prefixes: `source:`, `type:`, `url:`, `title:`, `after:`, `before:`. +/// Recognized prefixes: `source:`, `type:`, `url:`, `title:`, `after:`, `before:`, `tag:`. /// Tokens with an unrecognized `prefix:` return `Err(prefix)`. /// Remaining non-prefix tokens are joined as the free-text `q`. /// Quoted values (`title:"resume templates"`) are supported for single-word values @@ -346,6 +365,7 @@ pub fn parse_search_query(raw: &str) -> Result { "title" => query.title = Some(value), "after" => query.after = Some(value), "before" => query.before = Some(value), + "tag" => query.tag = Some(value), other => return Err(other.to_string()), } } else { @@ -359,25 +379,52 @@ pub fn parse_search_query(raw: &str) -> Result { Ok(query) } +const ENTRY_SELECT_COLS: &str = + "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \ + e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \ + COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \ + parent.entry_uid AS parent_entry_uid"; + +const ENTRY_FROM_JOINS: &str = + "FROM archived_entries e \ + JOIN source_identities si ON si.id = e.source_identity_id \ + LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \ + LEFT JOIN blobs b ON b.id = ea.blob_id \ + LEFT JOIN archived_entries parent ON parent.id = e.parent_entry_id"; + /// Searches archived entries matching all non-`None` fields in `query`. /// -/// Returns root entries only (same scope as [`list_root_entries`]). +/// Without a tag filter, returns root entries only (same scope as [`list_root_entries`]). +/// With a tag filter, returns ALL entries (root and child) assigned to that tag subtree. pub fn search_entries( conn: &rusqlite::Connection, query: &SearchEntriesQuery, ) -> Result> { - let mut sql = String::from( - "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \ - e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \ - COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes \ - FROM archived_entries e \ - JOIN source_identities si ON si.id = e.source_identity_id \ - LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \ - LEFT JOIN blobs b ON b.id = ea.blob_id \ - WHERE e.parent_entry_id IS NULL", - ); - let mut params: Vec = Vec::new(); + let mut sql; + + if let Some(tag_path) = &query.tag { + params.push(tag_path.clone()); + sql = format!( + "WITH RECURSIVE descendants(id) AS (\ + SELECT id FROM tags WHERE full_path = ?1 \ + UNION ALL \ + SELECT child.id FROM tags child \ + JOIN descendants d ON child.parent_tag_id = d.id\ + ) {} {} \ + JOIN entry_tag_assignments eta ON eta.entry_id = e.id \ + JOIN descendants d ON eta.tag_id = d.id \ + WHERE 1=1", + ENTRY_SELECT_COLS, + ENTRY_FROM_JOINS, + ); + } else { + sql = format!( + "{} {} WHERE e.parent_entry_id IS NULL", + ENTRY_SELECT_COLS, + ENTRY_FROM_JOINS, + ); + } if let Some(q) = &query.q { let term = format!("%{}%", q.to_lowercase()); @@ -435,6 +482,191 @@ pub fn search_entries( original_url: row.get(6)?, artifact_count: row.get(7)?, total_artifact_bytes: row.get(8)?, + parent_entry_uid: row.get(9)?, + }) + })? + .collect::>>()?; + Ok(entries) +} + +fn tag_by_id(conn: &rusqlite::Connection, id: i64) -> Result { + conn.query_row( + "SELECT tag_uid, name, slug, full_path FROM tags WHERE id = ?1", + [id], + |row| { + Ok(Tag { + tag_uid: row.get(0)?, + name: row.get(1)?, + slug: row.get(2)?, + full_path: row.get(3)?, + }) + }, + ) + .context("tag not found by id") +} + +/// Creates all tag path segments (idempotent) and returns the leaf `Tag`. +pub fn create_tag(conn: &rusqlite::Connection, full_path: &str) -> Result { + let id = database::create_tag_path(conn, full_path)?; + tag_by_id(conn, id) +} + +/// Returns the full tag tree with root nodes at the top level and children nested. +pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result> { + use std::collections::HashMap; + + let records = database::list_all_tags(conn)?; + + let mut by_parent: HashMap, Vec> = HashMap::new(); + for record in records { + by_parent.entry(record.parent_tag_id).or_default().push(record); + } + + fn build_nodes( + parent_id: Option, + by_parent: &HashMap, Vec>, + ) -> Vec { + let Some(children) = by_parent.get(&parent_id) else { + return Vec::new(); + }; + children + .iter() + .map(|r| TagNode { + tag: Tag { + tag_uid: r.tag_uid.clone(), + name: r.name.clone(), + slug: r.slug.clone(), + full_path: r.full_path.clone(), + }, + children: build_nodes(Some(r.id), by_parent), + }) + .collect() + } + + Ok(build_nodes(None, &by_parent)) +} + +/// Returns the tags assigned to an entry. +/// +/// Returns `Ok(None)` if the entry_uid does not exist (caller maps to 404). +/// Returns `Ok(Some([]))` if the entry exists but has no tags. +pub fn get_entry_tags( + conn: &rusqlite::Connection, + entry_uid: &str, +) -> Result>> { + let Some(entry_id) = conn + .query_row( + "SELECT id FROM archived_entries WHERE entry_uid = ?1", + [entry_uid], + |row| row.get::<_, i64>(0), + ) + .optional()? + else { + return Ok(None); + }; + let records = database::list_tags_for_entry(conn, entry_id)?; + Ok(Some( + records + .into_iter() + .map(|r| Tag { + tag_uid: r.tag_uid, + name: r.name, + slug: r.slug, + full_path: r.full_path, + }) + .collect(), + )) +} + +/// Assigns a tag (by full path, creating it if needed) to an entry. +/// +/// Returns `Ok(None)` if the entry_uid does not exist. +/// Returns `Ok(Some(tag))` on success. +pub fn assign_entry_tag( + conn: &rusqlite::Connection, + entry_uid: &str, + tag_full_path: &str, +) -> Result> { + let Some(entry_id) = conn + .query_row( + "SELECT id FROM archived_entries WHERE entry_uid = ?1", + [entry_uid], + |row| row.get::<_, i64>(0), + ) + .optional()? + else { + return Ok(None); + }; + let tag_id = database::create_tag_path(conn, tag_full_path)?; + database::assign_entry_to_tag(conn, entry_id, tag_id)?; + Ok(Some(tag_by_id(conn, tag_id)?)) +} + +/// Removes a tag assignment from an entry. +/// +/// Returns `Ok(false)` if either the entry_uid or tag_uid is not found. +/// Returns `Ok(true)` on success (even if no row was deleted, i.e. assignment didn't exist). +pub fn remove_entry_tag( + conn: &rusqlite::Connection, + entry_uid: &str, + tag_uid: &str, +) -> Result { + let Some(entry_id) = conn + .query_row( + "SELECT id FROM archived_entries WHERE entry_uid = ?1", + [entry_uid], + |row| row.get::<_, i64>(0), + ) + .optional()? + else { + return Ok(false); + }; + let Some(tag_record) = database::get_tag_by_uid(conn, tag_uid)? else { + return Ok(false); + }; + database::remove_entry_tag_assignment(conn, entry_id, tag_record.id)?; + Ok(true) +} + +/// Returns all entries (root and child) assigned to any tag in the subtree rooted at `tag_full_path`. +pub fn entries_for_tag( + conn: &rusqlite::Connection, + tag_full_path: &str, +) -> Result> { + let mut stmt = conn.prepare( + "WITH RECURSIVE descendants(id) AS ( + SELECT id FROM tags WHERE full_path = ?1 + UNION ALL + SELECT child.id FROM tags child + JOIN descendants d ON child.parent_tag_id = d.id + ) + SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, + e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, + COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, + parent.entry_uid AS parent_entry_uid + FROM archived_entries e + JOIN source_identities si ON si.id = e.source_identity_id + LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id + LEFT JOIN blobs b ON b.id = ea.blob_id + LEFT JOIN archived_entries parent ON parent.id = e.parent_entry_id + JOIN entry_tag_assignments eta ON eta.entry_id = e.id + JOIN descendants d ON eta.tag_id = d.id + GROUP BY e.id + ORDER BY e.archived_at DESC, e.id DESC", + )?; + let entries = stmt + .query_map([tag_full_path], |row| { + Ok(EntrySummary { + entry_uid: row.get(0)?, + archived_at: row.get(1)?, + source_kind: row.get(2)?, + entity_kind: row.get(3)?, + title: row.get(4)?, + visibility: row.get(5)?, + original_url: row.get(6)?, + artifact_count: row.get(7)?, + total_artifact_bytes: row.get(8)?, + parent_entry_uid: row.get(9)?, }) })? .collect::>>()?; @@ -683,6 +915,13 @@ mod tests { assert_eq!(q.before.as_deref(), Some("2026-04-01")); } + #[test] + fn parse_search_query_tag_prefix() { + let q = parse_search_query("tag:/science/cs").unwrap(); + assert_eq!(q.tag.as_deref(), Some("/science/cs")); + assert_eq!(q.q, None); + } + // ---- search_entries tests ---- fn make_test_db_with_entries() -> rusqlite::Connection { @@ -805,4 +1044,153 @@ mod tests { }).unwrap(); assert_eq!(results.len(), 1); } + + // ---- tag API tests ---- + + fn make_tag_test_db() -> (rusqlite::Connection, i64, i64) { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + database::initialize_schema(&conn).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 2).unwrap(); + (conn, user_id, run.id) + } + + fn make_entry_in_db( + conn: &rusqlite::Connection, + user_id: i64, + run_id: i64, + parent_entry_id: Option, + root_entry_id: Option, + title: &str, + url: &str, + ) -> database::ArchivedEntry { + let si = database::upsert_source_identity( + conn, "web", "page", None, Some(url), url, + ).unwrap(); + database::create_archived_entry( + conn, + &database::NewEntry { + source_identity_id: si, + archive_run_id: run_id, + parent_entry_id, + root_entry_id, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "web".to_string(), + entity_kind: "page".to_string(), + title: Some(title.to_string()), + visibility: "private".to_string(), + representation_kind: "html".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ).unwrap() + } + + #[test] + fn tag_tree_roots_and_children() { + let (conn, _, _) = make_tag_test_db(); + create_tag(&conn, "/science/cs").unwrap(); + create_tag(&conn, "/art").unwrap(); + + let tree = list_tag_tree(&conn).unwrap(); + assert_eq!(tree.len(), 2, "expected two root nodes"); + + let science = tree.iter().find(|n| n.tag.slug == "science").expect("science root missing"); + assert_eq!(science.children.len(), 1, "science should have one child"); + assert_eq!(science.children[0].tag.slug, "cs"); + + let art = tree.iter().find(|n| n.tag.slug == "art").expect("art root missing"); + assert!(art.children.is_empty(), "art should have no children"); + } + + #[test] + fn assign_entry_tag_is_idempotent() { + let (conn, user_id, run_id) = make_tag_test_db(); + let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t1"); + + assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap(); + assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap(); + + let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap(); + assert_eq!(tags.len(), 1, "idempotent assign should yield exactly one tag"); + } + + #[test] + fn remove_entry_tag_clears_assignment() { + let (conn, user_id, run_id) = make_tag_test_db(); + let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Test", "https://example.com/t2"); + + let tag = assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap().unwrap(); + remove_entry_tag(&conn, &entry.entry_uid, &tag.tag_uid).unwrap(); + + let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap(); + assert!(tags.is_empty(), "tag should be removed"); + } + + #[test] + fn entries_for_tag_includes_descendants() { + let (conn, user_id, run_id) = make_tag_test_db(); + let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Compilers Paper", "https://example.com/c1"); + + assign_entry_tag(&conn, &entry.entry_uid, "/science/cs/compilers").unwrap(); + + let results = entries_for_tag(&conn, "/science").unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].entry_uid, entry.entry_uid); + } + + #[test] + fn entries_for_tag_includes_child_entries() { + let (conn, user_id, run_id) = make_tag_test_db(); + let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Playlist", "https://example.com/pl"); + let child = make_entry_in_db( + &conn, user_id, run_id, + Some(parent.id), Some(parent.id), + "Video 1", "https://example.com/pl/v1", + ); + + assign_entry_tag(&conn, &child.entry_uid, "/science").unwrap(); + + let results = entries_for_tag(&conn, "/science").unwrap(); + assert_eq!(results.len(), 1, "only the tagged child should appear"); + assert_eq!(results[0].entry_uid, child.entry_uid); + assert_eq!(results[0].parent_entry_uid.as_deref(), Some(parent.entry_uid.as_str())); + } + + #[test] + fn search_with_tag_filter_works() { + let (conn, user_id, run_id) = make_tag_test_db(); + let entry = make_entry_in_db(&conn, user_id, run_id, None, None, "Science Article", "https://example.com/s1"); + + assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap(); + + let results = search_entries(&conn, &SearchEntriesQuery { + tag: Some("/science".to_string()), + ..Default::default() + }).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].entry_uid, entry.entry_uid); + + let empty = search_entries(&conn, &SearchEntriesQuery { + tag: Some("/art".to_string()), + ..Default::default() + }).unwrap(); + assert!(empty.is_empty(), "no entries under /art"); + } + + #[test] + fn search_without_tag_returns_roots_only() { + let (conn, user_id, run_id) = make_tag_test_db(); + let parent = make_entry_in_db(&conn, user_id, run_id, None, None, "Parent", "https://example.com/par"); + let _child = make_entry_in_db( + &conn, user_id, run_id, + Some(parent.id), Some(parent.id), + "Child", "https://example.com/par/c", + ); + + let results = search_entries(&conn, &SearchEntriesQuery::default()).unwrap(); + assert_eq!(results.len(), 1, "plain search should return root entries only"); + assert_eq!(results[0].entry_uid, parent.entry_uid); + } } diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index 5ec3f74..d207c35 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -63,6 +63,16 @@ pub struct NewArtifact { pub metadata_json: Option, } +#[derive(Debug, Clone)] +pub struct TagRecord { + pub id: i64, + pub tag_uid: String, + pub parent_tag_id: Option, + pub name: String, + pub slug: String, + pub full_path: String, +} + pub fn database_path(archive_path: &Path) -> PathBuf { archive_path.join(DATABASE_FILE_NAME) } @@ -214,6 +224,7 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_entry_artifacts_entry_id ON entry_artifacts(entry_id); CREATE INDEX IF NOT EXISTS idx_entry_artifacts_blob_id ON entry_artifacts(blob_id); CREATE INDEX IF NOT EXISTS idx_tags_parent_tag_id ON tags(parent_tag_id); + CREATE INDEX IF NOT EXISTS idx_entry_tag_assignments_tag_id ON entry_tag_assignments(tag_id); "#, )?; Ok(()) @@ -488,6 +499,104 @@ pub fn add_entry_artifact(conn: &Connection, artifact: &NewArtifact) -> Result Result<()> { + conn.execute( + "DELETE FROM entry_tag_assignments WHERE entry_id = ?1 AND tag_id = ?2", + params![entry_id, tag_id], + )?; + Ok(()) +} + +pub fn list_all_tags(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, tag_uid, parent_tag_id, name, slug, full_path + FROM tags + ORDER BY full_path", + )?; + let records = stmt + .query_map([], |row| { + Ok(TagRecord { + id: row.get(0)?, + tag_uid: row.get(1)?, + parent_tag_id: row.get(2)?, + name: row.get(3)?, + slug: row.get(4)?, + full_path: row.get(5)?, + }) + })? + .collect::, _>>() + .context("failed to list tags")?; + Ok(records) +} + +pub fn list_tags_for_entry(conn: &Connection, entry_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT t.id, t.tag_uid, t.parent_tag_id, t.name, t.slug, t.full_path + FROM tags t + JOIN entry_tag_assignments eta ON eta.tag_id = t.id + WHERE eta.entry_id = ?1 + ORDER BY t.full_path", + )?; + let records = stmt + .query_map([entry_id], |row| { + Ok(TagRecord { + id: row.get(0)?, + tag_uid: row.get(1)?, + parent_tag_id: row.get(2)?, + name: row.get(3)?, + slug: row.get(4)?, + full_path: row.get(5)?, + }) + })? + .collect::, _>>() + .context("failed to list tags for entry")?; + Ok(records) +} + +pub fn get_tag_by_uid(conn: &Connection, tag_uid: &str) -> Result> { + conn.query_row( + "SELECT id, tag_uid, parent_tag_id, name, slug, full_path + FROM tags WHERE tag_uid = ?1", + [tag_uid], + |row| { + Ok(TagRecord { + id: row.get(0)?, + tag_uid: row.get(1)?, + parent_tag_id: row.get(2)?, + name: row.get(3)?, + slug: row.get(4)?, + full_path: row.get(5)?, + }) + }, + ) + .optional() + .context("failed to get tag by uid") +} + +pub fn get_tag_by_path(conn: &Connection, full_path: &str) -> Result> { + conn.query_row( + "SELECT id, tag_uid, parent_tag_id, name, slug, full_path + FROM tags WHERE full_path = ?1", + [full_path], + |row| { + Ok(TagRecord { + id: row.get(0)?, + tag_uid: row.get(1)?, + parent_tag_id: row.get(2)?, + name: row.get(3)?, + slug: row.get(4)?, + full_path: row.get(5)?, + }) + }, + ) + .optional() + .context("failed to get tag by path") +} + #[cfg(test)] pub fn set_public_settings( conn: &Connection, @@ -535,7 +644,6 @@ pub fn main_archive_entry_count(conn: &Connection) -> Result { Ok(count) } -#[cfg(test)] pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result { let segments = normalized_tag_segments(full_path)?; let mut parent_tag_id = None; @@ -577,7 +685,6 @@ pub fn create_tag_path(conn: &Connection, full_path: &str) -> Result { Ok(current_id) } -#[cfg(test)] pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Result<()> { conn.execute( "INSERT OR IGNORE INTO entry_tag_assignments (entry_id, tag_id) @@ -587,7 +694,6 @@ pub fn assign_entry_to_tag(conn: &Connection, entry_id: i64, tag_id: i64) -> Res Ok(()) } -#[cfg(test)] pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result { let count = conn.query_row( "WITH RECURSIVE descendants(id) AS ( @@ -653,7 +759,6 @@ fn validate_visibility(visibility: &str) -> Result<()> { } } -#[cfg(test)] fn normalized_tag_segments(full_path: &str) -> Result> { let segments = full_path .trim() @@ -669,7 +774,6 @@ fn normalized_tag_segments(full_path: &str) -> Result> { Ok(segments) } -#[cfg(test)] fn humanize_slug(slug: &str) -> String { slug.split('-') .map(|part| { From 5803f2119b9850119c74bb37ae4a3096c47f3452 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:13:02 +0200 Subject: [PATCH 2/3] feat(server+ui): tag management API routes and browser UI - 5 tag routes (list tree, create, assign, remove, search with tag=) - Tags nav, tag tree view, tag filter badge - Entry tag pills with remove, assign-tag form in context rail --- Cargo.lock | 1 + crates/archivr-server/Cargo.toml | 1 + crates/archivr-server/src/routes.rs | 443 +++++++++++++++++++++++- crates/archivr-server/static/app.js | 165 ++++++++- crates/archivr-server/static/index.html | 10 + crates/archivr-server/static/styles.css | 122 +++++++ 6 files changed, 727 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a6b629..e220753 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,7 @@ dependencies = [ "archivr-core", "axum", "serde", + "serde_json", "tempfile", "tokio", "toml", diff --git a/crates/archivr-server/Cargo.toml b/crates/archivr-server/Cargo.toml index 77155fa..4c3548e 100644 --- a/crates/archivr-server/Cargo.toml +++ b/crates/archivr-server/Cargo.toml @@ -16,3 +16,4 @@ tower-http.workspace = true [dev-dependencies] tempfile.workspace = true tower.workspace = true +serde_json.workspace = true diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 332e5c3..74d1ceb 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -6,7 +6,7 @@ use axum::{ extract::{Path, Query, Request, State}, http::StatusCode, response::{IntoResponse, Response}, - routing::get, + routing::{delete, get, post}, }; use tower_http::services::{ServeDir, ServeFile}; use tower::ServiceExt; @@ -21,6 +21,7 @@ pub struct AppState { #[derive(Debug, serde::Deserialize, Default)] pub struct EntrySearchParams { pub q: Option, + pub tag: Option, } pub fn app(registry: ServerRegistry) -> Router { @@ -43,6 +44,15 @@ pub fn app(registry: ServerRegistry) -> Router { get(serve_artifact), ) .route("/api/archives/:archive_id/runs", get(list_runs)) + .route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler)) + .route( + "/api/archives/:archive_id/entries/:entry_uid/tags", + get(list_entry_tags).post(assign_entry_tag_handler), + ) + .route( + "/api/archives/:archive_id/entries/:entry_uid/tags/:tag_uid", + delete(remove_entry_tag_handler), + ) .nest_service("/assets", ServeDir::new(&static_dir)) .fallback_service(ServeFile::new(static_dir.join("index.html"))) .with_state(state) @@ -75,12 +85,11 @@ async fn search_entries_handler( let mounted = mounted_archive(&state, &archive_id)?; let conn = database::open_or_initialize(&mounted.archive_path)?; let raw = params.q.as_deref().unwrap_or(""); - let search_query = archive::parse_search_query(raw) - .map_err(|prefix| ApiError::bad_request(&format!("unknown search prefix: {prefix}"))); - let search_query = match search_query { - Ok(q) => q, - Err(e) => return Err(e), - }; + let mut search_query = archive::parse_search_query(raw) + .map_err(|prefix| ApiError::bad_request(&format!("unknown search prefix: {prefix}")))?; + if let Some(tag) = params.tag { + search_query.tag = Some(tag); + } Ok(Json(archive::search_entries(&conn, &search_query)?)) } @@ -128,6 +137,80 @@ async fn serve_artifact( .into_response()) } +#[derive(Debug, serde::Deserialize)] +struct CreateTagBody { + path: String, +} + +#[derive(Debug, serde::Deserialize)] +struct AssignTagBody { + tag_path: String, +} + +async fn list_tags( + State(state): State, + Path(archive_id): Path, +) -> Result>, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + Ok(Json(archive::list_tag_tree(&conn)?)) +} + +async fn create_tag_handler( + State(state): State, + Path(archive_id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + if body.path.trim().is_empty() { + return Err(ApiError::bad_request("tag path must not be empty")); + } + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let tag = archive::create_tag(&conn, &body.path)?; + Ok((StatusCode::CREATED, Json(tag))) +} + +async fn list_entry_tags( + State(state): State, + Path((archive_id, entry_uid)): Path<(String, String)>, +) -> Result>, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + match archive::get_entry_tags(&conn, &entry_uid)? { + Some(tags) => Ok(Json(tags)), + None => Err(ApiError::not_found("entry not found")), + } +} + +async fn assign_entry_tag_handler( + State(state): State, + Path((archive_id, entry_uid)): Path<(String, String)>, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + if body.tag_path.trim().is_empty() { + return Err(ApiError::bad_request("tag_path must not be empty")); + } + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + match archive::assign_entry_tag(&conn, &entry_uid, &body.tag_path)? { + Some(tag) => Ok((StatusCode::CREATED, Json(tag))), + None => Err(ApiError::not_found("entry not found")), + } +} + +async fn remove_entry_tag_handler( + State(state): State, + Path((archive_id, entry_uid, tag_uid)): Path<(String, String, String)>, +) -> Result { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + if archive::remove_entry_tag(&conn, &entry_uid, &tag_uid)? { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found("entry or tag not found")) + } +} + fn mounted_archive<'a>( state: &'a AppState, archive_id: &str, @@ -463,4 +546,350 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + + // ---- helpers ---- + + fn make_test_registry(dir: &tempfile::TempDir) -> (ServerRegistry, std::path::PathBuf) { + let paths = archivr_core::archive::initialize_archive( + dir.path(), + &dir.path().join("store"), + "test", + false, + ) + .unwrap(); + let registry = ServerRegistry { + archives: vec![MountedArchive { + id: "test".to_string(), + label: "Test".to_string(), + archive_path: paths.archive_path.clone(), + }], + }; + (registry, paths.archive_path) + } + + fn make_test_entry(archive_path: &std::path::Path) -> archivr_core::database::ArchivedEntry { + let conn = database::open_or_initialize(archive_path).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 1).unwrap(); + let si = database::upsert_source_identity( + &conn, "web", "page", None, + Some("https://example.com/test"), + "https://example.com/test", + ) + .unwrap(); + database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: si, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "web".to_string(), + entity_kind: "page".to_string(), + title: Some("Test Entry".to_string()), + visibility: "private".to_string(), + representation_kind: "html".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ) + .unwrap() + } + + async fn body_json(response: axum::response::Response) -> serde_json::Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&bytes).unwrap() + } + + fn json_body(payload: &serde_json::Value) -> Body { + Body::from(serde_json::to_vec(payload).unwrap()) + } + + // ---- tag route tests ---- + + #[tokio::test] + async fn test_list_tags_unknown_archive() { + let response = app(ServerRegistry::default()) + .oneshot( + Request::builder() + .uri("/api/archives/ghost/tags") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_create_tag_unknown_archive() { + let response = app(ServerRegistry::default()) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/ghost/tags") + .header("content-type", "application/json") + .body(json_body(&serde_json::json!({"path": "/science"}))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_create_tag_empty_path() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _) = make_test_registry(&dir); + let response = app(registry) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/tags") + .header("content-type", "application/json") + .body(json_body(&serde_json::json!({"path": ""}))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_tag_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _) = make_test_registry(&dir); + + let create_response = app(registry.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/tags") + .header("content-type", "application/json") + .body(json_body(&serde_json::json!({"path": "/science"}))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(create_response.status(), StatusCode::CREATED); + + let list_response = app(registry.clone()) + .oneshot( + Request::builder() + .uri("/api/archives/test/tags") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(list_response.status(), StatusCode::OK); + let tree = body_json(list_response).await; + let slugs: Vec<&str> = tree + .as_array() + .unwrap() + .iter() + .map(|n| n["tag"]["slug"].as_str().unwrap()) + .collect(); + assert!(slugs.contains(&"science"), "expected 'science' in tag tree, got {slugs:?}"); + } + + #[tokio::test] + async fn test_entry_tag_assign_and_remove() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + let entry_uid = entry.entry_uid.clone(); + let entry_tags_uri = format!("/api/archives/test/entries/{entry_uid}/tags"); + + // Assign tag + let assign_response = app(registry.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri(&entry_tags_uri) + .header("content-type", "application/json") + .body(json_body(&serde_json::json!({"tag_path": "/science"}))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(assign_response.status(), StatusCode::CREATED); + let assigned_tag = body_json(assign_response).await; + let tag_uid = assigned_tag["tag_uid"].as_str().unwrap().to_string(); + + // List entry tags — should contain the assigned tag + let list_response = app(registry.clone()) + .oneshot( + Request::builder() + .uri(&entry_tags_uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(list_response.status(), StatusCode::OK); + let tags = body_json(list_response).await; + assert_eq!(tags.as_array().unwrap().len(), 1); + + // Remove tag + let delete_uri = format!("{entry_tags_uri}/{tag_uid}"); + let delete_response = app(registry.clone()) + .oneshot( + Request::builder() + .method("DELETE") + .uri(&delete_uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); + + // List entry tags again — should be empty + let list2_response = app(registry.clone()) + .oneshot( + Request::builder() + .uri(&entry_tags_uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(list2_response.status(), StatusCode::OK); + let tags2 = body_json(list2_response).await; + assert!(tags2.as_array().unwrap().is_empty(), "tags should be empty after removal"); + } + + #[tokio::test] + async fn test_search_with_tag_param() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + let entry_uid = entry.entry_uid.clone(); + + // Assign /science tag to entry + let assign_resp = app(registry.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{entry_uid}/tags")) + .header("content-type", "application/json") + .body(json_body(&serde_json::json!({"tag_path": "/science"}))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(assign_resp.status(), StatusCode::CREATED, "assign tag should return 201"); + + // Search with ?tag=/science — entry should appear + let response = app(registry.clone()) + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/search?tag=%2Fscience") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let results = body_json(response).await; + assert_eq!( + results.as_array().unwrap().len(), + 1, + "expected 1 result for /science tag, got {}", + results.as_array().unwrap().len() + ); + + // Search with ?tag=/art — should return empty + let response2 = app(registry.clone()) + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/search?tag=%2Fart") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response2.status(), StatusCode::OK); + let results2 = body_json(response2).await; + assert!( + results2.as_array().unwrap().is_empty(), + "expected 0 results for /art tag" + ); + } + + #[tokio::test] + async fn test_list_entry_tags_unknown_entry() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _) = make_test_registry(&dir); + let response = app(registry) + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/ghost_uid/tags") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_assign_entry_tag_unknown_entry() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _) = make_test_registry(&dir); + let response = app(registry) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/archives/test/entries/ghost_uid/tags") + .header("content-type", "application/json") + .body(json_body(&serde_json::json!({"tag_path": "/science"}))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn test_assign_entry_tag_empty_tag_path() { + let dir = tempfile::tempdir().unwrap(); + let (registry, archive_path) = make_test_registry(&dir); + let entry = make_test_entry(&archive_path); + let entry_uid = entry.entry_uid.clone(); + let response = app(registry) + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/archives/test/entries/{entry_uid}/tags")) + .header("content-type", "application/json") + .body(json_body(&serde_json::json!({"tag_path": ""}))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + + #[tokio::test] + async fn test_remove_entry_tag_unknown_entry() { + let dir = tempfile::tempdir().unwrap(); + let (registry, _) = make_test_registry(&dir); + let response = app(registry) + .oneshot( + Request::builder() + .method("DELETE") + .uri("/api/archives/test/entries/ghost_uid/tags/ghost_tag_uid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } } diff --git a/crates/archivr-server/static/app.js b/crates/archivr-server/static/app.js index 148f1d9..7c497a5 100644 --- a/crates/archivr-server/static/app.js +++ b/crates/archivr-server/static/app.js @@ -3,7 +3,10 @@ const state = { archiveId: null, entries: [], selectedEntryUid: null, + selectedEntry: null, + tagFilter: null, }; +let selectSeq = 0; const archiveSwitcher = document.querySelector("#archive-switcher"); const entriesBody = document.querySelector("#entries-body"); @@ -13,6 +16,11 @@ const navButtons = document.querySelectorAll(".nav-link"); const searchInput = document.querySelector("#search"); const resultCount = document.querySelector("#result-count"); const adminArchives = document.querySelector("#admin-archives"); +const tagTree = document.querySelector("#tag-tree"); +const entryTagsEl = document.querySelector("#entry-tags"); +const assignTagForm = document.querySelector("#assign-tag-form"); +const assignTagInput = document.querySelector("#assign-tag-input"); +const assignTagBtn = document.querySelector("#assign-tag-btn"); function formatBytes(bytes) { if (!bytes) return "0 B"; @@ -78,6 +86,16 @@ function renderEntries() { } else { resultCount.textContent = `${state.entries.length} entries`; } + if (state.tagFilter) { + const badge = document.createElement("button"); + badge.className = "tag-filter-badge"; + badge.textContent = `× ${state.tagFilter}`; + badge.addEventListener("click", () => { + state.tagFilter = null; + if (state.archiveId) loadEntries(searchInput.value); + }); + resultCount.appendChild(badge); + } for (const entry of state.entries) { const row = document.createElement("tr"); @@ -194,11 +212,59 @@ function renderContextDetail(detail) { } } +function renderEntryTags(tags, entryUid) { + entryTagsEl.innerHTML = ""; + if (!tags.length) { + entryTagsEl.textContent = "No tags."; + return; + } + for (const tag of tags) { + const pill = document.createElement("span"); + pill.className = "tag-pill"; + pill.textContent = tag.name; + pill.title = tag.full_path; + const removeBtn = document.createElement("button"); + removeBtn.className = "remove-tag"; + removeBtn.textContent = "×"; + removeBtn.title = `Remove tag ${tag.full_path}`; + removeBtn.addEventListener("click", async () => { + const resp = await fetch( + `/api/archives/${state.archiveId}/entries/${entryUid}/tags/${tag.tag_uid}`, + { method: "DELETE" } + ); + if (!resp.ok) { + removeBtn.title = `Remove failed (${resp.status})`; + return; + } + const updated = await getJson( + `/api/archives/${state.archiveId}/entries/${entryUid}/tags` + ); + renderEntryTags(updated, entryUid); + loadTagTree(); + }); + pill.appendChild(removeBtn); + entryTagsEl.appendChild(pill); + } +} + async function selectEntry(entry) { + const seq = ++selectSeq; state.selectedEntryUid = entry.entry_uid; + state.selectedEntry = entry; renderEntries(); - const detail = await getJson(`/api/archives/${state.archiveId}/entries/${entry.entry_uid}`); + const detail = await getJson( + `/api/archives/${state.archiveId}/entries/${entry.entry_uid}` + ); + if (seq !== selectSeq) return; renderContextDetail(detail); + entryTagsEl.hidden = false; + assignTagForm.hidden = false; + entryTagsEl.innerHTML = ""; + const tags = await getJson( + `/api/archives/${state.archiveId}/entries/${entry.entry_uid}/tags` + ); + if (seq !== selectSeq) return; + renderEntryTags(tags, entry.entry_uid); } async function loadRuns() { @@ -217,9 +283,13 @@ async function loadRuns() { async function loadEntries(q = "") { const trimmed = q.trim(); - const url = trimmed - ? `/api/archives/${state.archiveId}/entries/search?q=${encodeURIComponent(trimmed)}` - : `/api/archives/${state.archiveId}/entries`; + const params = new URLSearchParams(); + if (trimmed) params.set("q", trimmed); + if (state.tagFilter) params.set("tag", state.tagFilter); + const url = + trimmed || state.tagFilter + ? `/api/archives/${state.archiveId}/entries/search?${params}` + : `/api/archives/${state.archiveId}/entries`; searchInput.setAttribute("aria-busy", "true"); try { state.entries = await getJson(url); @@ -232,6 +302,49 @@ async function loadEntries(q = "") { renderEntries(); } +async function loadTagTree() { + if (!state.archiveId) return; + const nodes = await getJson(`/api/archives/${state.archiveId}/tags`); + tagTree.innerHTML = ""; + renderTagTree(nodes, tagTree); +} + +function renderTagTree(nodes, container) { + if (!nodes.length) { + container.textContent = "No tags yet."; + return; + } + const ul = document.createElement("ul"); + ul.className = "tag-tree-list"; + for (const node of nodes) { + const li = document.createElement("li"); + const btn = document.createElement("button"); + btn.className = "tag-node-btn"; + if (state.tagFilter === node.tag.full_path) btn.classList.add("is-active"); + btn.textContent = node.tag.name; + btn.title = node.tag.full_path; + btn.addEventListener("click", () => { + if (state.tagFilter === node.tag.full_path) { + state.tagFilter = null; + } else { + state.tagFilter = node.tag.full_path; + } + // Switch to archive view and reload + switchView("archive"); + if (state.archiveId) loadEntries(searchInput.value); + }); + li.appendChild(btn); + if (node.children?.length) { + const childContainer = document.createElement("div"); + childContainer.className = "tag-children"; + renderTagTree(node.children, childContainer); + li.appendChild(childContainer); + } + ul.appendChild(li); + } + container.appendChild(ul); +} + async function loadArchives() { state.archives = await getJson("/api/archives"); state.archiveId = state.archives[0]?.id ?? null; @@ -239,6 +352,7 @@ async function loadArchives() { if (state.archiveId) { await loadEntries(); await loadRuns(); + loadTagTree(); } else { contextBody.textContent = "No archives mounted."; resultCount.textContent = "0 entries"; @@ -254,9 +368,15 @@ function debounce(fn, ms) { } archiveSwitcher.addEventListener("change", async () => { + state.tagFilter = null; + state.selectedEntry = null; + state.selectedEntryUid = null; + entryTagsEl.hidden = true; + assignTagForm.hidden = true; state.archiveId = archiveSwitcher.value; await loadEntries(); await loadRuns(); + loadTagTree(); }); const debouncedSearch = debounce((q) => { @@ -267,15 +387,44 @@ searchInput.addEventListener("input", () => { debouncedSearch(searchInput.value); }); +function switchView(name) { + navButtons.forEach(b => b.classList.toggle("is-active", b.dataset.view === name)); + document.querySelectorAll(".view").forEach(v => v.classList.remove("is-active")); + document.querySelector(`#${name}-view`)?.classList.add("is-active"); +} + navButtons.forEach((button) => { button.addEventListener("click", () => { - navButtons.forEach((candidate) => candidate.classList.remove("is-active")); - document.querySelectorAll(".view").forEach((view) => view.classList.remove("is-active")); - button.classList.add("is-active"); - document.querySelector(`#${button.dataset.view}-view`).classList.add("is-active"); + switchView(button.dataset.view); + if (button.dataset.view === "tags") loadTagTree(); }); }); +assignTagBtn.addEventListener("click", async () => { + const path = assignTagInput.value.trim(); + if (!path || !state.selectedEntry) return; + const resp = await fetch( + `/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tag_path: path }), + } + ); + if (resp.ok) { + assignTagInput.setCustomValidity(""); + assignTagInput.value = ""; + const tags = await getJson( + `/api/archives/${state.archiveId}/entries/${state.selectedEntry.entry_uid}/tags` + ); + renderEntryTags(tags, state.selectedEntry.entry_uid); + loadTagTree(); + } else { + assignTagInput.setCustomValidity(`Failed to add tag (${resp.status})`); + assignTagInput.reportValidity(); + } +}); + loadArchives().catch((error) => { contextBody.textContent = `Failed to load archives: ${error.message}`; }); diff --git a/crates/archivr-server/static/index.html b/crates/archivr-server/static/index.html index 021ef15..6ed1d50 100644 --- a/crates/archivr-server/static/index.html +++ b/crates/archivr-server/static/index.html @@ -14,6 +14,7 @@ + @@ -66,11 +67,20 @@

Mounted Archives

+ +
+
+
diff --git a/crates/archivr-server/static/styles.css b/crates/archivr-server/static/styles.css index a1b1395..18a50a9 100644 --- a/crates/archivr-server/static/styles.css +++ b/crates/archivr-server/static/styles.css @@ -374,3 +374,125 @@ select { .search-input[aria-busy="true"] { cursor: progress; } + +/* Tag tree */ +.tag-tree { + padding: 12px; +} + +.tag-tree-list { + list-style: none; + margin: 0; + padding: 0; +} + +.tag-children { + padding-left: 16px; +} + +.tag-node-btn { + border: 0; + background: transparent; + color: var(--link); + cursor: pointer; + padding: 3px 0; + text-align: left; + font-size: 13px; + display: block; + width: 100%; +} + +.tag-node-btn:hover { + text-decoration: underline; +} + +.tag-node-btn.is-active { + font-weight: 600; + color: var(--ink); +} + +/* Tag pills in context rail */ +.entry-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 0; +} + +.tag-pill { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--paper-2); + border: 1px solid var(--line); + border-radius: 3px; + font-size: 12px; + padding: 2px 6px; + color: var(--ink); +} + +.remove-tag { + border: 0; + background: none; + cursor: pointer; + color: var(--muted); + padding: 0; + font-size: 14px; + line-height: 1; +} + +.remove-tag:hover { + color: var(--accent); +} + +/* Assign tag form */ +.assign-tag-form { + display: flex; + gap: 6px; + padding: 6px 0 8px; + border-top: 1px solid var(--line-soft); + margin-top: 4px; +} + +.assign-tag-input { + flex: 1; + min-width: 0; + border: 1px solid var(--line); + background: var(--paper-3); + color: var(--ink); + padding: 4px 7px; + font-size: 12px; +} + +.assign-tag-btn { + border: 1px solid var(--line); + background: var(--paper-2); + color: var(--ink); + cursor: pointer; + padding: 4px 10px; + font-size: 12px; + white-space: nowrap; +} + +.assign-tag-btn:hover { + background: var(--paper); +} + +/* Tag filter badge in search row */ +.tag-filter-badge { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--accent); + color: #fff; + border: 0; + border-radius: 3px; + font-size: 12px; + padding: 2px 8px; + cursor: pointer; + margin-left: 8px; +} + +.tag-filter-badge:hover { + opacity: 0.85; +} From 9c4f05b013750cb70dfab56f8a391a4d22671e22 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:13:53 +0200 Subject: [PATCH 3/3] fix(server): remove unused post import from routes.rs --- crates/archivr-server/src/routes.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 74d1ceb..39e633a 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -6,7 +6,7 @@ use axum::{ extract::{Path, Query, Request, State}, http::StatusCode, response::{IntoResponse, Response}, - routing::{delete, get, post}, + routing::{delete, get}, }; use tower_http::services::{ServeDir, ServeFile}; use tower::ServiceExt;