222 lines
7.2 KiB
TypeScript
222 lines
7.2 KiB
TypeScript
import { lazy, type ReactNode, Suspense, useRef, useState } from "react";
|
|
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { useAuth } from "../../hooks/useAuth.ts";
|
|
import { useWS } from "../../hooks/useWS.ts";
|
|
import { NotificationBell } from "./NotificationBell.tsx";
|
|
import { ChatButton } from "./ChatButton.tsx";
|
|
import { UserMenu } from "./UserMenu.tsx";
|
|
import { SearchBar } from "./SearchBar.tsx";
|
|
import { SITE_EMOJI, SITE_NAME } from "../../hooks/useDocumentTitle.ts";
|
|
|
|
const DumpCreateModal = lazy(() =>
|
|
import("../dump/create/DumpCreateModal.tsx").then((m) => ({
|
|
default: m.DumpCreateModal,
|
|
}))
|
|
);
|
|
|
|
/** Carried in a search entry's history state: the history index of the page
|
|
* the search started from, so leaving search returns there in one step
|
|
* however many searches (or result tabs) came in between. */
|
|
interface SearchEntryState {
|
|
searchExit?: number;
|
|
}
|
|
|
|
/** React Router's index of the current history entry — 0 for the first page
|
|
* of the session, which has nothing in the app to go back to. */
|
|
function historyIndex(): number {
|
|
return (window.history.state as { idx?: number } | null)?.idx ?? 0;
|
|
}
|
|
|
|
export function AppHeader(
|
|
{ centerSlot, disableNew, initialDumpUrl }: {
|
|
centerSlot?: ReactNode;
|
|
disableNew?: boolean;
|
|
initialDumpUrl?: string;
|
|
},
|
|
) {
|
|
const { user } = useAuth();
|
|
const { wsStatus, wsErrorMessage } = useWS();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [searchParams] = useSearchParams();
|
|
// The results page and each of its tabs (/search/~/users…).
|
|
const isSearchPage = location.pathname === "/search" ||
|
|
location.pathname.startsWith("/search/");
|
|
const query = isSearchPage ? searchParams.get("q") ?? "" : "";
|
|
|
|
// On the results page the search stays open, holding the query it shows.
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const searchExpanded = isSearchPage || searchOpen;
|
|
const [draft, setDraft] = useState(query);
|
|
const [prevQuery, setPrevQuery] = useState(query);
|
|
if (prevQuery !== query) {
|
|
setPrevQuery(query);
|
|
setDraft(query);
|
|
}
|
|
|
|
const [createModalOpen, setCreateModalOpen] = useState(!!initialDumpUrl);
|
|
const searchToggleRef = useRef<HTMLButtonElement>(null);
|
|
|
|
function submitSearch(q: string) {
|
|
const here = location.state as SearchEntryState | null;
|
|
// A search from elsewhere remembers that page; one from the results page
|
|
// passes on what it was given, or — for results reached by a link —
|
|
// the page before them. Results that opened the session have nowhere to
|
|
// return to, so a new search just replaces them.
|
|
const searchExit = !isSearchPage
|
|
? historyIndex()
|
|
: here?.searchExit ?? (historyIndex() > 0 ? historyIndex() - 1 : null);
|
|
const to = `/search?q=${encodeURIComponent(q)}`;
|
|
if (searchExit === null) navigate(to, { replace: true });
|
|
else navigate(to, { state: { searchExit } satisfies SearchEntryState });
|
|
}
|
|
|
|
function closeSearch() {
|
|
if (isSearchPage) {
|
|
const exit = (location.state as SearchEntryState | null)?.searchExit;
|
|
if (exit !== undefined) navigate(exit - historyIndex());
|
|
else if (historyIndex() > 0) navigate(-1);
|
|
else navigate("/");
|
|
} else {
|
|
setSearchOpen(false);
|
|
setDraft("");
|
|
searchToggleRef.current?.focus();
|
|
}
|
|
}
|
|
|
|
// Escape leaves an opened search, but only reverts an edited query on the
|
|
// results page — leaving that page is the ✕'s job.
|
|
function escapeSearch() {
|
|
if (isSearchPage) setDraft(query);
|
|
else closeSearch();
|
|
}
|
|
|
|
function handleSearchToggle() {
|
|
if (searchExpanded) closeSearch();
|
|
else setSearchOpen(true);
|
|
}
|
|
|
|
const searchBarProps = {
|
|
expanded: searchExpanded,
|
|
value: draft,
|
|
onValueChange: setDraft,
|
|
onSubmit: submitSearch,
|
|
onCancel: escapeSearch,
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<header className="app-header app-header--has-center">
|
|
<Link to="/" className="app-header-brand">
|
|
{SITE_EMOJI}
|
|
<span className="app-header-brand-name">
|
|
{" "}
|
|
{SITE_NAME}
|
|
</span>
|
|
</Link>
|
|
|
|
<div className="app-header-center">
|
|
{centerSlot}
|
|
<SearchBar {...searchBarProps} />
|
|
</div>
|
|
|
|
<nav className="app-header-nav">
|
|
<button
|
|
ref={searchToggleRef}
|
|
type="button"
|
|
className="nav-search-btn"
|
|
aria-label={searchExpanded ? t`Cancel search` : t`Search`}
|
|
aria-expanded={searchExpanded}
|
|
onClick={handleSearchToggle}
|
|
>
|
|
{searchExpanded ? "✕" : "🔍"}
|
|
</button>
|
|
{user
|
|
? (
|
|
<>
|
|
{/* Full text links — hidden below the compact breakpoint */}
|
|
<div className="nav-links">
|
|
<Link
|
|
to={`/users/${user.username}`}
|
|
className="app-header-user"
|
|
>
|
|
{user.username}
|
|
</Link>
|
|
<Link
|
|
to={`/users/${user.username}/playlists`}
|
|
className="app-header-user"
|
|
>
|
|
<Trans>Playlists</Trans>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Compact avatar menu — hidden above the compact breakpoint */}
|
|
<div className="nav-compact">
|
|
<UserMenu user={user} />
|
|
</div>
|
|
|
|
<ChatButton />
|
|
<NotificationBell />
|
|
<button
|
|
type="button"
|
|
className="btn-primary"
|
|
onClick={() => setCreateModalOpen(true)}
|
|
disabled={disableNew}
|
|
title={disableNew ? t`Server unreachable` : undefined}
|
|
>
|
|
+<span className="btn-new-label">
|
|
<Trans>Dump</Trans>
|
|
</span>
|
|
</button>
|
|
</>
|
|
)
|
|
: (
|
|
<>
|
|
<button type="button" onClick={() => navigate("/login")}>
|
|
<Trans>Log in</Trans>
|
|
</button>
|
|
</>
|
|
)}
|
|
</nav>
|
|
</header>
|
|
|
|
{
|
|
/* Stays mounted so the row can slide open and shut; `inert` keeps the
|
|
collapsed input out of the tab order. */
|
|
}
|
|
<div
|
|
className={`header-search-reveal${
|
|
searchExpanded ? " header-search-reveal--open" : ""
|
|
}`}
|
|
inert={!searchExpanded}
|
|
>
|
|
<div className="header-search-reveal-clip">
|
|
<div className="header-search-below">
|
|
<SearchBar {...searchBarProps} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{wsStatus === "disconnected" && wsErrorMessage && (
|
|
<div className="app-header-status" role="alert">
|
|
<strong>
|
|
<Trans>Live updates unavailable.</Trans>
|
|
</strong>{" "}
|
|
{wsErrorMessage}
|
|
</div>
|
|
)}
|
|
|
|
{createModalOpen && (
|
|
<Suspense>
|
|
<DumpCreateModal
|
|
onClose={() => setCreateModalOpen(false)}
|
|
initialUrl={initialDumpUrl}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</>
|
|
);
|
|
}
|