Files
siftlode/frontend/src/components/Header.tsx
T
peter 6d8b7be6d8 feat!: remove the shared demo account
The demo account — one shared user that whitelisted emails entered from
the login page, reset on a schedule — goes end to end (owner decision,
E34): demo.py and the reset job, users.is_demo and demo_whitelist
(migration 0098 deletes the demo user, its audit rows, the reset job's
override and notices, then drops the column and table; the downgrade
restores the empty schema), the /auth/demo entry, the admin whitelist and
reset, the Demo tab, the banner, the login page's "Try the demo", and
every is_demo branch in routes, userscope, scheduler and the UI.

require_human only ever refused the demo account, so its 87 uses become
current_user (owner-approved); require_write_scope keeps its own check.
The login page's "or" divider now shows only beside the Google button:
without the demo entry under it, an instance without Google would have
said "or" to nothing.

A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
2026-10-05 00:19:38 +02:00

126 lines
6.3 KiB
TypeScript

import { forwardRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { YouTubeIcon } from "../lib/brandIcons";
import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
import PlexViewToggle from "./PlexViewToggle";
import FeedScopeToggle from "./FeedScopeToggle";
import SyncStatus from "./SyncStatus";
import { useNavigation } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
import { useChannels, useChannelsActions } from "./channelsContext";
import { PaletteLauncher } from "./PaletteLauncher";
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
// has one, and the per-user sync chip at the right end.
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
// owns both the header slot and the padded content wrapper (E5 S7).
const Header = forwardRef<
HTMLDivElement,
{
me: Me;
// Trigger a live YouTube search for the current term (feed only).
onYtSearch: (q: string) => void;
}
>(function Header({ me, onYtSearch }, ref) {
const { t } = useTranslation();
const { page } = useNavigation();
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 { search: channelsSearch } = useChannels();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// 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.
// A page without a search field still gets the palette's entry (PaletteLauncher).
let search: ReactNode = <PaletteLauncher />;
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(
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
)}
goIcon={<YouTubeIcon className="w-4 h-4" />}
goDisabled={!trimmed}
/>
);
} else if (page === "plex" && me.plex_enabled) {
search = <PlexSearchBox />;
} 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
ref={ref}
// left-4 / right = 1rem + the scrollbar gutter defines the full content column; max-w-[--content-mw]
// + mx-auto then centres the header band on the SAME width the page's content uses, so on a max-width
// page (Plex/Channels/Playlists) the band lines up with the centred grid, not the whole column. `none`
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
// once the column is wider than the box's max-w — not hugged to the left edge.
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
>
{search}
{/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
{page === "feed" && <FeedScopeToggle />}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. The
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
{/* 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>
);
});
export default Header;