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
+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 {