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:
@@ -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 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
|
className={`${cls} hover:opacity-80 transition`}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={cls}>{tag.name}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="max-h-[240px] overflow-y-auto no-scrollbar">
|
||||||
|
{userTags.map((tg) => {
|
||||||
|
const on = attached.has(tg.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tg.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggle(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>
|
||||||
|
<span className="truncate">{tg.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{userTags.length === 0 && (
|
||||||
|
<p className="text-[11px] text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={!draft.trim()}
|
||||||
|
title={t("channels.tags.create")}
|
||||||
|
aria-label={t("channels.tags.create")}
|
||||||
|
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<HTMLDivElement | null>(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".
|
||||||
|
<div className="flex items-center gap-1 w-full max-w-[15rem] min-w-0">
|
||||||
|
<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 && (
|
||||||
|
<button
|
||||||
|
ref={overflowPop.refs.setReference}
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-card text-muted border border-border hover:text-fg transition"
|
||||||
|
{...overflowPop.getReferenceProps()}
|
||||||
|
>
|
||||||
|
+{hidden}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={editPop.refs.setReference}
|
||||||
|
type="button"
|
||||||
|
title={t("channels.row.editTags")}
|
||||||
|
aria-label={t("channels.row.editTags")}
|
||||||
|
className="shrink-0 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"
|
||||||
|
{...editPop.getReferenceProps()}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||||
|
<PopoverSurface
|
||||||
|
popover={overflowPop}
|
||||||
|
className="glass-menu z-popover max-w-[18rem] rounded-xl 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}
|
||||||
|
floatingRef={fade.ref}
|
||||||
|
style={fade.style}
|
||||||
|
className="glass-menu z-popover w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
<TagEditorMenu
|
||||||
|
userTags={userTags}
|
||||||
|
attachedIds={attachedIds}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onCreate={onCreate}
|
||||||
|
/>
|
||||||
|
</PopoverSurface>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,16 +12,14 @@ import {
|
|||||||
History,
|
History,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Table,
|
Table,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
|
||||||
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
||||||
import { subsColumn } from "./channelColumns";
|
import { subsColumn } from "./channelColumns";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
@@ -29,7 +27,7 @@ import Tooltip from "./Tooltip";
|
|||||||
import DataTable, { type Column } from "./DataTable";
|
import DataTable, { type Column } from "./DataTable";
|
||||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
import { ChannelTagsControl } from "./ChannelTags";
|
||||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||||
import ColumnSortControl from "./ColumnSortControl";
|
import ColumnSortControl from "./ColumnSortControl";
|
||||||
import Pager from "./Pager";
|
import Pager from "./Pager";
|
||||||
@@ -212,6 +210,19 @@ export default function Channels() {
|
|||||||
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
|
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
|
||||||
call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
|
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({
|
const syncSubs = useMutation({
|
||||||
mutationFn: () => api.syncSubscriptions(),
|
mutationFn: () => api.syncSubscriptions(),
|
||||||
onSuccess: (r: { subscriptions?: number }) => {
|
onSuccess: (r: { subscriptions?: number }) => {
|
||||||
@@ -452,17 +463,21 @@ export default function Channels() {
|
|||||||
key: "tags",
|
key: "tags",
|
||||||
header: t("channels.cols.tags"),
|
header: t("channels.cols.tags"),
|
||||||
cardLabel: false,
|
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: {
|
filter: {
|
||||||
kind: "multi",
|
kind: "multi",
|
||||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
||||||
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
||||||
},
|
},
|
||||||
render: (c) => (
|
render: (c) => (
|
||||||
<TagsCell
|
<ChannelTagsControl
|
||||||
c={c}
|
|
||||||
userTags={userTags}
|
userTags={userTags}
|
||||||
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
attachedIds={c.tag_ids}
|
||||||
onTagClick={onFilterByTag}
|
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({
|
function ActionsCell({
|
||||||
c,
|
c,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
|||||||
@@ -46,7 +46,11 @@
|
|||||||
"tags": {
|
"tags": {
|
||||||
"yourTags": "Your tags",
|
"yourTags": "Your tags",
|
||||||
"manage": "Manage 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…",
|
"loading": "Loading channels…",
|
||||||
"empty": "No channels.",
|
"empty": "No channels.",
|
||||||
|
|||||||
@@ -46,7 +46,11 @@
|
|||||||
"tags": {
|
"tags": {
|
||||||
"yourTags": "Címkéid",
|
"yourTags": "Címkéid",
|
||||||
"manage": "Címkék kezelése",
|
"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…",
|
"loading": "Csatornák betöltése…",
|
||||||
"empty": "Nincsenek csatornák.",
|
"empty": "Nincsenek csatornák.",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface Tag {
|
|||||||
|
|
||||||
export const tagsApi = {
|
export const tagsApi = {
|
||||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||||
createTag: (t: { name: string; color?: string; category?: string }) =>
|
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
|
||||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||||
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
||||||
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|||||||
Reference in New Issue
Block a user