request modal for radarr/sonarr
This commit is contained in:
@@ -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