feat(channels): fixed-width tag cell with "+N" overflow + inline create (R8 S1)
Redesign the channel tag cell (the popover pilot's first real consumer) to fix two UAT-reported bugs and two UX gaps: - Jumping editor (bug 1): the "+" edit trigger now sits in a STABLE slot at the end, so toggling a tag (which grows the chip row) no longer moves the anchor — the editor popover stays put (verified dx/dy = 0). - Whole-table reflow (bug 2): the cell is width-bounded (max-w inside the cell, since table-layout:auto ignores the column width — the documented channel/About trap) and single-line, collapsing overflow into a hoverable "+N" pill. Rows no longer wrap and reflow (verified uniform row heights). - The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline (type + Enter → create + attach) — tagging no longer requires the top-of-page dialog. New shared ChannelTagsControl (used by table + cards; the detail page is Part 2) over the R8 popover platform: a `menu` editor popover + a `hover` reveal-all popover. Greedy rem-safe fit via ResizeObserver. `api.createTag` now returns Promise<Tag>.
This commit is contained in:
@@ -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) => (
|
||||
<TagsCell
|
||||
c={c}
|
||||
<ChannelTagsControl
|
||||
userTags={userTags}
|
||||
onToggleTag={(tagId) => 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 (
|
||||
<div className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
|
||||
{attached.map((tg) => (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => onTagClick(tg.id, tg.name)}
|
||||
title={t("channels.row.filterFeedByTag", { name: tg.name })}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent hover:opacity-80 transition"
|
||||
>
|
||||
{tg.name}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
floatingRef={fade.ref}
|
||||
style={fade.style}
|
||||
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{userTags.map((tg) => {
|
||||
const on = c.tag_ids.includes(tg.id);
|
||||
return (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => onToggleTag(tg.id)}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
on ? "text-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{tg.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionsCell({
|
||||
c,
|
||||
isAdmin,
|
||||
|
||||
Reference in New Issue
Block a user