siftlode/frontend/src/components/Header.tsx

98 lines
4.2 KiB
TypeScript
Raw Normal View History

import { useTranslation } from "react-i18next";
import { Search, X, Youtube } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
// Contextual top bar over the content column. Primary navigation, account and the per-user sync
// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar.
// The header carries just the feed search (or the current page title).
export default function Header({
me,
filters,
setFilters,
page,
onYtSearch,
}: {
me: Me;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
page: Page;
// Trigger a live YouTube search for the current term (hidden for the demo account, which
// can't spend the shared quota).
onYtSearch: (q: string) => void;
}) {
const { t } = useTranslation();
const canSearchYt = !me.is_demo;
const trimmedQ = filters.q.trim();
return (
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
{page === "feed" ? (
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1 relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
value={filters.q}
onChange={(e) => {
const q = e.target.value;
// 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" } : {}) });
}}
onKeyDown={(e) => {
// Enter runs a live YouTube search for the typed term (the box itself filters
// the local catalog as you type; Enter escalates to the YouTube API).
if (e.key === "Enter" && trimmedQ && canSearchYt) onYtSearch(trimmedQ);
}}
placeholder={t("header.searchPlaceholder")}
className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent"
/>
{filters.q && (
<button
onClick={() => setFilters({ ...filters, q: "" })}
title={t("header.clearSearch")}
aria-label={t("header.clearSearch")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted hover:text-fg hover:bg-border/60 transition"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{trimmedQ && canSearchYt && (
<button
onClick={() => onYtSearch(trimmedQ)}
title={t("header.searchYoutubeTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-xs font-medium text-muted hover:text-accent hover:border-accent transition"
>
<Youtube className="w-4 h-4" />
<span className="hidden md:inline">{t("header.searchYoutube")}</span>
</button>
)}
</div>
) : (
<div className="flex-1 text-center text-sm font-semibold">
{page === "stats"
? t("header.usageStats")
: page === "playlists"
? t("header.account.playlists")
: page === "settings"
? t("settings.title")
: page === "scheduler"
? t("header.scheduler")
: page === "config"
? t("header.configuration")
: page === "users"
? t("header.users")
: page === "notifications"
? t("inbox.navLabel")
: page === "messages"
? t("messages.navLabel")
: t("header.channelManager")}
</div>
)}
</header>
);
}