mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: tag delete, rename, and per-user humanize-tags display setting (#16)
* feat: add tag delete and rename (backend + frontend)
- DELETE /api/archives/:id/tags/:tag_uid — deletes tag subtree via
recursive CTE; entry_tag_assignments cascade automatically
- PATCH /api/archives/:id/tags/:tag_uid { name } — renames a tag
segment (case-preserving slug), cascades full_path to all descendants
in one transaction using hierarchy CTE (not LIKE), returns updated Tag
- database: rename_tag + delete_tag with 7 unit tests covering subtree
cascade, collision detection, descendant path rewrite, slug stripping
- TagsView: inline rename (pencil icon / double-click → input), × delete
with confirmation dialog (warns about child tags)
- App.jsx: handleTagRenamed rewrites tagFilter for exact + descendant
paths; handleTagDeleted clears filter for deleted subtree
- api.js: renameTag (PATCH, returns Tag) + deleteTag (DELETE)
- ContextRail: tag pills show displayPath(full_path) (humanized) with
raw full_path in title tooltip; humanize-tags setting coming next
* feat: per-user humanize-tags display setting
- auth DB: idempotent migration adds users.humanize_slugs INTEGER DEFAULT 0
- GET /api/auth/me: returns humanize_slugs bool
- PATCH /api/auth/me: accepts { humanize_slugs: bool }, persisted via
database::update_user_humanize_slugs
- displayPath() helper moved to utils.js (was local to ContextRail)
- TagsView: node label shows tag.name vs tag.slug based on humanizeTags
- ContextRail: pill label applies displayPath conditionally
- App.jsx: derives humanizeTags from currentUser.humanize_slugs, passes
to TagsView + ContextRail; filter badge label humanized when on
- Settings > Profile: Display Preferences section with checkbox toggle,
updates backend and currentUser immediately via setCurrentUser
- api.js: patchMe(patch) for generic PATCH /api/auth/me
- Tests: auth_me default=false, PATCH persists=true (2 route tests)
This commit is contained in:
parent
d6b52ba06c
commit
55f85134df
13 changed files with 699 additions and 69 deletions
|
|
@ -468,6 +468,11 @@ pub fn initialize_auth_schema(conn: &Connection) -> Result<()> {
|
|||
)?;
|
||||
// Add display_name column to users if not present (idempotent migration)
|
||||
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(())
|
||||
}
|
||||
|
|
@ -752,6 +757,14 @@ pub fn update_user_display_name(conn: &Connection, user_id: i64, display_name: O
|
|||
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> {
|
||||
|
|
@ -1639,6 +1652,113 @@ pub fn entry_count_for_tag_path(conn: &Connection, full_path: &str) -> Result<i6
|
|||
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<()> {
|
||||
conn.execute(
|
||||
"UPDATE archive_runs
|
||||
|
|
@ -2441,4 +2561,148 @@ mod tests {
|
|||
assert_eq!(r2.bit_position, 5);
|
||||
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");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,6 +236,10 @@ pub fn app_with_state(state: AppState) -> Router {
|
|||
get(get_capture_job_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(
|
||||
"/api/archives/:archive_id/entries/:entry_uid/tags",
|
||||
get(list_entry_tags).post(assign_entry_tag_handler),
|
||||
|
|
@ -556,6 +560,41 @@ 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,
|
||||
|
|
@ -603,6 +642,11 @@ struct PatchEntryBody {
|
|||
title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct PatchTagBody {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn capture_handler(
|
||||
State(state): State<AppState>,
|
||||
auth_user: AuthUser,
|
||||
|
|
@ -776,17 +820,19 @@ async fn auth_me(
|
|||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let (user_id, role_bits) = auth_user.require_auth()?;
|
||||
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(
|
||||
"SELECT username, display_name FROM users WHERE id = ?1",
|
||||
"SELECT username, display_name, COALESCE(humanize_slugs, 0) FROM users WHERE id = ?1",
|
||||
[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}")))?;
|
||||
let humanize_slugs = humanize_slugs_int != 0;
|
||||
Ok(Json(serde_json::json!({
|
||||
"role_bits": role_bits,
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
"humanize_slugs": humanize_slugs,
|
||||
})))
|
||||
}
|
||||
|
||||
|
|
@ -817,6 +863,10 @@ async fn patch_me(
|
|||
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)
|
||||
}
|
||||
|
||||
|
|
@ -905,6 +955,7 @@ struct UpdateProfileBody {
|
|||
display_name: Option<String>,
|
||||
current_password: Option<String>,
|
||||
new_password: Option<String>,
|
||||
humanize_slugs: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
|
|
@ -2835,4 +2886,76 @@ mod tests {
|
|||
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 name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-Bysig1_i.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CprXPmri.css">
|
||||
<script type="module" crossorigin src="/assets/index-DuR832Rs.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oE_dvyrb.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue