101 lines
3.6 KiB
TypeScript
101 lines
3.6 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 } 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'] })
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
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>}
|
|||
|
|
|
|||
|
|
<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
|