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.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user