From 533e9d5d2e6b95b03c83c305d18b7e720adf20cf Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:33:35 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(collections):=20db=20=E2=80=94=20updat?= =?UTF-8?q?e=5Fcollection,=20delete=5Fcollection=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/archivr-core/src/database.rs | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index 07126a3..d690070 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -1692,6 +1692,45 @@ pub fn get_entry_collection_memberships( .map_err(Into::into) } +/// Renames a collection and/or updates its default_visibility_bits. +/// Returns true if updated, false if not found. +/// Refuses to rename the '_default_' collection. +pub fn update_collection( + conn: &Connection, + collection_uid: &str, + new_name: Option<&str>, + new_visibility_bits: Option, +) -> Result { + let coll = get_collection_by_uid(conn, collection_uid)?; + let Some(coll) = coll else { return Ok(false) }; + if coll.slug == "_default_" { + anyhow::bail!("cannot modify the default collection"); + } + let name = new_name.unwrap_or(&coll.name); + let vbits = new_visibility_bits.unwrap_or(coll.default_visibility_bits); + conn.execute( + "UPDATE collections SET name = ?1, default_visibility_bits = ?2 WHERE id = ?3", + params![name, vbits as i64, coll.id], + )?; + Ok(true) +} + +/// Deletes a collection and cascades to collection_entries. +/// Returns true if deleted, false if not found. +/// Refuses to delete the '_default_' collection. +pub fn delete_collection( + conn: &Connection, + collection_uid: &str, +) -> Result { + let coll = get_collection_by_uid(conn, collection_uid)?; + let Some(coll) = coll else { return Ok(false) }; + if coll.slug == "_default_" { + anyhow::bail!("cannot delete the default collection"); + } + conn.execute("DELETE FROM collections WHERE id = ?1", [coll.id])?; + Ok(true) +} + fn run_id_for_item(conn: &Connection, item_id: i64) -> Result { let run_id = conn.query_row( "SELECT run_id FROM archive_run_items WHERE id = ?1", From 383cec1f81cfee25c1bc6156c5302606bef5735c Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:33:38 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(collections):=20routes=20=E2=80=94=20P?= =?UTF-8?q?ATCH|DELETE=20/api/archives/:id/collections/:uid,=20visibility?= =?UTF-8?q?=5Fbits=20in=20GET=20response?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/archivr-server/src/routes.rs | 74 ++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 9a27c28..3ae5804 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -135,7 +135,9 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router .route("/api/archives/:archive_id/collections", get(list_collections_handler).post(create_collection_handler)) .route("/api/archives/:archive_id/collections/:coll_uid", - get(get_collection_handler)) + get(get_collection_handler) + .patch(patch_collection_handler) + .delete(delete_collection_handler)) .route("/api/archives/:archive_id/collections/:coll_uid/entries", post(add_entry_to_collection_handler)) .route("/api/archives/:archive_id/collections/:coll_uid/entries/:entry_uid", @@ -320,6 +322,12 @@ struct UpdateVisibilityBody { visibility_bits: u32, } +#[derive(Debug, serde::Deserialize)] +struct PatchCollectionBody { + name: Option, + default_visibility_bits: Option, +} + async fn list_tags( State(state): State, Path(archive_id): Path, @@ -939,13 +947,41 @@ async fn get_collection_handler( .ok_or(ApiError::not_found("collection not found"))?; let caller_bits = auth_to_caller_bits(&auth); let entries = archive::list_entries_for_collection(&conn, record.id, caller_bits)?; + // Collect per-entry visibility bits from collection_entries + let mut vis_map: std::collections::HashMap = std::collections::HashMap::new(); + { + let mut stmt = conn.prepare( + "SELECT ae.entry_uid, ce.visibility_bits \ + FROM collection_entries ce \ + JOIN archived_entries ae ON ae.id = ce.entry_id \ + WHERE ce.collection_id = ?1", + )?; + let rows = stmt.query_map([record.id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u32)) + })?; + for r in rows { + if let Ok((uid, bits)) = r { + vis_map.insert(uid, bits); + } + } + } + let entries_json: Vec = entries.iter().map(|e| { + let vis = vis_map.get(&e.entry_uid).copied().unwrap_or(record.default_visibility_bits); + serde_json::json!({ + "entry_uid": e.entry_uid, + "title": e.title, + "source_kind": e.source_kind, + "archived_at": e.archived_at, + "collection_visibility_bits": vis, + }) + }).collect(); Ok(Json(serde_json::json!({ "collection_uid": record.collection_uid, "name": record.name, "slug": record.slug, "default_visibility_bits": record.default_visibility_bits, "created_at": record.created_at, - "entries": entries, + "entries": entries_json, }))) } @@ -1041,6 +1077,40 @@ async fn list_entry_collections_handler( } } +async fn patch_collection_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid)): Path<(String, String)>, + Json(body): Json, +) -> Result { + auth.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let name_ref: Option<&str> = body.name.as_deref(); + let updated = database::update_collection(&conn, &coll_uid, name_ref, body.default_visibility_bits)?; + if updated { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found("collection not found")) + } +} + +async fn delete_collection_handler( + State(state): State, + auth: AuthUser, + Path((archive_id, coll_uid)): Path<(String, String)>, +) -> Result { + auth.require_role(ROLE_USER)?; + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let deleted = database::delete_collection(&conn, &coll_uid)?; + if deleted { + Ok(StatusCode::NO_CONTENT) + } else { + Err(ApiError::not_found("collection not found")) + } +} + #[cfg(test)] mod tests { use super::*; From c4ce2129bbb6fd4242068580b490d8d9066cb09c Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:33:42 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(collections):=20frontend=20=E2=80=94?= =?UTF-8?q?=20api.js=20updateCollection=20and=20deleteCollection=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/api.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/api.js b/frontend/src/api.js index 334fbf6..6b17079 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -285,6 +285,20 @@ export async function listEntryCollections(archiveId, entryUid) { return getJson(`/api/archives/${archiveId}/entries/${entryUid}/collections`); } +export async function updateCollection(archiveId, collUid, patch) { + const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }) + if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) } +} + +export async function deleteCollection(archiveId, collUid) { + const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}`, { method: 'DELETE' }) + if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) } +} + // ── 401 interceptor ─────────────────────────────────────────────────────────── const _origFetch = window.fetch; window.fetch = async (...args) => { From 80dce800338c03a12129f41791f762952da55f92 Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:33:46 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat(collections):=20frontend=20=E2=80=94?= =?UTF-8?q?=20CollectionsView=20rename,=20delete,=20add/remove=20entries,?= =?UTF-8?q?=20per-entry=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/CollectionsView.jsx | 325 ++++++++++++++++---- frontend/src/styles.css | 288 +++++++++++++++++ 2 files changed, 551 insertions(+), 62 deletions(-) diff --git a/frontend/src/components/CollectionsView.jsx b/frontend/src/components/CollectionsView.jsx index bd542e7..48e4271 100644 --- a/frontend/src/components/CollectionsView.jsx +++ b/frontend/src/components/CollectionsView.jsx @@ -1,15 +1,25 @@ -import { useState, useEffect, useCallback } from 'react' -import { listCollections, createCollection, getCollection } from '../api.js' +import { useState, useEffect, useCallback, useRef } from 'react' +import { + listCollections, createCollection, getCollection, + addEntryToCollection, removeEntryFromCollection, + updateEntryVisibility, updateCollection, deleteCollection, +} from '../api.js' -const VIS_LABELS = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' } +const VIS_OPTIONS = [ + { value: 0, label: 'Private' }, + { value: 2, label: 'Users only' }, + { value: 3, label: 'Public' }, +] +const VIS_LABEL = v => VIS_OPTIONS.find(o => o.value === v)?.label ?? String(v) export default function CollectionsView({ archiveId }) { const [collections, setCollections] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [selectedColl, setSelectedColl] = useState(null) + const [selectedUid, setSelectedUid] = useState(null) const [collDetail, setCollDetail] = useState(null) const [detailLoading, setDetailLoading] = useState(false) + const [detailError, setDetailError] = useState(null) // Create form const [newName, setNewName] = useState('') @@ -18,7 +28,21 @@ export default function CollectionsView({ archiveId }) { const [creating, setCreating] = useState(false) const [createError, setCreateError] = useState(null) - const refresh = useCallback(async () => { + // Add-entry form + const [addUid, setAddUid] = useState('') + const [addVis, setAddVis] = useState(2) + const [adding, setAdding] = useState(false) + const [addError, setAddError] = useState(null) + + // Inline rename state + const [renaming, setRenaming] = useState(false) + const [renameVal, setRenameVal] = useState('') + const renameRef = useRef(null) + + const selected = collections.find(c => c.collection_uid === selectedUid) ?? null + const isDefault = selected?.slug === '_default_' + + const refreshList = useCallback(async () => { if (!archiveId) return setLoading(true) setError(null) @@ -32,16 +56,25 @@ export default function CollectionsView({ archiveId }) { } }, [archiveId]) - useEffect(() => { refresh() }, [refresh]) - - useEffect(() => { - if (!selectedColl) { setCollDetail(null); return } + const refreshDetail = useCallback(async (uid) => { + if (!uid) { setCollDetail(null); return } setDetailLoading(true) - getCollection(archiveId, selectedColl.collection_uid) - .then(d => setCollDetail(d)) - .catch(e => setError(e.message)) - .finally(() => setDetailLoading(false)) - }, [selectedColl, archiveId]) + setDetailError(null) + try { + const d = await getCollection(archiveId, uid) + setCollDetail(d) + } catch (e) { + setDetailError(e.message) + } finally { + setDetailLoading(false) + } + }, [archiveId]) + + useEffect(() => { refreshList() }, [refreshList]) + useEffect(() => { refreshDetail(selectedUid) }, [selectedUid, refreshDetail]) + + // Auto-focus rename input + useEffect(() => { if (renaming && renameRef.current) renameRef.current.focus() }, [renaming]) async function handleCreate(e) { e.preventDefault() @@ -51,11 +84,12 @@ export default function CollectionsView({ archiveId }) { setCreating(true) setCreateError(null) try { - await createCollection(archiveId, name, slug, newVis) + const coll = await createCollection(archiveId, name, slug, newVis) setNewName('') setNewSlug('') setNewVis(2) - await refresh() + await refreshList() + setSelectedUid(coll.collection_uid) } catch (err) { setCreateError(err.message) } finally { @@ -63,71 +97,240 @@ export default function CollectionsView({ archiveId }) { } } + async function handleRenameCommit() { + const name = renameVal.trim() + if (!name || !selected) { setRenaming(false); return } + try { + await updateCollection(archiveId, selected.collection_uid, { name }) + await refreshList() + setCollDetail(d => d ? { ...d, name } : d) + } catch (e) { + setError(e.message) + } finally { + setRenaming(false) + } + } + + async function handleVisChange(newVisVal) { + if (!selected) return + try { + await updateCollection(archiveId, selected.collection_uid, { default_visibility_bits: newVisVal }) + await refreshList() + setCollDetail(d => d ? { ...d, default_visibility_bits: newVisVal } : d) + } catch (e) { + setError(e.message) + } + } + + async function handleDelete() { + if (!selected) return + if (!window.confirm(`Delete collection "${selected.name}"? Entries will not be deleted.`)) return + try { + await deleteCollection(archiveId, selected.collection_uid) + setSelectedUid(null) + setCollDetail(null) + await refreshList() + } catch (e) { + setError(e.message) + } + } + + async function handleAddEntry(e) { + e.preventDefault() + const uid = addUid.trim() + if (!uid || !selected) return + setAdding(true) + setAddError(null) + try { + await addEntryToCollection(archiveId, selected.collection_uid, uid, addVis) + setAddUid('') + await refreshDetail(selected.collection_uid) + } catch (err) { + setAddError(err.message) + } finally { + setAdding(false) + } + } + + async function handleRemoveEntry(entryUid) { + if (!selected) return + try { + await removeEntryFromCollection(archiveId, selected.collection_uid, entryUid) + await refreshDetail(selected.collection_uid) + } catch (e) { + setDetailError(e.message) + } + } + + async function handleEntryVisChange(entryUid, vis) { + if (!selected) return + try { + await updateEntryVisibility(archiveId, selected.collection_uid, entryUid, vis) + setCollDetail(d => d ? { + ...d, + entries: d.entries.map(en => + en.entry_uid === entryUid ? { ...en, collection_visibility_bits: vis } : en + ), + } : d) + } catch (e) { + setDetailError(e.message) + } + } + if (!archiveId) return
Select an archive.
return (
-

Collections

+

Collections

{loading &&
Loading…
} - {error &&
{error}
} + {error &&
{error}
}
-
+ {/* Sidebar */} +
{collections.map(c => ( -
setSelectedColl(c)} + className={`coll-row${selectedUid === c.collection_uid ? ' is-active' : ''}`} + onClick={() => setSelectedUid(c.collection_uid)} > - {c.name} - - {VIS_LABELS[c.default_visibility_bits] ?? c.default_visibility_bits} - -
+ {c.name} + {VIS_LABEL(c.default_visibility_bits)} + ))} {collections.length === 0 && !loading && ( -
No collections yet.
+
No collections yet.
)}
- {selectedColl && ( -
-

{selectedColl.name}

-
- Default visibility: {VIS_LABELS[selectedColl.default_visibility_bits] ?? selectedColl.default_visibility_bits} -
- {detailLoading ? ( -
Loading entries…
- ) : collDetail ? ( - collDetail.entries.length === 0 ? ( -
No entries visible to you in this collection.
+ {/* Detail pane */} + {selected ? ( +
+ {/* Header */} +
+ {renaming ? ( + setRenameVal(e.target.value)} + onBlur={handleRenameCommit} + onKeyDown={e => { + if (e.key === 'Enter') handleRenameCommit() + if (e.key === 'Escape') setRenaming(false) + }} + /> ) : ( -
    - {collDetail.entries.map(entry => ( -
  • - - {entry.title || entry.entry_uid} - - - {entry.source_kind} - -
  • - ))} -
- ) - ) : null} +

{ if (!isDefault) { setRenameVal(selected.name); setRenaming(true) } }} + > + {collDetail?.name ?? selected.name} + {!isDefault && } +

+ )} + {!isDefault && ( + + )} +
+ + {/* Visibility */} +
+ Default visibility + +
+ + {/* Entries */} +
+
Entries
+ {detailLoading &&
Loading…
} + {detailError &&
{detailError}
} + {!detailLoading && collDetail && ( + collDetail.entries.length === 0 ? ( +
No entries in this collection.
+ ) : ( +
    + {collDetail.entries.map(entry => ( +
  • +
    + {entry.title || entry.entry_uid} + {entry.source_kind} +
    +
    + + {!isDefault && ( + + )} +
    +
  • + ))} +
+ ) + )} +
+ + {/* Add entry (non-default only) */} + {!isDefault && ( +
+
Add entry
+
+ setAddUid(e.target.value)} + placeholder="entry_uid" + required + /> + + +
+ {addError &&
{addError}
} +
+ )} +
+ ) : ( +
+
Select a collection to view details.
)}
-
+ {/* Create form */} +
Create collection