diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx index 8f112d63..95f5d0c6 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/AnchoredPopover.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, type ReactNode, type Ref } from "react"; +import { type CSSProperties, type ReactNode } from "react"; import { useFloating, autoUpdate, @@ -13,7 +13,6 @@ import { useDismiss, useRole, useInteractions, - useMergeRefs, useFloatingNodeId, safePolygon, FloatingNode, @@ -122,7 +121,6 @@ export function PopoverSurface({ className, style, children, - floatingRef, focusManaged = false, initialFocus, }: { @@ -130,8 +128,6 @@ export function PopoverSurface({ className?: string; style?: CSSProperties; children: ReactNode; - /** Extra ref merged onto the floating node (e.g. a useScrollFade callback ref). */ - floatingRef?: Ref; focusManaged?: boolean; initialFocus?: number | React.MutableRefObject; }) { @@ -144,30 +140,38 @@ export function PopoverSurface({ referenceHidden, isPositioned, open, + mode, } = popover; - const mergedRef = useMergeRefs([refs.setFloating, floatingRef ?? null]); - const floatingProps = getFloatingProps(); + const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as { + style?: CSSProperties; + } & Record; if (!open) return null; + const zClass = mode === "hover" ? "z-tooltip" : "z-popover"; + const surface = (
- {children} + {/* Inner wrapper carries the visual styling + pop-in animation. Keeping the ANIMATION off the + positioned element is essential: the popIn keyframe animates `transform`, which would + otherwise override Floating UI's `transform: translate(x,y)` and park the popover at (0,0) + for the animation's duration — the "flash in the top-left corner" bug. */} +
+ {children} +
); diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index cade20b0..5c70a382 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -3,7 +3,6 @@ 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: @@ -139,36 +138,47 @@ export function ChannelTagsControl({ 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. + // Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their + // natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the + // pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always + // right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real + // rem boxes, never a px guess. const rowRef = useRef(null); - const [maxWidth, setMaxWidth] = useState(0); + const measureRef = useRef(null); const [visible, setVisible] = useState(attached.length); + const idsKey = attached.map((tg) => tg.id).join(","); - // 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); + const row = rowRef.current; + const measure = measureRef.current; + if (!row || !measure) return; + const compute = () => { + const nodes = Array.from(measure.children) as HTMLElement[]; + const pill = nodes[nodes.length - 1]; + if (!pill) return; + const pillW = pill.offsetWidth; + const chips = nodes.slice(0, -1); + const GAP = 4; // gap-1 + const avail = row.clientWidth; + let count = chips.length; + for (let i = 0; i < chips.length; i++) { + const chip = chips[i]; + if (!chip) break; + const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer + const moreAfter = i < chips.length - 1; + if (right > avail - (moreAfter ? pillW + GAP : 0)) { + count = i; + break; + } + } + setVisible(count); + }; + compute(); + if (typeof ResizeObserver === "undefined") return; + const ro = new ResizeObserver(compute); + ro.observe(row); 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]); + }, [idsKey]); const hidden = attached.length - visible; @@ -187,15 +197,28 @@ export function ChannelTagsControl({ mode: "hover", placement: "bottom", }); - const fade = useScrollFade(); + + const pillClass = + "shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-card text-muted border border-border"; return ( - // FIXED width (not just max-w): under `table-layout:auto` a content-driven cell re-solves the - // whole table's column widths every time a chip is added — the table visibly shifts until the cap - // is hit. A fixed width makes the cell's footprint constant, so adding/removing tags never moves - // any column; the chip row greedily fits within it and collapses the rest into "+N". (max-w-full - // lets a narrower card shrink it.) -
+ // FIXED width so the cell's footprint is constant under `table-layout:auto`: a content-driven cell + // re-solves EVERY column's width as chips are added, visibly shifting the whole table. A 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.) +
+ {/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */} +
+ {attached.map((tg) => ( + + ))} + +{attached.length} +
+
{attached.slice(0, visible).map((tg) => ( +{hidden} @@ -231,7 +254,7 @@ export function ChannelTagsControl({ {/* Hover: reveal every attached tag when some are collapsed into "+N". */} {attached.map((tg) => ( ({ value: String(tg.id), label: tg.name })), diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 756fdd3c..7ec4d8ca 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -107,8 +107,8 @@ "backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.", "resetBackfill": "Reset & re-fetch this channel", "resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.", - "fullHistoryQueued": "full history queued", - "fullHistoryComing": "full history coming", + "fullHistoryQueued": "queued", + "fullHistoryComing": "coming", "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", "queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.", "hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 4748812c..e61d737c 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -107,8 +107,8 @@ "backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.", "resetBackfill": "Csatorna resetelése és újraletöltése", "resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.", - "fullHistoryQueued": "teljes előzmény sorban", - "fullHistoryComing": "teljes előzmény úton", + "fullHistoryQueued": "sorban", + "fullHistoryComing": "úton", "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.", "queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.", "hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",