mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat(collections): frontend — CollectionsView, nav, visibility in ContextRail
This commit is contained in:
parent
dd56fc8c70
commit
763cb8e17f
5 changed files with 250 additions and 3 deletions
|
|
@ -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