Update README.md with additional SonarCloud badges and improve CI workflow for coverage reporting
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

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.
This commit is contained in:
Leonid Pershin
2026-07-27 03:19:18 +03:00
parent 464355434b
commit 790d01b587
22 changed files with 2891 additions and 99 deletions
@@ -6,13 +6,13 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import {
addCollectionShow,
getCollection,
@@ -165,18 +165,14 @@ export function CollectionDetail({ collectionId }: Readonly<{ collectionId: stri
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.collections.parts')}
</h3>
<Select value={pendingShow} onValueChange={setPendingShow}>
<SelectTrigger className="w-64">
<SelectValue placeholder={t('admin.collections.addShow')} />
</SelectTrigger>
<SelectContent>
{available.map((show) => (
<SelectItem key={show.id} value={show.id}>
{show.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Только те, кого в коллекции ещё нет: повторно добавлять некого. */}
<ShowPicker
value={pendingShow}
shows={available}
placeholder={t('admin.collections.pickShow')}
className="h-9 w-64"
onChange={setPendingShow}
/>
<Button
size="sm"
disabled={!pendingShow || addMutation.isPending}
@@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listShows } from '@/features/admin/shows/api'
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
import { qk } from '@/shared/api/query-keys'
import type { ManualInboxFileDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
@@ -18,7 +19,6 @@ import {
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { importManualInbox, listManualInbox } from './api'
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
@@ -221,24 +221,15 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.manualShow')}</Label>
<Select
<ShowPicker
value={showId}
onValueChange={(value) => {
shows={shows}
placeholder={t('admin.media.manualPickShow')}
onChange={(value) => {
setShowPicked(true)
setShowId(value)
}}
>
<SelectTrigger>
<SelectValue placeholder={t('admin.media.manualPickShow')} />
</SelectTrigger>
<SelectContent>
{(shows ?? []).map((show) => (
<SelectItem key={show.id} value={show.id}>
{show.name}
</SelectItem>
))}
</SelectContent>
</Select>
/>
{autoDetected && (
<p className="text-xs text-muted-foreground">{t('admin.media.manualDetected')}</p>
)}
@@ -14,15 +14,12 @@ import {
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { listShows } from '@/features/admin/shows/api'
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
import { matchShowByName } from './match-show'
import { useUploadStore } from './upload-store'
/** Radix Select запрещает пустое значение — под «в библиотеку» используем спец-значение. */
const LIBRARY_VALUE = '__library__'
export function UploadToShowDialog({
files,
onClose,
@@ -119,19 +116,16 @@ export function UploadToShowDialog({
<span className="text-muted-foreground">
{t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })}
</span>
<Select value="" onValueChange={(v) => applyToAll(v === LIBRARY_VALUE ? '' : v)}>
<SelectTrigger className="h-8 w-52">
<SelectValue placeholder={t('admin.media.applyToAll')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={LIBRARY_VALUE}>{t('admin.media.toShowLibrary')}</SelectItem>
{shows?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Значение не храним: выбор здесь — это действие «проставить всем», а не состояние,
поэтому на кнопке всегда её собственная подпись. */}
<ShowPicker
value=""
shows={shows}
noneLabel={t('admin.media.toShowLibrary')}
placeholder={t('admin.media.applyToAll')}
className="h-8 w-52"
onChange={applyToAll}
/>
</div>
</div>
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-sm">
@@ -148,26 +142,13 @@ export function UploadToShowDialog({
<span className="min-w-0 flex-1 truncate" title={p.name}>
{p.name}
</span>
<Select
value={current || LIBRARY_VALUE}
onValueChange={(v) =>
setOverrides((o) => ({ ...o, [p.name]: v === LIBRARY_VALUE ? '' : v }))
}
>
<SelectTrigger className="h-8 w-56 shrink-0 whitespace-nowrap [&>span]:truncate">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={LIBRARY_VALUE}>
{t('admin.media.toShowLibrary')}
</SelectItem>
{shows?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<ShowPicker
value={current}
shows={shows}
noneLabel={t('admin.media.toShowLibrary')}
className="h-8 w-56 shrink-0"
onChange={(showId) => setOverrides((o) => ({ ...o, [p.name]: showId }))}
/>
</li>
)
})}
@@ -0,0 +1,233 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Check, ChevronDown, Plus } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { ShowKind, ShowSummaryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { useApiError } from '@/shared/lib/use-api-error'
import { Input } from '@/shared/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/shared/ui/popover'
import { toast } from '@/shared/ui/toast-store'
import { createShow } from './api'
/** Сколько шоу показывать за раз: длинный список всё равно не читают — его сужают поиском. */
const MAX_VISIBLE = 50
/**
* Выбор шоу с поиском и созданием на месте. Шоу в библиотеке бывают сотни, поэтому обычный
* `Select` не годится: здесь строка поиска, а недостающее шоу заводится прямо из выпадашки —
* не покидая разбора файлов. Кнопка создания появляется, только когда набранное имя ни с чем не
* совпало, так что в обычном случае (шоу есть) она на глаза не попадается.
*/
export function ShowPicker({
value,
onChange,
shows,
noneLabel,
placeholder,
className,
}: Readonly<{
value: string
onChange: (showId: string) => void
shows: ShowSummaryDto[] | undefined
/** Подпись пункта «без шоу» (в загрузке — «в библиотеку»). Не задан — шоу обязательно. */
noneLabel?: string
/** Что показывать на кнопке, когда ничего не выбрано. */
placeholder?: string
className?: string
}>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const onError = useApiError()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const listRef = useRef<HTMLDivElement>(null)
const trimmed = query.trim()
const matches = useMemo(() => {
const q = trimmed.toLowerCase()
const all = shows ?? []
if (!q) return all.slice(0, MAX_VISIBLE)
return all
.filter(
(s) => s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
)
.slice(0, MAX_VISIBLE)
}, [shows, trimmed])
// Точное совпадение — создавать нечего: шоу уже есть, его надо просто выбрать.
const exists = (shows ?? []).some((s) => s.name.toLowerCase() === trimmed.toLowerCase())
const canCreate = trimmed.length > 0 && !exists
const selected = shows?.find((s) => s.id === value)
const label = selected?.name ?? placeholder ?? noneLabel ?? t('admin.shows.pickerPlaceholder')
const close = () => {
setOpen(false)
setQuery('')
setActive(0)
}
const pick = (showId: string) => {
onChange(showId)
close()
}
const create = useMutation({
mutationFn: (kind: ShowKind) => createShow({ name: trimmed, kind }),
onSuccess: async (created) => {
// Список шоу перечитываем до выбора: иначе кнопка покажет плейсхолдер вместо нового имени.
await queryClient.invalidateQueries({ queryKey: qk.shows.all })
toast.success(t('admin.shows.pickerCreated'))
pick(created.id)
},
onError,
})
/** Стрелки/Enter по списку — иначе после поиска пришлось бы тянуться к мыши. */
const onKeyDown = (event: React.KeyboardEvent) => {
const options = noneLabel ? matches.length + 1 : matches.length
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (options === 0) return
const next = event.key === 'ArrowDown' ? active + 1 : active - 1
setActive((next + options) % options)
requestAnimationFrame(() =>
listRef.current
?.querySelector('[data-active="true"]')
?.scrollIntoView({ block: 'nearest' }),
)
return
}
if (event.key !== 'Enter') return
event.preventDefault()
if (options === 0) {
if (canCreate && !create.isPending) create.mutate('Series')
return
}
if (noneLabel && active === 0) {
pick('')
return
}
const show = matches[noneLabel ? active - 1 : active]
if (show) pick(show.id)
}
return (
// modal — потому что пикер живёт внутри диалогов: те держат фокус-ловушку, и без своего
// слоя поповер отдавал бы фокус обратно диалогу, а в строку поиска нельзя было бы печатать.
<Popover modal open={open} onOpenChange={(next) => (next ? setOpen(true) : close())}>
<PopoverTrigger
className={cn(
'flex h-10 w-full items-center justify-between gap-2 rounded-sm border border-border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
className,
)}
>
<span className={cn('min-w-0 truncate', !selected && 'text-muted-foreground')}>
{label}
</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-60" />
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] min-w-64 p-1"
onKeyDown={onKeyDown}
>
<Input
autoFocus
className="h-8"
placeholder={t('admin.shows.pickerSearch')}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
}}
/>
<div ref={listRef} className="mt-1 max-h-64 overflow-y-auto">
{noneLabel && (
<Option
active={active === 0}
selected={!value}
label={noneLabel}
onSelect={() => pick('')}
/>
)}
{matches.map((show, index) => (
<Option
key={show.id}
active={active === (noneLabel ? index + 1 : index)}
selected={show.id === value}
label={show.name}
hint={show.originalName ?? undefined}
onSelect={() => pick(show.id)}
/>
))}
{matches.length === 0 && !canCreate && (
<p className="px-2 py-1.5 text-sm text-muted-foreground">
{t('admin.shows.pickerEmpty')}
</p>
)}
</div>
{canCreate && (
// Сериал — основной случай (в шоу заливают сезоны), полнометражка рядом отдельной кнопкой:
// так создание остаётся в один клик и при этом не навязывает тип.
<div className="mt-1 flex items-center gap-1 border-t border-border pt-1">
<button
type="button"
disabled={create.isPending}
className="flex min-w-0 flex-1 items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-primary hover:bg-muted disabled:opacity-50"
onClick={() => create.mutate('Series')}
>
<Plus className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{t('admin.shows.pickerCreate', { name: trimmed })}</span>
</button>
<button
type="button"
disabled={create.isPending}
className="shrink-0 rounded-sm border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary disabled:opacity-50"
onClick={() => create.mutate('Single')}
title={t('admin.shows.pickerCreateSingleHint')}
>
{t('admin.shows.kinds.Single')}
</button>
</div>
)}
</PopoverContent>
</Popover>
)
}
function Option({
active,
selected,
label,
hint,
onSelect,
}: Readonly<{
active: boolean
selected: boolean
label: string
hint?: string
onSelect: () => void
}>) {
return (
<button
type="button"
data-active={active}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted',
active && 'bg-muted',
)}
onClick={onSelect}
>
<Check className={cn('h-3.5 w-3.5 shrink-0', !selected && 'opacity-0')} />
<span className="min-w-0 truncate">{label}</span>
{hint && hint !== label && (
<span className="min-w-0 shrink truncate text-xs text-muted-foreground">{hint}</span>
)}
</button>
)
}
+7
View File
@@ -129,6 +129,7 @@ export const en = {
parts: 'Parts',
units: 'Units',
addShow: 'Add show',
pickShow: 'Pick a show',
empty: 'Collection is empty',
orderHint: 'Drag to set the order of the parts — that is the order they air in.',
},
@@ -295,6 +296,12 @@ export const en = {
originalName: 'Original name (eng)',
kind: 'Kind',
kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' },
pickerPlaceholder: 'Pick a show',
pickerSearch: 'Search by name…',
pickerEmpty: 'Nothing found',
pickerCreate: 'Create series “{{name}}”',
pickerCreateSingleHint: 'Create as a movie',
pickerCreated: 'Show created',
audience: 'Rating',
audienceUnset: 'Not rated',
audiences: {
+7
View File
@@ -129,6 +129,7 @@ export const ru = {
parts: 'Части',
units: 'Единиц',
addShow: 'Добавить шоу',
pickShow: 'Выберите шоу',
empty: 'Коллекция пуста',
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
},
@@ -296,6 +297,12 @@ export const ru = {
originalName: 'Оригинальное название (eng)',
kind: 'Тип',
kinds: { Series: 'Сериал', Single: 'Полнометражка', Interstitial: 'Ролик' },
pickerPlaceholder: 'Выберите шоу',
pickerSearch: 'Поиск по названию…',
pickerEmpty: 'Ничего не найдено',
pickerCreate: 'Создать сериал «{{name}}»',
pickerCreateSingleHint: 'Создать как полнометражку',
pickerCreated: 'Шоу создано',
audience: 'Рейтинг',
audienceUnset: 'Не проставлен',
audiences: {
+24
View File
@@ -0,0 +1,24 @@
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { type ComponentPropsWithoutRef, type ElementRef, forwardRef } from 'react'
import { cn } from '@/shared/lib/cn'
export const Popover = PopoverPrimitive.Root
export const PopoverTrigger = PopoverPrimitive.Trigger
export const PopoverAnchor = PopoverPrimitive.Anchor
export const PopoverContent = forwardRef<
ElementRef<typeof PopoverPrimitive.Content>,
ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
// z-50 — как у диалога: поповер живёт и внутри диалогов, и должен быть над ними.
className={cn('crt-panel z-50 rounded-sm outline-none', className)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName