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] 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; +}