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

Binary file not shown.

View File

@@ -0,0 +1,45 @@
import { Trans } from "@lingui/react/macro";
import { useCategories } from "../../hooks/useCategories.ts";
/**
* Multi-select of categories rendered as toggle chips. Renders nothing when no
* categories are configured, so dump forms stay unchanged until an admin adds
* the first category.
*/
export function CategorySelect(
{ selected, onToggle, disabled }: {
selected: Set<string>;
onToggle: (id: string) => void;
disabled?: boolean;
},
) {
const { categories } = useCategories();
if (categories.length === 0) return null;
return (
<div className="form-field">
<span className="form-label">
<Trans>Categories</Trans>
</span>
<div className="category-select">
{categories.map((category) => {
const isActive = selected.has(category.id);
return (
<button
type="button"
key={category.id}
className={`category-chip-toggle${
isActive ? " category-chip-toggle--active" : ""
}`}
onClick={() => onToggle(category.id)}
disabled={disabled}
aria-pressed={isActive}
>
{category.name}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import {
type CSSProperties,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro";
import { useCategories } from "../../hooks/useCategories.ts";
import { DEFAULT_FEED_TAB } from "../../config/feedTabs.ts";
/**
* Header dropdown that switches the feed scope between "All" and a category,
* preserving the active feed tab (e.g. /music/new → /cinema/new). Rendered as
* the leading item of the feed-sort tab row so it reads as part of the bar.
*
* A custom popover (not a native <select>) keeps it on-theme; the menu uses
* fixed positioning so the row's `overflow-x: auto` can't clip it. `~` is the
* sentinel value for the all-categories feed. Hidden when no categories exist.
*/
export function CategorySwitcher() {
const { categories } = useCategories();
const navigate = useNavigate();
const { categorySlug, feedTab } = useParams();
const [open, setOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
const wrapRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLUListElement>(null);
// Close on outside click / Escape. The menu is portaled out of the wrapper,
// so check it separately or selecting an option would close before navigating.
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
const target = e.target as Node;
if (
wrapRef.current && !wrapRef.current.contains(target) &&
menuRef.current && !menuRef.current.contains(target)
) {
setOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Anchor the fixed-position menu to the trigger; keep it aligned on scroll.
useLayoutEffect(() => {
if (!open) return;
const update = () => {
const r = btnRef.current?.getBoundingClientRect();
if (r) setMenuStyle({ top: r.bottom + 6, left: r.left });
};
update();
globalThis.addEventListener("scroll", update, true);
globalThis.addEventListener("resize", update);
return () => {
globalThis.removeEventListener("scroll", update, true);
globalThis.removeEventListener("resize", update);
};
}, [open]);
if (categories.length === 0) return null;
const value = categorySlug ?? "~";
const tab = feedTab ?? DEFAULT_FEED_TAB;
const currentLabel = categorySlug
? categories.find((c) => c.slug === categorySlug)?.name ?? categorySlug
: t`All`;
const options = [
{ value: "~", label: t`All` },
...categories.map((c) => ({ value: c.slug, label: c.name })),
];
function select(slug: string) {
setOpen(false);
navigate(`/${slug}/${tab}`);
}
return (
<div className="category-switcher" ref={wrapRef}>
<button
ref={btnRef}
type="button"
className={`feed-sort-btn category-switcher-btn${
open ? " category-switcher-btn--open" : ""
}`}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
title={t`Category`}
>
<span className="category-switcher-label">{currentLabel}</span>
<span className="category-switcher-caret" aria-hidden="true" />
</button>
{open && createPortal(
<ul
ref={menuRef}
className="category-switcher-menu"
role="listbox"
style={menuStyle}
>
{options.map((o) => (
<li key={o.value}>
<button
type="button"
role="option"
aria-selected={value === o.value}
className={`category-switcher-option${
value === o.value ? " category-switcher-option--active" : ""
}`}
onClick={() => select(o.value)}
>
{o.label}
</button>
</li>
))}
</ul>,
document.body,
)}
<span className="feed-sort-sep" aria-hidden="true" />
</div>
);
}