mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 21:03:17 +02:00
feat(collections): public collections, per-collection auth, UX improvements (#34)
* feat(core): add requires_auth to collections; include name in entry-collection memberships
- Add `requires_auth INTEGER NOT NULL DEFAULT 1` column to the
collections DDL and as an idempotent ALTER TABLE migration in
initialize_schema (archive DB), not initialize_auth_schema.
- CollectionRecord and CollectionSummary gain `requires_auth: bool`.
- create_collection() and update_collection() accept the new field.
- get_entry_collection_memberships() now returns collection name as the
third tuple element; EntryCollectionMembership gains a `name` field
so the sidebar can show human-readable names instead of raw UIDs.
* feat(server): conditional auth for public collections; add requires_auth + original_url to API
- CreateCollectionBody gains requires_auth (default true).
- PatchCollectionBody gains requires_auth: Option<bool>.
- get_collection_handler: load record first, then skip auth.require_auth()
when record.requires_auth == false so public collections are accessible
to unauthenticated callers; caller_bits falls back to ROLE_GUEST (1)
so only visibility_bits=3 entries are returned to guests.
- Collection JSON response includes requires_auth and each entry now
includes original_url for use by the public collection page.
- list_collections_handler keeps require_auth (management UI).
* feat(frontend): public collection page at /c/:archiveId/:collUid
- Detect PUBLIC_COLL_ROUTE at module load time (like PREVIEW_ROUTE) and
return <PublicCollectionPage> before any auth checks so unauthenticated
users can view public collections without hitting the login gate.
- PublicCollectionPage fetches via getCollection() and renders the
server-filtered entry list (no client-side bitmask filtering - the
server already applies caller_bits=GUEST for unauthenticated requests).
Entry titles link to original_url when present; fall back to plain
text when original_url is null.
- api.js createCollection() gains requiresAuth param (default true),
sent as requires_auth in the request body.
- Storybook story covers WithEntries, Empty, and LoadError states.
* feat(frontend): collections view improvements
- addVis in the 'Add entry' form now syncs to the selected collection's
default_visibility_bits via useEffect on collDetail, so the default
matches the collection's configured entry visibility.
- Rename 'Default visibility' label to 'Entries\' default visibility'
in both the detail pane and the create form to distinguish it from
the new collection-level access setting.
- Add 'Require authentication to view' checkbox in the detail pane
backed by a PATCH to requires_auth; reads collDetail?.requires_auth
with fallback to the list-level selected record.
- Create form gains a matching requires_auth checkbox (default: true),
passed as 5th arg to createCollection().
* feat(frontend): context rail collection improvements
- Show collection name (c.name) instead of raw UID in the sidebar
Collections section; names now come from the updated
EntryCollectionMembership API response.
- Fix horizontal overflow on long collection names: coll-name gains
overflow:hidden + text-overflow:ellipsis + white-space:nowrap +
min-width:0; coll-row gets overflow:hidden.
- Single-entry 'Add to collection' UI: dropdown + button inside the
Collections rail section lets users add the current entry to any
non-default collection without multi-selecting. After add, the
membership list refreshes automatically.
- Collections section now shows even when entryCollections is empty,
as long as non-default collections exist (so the add form is
accessible for un-membered entries).
- Bulk 'Add to collection' now uses the target collection's
default_visibility_bits instead of hardcoded 2 (Users only).
- Both bulk and single-entry dropdowns filter out slug='_default_'
to match the backend rejection in add_entry_to_collection_handler.
- Collections list is now fetched on archiveId change (not just on
bulk mode entry) so it is available for single-entry mode too.
* build(frontend): update static assets
* feat(frontend): public collection link UX + app-styled public page
CollectionsView:
- When a collection has requires_auth=false, show a read-only URL input
and Copy button below the auth checkbox so the public link is
immediately discoverable. The input auto-selects on focus so manual
copy always works. Copy button tries navigator.clipboard.writeText
first; falls back to execCommand('copy') for HTTP deployments where
the Clipboard API is unavailable in non-secure contexts.
PublicCollectionPage:
- Rewritten to use the app's CSS classes and variables instead of
bare inline styles, so it visually matches the main archive UI.
Dark topbar (.pub-coll-topbar) with brand + collection name, paper
background body, entry list via .coll-entries-list / .coll-entry-row /
.coll-entry-info / .coll-entry-kind — the same classes used in the
authenticated Collections view.
styles.css:
- .coll-public-link-row / -wrap / -input / .coll-copy-btn for the
new link field in CollectionsView detail pane.
- .pub-coll-* classes for the public page layout and typography.
* feat(core): add get_collection_by_slug; scope search to active collection
- get_collection_by_slug(): new function mirroring get_collection_by_uid
but matching on slug, used to resolve the _default_ collection when no
?collection param is supplied.
- SearchEntriesQuery gains collection_id: Option<i64>. When set, the
search SQL adds an EXISTS subquery that checks collection_entries cef
for both membership (cef.collection_id = ?) and visibility bits in
that specific collection — preventing cross-collection visibility
leaks where an entry is public in one collection but private in the
current one. Without collection_id the original cross-collection
visibility fallback is kept.
* feat(server): collection-scoped entries/search with uniform auth gate
All entry listing and search now route through the active collection:
list_entries (?collection=<uid>|main|<omitted>):
- Resolves the target collection; omitted or 'main' resolves to _default_.
- Checks requires_auth on that collection; gates auth conditionally.
- Returns list_entries_for_collection() — same EntrySummary shape.
search_entries_handler:
- Same collection resolution + conditional auth as list_entries.
- Sets search_query.collection_id so SQL scopes membership + visibility
to the specific collection, not cross-collection fallback.
list_collections_handler:
- Dropped require_auth() — collection summaries (name/slug/uid/
requires_auth/default_visibility_bits) are public metadata needed for
the guest collection-switcher dropdown.
Tests:
- list_collections_requires_auth → list_collections_is_public (200).
- list_entries_requires_auth and search coverage still pass.
* feat(frontend): integrate collection switching into main Archive view
Replaces the standalone /c/:archiveId/:collUid public page with a
unified main-view approach where all collection logic lives at /.
URL param:
- ?collection=<uid> selects a collection; omitted or 'main' = default.
- 'main' is normalized to null in parseLocation() so the dropdown shows
'All entries' and the URL stays clean.
Collection switcher (Topbar):
- Dropdown always visible (guests need it to navigate public collections).
- Non-default collections only (All entries = no param = _default_).
- Guest selecting an auth-required collection calls onSignInClick().
- handleCollectionChange checks both named and _default_ requires_auth
before proceeding, redirecting guests to login if needed.
listCollections fetched for all users (guests too) since the endpoint
is now public; used to populate the switcher without auth.
Public-session mode (authenticated state, no currentUser):
- Auth gate: fetchArchives() + fetchEntries() with collection param;
401 falls through to login, 200 proceeds as guest.
- auth:expired suppressed when !currentUser.
- fetchEntryDetail skipped; ContextRail shows entry summary + sign-in prompt.
- ContextRail selection effect skips tag/collection API calls.
- runs/tags not fetched in guest mode.
- Child row expansion disabled in EntryRow (hasChildren = false).
api.js:
- fetchEntries/searchEntries both thread ?collection=<uid> to server.
Deleted: PublicCollectionPage.jsx, PublicCollectionPage.stories.jsx,
copy-link UI from CollectionsView, pub-coll-*/copy-link CSS.
* build(frontend): update static assets
* feat(core): add is_entry_publicly_accessible; checks entry+parent vs public collections
* feat(server): allow guests to fetch detail/children/artifacts for public entries
* feat(frontend): guest collection dropdown filtering; public entry detail without auth wall
* build(frontend): update static assets
* test(server): public entry detail/artifact/children contract for guests
* feat(server): filter auth-required collections from guest list_collections response
This commit is contained in:
parent
1af920eb63
commit
e1ee05bd41
16 changed files with 1218 additions and 185 deletions
|
|
@ -26,6 +26,7 @@ export default function CollectionsView({ archiveId }) {
|
|||
const [newSlug, setNewSlug] = useState('')
|
||||
const [newVis, setNewVis] = useState(2)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newRequiresAuth, setNewRequiresAuth] = useState(true)
|
||||
const [createError, setCreateError] = useState(null)
|
||||
|
||||
// Add-entry form
|
||||
|
|
@ -75,6 +76,9 @@ export default function CollectionsView({ archiveId }) {
|
|||
|
||||
// Auto-focus rename input
|
||||
useEffect(() => { if (renaming && renameRef.current) renameRef.current.focus() }, [renaming])
|
||||
useEffect(() => {
|
||||
if (collDetail != null) setAddVis(collDetail.default_visibility_bits)
|
||||
}, [collDetail])
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
|
|
@ -84,10 +88,11 @@ export default function CollectionsView({ archiveId }) {
|
|||
setCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const coll = await createCollection(archiveId, name, slug, newVis)
|
||||
const coll = await createCollection(archiveId, name, slug, newVis, newRequiresAuth)
|
||||
setNewName('')
|
||||
setNewSlug('')
|
||||
setNewVis(2)
|
||||
setNewRequiresAuth(true)
|
||||
await refreshList()
|
||||
setSelectedUid(coll.collection_uid)
|
||||
} catch (err) {
|
||||
|
|
@ -122,6 +127,18 @@ export default function CollectionsView({ archiveId }) {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleAuthChange(val) {
|
||||
if (!selected) return
|
||||
try {
|
||||
await updateCollection(archiveId, selected.collection_uid, { requires_auth: val })
|
||||
await refreshList()
|
||||
setCollDetail(d => d ? { ...d, requires_auth: val } : 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
|
||||
|
|
@ -238,7 +255,7 @@ export default function CollectionsView({ archiveId }) {
|
|||
|
||||
{/* Visibility */}
|
||||
<div className="coll-detail-vis">
|
||||
<span className="coll-vis-label">Default visibility</span>
|
||||
<span className="coll-vis-label">Entries' default visibility</span>
|
||||
<select
|
||||
className="coll-vis-select"
|
||||
value={collDetail?.default_visibility_bits ?? selected.default_visibility_bits}
|
||||
|
|
@ -248,6 +265,19 @@ export default function CollectionsView({ archiveId }) {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{/* Auth requirement */}
|
||||
<div className="coll-detail-vis">
|
||||
<span className="coll-vis-label">Collection access</span>
|
||||
<label className="coll-auth-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!(collDetail?.requires_auth ?? selected.requires_auth)}
|
||||
onChange={e => handleAuthChange(e.target.checked)}
|
||||
/>
|
||||
{' Require authentication to view'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Entries */}
|
||||
<div className="coll-entries-section">
|
||||
<div className="coll-section-heading">Entries</div>
|
||||
|
|
@ -351,12 +381,22 @@ export default function CollectionsView({ archiveId }) {
|
|||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label className="form-label" htmlFor="coll-vis">Default visibility</label>
|
||||
<label className="form-label" htmlFor="coll-vis">Entries' 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>
|
||||
<div className="form-field">
|
||||
<label className="form-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newRequiresAuth}
|
||||
onChange={e => setNewRequiresAuth(e.target.checked)}
|
||||
/>
|
||||
{' Require authentication to view'}
|
||||
</label>
|
||||
</div>
|
||||
{createError && <div className="collections-error">{createError}</div>}
|
||||
<button className="btn-primary" type="submit" disabled={creating}>
|
||||
{creating ? 'Creating\u2026' : 'Create collection'}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const ExternalIcon = () => (
|
|||
</svg>
|
||||
)
|
||||
|
||||
export default function ContextRail({ archiveId, selectedEntry, selectedUids, selectedEntries, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, onBulkDeleted, humanizeTags, onDetailRefresh, onOpenPreview, onPlay }) {
|
||||
export default function ContextRail({ archiveId, selectedEntry, selectedUids, selectedEntries, detail, onTagFilterSet, tagNodes, onTagsRefresh, onEntryTitleChange, onEntryDeleted, onBulkDeleted, humanizeTags, onDetailRefresh, onOpenPreview, onPlay, isPublicSession }) {
|
||||
const [tags, setTags] = useState([])
|
||||
const [assignInput, setAssignInput] = useState('')
|
||||
const [entryCollections, setEntryCollections] = useState([])
|
||||
|
|
@ -36,6 +36,9 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
const [bulkCollState, setBulkCollState] = useState('idle') // 'idle'|'running'|'done'|'error'
|
||||
const [bulkCollError, setBulkCollError] = useState('')
|
||||
const [bulkDeleteState, setBulkDeleteState] = useState('idle') // 'idle'|'running'
|
||||
const [singleCollUid, setSingleCollUid] = useState('')
|
||||
const [singleCollState, setSingleCollState] = useState('idle')
|
||||
const [singleCollError, setSingleCollError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const seq = ++selectSeqRef.current
|
||||
|
|
@ -47,6 +50,12 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
setEntryCollections([])
|
||||
return
|
||||
}
|
||||
// Skip auth-required tag/collection fetches for public guests.
|
||||
if (isPublicSession) {
|
||||
setTags([])
|
||||
setEntryCollections([])
|
||||
return
|
||||
}
|
||||
setEditingTitle(false)
|
||||
setTitleDraft('')
|
||||
titleCancelRef.current = false
|
||||
|
|
@ -59,7 +68,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
setTags(tgs)
|
||||
setEntryCollections(ecs)
|
||||
}).catch(() => {})
|
||||
}, [selectedEntry, archiveId])
|
||||
}, [selectedEntry, archiveId, isPublicSession])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -67,11 +76,11 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Fetch available collections when entering bulk mode
|
||||
// Fetch available collections whenever archiveId is available
|
||||
useEffect(() => {
|
||||
if (!isBulk || !archiveId) { setCollections([]); return }
|
||||
if (!archiveId) { setCollections([]); return }
|
||||
listCollections(archiveId).then(setCollections).catch(() => setCollections([]))
|
||||
}, [isBulk, archiveId])
|
||||
}, [archiveId])
|
||||
|
||||
// Reset transient bulk state when selection changes
|
||||
useEffect(() => {
|
||||
|
|
@ -82,6 +91,9 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
setBulkCollState('idle')
|
||||
setBulkCollError('')
|
||||
setBulkDeleteState('idle')
|
||||
setSingleCollUid('')
|
||||
setSingleCollState('idle')
|
||||
setSingleCollError('')
|
||||
}, [selectedUids])
|
||||
|
||||
async function handleBulkDelete() {
|
||||
|
|
@ -125,9 +137,10 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
setBulkCollState('running')
|
||||
setBulkCollError('')
|
||||
const failed = []
|
||||
const coll = collections.find(c => c.collection_uid === bulkCollUid)
|
||||
for (const uid of selectedUids) {
|
||||
try {
|
||||
await addEntryToCollection(archiveId, bulkCollUid, uid)
|
||||
await addEntryToCollection(archiveId, bulkCollUid, uid, coll?.default_visibility_bits ?? 2)
|
||||
} catch (err) {
|
||||
failed.push(uid)
|
||||
}
|
||||
|
|
@ -141,6 +154,25 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
}
|
||||
}
|
||||
|
||||
async function handleSingleAddToCollection() {
|
||||
if (!singleCollUid || !selectedEntry) return
|
||||
setSingleCollState('running')
|
||||
setSingleCollError('')
|
||||
const coll = collections.find(c => c.collection_uid === singleCollUid)
|
||||
try {
|
||||
await addEntryToCollection(archiveId, singleCollUid, selectedEntry.entry_uid, coll?.default_visibility_bits ?? 2)
|
||||
setSingleCollState('done')
|
||||
setSingleCollUid('')
|
||||
// Refresh collection membership list
|
||||
const updated = await listEntryCollections(archiveId, selectedEntry.entry_uid)
|
||||
setEntryCollections(updated)
|
||||
setTimeout(() => setSingleCollState('idle'), 1800)
|
||||
} catch (err) {
|
||||
setSingleCollError(err.message)
|
||||
setSingleCollState('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTitleSave() {
|
||||
const newTitle = titleDraft.trim() || null
|
||||
try {
|
||||
|
|
@ -262,6 +294,12 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
<div className="rail-eyebrow">Context</div>
|
||||
|
||||
{isBulk ? (
|
||||
isPublicSession ? (
|
||||
<p className="bulk-count">
|
||||
<span className="bulk-count-num">{selectedUids.size}</span>
|
||||
{' entries selected'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="bulk-panel">
|
||||
<p className="bulk-count">
|
||||
<span className="bulk-count-num">{selectedUids.size}</span>
|
||||
|
|
@ -304,7 +342,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
onChange={e => setBulkCollUid(e.target.value)}
|
||||
>
|
||||
<option value="">Pick a collection…</option>
|
||||
{collections.map(c => (
|
||||
{collections.filter(c => c.slug !== '_default_').map(c => (
|
||||
<option key={c.collection_uid} value={c.collection_uid}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -334,13 +372,18 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : !selectedEntry ? (
|
||||
<p className="tags-empty">Select an entry.</p>
|
||||
) : !detail ? (
|
||||
<p className="tags-empty">Loading\u2026</p>
|
||||
) : (
|
||||
<>
|
||||
{editingTitle ? (
|
||||
{isPublicSession ? (
|
||||
<h2 className="rail-title">
|
||||
{valueText(detail.summary.title) || valueText(detail.summary.entry_uid)}
|
||||
</h2>
|
||||
) : editingTitle ? (
|
||||
<input
|
||||
className="rail-title-input"
|
||||
autoFocus
|
||||
|
|
@ -460,8 +503,7 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedEntry && !isBulk && (
|
||||
{selectedEntry && !isBulk && !isPublicSession && (
|
||||
<>
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">Tags</div>
|
||||
|
|
@ -499,17 +541,41 @@ export default function ContextRail({ archiveId, selectedEntry, selectedUids, se
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{entryCollections.length > 0 && (
|
||||
{(entryCollections.length > 0 || collections.filter(c => c.slug !== '_default_').length > 0) && (
|
||||
<div className="rail-section">
|
||||
<div className="rail-section-heading">Collections</div>
|
||||
{entryCollections.map(c => (
|
||||
<div key={c.collection_uid} className="coll-row">
|
||||
<span className="coll-name">{c.collection_uid}</span>
|
||||
<span className="coll-name">{c.name}</span>
|
||||
<span className="vis-badge">
|
||||
{VIS_LABEL[c.visibility_bits] ?? `bits:${c.visibility_bits}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{collections.filter(c => c.slug !== '_default_').length > 0 && (
|
||||
<div className="bulk-coll-row" style={{ marginTop: 8 }}>
|
||||
<select
|
||||
className="bulk-coll-select"
|
||||
value={singleCollUid}
|
||||
onChange={e => setSingleCollUid(e.target.value)}
|
||||
>
|
||||
<option value="">Add to collection…</option>
|
||||
{collections.filter(c => c.slug !== '_default_').map(c => (
|
||||
<option key={c.collection_uid} value={c.collection_uid}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="tag-add-btn"
|
||||
onClick={handleSingleAddToCollection}
|
||||
disabled={!singleCollUid || singleCollState === 'running'}
|
||||
>
|
||||
{singleCollState === 'running' ? '…' : singleCollState === 'done' ? '✓' : singleCollState === 'error' ? '!' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{singleCollError && (
|
||||
<p className="form-msg form-msg--err" style={{ margin: '4px 0 0' }}>{singleCollError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import SkeletonEntryRow from './SkeletonEntryRow';
|
|||
|
||||
import EntryRow from './EntryRow';
|
||||
|
||||
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids }) {
|
||||
export default function EntriesView({ entries, selectedUids, onRowClick, archiveId, pendingCaptures = [], deletedUids, isPublicSession }) {
|
||||
return (
|
||||
<section id="archive-view" className="view is-active">
|
||||
<div className="entry-table">
|
||||
|
|
@ -29,6 +29,7 @@ export default function EntriesView({ entries, selectedUids, onRowClick, archive
|
|||
onRowClick={onRowClick}
|
||||
selectedUids={selectedUids}
|
||||
deletedUids={deletedUids}
|
||||
isPublicSession={isPublicSession}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ function ChildRow({ entry, index, onRowClick, selectedUids }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids }) {
|
||||
export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMultiSelected, onRowClick, selectedUids, deletedUids, isPublicSession }) {
|
||||
const [favFailed, setFavFailed] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState(null);
|
||||
|
|
@ -67,7 +67,7 @@ export default function EntryRow({ entry, archiveId, rowIndex, isSelected, isMul
|
|||
);
|
||||
|
||||
const checked = isSelected || isMultiSelected;
|
||||
const hasChildren = entry.child_count > 0;
|
||||
const hasChildren = entry.child_count > 0 && !isPublicSession;
|
||||
|
||||
function handleCheckboxClick(e) {
|
||||
e.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useContext, useState } from 'react';
|
|||
import { AuthContext } from '../App.jsx';
|
||||
import { logout as apiLogout } from '../api.js';
|
||||
|
||||
export default function Topbar({ archives, archiveId, onArchiveChange, view, onViewChange, onCaptureClick }) {
|
||||
export default function Topbar({ archives, archiveId, onArchiveChange, view, onViewChange, onCaptureClick, collections, selectedCollectionUid, onCollectionChange, isPublicSession, onSignInClick }) {
|
||||
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {};
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
|
||||
|
|
@ -13,32 +13,66 @@ export default function Topbar({ archives, archiveId, onArchiveChange, view, onV
|
|||
window.location.reload();
|
||||
}
|
||||
|
||||
// For guests: hide auth-required collections; "All entries" only if _default_ is public.
|
||||
const defaultColl = collections.find(c => c.slug === '_default_')
|
||||
const showAllEntriesOption = !isPublicSession || (!!defaultColl && !defaultColl.requires_auth)
|
||||
const namedCollections = collections
|
||||
.filter(c => c.slug !== '_default_')
|
||||
.filter(c => !isPublicSession || !c.requires_auth)
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<div className="brand">Archivr</div>
|
||||
<div className="switcher">
|
||||
<select aria-label="Select archive"
|
||||
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
|
||||
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select>
|
||||
<div className="switchers">
|
||||
{!isPublicSession && (
|
||||
<div className="switcher">
|
||||
<select aria-label="Select archive"
|
||||
value={archiveId ?? ''} onChange={e => onArchiveChange(e.target.value)}>
|
||||
{archives.map(a => <option key={a.id} value={a.id}>{a.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{namedCollections.length > 0 && (
|
||||
<div className="switcher">
|
||||
<select
|
||||
aria-label="Select collection"
|
||||
value={selectedCollectionUid ?? ''}
|
||||
onChange={e => onCollectionChange(e.target.value || null)}
|
||||
>
|
||||
{showAllEntriesOption && <option value="">All entries</option>}
|
||||
{namedCollections.map(c => (
|
||||
<option key={c.collection_uid} value={c.collection_uid}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<nav className="nav" aria-label="Primary">
|
||||
{['archive', 'tags', 'collections', 'runs', 'admin', 'settings'].map(name => (
|
||||
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
||||
onClick={() => onViewChange(name)}>
|
||||
{name.charAt(0).toUpperCase() + name.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="capture-button" onClick={onCaptureClick}>Capture</button>
|
||||
{currentUser && (
|
||||
<div className="user-menu">
|
||||
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
|
||||
<button className="logout-btn" onClick={handleLogout} disabled={loggingOut}>
|
||||
{loggingOut ? 'Logging out\u2026' : 'Log out'}
|
||||
</button>
|
||||
</div>
|
||||
{isPublicSession ? (
|
||||
<>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="logout-btn" onClick={onSignInClick}>Sign in</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<nav className="nav" aria-label="Primary">
|
||||
{['archive', 'tags', 'collections', 'runs', 'admin', 'settings'].map(name => (
|
||||
<button key={name} className={`nav-link${view === name ? ' is-active' : ''}`}
|
||||
onClick={() => onViewChange(name)}>
|
||||
{name.charAt(0).toUpperCase() + name.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="capture-button" onClick={onCaptureClick}>Capture</button>
|
||||
{currentUser && (
|
||||
<div className="user-menu">
|
||||
<span className="user-name">{currentUser.display_name || currentUser.username}</span>
|
||||
<button className="logout-btn" onClick={handleLogout} disabled={loggingOut}>
|
||||
{loggingOut ? 'Logging out\u2026' : 'Log out'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue