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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user