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 && (
+
+ )}
+
+ ) : (
+
+
Select a collection to view details.
)}
-
+ {/* Create form */}
+
Create collection
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 8916da2..d5a2470 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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;
+}