1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(collections): Track 8 — collection rename, delete, add/remove entries, per-entry visibility UI

This commit is contained in:
TheGeneralist 2026-06-26 17:33:58 +02:00
commit 91e73d85e7
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
8 changed files with 719 additions and 66 deletions

View file

@ -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<u32>,
) -> Result<bool> {
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<bool> {
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<i64> {
let run_id = conn.query_row(
"SELECT run_id FROM archive_run_items WHERE id = ?1",

View file

@ -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<String>,
default_visibility_bits: Option<u32>,
}
async fn list_tags(
State(state): State<AppState>,
Path(archive_id): Path<String>,
@ -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<String, u32> = 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<serde_json::Value> = 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<AppState>,
auth: AuthUser,
Path((archive_id, coll_uid)): Path<(String, String)>,
Json(body): Json<PatchCollectionBody>,
) -> 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 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<AppState>,
auth: AuthUser,
Path((archive_id, coll_uid)): Path<(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 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::*;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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-DRRKzKIq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJpQthbx.css">
<script type="module" crossorigin src="/assets/index-C6h0jcH3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D40QFUPh.css">
</head>
<body>
<div id="root"></div>

View file

@ -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) => {

View file

@ -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 <div className="view-placeholder">Select an archive.</div>
return (
<div className="collections-view">
<h2 className="view-heading">Collections</h2>
<h2 className="collections-heading">Collections</h2>
{loading && <div className="muted">Loading</div>}
{error && <div className="error-text">{error}</div>}
{error && <div className="collections-error">{error} <button onClick={() => setError(null)} className="coll-dismiss">×</button></div>}
<div className="collections-layout">
<div className="collections-list">
{/* Sidebar */}
<div className="collections-sidebar">
{collections.map(c => (
<div
<button
key={c.collection_uid}
className={`collection-row${selectedColl?.collection_uid === c.collection_uid ? ' is-selected' : ''}`}
onClick={() => setSelectedColl(c)}
className={`coll-row${selectedUid === c.collection_uid ? ' is-active' : ''}`}
onClick={() => setSelectedUid(c.collection_uid)}
>
<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>
<span className="coll-row-name">{c.name}</span>
<span className="coll-row-meta">{VIS_LABEL(c.default_visibility_bits)}</span>
</button>
))}
{collections.length === 0 && !loading && (
<div className="muted">No collections yet.</div>
<div className="muted" style={{ padding: '8px 12px' }}>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>
{/* Detail pane */}
{selected ? (
<div className="coll-detail">
{/* Header */}
<div className="coll-detail-header">
{renaming ? (
<input
ref={renameRef}
className="coll-rename-input"
value={renameVal}
onChange={e => setRenameVal(e.target.value)}
onBlur={handleRenameCommit}
onKeyDown={e => {
if (e.key === 'Enter') handleRenameCommit()
if (e.key === 'Escape') setRenaming(false)
}}
/>
) : (
<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}
<h3
className={`coll-detail-name${isDefault ? '' : ' coll-detail-name--editable'}`}
title={isDefault ? undefined : 'Click to rename'}
onClick={() => { if (!isDefault) { setRenameVal(selected.name); setRenaming(true) } }}
>
{collDetail?.name ?? selected.name}
{!isDefault && <span className="coll-edit-hint"> </span>}
</h3>
)}
{!isDefault && (
<button className="coll-delete-btn" onClick={handleDelete} title="Delete collection">Delete</button>
)}
</div>
{/* Visibility */}
<div className="coll-detail-vis">
<span className="coll-vis-label">Default visibility</span>
<select
className="coll-vis-select"
value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits}
onChange={e => handleVisChange(Number(e.target.value))}
disabled={isDefault}
>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{/* Entries */}
<div className="coll-entries-section">
<div className="coll-section-heading">Entries</div>
{detailLoading && <div className="muted">Loading</div>}
{detailError && <div className="collections-error">{detailError}</div>}
{!detailLoading && collDetail && (
collDetail.entries.length === 0 ? (
<div className="muted">No entries in this collection.</div>
) : (
<ul className="coll-entries-list">
{collDetail.entries.map(entry => (
<li key={entry.entry_uid} className="coll-entry-row">
<div className="coll-entry-info">
<span className="coll-entry-title">{entry.title || entry.entry_uid}</span>
<span className="coll-entry-kind muted">{entry.source_kind}</span>
</div>
<div className="coll-entry-actions">
<select
className="coll-entry-vis-select"
value={entry.collection_visibility_bits}
onChange={e => handleEntryVisChange(entry.entry_uid, Number(e.target.value))}
>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
{!isDefault && (
<button
className="coll-entry-remove"
onClick={() => handleRemoveEntry(entry.entry_uid)}
title="Remove from collection"
>×</button>
)}
</div>
</li>
))}
</ul>
)
)}
</div>
{/* Add entry (non-default only) */}
{!isDefault && (
<form className="coll-add-entry-form" onSubmit={handleAddEntry}>
<div className="coll-section-heading">Add entry</div>
<div className="coll-add-entry-row">
<input
className="coll-add-entry-input"
type="text"
value={addUid}
onChange={e => setAddUid(e.target.value)}
placeholder="entry_uid"
required
/>
<select
className="coll-vis-select"
value={addVis}
onChange={e => setAddVis(Number(e.target.value))}
>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<button className="coll-add-btn" type="submit" disabled={adding}>
{adding ? '…' : 'Add'}
</button>
</div>
{addError && <div className="collections-error" style={{ marginTop: 4 }}>{addError}</div>}
</form>
)}
</div>
) : (
<div className="coll-detail coll-detail--empty">
<div className="muted">Select a collection to view details.</div>
</div>
)}
</div>
<details className="create-collection-form" style={{ marginTop: '1.5rem' }}>
{/* Create form */}
<details className="coll-create-details" 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"
className="capture-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, '')) }}
@ -138,7 +341,7 @@ export default function CollectionsView({ archiveId }) {
<label>
Slug
<input
className="form-input"
className="capture-input"
type="text"
value={newSlug}
onChange={e => setNewSlug(e.target.value)}
@ -148,14 +351,12 @@ export default function CollectionsView({ archiveId }) {
</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 className="capture-input" style={{ height: 42 }} value={newVis} onChange={e => setNewVis(Number(e.target.value))}>
{VIS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
{createError && <div className="muted" style={{ color: 'var(--accent)' }}>{createError}</div>}
<button className="btn" type="submit" disabled={creating}>
{createError && <div className="collections-error">{createError}</div>}
<button className="capture-submit" type="submit" disabled={creating} style={{ alignSelf: 'flex-start', padding: '8px 20px' }}>
{creating ? 'Creating…' : 'Create'}
</button>
</form>

View file

@ -640,3 +640,291 @@ select {
opacity: 0.45;
cursor: default;
}
/* ── Collections view ─────────────────────────────────────────────────────── */
.collections-view {
padding: 24px;
}
.collections-heading {
margin: 0 0 16px;
font-size: 22px;
font-family: Georgia, "Times New Roman", serif;
}
.collections-error {
color: var(--accent);
font-size: 13px;
margin-bottom: 8px;
}
.coll-dismiss {
background: none;
border: none;
cursor: pointer;
color: var(--accent);
font-size: 16px;
line-height: 1;
padding: 0 4px;
}
.collections-layout {
display: grid;
grid-template-columns: 220px 1fr;
gap: 0;
border: 1px solid var(--line);
min-height: 340px;
}
.collections-sidebar {
border-right: 1px solid var(--line);
overflow-y: auto;
}
.coll-row {
display: flex;
flex-direction: column;
width: 100%;
padding: 10px 14px;
border: none;
border-bottom: 1px solid var(--line-soft);
background: none;
cursor: pointer;
text-align: left;
gap: 2px;
}
.coll-row:hover {
background: var(--paper-2);
}
.coll-row.is-active {
background: var(--paper-2);
border-left: 3px solid var(--accent);
}
.coll-row-name {
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.coll-row-meta {
font-size: 11px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.coll-detail {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.coll-detail--empty {
justify-content: center;
align-items: center;
}
.coll-detail-header {
display: flex;
align-items: center;
gap: 12px;
}
.coll-detail-name {
margin: 0;
font-size: 18px;
font-family: Georgia, "Times New Roman", serif;
flex: 1;
}
.coll-detail-name--editable {
cursor: pointer;
}
.coll-detail-name--editable:hover {
color: var(--link);
}
.coll-edit-hint {
font-size: 14px;
opacity: 0.5;
margin-left: 4px;
}
.coll-rename-input {
flex: 1;
font-size: 16px;
font-family: Georgia, "Times New Roman", serif;
border: 2px solid var(--ink);
padding: 4px 8px;
background: var(--paper-3);
color: var(--ink);
}
.coll-delete-btn {
border: 1px solid var(--accent);
background: none;
color: var(--accent);
padding: 5px 12px;
font-size: 13px;
cursor: pointer;
}
.coll-delete-btn:hover {
background: var(--accent);
color: var(--paper);
}
.coll-detail-vis {
display: flex;
align-items: center;
gap: 10px;
}
.coll-vis-label {
font-size: 13px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.coll-vis-select {
border: 1px solid var(--line);
background: var(--paper-3);
color: var(--ink);
padding: 4px 8px;
font-size: 13px;
}
.coll-section-heading {
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 8px;
}
.coll-entries-section {
flex: 1;
}
.coll-entries-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.coll-entry-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid var(--line-soft);
}
.coll-entry-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.coll-entry-title {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.coll-entry-kind {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.coll-entry-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.coll-entry-vis-select {
border: 1px solid var(--line);
background: var(--paper-3);
color: var(--ink);
padding: 3px 6px;
font-size: 12px;
}
.coll-entry-remove {
border: none;
background: none;
cursor: pointer;
color: var(--muted);
font-size: 18px;
line-height: 1;
padding: 0 4px;
}
.coll-entry-remove:hover {
color: var(--accent);
}
.coll-add-entry-form {
border-top: 1px solid var(--line-soft);
padding-top: 12px;
}
.coll-add-entry-row {
display: flex;
gap: 8px;
align-items: center;
}
.coll-add-entry-input {
flex: 1;
border: 1px solid var(--line);
background: var(--paper-3);
color: var(--ink);
padding: 6px 10px;
font-size: 13px;
min-width: 0;
}
.coll-add-btn {
border: none;
background: var(--ink);
color: var(--paper);
padding: 6px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.coll-add-btn:hover {
opacity: 0.85;
}
.coll-add-btn:disabled {
opacity: 0.45;
cursor: default;
}
.coll-create-details summary {
font-weight: 600;
cursor: pointer;
}