feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts
`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.
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { api } from "../../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import ChannelAboutContent from "../../features/channel/ChannelAbout";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { ChannelDetail } from "../../lib/api";
|
||||
import { linkify } from "../../lib/linkify";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { languageName, regionName, vocab } from "../../lib/vocab";
|
||||
|
||||
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
||||
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
|
||||
function parseKeywords(s: string): string[] {
|
||||
return (s.match(/"[^"]+"|\S+/g) ?? []).map((k) => k.replace(/^"|"$/g, "")).filter(Boolean);
|
||||
}
|
||||
// topicCategories are Wikipedia URLs (…/wiki/Music) → the page slug ("Pop_music"), de-duplicated. The
|
||||
// slug is the vocabulary word: named in the reader's language, else shown with spaces.
|
||||
function topicSlugs(urls: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const url of urls) {
|
||||
const seg = url.split("/wiki/")[1] ?? url.split("/").pop() ?? url;
|
||||
let slug = seg;
|
||||
try {
|
||||
slug = decodeURIComponent(seg);
|
||||
} catch {
|
||||
/* keep raw */
|
||||
}
|
||||
slug = slug.trim();
|
||||
if (slug) seen.add(slug);
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel "About" content — description, external links, and the enriched metadata
|
||||
* (country, language, topics, keywords). Shared by the channel detail page's About tab and the
|
||||
* Channel manager's About-column hover overlay, so both show exactly the same thing.
|
||||
*/
|
||||
export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const topics = ch.topic_categories?.length ? topicSlugs(ch.topic_categories) : [];
|
||||
const keywords = ch.keywords ? parseKeywords(ch.keywords) : [];
|
||||
const hasAboutDetails =
|
||||
!!ch.country || !!ch.default_language || topics.length > 0 || keywords.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{ch.description ? (
|
||||
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{linkify(ch.description)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||
)}
|
||||
{ch.external_links && ch.external_links.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{ch.external_links.map((l, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-accent hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
{l.title || l.url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasAboutDetails && (
|
||||
<div className="mt-5 space-y-2.5 text-sm border-t border-border pt-4">
|
||||
{ch.country && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0">{t("channel.country")}</span>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{/^[A-Za-z]{2}$/.test(ch.country) && (
|
||||
// Real SVG flag (flag-icons) — Windows/Chrome don't render flag emoji.
|
||||
<span
|
||||
className={`fi fi-${ch.country.toLowerCase()} rounded-xs shrink-0`}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{regionName(t, ch.country, i18n.language)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{ch.default_language && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0">{t("channel.language")}</span>
|
||||
<span>{languageName(ch.default_language, i18n.language, ch.default_language)}</span>
|
||||
</div>
|
||||
)}
|
||||
{topics.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.topics")}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Future search epic: these topic chips become clickable filters. */}
|
||||
{topics.map((tp) => (
|
||||
<Badge key={tp}>{vocab(t, "topic", tp, tp.replace(/_/g, " "))}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{keywords.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw, i) => (
|
||||
<Badge key={i}>{kw}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel tag editor says a failed load of the tag catalog as one, with a retry — not as "no tags
|
||||
// yet, create one below", which invited a duplicate of a tag that exists. i18n is key-passthrough.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("the channel tag editor", () => {
|
||||
it("says a failed load of the tags as one, with a retry — not as no tags yet", async () => {
|
||||
vi.spyOn(api, "tags").mockRejectedValue(new Error("down"));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
/>
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByLabelText("channels.row.editTags"));
|
||||
const alert = await screen.findByRole("alert", { hidden: true });
|
||||
expect(alert.textContent).toContain("common.loadError");
|
||||
expect(screen.queryByText("channels.tags.noneYet")).toBeNull();
|
||||
expect(alert.closest("[role=menu]")).toBeNull();
|
||||
expect(await screen.findByText("common.retry")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the suggested-tag ghost chips: on an UNTAGGED channel, ChannelTagsControl renders
|
||||
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
|
||||
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
|
||||
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
|
||||
// and a ResizeObserver-driven fit measurer, so we stub the observer and wrap in a FloatingTree.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
// Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu").
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import type { Tag } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const suggestions: SuggestedTag[] = [
|
||||
{ tag: docu, confidence: 0.95 },
|
||||
{ tag: history, confidence: 0.6 },
|
||||
];
|
||||
|
||||
function renderControl(props: Partial<Parameters<typeof ChannelTagsControl>[0]> = {}) {
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[docu, history]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
suggestions={suggestions}
|
||||
onAcceptSuggestion={onAccept}
|
||||
onDismissSuggestion={onDismiss}
|
||||
{...props}
|
||||
/>
|
||||
</FloatingTree>,
|
||||
);
|
||||
return { onAccept, onDismiss };
|
||||
}
|
||||
|
||||
describe("ChannelTagsControl suggested ghost chips", () => {
|
||||
it("renders a ghost chip per suggestion and accepts on click", () => {
|
||||
const { onAccept } = renderControl();
|
||||
// Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag.
|
||||
fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" }));
|
||||
expect(onAccept).toHaveBeenCalledWith(docu.id);
|
||||
});
|
||||
|
||||
it("dismisses via the × without accepting", () => {
|
||||
const { onAccept, onDismiss } = renderControl();
|
||||
const dismissButtons = screen.getAllByRole("button", {
|
||||
name: "channels.tags.dismissSuggestion",
|
||||
});
|
||||
// Two visible ghost chips (the measurer's copies are aria-hidden → excluded); order matches the
|
||||
// suggestions array, so the second × is the "history" suggestion.
|
||||
expect(dismissButtons).toHaveLength(2);
|
||||
fireEvent.click(dismissButtons[1]!);
|
||||
expect(onDismiss).toHaveBeenCalledWith(history.id);
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => {
|
||||
renderControl({ attachedIds: [docu.id] });
|
||||
// A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes
|
||||
// tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch).
|
||||
expect(
|
||||
screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,442 +0,0 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../../lib/api";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { fitChipsWithPill } from "../../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
|
||||
import { Chip, DismissibleChip } from "../../components/ui/Chip";
|
||||
import { chipClass } from "../../components/ui/chipStyles";
|
||||
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
// 1. Stable edit anchor — the "+" edit trigger LEADS the row (fixed left slot), so toggling tags
|
||||
// (which grows the chip row to its right) never moves it, and the editor popover doesn't drift.
|
||||
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
|
||||
// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
|
||||
// wraps to a 2nd line, so it can't widen its table column and reflow every other row.
|
||||
// The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline — tagging is
|
||||
// no longer gated on the top-of-page "Manage tags" dialog.
|
||||
|
||||
interface TagChipProps {
|
||||
tag: Tag;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// `block truncate` on the name, not the chip: the chip is inline-flex, where text-overflow cannot bite.
|
||||
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
|
||||
return onClick ? (
|
||||
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
|
||||
{name}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className={chipClass("tag", "xs")}>{name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// A suggested user tag: a dashed "ghost" chip the user hasn't applied yet, learned from
|
||||
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
|
||||
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
|
||||
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
|
||||
export interface SuggestedTag {
|
||||
tag: Tag;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function SuggestedChip({
|
||||
suggestion,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestion: SuggestedTag;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.round(suggestion.confidence * 100);
|
||||
return (
|
||||
<DismissibleChip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
className="max-w-36 font-medium"
|
||||
icon={<Sparkles />}
|
||||
onClick={onAccept}
|
||||
bodyTitle={t("channels.tags.suggestedHint", { pct })}
|
||||
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
onDismiss={onDismiss}
|
||||
dismissLabel={t("channels.tags.dismissSuggestion")}
|
||||
>
|
||||
{suggestion.tag.name}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
|
||||
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
|
||||
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
|
||||
* stops a 2nd suggestion from being clipped mid-word right next to the edit "+". */
|
||||
function SuggestedChipsLane({
|
||||
suggestions,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestions: SuggestedTag[];
|
||||
onAccept: (tagId: number) => void;
|
||||
onDismiss: (tagId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|");
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: key,
|
||||
initial: suggestions.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, suggestions.length - visible);
|
||||
|
||||
// Open only while there is a "+N" trigger to anchor it — derived, not closed by an effect, so the
|
||||
// frame in which the last collapsed chip fits again cannot also hold a menu pointing at a
|
||||
// reference that has gone. Same shape as TagFilterChips and the second row below, INCLUDING the
|
||||
// render-phase reset: deriving hides the card but never clears the flag (a hover card whose pill
|
||||
// unmounts under the cursor gets no mouseleave), so without this it springs back the next time
|
||||
// the lane overflows. Convergent — the write falsifies its own condition.
|
||||
const [overflowOpenRaw, setOverflowOpen] = useState(false);
|
||||
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
|
||||
const overflowOpen = overflowOpenRaw && hidden > 0;
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
const { setReference: setOverflowAnchor } = overflowPop.refs;
|
||||
|
||||
return (
|
||||
<div className={`${chipLaneClass} flex items-center gap-1 min-w-0 flex-1`}>
|
||||
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<span
|
||||
key={s.tag.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={chipClass("suggest", "xs")}>
|
||||
+{suggestions.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{suggestions.slice(0, visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
ref={setOverflowAnchor}
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{suggestions.slice(visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
||||
* "create + attach" input. Rendered inside an anchored `menu` popover. The rows are the caller's
|
||||
* `userTags`; whether that list has loaded is read from the catalog query itself (mounted only while
|
||||
* the editor is open), so a failed or pending load is said as such, not as "no tags yet". */
|
||||
function TagEditorMenu({
|
||||
userTags,
|
||||
attachedIds,
|
||||
onToggle,
|
||||
onCreate,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
attachedIds: number[];
|
||||
onToggle: (tagId: number) => void;
|
||||
onCreate: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tags = useTags();
|
||||
const attached = new Set(attachedIds);
|
||||
return (
|
||||
<MembershipMenu
|
||||
dense
|
||||
rows={userTags.map((tg) => ({ id: tg.id, label: tg.name, checked: attached.has(tg.id) }))}
|
||||
onToggle={(row) => onToggle(row.id as number)}
|
||||
status={tags.isError && !tags.data ? "error" : tags.isPending ? "loading" : "ready"}
|
||||
onRetry={() => void tags.refetch()}
|
||||
emptyText={t("channels.tags.noneYet")}
|
||||
errorText={t("common.loadError")}
|
||||
create={{
|
||||
placeholder: t("channels.tags.createPlaceholder"),
|
||||
maxLength: 64,
|
||||
submitLabel: t("channels.tags.create"),
|
||||
onCreate: (name) => {
|
||||
onCreate(name);
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The full tag control for a channel: a stable leading "+" trigger (opens the shared editor),
|
||||
* followed by a width-bounded chip row with "+N" overflow (hover reveals all). Fills its container
|
||||
* width, so a table cell bounds it via the column width and a card bounds it via the card. */
|
||||
export function ChannelTagsControl({
|
||||
userTags,
|
||||
attachedIds,
|
||||
onToggle,
|
||||
onCreate,
|
||||
onChipClick,
|
||||
suggestions,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
bounded = true,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
attachedIds: number[];
|
||||
onToggle: (tagId: number) => void;
|
||||
onCreate: (name: string) => void;
|
||||
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
|
||||
onChipClick?: (tagId: number, name: string) => void;
|
||||
/** Manager only: suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
|
||||
* chips after the (empty) attached row. Omit on the detail page. */
|
||||
suggestions?: SuggestedTag[];
|
||||
onAcceptSuggestion?: (tagId: number) => void;
|
||||
onDismissSuggestion?: (tagId: number) => void;
|
||||
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
|
||||
* table (the manager). Set false where there is free width (the channel detail header), so tags
|
||||
* fit inline instead of needlessly collapsing into "+N". */
|
||||
bounded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
|
||||
// Suggestions only surface for channels with no user tag yet, but guard anyway: never suggest a tag
|
||||
// the channel already carries (an optimistic accept adds it before the query refetches).
|
||||
const shownSuggestions = (suggestions ?? []).filter((s) => !attachedIds.includes(s.tag.id));
|
||||
|
||||
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
|
||||
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
|
||||
// gap and the pill width off real boxes, and fitChipsWithPill computes EXACTLY how many fit (reserving
|
||||
// the pill whenever any overflow). One pass — the count is right as tags are added/removed/renamed —
|
||||
// and it re-measures on width/scale change via the hook's ResizeObserver.
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
|
||||
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
|
||||
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: tagsKey,
|
||||
initial: attached.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
// Clamped, and not defensively: `useChipOverflowFit` keeps `visible` in state and re-decides in a
|
||||
// layout effect, so the render in which the chip set SHRINKS still holds the previous measurement
|
||||
// and a raw subtraction goes negative. An unclamped `hidden` slips past the `=== 0` reset below —
|
||||
// the flag survives exactly the window it exists to close, and the popover springs back on the
|
||||
// next re-fit.
|
||||
const hidden = Math.max(0, attached.length - visible);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const editPop = useAnchoredPopover({
|
||||
open: editorOpen,
|
||||
onOpenChange: setEditorOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
// The menu contains a free-text "create tag" input — keep both typed chars and pointer-hover out
|
||||
// of the roving list so neither steals the field's caret.
|
||||
hostsInput: true,
|
||||
});
|
||||
const { setReference: setEditAnchor } = editPop.refs;
|
||||
|
||||
// Derived for the same reason as the suggestion row above: if tags change so nothing is collapsed
|
||||
// anymore, the "+N" trigger unmounts, and the popover must already be shut in that same render —
|
||||
// and the flag reset with it, or the card returns unasked on the next overflow.
|
||||
const [overflowOpenRaw, setOverflowOpen] = useState(false);
|
||||
if (overflowOpenRaw && hidden === 0) setOverflowOpen(false);
|
||||
const overflowOpen = overflowOpenRaw && hidden > 0;
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
const { setReference: setOverflowAnchor } = overflowPop.refs;
|
||||
|
||||
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
|
||||
const pillClass = chipClass("filter", "xs");
|
||||
|
||||
return (
|
||||
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
|
||||
// content-driven cell re-solves EVERY column's width as chips are added, visibly shifting the whole
|
||||
// table. The fixed width keeps the table stable and centered; the chip row fits what it can and
|
||||
// collapses the rest into "+N". (max-w-full lets a narrower card shrink it.) Unbounded, the row
|
||||
// takes its container's width, so a wide header shows the tags inline instead of collapsing them.
|
||||
<div
|
||||
className={`${chipLaneClass} flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
|
||||
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
|
||||
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
|
||||
<Chip
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
ref={setEditAnchor}
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
icon={<Plus className="size-3" />}
|
||||
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
|
||||
{...editPop.getReferenceProps()}
|
||||
>
|
||||
{!bounded && t("channels.tags.add")}
|
||||
</Chip>
|
||||
|
||||
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
|
||||
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
|
||||
channels only, and accepting one removes the channel from the suggestion set — so a channel
|
||||
never shows both, and each lane gets the full cell width instead of clipping the other. */}
|
||||
{attached.length === 0 && shownSuggestions.length > 0 ? (
|
||||
<SuggestedChipsLane
|
||||
suggestions={shownSuggestions}
|
||||
onAccept={(tagId) => onAcceptSuggestion?.(tagId)}
|
||||
onDismiss={(tagId) => onDismissSuggestion?.(tagId)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths.
|
||||
Each chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its
|
||||
right edge; the pill is the sample the hook reserves room for. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{attached.map((tg, i) => (
|
||||
<span
|
||||
key={tg.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<TagChip tag={tg} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={pillClass}>
|
||||
+{attached.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{attached.slice(0, visible).map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={
|
||||
onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip
|
||||
size="xs"
|
||||
ref={setOverflowAnchor}
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
|
||||
/>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
|
||||
{/* Click: the shared editor (toggle existing + create inline). */}
|
||||
<PopoverSurface
|
||||
popover={editPop}
|
||||
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
||||
listInside
|
||||
>
|
||||
<TagEditorMenu
|
||||
userTags={userTags}
|
||||
attachedIds={attachedIds}
|
||||
onToggle={onToggle}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,14 +33,14 @@ import { useChannelActions } from "./useChannelActions";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useTagSuggestions } from "../../features/channel/useTagSuggestions";
|
||||
import { useChannelPriority } from "../../lib/useChannelPriority";
|
||||
import { placeByPriority } from "../../lib/channelOrder";
|
||||
import { focusNavItem } from "../../lib/listNav";
|
||||
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
|
||||
import ColumnSortControl from "../../components/ui/ColumnSortControl";
|
||||
import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||
import TagFilterChips from "../feed/TagFilterChips";
|
||||
import TagFilterChips from "../../features/filters/TagFilterChips";
|
||||
import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "../../components/ui/Pager";
|
||||
@@ -57,10 +57,10 @@ import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import { optionsFromColumns } from "../../lib/sortOptions";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import TagManager from "../../features/channel/TagManager";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
||||
import { useWizard } from "../../features/wizard/wizardContext";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api, type Tag } from "../../lib/api";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
|
||||
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
|
||||
// hover popover on the count listing the tagged channels (each a link that focuses it in the
|
||||
// Channel manager). The popover rides the shared platform, so the list's own scroll can't clip it and
|
||||
// the cursor can travel the gap into it (safePolygon) without the old hand-rolled 150 ms timer.
|
||||
function TagRow({
|
||||
tag,
|
||||
channels,
|
||||
onRename,
|
||||
onDelete,
|
||||
onPickChannel,
|
||||
}: {
|
||||
tag: Tag;
|
||||
channels: { id: string; title: string }[];
|
||||
onRename: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onPickChannel: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(tag.name);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { ref: popFadeRef, style: popFadeStyle } = useScrollFade();
|
||||
const commit = () => {
|
||||
const v = name.trim();
|
||||
if (v && v !== tag.name) onRename(v);
|
||||
else setName(tag.name);
|
||||
};
|
||||
const pop = useAnchoredPopover({
|
||||
open: open && channels.length > 0,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "right-start",
|
||||
offset: 8,
|
||||
hoverDelay: { open: 0, close: 150 },
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
||||
/>
|
||||
<span
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className={`text-xs text-muted tabular-nums shrink-0 ${
|
||||
channels.length
|
||||
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{t("tagManager.channels", { count: tag.channel_count })}
|
||||
</span>
|
||||
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}>
|
||||
<div className="text-2xs uppercase tracking-wide text-muted px-2 py-1">
|
||||
{t("tagManager.onChannels")}
|
||||
</div>
|
||||
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
|
||||
<div
|
||||
ref={popFadeRef}
|
||||
style={popFadeStyle}
|
||||
className="max-h-72 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
<Pressable
|
||||
key={ch.id}
|
||||
layer
|
||||
onClick={() => onPickChannel(ch.title)}
|
||||
className="w-full text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg truncate"
|
||||
>
|
||||
{ch.title}
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagManager({
|
||||
onClose,
|
||||
onFocusChannel,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onFocusChannel: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const { ref: listFadeRef, style: listFadeStyle } = useScrollFade();
|
||||
const [newName, setNewName] = useState("");
|
||||
const tagsQuery = useTags();
|
||||
const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const channelsForTag = (tagId: number) =>
|
||||
(channelsQuery.data ?? [])
|
||||
.filter((c) => c.tag_ids.includes(tagId))
|
||||
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
};
|
||||
const create = useMutation({
|
||||
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
|
||||
meta: { failTitle: "channels.tags.createFailed" },
|
||||
onSuccess: () => {
|
||||
setNewName("");
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
// A failure is said and the list refetched — a tag deleted in another tab used to fail in silence
|
||||
// (a 404 `not_found/tag` gets no global dialog) and stay listed.
|
||||
const onFailed = (e: unknown, fallback: string) => {
|
||||
notifyActionError(e, fallback);
|
||||
invalidate();
|
||||
};
|
||||
const rename = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.updateTag(v.id, { name: v.name }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => onFailed(e, t("tagManager.renameFailed")),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (v: { id: number; name: string }) => api.deleteTag(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
invalidate();
|
||||
notify({ level: "success", message: t("tagManager.deleted", { name: v.name }) });
|
||||
},
|
||||
onError: (e) => onFailed(e, t("tagManager.deleteFailed")),
|
||||
});
|
||||
|
||||
// Picking a channel from a tag's popover: close the dialog, then focus it in the manager.
|
||||
const pickChannel = (name: string) => {
|
||||
onClose();
|
||||
onFocusChannel(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal initialFocus="close" title={t("tagManager.title")} onClose={onClose}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{userTags.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
|
||||
) : (
|
||||
// Cap at ~8 rows tall, then scroll — the list can grow long.
|
||||
<div
|
||||
ref={listFadeRef}
|
||||
style={listFadeStyle}
|
||||
className="flex flex-col gap-2 max-h-88 overflow-y-auto no-scrollbar pr-1"
|
||||
>
|
||||
{userTags.map((tag) => (
|
||||
<TagRow
|
||||
key={tag.id}
|
||||
tag={tag}
|
||||
channels={channelsForTag(tag.id)}
|
||||
onPickChannel={pickChannel}
|
||||
onRename={(name) => rename.mutate({ id: tag.id, name })}
|
||||
onDelete={async () => {
|
||||
// Only confirm when the tag is actually in use; an unused tag deletes outright.
|
||||
if (tag.channel_count > 0) {
|
||||
const ok = await confirm({
|
||||
title: t("tagManager.deleteTitle"),
|
||||
message: t("tagManager.confirmDelete", {
|
||||
name: tag.name,
|
||||
count: tag.channel_count,
|
||||
}),
|
||||
confirmLabel: t("tagManager.delete"),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
del.mutate({ id: tag.id, name: tag.name });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim()) create.mutate(newName.trim());
|
||||
}}
|
||||
className="flex items-center gap-2 mt-2 pt-3 border-t border-border"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-muted shrink-0" />
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("tagManager.newPlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={!newName.trim()}
|
||||
>
|
||||
{t("tagManager.add")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import type { Column } from "../../components/ui/DataTable";
|
||||
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "../../features/channel/ChannelTags";
|
||||
import { StoredCell } from "./channelCells";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
import { RelativeTime } from "../../components/ui/RelativeTime";
|
||||
import type { FilterChip } from "../feed/TagFilterChips";
|
||||
import type { FilterChip } from "../../features/filters/TagFilterChips";
|
||||
|
||||
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
||||
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// The channel manager's public face: its store, mounted at the root and read app-wide.
|
||||
export { ChannelsProvider } from "./ChannelsProvider";
|
||||
export { useChannels, useChannelsActions } from "./channelsContext";
|
||||
@@ -1,129 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Container-logic contract of the tag-suggestion hook, the wiring the presentational
|
||||
// ChannelTagsControl test can't reach: suggestionsFor resolves tag ids to Tags and DROPS a suggestion
|
||||
// whose tag was deleted since the last compute; accept optimistically removes the WHOLE channel from
|
||||
// the /tags/suggestions cache and toggles the tag on; dismiss is session-only and scoped to one
|
||||
// (channel, tag); visible/high-confidence derive correctly. Runs against a real QueryClient so the
|
||||
// optimistic cache write is the actual code path (not a re-implementation).
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
// The confirm dialog's answer is controllable per test (the bulk-accept path gates on it).
|
||||
const confirmState = vi.hoisted(() => ({ result: true }));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({
|
||||
useConfirm: () => () => Promise.resolve(confirmState.result),
|
||||
}));
|
||||
|
||||
import { api, type Tag } from "../../lib/api";
|
||||
import type { TagSuggestions } from "../../lib/api/tags";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const tags = [docu, history];
|
||||
|
||||
// UC_a: two suggestions (one high-confidence). UC_b: one. UC_ghost: a suggestion for a tag id that no
|
||||
// longer exists in the catalog (deleted since compute) → must be filtered out everywhere.
|
||||
const seed = (): TagSuggestions => ({
|
||||
UC_a: [
|
||||
{ tag_id: docu.id, confidence: 0.95 },
|
||||
{ tag_id: history.id, confidence: 0.6 },
|
||||
],
|
||||
UC_b: [{ tag_id: history.id, confidence: 0.7 }],
|
||||
UC_ghost: [{ tag_id: 999, confidence: 0.99 }],
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const qc = new QueryClient({
|
||||
// Freeze the seeded cache so no refetch clobbers the optimistic writes under test.
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } },
|
||||
});
|
||||
qc.setQueryData(qk.tagSuggestions(), seed());
|
||||
vi.spyOn(api, "tagSuggestions").mockResolvedValue(seed());
|
||||
const toggleTag = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useTagSuggestions({ tags, toggleTag }), { wrapper });
|
||||
return { qc, toggleTag, result };
|
||||
}
|
||||
|
||||
describe("useTagSuggestions", () => {
|
||||
it("resolves tag ids to Tags and drops suggestions whose tag was deleted", () => {
|
||||
const { result } = setup();
|
||||
const forA = result.current.suggestionsFor("UC_a");
|
||||
expect(forA.map((s) => s.tag.id)).toEqual([docu.id, history.id]);
|
||||
// The suggestion for the missing tag id 999 is dropped, not rendered as a broken chip.
|
||||
expect(result.current.suggestionsFor("UC_ghost")).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives visible and high-confidence suggestions, excluding the deleted-tag one", () => {
|
||||
const { result } = setup();
|
||||
// UC_a(docu 0.95) + UC_a(history 0.6) + UC_b(history 0.7) = 3; UC_ghost excluded.
|
||||
expect(result.current.visibleSuggestions).toHaveLength(3);
|
||||
// Only the ≥0.9 one (UC_a/docu) is high-confidence.
|
||||
expect(result.current.highConfidence).toEqual([
|
||||
{ cid: "UC_a", tagId: docu.id, confidence: 0.95 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accept optimistically removes the whole channel from the cache and toggles the tag on", () => {
|
||||
const { qc, toggleTag, result } = setup();
|
||||
act(() => result.current.acceptSuggestion("UC_a", docu.id));
|
||||
// The real tag is attached (hasTag=false → attach).
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
// The whole channel is dropped from the suggestions cache (attached lane XOR ghost lane), so its
|
||||
// OTHER ghost chip (history) goes too — not just the accepted one.
|
||||
const cache = qc.getQueryData<TagSuggestions>(qk.tagSuggestions());
|
||||
expect(cache?.UC_a).toBeUndefined();
|
||||
expect(cache?.UC_b).toBeDefined(); // other channels untouched
|
||||
});
|
||||
|
||||
it("dismiss hides only that one (channel, tag) for the session", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.dismissSuggestion("UC_a", history.id));
|
||||
// UC_a loses only the history suggestion; docu stays.
|
||||
expect(result.current.suggestionsFor("UC_a").map((s) => s.tag.id)).toEqual([docu.id]);
|
||||
// The same tag on a DIFFERENT channel is not dismissed (dismiss is scoped to the pair).
|
||||
expect(result.current.suggestionsFor("UC_b").map((s) => s.tag.id)).toEqual([history.id]);
|
||||
});
|
||||
|
||||
it("bulk-accept applies ONLY the high-confidence suggestions after a confirm", async () => {
|
||||
confirmState.result = true;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
// Only UC_a/docu (0.95 ≥ 0.9) is accepted — not history (0.6) or UC_b/history (0.7).
|
||||
expect(toggleTag).toHaveBeenCalledTimes(1);
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
});
|
||||
|
||||
it("bulk-accept does nothing when the confirm is cancelled", async () => {
|
||||
confirmState.result = false;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
expect(toggleTag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type Tag, type TagSuggestions } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
|
||||
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
|
||||
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
|
||||
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
|
||||
const HIGH_CONFIDENCE = 0.9;
|
||||
|
||||
type VisibleSuggestion = { cid: string; tagId: number; confidence: number };
|
||||
|
||||
/** Suggested-tag state for the Channel Manager: the /tags/suggestions query, a session-only
|
||||
* dismiss set, and the accept / dismiss / bulk-accept actions. Suggestions are learned from the
|
||||
* user's own manual-tag pattern and shown as ghost chips on UNTAGGED channels. Extracted as a hook
|
||||
* (like useChannelTagActions) so the optimistic-cache and dismiss logic is unit-testable apart from
|
||||
* the large Channels component. */
|
||||
export function useTagSuggestions({
|
||||
tags,
|
||||
toggleTag,
|
||||
}: {
|
||||
/** The full tag catalog, to resolve a suggestion's tag_id → Tag (and drop tags deleted since compute). */
|
||||
tags: Tag[];
|
||||
/** The shared attach/detach toggle. Its onError must also refetch suggestions (see Channels) so a
|
||||
* failed accept restores the ghost chips this hook optimistically removed. */
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const suggestionsQuery = useQuery({ queryKey: qk.tagSuggestions(), queryFn: api.tagSuggestions });
|
||||
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
|
||||
const tagById = useMemo(() => new Map(tags.map((tg) => [tg.id, tg])), [tags]);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
||||
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
|
||||
|
||||
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
|
||||
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
|
||||
const suggestionsFor = (cid: string): SuggestedTag[] =>
|
||||
(suggestions[cid] ?? []).flatMap((s) => {
|
||||
const tag = tagById.get(s.tag_id);
|
||||
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
|
||||
});
|
||||
|
||||
const dismissSuggestion = (cid: string, tagId: number) =>
|
||||
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
|
||||
|
||||
const acceptSuggestion = (cid: string, tagId: number) => {
|
||||
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
|
||||
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
|
||||
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
|
||||
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; on success
|
||||
// its onSuccess re-invalidates suggestions, and on failure its onError refetches them — so a
|
||||
// failed accept restores these ghost chips rather than leaving an empty cell (see Channels' onError).
|
||||
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
|
||||
if (!old?.[cid]) return old;
|
||||
const next = { ...old };
|
||||
delete next[cid];
|
||||
return next;
|
||||
});
|
||||
toggleTag(cid, tagId, false);
|
||||
};
|
||||
|
||||
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
|
||||
// the bulk action accepts.
|
||||
const visibleSuggestions: VisibleSuggestion[] = Object.keys(suggestions).flatMap((cid) =>
|
||||
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
|
||||
);
|
||||
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
|
||||
const acceptAllHighConfidence = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.tags.acceptAllTitle"),
|
||||
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
|
||||
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
|
||||
});
|
||||
if (!ok) return;
|
||||
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
|
||||
};
|
||||
|
||||
return {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user