mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
Compare commits
3 commits
1ff91956a1
...
55f85134df
| Author | SHA1 | Date | |
|---|---|---|---|
| 55f85134df | |||
| d6b52ba06c | |||
| 2502de45b6 |
15 changed files with 1030 additions and 85 deletions
|
|
@ -468,6 +468,11 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
|
||||||
)?;
|
)?;
|
||||||
// Add display_name column to users if not present (idempotent migration)
|
// Add display_name column to users if not present (idempotent migration)
|
||||||
let _ = conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT", []);
|
let _ = conn.execute("ALTER TABLE users ADD COLUMN display_name TEXT", []);
|
||||||
|
// Add humanize_slugs column to users if not present (idempotent migration)
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE users ADD COLUMN humanize_slugs INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -752,6 +757,24 @@ pub fn update_user_display_name(conn: &Connection, user_id: i64, display_name: O
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_user_humanize_slugs(conn: &Connection, user_id: i64, value: bool) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET humanize_slugs = ?1 WHERE id = ?2",
|
||||||
|
params![value as i64, user_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the user-visible title of an archived entry.
|
||||||
|
/// Returns `Ok(true)` if a row was updated, `Ok(false)` if the entry_uid was not found.
|
||||||
|
pub fn update_entry_title(conn: &Connection, entry_uid: &str, title: Option<&str>) -> Result<bool> {
|
||||||
|
let n = conn.execute(
|
||||||
|
"UPDATE archived_entries SET title = ?1 WHERE entry_uid = ?2",
|
||||||
|
params![title, entry_uid],
|
||||||
|
)?;
|
||||||
|
Ok(n > 0)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_user_display_name(conn: &Connection, user_id: i64) -> Result<Option<String>> {
|
pub fn get_user_display_name(conn: &Connection, user_id: i64) -> Result<Option<String>> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT display_name FROM users WHERE id = ?1",
|
"SELECT display_name FROM users WHERE id = ?1",
|
||||||
|
|
@ -1629,6 +1652,113 @@ pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result<i6
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn rename_tag(
|
||||||
|
conn: &Connection,
|
||||||
|
tag_uid: &str,
|
||||||
|
new_segment: &str,
|
||||||
|
) -> Result<Option<TagRecord>> {
|
||||||
|
// Slugify: spaces→hyphens, keep alphanumeric and hyphens (case preserved), collapse runs, strip edges.
|
||||||
|
let trimmed = new_segment.trim();
|
||||||
|
let hyphenated: String = trimmed.chars().map(|c| if c == ' ' { '-' } else { c }).collect();
|
||||||
|
let filtered: String = hyphenated
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '-')
|
||||||
|
.collect();
|
||||||
|
let mut new_slug = String::new();
|
||||||
|
let mut prev_hyphen = false;
|
||||||
|
for c in filtered.chars() {
|
||||||
|
if c == '-' {
|
||||||
|
if !prev_hyphen {
|
||||||
|
new_slug.push(c);
|
||||||
|
}
|
||||||
|
prev_hyphen = true;
|
||||||
|
} else {
|
||||||
|
new_slug.push(c);
|
||||||
|
prev_hyphen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let new_slug = new_slug.trim_matches('-').to_string();
|
||||||
|
if new_slug.is_empty() {
|
||||||
|
bail!("new segment slugifies to empty string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch existing tag.
|
||||||
|
let tag = match get_tag_by_uid(conn, tag_uid)? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build new full_path by replacing the last segment.
|
||||||
|
let old_prefix = tag.full_path.clone();
|
||||||
|
let parent_prefix = match old_prefix.rfind('/') {
|
||||||
|
Some(idx) => &old_prefix[..idx],
|
||||||
|
None => "",
|
||||||
|
};
|
||||||
|
let new_full_path = format!("{}/{}", parent_prefix, new_slug);
|
||||||
|
|
||||||
|
// Collision check: bail if another tag already owns this path.
|
||||||
|
if let Some(existing) = get_tag_by_path(conn, &new_full_path)? {
|
||||||
|
if existing.tag_uid != tag_uid {
|
||||||
|
bail!("tag path already exists: {new_full_path}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_name = humanize_slug(&new_slug);
|
||||||
|
|
||||||
|
// Transaction: update the tag row, then cascade path change to descendants.
|
||||||
|
let result = (|| -> Result<()> {
|
||||||
|
conn.execute_batch("BEGIN")?;
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tags SET name=?1, slug=?2, full_path=?3 WHERE tag_uid=?4",
|
||||||
|
params![new_name, new_slug, new_full_path, tag_uid],
|
||||||
|
)?;
|
||||||
|
let old_prefix_slash = format!("{}/", old_prefix);
|
||||||
|
let new_prefix_slash = format!("{}/", new_full_path);
|
||||||
|
// Use hierarchy (recursive CTE over parent_tag_id) instead of LIKE to avoid
|
||||||
|
// treating '_'/'%' in historical slugs as wildcards.
|
||||||
|
conn.execute(
|
||||||
|
"WITH RECURSIVE descendants(id) AS (\
|
||||||
|
SELECT id FROM tags WHERE parent_tag_id = ?1 \
|
||||||
|
UNION ALL \
|
||||||
|
SELECT t.id FROM tags t JOIN descendants d ON t.parent_tag_id = d.id \
|
||||||
|
) \
|
||||||
|
UPDATE tags SET full_path = REPLACE(full_path, ?2, ?3) \
|
||||||
|
WHERE id IN (SELECT id FROM descendants)",
|
||||||
|
params![tag.id, old_prefix_slash, new_prefix_slash],
|
||||||
|
)?;
|
||||||
|
conn.execute_batch("COMMIT")?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = conn.execute_batch("ROLLBACK");
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch the updated record.
|
||||||
|
get_tag_by_uid(conn, tag_uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes a tag and its entire descendant subtree.
|
||||||
|
///
|
||||||
|
/// `entry_tag_assignments` rows are removed automatically via `ON DELETE CASCADE`.
|
||||||
|
/// `parent_tag_id` has no cascade so a recursive CTE is used to collect the subtree
|
||||||
|
/// before issuing a single DELETE.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(true)` if anything was deleted, `Ok(false)` if `tag_uid` was not found.
|
||||||
|
pub fn delete_tag(conn: &Connection, tag_uid: &str) -> Result<bool> {
|
||||||
|
let deleted = conn.execute(
|
||||||
|
"WITH RECURSIVE subtree(id) AS (
|
||||||
|
SELECT id FROM tags WHERE tag_uid = ?1
|
||||||
|
UNION ALL
|
||||||
|
SELECT t.id FROM tags t JOIN subtree s ON t.parent_tag_id = s.id
|
||||||
|
)
|
||||||
|
DELETE FROM tags WHERE id IN (SELECT id FROM subtree)",
|
||||||
|
[tag_uid],
|
||||||
|
)?;
|
||||||
|
Ok(deleted > 0)
|
||||||
|
}
|
||||||
|
|
||||||
fn refresh_run_counters(conn: &Connection, run_id: i64) -> Result<()> {
|
fn refresh_run_counters(conn: &Connection, run_id: i64) -> Result<()> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE archive_runs
|
"UPDATE archive_runs
|
||||||
|
|
@ -2431,4 +2561,148 @@ mod tests {
|
||||||
assert_eq!(r2.bit_position, 5);
|
assert_eq!(r2.bit_position, 5);
|
||||||
assert_eq!(r2.level, 2);
|
assert_eq!(r2.level, 2);
|
||||||
}
|
}
|
||||||
|
// ── rename_tag / delete_tag ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_tag_unknown_uid_returns_none() {
|
||||||
|
let conn = conn();
|
||||||
|
let result = rename_tag(&conn, "tag_doesnotexist", "anything").unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_tag_updates_own_path_and_cascades_to_children() {
|
||||||
|
let conn = conn();
|
||||||
|
// Create /science → /science/cs → /science/cs/algorithms
|
||||||
|
let _ = create_tag_path(&conn, "science/cs/algorithms").unwrap();
|
||||||
|
|
||||||
|
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
|
||||||
|
let cs = get_tag_by_path(&conn, "/science/cs").unwrap().unwrap();
|
||||||
|
let algo = get_tag_by_path(&conn, "/science/cs/algorithms").unwrap().unwrap();
|
||||||
|
|
||||||
|
// Rename "science" → "natural-science"
|
||||||
|
let updated = rename_tag(&conn, &science.tag_uid, "natural-science")
|
||||||
|
.unwrap()
|
||||||
|
.expect("should return updated tag");
|
||||||
|
|
||||||
|
assert_eq!(updated.slug, "natural-science");
|
||||||
|
assert_eq!(updated.name, "Natural Science");
|
||||||
|
assert_eq!(updated.full_path, "/natural-science");
|
||||||
|
|
||||||
|
// /science must no longer exist
|
||||||
|
assert!(get_tag_by_path(&conn, "/science").unwrap().is_none());
|
||||||
|
|
||||||
|
// /science/cs must have moved
|
||||||
|
assert!(get_tag_by_path(&conn, "/science/cs").unwrap().is_none());
|
||||||
|
let cs_new = get_tag_by_uid(&conn, &cs.tag_uid).unwrap().unwrap();
|
||||||
|
assert_eq!(cs_new.full_path, "/natural-science/cs");
|
||||||
|
|
||||||
|
// /science/cs/algorithms must have moved
|
||||||
|
assert!(get_tag_by_path(&conn, "/science/cs/algorithms").unwrap().is_none());
|
||||||
|
let algo_new = get_tag_by_uid(&conn, &algo.tag_uid).unwrap().unwrap();
|
||||||
|
assert_eq!(algo_new.full_path, "/natural-science/cs/algorithms");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_tag_sibling_collision_returns_err() {
|
||||||
|
let conn = conn();
|
||||||
|
// Create /science and /natural-science as siblings
|
||||||
|
let _ = create_tag_path(&conn, "science").unwrap();
|
||||||
|
let _ = create_tag_path(&conn, "natural-science").unwrap();
|
||||||
|
|
||||||
|
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
|
||||||
|
|
||||||
|
// Renaming /science → natural-science should collide
|
||||||
|
let result = rename_tag(&conn, &science.tag_uid, "natural-science");
|
||||||
|
assert!(result.is_err(), "expected collision error, got {:?}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_tag_to_same_name_is_noop() {
|
||||||
|
let conn = conn();
|
||||||
|
let _ = create_tag_path(&conn, "science").unwrap();
|
||||||
|
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
|
||||||
|
|
||||||
|
// "Science" humanizes to the same slug; rename should succeed (no collision since same uid)
|
||||||
|
let updated = rename_tag(&conn, &science.tag_uid, "science")
|
||||||
|
.unwrap()
|
||||||
|
.expect("should return tag");
|
||||||
|
assert_eq!(updated.full_path, "/science");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_tag_unknown_uid_returns_false() {
|
||||||
|
let conn = conn();
|
||||||
|
assert!(!delete_tag(&conn, "tag_doesnotexist").unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_tag_removes_subtree_and_cascades_assignments() {
|
||||||
|
let conn = conn();
|
||||||
|
// Build /science/cs and /science/math
|
||||||
|
let cs_id = create_tag_path(&conn, "science/cs").unwrap();
|
||||||
|
let math_id = create_tag_path(&conn, "science/math").unwrap();
|
||||||
|
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
|
||||||
|
|
||||||
|
// Create an entry and assign it to /science/cs
|
||||||
|
let entry = create_entry_fixture(&conn, "private", None, None);
|
||||||
|
assign_entry_to_tag(&conn, entry.id, cs_id).unwrap();
|
||||||
|
|
||||||
|
// Verify assignment exists
|
||||||
|
let assigned_before: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM entry_tag_assignments WHERE entry_id = ?1",
|
||||||
|
[entry.id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(assigned_before, 1);
|
||||||
|
|
||||||
|
// Delete the /science subtree
|
||||||
|
assert!(delete_tag(&conn, &science.tag_uid).unwrap());
|
||||||
|
|
||||||
|
// All three tag rows must be gone
|
||||||
|
let tag_count: i64 = conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM tags", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tag_count, 0, "all tags in subtree should be deleted");
|
||||||
|
|
||||||
|
// Assignment must have been cascade-deleted
|
||||||
|
let assigned_after: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM entry_tag_assignments WHERE entry_id = ?1",
|
||||||
|
[entry.id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(assigned_after, 0, "assignment should be removed by cascade");
|
||||||
|
|
||||||
|
// Verify by uid too (subtree ids: science, cs, math)
|
||||||
|
assert!(get_tag_by_uid(&conn, &science.tag_uid).unwrap().is_none());
|
||||||
|
let cs_tag = conn
|
||||||
|
.query_row("SELECT tag_uid FROM tags WHERE id = ?1", [cs_id], |r| r.get::<_, String>(0))
|
||||||
|
.optional()
|
||||||
|
.unwrap();
|
||||||
|
assert!(cs_tag.is_none(), "/science/cs should be deleted");
|
||||||
|
let math_tag = conn
|
||||||
|
.query_row("SELECT tag_uid FROM tags WHERE id = ?1", [math_id], |r| r.get::<_, String>(0))
|
||||||
|
.optional()
|
||||||
|
.unwrap();
|
||||||
|
assert!(math_tag.is_none(), "/science/math should be deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rename_tag_slug_with_special_chars_is_stripped() {
|
||||||
|
let conn = conn();
|
||||||
|
let _ = create_tag_path(&conn, "science").unwrap();
|
||||||
|
let science = get_tag_by_path(&conn, "/science").unwrap().unwrap();
|
||||||
|
|
||||||
|
// Input with spaces and underscores — underscores stripped, spaces become hyphens, case preserved
|
||||||
|
let updated = rename_tag(&conn, &science.tag_uid, "Natural Science")
|
||||||
|
.unwrap()
|
||||||
|
.expect("should rename");
|
||||||
|
assert_eq!(updated.slug, "Natural-Science");
|
||||||
|
assert_eq!(updated.full_path, "/Natural-Science");
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
// POST /api/archives/:id/captures
|
// POST /api/archives/:id/captures
|
||||||
// POST /api/archives/:id/tags
|
// POST /api/archives/:id/tags
|
||||||
// POST/DELETE /api/archives/:id/entries/:uid/tags
|
// POST/DELETE /api/archives/:id/entries/:uid/tags
|
||||||
|
// PATCH /api/archives/:id/entries/:uid
|
||||||
// ADMIN — requires ROLE_ADMIN: (future)
|
// ADMIN — requires ROLE_ADMIN: (future)
|
||||||
// OWNER — requires ROLE_OWNER: (future)
|
// OWNER — requires ROLE_OWNER: (future)
|
||||||
// AUTH_SELF — own resources, require_auth() only:
|
// AUTH_SELF — own resources, require_auth() only:
|
||||||
|
|
@ -214,7 +215,7 @@ pub fn app_with_state(state: AppState) -> Router {
|
||||||
.route("/api/archives/:archive_id/entries/search", get(search_entries_handler))
|
.route("/api/archives/:archive_id/entries/search", get(search_entries_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid",
|
"/api/archives/:archive_id/entries/:entry_uid",
|
||||||
get(entry_detail),
|
get(entry_detail).patch(patch_entry_handler),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
"/api/archives/:archive_id/entries/:entry_uid/artifacts/:artifact_index",
|
||||||
|
|
@ -235,6 +236,10 @@ pub fn app_with_state(state: AppState) -> Router {
|
||||||
get(get_capture_job_handler),
|
get(get_capture_job_handler),
|
||||||
)
|
)
|
||||||
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
|
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
|
||||||
|
.route(
|
||||||
|
"/api/archives/:archive_id/tags/:tag_uid",
|
||||||
|
patch(patch_tag_handler).delete(delete_tag_handler),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid/tags",
|
"/api/archives/:archive_id/entries/:entry_uid/tags",
|
||||||
get(list_entry_tags).post(assign_entry_tag_handler),
|
get(list_entry_tags).post(assign_entry_tag_handler),
|
||||||
|
|
@ -555,6 +560,61 @@ async fn remove_entry_tag_handler(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn patch_tag_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path((archive_id, tag_uid)): Path<(String, String)>,
|
||||||
|
Json(body): Json<PatchTagBody>,
|
||||||
|
) -> Result<Json<archive::Tag>, ApiError> {
|
||||||
|
auth_user.require_role(ROLE_USER)?;
|
||||||
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
match database::rename_tag(&conn, &tag_uid, &body.name)? {
|
||||||
|
Some(record) => Ok(Json(archive::Tag {
|
||||||
|
tag_uid: record.tag_uid,
|
||||||
|
name: record.name,
|
||||||
|
slug: record.slug,
|
||||||
|
full_path: record.full_path,
|
||||||
|
})),
|
||||||
|
None => Err(ApiError::not_found("tag not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_tag_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path((archive_id, tag_uid)): Path<(String, String)>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
auth_user.require_role(ROLE_USER)?;
|
||||||
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
if database::delete_tag(&conn, &tag_uid)? {
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
} else {
|
||||||
|
Err(ApiError::not_found("tag not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_entry_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||||
|
Json(body): Json<PatchEntryBody>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
auth_user.require_role(ROLE_USER)?;
|
||||||
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
let title = body.title.as_deref().map(|s| {
|
||||||
|
let t = s.trim();
|
||||||
|
if t.is_empty() { None } else { Some(t) }
|
||||||
|
}).flatten();
|
||||||
|
if database::update_entry_title(&conn, &entry_uid, title)? {
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
} else {
|
||||||
|
Err(ApiError::not_found("entry not found"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct CaptureBody {
|
struct CaptureBody {
|
||||||
locator: String,
|
locator: String,
|
||||||
|
|
@ -577,6 +637,16 @@ struct CreateTokenBody {
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct PatchEntryBody {
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct PatchTagBody {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
async fn capture_handler(
|
async fn capture_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -750,17 +820,19 @@ async fn auth_me(
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let (user_id, role_bits) = auth_user.require_auth()?;
|
let (user_id, role_bits) = auth_user.require_auth()?;
|
||||||
let conn = database::open_auth_db(&state.auth_db_path)?;
|
let conn = database::open_auth_db(&state.auth_db_path)?;
|
||||||
let (username, display_name): (String, Option<String>) = conn
|
let (username, display_name, humanize_slugs_int): (String, Option<String>, i64) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT username, display_name FROM users WHERE id = ?1",
|
"SELECT username, display_name, COALESCE(humanize_slugs, 0) FROM users WHERE id = ?1",
|
||||||
[user_id],
|
[user_id],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?))
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))
|
||||||
)
|
)
|
||||||
.map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?;
|
.map_err(|e| ApiError::from(anyhow::anyhow!("db error: {e}")))?;
|
||||||
|
let humanize_slugs = humanize_slugs_int != 0;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"role_bits": role_bits,
|
"role_bits": role_bits,
|
||||||
"username": username,
|
"username": username,
|
||||||
"display_name": display_name,
|
"display_name": display_name,
|
||||||
|
"humanize_slugs": humanize_slugs,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -791,6 +863,10 @@ async fn patch_me(
|
||||||
database::update_user_display_name(&conn, user_id, v)?;
|
database::update_user_display_name(&conn, user_id, v)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(hs) = body.humanize_slugs {
|
||||||
|
database::update_user_humanize_slugs(&conn, user_id, hs)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -879,6 +955,7 @@ struct UpdateProfileBody {
|
||||||
display_name: Option<String>,
|
display_name: Option<String>,
|
||||||
current_password: Option<String>,
|
current_password: Option<String>,
|
||||||
new_password: Option<String>,
|
new_password: Option<String>,
|
||||||
|
humanize_slugs: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
|
@ -2733,4 +2810,152 @@ mod tests {
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_entry_title_requires_auth() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri("/api/archives/test/entries/nonexistent")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(r#"{"title":"New Title"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_entry_title_persists_and_reflects_in_list() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, archive_path, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let entry = make_test_entry(&archive_path);
|
||||||
|
|
||||||
|
// PATCH the title
|
||||||
|
let response = app(registry.clone(), auth_path.clone())
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri(&format!("/api/archives/test/entries/{}", entry.entry_uid))
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(r#"{"title":"Renamed Title"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// Verify via entry detail
|
||||||
|
let get_resp = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(&format!("/api/archives/test/entries/{}", entry.entry_uid))
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(get_resp.status(), StatusCode::OK);
|
||||||
|
let json = body_json(get_resp).await;
|
||||||
|
assert_eq!(json["summary"]["title"], "Renamed Title");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_entry_title_returns_404_for_unknown_uid() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri("/api/archives/test/entries/no-such-uid")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(r#"{"title":"Anything"}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auth_me_returns_humanize_slugs_false_by_default() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/auth/me")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let json = body_json(response).await;
|
||||||
|
assert_eq!(
|
||||||
|
json["humanize_slugs"], false,
|
||||||
|
"humanize_slugs must default to false for new users"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn patch_me_humanize_slugs_persists() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let auth_path = dir.path().join("auth.sqlite");
|
||||||
|
{
|
||||||
|
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
|
||||||
|
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
|
||||||
|
}
|
||||||
|
let state = AppState {
|
||||||
|
registry: Arc::new(ServerRegistry { archives: vec![], bind: None, auth_db_path: None }),
|
||||||
|
auth_db_path: Arc::new(auth_path.clone()),
|
||||||
|
login_attempts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
};
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
|
||||||
|
// PATCH humanize_slugs = true
|
||||||
|
let patch_resp = app_with_state(state.clone())
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("PATCH")
|
||||||
|
.uri("/api/auth/me")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(r#"{"humanize_slugs":true}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(patch_resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// GET /api/auth/me — must now return humanize_slugs: true
|
||||||
|
let get_resp = app_with_state(state)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/auth/me")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(get_resp.status(), StatusCode::OK);
|
||||||
|
let json = body_json(get_resp).await;
|
||||||
|
assert_eq!(
|
||||||
|
json["humanize_slugs"], true,
|
||||||
|
"humanize_slugs must be true after PATCH"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
40
crates/archivr-server/static/assets/index-DuR832Rs.js
Normal file
40
crates/archivr-server/static/assets/index-DuR832Rs.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
|
|
@ -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-DnX_j2fa.js"></script>
|
<script type="module" crossorigin src="/assets/index-DuR832Rs.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DXhicc6w.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-oE_dvyrb.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import TagsView from './components/TagsView'
|
||||||
import CollectionsView from './components/CollectionsView'
|
import CollectionsView from './components/CollectionsView'
|
||||||
import SettingsView from './components/SettingsView'
|
import SettingsView from './components/SettingsView'
|
||||||
import ContextRail from './components/ContextRail'
|
import ContextRail from './components/ContextRail'
|
||||||
|
import { displayPath } from './utils'
|
||||||
|
|
||||||
export const AuthContext = createContext(null);
|
export const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
|
@ -76,7 +77,17 @@ export default function App() {
|
||||||
const [searchBusy, setSearchBusy] = useState(false)
|
const [searchBusy, setSearchBusy] = useState(false)
|
||||||
const [runs, setRuns] = useState([])
|
const [runs, setRuns] = useState([])
|
||||||
const [tagNodes, setTagNodes] = useState([])
|
const [tagNodes, setTagNodes] = useState([])
|
||||||
const [captureDialogOpen, setCaptureDialogOpen] = useState(false)
|
const [captureDialogOpen, setCaptureDialogOpen] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('captureDialogOpen')
|
||||||
|
return saved === 'true'
|
||||||
|
})
|
||||||
|
|
||||||
|
const humanizeTags = currentUser?.humanize_slugs ?? false;
|
||||||
|
|
||||||
|
// Persist captureDialogOpen to sessionStorage
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
|
||||||
|
}, [captureDialogOpen])
|
||||||
|
|
||||||
const loadEntries = useCallback(async (aid, q, tag) => {
|
const loadEntries = useCallback(async (aid, q, tag) => {
|
||||||
if (!aid) return
|
if (!aid) return
|
||||||
|
|
@ -178,6 +189,29 @@ export default function App() {
|
||||||
if (archiveId) fetchTags(archiveId).then(setTagNodes)
|
if (archiveId) fetchTags(archiveId).then(setTagNodes)
|
||||||
}, [archiveId])
|
}, [archiveId])
|
||||||
|
|
||||||
|
const handleTagRenamed = useCallback((oldFullPath, newFullPath) => {
|
||||||
|
if (tagFilter === oldFullPath) {
|
||||||
|
setTagFilter(newFullPath);
|
||||||
|
} else if (tagFilter?.startsWith(oldFullPath + '/')) {
|
||||||
|
setTagFilter(newFullPath + tagFilter.slice(oldFullPath.length));
|
||||||
|
}
|
||||||
|
}, [tagFilter]);
|
||||||
|
|
||||||
|
const handleTagDeleted = useCallback((deletedFullPath) => {
|
||||||
|
if (tagFilter === deletedFullPath || tagFilter?.startsWith(deletedFullPath + '/')) {
|
||||||
|
setTagFilter(null);
|
||||||
|
}
|
||||||
|
}, [tagFilter]);
|
||||||
|
|
||||||
|
const handleEntryTitleChange = useCallback((entryUid, newTitle) => {
|
||||||
|
setEntries(prev => prev.map(e =>
|
||||||
|
e.entry_uid === entryUid ? { ...e, title: newTitle } : e
|
||||||
|
))
|
||||||
|
setSelectedEntry(prev =>
|
||||||
|
prev && prev.entry_uid === entryUid ? { ...prev, title: newTitle } : prev
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleCaptureClick = useCallback(() => {
|
const handleCaptureClick = useCallback(() => {
|
||||||
setCaptureDialogOpen(true)
|
setCaptureDialogOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -234,7 +268,7 @@ export default function App() {
|
||||||
{resultCount && <><b>{resultCount.split(' ')[0]}</b>{' '}{resultCount.split(' ').slice(1).join(' ')}</>}
|
{resultCount && <><b>{resultCount.split(' ')[0]}</b>{' '}{resultCount.split(' ').slice(1).join(' ')}</>}
|
||||||
{tagFilter && (
|
{tagFilter && (
|
||||||
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
|
||||||
× {tagFilter}
|
× {humanizeTags ? displayPath(tagFilter) : tagFilter}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -258,10 +292,15 @@ export default function App() {
|
||||||
{view === 'admin' && <AdminView archives={archives} />}
|
{view === 'admin' && <AdminView archives={archives} />}
|
||||||
{view === 'tags' && (
|
{view === 'tags' && (
|
||||||
<TagsView
|
<TagsView
|
||||||
|
archiveId={archiveId}
|
||||||
tagNodes={tagNodes}
|
tagNodes={tagNodes}
|
||||||
tagFilter={tagFilter}
|
tagFilter={tagFilter}
|
||||||
onTagFilterSet={handleTagFilterSet}
|
onTagFilterSet={handleTagFilterSet}
|
||||||
onViewChange={handleViewChange}
|
onViewChange={handleViewChange}
|
||||||
|
onTagRenamed={handleTagRenamed}
|
||||||
|
onTagDeleted={handleTagDeleted}
|
||||||
|
onTagsRefresh={handleTagsRefresh}
|
||||||
|
humanizeTags={humanizeTags}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'collections' && (
|
{view === 'collections' && (
|
||||||
|
|
@ -277,6 +316,8 @@ export default function App() {
|
||||||
onTagFilterSet={handleTagFilterSet}
|
onTagFilterSet={handleTagFilterSet}
|
||||||
tagNodes={tagNodes}
|
tagNodes={tagNodes}
|
||||||
onTagsRefresh={handleTagsRefresh}
|
onTagsRefresh={handleTagsRefresh}
|
||||||
|
onEntryTitleChange={handleEntryTitleChange}
|
||||||
|
humanizeTags={humanizeTags}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
<CaptureDialog
|
<CaptureDialog
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,15 @@ export async function fetchEntryDetail(archiveId, entryUid) {
|
||||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
|
return getJson(`/api/archives/${archiveId}/entries/${entryUid}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateEntryTitle(archiveId, entryUid, title) {
|
||||||
|
const res = await fetch(`/api/archives/${archiveId}/entries/${entryUid}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: title ?? null }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchEntryTags(archiveId, entryUid) {
|
export async function fetchEntryTags(archiveId, entryUid) {
|
||||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
|
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/tags`);
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +58,27 @@ export async function removeTag(archiveId, entryUid, tagUid) {
|
||||||
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
|
if (!resp.ok) throw new Error(`Remove failed (${resp.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renameTag(archiveId, tagUid, name) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/archives/${archiveId}/tags/${tagUid}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json(); // returns the updated Tag: { tag_uid, name, slug, full_path }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTag(archiveId, tagUid) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/archives/${archiveId}/tags/${tagUid}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchRuns(archiveId) {
|
export async function fetchRuns(archiveId) {
|
||||||
return getJson(`/api/archives/${archiveId}/runs`);
|
return getJson(`/api/archives/${archiveId}/runs`);
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +153,15 @@ export async function updateProfile(displayName) {
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function patchMe(patch) {
|
||||||
|
const res = await fetch('/api/auth/me', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
export async function changePassword(currentPassword, newPassword) {
|
export async function changePassword(currentPassword, newPassword) {
|
||||||
const res = await fetch('/api/auth/me', {
|
const res = await fetch('/api/auth/me', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,89 @@ import { submitCapture, pollCaptureJob } from '../api'
|
||||||
|
|
||||||
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
|
export default function CaptureDialog({ open, archiveId, onClose, onCaptured }) {
|
||||||
const dialogRef = useRef(null)
|
const dialogRef = useRef(null)
|
||||||
const [locator, setLocator] = useState('')
|
const isFirstRenderRef = useRef(true)
|
||||||
const [error, setError] = useState(null)
|
const hasResumedPollingRef = useRef(false)
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [jobStatus, setJobStatus] = useState(null) // null | 'running' | 'completed' | 'failed'
|
const [locator, setLocator] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('captureDialogLocator')
|
||||||
|
return saved || ''
|
||||||
|
})
|
||||||
|
const [error, setError] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('captureDialogError')
|
||||||
|
return saved || null
|
||||||
|
})
|
||||||
|
const [busy, setBusy] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('captureDialogBusy')
|
||||||
|
return saved === 'true'
|
||||||
|
})
|
||||||
|
const [jobStatus, setJobStatus] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('captureDialogJobStatus')
|
||||||
|
return saved || null
|
||||||
|
})
|
||||||
|
const [jobUid, setJobUid] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('captureDialogJobUid')
|
||||||
|
return saved || null
|
||||||
|
})
|
||||||
const pollRef = useRef(null)
|
const pollRef = useRef(null)
|
||||||
|
|
||||||
|
// Persist state to sessionStorage
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('captureDialogLocator', locator)
|
||||||
|
}, [locator])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('captureDialogError', error || '')
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('captureDialogBusy', busy)
|
||||||
|
}, [busy])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('captureDialogJobStatus', jobStatus || '')
|
||||||
|
}, [jobStatus])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('captureDialogJobUid', jobUid || '')
|
||||||
|
}, [jobUid])
|
||||||
|
|
||||||
|
// On mount, resume polling if a job was in progress before page refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasResumedPollingRef.current) return
|
||||||
|
if (!jobUid || jobStatus !== 'running' || !archiveId) return
|
||||||
|
|
||||||
|
hasResumedPollingRef.current = true
|
||||||
|
|
||||||
|
// Resume polling for the saved job
|
||||||
|
pollRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const updated = await pollCaptureJob(archiveId, jobUid)
|
||||||
|
if (updated.status === 'completed') {
|
||||||
|
clearInterval(pollRef.current)
|
||||||
|
pollRef.current = null
|
||||||
|
setBusy(false)
|
||||||
|
setJobStatus('completed')
|
||||||
|
clearCaptureState()
|
||||||
|
dialogRef.current?.close()
|
||||||
|
onCaptured()
|
||||||
|
} else if (updated.status === 'failed') {
|
||||||
|
clearInterval(pollRef.current)
|
||||||
|
pollRef.current = null
|
||||||
|
setBusy(false)
|
||||||
|
setJobStatus('failed')
|
||||||
|
setError(updated.error_text || 'Capture failed.')
|
||||||
|
}
|
||||||
|
// pending / running: keep polling
|
||||||
|
} catch (pollErr) {
|
||||||
|
clearInterval(pollRef.current)
|
||||||
|
pollRef.current = null
|
||||||
|
setBusy(false)
|
||||||
|
setError(pollErr.message)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}, [jobUid, jobStatus, archiveId, onCaptured])
|
||||||
|
|
||||||
|
// Handle dialog close event
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dialog = dialogRef.current
|
const dialog = dialogRef.current
|
||||||
if (!dialog) return
|
if (!dialog) return
|
||||||
|
|
@ -20,21 +97,41 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
|
||||||
return () => dialog.removeEventListener('close', handleClose)
|
return () => dialog.removeEventListener('close', handleClose)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
|
// Handle open/close from parent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dialog = dialogRef.current
|
const dialog = dialogRef.current
|
||||||
if (!dialog) return
|
if (!dialog) return
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
setLocator('')
|
// Only clear state if this is a fresh open from user click (not a restore on first render)
|
||||||
setError(null)
|
if (!isFirstRenderRef.current) {
|
||||||
setJobStatus(null)
|
setLocator('')
|
||||||
setBusy(false)
|
setError(null)
|
||||||
clearInterval(pollRef.current)
|
setJobStatus(null)
|
||||||
|
setBusy(false)
|
||||||
|
setJobUid(null)
|
||||||
|
clearInterval(pollRef.current)
|
||||||
|
}
|
||||||
|
isFirstRenderRef.current = false
|
||||||
if (!dialog.open) dialog.showModal()
|
if (!dialog.open) dialog.showModal()
|
||||||
} else {
|
} else {
|
||||||
if (dialog.open) dialog.close()
|
if (dialog.open) dialog.close()
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
|
function clearCaptureState() {
|
||||||
|
sessionStorage.removeItem('captureDialogLocator')
|
||||||
|
sessionStorage.removeItem('captureDialogError')
|
||||||
|
sessionStorage.removeItem('captureDialogBusy')
|
||||||
|
sessionStorage.removeItem('captureDialogJobStatus')
|
||||||
|
sessionStorage.removeItem('captureDialogJobUid')
|
||||||
|
setLocator('')
|
||||||
|
setError(null)
|
||||||
|
setBusy(false)
|
||||||
|
setJobStatus(null)
|
||||||
|
setJobUid(null)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!locator.trim()) { setError('Enter a locator.'); return }
|
if (!locator.trim()) { setError('Enter a locator.'); return }
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
|
|
@ -42,6 +139,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
|
||||||
setJobStatus(null)
|
setJobStatus(null)
|
||||||
try {
|
try {
|
||||||
const job = await submitCapture(archiveId, locator.trim())
|
const job = await submitCapture(archiveId, locator.trim())
|
||||||
|
setJobUid(job.job_uid)
|
||||||
setJobStatus('running')
|
setJobStatus('running')
|
||||||
pollRef.current = setInterval(async () => {
|
pollRef.current = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -51,6 +149,7 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured })
|
||||||
pollRef.current = null
|
pollRef.current = null
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
setJobStatus('completed')
|
setJobStatus('completed')
|
||||||
|
clearCaptureState()
|
||||||
dialogRef.current?.close()
|
dialogRef.current?.close()
|
||||||
onCaptured()
|
onCaptured()
|
||||||
} else if (updated.status === 'failed') {
|
} else if (updated.status === 'failed') {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,26 @@
|
||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
|
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections, updateEntryTitle } from '../api'
|
||||||
import { formatTimestamp, formatBytes, valueText, sourceIconSvg } from '../utils'
|
import { formatTimestamp, formatBytes, valueText, sourceIconSvg, displayPath } from '../utils'
|
||||||
|
|
||||||
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
const VIS_LABEL = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
||||||
|
|
||||||
|
|
||||||
const ExternalIcon = () => (
|
const ExternalIcon = () => (
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M7 17 17 7M9 7h8v8"/>
|
<path d="M7 17 17 7M9 7h8v8"/>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
|
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, humanizeTags }) {
|
||||||
const [detail, setDetail] = useState(null)
|
const [detail, setDetail] = useState(null)
|
||||||
const [tags, setTags] = useState([])
|
const [tags, setTags] = useState([])
|
||||||
const [assignInput, setAssignInput] = useState('')
|
const [assignInput, setAssignInput] = useState('')
|
||||||
const [entryCollections, setEntryCollections] = useState([])
|
const [entryCollections, setEntryCollections] = useState([])
|
||||||
const [assignError, setAssignError] = useState('')
|
const [assignError, setAssignError] = useState('')
|
||||||
const selectSeqRef = useRef(0)
|
const selectSeqRef = useRef(0)
|
||||||
|
const titleCancelRef = useRef(false)
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false)
|
||||||
|
const [titleDraft, setTitleDraft] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedEntry || !archiveId) {
|
if (!selectedEntry || !archiveId) {
|
||||||
|
|
@ -25,6 +29,9 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
setEntryCollections([])
|
setEntryCollections([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setEditingTitle(false)
|
||||||
|
setTitleDraft('')
|
||||||
|
titleCancelRef.current = false
|
||||||
const seq = ++selectSeqRef.current
|
const seq = ++selectSeqRef.current
|
||||||
setDetail(null)
|
setDetail(null)
|
||||||
setTags([])
|
setTags([])
|
||||||
|
|
@ -40,6 +47,19 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [selectedEntry, archiveId])
|
}, [selectedEntry, archiveId])
|
||||||
|
|
||||||
|
async function handleTitleSave() {
|
||||||
|
const newTitle = titleDraft.trim() || null
|
||||||
|
try {
|
||||||
|
await updateEntryTitle(archiveId, selectedEntry.entry_uid, newTitle)
|
||||||
|
setDetail(prev => prev ? { ...prev, summary: { ...prev.summary, title: newTitle } } : prev)
|
||||||
|
onEntryTitleChange?.(selectedEntry.entry_uid, newTitle)
|
||||||
|
} catch {
|
||||||
|
// silently revert
|
||||||
|
} finally {
|
||||||
|
setEditingTitle(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAssignTag() {
|
async function handleAssignTag() {
|
||||||
const path = assignInput.trim()
|
const path = assignInput.trim()
|
||||||
if (!path || !selectedEntry) return
|
if (!path || !selectedEntry) return
|
||||||
|
|
@ -84,9 +104,33 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
<p className="tags-empty">Loading…</p>
|
<p className="tags-empty">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 className="rail-title">
|
{editingTitle ? (
|
||||||
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
|
<input
|
||||||
</h2>
|
className="rail-title-input"
|
||||||
|
autoFocus
|
||||||
|
value={titleDraft}
|
||||||
|
onChange={e => setTitleDraft(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter') e.currentTarget.blur()
|
||||||
|
if (e.key === 'Escape') { titleCancelRef.current = true; e.currentTarget.blur() }
|
||||||
|
}}
|
||||||
|
onBlur={() => { if (titleCancelRef.current) { setEditingTitle(false) } else { handleTitleSave() } titleCancelRef.current = false }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h2
|
||||||
|
className="rail-title rail-title--editable"
|
||||||
|
title="Click to rename"
|
||||||
|
onClick={() => {
|
||||||
|
setTitleDraft(detail.summary.title ?? '')
|
||||||
|
setEditingTitle(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
|
||||||
|
<svg className="edit-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M11.5 2.5a1.5 1.5 0 0 1 2 2L5 13l-3 1 1-3 8.5-8.5z"/>
|
||||||
|
</svg>
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
{detail.summary.original_url && (
|
{detail.summary.original_url && (
|
||||||
<a
|
<a
|
||||||
|
|
@ -147,7 +191,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
||||||
<div className="tags-wrap">
|
<div className="tags-wrap">
|
||||||
{tags.map(tag => (
|
{tags.map(tag => (
|
||||||
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
|
<span key={tag.tag_uid} className="tag-pill" title={tag.full_path}>
|
||||||
{tag.name}
|
{humanizeTags ? displayPath(tag.full_path) : tag.full_path}
|
||||||
<button
|
<button
|
||||||
className="remove"
|
className="remove"
|
||||||
title={`Remove tag ${tag.full_path}`}
|
title={`Remove tag ${tag.full_path}`}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useContext, useCallback } from 'react'
|
import { useState, useEffect, useContext, useCallback } from 'react'
|
||||||
import { AuthContext } from '../App.jsx'
|
import { AuthContext } from '../App.jsx'
|
||||||
import {
|
import {
|
||||||
updateProfile, changePassword,
|
updateProfile, changePassword, patchMe,
|
||||||
listTokens, createToken, deleteToken,
|
listTokens, createToken, deleteToken,
|
||||||
getInstanceSettings, updateInstanceSettings,
|
getInstanceSettings, updateInstanceSettings,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
|
|
@ -95,6 +95,29 @@ function ProfileTab({ currentUser, setCurrentUser }) {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h2>Display Preferences</h2>
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={currentUser?.humanize_slugs ?? false}
|
||||||
|
onChange={async e => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
try {
|
||||||
|
await patchMe({ humanize_slugs: checked });
|
||||||
|
setCurrentUser(prev => ({ ...prev, humanize_slugs: checked }));
|
||||||
|
} catch {
|
||||||
|
// silently revert
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="form-label" style={{ margin: 0 }}>Humanize tag display</span>
|
||||||
|
</label>
|
||||||
|
<p className="muted" style={{ fontSize: 13, margin: '4px 0 0' }}>
|
||||||
|
When on, tag paths show as "X / Articles" instead of "x/articles".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<h2>Change Password</h2>
|
<h2>Change Password</h2>
|
||||||
<form onSubmit={handleChangePassword}>
|
<form onSubmit={handleChangePassword}>
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,125 @@
|
||||||
function TagNode({ node, tagFilter, onTagFilterSet, onViewChange }) {
|
import { useState, useRef } from 'react';
|
||||||
const isActive = tagFilter === node.tag.full_path
|
import { renameTag, deleteTag } from '../api';
|
||||||
function handleClick() {
|
|
||||||
const next = isActive ? null : node.tag.full_path
|
function TagNode({ node, archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
||||||
onTagFilterSet(next)
|
const isActive = tagFilter === node.tag.full_path;
|
||||||
onViewChange('archive')
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const cancelRef = useRef(false);
|
||||||
|
|
||||||
|
function handleFilterClick() {
|
||||||
|
if (editing) return;
|
||||||
|
const next = isActive ? null : node.tag.full_path;
|
||||||
|
onTagFilterSet(next);
|
||||||
|
onViewChange('archive');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startEdit(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDraft(node.tag.slug);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRenameSave() {
|
||||||
|
const value = draft.trim();
|
||||||
|
if (!value || value === node.tag.slug) {
|
||||||
|
setEditing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await renameTag(archiveId, node.tag.tag_uid, value);
|
||||||
|
onTagRenamed(node.tag.full_path, updated.full_path);
|
||||||
|
onTagsRefresh();
|
||||||
|
} catch {
|
||||||
|
// silently revert
|
||||||
|
} finally {
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const msg = node.children?.length > 0
|
||||||
|
? `Delete tag "${node.tag.full_path}" and all its child tags? This cannot be undone.`
|
||||||
|
: `Delete tag "${node.tag.full_path}"? This cannot be undone.`;
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
try {
|
||||||
|
await deleteTag(archiveId, node.tag.tag_uid);
|
||||||
|
onTagDeleted(node.tag.full_path);
|
||||||
|
onTagsRefresh();
|
||||||
|
} catch {
|
||||||
|
// silently ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childProps = { archiveId, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<button
|
<div className="tag-node-row">
|
||||||
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
|
{editing ? (
|
||||||
title={node.tag.full_path}
|
<input
|
||||||
onClick={handleClick}>
|
className="tag-rename-input"
|
||||||
{node.tag.name}
|
autoFocus
|
||||||
</button>
|
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;
|
||||||
|
setEditing(false);
|
||||||
|
} else {
|
||||||
|
handleRenameSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`tag-node-btn${isActive ? ' is-active' : ''}`}
|
||||||
|
title={node.tag.full_path}
|
||||||
|
onClick={handleFilterClick}
|
||||||
|
onDoubleClick={startEdit}
|
||||||
|
>
|
||||||
|
{humanizeTags ? node.tag.name : node.tag.slug}
|
||||||
|
<svg
|
||||||
|
className="edit-icon"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
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
|
||||||
|
className="remove tag-node-delete"
|
||||||
|
title={`Delete tag ${node.tag.full_path}`}
|
||||||
|
onClick={handleDelete}
|
||||||
|
aria-label={`Delete tag ${node.tag.full_path}`}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
{node.children?.length > 0 && (
|
{node.children?.length > 0 && (
|
||||||
<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}
|
<TagNode key={child.tag.tag_uid} node={child} {...childProps} />
|
||||||
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewChange }) {
|
export default function TagsView({ archiveId, tagNodes, tagFilter, onTagFilterSet, onViewChange, onTagRenamed, onTagDeleted, onTagsRefresh, humanizeTags }) {
|
||||||
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">
|
||||||
|
|
@ -43,11 +135,19 @@ export default function TagsView({ tagNodes, tagFilter, onTagFilterSet, onViewCh
|
||||||
<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}
|
||||||
tagFilter={tagFilter} onTagFilterSet={onTagFilterSet} onViewChange={onViewChange} />
|
archiveId={archiveId}
|
||||||
|
tagFilter={tagFilter}
|
||||||
|
onTagFilterSet={onTagFilterSet}
|
||||||
|
onViewChange={onViewChange}
|
||||||
|
onTagRenamed={onTagRenamed}
|
||||||
|
onTagDeleted={onTagDeleted}
|
||||||
|
onTagsRefresh={onTagsRefresh}
|
||||||
|
humanizeTags={humanizeTags}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<button className="capture-button" onClick={onCaptureClick}>+ Capture</button>
|
<button className="capture-button" onClick={onCaptureClick}>Capture</button>
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
<div className="user-menu">
|
<div className="user-menu">
|
||||||
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
|
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
|
||||||
|
|
|
||||||
|
|
@ -673,6 +673,56 @@ select {
|
||||||
.tag-node-btn:hover { text-decoration: underline; }
|
.tag-node-btn:hover { text-decoration: underline; }
|
||||||
.tag-node-btn.is-active { font-weight: 600; color: var(--ink); }
|
.tag-node-btn.is-active { font-weight: 600; color: var(--ink); }
|
||||||
|
|
||||||
|
/* tag-node-row: flex row containing the name button + delete × */
|
||||||
|
.tag-node-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.tag-node-row .tag-node-btn {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
/* constrain the edit pencil so it doesn't inherit unconstrained SVG dimensions */
|
||||||
|
.tag-node-btn .edit-icon {
|
||||||
|
width: 0.75em;
|
||||||
|
height: 0.75em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: -0.1em;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
.tag-node-btn:hover .edit-icon { opacity: 0.65; }
|
||||||
|
/* delete × button next to tag name — small, unobtrusive */
|
||||||
|
.tag-node-delete {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted-2);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 3px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.tag-node-row:hover .tag-node-delete { opacity: 1; }
|
||||||
|
.tag-node-delete:hover { color: var(--accent); }
|
||||||
|
/* rename input inside the tag list */
|
||||||
|
.tag-rename-input {
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: var(--field);
|
||||||
|
color: var(--ink);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Collections page (sidebar layout) ──────────────────────────────────── */
|
/* ── Collections page (sidebar layout) ──────────────────────────────────── */
|
||||||
.collections-view {
|
.collections-view {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
@ -1349,3 +1399,37 @@ select {
|
||||||
}
|
}
|
||||||
.entry-table { min-width: 860px; }
|
.entry-table { min-width: 860px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline title edit in ContextRail */
|
||||||
|
.rail-title--editable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.rail-title--editable:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.rail-title--editable .edit-icon {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.75em;
|
||||||
|
height: 0.75em;
|
||||||
|
margin-left: 0.35em;
|
||||||
|
vertical-align: middle;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.rail-title--editable:hover .edit-icon {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.rail-title-input {
|
||||||
|
width: 100%;
|
||||||
|
font: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid currentColor;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,19 @@ export const SOURCE_ICONS = {
|
||||||
export function sourceIconSvg(kind) {
|
export function sourceIconSvg(kind) {
|
||||||
return SOURCE_ICONS[kind] ?? SOURCE_ICONS.other;
|
return SOURCE_ICONS[kind] ?? SOURCE_ICONS.other;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Humanize each slash-segment of a stored full_path for display.
|
||||||
|
// Matches the backend humanize_slug convention: capitalize first char of each
|
||||||
|
// hyphen-part, replace hyphens with spaces.
|
||||||
|
// e.g. "/x/natural-science" → "/X/Natural Science"
|
||||||
|
// Used only for rendered label text; never mutate state/API values with this.
|
||||||
|
export function displayPath(fullPath) {
|
||||||
|
return fullPath
|
||||||
|
.split('/')
|
||||||
|
.map(seg =>
|
||||||
|
seg
|
||||||
|
? seg.split('-').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
|
||||||
|
: ''
|
||||||
|
)
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue