1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 11:15:41 +02:00
archivr/frontend/src/components/CollectionsView.jsx
TheGeneralist 4311e85f95
ui: comprehensive UI polish pass
- LoginPage/SetupPage: centered card layout with display font, styled fields and submit button
- Topbar: fix duplicate Settings nav item; add styled user-menu with username + logout button
- RunsView: format ISO timestamps to readable dates, add colored status badges (completed/failed/running)
- AdminView: view-tabs system, styled admin-table, admin-input, status-badge (active/disabled), btn-primary
- SettingsView: replace all inline styles with form-section/form-field/field-input/btn-primary/btn-danger
- CollectionsView: restructure create form with proper field labels and btn-primary
- TagsView: add Tags section heading with separator
- ContextRail: show visibility as human-readable label not raw number; fix assign-error class
- styles.css: add auth-loading, view-tabs, form utilities, btn variants, status badges,
  run-status pills, token-banner/row, checkbox-row, coll-create-form, tag-tree-header CSS
2026-06-28 22:14:08 +02:00

369 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from 'react'
import {
listCollections, createCollection, getCollection,
addEntryToCollection, removeEntryFromCollection,
updateEntryVisibility, updateCollection, deleteCollection,
} from '../api.js'
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 [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('')
const [newSlug, setNewSlug] = useState('')
const [newVis, setNewVis] = useState(2)
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState(null)
// 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)
try {
const cols = await listCollections(archiveId)
setCollections(cols)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}, [archiveId])
const refreshDetail = useCallback(async (uid) => {
if (!uid) { setCollDetail(null); return }
setDetailLoading(true)
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()
const name = newName.trim()
const slug = newSlug.trim()
if (!name || !slug) return
setCreating(true)
setCreateError(null)
try {
const coll = await createCollection(archiveId, name, slug, newVis)
setNewName('')
setNewSlug('')
setNewVis(2)
await refreshList()
setSelectedUid(coll.collection_uid)
} catch (err) {
setCreateError(err.message)
} finally {
setCreating(false)
}
}
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="collections-heading">Collections</h2>
{loading && <div className="muted">Loading</div>}
{error && <div className="collections-error">{error} <button onClick={() => setError(null)} className="coll-dismiss">×</button></div>}
<div className="collections-layout">
{/* Sidebar */}
<div className="collections-sidebar">
{collections.map(c => (
<button
key={c.collection_uid}
className={`coll-sidebar-row${selectedUid === c.collection_uid ? ' is-active' : ''}`}
onClick={() => setSelectedUid(c.collection_uid)}
>
<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" style={{ padding: '8px 12px' }}>No collections yet.</div>
)}
</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)
}}
/>
) : (
<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>
{/* Create form */}
<details className="coll-create-details">
<summary>+ Create collection</summary>
<form className="coll-create-form" onSubmit={handleCreate}>
<div className="form-field">
<label className="form-label" htmlFor="coll-name">Name</label>
<input
className="capture-input"
id="coll-name"
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
/>
</div>
<div className="form-field">
<label className="form-label" htmlFor="coll-slug">Slug</label>
<input
className="capture-input"
id="coll-slug"
type="text"
value={newSlug}
onChange={e => setNewSlug(e.target.value)}
placeholder="my-collection"
required
/>
</div>
<div className="form-field">
<label className="form-label" htmlFor="coll-vis">Default visibility</label>
<select className="capture-input" id="coll-vis" 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>
</div>
{createError && <div className="collections-error">{createError}</div>}
<button className="btn-primary" type="submit" disabled={creating}>
{creating ? 'Creating\u2026' : 'Create collection'}
</button>
</form>
</details>
</div>
)
}