request modal for radarr/sonarr
This commit is contained in:
@@ -0,0 +1,91 @@
|
|||||||
|
import { Database, Check, RefreshCw, Clock } from '../../lib/icons'
|
||||||
|
import { useRequestModal } from '../../stores/request-modal-store'
|
||||||
|
import { useItemAvailability } from '../../hooks/use-availability'
|
||||||
|
import type { TmdbMovie, TmdbTvShow } from '../../api/tmdb'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tmdbId: number | null | undefined
|
||||||
|
kind: 'movie' | 'tv'
|
||||||
|
tmdbData: TmdbMovie | TmdbTvShow | null
|
||||||
|
/** Visual style. 'pill' for hero rows, 'inline' for compact contexts. */
|
||||||
|
variant?: 'pill' | 'inline'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a Request CTA when the item isn't available locally and an
|
||||||
|
* appropriate *arr instance exists. When *arr already tracks the item,
|
||||||
|
* morphs into a status pill (Downloading / Pending / etc.) so the user
|
||||||
|
* sees the request state without a confusing duplicate Request button.
|
||||||
|
*
|
||||||
|
* Hides itself entirely when no *arr instance is configured for the
|
||||||
|
* relevant kind, since there's nothing to request against.
|
||||||
|
*/
|
||||||
|
export default function RequestButton({ tmdbId, kind, tmdbData, variant = 'pill' }: Props) {
|
||||||
|
const show = useRequestModal(s => s.show)
|
||||||
|
const availability = useItemAvailability(tmdbId)
|
||||||
|
|
||||||
|
if (!tmdbId) return null
|
||||||
|
|
||||||
|
// Available locally - nothing to request. Hide.
|
||||||
|
if (availability?.status === 'available') return null
|
||||||
|
|
||||||
|
// Note: we intentionally render the button even when the matching
|
||||||
|
// *arr (Radarr for movies, Sonarr for shows) isn't configured. The
|
||||||
|
// modal explains how to set it up. Hiding silently was confusing -
|
||||||
|
// users assumed the app didn't support requests at all.
|
||||||
|
|
||||||
|
// Tracked by *arr already - render a status chip instead of "Request"
|
||||||
|
// so the user can see what stage their existing request is in.
|
||||||
|
if (availability && availability.status !== 'missing') {
|
||||||
|
const Icon =
|
||||||
|
availability.status === 'processing' ? RefreshCw
|
||||||
|
: availability.status === 'partial' ? Check
|
||||||
|
: Clock
|
||||||
|
const label =
|
||||||
|
availability.status === 'processing' ? 'Downloading'
|
||||||
|
: availability.status === 'partial' ? 'Partially downloaded'
|
||||||
|
: availability.status === 'pending' ? 'Pending release'
|
||||||
|
: 'Requested'
|
||||||
|
if (variant === 'inline') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 h-7 px-3 rounded-full bg-accent/10 ring-1 ring-accent/30 text-[11.5px] text-accent font-medium tracking-tight">
|
||||||
|
<Icon size={11} stroke={2.25} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2 h-11 px-5 rounded-lg bg-accent/15 text-accent border border-accent/30 text-[13px] font-medium tracking-tight">
|
||||||
|
<Icon size={14} stroke={2} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!tmdbId) return
|
||||||
|
show({ tmdbId, kind, tmdbData })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'inline') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={open}
|
||||||
|
className="inline-flex items-center gap-1.5 h-7 px-3 rounded-full bg-accent text-void text-[11.5px] font-semibold tracking-tight transition hover:bg-accent-hover focus-ring"
|
||||||
|
>
|
||||||
|
<Database size={11} stroke={2.25} />
|
||||||
|
Request
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={open}
|
||||||
|
className="h-11 px-5 bg-accent hover:bg-accent-hover text-void rounded-lg flex items-center gap-2 text-[13px] font-semibold tracking-tight transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] shadow-[0_8px_24px_-8px_rgba(245,182,66,0.5)] focus-ring"
|
||||||
|
>
|
||||||
|
<Database size={14} stroke={2} />
|
||||||
|
Request
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { X, Database, Check, AlertCircle, Settings } from '../../lib/icons'
|
||||||
|
import { useArrInstances, type ArrInstance } from '../../stores/arr-instances-store'
|
||||||
|
import { useArrProfiles, useArrRootFolders, useSonarrLanguageProfiles } from '../../hooks/use-arr'
|
||||||
|
import { useOverrideRules, applyOverrideRules } from '../../stores/override-rules-store'
|
||||||
|
import { radarrClient } from '../../api/radarr'
|
||||||
|
import { sonarrClient, type SonarrSeason } from '../../api/sonarr'
|
||||||
|
import Select, { type SelectOption } from '../ui/Select'
|
||||||
|
import type { TmdbMovie, TmdbTvShow } from '../../api/tmdb'
|
||||||
|
|
||||||
|
/** Merged shape that lets call sites access both movie and TV fields without
|
||||||
|
* rediscriminating the union at every read. */
|
||||||
|
type TmdbDetail = Partial<TmdbMovie> & Partial<TmdbTvShow>
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
/** TMDB id of the item to request. */
|
||||||
|
tmdbId: number
|
||||||
|
kind: 'movie' | 'tv'
|
||||||
|
/** TMDB metadata so we can pre-fill title / year / images for *arr. */
|
||||||
|
tmdbData: TmdbMovie | TmdbTvShow | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot request flow. Opens with a default tier (regular) and lets
|
||||||
|
* the user choose between regular/4K, quality profile, root folder,
|
||||||
|
* monitored, search-on-add, and per-season selection for shows. On
|
||||||
|
* submit we call Radarr's POST /movie or Sonarr's POST /series.
|
||||||
|
*
|
||||||
|
* Override rules (Wave 31) hook in here later: when the user opens
|
||||||
|
* the modal, we evaluate active rules against the TMDB metadata and
|
||||||
|
* pre-select tier + profile + root folder accordingly.
|
||||||
|
*/
|
||||||
|
export default function RequestModal({ open, onClose, tmdbId, kind, tmdbData }: Props) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const radarr = useArrInstances(s => s.pick('radarr', 'default'))
|
||||||
|
const radarr4k = useArrInstances(s => s.pick('radarr', '4k'))
|
||||||
|
const sonarr = useArrInstances(s => s.pick('sonarr', 'default'))
|
||||||
|
const sonarr4k = useArrInstances(s => s.pick('sonarr', '4k'))
|
||||||
|
|
||||||
|
const tierOptions: Array<{ tier: 'default' | '4k'; instance: ArrInstance | null }> = kind === 'movie'
|
||||||
|
? [
|
||||||
|
{ tier: 'default', instance: radarr },
|
||||||
|
{ tier: '4k', instance: radarr4k },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ tier: 'default', instance: sonarr },
|
||||||
|
{ tier: '4k', instance: sonarr4k },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Evaluate override rules once per modal open so the tier and other
|
||||||
|
// selections start from the rule-driven values when one matches.
|
||||||
|
const rules = useOverrideRules(s => s.rules)
|
||||||
|
const matched = useMemo(() => {
|
||||||
|
if (!tmdbData) return null
|
||||||
|
const d = tmdbData as TmdbDetail
|
||||||
|
const ctx = {
|
||||||
|
kind,
|
||||||
|
genres: (d.genres || []).map(g => g.name).filter(Boolean) as string[],
|
||||||
|
originalLanguage: d.original_language || null,
|
||||||
|
year: parseInt(
|
||||||
|
String(d.release_date || d.first_air_date || '').slice(0, 4),
|
||||||
|
) || null,
|
||||||
|
keywordIds: (d.keywords?.keywords || d.keywords?.results || [])
|
||||||
|
.map(k => k?.id)
|
||||||
|
.filter((x): x is number => typeof x === 'number'),
|
||||||
|
}
|
||||||
|
return applyOverrideRules(rules, ctx)
|
||||||
|
}, [rules, tmdbData, kind])
|
||||||
|
|
||||||
|
const [tier, setTier] = useState<'default' | '4k'>(matched?.tier || 'default')
|
||||||
|
const active = tierOptions.find(t => t.tier === tier)?.instance || null
|
||||||
|
|
||||||
|
const profiles = useArrProfiles(active)
|
||||||
|
const rootFolders = useArrRootFolders(active)
|
||||||
|
const langProfiles = useSonarrLanguageProfiles(active)
|
||||||
|
|
||||||
|
const [profileId, setProfileId] = useState<number | null>(null)
|
||||||
|
const [rootFolder, setRootFolder] = useState<string | null>(null)
|
||||||
|
const [langProfileId, setLangProfileId] = useState<number | null>(null)
|
||||||
|
const [monitored, setMonitored] = useState(true)
|
||||||
|
const [searchOnAdd, setSearchOnAdd] = useState(true)
|
||||||
|
const [seasonsRequested, setSeasonsRequested] = useState<Record<number, boolean>>({})
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||||
|
|
||||||
|
// Initialise selections from the active instance's defaults whenever
|
||||||
|
// the user picks a different tier (or opens the modal). When a rule
|
||||||
|
// matched, its overrides take precedence over the instance defaults.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
setProfileId(matched?.qualityProfileId ?? active.defaultQualityProfileId ?? null)
|
||||||
|
setRootFolder(matched?.rootFolderPath ?? active.defaultRootFolder ?? null)
|
||||||
|
setLangProfileId(matched?.languageProfileId ?? active.defaultLanguageProfileId ?? null)
|
||||||
|
setMonitored(active.defaultMonitored ?? true)
|
||||||
|
}, [active, matched])
|
||||||
|
|
||||||
|
// For shows, pre-populate the seasons-to-request map from the TMDB
|
||||||
|
// payload. All seasons selected by default; user can opt out per-season.
|
||||||
|
const tvSeasons = useMemo(() => {
|
||||||
|
if (kind !== 'tv' || !tmdbData) return []
|
||||||
|
const seasons = (tmdbData as TmdbTvShow).seasons || []
|
||||||
|
return seasons.filter(s => (s.season_number ?? 0) > 0 || s.episode_count > 0)
|
||||||
|
}, [kind, tmdbData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (kind !== 'tv') return
|
||||||
|
const init: Record<number, boolean> = {}
|
||||||
|
for (const s of tvSeasons) init[s.season_number ?? 0] = true
|
||||||
|
setSeasonsRequested(init)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tmdbData?.id])
|
||||||
|
|
||||||
|
// Esc closes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
// Reset transient state when the modal closes so re-opening it for a
|
||||||
|
// different item starts fresh.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setResult(null)
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Auto-close after a successful request - the user gets a brief flash
|
||||||
|
// of confirmation, then we get out of their way.
|
||||||
|
useEffect(() => {
|
||||||
|
if (result?.ok) {
|
||||||
|
const id = setTimeout(() => onClose(), 1800)
|
||||||
|
return () => clearTimeout(id)
|
||||||
|
}
|
||||||
|
}, [result?.ok, onClose])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!active || !profileId || !rootFolder) return
|
||||||
|
if (!tmdbData) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
if (kind === 'movie' && active.kind === 'radarr') {
|
||||||
|
const lookup = await radarrClient(active).lookupByTmdbId(tmdbId)
|
||||||
|
const match = lookup?.[0]
|
||||||
|
if (!match) {
|
||||||
|
setResult({ ok: false, message: "Radarr couldn't find this movie on TMDB - check the id." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const added = await radarrClient(active).addMovie({
|
||||||
|
tmdbId,
|
||||||
|
qualityProfileId: profileId,
|
||||||
|
rootFolderPath: rootFolder,
|
||||||
|
monitored,
|
||||||
|
searchOnAdd,
|
||||||
|
title: match.title,
|
||||||
|
titleSlug: (match as any).titleSlug || '',
|
||||||
|
year: match.year || 0,
|
||||||
|
images: match.images,
|
||||||
|
minimumAvailability: 'released',
|
||||||
|
})
|
||||||
|
if (added) {
|
||||||
|
setResult({ ok: true, message: `Requested "${match.title}". Radarr will start searching now.` })
|
||||||
|
qc.invalidateQueries({ queryKey: ['radarr', 'library'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['radarr', 'queue'] })
|
||||||
|
} else {
|
||||||
|
setResult({ ok: false, message: 'Radarr rejected the request. It may already exist there.' })
|
||||||
|
}
|
||||||
|
} else if (kind === 'tv' && active.kind === 'sonarr') {
|
||||||
|
const lookup = await sonarrClient(active).lookupByTmdbId(tmdbId)
|
||||||
|
const match = lookup?.[0]
|
||||||
|
if (!match || !match.tvdbId) {
|
||||||
|
setResult({ ok: false, message: "Sonarr couldn't find a TVDB id for this show. Check that Sonarr's metadata is populated." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const seasons: SonarrSeason[] = (match.seasons || []).map(s => ({
|
||||||
|
seasonNumber: s.seasonNumber,
|
||||||
|
monitored: !!seasonsRequested[s.seasonNumber],
|
||||||
|
}))
|
||||||
|
const added = await sonarrClient(active).addSeries({
|
||||||
|
tvdbId: match.tvdbId,
|
||||||
|
qualityProfileId: profileId,
|
||||||
|
languageProfileId: langProfileId ?? undefined,
|
||||||
|
rootFolderPath: rootFolder,
|
||||||
|
monitored,
|
||||||
|
searchOnAdd,
|
||||||
|
title: match.title,
|
||||||
|
titleSlug: (match as any).titleSlug || '',
|
||||||
|
year: match.year || 0,
|
||||||
|
images: match.images,
|
||||||
|
seasons,
|
||||||
|
})
|
||||||
|
if (added) {
|
||||||
|
setResult({ ok: true, message: `Requested "${match.title}". Sonarr is monitoring the seasons you picked.` })
|
||||||
|
qc.invalidateQueries({ queryKey: ['sonarr', 'library'] })
|
||||||
|
qc.invalidateQueries({ queryKey: ['sonarr', 'queue'] })
|
||||||
|
} else {
|
||||||
|
setResult({ ok: false, message: 'Sonarr rejected the request. It may already exist there.' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setResult({ ok: false, message: `No ${kind === 'movie' ? 'Radarr' : 'Sonarr'} instance configured for this tier.` })
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
setResult({ ok: false, message: String(e?.message || e) })
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const noInstance = !radarr && !radarr4k && !sonarr && !sonarr4k
|
||||||
|
const noTierForKind = kind === 'movie'
|
||||||
|
? !radarr && !radarr4k
|
||||||
|
: !sonarr && !sonarr4k
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 z-[80] grid place-items-center bg-black/65 backdrop-blur-sm p-6"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 20, scale: 0.96 }}
|
||||||
|
animate={{ y: 0, scale: 1 }}
|
||||||
|
exit={{ y: 20, scale: 0.96 }}
|
||||||
|
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="relative w-full max-w-xl rounded-2xl bg-surface ring-1 ring-border-strong shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] max-h-[88vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<header className="sticky top-0 z-10 flex items-center justify-between p-5 pb-3 bg-surface/95 backdrop-blur border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database size={16} className="text-accent" />
|
||||||
|
<h2 className="text-[16px] font-semibold tracking-tight text-text-1">
|
||||||
|
Request {kind === 'movie' ? 'movie' : 'show'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
className="w-8 h-8 grid place-items-center rounded-full text-text-3 hover:text-text-1 hover:bg-elevated transition focus-ring"
|
||||||
|
>
|
||||||
|
<X size={14} stroke={2} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4 text-[13px]">
|
||||||
|
{tmdbData && (() => { const d = tmdbData as TmdbDetail; return (
|
||||||
|
<p className="text-[14px] text-text-1 font-semibold tracking-tight">
|
||||||
|
{d.title || d.name}
|
||||||
|
{d.release_date && (
|
||||||
|
<span className="text-text-3 font-normal ml-2 tabular-nums">
|
||||||
|
{String(d.release_date).slice(0, 4)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{d.first_air_date && (
|
||||||
|
<span className="text-text-3 font-normal ml-2 tabular-nums">
|
||||||
|
{String(d.first_air_date).slice(0, 4)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)})()}
|
||||||
|
|
||||||
|
{noInstance || noTierForKind ? (
|
||||||
|
<div className="rounded-xl bg-amber-500/8 ring-1 ring-amber-400/25 p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle size={16} className="text-amber-300 shrink-0 mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[12.5px] text-amber-100 font-medium mb-1">
|
||||||
|
{noInstance
|
||||||
|
? 'No Sonarr or Radarr instance configured'
|
||||||
|
: `No ${kind === 'movie' ? 'Radarr' : 'Sonarr'} instance configured`}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11.5px] text-amber-100/80 leading-relaxed mb-2.5">
|
||||||
|
{noInstance
|
||||||
|
? 'Connect a Sonarr or Radarr instance to start requesting new titles.'
|
||||||
|
: `Add a ${kind === 'movie' ? 'Radarr' : 'Sonarr'} instance to request ${kind === 'movie' ? 'movies' : 'shows'}.`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onClose()
|
||||||
|
navigate('/settings#sonarr-radarr')
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-amber-500/15 ring-1 ring-amber-400/40 text-[11.5px] text-amber-100 font-semibold tracking-tight hover:bg-amber-500/25 transition focus-ring"
|
||||||
|
>
|
||||||
|
<Settings size={11} stroke={2} />
|
||||||
|
Open Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Tier picker */}
|
||||||
|
{tierOptions.filter(t => t.instance).length > 1 && (
|
||||||
|
<Field label="Quality tier">
|
||||||
|
<div className="inline-flex bg-elevated/40 ring-1 ring-border rounded-md p-0.5">
|
||||||
|
{tierOptions.map(t => {
|
||||||
|
if (!t.instance) return null
|
||||||
|
const on = tier === t.tier
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.tier}
|
||||||
|
onClick={() => setTier(t.tier)}
|
||||||
|
className={`h-8 px-3 rounded text-[11.5px] font-medium tracking-tight transition focus-ring ${
|
||||||
|
on ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.tier === '4k' ? '4K' : 'Regular'}
|
||||||
|
<span className="ml-1.5 text-[10px] opacity-70">{t.instance.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Quality profile">
|
||||||
|
<Select
|
||||||
|
ariaLabel="Quality profile"
|
||||||
|
width="w-full"
|
||||||
|
value={profileId != null ? String(profileId) : ''}
|
||||||
|
onChange={v => setProfileId(v ? Number(v) : null)}
|
||||||
|
placeholder="Pick a profile"
|
||||||
|
options={(profiles.data || []).map<SelectOption<string>>((p: any) => ({
|
||||||
|
value: String(p.id),
|
||||||
|
label: p.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Root folder">
|
||||||
|
<Select
|
||||||
|
ariaLabel="Root folder"
|
||||||
|
width="w-full"
|
||||||
|
value={rootFolder ?? ''}
|
||||||
|
onChange={v => setRootFolder(v || null)}
|
||||||
|
placeholder="Pick a folder"
|
||||||
|
options={(rootFolders.data || []).map<SelectOption<string>>((r: any) => ({
|
||||||
|
value: r.path,
|
||||||
|
label: <span className="font-mono">{r.path}</span>,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{kind === 'tv' && langProfiles.data && langProfiles.data.length > 0 && (
|
||||||
|
<Field label="Language profile">
|
||||||
|
<Select
|
||||||
|
ariaLabel="Language profile"
|
||||||
|
width="w-full"
|
||||||
|
value={langProfileId != null ? String(langProfileId) : ''}
|
||||||
|
onChange={v => setLangProfileId(v ? Number(v) : null)}
|
||||||
|
placeholder="Pick a language profile"
|
||||||
|
options={(langProfiles.data || []).map<SelectOption<string>>((p: any) => ({
|
||||||
|
value: String(p.id),
|
||||||
|
label: p.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kind === 'tv' && tvSeasons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="block text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3">
|
||||||
|
Seasons
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="text-text-4 tabular-nums">
|
||||||
|
<span className="text-text-2 font-medium">{Object.values(seasonsRequested).filter(Boolean).length}</span>
|
||||||
|
{' / '}
|
||||||
|
{tvSeasons.length} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const next: Record<number, boolean> = {}
|
||||||
|
for (const s of tvSeasons) next[s.season_number ?? 0] = true
|
||||||
|
setSeasonsRequested(next)
|
||||||
|
}}
|
||||||
|
className="text-accent hover:text-accent-hover transition focus-ring rounded font-medium tracking-tight"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<span className="text-text-5">·</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSeasonsRequested({})}
|
||||||
|
className="text-text-3 hover:text-text-1 transition focus-ring rounded font-medium tracking-tight"
|
||||||
|
>
|
||||||
|
None
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 max-h-44 overflow-y-auto py-1 px-0.5 -mx-0.5 hide-scrollbar">
|
||||||
|
{tvSeasons.map(s => {
|
||||||
|
const num = s.season_number ?? 0
|
||||||
|
const on = !!seasonsRequested[num]
|
||||||
|
const label = num === 0 ? 'Specials' : `Season ${num}`
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={num}
|
||||||
|
onClick={() =>
|
||||||
|
setSeasonsRequested(p => ({ ...p, [num]: !on }))
|
||||||
|
}
|
||||||
|
className={`h-7 px-2.5 rounded-full text-[11px] font-medium tracking-tight transition border focus-ring ${
|
||||||
|
on
|
||||||
|
? 'bg-accent/15 text-accent border-accent/40'
|
||||||
|
: 'bg-elevated/40 text-text-2 border-border hover:border-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className={`tabular-nums ml-1 ${on ? 'text-accent/70' : 'text-text-4'}`}>{s.episode_count || 0}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 pt-1 flex-wrap">
|
||||||
|
<label className="inline-flex items-center gap-2 text-[12px] text-text-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={monitored}
|
||||||
|
onChange={e => setMonitored(e.target.checked)}
|
||||||
|
className="accent-amber-500"
|
||||||
|
/>
|
||||||
|
Monitor for new episodes / releases
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-2 text-[12px] text-text-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={searchOnAdd}
|
||||||
|
onChange={e => setSearchOnAdd(e.target.checked)}
|
||||||
|
className="accent-amber-500"
|
||||||
|
/>
|
||||||
|
Search now
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div
|
||||||
|
className={`rounded-md p-3 text-[12px] leading-relaxed ring-1 ${
|
||||||
|
result.ok
|
||||||
|
? 'bg-success/10 text-success ring-success/30'
|
||||||
|
: 'bg-red-500/10 text-red-200 ring-red-500/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1.5 font-medium">
|
||||||
|
{result.ok ? <Check size={12} stroke={2.5} /> : <AlertCircle size={12} />}
|
||||||
|
{result.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="sticky bottom-0 z-10 flex items-center justify-end gap-2 p-5 pt-3 bg-surface/95 backdrop-blur border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-10 px-4 rounded-full text-[12.5px] text-text-2 hover:text-text-1 hover:bg-elevated transition focus-ring"
|
||||||
|
>
|
||||||
|
{result?.ok ? 'Done' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
{!result?.ok && (
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={submitting || noInstance || noTierForKind || !profileId || !rootFolder}
|
||||||
|
className="h-10 px-5 rounded-full bg-accent text-void text-[12.5px] font-semibold tracking-tight transition disabled:opacity-40 disabled:cursor-not-allowed hover:bg-accent-hover focus-ring"
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting...' : 'Request'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { Plus, Trash2, ChevronUp, ChevronDown } from '../../lib/icons'
|
||||||
|
import { useOverrideRules, type OverrideRule } from '../../stores/override-rules-store'
|
||||||
|
import { useArrInstances } from '../../stores/arr-instances-store'
|
||||||
|
import { useArrProfiles, useArrRootFolders } from '../../hooks/use-arr'
|
||||||
|
import Select, { type SelectOption } from '../ui/Select'
|
||||||
|
|
||||||
|
const COMMON_GENRES = [
|
||||||
|
'Action', 'Adventure', 'Animation', 'Anime', 'Comedy', 'Crime',
|
||||||
|
'Documentary', 'Drama', 'Family', 'Fantasy', 'History', 'Horror',
|
||||||
|
'Music', 'Mystery', 'Romance', 'Science Fiction', 'Thriller', 'War', 'Western',
|
||||||
|
]
|
||||||
|
|
||||||
|
const COMMON_LANGS = ['en', 'ja', 'ko', 'fr', 'es', 'it', 'de', 'zh', 'ru', 'pt', 'tr', 'ar']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI for the override-rules store. Lists existing rules with reorder /
|
||||||
|
* delete affordances; expanding a row shows the editor inline. The
|
||||||
|
* "Add rule" button creates a permissive default the user can refine.
|
||||||
|
*/
|
||||||
|
export default function OverrideRulesEditor() {
|
||||||
|
const rules = useOverrideRules(s => s.rules)
|
||||||
|
const add = useOverrideRules(s => s.add)
|
||||||
|
const update = useOverrideRules(s => s.update)
|
||||||
|
const remove = useOverrideRules(s => s.remove)
|
||||||
|
const reorder = useOverrideRules(s => s.reorder)
|
||||||
|
const [editing, setEditing] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function newRule() {
|
||||||
|
const created = add({
|
||||||
|
name: 'Untitled rule',
|
||||||
|
kind: 'any',
|
||||||
|
genres: [],
|
||||||
|
languages: [],
|
||||||
|
keywordIds: [],
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
setEditing(created.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[12.5px] text-text-3 leading-relaxed max-w-md">
|
||||||
|
Rules evaluate top-to-bottom; the first match wins. Use them to route Anime to a different Sonarr instance, or 4K-only requests to a different root folder.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={newRule}
|
||||||
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-full bg-accent text-void text-[12px] font-semibold tracking-tight hover:bg-accent-hover transition focus-ring shrink-0"
|
||||||
|
>
|
||||||
|
<Plus size={11} stroke={2.25} />
|
||||||
|
Add rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-text-4 italic py-4">No rules yet - requests will use each instance's defaults.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{rules.map((rule, i) => (
|
||||||
|
<RuleCard
|
||||||
|
key={rule.id}
|
||||||
|
rule={rule}
|
||||||
|
expanded={editing === rule.id}
|
||||||
|
isFirst={i === 0}
|
||||||
|
isLast={i === rules.length - 1}
|
||||||
|
onToggle={() => setEditing(editing === rule.id ? null : rule.id)}
|
||||||
|
onChange={patch => update(rule.id, patch)}
|
||||||
|
onRemove={() => {
|
||||||
|
if (confirm(`Delete rule "${rule.name}"?`)) remove(rule.id)
|
||||||
|
}}
|
||||||
|
onMove={dir => reorder(rule.id, dir)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuleCard({
|
||||||
|
rule,
|
||||||
|
expanded,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
onToggle,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
onMove,
|
||||||
|
}: {
|
||||||
|
rule: OverrideRule
|
||||||
|
expanded: boolean
|
||||||
|
isFirst: boolean
|
||||||
|
isLast: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
onChange: (patch: Partial<OverrideRule>) => void
|
||||||
|
onRemove: () => void
|
||||||
|
onMove: (dir: -1 | 1) => void
|
||||||
|
}) {
|
||||||
|
const instances = useArrInstances(s => s.instances)
|
||||||
|
// Pick the instance the rule's tier + kind would route to so we can
|
||||||
|
// populate profile / root folder dropdowns from a real source.
|
||||||
|
const targetInstance = useMemo(() => {
|
||||||
|
if (rule.kind === 'movie') {
|
||||||
|
return instances.find(i => i.kind === 'radarr' && i.tier === (rule.tier || 'default')) || null
|
||||||
|
}
|
||||||
|
if (rule.kind === 'tv') {
|
||||||
|
return instances.find(i => i.kind === 'sonarr' && i.tier === (rule.tier || 'default')) || null
|
||||||
|
}
|
||||||
|
// For 'any', prefer Radarr default (the override fields apply to
|
||||||
|
// whichever kind the request is - we just need an instance to list
|
||||||
|
// valid choices from).
|
||||||
|
return instances.find(i => i.tier === (rule.tier || 'default')) || instances[0] || null
|
||||||
|
}, [instances, rule.kind, rule.tier])
|
||||||
|
const profiles = useArrProfiles(targetInstance)
|
||||||
|
const rootFolders = useArrRootFolders(targetInstance)
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg bg-elevated/40 ring-1 ring-border overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.enabled}
|
||||||
|
onChange={e => onChange({ enabled: e.target.checked })}
|
||||||
|
className="accent-amber-500"
|
||||||
|
aria-label="Rule enabled"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex-1 text-left min-w-0 focus-ring rounded"
|
||||||
|
>
|
||||||
|
<p className="text-[13px] font-medium text-text-1 truncate tracking-tight">{rule.name}</p>
|
||||||
|
<p className="text-[11px] text-text-3 truncate tabular-nums">
|
||||||
|
{summarise(rule)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onMove(-1)}
|
||||||
|
disabled={isFirst}
|
||||||
|
className="w-7 h-7 grid place-items-center rounded text-text-4 hover:text-text-1 disabled:opacity-30 transition"
|
||||||
|
aria-label="Move up"
|
||||||
|
>
|
||||||
|
<ChevronUp size={12} stroke={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onMove(1)}
|
||||||
|
disabled={isLast}
|
||||||
|
className="w-7 h-7 grid place-items-center rounded text-text-4 hover:text-text-1 disabled:opacity-30 transition"
|
||||||
|
aria-label="Move down"
|
||||||
|
>
|
||||||
|
<ChevronDown size={12} stroke={2} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="w-7 h-7 grid place-items-center rounded text-text-4 hover:text-red-300 transition"
|
||||||
|
aria-label="Delete rule"
|
||||||
|
>
|
||||||
|
<Trash2 size={11} stroke={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{expanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="overflow-hidden border-t border-border/50"
|
||||||
|
>
|
||||||
|
<div className="p-4 space-y-4 text-[13px]">
|
||||||
|
<Field label="Name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.name}
|
||||||
|
onChange={e => onChange({ name: e.target.value })}
|
||||||
|
className="w-full h-9 px-3 rounded-md bg-void/40 ring-1 ring-border focus:ring-accent/50 outline-none text-[12.5px] tracking-tight"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Applies to">
|
||||||
|
<Segmented
|
||||||
|
value={rule.kind}
|
||||||
|
onChange={v => onChange({ kind: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'any', label: 'Any' },
|
||||||
|
{ value: 'movie', label: 'Movies' },
|
||||||
|
{ value: 'tv', label: 'Shows' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Genres" hint="Match if any of these genre names is present (empty = match any)">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{COMMON_GENRES.map(g => {
|
||||||
|
const on = rule.genres.includes(g)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
genres: on
|
||||||
|
? rule.genres.filter(x => x !== g)
|
||||||
|
: [...rule.genres, g],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`h-7 px-2.5 rounded-full text-[11px] tracking-tight transition border ${
|
||||||
|
on
|
||||||
|
? 'bg-accent/15 text-accent border-accent/40'
|
||||||
|
: 'bg-elevated/40 text-text-2 border-border hover:border-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Original language" hint="ISO 639-1 codes - empty matches any">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{COMMON_LANGS.map(l => {
|
||||||
|
const on = rule.languages.includes(l)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
languages: on
|
||||||
|
? rule.languages.filter(x => x !== l)
|
||||||
|
: [...rule.languages, l],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`h-7 px-2.5 rounded-full text-[11px] uppercase tracking-[0.08em] font-medium transition border ${
|
||||||
|
on
|
||||||
|
? 'bg-accent/15 text-accent border-accent/40'
|
||||||
|
: 'bg-elevated/40 text-text-2 border-border hover:border-border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{l}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Year range">
|
||||||
|
<div className="flex items-center gap-2 max-w-xs">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.yearMin ?? ''}
|
||||||
|
onChange={e => onChange({ yearMin: e.target.value ? Number(e.target.value) : undefined })}
|
||||||
|
placeholder="From"
|
||||||
|
className="flex-1 h-9 px-3 rounded-md bg-void/40 ring-1 ring-border focus:ring-accent/50 outline-none tabular-nums text-[12.5px]"
|
||||||
|
/>
|
||||||
|
<span className="text-text-4">→</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.yearMax ?? ''}
|
||||||
|
onChange={e => onChange({ yearMax: e.target.value ? Number(e.target.value) : undefined })}
|
||||||
|
placeholder="To"
|
||||||
|
className="flex-1 h-9 px-3 rounded-md bg-void/40 ring-1 ring-border focus:ring-accent/50 outline-none tabular-nums text-[12.5px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-border/40 space-y-3">
|
||||||
|
<p className="text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3">
|
||||||
|
When matched, override:
|
||||||
|
</p>
|
||||||
|
<Field label="Tier">
|
||||||
|
<Segmented
|
||||||
|
value={rule.tier || 'inherit'}
|
||||||
|
onChange={v => onChange({ tier: v === 'inherit' ? undefined : (v as 'default' | '4k') })}
|
||||||
|
options={[
|
||||||
|
{ value: 'inherit', label: 'Inherit' },
|
||||||
|
{ value: 'default', label: 'Regular' },
|
||||||
|
{ value: '4k', label: '4K' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{!targetInstance && (
|
||||||
|
<p className="text-[11px] text-amber-200 bg-amber-500/8 ring-1 ring-amber-400/25 rounded-md px-3 py-2 leading-relaxed">
|
||||||
|
Connect a {rule.kind === 'tv' ? 'Sonarr' : rule.kind === 'movie' ? 'Radarr' : 'Sonarr or Radarr'} instance to pick a profile or folder. Until then this rule will inherit the instance defaults.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Field label="Quality profile">
|
||||||
|
<Select
|
||||||
|
ariaLabel="Quality profile override"
|
||||||
|
width="w-full"
|
||||||
|
value={rule.qualityProfileId != null ? String(rule.qualityProfileId) : ''}
|
||||||
|
onChange={v => onChange({ qualityProfileId: v ? Number(v) : undefined })}
|
||||||
|
placeholder="Inherit from instance"
|
||||||
|
disabled={!targetInstance || !profiles.data}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Inherit from instance', muted: true } as SelectOption<string>,
|
||||||
|
...((profiles.data || []).map<SelectOption<string>>((p: any) => ({
|
||||||
|
value: String(p.id),
|
||||||
|
label: p.name,
|
||||||
|
}))),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Root folder">
|
||||||
|
<Select
|
||||||
|
ariaLabel="Root folder override"
|
||||||
|
width="w-full"
|
||||||
|
value={rule.rootFolderPath ?? ''}
|
||||||
|
onChange={v => onChange({ rootFolderPath: v || undefined })}
|
||||||
|
placeholder="Inherit from instance"
|
||||||
|
disabled={!targetInstance || !rootFolders.data}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'Inherit from instance', muted: true } as SelectOption<string>,
|
||||||
|
...((rootFolders.data || []).map<SelectOption<string>>((r: any) => ({
|
||||||
|
value: r.path,
|
||||||
|
label: <span className="font-mono">{r.path}</span>,
|
||||||
|
}))),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-[10.5px] uppercase tracking-[0.16em] font-semibold text-text-3 mb-1.5">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{hint && <p className="text-[11px] text-text-4 mt-1.5 leading-relaxed">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segmented<T extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
value: T
|
||||||
|
onChange: (v: T) => void
|
||||||
|
options: { value: T; label: string }[]
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex bg-elevated/40 ring-1 ring-border rounded-md p-0.5">
|
||||||
|
{options.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
className={`h-8 px-3 rounded text-[11.5px] font-medium tracking-tight transition focus-ring ${
|
||||||
|
value === o.value ? 'bg-accent text-void' : 'text-text-3 hover:text-text-1'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarise(rule: OverrideRule): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (rule.kind !== 'any') parts.push(rule.kind === 'movie' ? 'Movies' : 'Shows')
|
||||||
|
if (rule.genres.length > 0) parts.push(rule.genres.slice(0, 3).join(', '))
|
||||||
|
if (rule.languages.length > 0) parts.push(rule.languages.join(' / ').toUpperCase())
|
||||||
|
if (rule.yearMin != null || rule.yearMax != null) parts.push(`${rule.yearMin ?? '...'}-${rule.yearMax ?? '...'}`)
|
||||||
|
if (rule.tier) parts.push(`→ ${rule.tier === '4k' ? '4K tier' : 'Regular tier'}`)
|
||||||
|
if (rule.qualityProfileId) parts.push(`profile ${rule.qualityProfileId}`)
|
||||||
|
return parts.length > 0 ? parts.join(' · ') : 'Matches everything'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user