feat(channels): tag editing on the channel detail page (R8 S1 Part 2)

Bring the same tag control to the channel page, next to Subscribe:
- backend: GET /api/channels/{id} now returns this user's `tag_ids` for the channel
  (added to _channel_detail_dict + the ChannelDetail interface), matching the manager
  list shape;
- ChannelPage renders the shared ChannelTagsControl, wired to attach/detach/create with
  an optimistic update on the channel-detail cache (reverting on failure) and a manager
  list invalidation so a tag added here shows there too.

Reuses the R8 popover platform + ChannelTagsControl — no new tag UI.
This commit is contained in:
2026-07-30 23:33:50 +02:00
parent 2b70ba7a1f
commit 56f95fa626
3 changed files with 56 additions and 1 deletions
+12
View File
@@ -230,6 +230,7 @@ def _channel_detail_dict(
blocked: bool,
stored: int,
deep_requested: bool,
tag_ids: list[int],
) -> dict:
return {
"id": channel.id,
@@ -257,6 +258,9 @@ def _channel_detail_dict(
"backfill_done": channel.backfill_done,
"from_explore": channel.from_explore,
"details_synced": channel.details_synced_at is not None,
# This user's personal tag links for the channel (same shape as the manager list), so the
# channel page can offer the same tag editor next to Subscribe.
"tag_ids": tag_ids,
}
@@ -297,6 +301,13 @@ def channel_detail(
).first() is not None
blocked = _is_blocked(db, user, channel_id)
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
tag_ids = list(
db.scalars(
select(ChannelTag.tag_id).where(
ChannelTag.user_id == user.id, ChannelTag.channel_id == channel_id
)
).all()
)
return _channel_detail_dict(
channel,
subscribed=subscribed,
@@ -304,6 +315,7 @@ def channel_detail(
blocked=blocked,
stored=int(stored),
deep_requested=bool(sub.deep_requested) if sub else False,
tag_ids=tag_ids,
)
+43 -1
View File
@@ -22,9 +22,10 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me } from "../lib/api";
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -166,6 +167,39 @@ export default function ChannelPage({
onError: (err) => notifyYouTubeActionError(err, t("channel.fullHistoryFailed")),
});
// Personal tags, editable right here (the same control the channel manager uses). Optimistic on
// this channel's detail cache; a failed attach/detach reverts by refetching. Invalidate the manager
// list too so a tag added here shows there. createTag mirrors the manager's create-and-attach.
const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
const toggleTag = (tagId: number) => {
if (!ch) return;
const hasTag = ch.tag_ids.includes(tagId);
qc.setQueryData<ChannelDetail>(qk.channel(channelId), (old) =>
old
? {
...old,
tag_ids: hasTag ? old.tag_ids.filter((x) => x !== tagId) : [...old.tag_ids, tagId],
}
: old,
);
const call = hasTag
? api.detachChannelTag(channelId, tagId)
: api.attachChannelTag(channelId, tagId);
call
.then(() => qc.invalidateQueries({ queryKey: qk.channels() }))
.catch(() => qc.invalidateQueries({ queryKey: qk.channel(channelId) }));
};
const createAndAttachTag = (name: string) => {
api
.createTag({ name })
.then((tag) => {
qc.invalidateQueries({ queryKey: qk.tags() });
toggleTag(tag.id);
})
.catch(() => notify({ level: "error", message: t("channels.tags.createFailed") }));
};
const onUnsubscribe = async () => {
const ok = await confirm({
title: t("channel.unsubTitle"),
@@ -411,6 +445,14 @@ export default function ChannelPage({
{t("channel.subscribe")}
</button>
))}
{ch && !me.is_demo && (
<ChannelTagsControl
userTags={userTags}
attachedIds={ch.tag_ids}
onToggle={toggleTag}
onCreate={createAndAttachTag}
/>
)}
</div>
</div>
</div>
+1
View File
@@ -31,6 +31,7 @@ export interface ChannelDetail {
backfill_done: boolean;
from_explore: boolean;
details_synced: boolean;
tag_ids: number[];
}
export interface ExploreResult {