mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
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
This commit is contained in:
parent
db953de67a
commit
5803f2119b
6 changed files with 727 additions and 15 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -124,6 +124,7 @@ dependencies = [
|
|||
"archivr-core",
|
||||
"axum",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ tower-http.workspace = true
|
|||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tower.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub tag: Option<String>,
|
||||
}
|
||||
|
||||
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<AppState>,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<Vec<archive::TagNode>>, 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<AppState>,
|
||||
Path(archive_id): Path<String>,
|
||||
Json(body): Json<CreateTagBody>,
|
||||
) -> Result<(StatusCode, Json<archive::Tag>), 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<AppState>,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<Vec<archive::Tag>>, 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<AppState>,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
Json(body): Json<AssignTagBody>,
|
||||
) -> Result<(StatusCode, Json<archive::Tag>), 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<AppState>,
|
||||
Path((archive_id, entry_uid, tag_uid)): Path<(String, String, String)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<button class="nav-link is-active" data-view="archive">Archive</button>
|
||||
<button class="nav-link" data-view="runs">Runs</button>
|
||||
<button class="nav-link" data-view="admin">Admin</button>
|
||||
<button class="nav-link" data-view="tags">Tags</button>
|
||||
</nav>
|
||||
<button class="capture-button">+ Capture</button>
|
||||
</header>
|
||||
|
|
@ -66,11 +67,20 @@
|
|||
<h1>Mounted Archives</h1>
|
||||
<div id="admin-archives" class="admin-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="tags-view" class="view">
|
||||
<div id="tag-tree" class="tag-tree"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="context-rail">
|
||||
<div class="rail-title">Context</div>
|
||||
<div id="context-body" class="rail-body">Select an entry.</div>
|
||||
<div id="entry-tags" class="entry-tags" hidden></div>
|
||||
<div id="assign-tag-form" class="assign-tag-form" hidden>
|
||||
<input id="assign-tag-input" class="assign-tag-input" type="text" placeholder="/science/cs" autocomplete="off">
|
||||
<button id="assign-tag-btn" class="assign-tag-btn">Add tag</button>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue