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

Compare commits

...

2 commits

Author SHA1 Message Date
github-actions[bot]
b0d02034ea
chore: yt-dlp 2026.06.09 → 2026.07.04 (#25)
Co-authored-by: thegeneralist01 <180094941+thegeneralist01@users.noreply.github.com>
2026-07-13 14:58:29 +02:00
39765ef893
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.
2026-07-13 14:50:52 +02:00
9 changed files with 190 additions and 110 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 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>

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1782723713,
"narHash": "sha256-oPXCU/SSUokcGaJREHibG1CBX3+s/W7orDWQOZDsEeQ=",
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b5aa0fbd538984f6e3d201be0005b4463d8b09f8",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"type": "github"
},
"original": {

View file

@ -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"

View file

@ -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>

View file

@ -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);