diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f6f119b..f8f01ab 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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' && ( + + )} ({ 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) => { diff --git a/frontend/src/components/CollectionsView.jsx b/frontend/src/components/CollectionsView.jsx new file mode 100644 index 0000000..bd542e7 --- /dev/null +++ b/frontend/src/components/CollectionsView.jsx @@ -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
Select an archive.
+ + return ( +
+

Collections

+ + {loading &&
Loading…
} + {error &&
{error}
} + +
+
+ {collections.map(c => ( +
setSelectedColl(c)} + > + {c.name} + + {VIS_LABELS[c.default_visibility_bits] ?? c.default_visibility_bits} + +
+ ))} + {collections.length === 0 && !loading && ( +
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.
+ ) : ( +
    + {collDetail.entries.map(entry => ( +
  • + + {entry.title || entry.entry_uid} + + + {entry.source_kind} + +
  • + ))} +
+ ) + ) : null} +
+ )} +
+ +
+ Create collection +
+ + + + {createError &&
{createError}
} + +
+
+
+ ) +} diff --git a/frontend/src/components/ContextRail.jsx b/frontend/src/components/ContextRail.jsx index ff497fc..b94c6a5 100644 --- a/frontend/src/components/ContextRail.jsx +++ b/frontend/src/components/ContextRail.jsx @@ -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, )} + {selectedEntry && entryCollections.length > 0 && ( +
+
Collections
+ {entryCollections.map(c => ( +
+ {c.collection_uid}:{' '} + + {{ 0: 'Private', 1: 'Public', 2: 'Users only', 3: 'Public' }[c.visibility_bits] ?? `bits:${c.visibility_bits}`} + +
+ ))} +
+ )} )} diff --git a/frontend/src/components/Topbar.jsx b/frontend/src/components/Topbar.jsx index fb55ea7..bf6ced4 100644 --- a/frontend/src/components/Topbar.jsx +++ b/frontend/src/components/Topbar.jsx @@ -21,7 +21,7 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV {archives.map(a => )}