1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-10-09 21:03:17 +02:00
archivr/frontend/src/App.jsx
TheGeneralist e1ee05bd41
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
2026-07-24 20:16:17 +02:00

829 lines
34 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, useMemo, createContext } from 'react'
import { fetchArchives, fetchEntries, searchEntries, fetchRuns, fetchTags, checkSetup, fetchMe, fetchEntryDetail, listCollections } from './api'
import LoginPage from './components/LoginPage.jsx'
import SetupPage from './components/SetupPage.jsx'
import Topbar from './components/Topbar'
import CaptureDialog from './components/CaptureDialog'
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 SettingsView from './components/SettingsView'
import ContextRail from './components/ContextRail'
import PreviewModal from './components/PreviewModal'
import AudioBar from './components/AudioBar'
import PreviewPage from './components/PreviewPage'
import { displayPath } from './utils'
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
})()
function parseLocation() {
const parts = window.location.pathname.split('/').filter(Boolean)
const view = VIEWS.includes(parts[0]) ? parts[0] : 'archive'
const settingsTab = (view === 'settings' && SETTINGS_TABS.includes(parts[1])) ? parts[1] : 'profile'
const params = new URLSearchParams(window.location.search)
const q = params.get('q') ?? ''
const tag = view === 'archive' ? (params.get('tag') ?? null) : null
const entry = view === 'archive' ? (params.get('entry') ?? null) : null
// '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) {
if (view === 'archive') return '/'
if (view === 'settings') return `/settings/${settingsTab}`
return `/${view}`
}
export default function App() {
const [authState, setAuthState] = useState('loading');
const [currentUser, setCurrentUser] = useState(null);
useEffect(() => {
(async () => {
const needsSetup = await checkSetup();
if (needsSetup) { setAuthState('setup'); return; }
const user = await fetchMe();
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 = () => {
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, collection } = parseLocation()
setView(view)
setSettingsTab(settingsTab)
setSearchQuery(q)
setTagFilter(tag)
setSelectedEntryUid(entry)
setSelectedEntry(null)
setSelectedUids(entry ? new Set([entry]) : new Set())
setSelectedCollectionUid(collection)
}
window.addEventListener('popstate', handler)
return () => window.removeEventListener('popstate', handler)
}, [])
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)
const [selectedEntry, setSelectedEntry] = useState(null)
const [selectedUids, setSelectedUids] = useState(() => {
const e = parseLocation().entry
return e ? new Set([e]) : new Set()
})
const [tagFilter, setTagFilter] = useState(() => parseLocation().tag)
const [view, setView] = useState(() => parseLocation().view)
const [settingsTab, setSettingsTab] = useState(() => parseLocation().settingsTab)
const [searchQuery, setSearchQuery] = useState(() => parseLocation().q)
const [resultCount, setResultCount] = useState('')
const [searchBusy, setSearchBusy] = useState(false)
const [runs, setRuns] = useState([])
const [tagNodes, setTagNodes] = useState([])
const [captureDialogOpen, setCaptureDialogOpen] = useState(() => {
const saved = sessionStorage.getItem('captureDialogOpen')
return saved === 'true'
})
const [toasts, setToasts] = useState([])
const toastIdRef = useRef(0)
const [pendingCaptures, setPendingCaptures] = useState(() => {
try {
const saved = JSON.parse(sessionStorage.getItem('pendingCaptures') || '[]')
if (Array.isArray(saved)) return saved
} catch {}
return []
})
useEffect(() => {
sessionStorage.setItem('pendingCaptures', JSON.stringify(pendingCaptures))
}, [pendingCaptures])
const [ublockWarningIgnored, setUblockWarningIgnored] = useState(
() => sessionStorage.getItem('ublockWarningIgnored') === 'true'
)
// ── Entry detail (shared between PreviewPanel and ContextRail) ──────────
const [entryDetail, setEntryDetail] = useState(null)
const detailSeqRef = useRef(0)
const searchInputRef = useRef(null)
const pendingSearchFocus = useRef(false)
const firstArchiveLoad = useRef(true)
const lastAnchorIndexRef = useRef(null)
// Monotonic token for j/k keyboard navigation; cancels stale fetchEntryDetail calls.
const jkSeqRef = useRef(0)
// uid → entry object cache; populated on every row click so ctrl/shift
// selections can resolve child entries that aren't in the root entries array.
const entryCacheRef = useRef(new Map())
const humanizeTags = currentUser?.humanize_slugs ?? false;
// 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)
if (!selectedEntry || !archiveId) return
fetchEntryDetail(archiveId, selectedEntry.entry_uid).then(det => {
if (seq !== detailSeqRef.current) return
setEntryDetail(det)
}).catch(() => {})
}, [selectedEntry, archiveId])
// Persist captureDialogOpen to sessionStorage
useEffect(() => {
sessionStorage.setItem('captureDialogOpen', captureDialogOpen)
}, [captureDialogOpen])
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, collUid)
} else {
results = await fetchEntries(aid, collUid)
}
setEntries(results)
// Prune multi-selection to only entries still visible after load.
// Single-select (size 1) survives naturally; bulk-select is clipped to visible rows.
setSelectedUids(prev => {
if (prev.size < 2) return prev // single-select persists across search/filter
const visible = new Set(results.map(e => e.entry_uid))
const pruned = new Set([...prev].filter(uid => visible.has(uid)))
return pruned.size === prev.size ? prev : pruned
})
setResultCount(results.length === 0 ? 'No results' : `${results.length} result${results.length === 1 ? '' : 's'}`)
} catch {
setEntries([])
setResultCount('Search failed. Try again.')
} finally {
setSearchBusy(false)
}
}, [])
// 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 { archive: archiveParam } = parseLocation()
const preferred = archiveParam ? list.find(a => a.id === archiveParam) : null
setArchiveId(preferred?.id ?? list[0].id)
}
})
}, [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) {
firstArchiveLoad.current = false
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())
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 — scoped to active collection
useEffect(() => {
if (archiveId === null) return
const timer = setTimeout(() => {
loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid)
}, 300)
return () => clearTimeout(timer)
}, [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)
// must not stomp the URL-initialised view on first load.
useEffect(() => {
if (archiveId === null) return
if (tagFilter !== null) setView('archive')
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) {
fetchTags(archiveId).then(setTagNodes)
}
}, [archiveId])
// Sync view + settingsTab → URL (skip when serving a standalone preview page)
// Preserve existing search params so ?q/tag/entry survive view navigation.
useEffect(() => {
if (PREVIEW_ROUTE) return
const path = locationPath(view, settingsTab)
if (window.location.pathname !== path) {
history.pushState(null, '', path + window.location.search)
}
}, [view, settingsTab])
const selectEntry = useCallback((entry) => {
setSelectedEntryUid(entry ? entry.entry_uid : null)
setSelectedEntry(entry)
}, [])
const handleRowClick = useCallback((entry, e) => {
// Cache every clicked entry so shift/ctrl can resolve child entries
// that are not present in the root `entries` array.
entryCacheRef.current.set(entry.entry_uid, entry)
// Invalidate any in-flight j/k uncached-child fetch.
++jkSeqRef.current
if (e.shiftKey && lastAnchorIndexRef.current !== null) {
e.preventDefault()
// Use DOM order so child rows (not in the `entries` array) participate
// in range selection. Every rendered row has data-entry-uid.
const allNodes = [...document.querySelectorAll('#entries-body [data-entry-uid]')]
const anchorIdx = allNodes.findIndex(n => n.dataset.entryUid === lastAnchorIndexRef.current)
const clickIdx = allNodes.findIndex(n => n.dataset.entryUid === entry.entry_uid)
if (anchorIdx === -1 || clickIdx === -1) {
lastAnchorIndexRef.current = entry.entry_uid
setSelectedUids(new Set([entry.entry_uid]))
selectEntry(entry)
return
}
const lo = Math.min(anchorIdx, clickIdx)
const hi = Math.max(anchorIdx, clickIdx)
const uids = new Set(allNodes.slice(lo, hi + 1).map(n => n.dataset.entryUid))
setSelectedUids(uids)
if (uids.size === 1) selectEntry(entry)
} else if (e.ctrlKey || e.metaKey) {
lastAnchorIndexRef.current = entry.entry_uid
const next = new Set(selectedUids)
if (next.has(entry.entry_uid)) next.delete(entry.entry_uid)
else next.add(entry.entry_uid)
setSelectedUids(next)
if (next.size === 0) {
selectEntry(null)
} else if (next.size === 1) {
// Resolve the remaining UID — may be a child not in the root entries array
// and not yet cached (e.g. picked up via shift-range without a direct click).
const [remainingUid] = next
const cached = entryCacheRef.current.get(remainingUid)
?? entries.find(x => x.entry_uid === remainingUid)
?? null
if (cached) {
selectEntry(cached)
} else {
const tok = ++jkSeqRef.current
fetchEntryDetail(archiveId, remainingUid)
.then(det => { if (tok === jkSeqRef.current && det?.summary) selectEntry(det.summary) })
.catch(() => {})
}
} else {
selectEntry(null)
}
} else {
lastAnchorIndexRef.current = entry.entry_uid
setSelectedUids(new Set([entry.entry_uid]))
selectEntry(entry)
}
}, [entries, selectedUids, selectEntry, archiveId])
const handleTagFilterSet = useCallback((fullPath) => {
setTagFilter(fullPath)
}, [])
const handleClearTagFilter = useCallback(() => {
setTagFilter(null)
}, [])
const handleTagsRefresh = useCallback(() => {
if (archiveId) fetchTags(archiveId).then(setTagNodes)
}, [archiveId])
const handleTagRenamed = useCallback((oldFullPath, newFullPath) => {
if (tagFilter === oldFullPath) {
setTagFilter(newFullPath);
} else if (tagFilter?.startsWith(oldFullPath + '/')) {
setTagFilter(newFullPath + tagFilter.slice(oldFullPath.length));
}
}, [tagFilter]);
const handleTagDeleted = useCallback((deletedFullPath) => {
if (tagFilter === deletedFullPath || tagFilter?.startsWith(deletedFullPath + '/')) {
setTagFilter(null);
}
}, [tagFilter]);
const handleEntryTitleChange = useCallback((entryUid, newTitle) => {
setEntries(prev => prev.map(e =>
e.entry_uid === entryUid ? { ...e, title: newTitle } : e
))
setSelectedEntry(prev =>
prev && prev.entry_uid === entryUid ? { ...prev, title: newTitle } : prev
)
setEntryDetail(prev =>
prev && prev.summary.entry_uid === entryUid
? { ...prev, summary: { ...prev.summary, title: newTitle } }
: prev
)
}, [])
const handleDetailRefresh = useCallback(() => {
if (!archiveId || !selectedEntry) return
const seq = ++detailSeqRef.current
fetchEntryDetail(archiveId, selectedEntry.entry_uid).then(det => {
if (seq !== detailSeqRef.current) return
setEntryDetail(det)
}).catch(() => {})
}, [archiveId, selectedEntry])
const handleEntryDeleted = useCallback((entryUid) => {
const isRoot = entries.some(e => e.entry_uid === entryUid)
setDeletedUids(prev => { const n = new Set(prev); n.add(entryUid); return n })
setEntries(prev => prev.filter(e => e.entry_uid !== entryUid))
setSelectedEntry(prev => prev?.entry_uid === entryUid ? null : prev)
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, selectedCollectionUid)
}, [entries, archiveId, searchQuery, tagFilter, selectedCollectionUid, loadEntries])
const handleBulkDeleted = useCallback((uids) => {
const rootUids = new Set(entries.map(e => e.entry_uid))
const hasChildDelete = [...uids].some(u => !rootUids.has(u))
setDeletedUids(prev => { const n = new Set(prev); uids.forEach(u => n.add(u)); return n })
setEntries(prev => prev.filter(e => !uids.has(e.entry_uid)))
setSelectedUids(new Set())
setSelectedEntry(null)
setSelectedEntryUid(null)
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).
useEffect(() => {
if (selectedUids.size >= 2) {
setSelectedEntryUid(null)
setSelectedEntry(null)
} else if (selectedUids.size === 1) {
const [uid] = selectedUids
setSelectedEntryUid(uid)
// selectedEntry object restored by the existing restore effect below
} else {
setSelectedEntryUid(null)
setSelectedEntry(null)
}
}, [selectedUids])
const selectedEntries = useMemo(
() => entries.filter(e => selectedUids.has(e.entry_uid)),
[entries, selectedUids]
)
// Restore selectedEntry object from selectedEntryUid when entries load.
// Handles page refresh and back/forward navigation where only the UID is known.
useEffect(() => {
if (!selectedEntryUid || selectedEntry) return
const found = entries.find(e => e.entry_uid === selectedEntryUid)
if (found) setSelectedEntry(found)
}, [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, selectedCollectionUid, archiveId, view])
// ⌘K / Ctrl+K / /: focus the search input, switching to archive view first if needed.
useEffect(() => {
const handler = (e) => {
const isSlash = e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey
const isCmdK = (e.metaKey || e.ctrlKey) && e.key === 'k'
if (!isSlash && !isCmdK) return
// Don't intercept / when already typing in an input
const tag = document.activeElement?.tagName
if (isSlash && (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')) return
if (isSlash && document.activeElement?.isContentEditable) return
e.preventDefault()
if (view === 'archive') {
searchInputRef.current?.focus()
searchInputRef.current?.select()
} else {
pendingSearchFocus.current = true
setView('archive')
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [view])
// j/k: navigate entries down/up when not focused on an input element.
useEffect(() => {
const handler = (e) => {
// Ignore when a modifier is held (don't steal browser/app shortcuts)
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key !== 'j' && e.key !== 'k') return
// Ignore when focus is on any editable target
const tag = document.activeElement?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if (document.activeElement?.isContentEditable) return
const allNodes = [...document.querySelectorAll('#entries-body [data-entry-uid]')]
if (allNodes.length === 0) return
const [currentUid] = selectedUids.size === 1 ? selectedUids : [null]
const currentIdx = currentUid
? allNodes.findIndex(n => n.dataset.entryUid === currentUid)
: -1
const nextIdx = e.key === 'j'
? Math.min(currentIdx + 1, allNodes.length - 1)
: Math.max(currentIdx - 1, 0)
if (nextIdx === currentIdx && currentIdx !== -1) return
const nextNode = allNodes[nextIdx < 0 ? 0 : nextIdx]
const nextUid = nextNode.dataset.entryUid
lastAnchorIndexRef.current = nextUid
const tok = ++jkSeqRef.current
setSelectedUids(new Set([nextUid]))
// Resolve entry object: cache → root entries array → server fetch.
// Token guards against a slow fetch settling after the user has moved on.
const cached = entryCacheRef.current.get(nextUid)
?? entries.find(x => x.entry_uid === nextUid)
?? null
if (cached) {
selectEntry(cached)
} else {
fetchEntryDetail(archiveId, nextUid)
.then(det => { if (tok === jkSeqRef.current && det?.summary) selectEntry(det.summary) })
.catch(() => {})
}
nextNode.scrollIntoView({ block: 'nearest' })
e.preventDefault()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [selectedUids, entries, selectEntry, archiveId])
// After switching to archive view via ⌘K, focus the search input once rendered.
useEffect(() => {
if (view === 'archive' && pendingSearchFocus.current) {
pendingSearchFocus.current = false
requestAnimationFrame(() => {
searchInputRef.current?.focus()
searchInputRef.current?.select()
})
}
}, [view])
const handleCaptureClick = useCallback(() => {
setCaptureDialogOpen(true)
}, [])
const handleCaptureClose = useCallback(() => {
setCaptureDialogOpen(false)
}, [])
const handleCaptured = useCallback(() => {
if (!archiveId) return
return Promise.allSettled([
loadEntries(archiveId, searchQuery, tagFilter, selectedCollectionUid),
fetchRuns(archiveId).then(setRuns),
])
}, [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).
// Batch summary warnings (locator = null) must always show.
if (type === 'warning' && ublockWarningIgnored && locator) return
const id = ++toastIdRef.current
setToasts(prev => [...prev, { id, text, locator, type, headline }])
}, [ublockWarningIgnored])
const handleDismissToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id))
}, [])
const handleIgnoreUblock = useCallback(() => {
sessionStorage.setItem('ublockWarningIgnored', 'true')
setUblockWarningIgnored(true)
setToasts(prev => prev.filter(t => !(t.type === 'warning' && t.locator)))
}, [])
const handleJobStarted = useCallback((record) => {
setPendingCaptures(prev => [...prev, record])
}, [])
const handleJobSettled = useCallback((id) => {
setPendingCaptures(prev => prev.filter(c => c.id !== id))
}, [])
const [previewEntryUid, setPreviewEntryUid] = useState(null)
const [currentAudio, setCurrentAudio] = useState(null)
const handleOpenPreview = useCallback(() => {
if (selectedEntry) setPreviewEntryUid(selectedEntry.entry_uid)
}, [selectedEntry])
const handleClosePreview = useCallback(() => setPreviewEntryUid(null), [])
const handlePlay = useCallback((src, entry) => {
setCurrentAudio({ src, entry })
}, [])
const handleCloseAudio = useCallback(() => setCurrentAudio(null), [])
// Close stale modal when selection changes (audio persists intentionally)
useEffect(() => {
setPreviewEntryUid(null)
}, [selectedEntry])
// Esc: deselect current entry/entries, unless a modal or input has focus.
useEffect(() => {
const handler = (e) => {
if (e.key !== 'Escape') return
if (captureDialogOpen || previewEntryUid) return
const tag = document.activeElement?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
if (selectedUids.size > 0) {
e.preventDefault()
setSelectedUids(new Set())
document.activeElement?.blur?.()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [captureDialogOpen, previewEntryUid, selectedUids])
// Toggle body class so fixed AudioBar doesn't obscure scrollable content
useEffect(() => {
document.body.classList.toggle('has-audio-bar', !!currentAudio)
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'); }} />;
return (
<AuthContext.Provider value={{ currentUser, setCurrentUser }}>
<>
<Topbar
archives={archives}
archiveId={archiveId}
onArchiveChange={handleArchiveChange}
view={view}
onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick}
collections={collections}
selectedCollectionUid={selectedCollectionUid}
onCollectionChange={handleCollectionChange}
isPublicSession={!currentUser}
onSignInClick={() => setAuthState('login')}
/>
<main className="app-shell">
<div className="workspace">
{view === 'archive' && (
<div className="toolbar">
<div className="search-field">
<span className="ico" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="11" cy="11" r="7"/><line x1="16.5" y1="16.5" x2="21" y2="21"/>
</svg>
</span>
<input
ref={searchInputRef}
className="search-input"
type="search"
aria-label="Search archive"
aria-busy={searchBusy}
placeholder="Search titles, URLs, types, tags…"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
<span className="kbd">⌘K</span>
</div>
<span className="result-count">
{resultCount && <><b>{resultCount.split(' ')[0]}</b>{' '}{resultCount.split(' ').slice(1).join(' ')}</>}
{tagFilter && (
<button className="tag-filter-badge" onClick={handleClearTagFilter}>
× {humanizeTags ? displayPath(tagFilter) : tagFilter}
</button>
)}
</span>
</div>
)}
{view === 'archive' && (
<EntriesView
entries={entries}
selectedUids={selectedUids}
onRowClick={handleRowClick}
archiveId={archiveId}
pendingCaptures={pendingCaptures}
deletedUids={deletedUids}
isPublicSession={!currentUser}
/>
)}
{view === 'runs' && <RunsView runs={runs} />}
{view === 'admin' && <AdminView archives={archives} />}
{view === 'tags' && (
<TagsView
archiveId={archiveId}
tagNodes={tagNodes}
tagFilter={tagFilter}
onTagFilterSet={handleTagFilterSet}
onViewChange={handleViewChange}
onTagRenamed={handleTagRenamed}
onTagDeleted={handleTagDeleted}
onTagsRefresh={handleTagsRefresh}
humanizeTags={humanizeTags}
/>
)}
{view === 'collections' && (
<CollectionsView archiveId={archiveId} />
)}
{view === 'settings' && (
<SettingsView tab={settingsTab} onTabChange={setSettingsTab} archiveId={archiveId} />
)}
</div>
<ContextRail
archiveId={archiveId}
selectedEntry={selectedEntry}
selectedUids={selectedUids}
selectedEntries={selectedEntries}
detail={entryDetail}
onTagFilterSet={handleTagFilterSet}
tagNodes={tagNodes}
onTagsRefresh={handleTagsRefresh}
onEntryTitleChange={handleEntryTitleChange}
onEntryDeleted={handleEntryDeleted}
onBulkDeleted={handleBulkDeleted}
humanizeTags={humanizeTags}
onDetailRefresh={handleDetailRefresh}
onOpenPreview={handleOpenPreview}
onPlay={handlePlay}
isPublicSession={!currentUser}
/>
</main>
{previewEntryUid && selectedEntry && selectedEntry.entry_uid === previewEntryUid && (
<PreviewModal
archiveId={archiveId}
entry={selectedEntry}
detail={entryDetail}
onClose={handleClosePreview}
/>
)}
{currentAudio && (
<AudioBar
entry={currentAudio.entry}
src={currentAudio.src}
archiveId={archiveId}
onClose={handleCloseAudio}
/>
)}
<CaptureDialog
open={captureDialogOpen}
archiveId={archiveId}
onClose={handleCaptureClose}
onCaptured={handleCaptured}
onToast={handleToast}
activeJobs={pendingCaptures}
onJobStarted={handleJobStarted}
onJobSettled={handleJobSettled}
/>
<ToastStack toasts={toasts} onDismiss={handleDismissToast} onIgnoreUblock={handleIgnoreUblock} />
</>
</AuthContext.Provider>
)
}