v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:30:28 +00:00
parent de5abeceb2
commit 07d0344be7
131 changed files with 3893 additions and 3596 deletions

View File

@@ -0,0 +1,221 @@
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>
)}
</>
);
}