mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 21:03:17 +02:00
* 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
829 lines
34 KiB
JavaScript
829 lines
34 KiB
JavaScript
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>
|
||
)
|
||
}
|