myYouTube/frontend/src/components/ChannelCard.tsx
vrubelroman 3089202316 Add delete-downloaded-video and real YouTube unsubscribe
Delete local copy:
- MeTubeClient.delete_download() -> POST /delete {ids, where: "done"}
- Only ever acts on a job our own app tracked (metube_job_id we stored from
  its own 'completed' event), never a pre-existing MeTube file
- New "deleted" terminal status; DELETE /api/videos/{id}/download
- Frontend: delete button next to "На сервере" badge, confirm dialog

Unsubscribe (deliberate deviation from the original TZ's MVP exclusion of
subscription management, per explicit user request after being shown the
tradeoff):
- OAuth scope widened from youtube.readonly to full youtube (read/write) --
  existing stored tokens only cover the old scope, so unsubscribing needs a
  fresh reconnect; reads keep working unchanged on the old token meanwhile
- channels.youtube_subscription_id (distinct from the channel id; that's
  what subscriptions.delete actually keys on) captured during subscriptions
  sync
- YouTubeInsufficientScope raised on 401/403 "insufficient authentication
  scopes" and surfaced as a clear 403 asking the user to reconnect, rather
  than a generic API error
- POST /api/channels/{id}/unsubscribe calls subscriptions.delete and marks
  the channel unsubscribed locally on success
- Frontend: "Отписаться" button on ChannelCard with confirm dialog

10 new backend tests (73 total).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 19:47:35 +00:00

123 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import type { CategoryDto, ChannelDto } from '../api/client'
import { setChannelCategories, unsubscribeChannel } from '../api/client'
interface Props {
channel: ChannelDto
categories: CategoryDto[]
}
function ChannelCard({ channel, categories }: Props) {
const [open, setOpen] = useState(false)
const popoverRef = useRef<HTMLDivElement>(null)
const queryClient = useQueryClient()
useEffect(() => {
if (!open) return
function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [open])
const mutation = useMutation({
mutationFn: (categoryIds: number[]) => setChannelCategories(channel.id, categoryIds),
onMutate: async (categoryIds: number[]) => {
await queryClient.cancelQueries({ queryKey: ['channels'] })
const previous = queryClient.getQueriesData<ChannelDto[]>({ queryKey: ['channels'] })
queryClient.setQueriesData<ChannelDto[]>({ queryKey: ['channels'] }, (old) =>
old?.map((c) => (c.id === channel.id ? { ...c, category_ids: categoryIds } : c)),
)
return { previous }
},
onError: (_err, _vars, context) => {
context?.previous.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
queryClient.invalidateQueries({ queryKey: ['categories'] })
},
})
const unsubscribeMutation = useMutation({
mutationFn: () => unsubscribeChannel(channel.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] })
},
})
function toggleCategory(categoryId: number) {
const current = new Set(channel.category_ids)
if (current.has(categoryId)) {
current.delete(categoryId)
} else {
current.add(categoryId)
}
mutation.mutate(Array.from(current))
}
const assignedNames = categories.filter((c) => channel.category_ids.includes(c.id)).map((c) => c.name)
return (
<li className="channel-card">
{channel.thumbnail_url && <img src={channel.thumbnail_url} alt={channel.title} />}
<div className="channel-info">
<Link to={`/channels/${channel.id}/videos`} className="channel-title">
{channel.title}
</Link>
{!channel.subscribed && <span className="badge">отписан</span>}
{channel.subscribed && (
<button
className="unsubscribe-button"
onClick={() => {
if (confirm(`Отписаться от канала "${channel.title}" на YouTube?`)) {
unsubscribeMutation.mutate()
}
}}
disabled={unsubscribeMutation.isPending}
>
Отписаться
</button>
)}
{unsubscribeMutation.isError && (
<p className="error unsubscribe-error">{(unsubscribeMutation.error as Error).message}</p>
)}
<div className="channel-categories">
{assignedNames.map((name) => (
<span key={name} className="chip">
{name}
</span>
))}
<div className="category-popover-wrapper" ref={popoverRef}>
<button className="chip-edit" onClick={() => setOpen((v) => !v)}>
+ Категория
</button>
{open && (
<div className="category-popover">
{categories.length === 0 && <div className="category-popover-empty">Сначала создай категорию</div>}
{categories.map((category) => (
<label key={category.id} className="category-checkbox">
<input
type="checkbox"
checked={channel.category_ids.includes(category.id)}
onChange={() => toggleCategory(category.id)}
/>
{category.name}
</label>
))}
</div>
)}
</div>
</div>
</div>
</li>
)
}
export default ChannelCard