`filters.channelId` was settable by exactly one thing: a "?channel=" URL from a shared view. The panel still rendered a CHANNEL card whose only control was "remove" — a remove button for a filter the UI could not apply. Two ways in now. The panel gets a real Channel group (searchable picker, first-class alongside date/tags/language, so the chip row surfaces it for free); normalizeLayout appends it to saved layouts, so nobody loses their arrangement. And a subscribed channel's page gets "Show in my feed", which applies the filter and keeps the rest of your filters — the point is "this channel, the way I normally read", which the channel page itself can't answer (it runs show=all, scope=all over the whole catalog). The picker owns its channel query so a few-hundred-entry list loads when the panel is actually opened, not on every feed visit, and says "showing 50 of N" rather than quietly stopping.
412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
import { type ReactNode, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
|
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 { 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";
|
|
|
|
// 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
|
|
onClick={onClick}
|
|
title={t("sidebar.channelCount", { count })}
|
|
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
|
active
|
|
? "bg-accent text-accent-fg border-accent"
|
|
: "bg-card border-border text-fg hover:border-accent"
|
|
}`}
|
|
>
|
|
<span>{tag.name}</span>
|
|
<span
|
|
className={`tabular-nums text-[10px] 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({
|
|
filters,
|
|
setFilters,
|
|
layout,
|
|
setLayout,
|
|
onFocusChannel,
|
|
isDemo = false,
|
|
collapsed,
|
|
onToggleCollapse,
|
|
}: {
|
|
filters: FeedFilters;
|
|
setFilters: (f: FeedFilters) => void;
|
|
layout: PanelLayout;
|
|
setLayout: (l: PanelLayout) => void;
|
|
onFocusChannel: (name: string) => void;
|
|
isDemo?: boolean;
|
|
collapsed: boolean;
|
|
onToggleCollapse: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tagsQuery = useQuery({ queryKey: ["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: ["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() {
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl(filters));
|
|
notify({ level: "success", message: t("sidebar.shareCopied") });
|
|
} catch {
|
|
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,
|
|
})
|
|
}
|
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
|
activePreset
|
|
? "bg-accent text-accent-fg border-accent"
|
|
: "bg-card border-border hover:border-accent"
|
|
}`}
|
|
>
|
|
{t("sidebar.datePreset." + p.key)}
|
|
</button>
|
|
);
|
|
})}
|
|
<button
|
|
onClick={() => {
|
|
const next = !customDates;
|
|
setCustomDates(next);
|
|
if (next) setFilters({ ...filters, maxAgeDays: undefined });
|
|
}}
|
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
|
customDates || filters.dateFrom || filters.dateTo
|
|
? "bg-accent text-accent-fg border-accent"
|
|
: "bg-card border-border hover:border-accent"
|
|
}`}
|
|
>
|
|
{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-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
|
/>
|
|
</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-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
|
/>
|
|
</label>
|
|
{(filters.dateFrom || filters.dateTo) && (
|
|
<button
|
|
onClick={() =>
|
|
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
|
|
}
|
|
className="text-[11px] 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-[11px] 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-[10px] 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-[10px]"
|
|
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={`px-2 py-0.5 rounded-full transition ${
|
|
filters.tagMode === m
|
|
? "bg-accent text-accent-fg"
|
|
: "text-muted hover:text-fg"
|
|
}`}
|
|
>
|
|
{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-[11px] 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-[11px] 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={`flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full transition ${
|
|
filters.scope === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
|
}`}
|
|
>
|
|
{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>
|
|
);
|
|
}
|