`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up (lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide, which features/ may read too; a page is still opened lazily by import(). Its contract test probes every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red. Six modules got that face; the shared channel UI (about, tags, tag manager) moved to features/channel, the filter chips and the saved-views context to features/filters, and the prefs store's controller type moved next to the store instead of importing it from its own page. The header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each naming why: they go when the header gets slots and every rail loads lazily. `chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its import() a silent no-op. Proved on a build with that mutant; today's build has none.
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
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 "../../features/channel/TagManager";
|
|
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
|
import { useSavedViews } from "../../features/filters/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";
|
|
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>
|
|
);
|
|
}
|