1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat(frontend): small dashboard revamp (#26)

Add ⌘K search focus, URL-param'd search/tag/entry, persistent selection, extensions grid

* feat(frontend): focus search input on ⌘K/Ctrl+K

Add a global keydown handler that focuses (and selects) the search
input when Meta+K or Ctrl+K is pressed. If the current view is not
'archive', switch to it first and focus the input after the next
render frame via a pendingSearchFocus ref + requestAnimationFrame.

The ⌘K hint badge in the toolbar was already present; this wires
the behaviour behind it.

* feat(frontend): persist search query and tag filter in URL params

Extend parseLocation() to read ?q= and ?tag= search parameters.
Initialise searchQuery and tagFilter from the URL on mount so that
sharing or refreshing a filtered view restores the exact same
results.

- replaceState on every q/tag change (no extra history entries)
- pushState on view navigation now preserves existing search params
- popstate handler restores q and tag on back/forward
- firstArchiveLoad ref prevents the archive-change effect from
  wiping URL-initialised filters on the first mount

* feat(frontend): persist selected entry in URL params

Extend parseLocation() to read ?entry= and initialise
selectedEntryUid from it on mount.

When entries load (or reload), a new effect checks whether
selectedEntryUid is set without a corresponding selectedEntry
object and restores it by finding the matching entry in the list.
This covers page refresh, URL sharing, and back/forward navigation.

The URL params sync effect now includes selectedEntryUid alongside
q and tag, and the popstate handler restores all three.

Also includes rebuilt frontend static assets.

* fix(frontend): add cookies and extensions to settings URL routing

SETTINGS_TABS was missing 'cookies' and 'extensions', so navigating
to /settings/cookies or /settings/extensions silently fell back to
the profile tab. Both tabs already existed in SettingsView; they
just weren't recognised by parseLocation().

Includes rebuilt frontend static assets.

* style(frontend): ext cards in auto-fill grid

* fix(frontend): URL state regressions from Codex review

Two issues:

1. Re-selection stall when popstate lands on the same entry UID.
   The restoration effect only had [entries, selectedEntryUid] as
   deps, so setting selectedEntry=null without changing either dep
   left the rail blank. Adding selectedEntry to the dep array lets
   the null→restore cycle complete.

2. tag/entry params leaking onto non-archive URLs.
   pushState on view changes carried window.location.search verbatim,
   so /settings?tag=foo was possible; reloading it triggered the
   tagFilter effect and force-switched back to archive.
   Fix: parseLocation() only extracts tag/entry when view=archive,
   and the params-sync effect only emits them on archive too.
This commit is contained in:
TheGeneralist 2026-07-13 14:50:52 +02:00 committed by GitHub
parent 72cf7ff642
commit 39765ef893
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 187 additions and 107 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,8 +4,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <title>Archivr</title>
<script type="module" crossorigin src="/assets/index-Gm5awz10.js"></script> <script type="module" crossorigin src="/assets/index-u6DliWTb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C89vW3ol.css"> <link rel="stylesheet" crossorigin href="/assets/index-p2mzAHzf.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -27,13 +27,17 @@ const PREVIEW_ROUTE = (() => {
})() })()
const VIEWS = ['archive','tags','collections','runs','admin','settings'] const VIEWS = ['archive','tags','collections','runs','admin','settings']
const SETTINGS_TABS = ['profile','tokens','instance','storage'] const SETTINGS_TABS = ['profile','tokens','instance','cookies','extensions','storage']
function parseLocation() { function parseLocation() {
const parts = window.location.pathname.split('/').filter(Boolean) const parts = window.location.pathname.split('/').filter(Boolean)
const view = VIEWS.includes(parts[0]) ? parts[0] : 'archive' const view = VIEWS.includes(parts[0]) ? parts[0] : 'archive'
const settingsTab = (view === 'settings' && SETTINGS_TABS.includes(parts[1])) ? parts[1] : 'profile' const settingsTab = (view === 'settings' && SETTINGS_TABS.includes(parts[1])) ? parts[1] : 'profile'
return { view, settingsTab } 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) { function locationPath(view, settingsTab) {
@ -66,9 +70,13 @@ export default function App() {
// Sync URL state on back/forward // Sync URL state on back/forward
useEffect(() => { useEffect(() => {
const handler = () => { const handler = () => {
const { view, settingsTab } = parseLocation() const { view, settingsTab, q, tag, entry } = parseLocation()
setView(view) setView(view)
setSettingsTab(settingsTab) setSettingsTab(settingsTab)
setSearchQuery(q)
setTagFilter(tag)
setSelectedEntryUid(entry)
setSelectedEntry(null)
} }
window.addEventListener('popstate', handler) window.addEventListener('popstate', handler)
return () => window.removeEventListener('popstate', handler) return () => window.removeEventListener('popstate', handler)
@ -77,12 +85,12 @@ export default function App() {
const [archives, setArchives] = useState([]) const [archives, setArchives] = useState([])
const [archiveId, setArchiveId] = useState(null) const [archiveId, setArchiveId] = useState(null)
const [entries, setEntries] = useState([]) const [entries, setEntries] = useState([])
const [selectedEntryUid, setSelectedEntryUid] = useState(null) const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry)
const [selectedEntry, setSelectedEntry] = useState(null) const [selectedEntry, setSelectedEntry] = useState(null)
const [tagFilter, setTagFilter] = useState(null) const [tagFilter, setTagFilter] = useState(() => parseLocation().tag)
const [view, setView] = useState(() => parseLocation().view) const [view, setView] = useState(() => parseLocation().view)
const [settingsTab, setSettingsTab] = useState(() => parseLocation().settingsTab) const [settingsTab, setSettingsTab] = useState(() => parseLocation().settingsTab)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState(() => parseLocation().q)
const [resultCount, setResultCount] = useState('') const [resultCount, setResultCount] = useState('')
const [searchBusy, setSearchBusy] = useState(false) const [searchBusy, setSearchBusy] = useState(false)
const [runs, setRuns] = useState([]) const [runs, setRuns] = useState([])
@ -102,6 +110,9 @@ export default function App() {
// Entry detail (shared between PreviewPanel and ContextRail) // Entry detail (shared between PreviewPanel and ContextRail)
const [entryDetail, setEntryDetail] = useState(null) const [entryDetail, setEntryDetail] = useState(null)
const detailSeqRef = useRef(0) const detailSeqRef = useRef(0)
const searchInputRef = useRef(null)
const pendingSearchFocus = useRef(false)
const firstArchiveLoad = useRef(true)
const humanizeTags = currentUser?.humanize_slugs ?? false; const humanizeTags = currentUser?.humanize_slugs ?? false;
@ -157,6 +168,16 @@ export default function App() {
// Archive change: parallel load entries + runs + tags // Archive change: parallel load entries + runs + tags
useEffect(() => { useEffect(() => {
if (!archiveId) return 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) setTagFilter(null)
setSelectedEntry(null) setSelectedEntry(null)
setSelectedEntryUid(null) setSelectedEntryUid(null)
@ -196,13 +217,13 @@ export default function App() {
} }
}, [archiveId]) }, [archiveId])
// Sync view + settingsTab URL
// Sync view + settingsTab URL (skip when serving a standalone preview page) // Sync view + settingsTab URL (skip when serving a standalone preview page)
// Preserve existing search params so ?q/tag/entry survive view navigation.
useEffect(() => { useEffect(() => {
if (PREVIEW_ROUTE) return if (PREVIEW_ROUTE) return
const path = locationPath(view, settingsTab) const path = locationPath(view, settingsTab)
if (window.location.pathname !== path) { if (window.location.pathname !== path) {
history.pushState(null, '', path) history.pushState(null, '', path + window.location.search)
} }
}, [view, settingsTab]) }, [view, settingsTab])
@ -266,6 +287,56 @@ export default function App() {
setSelectedEntryUid(prev => prev === entryUid ? null : prev) setSelectedEntryUid(prev => prev === entryUid ? null : prev)
}, []) }, [])
// 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(() => { const handleCaptureClick = useCallback(() => {
setCaptureDialogOpen(true) setCaptureDialogOpen(true)
}, []) }, [])
@ -349,6 +420,7 @@ export default function App() {
</svg> </svg>
</span> </span>
<input <input
ref={searchInputRef}
className="search-input" className="search-input"
type="search" type="search"
aria-label="Search archive" aria-label="Search archive"

View file

@ -691,7 +691,7 @@ function ExtensionsTab() {
const cookieExtEnabled = settings?.cookie_ext_enabled ?? true const cookieExtEnabled = settings?.cookie_ext_enabled ?? true
return ( return (
<div style={{ maxWidth: 560 }}> <div>
<div className="form-section"> <div className="form-section">
<h2>Extensions</h2> <h2>Extensions</h2>
<p className="form-hint" style={{ marginBottom: 20 }}> <p className="form-hint" style={{ marginBottom: 20 }}>
@ -699,60 +699,62 @@ function ExtensionsTab() {
accept cookie banners, and more. Changes take effect on the next capture. accept cookie banners, and more. Changes take effect on the next capture.
</p> </p>
<div className="ext-card"> <div className="ext-grid">
<div className="ext-card-header"> <div className="ext-card">
<div className="ext-card-info"> <div className="ext-card-header">
<span className="ext-card-name">uBlock Origin Lite</span> <div className="ext-card-info">
<span className="ext-card-desc"> <span className="ext-card-name">uBlock Origin Lite</span>
Blocks ads, trackers, and other page clutter during archiving <span className="ext-card-desc">
via Chrome&rsquo;s declarativeNetRequest API (Manifest V3). Blocks ads, trackers, and other page clutter during archiving
</span> via Chrome&rsquo;s declarativeNetRequest API (Manifest V3).
{!extAvailable && (
<span className="ext-card-hint">
Not configured &mdash; set <code>ARCHIVR_UBLOCK_EXT</code> to the
unpacked extension directory to enable.
</span> </span>
)} {!extAvailable && (
<span className="ext-card-hint">
Not configured &mdash; set <code>ARCHIVR_UBLOCK_EXT</code> to the
unpacked extension directory to enable.
</span>
)}
</div>
<button
type="button"
role="switch"
aria-checked={extEnabled}
className={`ext-toggle${extEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => toggleUblock(!extEnabled)}
disabled={saving}
aria-label="Toggle uBlock Origin Lite"
>
<span className="ext-toggle-knob" />
</button>
</div> </div>
<button
type="button"
role="switch"
aria-checked={extEnabled}
className={`ext-toggle${extEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => toggleUblock(!extEnabled)}
disabled={saving}
aria-label="Toggle uBlock Origin Lite"
>
<span className="ext-toggle-knob" />
</button>
</div> </div>
</div>
<div className="ext-card"> <div className="ext-card">
<div className="ext-card-header"> <div className="ext-card-header">
<div className="ext-card-info"> <div className="ext-card-info">
<span className="ext-card-name">I Still Don&rsquo;t Care About Cookies</span> <span className="ext-card-name">I Still Don&rsquo;t Care About Cookies</span>
<span className="ext-card-desc"> <span className="ext-card-desc">
Dismiss cookie consent banners during archiving. Dismiss cookie consent banners during archiving.
</span>
{!cookieExtAvailable && (
<span className="ext-card-hint">
Not configured &mdash; set <code>ARCHIVR_COOKIE_EXT</code> to the
unpacked extension directory to enable.
</span> </span>
)} {!cookieExtAvailable && (
<span className="ext-card-hint">
Not configured &mdash; set <code>ARCHIVR_COOKIE_EXT</code> to the
unpacked extension directory to enable.
</span>
)}
</div>
<button
type="button"
role="switch"
aria-checked={cookieExtEnabled}
className={`ext-toggle${cookieExtEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => toggleCookieExt(!cookieExtEnabled)}
disabled={saving}
aria-label="Toggle I Still Don't Care About Cookies"
>
<span className="ext-toggle-knob" />
</button>
</div> </div>
<button
type="button"
role="switch"
aria-checked={cookieExtEnabled}
className={`ext-toggle${cookieExtEnabled ? ' ext-toggle--on' : ''}`}
onClick={() => toggleCookieExt(!cookieExtEnabled)}
disabled={saving}
aria-label="Toggle I Still Don't Care About Cookies"
>
<span className="ext-toggle-knob" />
</button>
</div> </div>
</div> </div>

View file

@ -1014,6 +1014,12 @@ select {
.ext-toggle--sm.ext-toggle--on .ext-toggle-knob { transform: translateX(16px); } .ext-toggle--sm.ext-toggle--on .ext-toggle-knob { transform: translateX(16px); }
/* ── Extension card (Settings / Extensions tab) ──────────────────────────── */ /* ── Extension card (Settings / Extensions tab) ──────────────────────────── */
.ext-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.ext-card { .ext-card {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--r2); border-radius: var(--r2);