diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx new file mode 100644 index 00000000..19d4c3d0 --- /dev/null +++ b/frontend/src/components/ChannelTags.tsx @@ -0,0 +1,260 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { Check, Plus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { type Tag } from "../lib/api"; +import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover"; +import { useScrollFade } from "../lib/useScrollFade"; + +// The channel tag surface, shared by the channel manager (table + cards) and the channel detail +// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell: +// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags +// (which grows the chip row) 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; +} + +function TagChip({ tag, onClick, title }: TagChipProps) { + const cls = + "shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-[9rem] truncate"; + return onClick ? ( + + ) : ( + {tag.name} + ); +} + +/** 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. (Exported for the channel + * detail page in Part 2; internal for now.) */ +function TagEditorMenu({ + userTags, + attachedIds, + onToggle, + onCreate, +}: { + userTags: Tag[]; + attachedIds: number[]; + onToggle: (tagId: number) => void; + onCreate: (name: string) => void; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(""); + const attached = new Set(attachedIds); + const submit = () => { + const name = draft.trim(); + if (!name) return; + onCreate(name); + setDraft(""); + }; + return ( +
+
+ {userTags.map((tg) => { + const on = attached.has(tg.id); + return ( + + ); + })} + {userTags.length === 0 && ( +

{t("channels.tags.noneYet")}

+ )} +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + submit(); + } + }} + maxLength={64} + placeholder={t("channels.tags.createPlaceholder")} + className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:border-accent outline-none" + /> + +
+
+ ); +} + +/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals + * all), followed by a stable "+" trigger that opens the shared editor. 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, +}: { + 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; +}) { + const { t } = useTranslation(); + const attached = userTags.filter((tg) => attachedIds.includes(tg.id)); + + // Greedy single-line fit: start showing all, shrink one chip at a time until the row (including + // the "+N" pill it then needs) fits its bounded width. Re-expands and re-fits on width/scale + // change via ResizeObserver — measurement is off the real rem-rendered chips, not a px guess. + const rowRef = useRef(null); + const [maxWidth, setMaxWidth] = useState(0); + const [visible, setVisible] = useState(attached.length); + + // Track the bounded width so a font-scale/viewport change re-fits — measured off the real box, + // never a px guess (widths here are rem and grow with the text-size slider). + useLayoutEffect(() => { + const el = rowRef.current; + if (!el || typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w != null) setMaxWidth(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Reset to "show all" whenever the tag set or the available width changes… + useLayoutEffect(() => { + setVisible(attached.length); + }, [attached.length, maxWidth]); + + // …then drop one chip at a time until the row (with the "+N" pill it then needs) fits its width. + useLayoutEffect(() => { + const el = rowRef.current; + if (el && el.scrollWidth > el.clientWidth + 1 && visible > 0) setVisible((v) => v - 1); + }, [visible, maxWidth, attached.length]); + + const hidden = attached.length - visible; + + const [editorOpen, setEditorOpen] = useState(false); + const editPop = useAnchoredPopover({ + open: editorOpen, + onOpenChange: setEditorOpen, + mode: "menu", + placement: "bottom-start", + }); + + const [overflowOpen, setOverflowOpen] = useState(false); + const overflowPop = useAnchoredPopover({ + open: overflowOpen, + onOpenChange: setOverflowOpen, + mode: "hover", + placement: "bottom", + }); + const fade = useScrollFade(); + + return ( + // max-w caps the cell's MAX-CONTENT so `table-layout:auto` can't ignore the column width and let + // an over-tagged row stretch the whole table (the documented channel/About-column trap). The chip + // row then greedily fits within it and collapses the rest into "+N". +
+
+ {attached.slice(0, visible).map((tg) => ( + onChipClick(tg.id, tg.name) : undefined} + title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined} + /> + ))} + {hidden > 0 && ( + + )} +
+ + + + {/* Hover: reveal every attached tag when some are collapsed into "+N". */} + + {attached.map((tg) => ( + onChipClick(tg.id, tg.name) : undefined} + title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined} + /> + ))} + + + {/* Click: the shared editor (toggle existing + create inline). */} + + + +
+ ); +} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 9bb41e8d..b0138fe5 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -12,16 +12,14 @@ import { History, LayoutGrid, Pencil, - Plus, RefreshCw, Table, UserMinus, X, } from "lucide-react"; -import { api, type ManagedChannel, type Tag } from "../lib/api"; +import { api, type ManagedChannel } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; -import { useScrollFade } from "../lib/useScrollFade"; import { formatEta, formatTotalHours, relativeTime } from "../lib/format"; import { subsColumn } from "./channelColumns"; import { notify } from "../lib/notifications"; @@ -29,7 +27,7 @@ import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; import { LoadingState, StateMessage } from "./QueryState"; -import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover"; +import { ChannelTagsControl } from "./ChannelTags"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; @@ -212,6 +210,19 @@ export default function Channels() { const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId); call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() })); }; + + // Create a brand-new personal tag from the row/card editor and attach it to this channel in one + // step (so tagging no longer requires the top-of-page "Manage tags" dialog first). Refresh the tag + // catalog so the new tag appears everywhere; attach optimistically via toggleTag. + const createAndAttachTag = (channelId: string, name: string) => { + api + .createTag({ name }) + .then((tag) => { + qc.invalidateQueries({ queryKey: qk.tags() }); + toggleTag(channelId, tag.id, false); + }) + .catch(() => notify({ level: "error", message: t("channels.tags.createFailed") })); + }; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { @@ -452,17 +463,21 @@ export default function Channels() { key: "tags", header: t("channels.cols.tags"), cardLabel: false, + // Bounded width so the cell can't widen the column and reflow the whole table; the control + // fills it and collapses overflow into "+N" (see ChannelTagsControl). + width: "15rem", filter: { kind: "multi", options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), }, render: (c) => ( - toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} - onTagClick={onFilterByTag} + attachedIds={c.tag_ids} + onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} + onCreate={(name) => createAndAttachTag(c.id, name)} + onChipClick={onFilterByTag} /> ), }, @@ -1023,90 +1038,6 @@ function SyncCell({ c }: { c: ManagedChannel }) { ); } -function TagsCell({ - c, - userTags, - onToggleTag, - onTagClick, -}: { - c: ManagedChannel; - userTags: Tag[]; - onToggleTag: (tagId: number) => void; - onTagClick: (tagId: number, name: string) => void; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const fade = useScrollFade(); - // R8 anchored-popover platform: positioning (measured flip via flip(), horizontal clamp via shift(), - // scroll-follow via autoUpdate, hide-on-leave-scroller via hide()), body portal, dismiss and - // topmost Escape all come from the shared hook — replacing this cell's former hand-rolled engine. - // The portal is what lets the CARD layout's per-row `transform` (a stacking context) NOT trap the - // menu under the next card. Focus management (roving nav / return-to-trigger) arrives in S2. - const pop = useAnchoredPopover({ - open, - onOpenChange: setOpen, - mode: "menu", - placement: "bottom-start", - }); - - if (userTags.length === 0) return null; - // Show only the tags actually attached to this channel; the “+” opens a per-channel - // picker to attach/detach (kept the convenient "kirakás" without listing every tag - // on every row). Tag create/delete still lives in the top "YOUR TAGS" row. - const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id)); - return ( -
- {attached.map((tg) => ( - - ))} - - - {userTags.map((tg) => { - const on = c.tag_ids.includes(tg.id); - return ( - - ); - })} - -
- ); -} - function ActionsCell({ c, isAdmin, diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index e9249120..756fdd3c 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -46,7 +46,11 @@ "tags": { "yourTags": "Your tags", "manage": "Manage tags", - "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)" + "yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)", + "noneYet": "No tags yet — create one below.", + "create": "Create tag", + "createPlaceholder": "New tag…", + "createFailed": "Could not create the tag." }, "loading": "Loading channels…", "empty": "No channels.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 55e3a113..4748812c 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -46,7 +46,11 @@ "tags": { "yourTags": "Címkéid", "manage": "Címkék kezelése", - "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)" + "yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)", + "noneYet": "Még nincs címke — hozz létre egyet lent.", + "create": "Címke létrehozása", + "createPlaceholder": "Új címke…", + "createFailed": "A címke létrehozása nem sikerült." }, "loading": "Csatornák betöltése…", "empty": "Nincsenek csatornák.", diff --git a/frontend/src/lib/api/tags.ts b/frontend/src/lib/api/tags.ts index f9eb575d..afb17edd 100644 --- a/frontend/src/lib/api/tags.ts +++ b/frontend/src/lib/api/tags.ts @@ -12,7 +12,7 @@ export interface Tag { export const tagsApi = { tags: (): Promise => req("/api/tags"), - createTag: (t: { name: string; color?: string; category?: string }) => + createTag: (t: { name: string; color?: string; category?: string }): Promise => req("/api/tags", { method: "POST", body: JSON.stringify(t) }), updateTag: (id: number, patch: { name?: string; color?: string }) => req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),