Lift the Channels manager's shared local state (name filter, status chip,
active tab, column-reset token, focus-a-channel intent) out of App into a
split-context ChannelsProvider, nested inside NavigationProvider so its
focusChannel/goToFullHistory actions can drive navigation. The Channels page,
the header search box + sync chip, the feed filter sidebar, and the
notifications inbox all read it via useChannels()/useChannelsActions() instead
of props drilled through App. Channels also internalises onViewChannel /
onFilterByTag / onFocusChannel (nav + feed-filters straight from providers) and
takes only { me, onOpenWizard } now. App loses five useStates, the focusChannel
helper, the focus-clear effect, and a large block of per-page prop plumbing.
(Platform S6b.2)
125 lines
5.4 KiB
TypeScript
125 lines
5.4 KiB
TypeScript
import { type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Youtube } from "lucide-react";
|
|
import type { Me } from "../lib/api";
|
|
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
|
import ModuleName from "./ModuleName";
|
|
import SearchBar from "./SearchBar";
|
|
import SyncStatus from "./SyncStatus";
|
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
|
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
|
import { usePlex, usePlexActions } from "./PlexProvider";
|
|
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
|
|
|
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
|
|
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
|
|
// sync chip at the right end.
|
|
export default function Header({
|
|
me,
|
|
onYtSearch,
|
|
}: {
|
|
me: Me;
|
|
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
|
|
// which can't spend the shared quota).
|
|
onYtSearch: (q: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { page } = useNavigation();
|
|
const { setPage } = useNavigationActions();
|
|
const filters = useFeedFilters();
|
|
const { setFilters } = useFeedFiltersActions();
|
|
// Each module's search term lives in its own provider (shared with that module's rail/content):
|
|
// playlists name filter, Plex search term, channels name filter. The channel sync chip's
|
|
// "go to full history" jump is a ChannelsProvider action.
|
|
const { search: playlistsSearch } = usePlaylists();
|
|
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
|
const { q: plexQ } = usePlex();
|
|
const { setQ: setPlexQ } = usePlexActions();
|
|
const { search: channelsSearch } = useChannels();
|
|
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
|
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
|
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
|
|
// dynamic width so the pill is sized to the widest reachable title in the current language.
|
|
const order = moduleOrder(me);
|
|
const moduleLabels = order.map((p) => t(moduleLabelKey[p]));
|
|
const multiModule = order.length > 1;
|
|
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
|
|
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
|
|
|
// Per-page SearchBar wiring — null for modules without a search.
|
|
let search: ReactNode = null;
|
|
if (page === "feed") {
|
|
const trimmed = filters.q.trim();
|
|
search = (
|
|
<SearchBar
|
|
value={filters.q}
|
|
placeholder={t("header.searchPlaceholder")}
|
|
onChange={(q) => {
|
|
// When a search first appears, rank the feed by relevance — set atomically with the
|
|
// query (race-free vs. per-keystroke updates). Only overrides the default "newest"
|
|
// sort; a custom sort the user chose is left alone. Cleared in Feed.
|
|
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
|
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
|
}}
|
|
onGo={() => onYtSearch(trimmed)}
|
|
goLabel={t("header.searchYoutube")}
|
|
goTitle={t("header.searchYoutubeTip")}
|
|
goIcon={<Youtube className="w-4 h-4" />}
|
|
goDisabled={!trimmed || me.is_demo}
|
|
/>
|
|
);
|
|
} else if (page === "plex" && me.plex_enabled) {
|
|
search = (
|
|
<SearchBar
|
|
value={plexQ}
|
|
placeholder={t("plex.searchPlaceholder")}
|
|
onChange={setPlexQ}
|
|
onGo={blur}
|
|
/>
|
|
);
|
|
} else if (page === "channels") {
|
|
search = (
|
|
<SearchBar
|
|
value={channelsSearch}
|
|
placeholder={t("channels.searchPlaceholder")}
|
|
onChange={setChannelsSearch}
|
|
onGo={blur}
|
|
/>
|
|
);
|
|
} else if (page === "playlists") {
|
|
search = (
|
|
<SearchBar
|
|
value={playlistsSearch}
|
|
placeholder={t("playlists.searchPlaceholder")}
|
|
onChange={setPlaylistsSearch}
|
|
onGo={blur}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
// Absolutely positioned within the (relative) content column, so its left edge tracks the
|
|
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
|
// slim and/or no filter sidebar is present — no hard-coded offset.
|
|
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex items-center gap-3">
|
|
<ModuleName
|
|
label={t(moduleLabelKey[page])}
|
|
labels={moduleLabels}
|
|
onPrev={() => setPage(stepModule(me, page, -1))}
|
|
onNext={() => setPage(stepModule(me, page, 1))}
|
|
canPrev={multiModule}
|
|
canNext={multiModule}
|
|
/>
|
|
{search}
|
|
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
|
|
pills rather than pinned to the far right: on a wide window that just strands it. Only on
|
|
the two modules it says anything about: the feed those subscriptions fill, and the manager
|
|
where you curate them. It's noise on Plex (a different source entirely), playlists, etc. */}
|
|
{(page === "feed" || page === "channels") && (
|
|
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|