fix(search): make the YouTube-search view its own history entry

The live-search results view had no browser-history entry of its own, so a
player opened over the results sat directly on the feed page entry. Pressing
Back (e.g. the mouse back button over the player) could pop past both the
player and the search in one step, bouncing from the search results to the
normal feed instead of just closing the player.

The search is now a feed sub-view that owns a history entry (history.state._yt):
entering a search pushes it, the popstate handler derives ytSearch from it, and
"Back to feed" pops it. Back now steps player -> search -> feed: the first Back
closes only the player (results stay), the second returns to the normal feed.
A reload drops any stale _yt so the first Back can't resurrect a gone search.
This commit is contained in:
npeter83 2026-06-29 23:03:25 +02:00
parent 1d6dfaf486
commit ff9b0601c3
2 changed files with 35 additions and 12 deletions

View file

@ -124,7 +124,22 @@ export default function App() {
const [page, setPageState] = useState<Page>(loadInitialPage); const [page, setPageState] = useState<Page>(loadInitialPage);
// Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL, // Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL,
// so a reload returns to the normal feed (a search spends quota, so we never auto-replay it). // so a reload returns to the normal feed (a search spends quota, so we never auto-replay it).
// The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the
// popstate handler below derives ytSearch from it, so Back steps player → search → feed in
// that order (a player opened over the results pops first, then the search, then the feed) —
// instead of the search vanishing because it had no history entry of its own.
const [ytSearch, setYtSearch] = useState<string | null>(null); const [ytSearch, setYtSearch] = useState<string | null>(null);
const enterYtSearch = useCallback((q: string) => {
setYtSearch(q);
const st = window.history.state || {};
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
if (window.history.state?._yt) window.history.back();
else setYtSearch(null);
}, []);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render. // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all"); const [channelFilterRaw, setChannelFilter] = usePersistedState(LS.channelFilter, "all");
@ -230,10 +245,10 @@ export default function App() {
// from history.state on popstate. (stripUrlParams preserves history.state, so this stamp // from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
// survives a later query-string strip.) // survives a later query-string strip.)
useEffect(() => { useEffect(() => {
window.history.replaceState( // Drop any stale _yt from a prior session (a reload starts on the normal feed; ytSearch
{ ...window.history.state, sfPage: page }, // begins null), so the first Back doesn't resurrect a search we're no longer showing.
"" const { _yt: _staleYt, ...rest } = window.history.state || {};
); window.history.replaceState({ ...rest, sfPage: page }, "");
function onPop(e: PopStateEvent) { function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed"; const p = (e.state?.sfPage as Page) ?? "feed";
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings // Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
@ -255,6 +270,9 @@ export default function App() {
} }
setPageState(p); setPageState(p);
localStorage.setItem(PAGE_KEY, p); localStorage.setItem(PAGE_KEY, p);
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
// clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null);
} }
window.addEventListener("popstate", onPop); window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
@ -505,7 +523,7 @@ export default function App() {
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
page={page} page={page}
onYtSearch={setYtSearch} onYtSearch={enterYtSearch}
onGoToFullHistory={() => { onGoToFullHistory={() => {
setChannelFilter("needs_full"); setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
@ -579,7 +597,8 @@ export default function App() {
isDemo={meQuery.data!.is_demo} isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
ytSearch={ytSearch} ytSearch={ytSearch}
setYtSearch={setYtSearch} onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/> />
)} )}
</main> </main>

View file

@ -72,7 +72,8 @@ export default function Feed({
isDemo = false, isDemo = false,
onOpenWizard, onOpenWizard,
ytSearch, ytSearch,
setYtSearch, onYtSearch,
onExitYtSearch,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
@ -80,10 +81,13 @@ export default function Feed({
canRead: boolean; canRead: boolean;
isDemo?: boolean; isDemo?: boolean;
onOpenWizard: () => void; onOpenWizard: () => void;
// Live YouTube search: the active search term (null = normal feed), and its setter so the // Live YouTube search: the active search term (null = normal feed). Entering a search and
// empty-state CTA / "back to feed" can toggle it. Search affordances are hidden for demo. // leaving it both step browser history (the search is a feed sub-view with its own history
// entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via
// onExitYtSearch. Search affordances are hidden for demo.
ytSearch: string | null; ytSearch: string | null;
setYtSearch: (q: string | null) => void; onYtSearch: (q: string) => void;
onExitYtSearch: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [overrides, setOverrides] = useState<Record<string, string>>({}); const [overrides, setOverrides] = useState<Record<string, string>>({});
@ -292,7 +296,7 @@ export default function Feed({
// The search just ingested new catalog videos, so the normal feed (which was // The search just ingested new catalog videos, so the normal feed (which was
// disabled and still holds its pre-search cache) is stale. Drop those caches so // disabled and still holds its pre-search cache) is stale. Drop those caches so
// it re-fetches fresh on return instead of showing the old (often empty) result. // it re-fetches fresh on return instead of showing the old (often empty) result.
setYtSearch(null); onExitYtSearch();
qc.removeQueries({ queryKey: ["feed"] }); qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] }); qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] }); qc.removeQueries({ queryKey: ["facets"] });
@ -529,7 +533,7 @@ export default function Feed({
<p>{t("feed.noMatches")}</p> <p>{t("feed.noMatches")}</p>
{filters.q.trim() && !isDemo && ( {filters.q.trim() && !isDemo && (
<button <button
onClick={() => setYtSearch(filters.q.trim())} onClick={() => onYtSearch(filters.q.trim())}
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition" className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
> >
<Youtube className="w-4 h-4" /> <Youtube className="w-4 h-4" />