feat(keys): Back returns focus to the item a view was opened from; "Feed" everywhere; every backend id named
Owner answers to the E11 S5 raises (2026-09-13): 1 → A, 2 → "Feed" in every language, 3 → translate the
proxy label and fix every config key missing in BOTH languages ("maintenance", "tmdb" — old bugs).
Focus across a view change (1-A). Opening a Plex title from Home's rail, a channel from the Channels
table or a title from a Plex grid switches the page or the sub-view, which unmounts the list; Back used
to hand focus to <body>, so the next `j` started over at the top. The YouTube player (an overlay)
already restored onto the card.
- `lib/listNav`: `navPosition()` (list's place in reading order, the page's list count, item index)
and `restoreNavPosition()`: focuses the item once the page has as many lists on screen as when it was
left, polls on a timer (a hidden tab runs no animation frames), gives up after 2 s or at once when
something already holds focus, focuses without scrolling (the scroll restore owns the position).
- `lib/history`: `rememberNavPosition()` stamps `sfNav` onto the entry being left and returns the state
without it (a new entry must not inherit it); one popstate listener restores and spends the stamp,
so a later landing on the same entry (an overlay closing over it) cannot pull focus to an old item.
- Called before every view push: `setPage`, `openChannel`, `enterYtSearch`, `useHistorySubview.open`,
the Channels tab push. `navPosition.wiring.contract.test` fails on any `history.pushState(` without
it (the overlay stack's own push is the one exemption).
"Feed" (2). The module is "Feed" in both languages: every "hírfolyam" in the Hungarian UI (≈45 strings,
suffixed: Feedből, Feeded, Feedben…) and the prose "feed" in English that means the module ("your
Feed", "Hide from Feed"); "RSS feed" keeps its meaning. The welcome email says it too.
Backend ids without a name (3). The Configuration page showed `maintenance`, `tmdb`,
`maintenance_revalidate_batch`, `download_upload_max_bytes`, `audit_retention_days`, `plex_overlay_map`,
`tmdb_api_key` and `tmdb_language` as raw identifiers — the lookup falls back to the id, and the i18n
parity test stayed green because both languages missed the same keys. Added labels + hints (EN/HU),
the two groups (and their tab order), the missing `scheduler.jobDesc.demo_reset`, removed the stale
`plex_sync_interval_min` field, and "YouTube API kimenő proxy". `i18n/backendIds.contract.test` reads
`sysconfig.SPECS`, `scheduler.JOB_INTERVALS` and the audit/quota action enums from the backend sources
and fails on an id without its name in either language, or a name left for an id the backend dropped.
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
//
|
||||
// setPage() pushes a clean { sfPage } entry (dropping _sub/_ov), so each page starts at its root.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { navPosition, restoreNavPosition, type NavPosition } from "./listNav";
|
||||
import { urlWithParams } from "./urlState";
|
||||
|
||||
// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------
|
||||
@@ -64,6 +65,36 @@ export function subscribeNav(cb: () => void): () => void {
|
||||
return () => navListeners.delete(cb);
|
||||
}
|
||||
|
||||
// --- The list item a view was left from (history.state.sfNav) -------------------------------------
|
||||
// See lib/listNav "The item a view was left from". Stamped onto the entry being LEFT, right before a
|
||||
// push opens a new view over it; read when Back/Forward lands on that entry, and dropped from it there,
|
||||
// so a later landing on the same entry — an overlay closing over it — cannot pull focus to an old item.
|
||||
let cancelNavRestore = () => {};
|
||||
let navRestoreInstalled = false;
|
||||
function ensureNavRestore(): void {
|
||||
if (navRestoreInstalled) return;
|
||||
navRestoreInstalled = true;
|
||||
window.addEventListener("popstate", (e) => {
|
||||
cancelNavRestore();
|
||||
const pos = (e.state as { sfNav?: NavPosition } | null)?.sfNav;
|
||||
if (!pos) return;
|
||||
const { sfNav: _spent, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
window.history.replaceState(st, "");
|
||||
cancelNavRestore = restoreNavPosition(pos);
|
||||
});
|
||||
}
|
||||
|
||||
/** Call right before a pushState that opens a new view over the current entry (a page, a sub-view, a
|
||||
* channel, a tab). Returns the current state WITHOUT the stamp: a push that spreads the current state
|
||||
* must spread this, so the new entry does not inherit a position it was never left from. */
|
||||
export function rememberNavPosition(): Record<string, unknown> {
|
||||
ensureNavRestore();
|
||||
const { sfNav: _stale, ...st } = (window.history.state ?? {}) as Record<string, unknown>;
|
||||
const pos = navPosition();
|
||||
window.history.replaceState(pos ? { ...st, sfNav: pos } : st, "");
|
||||
return st;
|
||||
}
|
||||
|
||||
// --- Sub-views (value carried in history.state._sub) ---------------------------------------
|
||||
/** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is
|
||||
* bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */
|
||||
@@ -127,7 +158,7 @@ export function useHistorySubview<T>(
|
||||
// A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back
|
||||
// restores the view we came from) — then notify PageScroller, which this push doesn't re-render.
|
||||
window.history.pushState(
|
||||
{ ...window.history.state, _sub: next, sfScroll: nextNavId() },
|
||||
{ ...rememberNavPosition(), _sub: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
href,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user