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>
123 lines
4.3 KiB
TypeScript
123 lines
4.3 KiB
TypeScript
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
|