The first S4 pass migrated a subset, which left same-idiom selection pills hand-rolled and made adjacent controls inconsistent (a /code-review finding). This completes the sweep so every selected-among-options control shares the recipe: - plain pills → .pill (+ aria-pressed): Channels status filters + view tabs, Sidebar filter-tag chips + date presets + custom-dates, Stats tabs + ranges. - glass-card-resting pills → .pill with the resting `glass-card glass-hover` kept in the non-pressed branch (solid accent when pressed): PlexSidebar library scope + the facet Chip (retired the round-1 "deferred" note — the recipe already expresses this via the resting branch, no new variant needed). - custom-dates aria-pressed is coerced with !! so a date string can't leak into the attribute and miss the [aria-pressed="true"] paint. - drop the dead `border` on ChannelTags' chip-solid (accent border on accent bg). Out of scope (a separate concern, NOT selection-among-options): primary/action buttons that share the accent fill — Banner action, ConfirmProvider primary, PlexBrowse primary, PlayerModal watched-toggle — these want a future .btn-primary recipe. Left by-design: tableHeader filter options (PopoverItem + keepSelectedPaint, owned by the popover platform).
398 lines
14 KiB
TypeScript
398 lines
14 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 { Library, Pencil, Share2, SlidersHorizontal, User } from "lucide-react";
|
|
import { api, type FeedFilters, type Tag } from "../lib/api";
|
|
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
|
import { shareUrl } from "../lib/urlState";
|
|
import { copyText } from "../lib/clipboard";
|
|
import { notify } from "../lib/notifications";
|
|
import { useDebounced } from "../lib/useDebounced";
|
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
|
import TagManager from "./TagManager";
|
|
import SavedViewsWidget from "./SavedViewsWidget";
|
|
import SidePanel from "./SidePanel";
|
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
|
import ChannelPicker from "./ChannelPicker";
|
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
|
import { useChannelsActions } from "./ChannelsProvider";
|
|
import { useMe } from "../lib/useMe";
|
|
|
|
// 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: 30, key: "1month" },
|
|
{ days: 180, key: "6months" },
|
|
{ days: 365, key: "1year" },
|
|
];
|
|
|
|
function TagChip({
|
|
tag,
|
|
count,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
tag: Tag;
|
|
count: number;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<button
|
|
data-testid="filter-tag-chip"
|
|
data-tag={tag.name}
|
|
onClick={onClick}
|
|
title={t("sidebar.channelCount", { count })}
|
|
aria-pressed={active}
|
|
className="pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs hover:shadow-sm active:translate-y-px"
|
|
>
|
|
<span>{tag.name}</span>
|
|
<span
|
|
className={`tabular-nums text-3xs leading-none px-1 py-0.5 rounded-full ${
|
|
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
|
|
}`}
|
|
>
|
|
{count}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function Sidebar({
|
|
layout,
|
|
setLayout,
|
|
collapsed,
|
|
onToggleCollapse,
|
|
}: {
|
|
layout: PanelLayout;
|
|
setLayout: (l: PanelLayout) => void;
|
|
collapsed: boolean;
|
|
onToggleCollapse: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const isDemo = useMe().is_demo;
|
|
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 = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
|
|
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) };
|
|
const facetsQuery = useQuery({
|
|
queryKey: qk.facets(facetFilters),
|
|
queryFn: () => api.facets(facetFilters),
|
|
staleTime: 30_000,
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
const facetsReady = !!facetsQuery.data;
|
|
const facetCounts = facetsQuery.data?.counts ?? {};
|
|
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) || a.name.localeCompare(b.name));
|
|
};
|
|
|
|
const languages = tags.filter((t) => t.category === "language");
|
|
const topics = tags.filter((t) => t.category === "topic");
|
|
const userTags = tags.filter((t) => !t.system);
|
|
const [customDates, setCustomDates] = useState(false);
|
|
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
|
const [editing, setEditing] = useState(false);
|
|
|
|
function toggleTag(id: number) {
|
|
const has = filters.tags.includes(id);
|
|
setFilters({
|
|
...filters,
|
|
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
|
|
});
|
|
}
|
|
|
|
// 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 active = activeCount > 0;
|
|
|
|
async function shareView() {
|
|
if (await copyText(shareUrl(filters))) {
|
|
notify({ level: "success", message: t("sidebar.shareCopied") });
|
|
} else {
|
|
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
|
}
|
|
}
|
|
|
|
function clearAll() {
|
|
setCustomDates(false);
|
|
setFilters(clearedFilters(filters));
|
|
}
|
|
|
|
const available: Record<string, boolean> = {
|
|
savedviews: !isDemo,
|
|
date: true,
|
|
channel: true,
|
|
language: languages.length > 0,
|
|
topic: topics.length > 0,
|
|
tags: userTags.length > 0,
|
|
};
|
|
|
|
function widgetBody(id: string): ReactNode {
|
|
switch (id) {
|
|
case "savedviews":
|
|
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
|
|
case "channel":
|
|
return <ChannelPicker filters={filters} setFilters={setFilters} />;
|
|
case "date":
|
|
return (
|
|
<>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{DATE_PRESETS.map((p) => {
|
|
const activePreset = filters.maxAgeDays === p.days && !customDates;
|
|
return (
|
|
<button
|
|
key={p.days}
|
|
onClick={() =>
|
|
setFilters({
|
|
...filters,
|
|
maxAgeDays: activePreset ? undefined : p.days,
|
|
dateFrom: undefined,
|
|
dateTo: undefined,
|
|
})
|
|
}
|
|
aria-pressed={activePreset}
|
|
className="pill text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px"
|
|
>
|
|
{t("sidebar.datePreset." + p.key)}
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
onClick={() => {
|
|
const next = !customDates;
|
|
setCustomDates(next);
|
|
if (next) setFilters({ ...filters, maxAgeDays: undefined });
|
|
}}
|
|
aria-pressed={!!(customDates || filters.dateFrom || filters.dateTo)}
|
|
className="pill text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px"
|
|
>
|
|
{t("sidebar.custom")}
|
|
</button>
|
|
</div>
|
|
|
|
{(customDates || filters.dateFrom || filters.dateTo) && (
|
|
<div className="flex flex-col gap-1.5 mt-2">
|
|
<label className="flex items-center justify-between gap-2 text-xs">
|
|
<span className="text-muted">{t("sidebar.from")}</span>
|
|
<input
|
|
type="date"
|
|
value={filters.dateFrom ?? ""}
|
|
onChange={(e) =>
|
|
setFilters({
|
|
...filters,
|
|
dateFrom: e.target.value || undefined,
|
|
maxAgeDays: undefined,
|
|
})
|
|
}
|
|
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
</label>
|
|
<label className="flex items-center justify-between gap-2 text-xs">
|
|
<span className="text-muted">{t("sidebar.to")}</span>
|
|
<input
|
|
type="date"
|
|
value={filters.dateTo ?? ""}
|
|
onChange={(e) =>
|
|
setFilters({
|
|
...filters,
|
|
dateTo: e.target.value || undefined,
|
|
maxAgeDays: undefined,
|
|
})
|
|
}
|
|
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
|
/>
|
|
</label>
|
|
{(filters.dateFrom || filters.dateTo) && (
|
|
<button
|
|
onClick={() =>
|
|
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
|
|
}
|
|
className="text-2xs text-muted hover:text-accent self-end"
|
|
>
|
|
{t("sidebar.clearDates")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
case "language": {
|
|
const chips = visibleChips(languages);
|
|
return (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{chips.map((tg) => (
|
|
<TagChip
|
|
key={tg.id}
|
|
tag={tg}
|
|
count={chipCount(tg)}
|
|
active={filters.tags.includes(tg.id)}
|
|
onClick={() => toggleTag(tg.id)}
|
|
/>
|
|
))}
|
|
{facetsReady && chips.length === 0 && (
|
|
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
case "topic": {
|
|
const chips = visibleChips(topics);
|
|
return (
|
|
<>
|
|
<div
|
|
className="flex items-center justify-between mb-1.5"
|
|
title={t("sidebar.tagModeTooltip")}
|
|
>
|
|
<span className="text-3xs uppercase tracking-wide text-muted">
|
|
{t("sidebar.match")}
|
|
</span>
|
|
<div
|
|
className="flex items-center rounded-full border border-border bg-card p-0.5 text-3xs"
|
|
role="group"
|
|
aria-label={t("sidebar.match")}
|
|
>
|
|
{(["or", "and"] as const).map((m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => setFilters({ ...filters, tagMode: m })}
|
|
aria-pressed={filters.tagMode === m}
|
|
className="pill px-2 py-0.5 rounded-full"
|
|
>
|
|
{m === "or" ? t("sidebar.any") : t("sidebar.all")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{chips.map((tg) => (
|
|
<TagChip
|
|
key={tg.id}
|
|
tag={tg}
|
|
count={chipCount(tg)}
|
|
active={filters.tags.includes(tg.id)}
|
|
onClick={() => toggleTag(tg.id)}
|
|
/>
|
|
))}
|
|
{facetsReady && chips.length === 0 && (
|
|
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
case "tags": {
|
|
const tagChips = visibleChips(userTags);
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{tagChips.map((tg) => (
|
|
<TagChip
|
|
key={tg.id}
|
|
tag={tg}
|
|
count={chipCount(tg)}
|
|
active={filters.tags.includes(tg.id)}
|
|
onClick={() => toggleTag(tg.id)}
|
|
/>
|
|
))}
|
|
{facetsReady && tagChips.length === 0 && (
|
|
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
|
|
)}
|
|
<button
|
|
onClick={() => setTagManagerOpen(true)}
|
|
title={t("sidebar.manageTags")}
|
|
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
|
>
|
|
<Pencil className="w-3 h-3" />
|
|
{t("sidebar.manageTags")}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
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("feed")
|
|
.order.filter((id) => available[id])
|
|
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
|
|
|
|
const actions = (
|
|
<>
|
|
<button
|
|
onClick={clearAll}
|
|
disabled={!active}
|
|
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
|
|
>
|
|
{t("sidebar.clearAll")}
|
|
</button>
|
|
<button
|
|
onClick={shareView}
|
|
title={t("sidebar.shareView")}
|
|
aria-label={t("sidebar.shareView")}
|
|
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
|
>
|
|
<Share2 className="w-4 h-4" />
|
|
</button>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<SidePanel
|
|
title={t("sidebar.filters")}
|
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
|
count={activeCount}
|
|
collapsed={collapsed}
|
|
onToggleCollapse={onToggleCollapse}
|
|
editing={editing}
|
|
onToggleEditing={() => setEditing((e) => !e)}
|
|
onReset={() => setLayout(defaultLayout("feed"))}
|
|
editHint={t("sidebar.editHint")}
|
|
actions={actions}
|
|
>
|
|
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
|
|
<div
|
|
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs"
|
|
role="group"
|
|
aria-label={t("header.scope.label")}
|
|
>
|
|
{(["my", "all"] as const).map((s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setFilters({ ...filters, scope: s })}
|
|
title={t("header.scope." + s + "Tip")}
|
|
aria-pressed={filters.scope === s}
|
|
className="pill flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full"
|
|
>
|
|
{s === "my" ? <User className="w-3.5 h-3.5" /> : <Library className="w-3.5 h-3.5" />}
|
|
<span>{t("header.scope." + s)}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
|
|
|
{tagManagerOpen && (
|
|
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
|
)}
|
|
</SidePanel>
|
|
);
|
|
}
|