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:
@@ -230,6 +230,7 @@ def _channel_detail_dict(
|
|||||||
blocked: bool,
|
blocked: bool,
|
||||||
stored: int,
|
stored: int,
|
||||||
deep_requested: bool,
|
deep_requested: bool,
|
||||||
|
tag_ids: list[int],
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": channel.id,
|
"id": channel.id,
|
||||||
@@ -257,6 +258,9 @@ def _channel_detail_dict(
|
|||||||
"backfill_done": channel.backfill_done,
|
"backfill_done": channel.backfill_done,
|
||||||
"from_explore": channel.from_explore,
|
"from_explore": channel.from_explore,
|
||||||
"details_synced": channel.details_synced_at is not None,
|
"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
|
).first() is not None
|
||||||
blocked = _is_blocked(db, user, channel_id)
|
blocked = _is_blocked(db, user, channel_id)
|
||||||
stored = db.scalar(select(func.count(Video.id)).where(Video.channel_id == channel_id)) or 0
|
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(
|
return _channel_detail_dict(
|
||||||
channel,
|
channel,
|
||||||
subscribed=subscribed,
|
subscribed=subscribed,
|
||||||
@@ -304,6 +315,7 @@ def channel_detail(
|
|||||||
blocked=blocked,
|
blocked=blocked,
|
||||||
stored=int(stored),
|
stored=int(stored),
|
||||||
deep_requested=bool(sub.deep_requested) if sub else False,
|
deep_requested=bool(sub.deep_requested) if sub else False,
|
||||||
|
tag_ids=tag_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ import { useFeedFilters } from "./FeedFiltersProvider";
|
|||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
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 type { FeedView } from "../lib/feedView";
|
||||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||||
|
import { ChannelTagsControl } from "./ChannelTags";
|
||||||
|
|
||||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
// 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
|
// 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")),
|
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 onUnsubscribe = async () => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: t("channel.unsubTitle"),
|
title: t("channel.unsubTitle"),
|
||||||
@@ -411,6 +445,14 @@ export default function ChannelPage({
|
|||||||
{t("channel.subscribe")}
|
{t("channel.subscribe")}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{ch && !me.is_demo && (
|
||||||
|
<ChannelTagsControl
|
||||||
|
userTags={userTags}
|
||||||
|
attachedIds={ch.tag_ids}
|
||||||
|
onToggle={toggleTag}
|
||||||
|
onCreate={createAndAttachTag}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface ChannelDetail {
|
|||||||
backfill_done: boolean;
|
backfill_done: boolean;
|
||||||
from_explore: boolean;
|
from_explore: boolean;
|
||||||
details_synced: boolean;
|
details_synced: boolean;
|
||||||
|
tag_ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExploreResult {
|
export interface ExploreResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user