Migrate UI to React and Tailwind Refs #32

This commit is contained in:
2026-07-30 14:54:46 +02:00
parent fb955adaf8
commit 65c96bbc4b
23 changed files with 6061 additions and 545 deletions
+23
View File
@@ -0,0 +1,23 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "../lib/utils";
const buttonVariants = cva("inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary disabled:pointer-events-none disabled:opacity-50", {
variants: { variant: { default: "bg-primary text-white hover:bg-secondary", secondary: "bg-secondary text-white hover:bg-primary", outline: "border bg-card hover:bg-input", ghost: "hover:bg-input", destructive: "bg-primary text-white hover:bg-secondary" }, size: { default: "h-9 px-4", icon: "h-9 w-9 p-0", sm: "h-8 px-3" } },
defaultVariants: { variant: "default", size: "default" }
});
export function Button({ className, variant, size, asChild = false, ...props }) { const Comp = asChild ? Slot : "button"; return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />; }
export function Input({ className, ...props }) { return <input className={cn("flex h-9 w-full rounded-md border bg-input px-3 py-1 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-secondary", className)} {...props} />; }
export function Card({ className, ...props }) { return <section className={cn("rounded-lg border bg-card shadow-sm", className)} {...props} />; }
export function CardHeader({ className, ...props }) { return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />; }
export function CardTitle({ className, ...props }) { return <h2 className={cn("text-lg font-semibold", className)} {...props} />; }
export function CardContent({ className, ...props }) { return <div className={cn("p-6 pt-0", className)} {...props} />; }
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;
export function DialogContent({ className, children, ...props }) { return <DialogPrimitive.Portal><DialogPrimitive.Overlay className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" /><DialogPrimitive.Content className={cn("fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border bg-card p-6 shadow-xl", className)} {...props}>{children}<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm text-muted-foreground hover:text-foreground" aria-label="Close"><X className="h-4 w-4" /></DialogPrimitive.Close></DialogPrimitive.Content></DialogPrimitive.Portal>; }
export function DialogHeader({ className, ...props }) { return <div className={cn("mb-4 space-y-1.5", className)} {...props} />; }
export function DialogTitle({ className, ...props }) { return <DialogPrimitive.Title className={cn("text-lg font-semibold", className)} {...props} />; }
export function Badge({ className, ...props }) { return <span className={cn("inline-flex rounded-full bg-primary px-2 py-0.5 text-xs font-medium text-white", className)} {...props} />; }
+33
View File
@@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
color-scheme: light;
--background: #F8FAFC;
--card: #FFFFFF;
--input: #FFFFFF;
--primary: #4A43EC;
--secondary: #7171FF;
--accent: #2AD1ED;
--foreground: #1A1D2E;
--muted-foreground: #64748B;
--border: #E2E8F0;
}
.dark {
color-scheme: dark;
--background: #252B42;
--card: #303753;
--input: #23283B;
--primary: #4A43EC;
--secondary: #7171FF;
--accent: #42ECF5;
--foreground: #FFFFFF;
--muted-foreground: #8B95B7;
--border: #3D4668;
}
* { @apply border-border; }
body { @apply m-0 min-w-0 bg-background text-foreground antialiased; }
button, input { font: inherit; }
}
+3
View File
@@ -0,0 +1,3 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs) => twMerge(clsx(inputs));
+51
View File
@@ -0,0 +1,51 @@
import React, { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import { AlertTriangle, Clock, Download, FolderPlus, History, Info, Menu, Moon, Pause, Play, RefreshCw, Settings, Square, Sun, Trash2, XCircle, Zap } from "lucide-react";
import "./globals.css";
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Input } from "./components/ui";
const groups = ["sab_processing", "ready", "importing", "failed", "ignored_category", "manual_batch", "completed"];
const labels = { sab_processing: "SAB processing", ready: "Ready", importing: "Importing", failed: "Failed", ignored_category: "Ignored category", manual_batch: "Manual batch", completed: "Completed" };
const bytes = value => { let n=Number(value||0), i=0; const units=["B","KB","MB","GB","TB"]; if(!n)return "size unknown"; while(n>=1024&&i<4){n/=1024;i++} return `${n.toFixed(n>=10||!i?0:1)} ${units[i]}`; };
const duration = value => { const s=Math.max(0,Math.floor(Number(value||0))); return s>=60?`${Math.floor(s/60)}m ${String(s%60).padStart(2,"0")}s`:`${s}s`; };
async function request(url, options={}) {
const response=await fetch(url, options.body ? {...options,headers:{"content-type":"application/json",...options.headers},body:JSON.stringify(options.body)} : options);
if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); throw new Error(typeof error.detail==="string"?error.detail:"Request failed"); }
return response.status===204?null:response.json();
}
function Modal({ trigger, title, children }) { return <Dialog><DialogTrigger asChild>{trigger}</DialogTrigger><DialogContent><DialogHeader><DialogTitle>{title}</DialogTitle></DialogHeader>{children}</DialogContent></Dialog>; }
function Field({ label, ...props }) { return <label className="grid gap-2 text-sm font-medium">{label}<Input {...props}/></label>; }
export function SettingsDialog({ status, refresh }) {
const [result,setResult]=useState({});
const [urls,setUrls]=useState({sab_url:"",radarr_url:"",sonarr_url:""});
useEffect(()=>setUrls({sab_url:status?.sab_url||"",radarr_url:status?.radarr_url||"",sonarr_url:status?.sonarr_url||""}),[status?.sab_url,status?.radarr_url,status?.sonarr_url]);
const submit=async e=>{ e.preventDefault(); try { await request("/api/settings",{method:"POST",body:Object.fromEntries(new FormData(e.currentTarget))}); e.currentTarget.reset(); await refresh(); } catch(error){ alert(error.message); } };
const test=async (service,form)=>{ const prefix=service==="sabnzbd"?"sab":service; setResult(r=>({...r,[service]:"Testing…"})); try { const data=await request("/api/settings/test-connection",{method:"POST",body:{service,url:form.elements[`${prefix}_url`].value,api_key:form.elements[`${prefix}_api_key`].value}}); setResult(r=>({...r,[service]:data.message})); } catch(error){setResult(r=>({...r,[service]:error.message}))} };
return <><Modal title="Settings" trigger={<Button variant="ghost" className="w-full justify-start"><Settings/> Settings</Button>}><form onSubmit={submit} className="grid gap-6">{[["sab","sabnzbd","SABnzbd"],["radarr","radarr","Radarr"],["sonarr","sonarr","Sonarr"]].map(([prefix,service,label])=><fieldset className="grid gap-3 rounded-md border p-4" key={service}><legend className="px-2 font-semibold">{label}</legend><Field label={`${label} URL`} name={`${prefix}_url`} type="url" required={prefix==="sab"} value={urls[`${prefix}_url`]} onChange={e=>setUrls(current=>({...current,[e.target.name]:e.target.value}))}/><Field label="API token" name={`${prefix}_api_key`} type="password" autoComplete="off" placeholder={status?.[`${prefix}_api_key_configured`]?"Configured; enter replacement":"API token"}/><div className="flex items-center gap-3"><Button type="button" variant="outline" onClick={e=>test(service,e.currentTarget.form)}>Test connection</Button><span className="text-sm text-muted-foreground">{result[service]}</span></div></fieldset>)}<p className="text-sm text-muted-foreground">Blank token fields clear stored tokens.</p><Button>Save settings</Button></form></Modal><Button variant="ghost" className="w-full justify-start" onClick={async()=>{try{await request("/api/import/run-now",{method:"POST",body:{force:true}});await refresh()}catch(error){alert(error.message)}}}><Zap/> Force run now</Button></>;
}
function ManualBatches({ refresh }) { const [batches,setBatches]=useState([]); const load=()=>request("/api/manual-batches").then(setBatches).catch(()=>{}); return <Modal title="Manual batches" trigger={<Button variant="ghost" className="w-full justify-start" onClick={load}><FolderPlus/> Manual batches</Button>}><form className="flex flex-col gap-3 sm:flex-row" onSubmit={async e=>{e.preventDefault();try{await request("/api/manual-batches",{method:"POST",body:{path:e.currentTarget.path.value}});e.currentTarget.reset();load();refresh()}catch(error){alert(error.message)}}}><Input name="path" required placeholder="Folder under download root"/><Button>Add batch</Button></form><div className="mt-4 grid gap-2">{batches.length?batches.map(b=><div className="rounded-md border p-3 text-sm" key={b.id}><strong>#{b.id} · {b.status}</strong><p className="break-all text-muted-foreground">{b.path}</p></div>):<p className="text-muted-foreground">No manual batches.</p>}</div></Modal>; }
export function App(){
const [status,setStatus]=useState(null), [jobs,setJobs]=useState([]), [update,setUpdate]=useState(null), [menu,setMenu]=useState(false);
const [dark,setDark]=useState(()=>localStorage.getItem("importarr-theme")!=="light" && (localStorage.getItem("importarr-theme")==="dark"||matchMedia("(prefers-color-scheme: dark)").matches));
const refresh=async()=>{ try { const [s,j]=await Promise.all([request("/api/status"),request("/api/jobs")]); setStatus(s);setJobs((j.jobs||[]).map(job=>({...job,reason:[job.reason,job.source_type==="sab"&&`SAB ${job.sab_status||"—"}${job.sab_category?` · ${job.sab_category}`:""}`,job.batch_id&&`batch ${job.batch_id}`].filter(Boolean).join(" · ")})));document.title=`${j.jobs?.length?`${j.jobs.length} jobs`:"Idle"} · Importarr`; } catch(error){ console.error(error); } };
useEffect(()=>{document.documentElement.classList.toggle("dark",dark);localStorage.setItem("importarr-theme",dark?"dark":"light")},[dark]);
useEffect(()=>{refresh();const id=setInterval(refresh,2000);request("/api/control/update-check").then(setUpdate).catch(()=>{});return()=>clearInterval(id)},[]);
const post=async(url,body)=>{try{await request(url,{method:"POST",body});await refresh()}catch(error){alert(error.message)}};
const control=action=>{if(action==="cancel-current"&&!confirm("Cancel the current import job?"))return;post(`/api/control/${action}`)};
const current=status?.current, currentName=typeof current==="object"?(current.file||current.name):current;
return <div className="min-h-screen bg-background">
<header className="sticky top-0 z-30 border-b bg-card/95 backdrop-blur"><div className="mx-auto flex max-w-7xl items-center gap-3 p-4"><Download className="h-7 w-7 text-accent"/><div><h1 className="text-xl font-bold">Importarr</h1><p className="text-xs capitalize text-muted-foreground">{status?.control?.queue_mode||"Connecting"}</p></div><div className="ml-auto hidden max-w-md truncate text-sm text-muted-foreground sm:block">{currentName||"No active import"}</div><Button size="icon" aria-label="Start imports" onClick={()=>control("start")}><Play/></Button><Button size="icon" variant="outline" aria-label="Pause imports" onClick={()=>control("pause")}><Pause/></Button><div className="relative"><Button size="icon" variant="outline" aria-label="Open menu" onClick={()=>setMenu(!menu)}><Menu/></Button>{menu&&<Card className="absolute right-0 mt-2 w-60 p-2"><SettingsDialog status={status} refresh={refresh}/><ManualBatches refresh={refresh}/><Modal title="Service info" trigger={<Button variant="ghost" className="w-full justify-start"><Info/> Service info</Button>}><dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-sm">{status&&Object.entries({Version:status.build.version,"Build date":status.build.build_date,"Git SHA":status.build.git_sha,"SAB URL":status.sab_url,"Download root":status.download_root,"Movies root":status.movies_root,"TV root":status.tv_root,"Write auth":status.auth_enabled?"enabled":"disabled"}).map(([k,v])=><React.Fragment key={k}><dt className="font-medium">{k}</dt><dd className="break-all text-muted-foreground">{String(v)}</dd></React.Fragment>)}</dl></Modal><Button variant="ghost" className="w-full justify-start" onClick={()=>setDark(!dark)}>{dark?<Sun/>:<Moon/>}{dark?"Light":"Dark"} theme</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("stop")}><Square/> Stop queue</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>control("cancel-current")}><XCircle/> Cancel current</Button><Button variant="ghost" className="w-full justify-start" onClick={()=>post("/api/import/run-now",{force:true})}><Zap/> Force run now</Button></Card>}</div></div></header>
<main className="mx-auto grid max-w-7xl gap-4 p-4 sm:p-6">
{update?.update_available&&<Card className="border-secondary"><CardContent className="flex flex-wrap items-center gap-3 p-4"><AlertTriangle className="text-accent"/><strong>Version {update.latest_version} is available</strong><Button className="ml-auto" onClick={()=>confirm("Update Importarr now?")&&post("/api/control/update")}>Update now</Button></CardContent></Card>}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-5">{[["Queue mode",status?.control?.queue_mode],["Current import",currentName||"Idle"],["Imported",status?.imported_total],["Failed",status?.failed_total],["Queue items",status?.queue_total]].map(([label,value])=><Card key={label}><CardContent className="p-4"><strong className="block truncate text-lg capitalize">{value??"—"}</strong><span className="text-sm text-muted-foreground">{label}</span></CardContent></Card>)}</div>
<Card><CardHeader className="flex-row items-center justify-between"><div><CardTitle>Current import</CardTitle><p className="text-sm text-muted-foreground">{currentName?`Running ${duration(current.elapsed_seconds)}`:"Waiting for an active import"}</p></div><Clock className="text-accent"/></CardHeader><CardContent><p className="mb-3 break-all text-sm">{currentName||"No active copy."}</p><progress className="h-2 w-full accent-accent" max="100" value={typeof current==="object"?current.percent||0:0}/>{currentName&&<p className="mt-2 text-xs text-muted-foreground">{Number(current.percent||0).toFixed(1)}% · {bytes(current.bytes_copied)} / {bytes(current.total_bytes)}</p>}</CardContent></Card>
<Card><CardHeader className="flex-row items-center gap-3"><History className="text-accent"/><div><CardTitle>Queue and history</CardTitle><p className="text-sm text-muted-foreground">Grouped by processing state</p></div></CardHeader><CardContent className="grid gap-6">{jobs.length?groups.map(group=>{const items=jobs.filter(j=>j.group===group);return items.length?<section key={group}><h3 className="mb-3 flex items-center gap-2 font-semibold">{labels[group]} <Badge>{items.length}</Badge></h3><div className="grid gap-2">{items.map(j=><article className="grid gap-3 rounded-md border bg-input p-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center" key={j.id}><div className="min-w-0"><strong className="block break-words">{j.name}</strong><p className="break-all text-xs text-muted-foreground">{j.source_type} · attempts {j.attempt_count||0} · {j.relative_path||j.storage||j.source_id}</p><div className="mt-2 flex flex-wrap items-center gap-2"><Badge>{j.state}</Badge><span className="text-xs text-muted-foreground">{j.reason}</span></div></div><div className="flex gap-2">{j.can_run_now&&<Button size="icon" title="Run now" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"run-now"})}><Play/></Button>}{j.can_retry&&<Button size="icon" variant="outline" title="Retry" onClick={()=>post(`/api/queue-items/${j.id}/action`,{action:"retry"})}><RefreshCw/></Button>}{j.can_ignore&&<Button size="icon" variant="outline" title="Ignore" onClick={()=>confirm("Ignore this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"ignore"})}><XCircle/></Button>}{j.can_remove&&<Button size="icon" variant="outline" title="Remove" onClick={()=>confirm("Remove this queue item?")&&post(`/api/queue-items/${j.id}/action`,{action:"remove"})}><Trash2/></Button>}</div></article>)}</div></section>:null}):<p className="text-muted-foreground">No queue items.</p>}</CardContent></Card>
</main></div>
}
const root=document.getElementById("root");
if(root) createRoot(root).render(<React.StrictMode><App/></React.StrictMode>);
+29
View File
@@ -0,0 +1,29 @@
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsDialog } from "./main";
describe("Importarr UI behavior", () => {
beforeEach(() => vi.stubGlobal("fetch", vi.fn()));
afterEach(() => vi.restoreAllMocks());
it("hydrates settings URLs when status arrives asynchronously", async () => {
const { rerender } = render(<SettingsDialog status={null} refresh={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
expect(screen.getByLabelText("SABnzbd URL")).toHaveValue("");
rerender(<SettingsDialog status={{ sab_url: "http://sab", radarr_url: "http://radarr", sonarr_url: "http://sonarr" }} refresh={vi.fn()} />);
await waitFor(() => expect(screen.getByLabelText("SABnzbd URL")).toHaveValue("http://sab"));
expect(screen.getByLabelText("Radarr URL")).toHaveValue("http://radarr");
expect(screen.getByLabelText("Sonarr URL")).toHaveValue("http://sonarr");
});
it("posts a forced global run", async () => {
fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
render(<SettingsDialog status={null} refresh={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Force run now" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith("/api/import/run-now", expect.objectContaining({ method: "POST", body: JSON.stringify({ force: true }) })));
});
});