mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
feat(tags): revamp tags tab (#29)
feat(tags): revamp tags tab — tooltips, entry counts, Create/Move flows, Esc handling (#29)
This commit is contained in:
parent
a4de506495
commit
289037235c
11 changed files with 2942 additions and 847 deletions
|
|
@ -85,6 +85,8 @@ pub struct Tag {
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct TagNode {
|
pub struct TagNode {
|
||||||
pub tag: Tag,
|
pub tag: Tag,
|
||||||
|
pub entry_count: i64,
|
||||||
|
pub subtree_count: i64,
|
||||||
pub children: Vec<TagNode>,
|
pub children: Vec<TagNode>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,7 +198,10 @@ pub fn initialize_store_directories(store_path: &Path) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Result<Vec<EntrySummary>> {
|
pub fn list_root_entries(
|
||||||
|
conn: &rusqlite::Connection,
|
||||||
|
caller_bits: u32,
|
||||||
|
) -> Result<Vec<EntrySummary>> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
e.entry_uid,
|
e.entry_uid,
|
||||||
|
|
@ -336,16 +341,18 @@ pub fn get_capture_job(
|
||||||
conn: &rusqlite::Connection,
|
conn: &rusqlite::Connection,
|
||||||
job_uid: &str,
|
job_uid: &str,
|
||||||
) -> Result<Option<CaptureJobSummary>> {
|
) -> Result<Option<CaptureJobSummary>> {
|
||||||
Ok(database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary {
|
Ok(
|
||||||
job_uid: r.job_uid,
|
database::get_capture_job(conn, job_uid)?.map(|r| CaptureJobSummary {
|
||||||
archive_id: r.archive_id,
|
job_uid: r.job_uid,
|
||||||
run_uid: r.run_uid,
|
archive_id: r.archive_id,
|
||||||
status: r.status,
|
run_uid: r.run_uid,
|
||||||
error_text: r.error_text,
|
status: r.status,
|
||||||
notes_json: r.notes_json,
|
error_text: r.error_text,
|
||||||
created_at: r.created_at,
|
notes_json: r.notes_json,
|
||||||
updated_at: r.updated_at,
|
created_at: r.created_at,
|
||||||
}))
|
updated_at: r.updated_at,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lists all collections in the archive.
|
/// Lists all collections in the archive.
|
||||||
|
|
@ -413,8 +420,7 @@ pub fn list_entries_for_collection(
|
||||||
OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \
|
OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \
|
||||||
GROUP BY e.id \
|
GROUP BY e.id \
|
||||||
ORDER BY e.archived_at DESC, e.id DESC",
|
ORDER BY e.archived_at DESC, e.id DESC",
|
||||||
ENTRY_SELECT_COLS,
|
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
|
||||||
ENTRY_FROM_JOINS,
|
|
||||||
);
|
);
|
||||||
let mut stmt = conn.prepare(&sql)?;
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
let entries = stmt
|
let entries = stmt
|
||||||
|
|
@ -448,9 +454,12 @@ pub fn resolve_artifact_path(
|
||||||
artifact: &EntryArtifactSummary,
|
artifact: &EntryArtifactSummary,
|
||||||
) -> Result<PathBuf> {
|
) -> Result<PathBuf> {
|
||||||
let joined = store_path.join(&artifact.relpath);
|
let joined = store_path.join(&artifact.relpath);
|
||||||
let canonical_store = store_path
|
let canonical_store = store_path.canonicalize().with_context(|| {
|
||||||
.canonicalize()
|
format!(
|
||||||
.with_context(|| format!("failed to canonicalize store path: {}", store_path.display()))?;
|
"failed to canonicalize store path: {}",
|
||||||
|
store_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
let canonical_artifact = joined
|
let canonical_artifact = joined
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.with_context(|| format!("artifact path does not exist: {}", joined.display()))?;
|
.with_context(|| format!("artifact path does not exist: {}", joined.display()))?;
|
||||||
|
|
@ -522,13 +531,13 @@ pub fn parse_search_query(raw: &str) -> Result<SearchEntriesQuery, String> {
|
||||||
|
|
||||||
match prefix {
|
match prefix {
|
||||||
"source" => query.source_kind = Some(value),
|
"source" => query.source_kind = Some(value),
|
||||||
"type" => query.entity_kind = Some(value),
|
"type" => query.entity_kind = Some(value),
|
||||||
"url" => query.url = Some(value),
|
"url" => query.url = Some(value),
|
||||||
"title" => query.title = Some(value),
|
"title" => query.title = Some(value),
|
||||||
"after" => query.after = Some(value),
|
"after" => query.after = Some(value),
|
||||||
"before" => query.before = Some(value),
|
"before" => query.before = Some(value),
|
||||||
"tag" => query.tag = Some(value),
|
"tag" => query.tag = Some(value),
|
||||||
other => return Err(other.to_string()),
|
other => return Err(other.to_string()),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
free_text_tokens.push(token);
|
free_text_tokens.push(token);
|
||||||
|
|
@ -541,16 +550,14 @@ pub fn parse_search_query(raw: &str) -> Result<SearchEntriesQuery, String> {
|
||||||
Ok(query)
|
Ok(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENTRY_SELECT_COLS: &str =
|
const ENTRY_SELECT_COLS: &str = "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \
|
||||||
"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, \
|
e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \
|
||||||
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \
|
COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes, \
|
||||||
parent.entry_uid AS parent_entry_uid, \
|
parent.entry_uid AS parent_entry_uid, \
|
||||||
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon, \
|
EXISTS(SELECT 1 FROM entry_artifacts fav WHERE fav.entry_id = e.id AND fav.artifact_role = 'favicon') AS has_favicon, \
|
||||||
e.cached_bytes";
|
e.cached_bytes";
|
||||||
|
|
||||||
const ENTRY_FROM_JOINS: &str =
|
const ENTRY_FROM_JOINS: &str = "FROM archived_entries e \
|
||||||
"FROM archived_entries e \
|
|
||||||
JOIN source_identities si ON si.id = e.source_identity_id \
|
JOIN source_identities si ON si.id = e.source_identity_id \
|
||||||
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \
|
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \
|
||||||
LEFT JOIN blobs b ON b.id = ea.blob_id \
|
LEFT JOIN blobs b ON b.id = ea.blob_id \
|
||||||
|
|
@ -579,14 +586,12 @@ pub fn search_entries(
|
||||||
JOIN entry_tag_assignments eta ON eta.entry_id = e.id \
|
JOIN entry_tag_assignments eta ON eta.entry_id = e.id \
|
||||||
JOIN descendants d ON eta.tag_id = d.id \
|
JOIN descendants d ON eta.tag_id = d.id \
|
||||||
WHERE 1=1",
|
WHERE 1=1",
|
||||||
ENTRY_SELECT_COLS,
|
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
|
||||||
ENTRY_FROM_JOINS,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
sql = format!(
|
sql = format!(
|
||||||
"{} {} WHERE e.parent_entry_id IS NULL",
|
"{} {} WHERE e.parent_entry_id IS NULL",
|
||||||
ENTRY_SELECT_COLS,
|
ENTRY_SELECT_COLS, ENTRY_FROM_JOINS,
|
||||||
ENTRY_FROM_JOINS,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -687,19 +692,65 @@ pub fn create_tag(conn: &rusqlite::Connection, full_path: &str) -> Result<Tag> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the full tag tree with root nodes at the top level and children nested.
|
/// Returns the full tag tree with root nodes at the top level and children nested.
|
||||||
|
/// Each node includes a direct entry count and a subtree count (unique entries
|
||||||
|
/// assigned to the tag itself or any descendant).
|
||||||
pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result<Vec<TagNode>> {
|
pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result<Vec<TagNode>> {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
let records = database::list_all_tags(conn)?;
|
let records = database::list_all_tags(conn)?;
|
||||||
|
|
||||||
|
// Fetch direct entry counts for all tags in a single query.
|
||||||
|
let mut counts: HashMap<String, i64> = HashMap::new();
|
||||||
|
{
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT t.tag_uid, COUNT(eta.entry_id) \
|
||||||
|
FROM tags t \
|
||||||
|
LEFT JOIN entry_tag_assignments eta ON eta.tag_id = t.id \
|
||||||
|
GROUP BY t.id",
|
||||||
|
)?;
|
||||||
|
for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? {
|
||||||
|
let (uid, cnt) = row?;
|
||||||
|
counts.insert(uid, cnt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch subtree entry counts: for each tag, count distinct entries assigned
|
||||||
|
// to it or any descendant. COUNT(DISTINCT) ensures an entry tagged at both
|
||||||
|
// a parent and a child is counted only once.
|
||||||
|
let mut subtree_counts: HashMap<String, i64> = HashMap::new();
|
||||||
|
{
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"WITH RECURSIVE descendants(ancestor_id, descendant_id) AS ( \
|
||||||
|
SELECT id, id FROM tags \
|
||||||
|
UNION ALL \
|
||||||
|
SELECT d.ancestor_id, t.id \
|
||||||
|
FROM tags t JOIN descendants d ON t.parent_tag_id = d.descendant_id \
|
||||||
|
) \
|
||||||
|
SELECT t.tag_uid, COUNT(DISTINCT eta.entry_id) \
|
||||||
|
FROM tags t \
|
||||||
|
JOIN descendants d ON d.ancestor_id = t.id \
|
||||||
|
LEFT JOIN entry_tag_assignments eta ON eta.tag_id = d.descendant_id \
|
||||||
|
GROUP BY t.id",
|
||||||
|
)?;
|
||||||
|
for row in stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))? {
|
||||||
|
let (uid, cnt) = row?;
|
||||||
|
subtree_counts.insert(uid, cnt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut by_parent: HashMap<Option<i64>, Vec<database::TagRecord>> = HashMap::new();
|
let mut by_parent: HashMap<Option<i64>, Vec<database::TagRecord>> = HashMap::new();
|
||||||
for record in records {
|
for record in records {
|
||||||
by_parent.entry(record.parent_tag_id).or_default().push(record);
|
by_parent
|
||||||
|
.entry(record.parent_tag_id)
|
||||||
|
.or_default()
|
||||||
|
.push(record);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_nodes(
|
fn build_nodes(
|
||||||
parent_id: Option<i64>,
|
parent_id: Option<i64>,
|
||||||
by_parent: &HashMap<Option<i64>, Vec<database::TagRecord>>,
|
by_parent: &HashMap<Option<i64>, Vec<database::TagRecord>>,
|
||||||
|
counts: &HashMap<String, i64>,
|
||||||
|
subtree_counts: &HashMap<String, i64>,
|
||||||
) -> Vec<TagNode> {
|
) -> Vec<TagNode> {
|
||||||
let Some(children) = by_parent.get(&parent_id) else {
|
let Some(children) = by_parent.get(&parent_id) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
|
|
@ -713,22 +764,21 @@ pub fn list_tag_tree(conn: &rusqlite::Connection) -> Result<Vec<TagNode>> {
|
||||||
slug: r.slug.clone(),
|
slug: r.slug.clone(),
|
||||||
full_path: r.full_path.clone(),
|
full_path: r.full_path.clone(),
|
||||||
},
|
},
|
||||||
children: build_nodes(Some(r.id), by_parent),
|
entry_count: counts.get(&r.tag_uid).copied().unwrap_or(0),
|
||||||
|
subtree_count: subtree_counts.get(&r.tag_uid).copied().unwrap_or(0),
|
||||||
|
children: build_nodes(Some(r.id), by_parent, counts, subtree_counts),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(build_nodes(None, &by_parent))
|
Ok(build_nodes(None, &by_parent, &counts, &subtree_counts))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the tags assigned to an entry.
|
/// Returns the tags assigned to an entry.
|
||||||
///
|
///
|
||||||
/// Returns `Ok(None)` if the entry_uid does not exist (caller maps to 404).
|
/// 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.
|
/// Returns `Ok(Some([]))` if the entry exists but has no tags.
|
||||||
pub fn get_entry_tags(
|
pub fn get_entry_tags(conn: &rusqlite::Connection, entry_uid: &str) -> Result<Option<Vec<Tag>>> {
|
||||||
conn: &rusqlite::Connection,
|
|
||||||
entry_uid: &str,
|
|
||||||
) -> Result<Option<Vec<Tag>>> {
|
|
||||||
let Some(entry_id) = conn
|
let Some(entry_id) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
||||||
|
|
@ -1111,10 +1161,14 @@ mod tests {
|
||||||
|
|
||||||
// Entry 1: tweet by source x
|
// Entry 1: tweet by source x
|
||||||
let si1 = database::upsert_source_identity(
|
let si1 = database::upsert_source_identity(
|
||||||
&conn, "x", "tweet", Some("t-1"),
|
&conn,
|
||||||
|
"x",
|
||||||
|
"tweet",
|
||||||
|
Some("t-1"),
|
||||||
Some("https://x.com/user/status/1"),
|
Some("https://x.com/user/status/1"),
|
||||||
"https://x.com/user/status/1",
|
"https://x.com/user/status/1",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
database::create_archived_entry(
|
database::create_archived_entry(
|
||||||
&conn,
|
&conn,
|
||||||
&database::NewEntry {
|
&database::NewEntry {
|
||||||
|
|
@ -1132,14 +1186,19 @@ mod tests {
|
||||||
source_metadata_json: "{}".to_string(),
|
source_metadata_json: "{}".to_string(),
|
||||||
display_metadata_json: None,
|
display_metadata_json: None,
|
||||||
},
|
},
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Entry 2: web page
|
// Entry 2: web page
|
||||||
let si2 = database::upsert_source_identity(
|
let si2 = database::upsert_source_identity(
|
||||||
&conn, "web", "page", Some("page-1"),
|
&conn,
|
||||||
|
"web",
|
||||||
|
"page",
|
||||||
|
Some("page-1"),
|
||||||
Some("https://medium.com/article"),
|
Some("https://medium.com/article"),
|
||||||
"https://medium.com/article",
|
"https://medium.com/article",
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
database::create_archived_entry(
|
database::create_archived_entry(
|
||||||
&conn,
|
&conn,
|
||||||
&database::NewEntry {
|
&database::NewEntry {
|
||||||
|
|
@ -1157,7 +1216,8 @@ mod tests {
|
||||||
source_metadata_json: "{}".to_string(),
|
source_metadata_json: "{}".to_string(),
|
||||||
display_metadata_json: None,
|
display_metadata_json: None,
|
||||||
},
|
},
|
||||||
).unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
conn
|
conn
|
||||||
}
|
}
|
||||||
|
|
@ -1173,10 +1233,14 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn search_q_filters_on_title() {
|
fn search_q_filters_on_title() {
|
||||||
let conn = make_test_db_with_entries();
|
let conn = make_test_db_with_entries();
|
||||||
let results = search_entries(&conn, &SearchEntriesQuery {
|
let results = search_entries(
|
||||||
q: Some("polymarket".to_string()),
|
&conn,
|
||||||
..Default::default()
|
&SearchEntriesQuery {
|
||||||
}).unwrap();
|
q: Some("polymarket".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].entity_kind, "tweet");
|
assert_eq!(results[0].entity_kind, "tweet");
|
||||||
}
|
}
|
||||||
|
|
@ -1184,10 +1248,14 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn search_entity_kind_exact_match() {
|
fn search_entity_kind_exact_match() {
|
||||||
let conn = make_test_db_with_entries();
|
let conn = make_test_db_with_entries();
|
||||||
let results = search_entries(&conn, &SearchEntriesQuery {
|
let results = search_entries(
|
||||||
entity_kind: Some("page".to_string()),
|
&conn,
|
||||||
..Default::default()
|
&SearchEntriesQuery {
|
||||||
}).unwrap();
|
entity_kind: Some("page".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].source_kind, "web");
|
assert_eq!(results[0].source_kind, "web");
|
||||||
}
|
}
|
||||||
|
|
@ -1195,10 +1263,14 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn search_url_like_filter() {
|
fn search_url_like_filter() {
|
||||||
let conn = make_test_db_with_entries();
|
let conn = make_test_db_with_entries();
|
||||||
let results = search_entries(&conn, &SearchEntriesQuery {
|
let results = search_entries(
|
||||||
url: Some("medium.com".to_string()),
|
&conn,
|
||||||
..Default::default()
|
&SearchEntriesQuery {
|
||||||
}).unwrap();
|
url: Some("medium.com".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].title.as_deref(), Some("Resume Templates"));
|
assert_eq!(results[0].title.as_deref(), Some("Resume Templates"));
|
||||||
}
|
}
|
||||||
|
|
@ -1206,21 +1278,29 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn search_no_match_returns_empty() {
|
fn search_no_match_returns_empty() {
|
||||||
let conn = make_test_db_with_entries();
|
let conn = make_test_db_with_entries();
|
||||||
let results = search_entries(&conn, &SearchEntriesQuery {
|
let results = search_entries(
|
||||||
q: Some("zzznonexistent".to_string()),
|
&conn,
|
||||||
..Default::default()
|
&SearchEntriesQuery {
|
||||||
}).unwrap();
|
q: Some("zzznonexistent".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(results.is_empty());
|
assert!(results.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_multiple_filters_compound() {
|
fn search_multiple_filters_compound() {
|
||||||
let conn = make_test_db_with_entries();
|
let conn = make_test_db_with_entries();
|
||||||
let results = search_entries(&conn, &SearchEntriesQuery {
|
let results = search_entries(
|
||||||
source_kind: Some("x".to_string()),
|
&conn,
|
||||||
entity_kind: Some("tweet".to_string()),
|
&SearchEntriesQuery {
|
||||||
..Default::default()
|
source_kind: Some("x".to_string()),
|
||||||
}).unwrap();
|
entity_kind: Some("tweet".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1243,9 +1323,8 @@ mod tests {
|
||||||
title: &str,
|
title: &str,
|
||||||
url: &str,
|
url: &str,
|
||||||
) -> database::ArchivedEntry {
|
) -> database::ArchivedEntry {
|
||||||
let si = database::upsert_source_identity(
|
let si =
|
||||||
conn, "web", "page", None, Some(url), url,
|
database::upsert_source_identity(conn, "web", "page", None, Some(url), url).unwrap();
|
||||||
).unwrap();
|
|
||||||
database::create_archived_entry(
|
database::create_archived_entry(
|
||||||
conn,
|
conn,
|
||||||
&database::NewEntry {
|
&database::NewEntry {
|
||||||
|
|
@ -1263,7 +1342,8 @@ mod tests {
|
||||||
source_metadata_json: "{}".to_string(),
|
source_metadata_json: "{}".to_string(),
|
||||||
display_metadata_json: None,
|
display_metadata_json: None,
|
||||||
},
|
},
|
||||||
).unwrap()
|
)
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1275,32 +1355,143 @@ mod tests {
|
||||||
let tree = list_tag_tree(&conn).unwrap();
|
let tree = list_tag_tree(&conn).unwrap();
|
||||||
assert_eq!(tree.len(), 2, "expected two root nodes");
|
assert_eq!(tree.len(), 2, "expected two root nodes");
|
||||||
|
|
||||||
let science = tree.iter().find(|n| n.tag.slug == "science").expect("science root missing");
|
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.len(), 1, "science should have one child");
|
||||||
assert_eq!(science.children[0].tag.slug, "cs");
|
assert_eq!(science.children[0].tag.slug, "cs");
|
||||||
|
|
||||||
let art = tree.iter().find(|n| n.tag.slug == "art").expect("art root missing");
|
let art = tree
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.tag.slug == "art")
|
||||||
|
.expect("art root missing");
|
||||||
assert!(art.children.is_empty(), "art should have no children");
|
assert!(art.children.is_empty(), "art should have no children");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_tree_entry_counts_direct_and_subtree() {
|
||||||
|
let (conn, user_id, run_id) = make_tag_test_db();
|
||||||
|
// Tree: /science -> /science/cs -> /science/cs/algorithms
|
||||||
|
create_tag(&conn, "/science/cs/algorithms").unwrap();
|
||||||
|
|
||||||
|
let e1 = make_entry_in_db(
|
||||||
|
&conn,
|
||||||
|
user_id,
|
||||||
|
run_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"E1",
|
||||||
|
"https://example.com/e1",
|
||||||
|
);
|
||||||
|
let e2 = make_entry_in_db(
|
||||||
|
&conn,
|
||||||
|
user_id,
|
||||||
|
run_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"E2",
|
||||||
|
"https://example.com/e2",
|
||||||
|
);
|
||||||
|
|
||||||
|
// e1 → /science/cs/algorithms (leaf), e2 → /science (root)
|
||||||
|
assign_entry_tag(&conn, &e1.entry_uid, "/science/cs/algorithms").unwrap();
|
||||||
|
assign_entry_tag(&conn, &e2.entry_uid, "/science").unwrap();
|
||||||
|
|
||||||
|
let tree = list_tag_tree(&conn).unwrap();
|
||||||
|
let science = tree.iter().find(|n| n.tag.slug == "science").unwrap();
|
||||||
|
let cs = science
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.tag.slug == "cs")
|
||||||
|
.unwrap();
|
||||||
|
let algo = cs
|
||||||
|
.children
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.tag.slug == "algorithms")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(science.entry_count, 1, "science direct = 1");
|
||||||
|
assert_eq!(
|
||||||
|
science.subtree_count, 2,
|
||||||
|
"science subtree = 2 (e1 via algo, e2 direct)"
|
||||||
|
);
|
||||||
|
assert_eq!(cs.entry_count, 0, "cs direct = 0");
|
||||||
|
assert_eq!(cs.subtree_count, 1, "cs subtree = 1 (e1 via algo)");
|
||||||
|
assert_eq!(algo.entry_count, 1, "algo direct = 1");
|
||||||
|
assert_eq!(algo.subtree_count, 1, "algo subtree = 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_tree_subtree_count_deduplicates_shared_entry() {
|
||||||
|
// Regression: an entry assigned to both a parent and a child must count
|
||||||
|
// as 1 in the parent's subtree_count, not 2.
|
||||||
|
let (conn, user_id, run_id) = make_tag_test_db();
|
||||||
|
create_tag(&conn, "/science/cs").unwrap();
|
||||||
|
|
||||||
|
let e = make_entry_in_db(
|
||||||
|
&conn,
|
||||||
|
user_id,
|
||||||
|
run_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"E",
|
||||||
|
"https://example.com/ded",
|
||||||
|
);
|
||||||
|
assign_entry_tag(&conn, &e.entry_uid, "/science").unwrap();
|
||||||
|
assign_entry_tag(&conn, &e.entry_uid, "/science/cs").unwrap();
|
||||||
|
|
||||||
|
let tree = list_tag_tree(&conn).unwrap();
|
||||||
|
let science = tree.iter().find(|n| n.tag.slug == "science").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
science.subtree_count, 1,
|
||||||
|
"entry assigned to both parent and child must not be double-counted"
|
||||||
|
);
|
||||||
|
assert_eq!(science.entry_count, 1, "science direct = 1");
|
||||||
|
assert_eq!(science.children[0].subtree_count, 1, "cs subtree = 1");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn assign_entry_tag_is_idempotent() {
|
fn assign_entry_tag_is_idempotent() {
|
||||||
let (conn, user_id, run_id) = make_tag_test_db();
|
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");
|
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();
|
||||||
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();
|
let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap();
|
||||||
assert_eq!(tags.len(), 1, "idempotent assign should yield exactly one tag");
|
assert_eq!(
|
||||||
|
tags.len(),
|
||||||
|
1,
|
||||||
|
"idempotent assign should yield exactly one tag"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remove_entry_tag_clears_assignment() {
|
fn remove_entry_tag_clears_assignment() {
|
||||||
let (conn, user_id, run_id) = make_tag_test_db();
|
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 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();
|
let tag = assign_entry_tag(&conn, &entry.entry_uid, "/science")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
remove_entry_tag(&conn, &entry.entry_uid, &tag.tag_uid).unwrap();
|
remove_entry_tag(&conn, &entry.entry_uid, &tag.tag_uid).unwrap();
|
||||||
|
|
||||||
let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap();
|
let tags = get_entry_tags(&conn, &entry.entry_uid).unwrap().unwrap();
|
||||||
|
|
@ -1310,7 +1501,15 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn entries_for_tag_includes_descendants() {
|
fn entries_for_tag_includes_descendants() {
|
||||||
let (conn, user_id, run_id) = make_tag_test_db();
|
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");
|
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();
|
assign_entry_tag(&conn, &entry.entry_uid, "/science/cs/compilers").unwrap();
|
||||||
|
|
||||||
|
|
@ -1322,11 +1521,23 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn entries_for_tag_includes_child_entries() {
|
fn entries_for_tag_includes_child_entries() {
|
||||||
let (conn, user_id, run_id) = make_tag_test_db();
|
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 parent = make_entry_in_db(
|
||||||
|
&conn,
|
||||||
|
user_id,
|
||||||
|
run_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"Playlist",
|
||||||
|
"https://example.com/pl",
|
||||||
|
);
|
||||||
let child = make_entry_in_db(
|
let child = make_entry_in_db(
|
||||||
&conn, user_id, run_id,
|
&conn,
|
||||||
Some(parent.id), Some(parent.id),
|
user_id,
|
||||||
"Video 1", "https://example.com/pl/v1",
|
run_id,
|
||||||
|
Some(parent.id),
|
||||||
|
Some(parent.id),
|
||||||
|
"Video 1",
|
||||||
|
"https://example.com/pl/v1",
|
||||||
);
|
);
|
||||||
|
|
||||||
assign_entry_tag(&conn, &child.entry_uid, "/science").unwrap();
|
assign_entry_tag(&conn, &child.entry_uid, "/science").unwrap();
|
||||||
|
|
@ -1334,42 +1545,77 @@ mod tests {
|
||||||
let results = entries_for_tag(&conn, "/science").unwrap();
|
let results = entries_for_tag(&conn, "/science").unwrap();
|
||||||
assert_eq!(results.len(), 1, "only the tagged child should appear");
|
assert_eq!(results.len(), 1, "only the tagged child should appear");
|
||||||
assert_eq!(results[0].entry_uid, child.entry_uid);
|
assert_eq!(results[0].entry_uid, child.entry_uid);
|
||||||
assert_eq!(results[0].parent_entry_uid.as_deref(), Some(parent.entry_uid.as_str()));
|
assert_eq!(
|
||||||
|
results[0].parent_entry_uid.as_deref(),
|
||||||
|
Some(parent.entry_uid.as_str())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_with_tag_filter_works() {
|
fn search_with_tag_filter_works() {
|
||||||
let (conn, user_id, run_id) = make_tag_test_db();
|
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");
|
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();
|
assign_entry_tag(&conn, &entry.entry_uid, "/science").unwrap();
|
||||||
|
|
||||||
let results = search_entries(&conn, &SearchEntriesQuery {
|
let results = search_entries(
|
||||||
tag: Some("/science".to_string()),
|
&conn,
|
||||||
..Default::default()
|
&SearchEntriesQuery {
|
||||||
}).unwrap();
|
tag: Some("/science".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(results.len(), 1);
|
||||||
assert_eq!(results[0].entry_uid, entry.entry_uid);
|
assert_eq!(results[0].entry_uid, entry.entry_uid);
|
||||||
|
|
||||||
let empty = search_entries(&conn, &SearchEntriesQuery {
|
let empty = search_entries(
|
||||||
tag: Some("/art".to_string()),
|
&conn,
|
||||||
..Default::default()
|
&SearchEntriesQuery {
|
||||||
}).unwrap();
|
tag: Some("/art".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert!(empty.is_empty(), "no entries under /art");
|
assert!(empty.is_empty(), "no entries under /art");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_without_tag_returns_roots_only() {
|
fn search_without_tag_returns_roots_only() {
|
||||||
let (conn, user_id, run_id) = make_tag_test_db();
|
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 parent = make_entry_in_db(
|
||||||
|
&conn,
|
||||||
|
user_id,
|
||||||
|
run_id,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"Parent",
|
||||||
|
"https://example.com/par",
|
||||||
|
);
|
||||||
let _child = make_entry_in_db(
|
let _child = make_entry_in_db(
|
||||||
&conn, user_id, run_id,
|
&conn,
|
||||||
Some(parent.id), Some(parent.id),
|
user_id,
|
||||||
"Child", "https://example.com/par/c",
|
run_id,
|
||||||
|
Some(parent.id),
|
||||||
|
Some(parent.id),
|
||||||
|
"Child",
|
||||||
|
"https://example.com/par/c",
|
||||||
);
|
);
|
||||||
|
|
||||||
let results = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
|
let results = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
|
||||||
assert_eq!(results.len(), 1, "plain search should return root entries only");
|
assert_eq!(
|
||||||
|
results.len(),
|
||||||
|
1,
|
||||||
|
"plain search should return root entries only"
|
||||||
|
);
|
||||||
assert_eq!(results[0].entry_uid, parent.entry_uid);
|
assert_eq!(results[0].entry_uid, parent.entry_uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
47
crates/archivr-server/static/assets/index-BpGwC-YY.js
Normal file
47
crates/archivr-server/static/assets/index-BpGwC-YY.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Archivr</title>
|
<title>Archivr</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Db35_tmT.js"></script>
|
<script type="module" crossorigin src="/assets/index-BpGwC-YY.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C0COQCCD.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DLdY9nrw.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,26 @@ export async function deleteTag(archiveId, tagUid) {
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createTag(archiveId, path) {
|
||||||
|
const res = await fetch(`/api/archives/${archiveId}/tags`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveTag(archiveId, tagUid, parentUid) {
|
||||||
|
const res = await fetch(`/api/archives/${archiveId}/tags/${tagUid}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ parent_uid: parentUid ?? null }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchRuns(archiveId) {
|
export async function fetchRuns(archiveId) {
|
||||||
return getJson(`/api/archives/${archiveId}/runs`);
|
return getJson(`/api/archives/${archiveId}/runs`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,156 @@
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { renameTag, deleteTag } from '../api';
|
import { renameTag, deleteTag, createTag, moveTag } from '../api';
|
||||||
|
|
||||||
function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
// ── TagPickerNode ─────────────────────────────────────────────────────────
|
||||||
|
// A node inside the destination picker modal.
|
||||||
|
function TagPickerNode({ node, onPick }) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="tag-picker-node-btn"
|
||||||
|
title={node.tag.full_path}
|
||||||
|
onClick={() => onPick(node.tag)}
|
||||||
|
>
|
||||||
|
{node.tag.slug}
|
||||||
|
</button>
|
||||||
|
{node.children?.length > 0 && (
|
||||||
|
<div className="tag-children">
|
||||||
|
<ul className="tag-tree-list">
|
||||||
|
{node.children.map(child => (
|
||||||
|
<TagPickerNode key={child.tag.tag_uid} node={child} onPick={onPick} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TagPickerModal ────────────────────────────────────────────────────────
|
||||||
|
// Shared modal for "create under" and "move under" destination selection.
|
||||||
|
// `excludeUid` hides that tag and all its descendants (used for move).
|
||||||
|
// `onPick(tag | null)` — null means "make root / no parent".
|
||||||
|
function TagPickerModal({ title, tagNodes, excludeUid, onPick, onCancel }) {
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel(); } }
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [onCancel]);
|
||||||
|
function filterTree(nodes) {
|
||||||
|
return nodes
|
||||||
|
.filter(n => n.tag.tag_uid !== excludeUid)
|
||||||
|
.map(n => ({ ...n, children: filterTree(n.children) }));
|
||||||
|
}
|
||||||
|
const visibleNodes = excludeUid ? filterTree(tagNodes) : tagNodes;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="tag-picker-backdrop"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
>
|
||||||
|
<div className="tag-picker-modal" role="dialog" aria-modal="true">
|
||||||
|
<div className="tag-picker-header">
|
||||||
|
<span className="tag-picker-title">{title}</span>
|
||||||
|
<button
|
||||||
|
className="tag-picker-close"
|
||||||
|
onClick={onCancel}
|
||||||
|
title="Cancel"
|
||||||
|
aria-label="Cancel"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="tag-picker-body">
|
||||||
|
<button
|
||||||
|
className="tag-picker-root-btn"
|
||||||
|
onClick={() => onPick(null)}
|
||||||
|
title="Place at root level (no parent)"
|
||||||
|
>
|
||||||
|
↑ Root tag (no parent)
|
||||||
|
</button>
|
||||||
|
{visibleNodes.length > 0 ? (
|
||||||
|
<ul className="tag-tree-list tag-picker-tree">
|
||||||
|
{visibleNodes.map(node => (
|
||||||
|
<TagPickerNode key={node.tag.tag_uid} node={node} onPick={onPick} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="tag-picker-empty">No other tags available.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CreateInput ───────────────────────────────────────────────────────────
|
||||||
|
// Inline text input that appears in the tag tree when creating a new tag.
|
||||||
|
// Matches the rename-input UX: Enter saves, Escape cancels, blur saves.
|
||||||
|
function CreateInput({ parentPath, archiveId, onDone, onCancel }) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const cancelRef = useRef(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const name = draft.trim();
|
||||||
|
if (!name) { onCancel(); return; }
|
||||||
|
const path = parentPath ? `${parentPath}/${name}` : `/${name}`;
|
||||||
|
try {
|
||||||
|
await createTag(archiveId, path);
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Create failed');
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="tag-rename-input"
|
||||||
|
autoFocus
|
||||||
|
placeholder="tag name"
|
||||||
|
value={draft}
|
||||||
|
onChange={e => setDraft(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') e.currentTarget.blur();
|
||||||
|
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (cancelRef.current) { cancelRef.current = false; onCancel(); }
|
||||||
|
else { submit(); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TagNode ───────────────────────────────────────────────────────────────
|
||||||
|
function TagNode({
|
||||||
|
node,
|
||||||
|
archiveId,
|
||||||
|
tagFilter,
|
||||||
|
onTagFilterSet,
|
||||||
|
onViewChange,
|
||||||
|
onTagRenamed,
|
||||||
|
onTagDeleted,
|
||||||
|
onTagsRefresh,
|
||||||
|
humanizeTags,
|
||||||
|
// Move source-selection mode: clicking a tag selects it as the thing to move.
|
||||||
|
moveSelectMode,
|
||||||
|
onMoveSourceSelect,
|
||||||
|
// Pending create: the tag_uid of the parent that should render an inline input,
|
||||||
|
// or '__root__' for the root list (handled in TagsView, not here).
|
||||||
|
pendingCreateParentUid,
|
||||||
|
onCreateDone,
|
||||||
|
onCreateCancel,
|
||||||
|
}) {
|
||||||
const isActive = tagFilter === node.tag.full_path;
|
const isActive = tagFilter === node.tag.full_path;
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const cancelRef = useRef(false);
|
const cancelRef = useRef(false);
|
||||||
|
|
||||||
function handleFilterClick() {
|
function handleClick() {
|
||||||
if (editing) return;
|
if (editing) return;
|
||||||
|
if (moveSelectMode) {
|
||||||
|
onMoveSourceSelect(node);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const next = isActive ? null : node.tag.full_path;
|
const next = isActive ? null : node.tag.full_path;
|
||||||
onTagFilterSet(next);
|
onTagFilterSet(next);
|
||||||
onViewChange('archive');
|
onViewChange('archive');
|
||||||
|
|
@ -16,16 +158,14 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
|
||||||
|
|
||||||
function startEdit(e) {
|
function startEdit(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (moveSelectMode) return;
|
||||||
setDraft(node.tag.slug);
|
setDraft(node.tag.slug);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRenameSave() {
|
async function handleRenameSave() {
|
||||||
const value = draft.trim();
|
const value = draft.trim();
|
||||||
if (!value || value === node.tag.slug) {
|
if (!value || value === node.tag.slug) { setEditing(false); return; }
|
||||||
setEditing(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const updated = await renameTag(archiveId, node.tag.tag_uid, value);
|
const updated = await renameTag(archiveId, node.tag.tag_uid, value);
|
||||||
onTagRenamed(node.tag.full_path, updated.full_path);
|
onTagRenamed(node.tag.full_path, updated.full_path);
|
||||||
|
|
@ -52,11 +192,18 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const childProps = { archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags };
|
const childProps = {
|
||||||
|
archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted,
|
||||||
|
onTagsRefresh, humanizeTags, moveSelectMode, onMoveSourceSelect,
|
||||||
|
pendingCreateParentUid, onCreateDone, onCreateCancel,
|
||||||
|
};
|
||||||
|
|
||||||
|
const showCreateInput = pendingCreateParentUid === node.tag.tag_uid;
|
||||||
|
const hasChildren = node.children?.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<div className="tag-node-row">
|
<div className={`tag-node-row${moveSelectMode ? ' tag-node-row--move-select' : ''}`}>
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
className="tag-rename-input"
|
className="tag-rename-input"
|
||||||
|
|
@ -68,50 +215,66 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
|
||||||
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
|
if (e.key === 'Escape') { cancelRef.current = true; e.currentTarget.blur(); }
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
if (cancelRef.current) {
|
if (cancelRef.current) { cancelRef.current = false; setEditing(false); }
|
||||||
cancelRef.current = false;
|
else { handleRenameSave(); }
|
||||||
setEditing(false);
|
|
||||||
} else {
|
|
||||||
handleRenameSave();
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
|
className={`tag-node-btn${isActive ? ' is-active' : ''}${moveSelectMode ? ' tag-node-btn--move-select' : ''}`}
|
||||||
title={node.tag.full_path}
|
title={moveSelectMode ? `Select "${node.tag.full_path}" to move` : node.tag.full_path}
|
||||||
onClick={handleFilterClick}
|
onClick={handleClick}
|
||||||
onDoubleClick={startEdit}
|
onDoubleClick={moveSelectMode ? undefined : startEdit}
|
||||||
>
|
>
|
||||||
{humanizeTags ? node.tag.name : node.tag.slug}
|
<span className="tag-node-label">{humanizeTags ? node.tag.name : node.tag.slug}</span>
|
||||||
<svg
|
<span className="tag-node-count">
|
||||||
className="edit-icon"
|
{node.children.length === 0
|
||||||
viewBox="0 0 16 16"
|
? `(${node.entry_count})`
|
||||||
fill="none"
|
: `(${node.entry_count}) (${node.subtree_count} Total)`}
|
||||||
stroke="currentColor"
|
</span>
|
||||||
strokeWidth="1.5"
|
{!moveSelectMode && (
|
||||||
strokeLinecap="round"
|
<svg
|
||||||
strokeLinejoin="round"
|
className="edit-icon"
|
||||||
aria-hidden="true"
|
viewBox="0 0 16 16"
|
||||||
onClick={e => { e.stopPropagation(); startEdit(e); }}
|
fill="none"
|
||||||
>
|
stroke="currentColor"
|
||||||
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
|
strokeWidth="1.5"
|
||||||
</svg>
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
title="Rename tag"
|
||||||
|
onClick={e => { e.stopPropagation(); startEdit(e); }}
|
||||||
|
>
|
||||||
|
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{!editing && !moveSelectMode && (
|
||||||
className="remove tag-node-delete"
|
<button
|
||||||
title={`Delete tag ${node.tag.full_path}`}
|
className="remove tag-node-delete"
|
||||||
onClick={handleDelete}
|
title={`Delete "${node.tag.full_path}"`}
|
||||||
aria-label={`Delete tag ${node.tag.full_path}`}
|
onClick={handleDelete}
|
||||||
>×</button>
|
aria-label={`Delete "${node.tag.full_path}"`}
|
||||||
|
>×</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{node.children?.length > 0 && (
|
{(hasChildren || showCreateInput) && (
|
||||||
<div className="tag-children">
|
<div className="tag-children">
|
||||||
<ul className="tag-tree-list">
|
<ul className="tag-tree-list">
|
||||||
{node.children.map(child => (
|
{node.children.map(child => (
|
||||||
<TagNode key={child.tag.tag_uid} node={child} {...childProps} />
|
<TagNode key={child.tag.tag_uid} node={child} {...childProps} />
|
||||||
))}
|
))}
|
||||||
|
{showCreateInput && (
|
||||||
|
<li>
|
||||||
|
<CreateInput
|
||||||
|
parentPath={node.tag.full_path}
|
||||||
|
archiveId={archiveId}
|
||||||
|
onDone={onCreateDone}
|
||||||
|
onCancel={onCreateCancel}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -119,35 +282,162 @@ function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagsView({ archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
// ── TagsView ──────────────────────────────────────────────────────────────
|
||||||
|
export default function TagsView({
|
||||||
|
archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange,
|
||||||
|
onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags,
|
||||||
|
}) {
|
||||||
|
// ── Move state ────────────────────────────────────────────────────────
|
||||||
|
// moveStep: null | 'select-source' | 'select-dest'
|
||||||
|
const [moveStep, setMoveStep] = useState(null);
|
||||||
|
const [moveSourceNode, setMoveSourceNode] = useState(null);
|
||||||
|
|
||||||
|
function startMoveMode() { setMoveStep('select-source'); setMoveSourceNode(null); }
|
||||||
|
function cancelMove() { setMoveStep(null); setMoveSourceNode(null); }
|
||||||
|
// Esc while in source-selection mode cancels the move.
|
||||||
|
useEffect(() => {
|
||||||
|
if (moveStep !== 'select-source') return;
|
||||||
|
function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); cancelMove(); } }
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [moveStep]);
|
||||||
|
|
||||||
|
function handleMoveSourceSelect(node) {
|
||||||
|
setMoveSourceNode(node);
|
||||||
|
setMoveStep('select-dest');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMoveDest(destTag) {
|
||||||
|
// destTag: Tag object | null (null = make root)
|
||||||
|
const sourceOldPath = moveSourceNode.tag.full_path;
|
||||||
|
const sourceUid = moveSourceNode.tag.tag_uid;
|
||||||
|
const destUid = destTag?.tag_uid ?? null;
|
||||||
|
cancelMove();
|
||||||
|
try {
|
||||||
|
const updated = await moveTag(archiveId, sourceUid, destUid);
|
||||||
|
// Keep the active tag filter consistent when the moved tag/subtree was active.
|
||||||
|
onTagRenamed(sourceOldPath, updated.full_path);
|
||||||
|
onTagsRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Move failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create state ──────────────────────────────────────────────────────
|
||||||
|
// createStep: null | 'select-parent' | 'input'
|
||||||
|
const [createStep, setCreateStep] = useState(null);
|
||||||
|
const [createParentTag, setCreateParentTag] = useState(undefined);
|
||||||
|
|
||||||
|
function startCreateMode() { setCreateStep('select-parent'); setCreateParentTag(undefined); }
|
||||||
|
function cancelCreate() { setCreateStep(null); setCreateParentTag(undefined); }
|
||||||
|
|
||||||
|
function handleCreateParentPick(tag) {
|
||||||
|
// tag: Tag | null (null = root)
|
||||||
|
setCreateParentTag(tag ?? null);
|
||||||
|
setCreateStep('input');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateDone() {
|
||||||
|
setCreateStep(null);
|
||||||
|
setCreateParentTag(undefined);
|
||||||
|
onTagsRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Derived ───────────────────────────────────────────────────────────
|
||||||
|
const moveSelectMode = moveStep === 'select-source';
|
||||||
|
|
||||||
|
// The tag_uid of the parent that should render a create input inside its
|
||||||
|
// children list. '__root__' means the root-level list handles it.
|
||||||
|
const pendingCreateParentUid = createStep === 'input'
|
||||||
|
? (createParentTag ? createParentTag.tag_uid : '__root__')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const nodeProps = {
|
||||||
|
archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted,
|
||||||
|
onTagsRefresh, humanizeTags, moveSelectMode, onMoveSourceSelect: handleMoveSourceSelect,
|
||||||
|
pendingCreateParentUid, onCreateDone: handleCreateDone, onCreateCancel: cancelCreate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const showRootCreateInput = pendingCreateParentUid === '__root__';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="tags-view" className="view is-active">
|
<section id="tags-view" className="view is-active">
|
||||||
<div className="tag-tree">
|
<div className="tag-tree">
|
||||||
<div className="tag-tree-header">
|
<div className="tag-tree-header">
|
||||||
<span className="tag-tree-title">Tags</span>
|
{moveSelectMode ? (
|
||||||
{tagFilter && (
|
<>
|
||||||
<span className="tag-tree-active">Filtering: {tagFilter}</span>
|
<span className="tag-tree-title tag-tree-title--move">Select a tag to move</span>
|
||||||
|
<button
|
||||||
|
className="tag-tree-action-btn tag-tree-action-btn--cancel"
|
||||||
|
onClick={cancelMove}
|
||||||
|
>Cancel</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="tag-tree-title">Tags</span>
|
||||||
|
{tagFilter && (
|
||||||
|
<span className="tag-tree-active" title={tagFilter}>Filtering: {tagFilter}</span>
|
||||||
|
)}
|
||||||
|
<div className="tag-tree-actions">
|
||||||
|
<button
|
||||||
|
className="tag-tree-action-btn"
|
||||||
|
onClick={startCreateMode}
|
||||||
|
title="Create a new tag"
|
||||||
|
disabled={!!createStep || !!moveStep}
|
||||||
|
>+ New</button>
|
||||||
|
<button
|
||||||
|
className="tag-tree-action-btn"
|
||||||
|
onClick={startMoveMode}
|
||||||
|
title="Move a tag to a different parent"
|
||||||
|
disabled={!!createStep || !!moveStep || tagNodes.length === 0}
|
||||||
|
>Move</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tagNodes.length === 0 ? (
|
|
||||||
|
{tagNodes.length === 0 && !showRootCreateInput ? (
|
||||||
<p className="muted" style={{ padding: '8px 0' }}>No tags yet.</p>
|
<p className="muted" style={{ padding: '8px 0' }}>No tags yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="tag-tree-list">
|
<ul className="tag-tree-list">
|
||||||
{tagNodes.map(node => (
|
{tagNodes.map(node => (
|
||||||
<TagNode key={node.tag.tag_uid} node={node}
|
<TagNode key={node.tag.tag_uid} node={node} {...nodeProps} />
|
||||||
archiveId={archiveId}
|
|
||||||
tagFilter={tagFilter}
|
|
||||||
onTagFilterSet={onTagFilterSet}
|
|
||||||
onViewChange={onViewChange}
|
|
||||||
onTagRenamed={onTagRenamed}
|
|
||||||
onTagDeleted={onTagDeleted}
|
|
||||||
onTagsRefresh={onTagsRefresh}
|
|
||||||
humanizeTags={humanizeTags}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
{showRootCreateInput && (
|
||||||
|
<li>
|
||||||
|
<CreateInput
|
||||||
|
parentPath={null}
|
||||||
|
archiveId={archiveId}
|
||||||
|
onDone={handleCreateDone}
|
||||||
|
onCancel={cancelCreate}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Create: pick parent modal */}
|
||||||
|
{createStep === 'select-parent' && (
|
||||||
|
<TagPickerModal
|
||||||
|
title="Create tag under…"
|
||||||
|
tagNodes={tagNodes}
|
||||||
|
excludeUid={null}
|
||||||
|
onPick={handleCreateParentPick}
|
||||||
|
onCancel={cancelCreate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Move: pick destination modal (shown after source is selected) */}
|
||||||
|
{moveStep === 'select-dest' && moveSourceNode && (
|
||||||
|
<TagPickerModal
|
||||||
|
title={`Move "${moveSourceNode.tag.slug}" under…`}
|
||||||
|
tagNodes={tagNodes}
|
||||||
|
excludeUid={moveSourceNode.tag.tag_uid}
|
||||||
|
onPick={handleMoveDest}
|
||||||
|
onCancel={cancelMove}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
167
frontend/src/components/TagsView.stories.jsx
Normal file
167
frontend/src/components/TagsView.stories.jsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import TagsView from './TagsView';
|
||||||
|
|
||||||
|
// Installs a per-story fetch stub that intercepts /api/ tag mutations and
|
||||||
|
// returns realistic Tag shapes so renameTag/moveTag/createTag don't throw.
|
||||||
|
// Installed in useEffect so it never leaks into other stories and won't
|
||||||
|
// double-wrap on re-renders.
|
||||||
|
function ApiStub({ children }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const realFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = (url, opts) => {
|
||||||
|
if (typeof url === 'string' && url.startsWith('/api/')) {
|
||||||
|
const method = (opts?.method ?? 'GET').toUpperCase();
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
return Promise.resolve(new Response(null, { status: 204 }));
|
||||||
|
}
|
||||||
|
// Parse request body to construct a realistic Tag shape.
|
||||||
|
// renameTag sends { name }, moveTag/createTag send path info.
|
||||||
|
// full_path must be present or callers throw on updated.full_path.
|
||||||
|
let body = {};
|
||||||
|
try { body = JSON.parse(opts?.body ?? '{}'); } catch { /* ignore */ }
|
||||||
|
const slug = (body.name ?? body.path ?? 'stub')
|
||||||
|
.trim().replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '').replace(/^-+|-+$/g, '') || 'stub';
|
||||||
|
const stubTag = {
|
||||||
|
tag_uid: 'stub-uid',
|
||||||
|
name: slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
|
||||||
|
slug,
|
||||||
|
full_path: `/${slug}`,
|
||||||
|
};
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(stubTag), {
|
||||||
|
status: method === 'POST' ? 201 : 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return realFetch(url, opts);
|
||||||
|
};
|
||||||
|
return () => { window.fetch = realFetch; };
|
||||||
|
}, []);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withApiStub(Story) {
|
||||||
|
return <ApiStub><Story /></ApiStub>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
component: TagsView,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'padded' },
|
||||||
|
decorators: [withApiStub],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Shared fixtures ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
function tag(tag_uid, name, slug, full_path, entry_count = 0, children = [], subtree_count = null) {
|
||||||
|
return { tag: { tag_uid, name, slug, full_path }, entry_count, subtree_count: subtree_count ?? entry_count, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleTree = [
|
||||||
|
tag('t1', 'Science', 'science', '/science', 12, [
|
||||||
|
tag('t2', 'Computer Science', 'computer-science', '/science/computer-science', 7, [
|
||||||
|
tag('t3', 'Algorithms', 'algorithms', '/science/computer-science/algorithms', 3),
|
||||||
|
tag('t4', 'Compilers', 'compilers', '/science/computer-science/compilers', 1),
|
||||||
|
], 11),
|
||||||
|
tag('t5', 'Physics', 'physics', '/science/physics', 4),
|
||||||
|
], 23),
|
||||||
|
tag('t6', 'History', 'history', '/history', 5, [
|
||||||
|
tag('t7', 'Ancient', 'ancient', '/history/ancient', 2),
|
||||||
|
], 7),
|
||||||
|
tag('t8', 'Reading List', 'reading-list', '/reading-list', 0),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wrapper that wires local state so onTagsRefresh/onTagRenamed callbacks
|
||||||
|
// keep the tree consistent within a story session.
|
||||||
|
function TagsViewSandbox({ initialNodes = sampleTree, tagFilter = null, humanizeTags = false }) {
|
||||||
|
const [nodes, setNodes] = useState(initialNodes);
|
||||||
|
const [filter, setFilter] = useState(tagFilter);
|
||||||
|
|
||||||
|
function handleTagRenamed(oldPath, newPath) {
|
||||||
|
if (filter === oldPath) setFilter(newPath);
|
||||||
|
else if (filter?.startsWith(oldPath + '/')) setFilter(newPath + filter.slice(oldPath.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTagDeleted(deletedPath) {
|
||||||
|
if (filter === deletedPath || filter?.startsWith(deletedPath + '/')) setFilter(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// onTagsRefresh is a no-op here: in a real app it re-fetches; the stub
|
||||||
|
// tag returned from fetch won't match our fixture tree, so the tree stays
|
||||||
|
// as-is after mutations. That is acceptable for visual QA purposes.
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 340, fontFamily: 'Helvetica Neue, sans-serif' }}>
|
||||||
|
<TagsView
|
||||||
|
archiveId="demo"
|
||||||
|
tagNodes={nodes}
|
||||||
|
tagFilter={filter}
|
||||||
|
onTagFilterSet={setFilter}
|
||||||
|
onViewChange={noop}
|
||||||
|
onTagRenamed={handleTagRenamed}
|
||||||
|
onTagDeleted={handleTagDeleted}
|
||||||
|
onTagsRefresh={noop}
|
||||||
|
humanizeTags={humanizeTags}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stories ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Default view with a nested tag tree. All interactions are exercisable:
|
||||||
|
* click + New / Move to open the picker modal; click a tag to filter;
|
||||||
|
* double-click or pencil to rename; × to delete.
|
||||||
|
* API mutations are stubbed — the tree won't re-fetch after saves,
|
||||||
|
* but no network errors will occur. */
|
||||||
|
export const Default = {
|
||||||
|
render: () => <TagsViewSandbox />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Empty archive — "No tags yet." shown; + New still works. */
|
||||||
|
export const Empty = {
|
||||||
|
render: () => <TagsViewSandbox initialNodes={[]} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Humanize mode: slugs displayed as Title Case names. */
|
||||||
|
export const HumanizedNames = {
|
||||||
|
render: () => <TagsViewSandbox humanizeTags />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Active tag filter — header shows the current filter path. */
|
||||||
|
export const WithActiveFilter = {
|
||||||
|
render: () => (
|
||||||
|
<TagsViewSandbox tagFilter="/science/computer-science" />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Flat list with no nesting. */
|
||||||
|
export const FlatList = {
|
||||||
|
render: () => (
|
||||||
|
<TagsViewSandbox
|
||||||
|
initialNodes={[
|
||||||
|
tag('a1', 'Books', 'books', '/books', 8),
|
||||||
|
tag('a2', 'Films', 'films', '/films', 3),
|
||||||
|
tag('a3', 'Music', 'music', '/music', 0),
|
||||||
|
tag('a4', 'Podcasts', 'podcasts', '/podcasts', 14),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tags with large entry counts — verifies count badge layout. */
|
||||||
|
export const HighCounts = {
|
||||||
|
render: () => (
|
||||||
|
<TagsViewSandbox
|
||||||
|
initialNodes={[
|
||||||
|
tag('h1', 'All', 'all', '/all', 9999, [
|
||||||
|
tag('h2', 'Starred', 'starred', '/all/starred', 432),
|
||||||
|
tag('h3', 'Archive', 'archive', '/all/archive', 1204),
|
||||||
|
]),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
@ -1279,6 +1279,135 @@ select {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* entry count badge next to tag name */
|
||||||
|
.tag-node-label { flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.tag-node-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-weight: 400;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* header action buttons (+ New, Move) */
|
||||||
|
.tag-tree-header { align-items: center; }
|
||||||
|
.tag-tree-actions { margin-left: auto; display: flex; gap: 4px; }
|
||||||
|
.tag-tree-action-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.tag-tree-action-btn:hover:not(:disabled) { background: var(--paper-2); color: var(--ink); }
|
||||||
|
.tag-tree-action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
.tag-tree-action-btn--cancel { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.tag-tree-action-btn--cancel:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); }
|
||||||
|
.tag-tree-title--move { font-style: italic; color: var(--accent-2); }
|
||||||
|
|
||||||
|
/* move source-select mode: nodes act as clickable targets */
|
||||||
|
.tag-node-row--move-select .tag-node-btn { cursor: crosshair; color: var(--ink); }
|
||||||
|
.tag-node-row--move-select .tag-node-btn:hover {
|
||||||
|
background: color-mix(in srgb, var(--link) 10%, transparent);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: var(--r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tag picker modal (create/move destination) ──────────────────────────── */
|
||||||
|
.tag-picker-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.tag-picker-modal {
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: var(--r3);
|
||||||
|
box-shadow: 0 16px 56px rgba(0, 0, 0, 0.24);
|
||||||
|
width: 300px;
|
||||||
|
max-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 60vh;
|
||||||
|
}
|
||||||
|
.tag-picker-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.tag-picker-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tag-picker-close {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-2);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tag-picker-close:hover { background: var(--paper-2); color: var(--ink); }
|
||||||
|
.tag-picker-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
}
|
||||||
|
.tag-picker-root-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 5px 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tag-picker-root-btn:hover { background: var(--paper-2); color: var(--ink); border-color: var(--muted-2); }
|
||||||
|
.tag-picker-tree { border-top: 1px solid var(--line-soft); padding-top: 6px; margin-top: 2px; }
|
||||||
|
.tag-picker-node-btn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--link);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 6px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--r);
|
||||||
|
}
|
||||||
|
.tag-picker-node-btn:hover { background: var(--paper-2); text-decoration: none; }
|
||||||
|
.tag-picker-empty { font-size: 13px; color: var(--muted-2); margin: 6px 0 0; }
|
||||||
|
|
||||||
/* ── Collections page (sidebar layout) ──────────────────────────────────── */
|
/* ── Collections page (sidebar layout) ──────────────────────────────────── */
|
||||||
.collections-view {
|
.collections-view {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue