diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 3325d509..2bb2c1ba 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -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, ) diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index a8bef922..e3ce76d4 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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(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")} ))} + {ch && !me.is_demo && ( + + )} diff --git a/frontend/src/lib/api/channels.ts b/frontend/src/lib/api/channels.ts index 0e49fe8a..1ed889b2 100644 --- a/frontend/src/lib/api/channels.ts +++ b/frontend/src/lib/api/channels.ts @@ -31,6 +31,7 @@ export interface ChannelDetail { backfill_done: boolean; from_explore: boolean; details_synced: boolean; + tag_ids: number[]; } export interface ExploreResult {