refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
+260
View File
@@ -0,0 +1,260 @@
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys";
import { SlidersHorizontal } from "lucide-react";
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../../lib/api";
import { defaultLayout, type PanelLayout } from "../../lib/panelLayout";
import { useDebounced } from "../../lib/useDebounced";
import { useActiveFilters } from "../../lib/useActiveFilters";
import TagManager from "../channels/TagManager";
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
import { useSavedViews } from "./savedViewsContext";
import FilterRail from "../../components/ui/FilterRail";
import { type PanelItem } from "../../components/ui/PanelGroups";
import { type MediaSourceKind } from "../../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import { facetOption } from "./facetOption";
import DateFilterSelect from "./DateFilterSelect";
import YearFilter from "./YearFilter";
import Combobox from "../../components/ui/Combobox";
import MatchToggle from "../../components/ui/MatchToggle";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "../channels/channelsContext";
import { useTags } from "../../lib/useTags";
import { compareText } from "../../lib/collate";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 1, key: "24h" },
{ days: 7, key: "1week" },
{ days: 14, key: "2weeks" },
{ days: 30, key: "1month" },
{ days: 90, key: "3months" },
{ days: 180, key: "6months" },
{ days: 365, key: "1year" },
{ days: 730, key: "2years" },
{ days: 1825, key: "5years" },
];
export default function Sidebar({
layout,
setLayout,
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const { openModal } = useSavedViews();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
const { focusChannel: onFocusChannel } = useChannelsActions();
const tagsQuery = useTags();
const tags = tagsQuery.data ?? [];
// Live per-tag channel counts for the current filter context. Debounce the search term so
// typing doesn't refire facets per keystroke, and keep previous counts during a refetch.
const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) };
// Shared with the feed's count (same key + filters -> React Query dedupes to one request): the feed
// reads `.count`, this reads `.facets` (PERF-13, replaces the separate facets query).
const summaryQuery = useQuery({
queryKey: qk.feedSummary(facetFilters),
queryFn: () => api.feedSummary(facetFilters),
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const facetsReady = !!summaryQuery.data;
const facetCounts = summaryQuery.data?.facets ?? {};
// Ordering falls back to the tag's whole-library count until the facets land; the number shown does
// not (facetOption).
const chipCount = (tag: Tag): number =>
facetsReady ? (facetCounts[String(tag.id)] ?? 0) : tag.channel_count;
const visibleChips = (list: Tag[]): Tag[] => {
const shown = facetsReady
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
: list;
return [...shown].sort(
(a, b) => chipCount(b) - chipCount(a) || compareText(a.name, b.name, lang),
);
};
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
const userTags = tags.filter((t) => !t.system);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
// the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "feed";
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
// only its own slice: its selection replaces the ids that belong to its list, leaving the others.
const tagOption = (tg: Tag) => facetOption(tg, summaryQuery.data?.facets);
const selectedIn = (list: Tag[]): string[] =>
filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String);
function setTagSelection(list: Tag[], next: string[]) {
const inList = new Set(list.map((tg) => tg.id));
setFilters({
...filters,
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
});
}
// Each tag category (language/topic/other) has its OWN any/all match, set from the switch above its
// picker (ui/MatchToggle); the store keeps the older or/and words.
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
// The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to
// channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually
// exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears
// it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here.
function userTagsCombo(): ReactNode {
const noneOn = !!filters.untagged;
const inList = (id: number) => userTags.some((tg) => tg.id === id);
const onChange = (next: string[]) => {
if (next.includes(NO_TAG_SENTINEL) && !noneOn) {
setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true });
} else {
const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number);
setFilters({
...filters,
tags: [...filters.tags.filter((id) => !inList(id)), ...reals],
untagged: false,
});
}
};
return (
<>
<MatchToggle
picked={selectedIn(userTags).length}
value={filters.tagModes.other === "and" ? "all" : "any"}
onChange={(m) => setTagMode("other", m === "all" ? "and" : "or")}
/>
<Combobox
label={t("sidebar.widget.tags")}
options={[
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
...visibleChips(userTags).map(tagOption),
]}
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
onChange={onChange}
emptyText={t("sidebar.noMatchingTags")}
/>
</>
);
}
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
// disagree about what's applied.
const activeCount = useActiveFilters(filters, setFilters).length;
const available: Record<string, boolean> = {
date: true,
year: true,
channel: true,
language: languages.length > 0,
topic: topics.length > 0,
tags: userTags.length > 0,
};
function widgetBody(id: string): ReactNode {
switch (id) {
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date":
return (
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
);
case "year":
return <YearFilter filters={filters} setFilters={setFilters} />;
case "language":
return (
<>
<MatchToggle
picked={selectedIn(languages).length}
value={filters.tagModes.language === "and" ? "all" : "any"}
onChange={(m) => setTagMode("language", m === "all" ? "and" : "or")}
/>
<Combobox
label={t("sidebar.widget.language")}
options={visibleChips(languages).map(tagOption)}
selected={selectedIn(languages)}
onChange={(next) => setTagSelection(languages, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
</>
);
case "topic":
return (
<>
<MatchToggle
picked={selectedIn(topics).length}
value={filters.tagModes.topic === "and" ? "all" : "any"}
onChange={(m) => setTagMode("topic", m === "all" ? "and" : "or")}
/>
<Combobox
label={t("sidebar.widget.topic")}
options={visibleChips(topics).map(tagOption)}
selected={selectedIn(topics)}
onChange={(next) => setTagSelection(topics, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
</>
);
case "tags":
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
// body is just the picker — user tags plus the "No tag" sentinel.
return userTagsCombo();
default:
return null;
}
}
// Group ids come from the shared default order (panelLayout) so there's one source of truth;
// the actual render order is the user's saved layout (PanelGroups orders by it).
const items: PanelItem[] = defaultLayout(source)
.order.filter((id) => available[id])
.map((id) => ({
id,
title: t("sidebar.widget." + id),
body: widgetBody(id),
// The widgets aren't collapsible, so a widget's title can act as its own trigger: "Your tags"
// opens the tag manager (replacing the old inline Manage button).
onTitleClick: id === "tags" ? () => setTagManagerOpen(true) : undefined,
titleActionLabel: id === "tags" ? t("sidebar.manageTags") : undefined,
}));
return (
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
// The feed widgets are simple label + control rows, not collapsible islands.
collapsible={false}
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
// full manager modal. Feed rail only.
iconExpanded={
source === "feed" ? (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
) : undefined
}
onTitleClick={source === "feed" ? openModal : undefined}
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
</FilterRail>
);
}