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:
parent
72cf7ff642
commit
39765ef893
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
1
crates/archivr-server/static/assets/index-p2mzAHzf.css
Normal file
1
crates/archivr-server/static/assets/index-p2mzAHzf.css
Normal file
File diff suppressed because one or more lines are too long
47
crates/archivr-server/static/assets/index-u6DliWTb.js
Normal file
47
crates/archivr-server/static/assets/index-u6DliWTb.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Archivr</title>
|
||||
<script type="module" crossorigin src="/assets/index-Gm5awz10.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C89vW3ol.css">
|
||||
<script type="module" crossorigin src="/assets/index-u6DliWTb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-p2mzAHzf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,17 @@ const PREVIEW_ROUTE = (() => {
|
|||
})()
|
||||
|
||||
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() {
|
||||
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'
|
||||
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) {
|
||||
|
|
@ -66,9 +70,13 @@ export default function App() {
|
|||
// Sync URL → state on back/forward
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const { view, settingsTab } = parseLocation()
|
||||
const { view, settingsTab, q, tag, entry } = parseLocation()
|
||||
setView(view)
|
||||
setSettingsTab(settingsTab)
|
||||
setSearchQuery(q)
|
||||
setTagFilter(tag)
|
||||
setSelectedEntryUid(entry)
|
||||
setSelectedEntry(null)
|
||||
}
|
||||
window.addEventListener('popstate', handler)
|
||||
return () => window.removeEventListener('popstate', handler)
|
||||
|
|
@ -77,12 +85,12 @@ export default function App() {
|
|||
const [archives, setArchives] = useState([])
|
||||
const [archiveId, setArchiveId] = useState(null)
|
||||
const [entries, setEntries] = useState([])
|
||||
const [selectedEntryUid, setSelectedEntryUid] = useState(null)
|
||||
const [selectedEntryUid, setSelectedEntryUid] = useState(() => parseLocation().entry)
|
||||
const [selectedEntry, setSelectedEntry] = useState(null)
|
||||
const [tagFilter, setTagFilter] = useState(null)
|
||||
const [tagFilter, setTagFilter] = useState(() => parseLocation().tag)
|
||||
const [view, setView] = useState(() => parseLocation().view)
|
||||
const [settingsTab, setSettingsTab] = useState(() => parseLocation().settingsTab)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState(() => parseLocation().q)
|
||||
const [resultCount, setResultCount] = useState('')
|
||||
const [searchBusy, setSearchBusy] = useState(false)
|
||||
const [runs, setRuns] = useState([])
|
||||
|
|
@ -102,6 +110,9 @@ export default function App() {
|
|||
// ── 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 humanizeTags = currentUser?.humanize_slugs ?? false;
|
||||
|
||||
|
|
@ -157,6 +168,16 @@ export default function App() {
|
|||
// 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)
|
||||
|
|
@ -196,13 +217,13 @@ export default function App() {
|
|||
}
|
||||
}, [archiveId])
|
||||
|
||||
// Sync view + settingsTab → URL
|
||||
// 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)
|
||||
history.pushState(null, '', path + window.location.search)
|
||||
}
|
||||
}, [view, settingsTab])
|
||||
|
||||
|
|
@ -266,6 +287,56 @@ export default function App() {
|
|||
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(() => {
|
||||
setCaptureDialogOpen(true)
|
||||
}, [])
|
||||
|
|
@ -349,6 +420,7 @@ export default function App() {
|
|||
</svg>
|
||||
</span>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="search-input"
|
||||
type="search"
|
||||
aria-label="Search archive"
|
||||
|
|
|
|||
|
|
@ -691,7 +691,7 @@ function ExtensionsTab() {
|
|||
const cookieExtEnabled = settings?.cookie_ext_enabled ?? true
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<div>
|
||||
<div className="form-section">
|
||||
<h2>Extensions</h2>
|
||||
<p className="form-hint" style={{ marginBottom: 20 }}>
|
||||
|
|
@ -699,6 +699,7 @@ function ExtensionsTab() {
|
|||
accept cookie banners, and more. Changes take effect on the next capture.
|
||||
</p>
|
||||
|
||||
<div className="ext-grid">
|
||||
<div className="ext-card">
|
||||
<div className="ext-card-header">
|
||||
<div className="ext-card-info">
|
||||
|
|
@ -755,6 +756,7 @@ function ExtensionsTab() {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`form-msg form-msg--${msg.ok ? 'ok' : 'err'}`}>{msg.text}</div>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1014,6 +1014,12 @@ select {
|
|||
.ext-toggle--sm.ext-toggle--on .ext-toggle-knob { transform: translateX(16px); }
|
||||
|
||||
/* ── Extension card (Settings / Extensions tab) ──────────────────────────── */
|
||||
.ext-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ext-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r2);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue