mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(collections): Track 6 permissions & visibility — collection model, role-bitmask visibility, collection UI
This commit is contained in:
commit
915878ff10
12 changed files with 862 additions and 22 deletions
25
NEXT.md
25
NEXT.md
|
|
@ -118,11 +118,20 @@ custom roles get bit_position≥4. Ban invalidates all sessions. Only-owner guar
|
|||
|
||||
---
|
||||
|
||||
### 6. Permissions & visibility — collection model
|
||||
### ~~6. Permissions & visibility — collection model~~ ✅ Done
|
||||
|
||||
**What:** Replace `archived_entries.visibility` with a `collections` + `collection_entries`
|
||||
model where visibility is a role-bitmask per entry-in-collection. Enforce visibility on all
|
||||
API queries. Collection UI (create, set visibility, add entries).
|
||||
**Implemented:** `database.rs`: `collections` + `collection_entries` tables; seed default collection ('All Entries');
|
||||
migration inserts existing entries into default collection with `visibility_bits` derived from legacy `visibility` string
|
||||
(`'public'`→3, `'unlisted'`→2, `'private'`→0). 8 new pub functions (`ensure_default_collection`, `create_collection`,
|
||||
`list_collections`, `get_collection_by_uid`, `add_entry_to_collection`, `update_collection_entry_visibility`,
|
||||
`remove_entry_from_collection`, `get_entry_collection_memberships`). `create_archived_entry` auto-enrolls new entries
|
||||
into the default collection. `archive.rs`: `list_root_entries` + `search_entries` accept `caller_bits: u32` and enforce
|
||||
collection visibility (`admin/owner bypass`; others filtered by `visibility_bits & caller_bits`). `list_entries_for_collection`,
|
||||
`get_entry_collections`, `EntryCollectionMembership` added. `routes.rs`: read routes extract auth and pass caller_bits;
|
||||
5 collection-entry route groups (`GET|POST /api/archives/:id/collections`, `GET /api/archives/:id/collections/:uid`,
|
||||
`POST|DELETE|PATCH /api/archives/:id/collections/:uid/entries(/:entry_uid)`,
|
||||
`GET /api/archives/:id/entries/:uid/collections`). Frontend: `CollectionsView.jsx` (list + detail + create form);
|
||||
`collections` nav item in Topbar; entry collection memberships + visibility shown in ContextRail. 169 tests green.
|
||||
Depends on Track 5.
|
||||
|
||||
---
|
||||
|
|
@ -196,12 +205,12 @@ and a corresponding downloader module. Consider `rclone` as a shell-out strategy
|
|||
|
||||
## What to Do First
|
||||
|
||||
Tracks 1, 2, 3, 4, and 5 are complete. Track 6 (permissions & visibility) is the next priority.
|
||||
Tracks 1, 2, 3, 4, 5, and 6 are complete. Track 7 (settings) is the next priority.
|
||||
|
||||
Open the next thread with:
|
||||
|
||||
```text
|
||||
Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 6: permissions and
|
||||
visibility — the collection model. Create a task-level implementation plan first, then wait
|
||||
for approval.
|
||||
Read ARCHIVR-MENTAL-MODEL.md and NEXT.md. I want to implement Track 7: settings —
|
||||
account profile, password change, instance settings UI, API token management.
|
||||
Create a task-level implementation plan first, then wait for approval.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -85,6 +85,15 @@ pub struct TagNode {
|
|||
pub children: Vec<TagNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct CollectionSummary {
|
||||
pub collection_uid: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub default_visibility_bits: u32,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub fn find_archive_path_from(start: &Path) -> Result<Option<PathBuf>> {
|
||||
let mut dir = start.to_path_buf();
|
||||
loop {
|
||||
|
|
@ -184,7 +193,7 @@ pub fn initialize_store_directories(store_path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary>> {
|
||||
pub fn list_root_entries(conn: &rusqlite::Connection, caller_bits: u32) -> Result<Vec<EntrySummary>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
e.entry_uid,
|
||||
|
|
@ -203,12 +212,20 @@ pub fn list_root_entries(conn: &rusqlite::Connection) -> Result<Vec<EntrySummary
|
|||
LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id
|
||||
LEFT JOIN blobs b ON b.id = ea.blob_id
|
||||
WHERE e.parent_entry_id IS NULL
|
||||
AND (
|
||||
CAST(?1 AS INTEGER) & 12 != 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM collection_entries ce
|
||||
WHERE ce.entry_id = e.id
|
||||
AND ce.visibility_bits & CAST(?1 AS INTEGER) != 0
|
||||
)
|
||||
)
|
||||
GROUP BY e.id
|
||||
ORDER BY e.archived_at DESC, e.id DESC",
|
||||
)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map([caller_bits as i64], |row| {
|
||||
Ok(EntrySummary {
|
||||
entry_uid: row.get(0)?,
|
||||
archived_at: row.get(1)?,
|
||||
|
|
@ -252,7 +269,7 @@ pub fn get_entry_detail(
|
|||
return Ok(None);
|
||||
};
|
||||
|
||||
let summary = list_root_entries(conn)?
|
||||
let summary = list_root_entries(conn, u32::MAX)?
|
||||
.into_iter()
|
||||
.find(|entry| entry.entry_uid == entry_uid)
|
||||
.context("entry disappeared while loading detail")?;
|
||||
|
|
@ -325,6 +342,95 @@ pub fn get_capture_job(
|
|||
}))
|
||||
}
|
||||
|
||||
/// Lists all collections in the archive.
|
||||
pub fn list_collections(conn: &rusqlite::Connection) -> Result<Vec<CollectionSummary>> {
|
||||
let records = database::list_collections(conn)?;
|
||||
Ok(records
|
||||
.into_iter()
|
||||
.map(|r| CollectionSummary {
|
||||
collection_uid: r.collection_uid,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
default_visibility_bits: r.default_visibility_bits,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Represents an entry's membership in a collection with its visibility bits.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct EntryCollectionMembership {
|
||||
pub collection_uid: String,
|
||||
pub visibility_bits: u32,
|
||||
}
|
||||
|
||||
/// Returns collection memberships for the given entry_uid.
|
||||
/// Returns Ok(None) if the entry_uid does not exist.
|
||||
pub fn get_entry_collections(
|
||||
conn: &rusqlite::Connection,
|
||||
entry_uid: &str,
|
||||
) -> Result<Option<Vec<EntryCollectionMembership>>> {
|
||||
let Some(entry_id) = conn
|
||||
.query_row(
|
||||
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
||||
[entry_uid],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.optional()?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let memberships = database::get_entry_collection_memberships(conn, entry_id)?;
|
||||
Ok(Some(
|
||||
memberships
|
||||
.into_iter()
|
||||
.map(|(_, uid, bits)| EntryCollectionMembership {
|
||||
collection_uid: uid,
|
||||
visibility_bits: bits,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Returns entries belonging to a collection, filtered by caller_bits visibility.
|
||||
/// Caller with admin/owner bits (4|8) sees all entries regardless of visibility_bits.
|
||||
pub fn list_entries_for_collection(
|
||||
conn: &rusqlite::Connection,
|
||||
collection_id: i64,
|
||||
caller_bits: u32,
|
||||
) -> Result<Vec<EntrySummary>> {
|
||||
let sql = format!(
|
||||
"{} {} \
|
||||
JOIN collection_entries ce ON ce.entry_id = e.id \
|
||||
WHERE ce.collection_id = ?1 \
|
||||
AND (CAST(?2 AS INTEGER) & 12 != 0 \
|
||||
OR (ce.visibility_bits & CAST(?2 AS INTEGER)) != 0) \
|
||||
GROUP BY e.id \
|
||||
ORDER BY e.archived_at DESC, e.id DESC",
|
||||
ENTRY_SELECT_COLS,
|
||||
ENTRY_FROM_JOINS,
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries = stmt
|
||||
.query_map([collection_id, caller_bits as i64], |row| {
|
||||
Ok(EntrySummary {
|
||||
entry_uid: row.get(0)?,
|
||||
archived_at: row.get(1)?,
|
||||
source_kind: row.get(2)?,
|
||||
entity_kind: row.get(3)?,
|
||||
title: row.get(4)?,
|
||||
visibility: row.get(5)?,
|
||||
original_url: row.get(6)?,
|
||||
artifact_count: row.get(7)?,
|
||||
total_artifact_bytes: row.get(8)?,
|
||||
parent_entry_uid: row.get(9)?,
|
||||
has_favicon: row.get::<_, i64>(10)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Resolves an artifact to its absolute on-disk path under `store_path`.
|
||||
///
|
||||
/// `artifact.relpath` is a store-relative path (e.g. `raw/a/b/abc.pdf`).
|
||||
|
|
@ -350,7 +456,7 @@ pub fn resolve_artifact_path(
|
|||
Ok(canonical_artifact)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchEntriesQuery {
|
||||
/// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility
|
||||
pub q: Option<String>,
|
||||
|
|
@ -368,6 +474,25 @@ pub struct SearchEntriesQuery {
|
|||
pub before: Option<String>,
|
||||
/// Tag full_path filter; includes all entries (root + child) matching the tag subtree
|
||||
pub tag: Option<String>,
|
||||
/// Role bits of the caller for visibility filtering. Admins (bits 4/8) bypass all filters.
|
||||
/// Pass `u32::MAX` internally to bypass all visibility. Pass 0 for unauthenticated guests only.
|
||||
pub caller_bits: u32,
|
||||
}
|
||||
|
||||
impl Default for SearchEntriesQuery {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
q: None,
|
||||
source_kind: None,
|
||||
entity_kind: None,
|
||||
url: None,
|
||||
title: None,
|
||||
after: None,
|
||||
before: None,
|
||||
tag: None,
|
||||
caller_bits: u32::MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a raw search string into a [`SearchEntriesQuery`].
|
||||
|
|
@ -498,6 +623,15 @@ pub fn search_entries(
|
|||
params.push(b.clone());
|
||||
}
|
||||
|
||||
// Visibility filter
|
||||
let n = params.len() + 1;
|
||||
sql.push_str(&format!(
|
||||
" AND (CAST(?{n} AS INTEGER) & 12 != 0 \
|
||||
OR EXISTS (SELECT 1 FROM collection_entries ce \
|
||||
WHERE ce.entry_id = e.id AND ce.visibility_bits & CAST(?{n} AS INTEGER) != 0))"
|
||||
));
|
||||
params.push(query.caller_bits.to_string());
|
||||
|
||||
sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC");
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
|
|
@ -849,7 +983,7 @@ mod tests {
|
|||
database::complete_archive_run_item(&conn, item.id, entry.id).unwrap();
|
||||
database::finish_archive_run(&conn, run.id).unwrap();
|
||||
|
||||
let entries = list_root_entries(&conn).unwrap();
|
||||
let entries = list_root_entries(&conn, u32::MAX).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].title.as_deref(), Some("Saved Article"));
|
||||
assert_eq!(entries[0].artifact_count, 1);
|
||||
|
|
@ -1020,7 +1154,7 @@ mod tests {
|
|||
#[test]
|
||||
fn search_empty_query_returns_all_root_entries() {
|
||||
let conn = make_test_db_with_entries();
|
||||
let all = list_root_entries(&conn).unwrap();
|
||||
let all = list_root_entries(&conn, u32::MAX).unwrap();
|
||||
let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap();
|
||||
assert_eq!(all.len(), searched.len());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,16 @@ pub struct RoleRecord {
|
|||
pub is_builtin: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CollectionRecord {
|
||||
pub id: i64,
|
||||
pub collection_uid: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub default_visibility_bits: u32,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub fn database_path(archive_path: &Path) -> PathBuf {
|
||||
archive_path.join(DATABASE_FILE_NAME)
|
||||
}
|
||||
|
|
@ -293,6 +303,43 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> {
|
|||
CREATE INDEX IF NOT EXISTS idx_entry_artifacts_blob_id ON entry_artifacts(blob_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_parent_tag_id ON tags(parent_tag_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entry_tag_assignments_tag_id ON entry_tag_assignments(tag_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id INTEGER PRIMARY KEY,
|
||||
collection_uid TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
default_visibility_bits INTEGER NOT NULL DEFAULT 2,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_entries (
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
entry_id INTEGER NOT NULL REFERENCES archived_entries(id) ON DELETE CASCADE,
|
||||
visibility_bits INTEGER NOT NULL DEFAULT 2,
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (collection_id, entry_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_entries_entry_id ON collection_entries(entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_entries_collection_id ON collection_entries(collection_id);
|
||||
|
||||
-- Seed default collection (idempotent)
|
||||
INSERT OR IGNORE INTO collections (collection_uid, name, slug, default_visibility_bits, created_at)
|
||||
VALUES ('coll_default', 'All Entries', '_default_', 2, datetime('now'));
|
||||
|
||||
-- Migrate existing entries to default collection (idempotent)
|
||||
INSERT OR IGNORE INTO collection_entries (collection_id, entry_id, visibility_bits, added_at)
|
||||
SELECT
|
||||
(SELECT id FROM collections WHERE slug = '_default_'),
|
||||
ae.id,
|
||||
CASE ae.visibility
|
||||
WHEN 'public' THEN 3
|
||||
WHEN 'unlisted' THEN 2
|
||||
ELSE 0
|
||||
END,
|
||||
ae.archived_at
|
||||
FROM archived_entries ae;
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
|
|
@ -1146,6 +1193,11 @@ pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<Arch
|
|||
)?;
|
||||
}
|
||||
|
||||
// Auto-enroll in the default collection with appropriate visibility_bits.
|
||||
let default_coll_id = ensure_default_collection(conn)?;
|
||||
let vbits = visibility_to_bits(&entry.visibility);
|
||||
add_entry_to_collection(conn, default_coll_id, id, vbits)?;
|
||||
|
||||
Ok(ArchivedEntry {
|
||||
id,
|
||||
entry_uid,
|
||||
|
|
@ -1396,6 +1448,166 @@ fn refresh_run_counters(conn: &Connection, run_id: i64) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Maps legacy visibility strings to collection_entries.visibility_bits.
|
||||
/// 'public'→3 (guest|user), 'unlisted'→2 (user only), 'private'→0 (nobody).
|
||||
pub fn visibility_to_bits(visibility: &str) -> u32 {
|
||||
match visibility {
|
||||
"public" => 3,
|
||||
"unlisted" => 2,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the id of the '_default_' collection, creating it if absent.
|
||||
pub fn ensure_default_collection(conn: &Connection) -> Result<i64> {
|
||||
let now = now_timestamp();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \
|
||||
VALUES ('coll_default', 'All Entries', '_default_', 2, ?1)",
|
||||
[&now],
|
||||
)?;
|
||||
let id: i64 = conn.query_row(
|
||||
"SELECT id FROM collections WHERE slug = '_default_'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Creates a new collection. Returns the created record.
|
||||
pub fn create_collection(
|
||||
conn: &Connection,
|
||||
name: &str,
|
||||
slug: &str,
|
||||
default_visibility_bits: u32,
|
||||
) -> Result<CollectionRecord> {
|
||||
if slug.is_empty() || slug.starts_with('_') {
|
||||
anyhow::bail!("collection slug must be non-empty and not start with underscore");
|
||||
}
|
||||
let collection_uid = public_id("coll");
|
||||
let now = now_timestamp();
|
||||
conn.execute(
|
||||
"INSERT INTO collections (collection_uid, name, slug, default_visibility_bits, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![collection_uid, name, slug, default_visibility_bits as i64, now],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(CollectionRecord {
|
||||
id,
|
||||
collection_uid,
|
||||
name: name.to_string(),
|
||||
slug: slug.to_string(),
|
||||
default_visibility_bits,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// Lists all collections ordered by creation date.
|
||||
pub fn list_collections(conn: &Connection) -> Result<Vec<CollectionRecord>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \
|
||||
FROM collections ORDER BY created_at ASC",
|
||||
)?;
|
||||
stmt.query_map([], |row| {
|
||||
Ok(CollectionRecord {
|
||||
id: row.get(0)?,
|
||||
collection_uid: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
slug: row.get(3)?,
|
||||
default_visibility_bits: row.get::<_, i64>(4)? as u32,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Returns a collection by its uid, or None if not found.
|
||||
pub fn get_collection_by_uid(
|
||||
conn: &Connection,
|
||||
uid: &str,
|
||||
) -> Result<Option<CollectionRecord>> {
|
||||
conn.query_row(
|
||||
"SELECT id, collection_uid, name, slug, default_visibility_bits, created_at \
|
||||
FROM collections WHERE collection_uid = ?1",
|
||||
[uid],
|
||||
|row| {
|
||||
Ok(CollectionRecord {
|
||||
id: row.get(0)?,
|
||||
collection_uid: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
slug: row.get(3)?,
|
||||
default_visibility_bits: row.get::<_, i64>(4)? as u32,
|
||||
created_at: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Adds an entry to a collection with given visibility_bits. Idempotent (INSERT OR IGNORE).
|
||||
pub fn add_entry_to_collection(
|
||||
conn: &Connection,
|
||||
collection_id: i64,
|
||||
entry_id: i64,
|
||||
visibility_bits: u32,
|
||||
) -> Result<()> {
|
||||
let now = now_timestamp();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO collection_entries (collection_id, entry_id, visibility_bits, added_at) \
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![collection_id, entry_id, visibility_bits as i64, now],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Updates the visibility_bits of an entry in a collection. Returns true if updated.
|
||||
pub fn update_collection_entry_visibility(
|
||||
conn: &Connection,
|
||||
collection_id: i64,
|
||||
entry_id: i64,
|
||||
visibility_bits: u32,
|
||||
) -> Result<bool> {
|
||||
let n = conn.execute(
|
||||
"UPDATE collection_entries SET visibility_bits = ?1 \
|
||||
WHERE collection_id = ?2 AND entry_id = ?3",
|
||||
params![visibility_bits as i64, collection_id, entry_id],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Removes an entry from a collection. Returns true if removed.
|
||||
pub fn remove_entry_from_collection(
|
||||
conn: &Connection,
|
||||
collection_id: i64,
|
||||
entry_id: i64,
|
||||
) -> Result<bool> {
|
||||
let n = conn.execute(
|
||||
"DELETE FROM collection_entries WHERE collection_id = ?1 AND entry_id = ?2",
|
||||
params![collection_id, entry_id],
|
||||
)?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Returns (collection_id, collection_uid, visibility_bits) for all collections containing an entry.
|
||||
pub fn get_entry_collection_memberships(
|
||||
conn: &Connection,
|
||||
entry_id: i64,
|
||||
) -> Result<Vec<(i64, String, u32)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT ce.collection_id, c.collection_uid, ce.visibility_bits \
|
||||
FROM collection_entries ce \
|
||||
JOIN collections c ON c.id = ce.collection_id \
|
||||
WHERE ce.entry_id = ?1",
|
||||
)?;
|
||||
stmt.query_map([entry_id], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get::<_, i64>(2)? as u32))
|
||||
})?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn run_id_for_item(conn: &Connection, item_id: i64) -> Result<i64> {
|
||||
let run_id = conn.query_row(
|
||||
"SELECT run_id FROM archive_run_items WHERE id = ?1",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ axum-extra.workspace = true
|
|||
chrono.workspace = true
|
||||
base64.workspace = true
|
||||
serde_json.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
|
|
|||
|
|
@ -26,15 +26,16 @@ use axum::{
|
|||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
routing::{delete, get, patch, post},
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::registry::{MountedArchive, ServerRegistry};
|
||||
use crate::auth;
|
||||
pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_OWNER, ROLE_USER};
|
||||
pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_GUEST, ROLE_OWNER, ROLE_USER};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
|
|
@ -127,6 +128,17 @@ pub fn app(registry: ServerRegistry, auth_db_path: std::path::PathBuf) -> Router
|
|||
.route("/api/admin/users/:uid/roles", axum::routing::post(admin_assign_role))
|
||||
.route("/api/admin/users/:uid/roles/:role_slug", axum::routing::delete(admin_remove_role))
|
||||
.route("/api/admin/roles", get(admin_list_roles).post(admin_create_role))
|
||||
.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))
|
||||
.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",
|
||||
delete(remove_entry_from_collection_handler)
|
||||
.patch(update_entry_visibility_handler))
|
||||
.route("/api/archives/:archive_id/entries/:entry_uid/collections",
|
||||
get(list_entry_collections_handler))
|
||||
.nest_service("/assets", ServeDir::new(static_dir.join("assets")))
|
||||
.fallback_service(ServeFile::new(static_dir.join("index.html")))
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard))
|
||||
|
|
@ -145,15 +157,18 @@ async fn list_archives(State(state): State<AppState>) -> Json<Vec<MountedArchive
|
|||
|
||||
async fn list_entries(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
Ok(Json(archive::list_root_entries(&conn)?))
|
||||
let caller_bits = auth_to_caller_bits(&auth);
|
||||
Ok(Json(archive::list_root_entries(&conn, caller_bits)?))
|
||||
}
|
||||
|
||||
async fn search_entries_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
Query(params): Query<EntrySearchParams>,
|
||||
) -> Result<Json<Vec<archive::EntrySummary>>, ApiError> {
|
||||
|
|
@ -165,6 +180,7 @@ async fn search_entries_handler(
|
|||
if let Some(tag) = params.tag {
|
||||
search_query.tag = Some(tag);
|
||||
}
|
||||
search_query.caller_bits = auth_to_caller_bits(&auth);
|
||||
Ok(Json(archive::search_entries(&conn, &search_query)?))
|
||||
}
|
||||
|
||||
|
|
@ -278,6 +294,28 @@ struct AssignTagBody {
|
|||
tag_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct CreateCollectionBody {
|
||||
name: String,
|
||||
slug: String,
|
||||
#[serde(default = "default_user_visibility")]
|
||||
default_visibility_bits: u32,
|
||||
}
|
||||
|
||||
fn default_user_visibility() -> u32 { 2 }
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AddEntryBody {
|
||||
entry_uid: String,
|
||||
#[serde(default = "default_user_visibility")]
|
||||
visibility_bits: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct UpdateVisibilityBody {
|
||||
visibility_bits: u32,
|
||||
}
|
||||
|
||||
async fn list_tags(
|
||||
State(state): State<AppState>,
|
||||
Path(archive_id): Path<String>,
|
||||
|
|
@ -698,6 +736,13 @@ async fn admin_create_role(
|
|||
Ok((StatusCode::CREATED, Json(role)))
|
||||
}
|
||||
|
||||
fn auth_to_caller_bits(auth: &AuthUser) -> u32 {
|
||||
match auth {
|
||||
AuthUser::Authenticated { role_bits, .. } => *role_bits,
|
||||
AuthUser::Guest => ROLE_GUEST,
|
||||
}
|
||||
}
|
||||
|
||||
fn mounted_archive<'a>(
|
||||
state: &'a AppState,
|
||||
archive_id: &str,
|
||||
|
|
@ -767,6 +812,156 @@ impl IntoResponse for ApiError {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Collection handlers ────────────────────────────────────────────────────────
|
||||
|
||||
async fn list_collections_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(archive_id): Path<String>,
|
||||
) -> Result<Json<Vec<archive::CollectionSummary>>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
Ok(Json(archive::list_collections(&conn)?))
|
||||
}
|
||||
|
||||
async fn create_collection_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(archive_id): Path<String>,
|
||||
Json(body): Json<CreateCollectionBody>,
|
||||
) -> Result<(StatusCode, Json<archive::CollectionSummary>), ApiError> {
|
||||
auth.require_role(ROLE_USER)?;
|
||||
if body.name.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("collection name must not be empty"));
|
||||
}
|
||||
if body.slug.trim().is_empty() || body.slug.starts_with('_') {
|
||||
return Err(ApiError::bad_request("collection slug must not be empty or start with underscore"));
|
||||
}
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let record = database::create_collection(&conn, &body.name, &body.slug, body.default_visibility_bits)
|
||||
.map_err(|e| ApiError::bad_request(&format!("{e:#}")))?;
|
||||
Ok((StatusCode::CREATED, Json(archive::CollectionSummary {
|
||||
collection_uid: record.collection_uid,
|
||||
name: record.name,
|
||||
slug: record.slug,
|
||||
default_visibility_bits: record.default_visibility_bits,
|
||||
created_at: record.created_at,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_collection_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((archive_id, coll_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let record = database::get_collection_by_uid(&conn, &coll_uid)?
|
||||
.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)?;
|
||||
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,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn add_entry_to_collection_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((archive_id, coll_uid)): Path<(String, String)>,
|
||||
Json(body): Json<AddEntryBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
auth.require_role(ROLE_USER)?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let coll = database::get_collection_by_uid(&conn, &coll_uid)?
|
||||
.ok_or(ApiError::not_found("collection not found"))?;
|
||||
if coll.slug == "_default_" {
|
||||
return Err(ApiError::bad_request("cannot manually add entries to the default collection"));
|
||||
}
|
||||
let entry_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
||||
[body.entry_uid.as_str()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(ApiError::not_found("entry not found"))?;
|
||||
database::add_entry_to_collection(&conn, coll.id, entry_id, body.visibility_bits)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn remove_entry_from_collection_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((archive_id, coll_uid, entry_uid)): Path<(String, String, String)>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
auth.require_role(ROLE_USER)?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let coll = database::get_collection_by_uid(&conn, &coll_uid)?
|
||||
.ok_or(ApiError::not_found("collection not found"))?;
|
||||
if coll.slug == "_default_" {
|
||||
return Err(ApiError::bad_request("cannot manually remove entries from the default collection"));
|
||||
}
|
||||
let entry_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
||||
[entry_uid.as_str()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(ApiError::not_found("entry not found"))?;
|
||||
if database::remove_entry_from_collection(&conn, coll.id, entry_id)? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(ApiError::not_found("entry not in collection"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_entry_visibility_handler(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path((archive_id, coll_uid, entry_uid)): Path<(String, String, String)>,
|
||||
Json(body): Json<UpdateVisibilityBody>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
auth.require_role(ROLE_USER)?;
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
let coll = database::get_collection_by_uid(&conn, &coll_uid)?
|
||||
.ok_or(ApiError::not_found("collection not found"))?;
|
||||
let entry_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT id FROM archived_entries WHERE entry_uid = ?1",
|
||||
[entry_uid.as_str()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(ApiError::not_found("entry not found"))?;
|
||||
if database::update_collection_entry_visibility(&conn, coll.id, entry_id, body.visibility_bits)? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(ApiError::not_found("entry not in collection"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_entry_collections_handler(
|
||||
State(state): State<AppState>,
|
||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||
) -> Result<Json<Vec<archive::EntryCollectionMembership>>, ApiError> {
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||
match archive::get_entry_collections(&conn, &entry_uid)? {
|
||||
Some(memberships) => Ok(Json(memberships)),
|
||||
None => Err(ApiError::not_found("entry not found")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1336,11 +1531,12 @@ mod tests {
|
|||
.unwrap();
|
||||
assert_eq!(assign_resp.status(), StatusCode::CREATED, "assign tag should return 201");
|
||||
|
||||
// Search with ?tag=/science — entry should appear
|
||||
// Search with ?tag=/science — entry should appear (requires auth since entry is private)
|
||||
let response = app(registry.clone(), auth_path.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives/test/entries/search?tag=%2Fscience")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
@ -1360,6 +1556,7 @@ mod tests {
|
|||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/archives/test/entries/search?tag=%2Fart")
|
||||
.header("cookie", &session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
|
|
|||
40
crates/archivr-server/static/assets/index-BfwsUa2j.js
Normal file
40
crates/archivr-server/static/assets/index-BfwsUa2j.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-Dfnwsgsa.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BfwsUa2j.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import EntriesView from './components/EntriesView'
|
|||
import RunsView from './components/RunsView'
|
||||
import AdminView from './components/AdminView'
|
||||
import TagsView from './components/TagsView'
|
||||
import CollectionsView from './components/CollectionsView'
|
||||
import ContextRail from './components/ContextRail'
|
||||
|
||||
export const AuthContext = createContext(null);
|
||||
|
|
@ -213,6 +214,9 @@ export default function App() {
|
|||
onViewChange={handleViewChange}
|
||||
/>
|
||||
)}
|
||||
{view === 'collections' && (
|
||||
<CollectionsView archiveId={archiveId} />
|
||||
)}
|
||||
</div>
|
||||
<ContextRail
|
||||
archiveId={archiveId}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,67 @@ export async function createRole(slug, name) {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
// ── Collection helpers ─────────────────────────────────────────────────────
|
||||
|
||||
export async function listCollections(archiveId) {
|
||||
return getJson(`/api/archives/${archiveId}/collections`);
|
||||
}
|
||||
|
||||
export async function createCollection(archiveId, name, slug, defaultVisibilityBits = 2) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name, slug, default_visibility_bits: defaultVisibilityBits }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getCollection(archiveId, collUid) {
|
||||
return getJson(`/api/archives/${archiveId}/collections/${collUid}`);
|
||||
}
|
||||
|
||||
export async function addEntryToCollection(archiveId, collUid, entryUid, visibilityBits = 2) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ entry_uid: entryUid, visibility_bits: visibilityBits }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeEntryFromCollection(archiveId, collUid, entryUid) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEntryVisibility(archiveId, collUid, entryUid, visibilityBits) {
|
||||
const res = await fetch(`/api/archives/${archiveId}/collections/${collUid}/entries/${entryUid}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ visibility_bits: visibilityBits }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listEntryCollections(archiveId, entryUid) {
|
||||
return getJson(`/api/archives/${archiveId}/entries/${entryUid}/collections`);
|
||||
}
|
||||
|
||||
// ── 401 interceptor ───────────────────────────────────────────────────────────
|
||||
const _origFetch = window.fetch;
|
||||
window.fetch = async (...args) => {
|
||||
|
|
|
|||
165
frontend/src/components/CollectionsView.jsx
Normal file
165
frontend/src/components/CollectionsView.jsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { listCollections, createCollection, getCollection } from '../api.js'
|
||||
|
||||
const VIS_LABELS = { 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }
|
||||
|
||||
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 [collDetail, setCollDetail] = useState(null)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
// Create form
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newSlug, setNewSlug] = useState('')
|
||||
const [newVis, setNewVis] = useState(2)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!archiveId) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const cols = await listCollections(archiveId)
|
||||
setCollections(cols)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [archiveId])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedColl) { setCollDetail(null); return }
|
||||
setDetailLoading(true)
|
||||
getCollection(archiveId, selectedColl.collection_uid)
|
||||
.then(d => setCollDetail(d))
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setDetailLoading(false))
|
||||
}, [selectedColl, archiveId])
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
const name = newName.trim()
|
||||
const slug = newSlug.trim()
|
||||
if (!name || !slug) return
|
||||
setCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
await createCollection(archiveId, name, slug, newVis)
|
||||
setNewName('')
|
||||
setNewSlug('')
|
||||
setNewVis(2)
|
||||
await refresh()
|
||||
} catch (err) {
|
||||
setCreateError(err.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!archiveId) return <div className="view-placeholder">Select an archive.</div>
|
||||
|
||||
return (
|
||||
<div className="collections-view">
|
||||
<h2 className="view-heading">Collections</h2>
|
||||
|
||||
{loading && <div className="muted">Loading…</div>}
|
||||
{error && <div className="error-text">{error}</div>}
|
||||
|
||||
<div className="collections-layout">
|
||||
<div className="collections-list">
|
||||
{collections.map(c => (
|
||||
<div
|
||||
key={c.collection_uid}
|
||||
className={`collection-row${selectedColl?.collection_uid === c.collection_uid ? ' is-selected' : ''}`}
|
||||
onClick={() => setSelectedColl(c)}
|
||||
>
|
||||
<span className="collection-name">{c.name}</span>
|
||||
<span className="muted" style={{ fontSize: '0.8em' }}>
|
||||
{VIS_LABELS[c.default_visibility_bits] ?? c.default_visibility_bits}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{collections.length === 0 && !loading && (
|
||||
<div className="muted">No collections yet.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedColl && (
|
||||
<div className="collection-detail">
|
||||
<h3 className="collection-detail-name">{selectedColl.name}</h3>
|
||||
<div className="muted" style={{ marginBottom: '0.75rem' }}>
|
||||
Default visibility: {VIS_LABELS[selectedColl.default_visibility_bits] ?? selectedColl.default_visibility_bits}
|
||||
</div>
|
||||
{detailLoading ? (
|
||||
<div className="muted">Loading entries…</div>
|
||||
) : collDetail ? (
|
||||
collDetail.entries.length === 0 ? (
|
||||
<div className="muted">No entries visible to you in this collection.</div>
|
||||
) : (
|
||||
<ul className="collection-entries-list">
|
||||
{collDetail.entries.map(entry => (
|
||||
<li key={entry.entry_uid} className="collection-entry-item">
|
||||
<span className="entry-title">
|
||||
{entry.title || entry.entry_uid}
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: '0.8em' }}>
|
||||
{entry.source_kind}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="create-collection-form" style={{ marginTop: '1.5rem' }}>
|
||||
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>Create collection</summary>
|
||||
<form onSubmit={handleCreate} style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', maxWidth: 400 }}>
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
className="form-input"
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={e => { setNewName(e.target.value); if (!newSlug) setNewSlug(e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')) }}
|
||||
placeholder="My Collection"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
<input
|
||||
className="form-input"
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={e => setNewSlug(e.target.value)}
|
||||
placeholder="my-collection"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Default visibility
|
||||
<select className="form-input" value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
|
||||
<option value={0}>Private (admin/owner only)</option>
|
||||
<option value={2}>Users only (logged in)</option>
|
||||
<option value={3}>Public</option>
|
||||
</select>
|
||||
</label>
|
||||
{createError && <div className="muted" style={{ color: 'var(--accent)' }}>{createError}</div>}
|
||||
<button className="btn" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag } from '../api'
|
||||
import { fetchEntryDetail, fetchEntryTags, assignTag, removeTag, listEntryCollections } from '../api'
|
||||
import { formatTimestamp, formatBytes, valueText } from '../utils'
|
||||
|
||||
export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet, tagNodes, onTagsRefresh }) {
|
||||
const [detail, setDetail] = useState(null)
|
||||
const [tags, setTags] = useState([])
|
||||
const [assignInput, setAssignInput] = useState('')
|
||||
const [entryCollections, setEntryCollections] = useState([])
|
||||
const [assignError, setAssignError] = useState('')
|
||||
const selectSeqRef = useRef(0)
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
if (!selectedEntry || !archiveId) {
|
||||
setDetail(null)
|
||||
setTags([])
|
||||
setEntryCollections([])
|
||||
return
|
||||
}
|
||||
const seq = ++selectSeqRef.current
|
||||
|
|
@ -21,10 +23,12 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
Promise.all([
|
||||
fetchEntryDetail(archiveId, selectedEntry.entry_uid),
|
||||
fetchEntryTags(archiveId, selectedEntry.entry_uid),
|
||||
]).then(([det, tgs]) => {
|
||||
listEntryCollections(archiveId, selectedEntry.entry_uid),
|
||||
]).then(([det, tgs, ecs]) => {
|
||||
if (seq !== selectSeqRef.current) return
|
||||
setDetail(det)
|
||||
setTags(tgs)
|
||||
setEntryCollections(ecs)
|
||||
}).catch(() => {})
|
||||
}, [selectedEntry, archiveId])
|
||||
|
||||
|
|
@ -156,6 +160,19 @@ export default function ContextRail({ archiveId, selectedEntry, onTagFilterSet,
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedEntry && entryCollections.length > 0 && (
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">Collections</div>
|
||||
{entryCollections.map(c => (
|
||||
<div key={c.collection_uid} className="rail-item">
|
||||
<span className="rail-label">{c.collection_uid}</span>:{' '}
|
||||
<span className="muted">
|
||||
{{ 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
|||
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select>
|
||||
<nav className="nav" aria-label="Primary">
|
||||
{['archive', 'runs', 'admin', 'tags'].map(name => (
|
||||
{['archive', 'runs', 'admin', 'tags', 'collections'].map(name => (
|
||||
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
||||
onClick={() => onViewChange(name)}>
|
||||
{name.charAt(0).toUpperCase() + name.slice(1)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue