1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00
archivr/frontend/src/App.jsx
TheGeneralist 03406099de
fix(core+frontend): child entry selection and delete correctness
database.rs — delete_entry:
- subtree_ids now uses WHERE id = ?1 OR root_entry_id = ?1 so the entry
  itself is always included; previously a child deletion passed an empty
  vec to cascade_cached_bytes_after_subtree_delete (no grandchildren
  exist), leaving cached_bytes stale on entries sharing that child's blobs

App.jsx — handleRowClick:
- Shift-range now queries DOM order (#entries-body [data-entry-uid])
  instead of entries.findIndex(); child rows are in the DOM but not in
  the root entries array, so findIndex always returned -1 for them
- Ctrl/meta branch computes next set before the state update so
  selectEntry can fire synchronously for child rows; auto-snap only
  restores root entries, so ctrl-clicking a lone child never loaded its
  detail panel — now calls selectEntry(entry) when the child ends up as
  the sole selection, selectEntry(null) on multi or deselect
- selectedUids added to handleRowClick's useCallback dep array
2026-07-21 13:39:35 +02:00

642 lines
24 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 } 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);
// 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'
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
return { view, settingsTab, q, tag, entry }
}
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) { setAuthState('login'); return; }
setCurrentUser(user);
setAuthState('authenticated');
})();
}, []);
useEffect(() => {
const handler = () => { setCurrentUser(null); setAuthState('login'); };
window.addEventListener('auth:expired', handler);
return () => window.removeEventListener('auth:expired', handler);
}, []);
// Sync URL → state on back/forward
useEffect(() => {
const handler = () => {
const { view, settingsTab, q, tag, entry } = parseLocation()
setView(view)
setSettingsTab(settingsTab)
setSearchQuery(q)
setTagFilter(tag)
setSelectedEntryUid(entry)
setSelectedEntry(null)
setSelectedUids(entry ? new Set([entry]) : new Set())
}
window.addEventListener('popstate', handler)
return () => window.removeEventListener('popstate', handler)
}, [])
const [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null)
const [entries, setEntries] = useState([])
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)
const humanizeTags = currentUser?.humanize_slugs ?? false;
// Fetch entry detail whenever selected entry changes
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) => {
if (!aid) return
setSearchBusy(true)
try {
let results
if (q || tag) {
results = await searchEntries(aid, q, tag)
} else {
results = await fetchEntries(aid)
}
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 (re-runs when authState changes so
// it triggers correctly after first login or a session refresh).
useEffect(() => {
if (authState !== 'authenticated') return
fetchArchives().then(list => {
setArchives(list)
if (list.length > 0) {
const first = list[0].id
setArchiveId(first)
}
})
}, [authState])
// Archive change: parallel load entries + runs + tags
useEffect(() => {
if (!archiveId) return
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),
])
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
// Debounced search
useEffect(() => {
if (archiveId === null) return
const timer = setTimeout(() => {
loadEntries(archiveId, searchQuery, tagFilter)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, archiveId]) // tagFilter handled separately below
// 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)
}, [tagFilter, archiveId]) // intentional: searchQuery excluded to avoid double-fire
const handleArchiveChange = useCallback((id) => {
setArchiveId(id)
}, [])
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) => {
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) {
// anchor no longer visible — fall back to single select
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)
// Auto-snap only restores root entries; explicitly drive selectEntry so
// ctrl-clicking a single child row loads its detail and clears on multi.
if (next.size === 1 && next.has(entry.entry_uid)) selectEntry(entry)
else if (next.size !== 1) selectEntry(null)
} else {
lastAnchorIndexRef.current = entry.entry_uid
setSelectedUids(new Set([entry.entry_uid]))
selectEntry(entry)
}
}, [entries, selectedUids, selectEntry])
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) => {
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 })
}, [])
const handleBulkDeleted = useCallback((uids) => {
setEntries(prev => prev.filter(e => !uids.has(e.entry_uid)))
setSelectedUids(new Set())
setSelectedEntry(null)
setSelectedEntryUid(null)
}, [])
// 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).
useEffect(() => {
if (PREVIEW_ROUTE) return
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)
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])
// ⌘K / Ctrl+K: focus the search input, switching to archive view first if needed.
useEffect(() => {
const handler = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
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])
// 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),
fetchRuns(archiveId).then(setRuns),
])
}, [archiveId, searchQuery, tagFilter, 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 (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 }}>
<>
<Topbar
archives={archives}
archiveId={archiveId}
onArchiveChange={handleArchiveChange}
view={view}
onViewChange={handleViewChange}
onCaptureClick={handleCaptureClick}
/>
<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}
/>
)}
{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}
/>
</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>
)
}