1
Fork 0
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:
TheGeneralist 2026-07-24 20:16:17 +02:00 • committed by GitHub
parent 1af920eb63
commit e1ee05bd41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1218 additions and 185 deletions

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, useMemo, createContext } from 'react'
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail } from './api'
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail, listCollections } from './api'
import LoginPage from './components/LoginPage.jsx'
import SetupPage from './components/SetupPage.jsx'
@ -20,15 +20,15 @@ import ToastStack from './components/ToastStack'
export const AuthContext = createContext(null);
const VIEWS = ['archive','tags','collections','runs','admin','settings']
const SETTINGS_TABS = ['profile','tokens','instance','cookies','extensions','storage']
// Detect /preview/:archiveId/:entryUid at load time (static — no navigation)
const PREVIEW_ROUTE = (() => {
const m = window.location.pathname.match(/^\/preview\/([^/]+)\/([^/]+)/)
return m ? { archiveId: m[1], entryUid: m[2] } : null
})()
const VIEWS = ['archive','tags','collections','runs','admin','settings']
const SETTINGS_TABS = ['profile','tokens','instance','cookies','extensions','storage']
function parseLocation() {
const parts = window.location.pathname.split('/').filter(Boolean)
const view = VIEWS.includes(parts[0]) ? parts[0] : 'archive'
@ -37,7 +37,14 @@ function parseLocation() {
const q = params.get('q') ?? ''
const tag = view === 'archive' ? (params.get('tag') ?? null) : null
const entry = view === 'archive' ? (params.get('entry') ?? null) : null
return { view, settingsTab, q, tag, entry }
// 'main' is the user-facing alias for the default collection; normalize to null
// so the collection dropdown shows "All entries" and no ?collection param is needed.
const rawColl = view === 'archive' ? (params.get('collection') ?? null) : null
const collection = rawColl === 'main' ? null : rawColl
// ?archive=<id> pins a specific archive for public-collection sharing in multi-archive
// setups; without it the bootstrap tries archives[0] which may be the wrong one.
const archive = params.get('archive') ?? null
return { view, settingsTab, q, tag, entry, collection, archive }
}
function locationPath(view, settingsTab) {
@ -55,22 +62,48 @@ export default function App() {
const needsSetup = await checkSetup();
if (needsSetup) { setAuthState('setup'); return; }
const user = await fetchMe();
if (!user) { setAuthState('login'); return; }
if (!user) {
// Before showing login: check whether the active collection is publicly accessible.
// fetchArchives is unauthenticated. ?archive=<id> pins the archive for multi-archive
// setups; without it we try archives[0] (works for the common single-archive case).
const { collection, archive: archiveParam } = parseLocation();
try {
const archiveList = await fetchArchives();
const aid = archiveParam
? (archiveList.find(a => a.id === archiveParam)?.id ?? archiveList[0]?.id)
: archiveList[0]?.id;
if (aid) {
await fetchEntries(aid, collection); // 401 if collection requires auth
setArchives(archiveList);
setArchiveId(aid);
setSelectedCollectionUid(collection);
setAuthState('authenticated');
return;
}
} catch { /* not public or error — fall through to login */ }
setAuthState('login');
return;
}
setCurrentUser(user);
setAuthState('authenticated');
})();
}, []);
// Suppress auth:expired redirect when browsing as a public guest (no currentUser).
// Without this, incidental 401s on auth-required tabs would kick guests to login.
useEffect(() => {
const handler = () => { setCurrentUser(null); setAuthState('login'); };
const handler = () => {
if (!currentUser) return;
setCurrentUser(null); setAuthState('login');
};
window.addEventListener('auth:expired', handler);
return () => window.removeEventListener('auth:expired', handler);
}, []);
}, [currentUser]);
// Sync URL → state on back/forward
useEffect(() => {
const handler = () => {
const { view, settingsTab, q, tag, entry } = parseLocation()
const { view, settingsTab, q, tag, entry, collection } = parseLocation()
setView(view)
setSettingsTab(settingsTab)
setSearchQuery(q)
@ -78,6 +111,7 @@ export default function App() {
setSelectedEntryUid(entry)
setSelectedEntry(null)
setSelectedUids(entry ? new Set([entry]) : new Set())
setSelectedCollectionUid(collection)
}
window.addEventListener('popstate', handler)
return () => window.removeEventListener('popstate', handler)
@ -85,6 +119,8 @@ export default function App() {
const [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null)
const [selectedCollectionUid, setSelectedCollectionUid] = useState(() => parseLocation().collection)
const [collections, setCollections] = useState([])
const [entries, setEntries] = useState([])
const [deletedUids, setDeletedUids] = useState(() => new Set())
const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry)
@ -140,7 +176,8 @@ export default function App() {
const humanizeTags = currentUser?.humanize_slugs ?? false;
// Fetch entry detail whenever selected entry changes
// Fetch entry detail whenever selected entry changes.
// The backend gates by public accessibility, so guests get detail for public entries too.
useEffect(() => {
const seq = ++detailSeqRef.current
setEntryDetail(null)
@ -156,15 +193,15 @@ export default function App() {
sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
}, [captureDialogOpen])
const loadEntries = useCallback(async (aid, q, tag) => {
const loadEntries = useCallback(async (aid, q, tag, collUid) => {
if (!aid) return
setSearchBusy(true)
try {
let results
if (q || tag) {
results = await searchEntries(aid, q, tag)
results = await searchEntries(aid, q, tag, collUid)
} else {
results = await fetchEntries(aid)
results = await fetchEntries(aid, collUid)
}
setEntries(results)
// Prune multi-selection to only entries still visible after load.
@ -184,51 +221,57 @@ export default function App() {
}
}, [])
// Load archives once authenticated (re-runs when authState changes so
// it triggers correctly after first login or a session refresh).
// Load archives once authenticated. Public-session auth also pre-sets archiveId in the auth
// useEffect; this effect handles the normal authenticated path and page refreshes.
useEffect(() => {
if (authState !== 'authenticated') return
if (archiveId) return // already set (public-session path set it synchronously)
fetchArchives().then(list => {
setArchives(list)
if (list.length > 0) {
const first = list[0].id
setArchiveId(first)
const { archive: archiveParam } = parseLocation()
const preferred = archiveParam ? list.find(a => a.id === archiveParam) : null
setArchiveId(preferred?.id ?? list[0].id)
}
})
}, [authState])
}, [authState, archiveId])
// Archive change: parallel load entries + runs + tags
// Archive change: load entries + runs/tags (runs/tags skipped for public guests).
useEffect(() => {
if (!archiveId) return
const isGuest = !currentUser
if (firstArchiveLoad.current) {
// First load: URL-initialized filters are already in state; the debounced
// search and tagFilter effects will call loadEntries with the right values.
firstArchiveLoad.current = false
Promise.all([
fetchRuns(archiveId).then(setRuns),
fetchTags(archiveId).then(setTagNodes),
])
const tasks = []
if (!isGuest) {
tasks.push(fetchRuns(archiveId).then(setRuns))
tasks.push(fetchTags(archiveId).then(setTagNodes))
}
if (tasks.length) Promise.all(tasks)
return
}
setTagFilter(null)
setSelectedEntry(null)
setSelectedEntryUid(null)
setSelectedUids(new Set())
Promise.all([
loadEntries(archiveId, '', null),
fetchRuns(archiveId).then(setRuns),
fetchTags(archiveId).then(setTagNodes),
])
}, [archiveId]) // intentionally not including loadEntries to avoid re-running on its recreation
setSelectedCollectionUid(null)
const tasks = [loadEntries(archiveId, '', null, null)]
if (!isGuest) {
tasks.push(fetchRuns(archiveId).then(setRuns))
tasks.push(fetchTags(archiveId).then(setTagNodes))
}
Promise.all(tasks)
}, [archiveId, currentUser]) // currentUser distinguishes guest vs authenticated
// Debounced search
// Debounced search — scoped to active collection
useEffect(() => {
if (archiveId === null) return
const timer = setTimeout(() => {
loadEntries(archiveId, searchQuery, tagFilter)
loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, archiveId]) // tagFilter handled separately below
}, [searchQuery, archiveId, selectedCollectionUid])
// Tag filter applied: switch to archive view and reload.
// Only reset view when tagFilter is non-null; archive change alone (tagFilter=null)
@ -236,13 +279,47 @@ export default function App() {
useEffect(() => {
if (archiveId === null) return
if (tagFilter !== null) setView('archive')
loadEntries(archiveId, searchQuery, tagFilter)
}, [tagFilter, archiveId]) // intentional: searchQuery excluded to avoid double-fire
loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid)
}, [tagFilter, archiveId, selectedCollectionUid])
const handleArchiveChange = useCallback((id) => {
setArchiveId(id)
setSelectedCollectionUid(null)
// Update URL so refresh/share reopens the correct archive.
const params = new URLSearchParams(window.location.search)
params.set('archive', id)
params.delete('collection')
const qs = params.toString()
history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''))
}, [])
// Load collection list for the switcher — listCollections is now public so guests get it too.
useEffect(() => {
if (!archiveId) { setCollections([]); return }
listCollections(archiveId).then(setCollections).catch(() => setCollections([]))
}, [archiveId])
const handleCollectionChange = useCallback((uid) => {
// Resolve which collection record applies: named uid or the _default_ collection.
// If the guest would land on an auth-required collection, redirect to login.
if (!currentUser) {
const coll = uid
? collections.find(c => c.collection_uid === uid)
: collections.find(c => c.slug === '_default_')
if (coll?.requires_auth) { setAuthState('login'); return }
}
setSelectedCollectionUid(uid)
setSelectedEntry(null)
setSelectedEntryUid(null)
setSelectedUids(new Set())
loadEntries(archiveId, searchQuery, tagFilter, uid)
const params = new URLSearchParams(window.location.search)
if (uid) { params.set('collection', uid); params.set('archive', archiveId) }
else { params.delete('collection'); if (archiveId) params.set('archive', archiveId) }
const qs = params.toString()
history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''))
}, [archiveId, searchQuery, tagFilter, currentUser, collections, loadEntries])
const handleViewChange = useCallback((name) => {
setView(name)
if (name === 'tags' && archiveId) {
@ -380,8 +457,8 @@ export default function App() {
setSelectedEntryUid(prev => prev === entryUid ? null : prev)
setSelectedUids(prev => { const n = new Set(prev); n.delete(entryUid); return n })
// Child delete: parent row's child_count/size are stale — reload after state updates.
if (!isRoot) loadEntries(archiveId, searchQuery, tagFilter)
}, [entries, archiveId, searchQuery, tagFilter, loadEntries])
if (!isRoot) loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid)
}, [entries, archiveId, searchQuery, tagFilter, selectedCollectionUid, loadEntries])
const handleBulkDeleted = useCallback((uids) => {
const rootUids = new Set(entries.map(e => e.entry_uid))
@ -391,8 +468,8 @@ export default function App() {
setSelectedUids(new Set())
setSelectedEntry(null)
setSelectedEntryUid(null)
if (hasChildDelete) loadEntries(archiveId, searchQuery, tagFilter)
}, [entries, archiveId, searchQuery, tagFilter, loadEntries])
if (hasChildDelete) loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid)
}, [entries, archiveId, searchQuery, tagFilter, selectedCollectionUid, loadEntries])
// Auto-snap: drive selectedEntryUid from selectedUids so URL sync and detail
// panel stay correct. size >= 2 clears single-entry state (bulk panel takes over).
@ -424,17 +501,27 @@ export default function App() {
}, [entries, selectedEntryUid, selectedEntry])
// Sync search params → URL via replaceState (no new history entry).
// ?archive=<id> is preserved whenever it was already in the URL (sticky) so that
// multi-archive public links (?archive=other) survive navigation to the default collection.
useEffect(() => {
if (PREVIEW_ROUTE) return
const existingArchive = new URLSearchParams(window.location.search).get('archive')
const params = new URLSearchParams()
if (searchQuery) params.set('q', searchQuery)
if (view === 'archive' && tagFilter) params.set('tag', tagFilter)
if (view === 'archive' && selectedEntryUid) params.set('entry', selectedEntryUid)
if (view === 'archive' && selectedCollectionUid) {
params.set('collection', selectedCollectionUid)
if (archiveId) params.set('archive', archiveId)
} else if (existingArchive && archiveId) {
// Preserve ?archive when already in the URL (e.g. default collection public link)
params.set('archive', archiveId)
}
const qs = params.toString()
const url = window.location.pathname + (qs ? '?' + qs : '')
const current = window.location.pathname + window.location.search
if (current !== url) history.replaceState(null, '', url)
}, [searchQuery, tagFilter, selectedEntryUid, view])
}, [searchQuery, tagFilter, selectedEntryUid, selectedCollectionUid, archiveId, view])
// ⌘K / Ctrl+K / /: focus the search input, switching to archive view first if needed.
useEffect(() => {
@ -533,10 +620,10 @@ export default function App() {
const handleCaptured = useCallback(() => {
if (!archiveId) return
return Promise.allSettled([
loadEntries(archiveId, searchQuery, tagFilter),
loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid),
fetchRuns(archiveId).then(setRuns),
])
}, [archiveId, searchQuery, tagFilter, loadEntries])
}, [archiveId, searchQuery, tagFilter, selectedCollectionUid, loadEntries])
const handleToast = useCallback((text, locator, type = 'error', headline = null) => {
// Only suppress per-item ublock/cookie warnings (those carry a locator).
@ -604,10 +691,10 @@ export default function App() {
return () => document.body.classList.remove('has-audio-bar')
}, [currentAudio])
if (PREVIEW_ROUTE) return <PreviewPage archiveId={PREVIEW_ROUTE.archiveId} entryUid={PREVIEW_ROUTE.entryUid} />;
if (authState === 'loading') return <div className="auth-loading">Loading\u2026</div>;
if (authState === 'setup') return <SetupPage onComplete={() => setAuthState('login')} />;
if (authState === 'login') return <LoginPage onLogin={user => { setCurrentUser(user); setAuthState('authenticated'); }} />;
if (PREVIEW_ROUTE) return <PreviewPage archiveId={PREVIEW_ROUTE.archiveId} entryUid={PREVIEW_ROUTE.entryUid} />;
return (
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
@ -619,6 +706,11 @@ export default function App() {
view={view}
onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick}
collections={collections}
selectedCollectionUid={selectedCollectionUid}
onCollectionChange={handleCollectionChange}
isPublicSession={!currentUser}
onSignInClick={() => setAuthState('login')}
/>
<main className="app-shell">
<div className="workspace">
@ -660,6 +752,7 @@ export default function App() {
archiveId={archiveId}
pendingCaptures={pendingCaptures}
deletedUids={deletedUids}
isPublicSession={!currentUser}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}
@ -700,6 +793,7 @@ export default function App() {
onDetailRefresh={handleDetailRefresh}
onOpenPreview={handleOpenPreview}
onPlay={handlePlay}
isPublicSession={!currentUser}
/>
</main>
{previewEntryUid && selectedEntry && selectedEntry.entry_uid === previewEntryUid && (

View file

@ -10,14 +10,18 @@ export async function fetchArchives() {
return getJson("/api/archives");
}
export async function fetchEntries(archiveId) {
return getJson(`/api/archives/${archiveId}/entries`);
export async function fetchEntries(archiveId, collectionUid = null) {
const url = collectionUid
? `/api/archives/${archiveId}/entries?collection=${encodeURIComponent(collectionUid)}`
: `/api/archives/${archiveId}/entries`
return getJson(url)
}
export async function searchEntries(archiveId, q, tag) {
export async function searchEntries(archiveId, q, tag, collectionUid = null) {
const params = new URLSearchParams();
if (q) params.set("q", q);
if (tag) params.set("tag", tag);
if (collectionUid) params.set("collection", collectionUid);
return getJson(`/api/archives/${archiveId}/entries/search?${params}`);
}
@ -358,11 +362,11 @@ export async function listCollections(archiveId) {
return getJson(`/api/archives/${archiveId}/collections`);
}
export async function createCollection(archiveId, name, slug, defaultVisibilityBits = 2) {
export async function createCollection(archiveId, name, slug, defaultVisibilityBits = 2, requiresAuth = true) {
const res = await fetch(`/api/archives/${archiveId}/collections`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, slug, default_visibility_bits: defaultVisibilityBits }),
body: JSON.stringify({ name, slug, default_visibility_bits: defaultVisibilityBits, requires_auth: requiresAuth }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));

View file

@ -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'}

View file

@ -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>
)}

View file

@ -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>

View file

@ -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();

View file

@ -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>
);
)
}

View file

@ -128,6 +128,9 @@ select {
pointer-events: none;
}
/* Groups archive + collection switchers as a single grid item */
.switchers { display: flex; gap: 8px; align-items: center; min-width: 0; }
.nav { display: flex; gap: 22px; justify-content: flex-end; min-width: 0; }
.nav-link {
border: 0;
@ -579,7 +582,7 @@ select {
/* collections in rail */
.coll-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
display: flex; align-items: center; justify-content: space-between; gap: 12px; overflow: hidden;
padding: 9px 13px;
background: var(--field);
border: 1px solid var(--line);
@ -588,7 +591,7 @@ select {
transition: border-color .15s ease;
}
.coll-row:hover { border-color: var(--accent); }
.coll-name { font-size: 13px; color: var(--ink); font-weight: 500; }
.coll-name { font-size: 13px; color: var(--ink); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.vis-badge {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; color: var(--muted);
@ -1729,6 +1732,10 @@ select {
}
.coll-add-btn:hover { opacity: 0.85; }
.coll-add-btn:disabled { opacity: 0.45; cursor: default; }
/* ── Public-session context rail summary panel ──────────────────────────── */
.rail-public-summary { padding: 4px 0; }
.rail-public-summary .rail-title { margin: 0 0 10px; }
/* old override — superseded by coll-create-details section below */
/* ── Auth loading state ─────────────────────────────────────────────────── */