Files
TeleWave/frontend/src/features/admin/collections/CollectionDetail.tsx
T
Leonid Pershin 790d01b587
ci / build-backend (push) Successful in 2m34s
ci / build-frontend (push) Successful in 41s
ci / tests (push) Successful in 2m55s
ci / sonar (push) Successful in 5m21s
Update README.md with additional SonarCloud badges and improve CI workflow for coverage reporting
Enhanced the README.md file by adding new SonarCloud badges for coverage, bugs, code smells, security rating, and maintainability rating. Updated the CI workflow to remove coverage collection from the test step, as it is now handled by SonarCloud, streamlining the process and ensuring accurate badge representation.
2026-07-27 03:19:18 +03:00

233 lines
8.6 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronLeft, GripVertical, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import { listShows } from '@/features/admin/shows/api'
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import {
addCollectionShow,
getCollection,
removeCollectionShow,
reorderCollection,
setCollectionPoster,
updateCollection,
} from './api'
export function CollectionDetail({ collectionId }: Readonly<{ collectionId: string }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [galleryOpen, setGalleryOpen] = useState(false)
const [dragged, setDragged] = useState<string | null>(null)
const [pendingShow, setPendingShow] = useState('')
const [name, setName] = useState<string | null>(null)
const [description, setDescription] = useState<string | null>(null)
const { data: collection, isLoading } = useQuery({
queryKey: qk.collections.detail(collectionId),
queryFn: () => getCollection(collectionId),
})
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qk.collections.all })
}
const onError = useApiError()
const saveMutation = useMutation({
mutationFn: () =>
updateCollection(collectionId, {
name: (name ?? collection?.name ?? '').trim(),
description: description ?? collection?.description ?? null,
}),
onSuccess: invalidate,
onError,
})
const posterMutation = useMutation({
mutationFn: (imageId: string | null) => setCollectionPoster(collectionId, imageId),
onSuccess: invalidate,
onError,
})
const addMutation = useMutation({
mutationFn: (showId: string) => addCollectionShow(collectionId, showId),
onSuccess: () => {
setPendingShow('')
invalidate()
},
onError,
})
const removeMutation = useMutation({
mutationFn: (showId: string) => removeCollectionShow(collectionId, showId),
onSuccess: invalidate,
onError,
})
const reorderMutation = useMutation({
mutationFn: (order: string[]) => reorderCollection(collectionId, order),
onSuccess: invalidate,
onError,
})
if (isLoading || !collection)
return <p className="text-muted-foreground">{t('common.loading')}</p>
const memberIds = new Set(collection.items.map((i) => i.showId))
const available = (shows ?? []).filter((s) => !memberIds.has(s.id))
/** Порядок пересобирается целиком и уходит одним запросом — сервер сам расставит позиции. */
const dropOn = (targetShowId: string) => {
if (!dragged || dragged === targetShowId) return
const order = collection.items.map((i) => i.showId).filter((id) => id !== dragged)
const at = order.indexOf(targetShowId)
order.splice(at, 0, dragged)
setDragged(null)
reorderMutation.mutate(order)
}
return (
<div className="flex flex-col gap-6">
<div>
<Button asChild size="sm" variant="ghost">
<Link to="/admin/collections">
<ChevronLeft className="h-4 w-4" />
{t('admin.collections.title')}
</Link>
</Button>
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex w-40 shrink-0 flex-col gap-2">
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
{collection.posterImageId ? (
<img
src={imageUrl(collection.posterImageId)}
alt=""
className="h-full w-full object-cover"
/>
) : (
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
)}
</div>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.metadata.pickPoster')}
</Button>
{collection.posterImageId && (
<Button size="sm" variant="ghost" onClick={() => posterMutation.mutate(null)}>
{t('common.delete')}
</Button>
)}
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="ShowPoster"
onSelect={(img) => posterMutation.mutate(img.id)}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.collections.name')}</Label>
<Input
value={name ?? collection.name}
maxLength={256}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.collections.description')}</Label>
<Input
value={description ?? collection.description ?? ''}
maxLength={2048}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div>
<Button
size="sm"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{t('common.save')}
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.collections.parts')}
</h3>
{/* Только те, кого в коллекции ещё нет: повторно добавлять некого. */}
<ShowPicker
value={pendingShow}
shows={available}
placeholder={t('admin.collections.pickShow')}
className="h-9 w-64"
onChange={setPendingShow}
/>
<Button
size="sm"
disabled={!pendingShow || addMutation.isPending}
onClick={() => addMutation.mutate(pendingShow)}
>
{t('admin.collections.addShow')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.collections.orderHint')}</p>
<div className="crt-panel rounded-md">
{collection.items.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
{t('admin.collections.empty')}
</p>
) : (
<ul className="divide-y divide-border text-sm">
{collection.items.map((item, index) => (
<li
key={item.showId}
draggable
onDragStart={() => setDragged(item.showId)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(item.showId)}
className="flex items-center gap-3 px-4 py-2"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
<Link
to="/admin/shows/$showId"
params={{ showId: item.showId }}
className="min-w-0 flex-1 truncate text-primary hover:underline"
>
{item.showName}
</Link>
{item.year && <span className="text-muted-foreground">{item.year}</span>}
<Badge variant="muted">{t(`admin.shows.kinds.${item.showKind}`)}</Badge>
<span className="text-muted-foreground">
{t('admin.shows.episodes')}: {item.episodeCount}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => removeMutation.mutate(item.showId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)
}