Skip to content

Commit 206c4b2

Browse files
authored
refactor(web): extract CrossSeed completion to accordion component (#762)
1 parent e3950de commit 206c4b2

2 files changed

Lines changed: 362 additions & 214 deletions

File tree

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
/*
2+
* Copyright (c) 2025, s0up and the autobrr contributors.
3+
* SPDX-License-Identifier: GPL-2.0-or-later
4+
*/
5+
6+
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
7+
import { Button } from "@/components/ui/button"
8+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
9+
import { Input } from "@/components/ui/input"
10+
import { Label } from "@/components/ui/label"
11+
import { Switch } from "@/components/ui/switch"
12+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
13+
import { useInstances } from "@/hooks/useInstances"
14+
import { api } from "@/lib/api"
15+
import { cn } from "@/lib/utils"
16+
import type { Instance, InstanceCrossSeedCompletionSettings } from "@/types"
17+
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"
18+
import { AlertCircle, Info, Loader2 } from "lucide-react"
19+
import { useMemo, useState } from "react"
20+
import { toast } from "sonner"
21+
22+
interface CompletionFormState {
23+
enabled: boolean
24+
categories: string
25+
tags: string
26+
excludeCategories: string
27+
excludeTags: string
28+
}
29+
30+
const DEFAULT_COMPLETION_FORM: CompletionFormState = {
31+
enabled: false,
32+
categories: "",
33+
tags: "",
34+
excludeCategories: "",
35+
excludeTags: "",
36+
}
37+
38+
function parseList(value: string): string[] {
39+
return value
40+
.split(/[\n,]/)
41+
.map((s) => s.trim())
42+
.filter(Boolean)
43+
}
44+
45+
function formatList(arr: string[]): string {
46+
return arr.join(", ")
47+
}
48+
49+
function settingsToForm(settings: InstanceCrossSeedCompletionSettings | undefined): CompletionFormState {
50+
if (!settings) return DEFAULT_COMPLETION_FORM
51+
return {
52+
enabled: settings.enabled,
53+
categories: formatList(settings.categories),
54+
tags: formatList(settings.tags),
55+
excludeCategories: formatList(settings.excludeCategories),
56+
excludeTags: formatList(settings.excludeTags),
57+
}
58+
}
59+
60+
function formToSettings(form: CompletionFormState): Omit<InstanceCrossSeedCompletionSettings, "instanceId"> {
61+
return {
62+
enabled: form.enabled,
63+
categories: parseList(form.categories),
64+
tags: parseList(form.tags),
65+
excludeCategories: parseList(form.excludeCategories),
66+
excludeTags: parseList(form.excludeTags),
67+
}
68+
}
69+
70+
export function CompletionOverview() {
71+
const queryClient = useQueryClient()
72+
const { instances } = useInstances()
73+
const [expandedInstances, setExpandedInstances] = useState<string[]>([])
74+
const [formMap, setFormMap] = useState<Record<number, CompletionFormState>>({})
75+
const [dirtyMap, setDirtyMap] = useState<Record<number, boolean>>({})
76+
77+
const activeInstances = useMemo(
78+
() => (instances ?? []).filter((inst) => inst.isActive),
79+
[instances]
80+
)
81+
82+
// Fetch completion settings for all active instances
83+
const settingsQueries = useQueries({
84+
queries: activeInstances.map((instance) => ({
85+
queryKey: ["cross-seed", "completion", instance.id],
86+
queryFn: () => api.getInstanceCompletionSettings(instance.id),
87+
staleTime: 30000,
88+
})),
89+
})
90+
91+
// Mutation for updating completion settings
92+
const updateMutation = useMutation({
93+
mutationFn: ({ instanceId, settings }: { instanceId: number; settings: Omit<InstanceCrossSeedCompletionSettings, "instanceId"> }) =>
94+
api.updateInstanceCompletionSettings(instanceId, settings),
95+
onSuccess: (data, variables) => {
96+
queryClient.invalidateQueries({ queryKey: ["cross-seed", "completion", variables.instanceId] })
97+
setFormMap((prev) => ({
98+
...prev,
99+
[variables.instanceId]: settingsToForm(data),
100+
}))
101+
setDirtyMap((prev) => ({
102+
...prev,
103+
[variables.instanceId]: false,
104+
}))
105+
toast.success("Settings saved", {
106+
description: activeInstances.find((i) => i.id === variables.instanceId)?.name,
107+
})
108+
},
109+
onError: (error) => {
110+
toast.error("Failed to save settings", {
111+
description: error instanceof Error ? error.message : "Unknown error",
112+
})
113+
},
114+
})
115+
116+
const handleToggleEnabled = (instance: Instance, enabled: boolean, queryIndex: number) => {
117+
const query = settingsQueries[queryIndex]
118+
// Don't allow toggle if settings haven't loaded successfully
119+
if (query?.isError || (!query?.data && !formMap[instance.id])) {
120+
toast.error("Cannot toggle - settings failed to load")
121+
return
122+
}
123+
124+
const currentForm = formMap[instance.id] ?? settingsToForm(query?.data)
125+
updateMutation.mutate({
126+
instanceId: instance.id,
127+
settings: formToSettings({ ...currentForm, enabled }),
128+
})
129+
}
130+
131+
const handleFormChange = (instanceId: number, field: keyof CompletionFormState, value: string | boolean) => {
132+
setFormMap((prev) => ({
133+
...prev,
134+
[instanceId]: {
135+
...(prev[instanceId] ?? DEFAULT_COMPLETION_FORM),
136+
[field]: value,
137+
},
138+
}))
139+
setDirtyMap((prev) => ({
140+
...prev,
141+
[instanceId]: true,
142+
}))
143+
}
144+
145+
const handleSave = (instance: Instance, queryIndex: number) => {
146+
const query = settingsQueries[queryIndex]
147+
// Don't allow save if settings haven't loaded successfully
148+
if (query?.isError || (!query?.data && !formMap[instance.id])) {
149+
toast.error("Cannot save - settings failed to load")
150+
return
151+
}
152+
153+
const form = formMap[instance.id] ?? settingsToForm(query?.data)
154+
updateMutation.mutate({
155+
instanceId: instance.id,
156+
settings: formToSettings(form),
157+
})
158+
}
159+
160+
if (!instances || instances.length === 0) {
161+
return (
162+
<Card>
163+
<CardHeader>
164+
<CardTitle className="text-lg font-semibold">Auto-search on completion</CardTitle>
165+
<CardDescription>
166+
No instances configured. Add one in Settings to use this feature.
167+
</CardDescription>
168+
</CardHeader>
169+
</Card>
170+
)
171+
}
172+
173+
if (activeInstances.length === 0) {
174+
return (
175+
<Card>
176+
<CardHeader>
177+
<CardTitle className="text-lg font-semibold">Auto-search on completion</CardTitle>
178+
<CardDescription>
179+
No active instances. Enable an instance in Settings to use this feature.
180+
</CardDescription>
181+
</CardHeader>
182+
</Card>
183+
)
184+
}
185+
186+
return (
187+
<Card>
188+
<CardHeader className="space-y-2">
189+
<div className="flex items-center gap-2">
190+
<CardTitle className="text-lg font-semibold">Auto-search on completion</CardTitle>
191+
<Tooltip>
192+
<TooltipTrigger asChild>
193+
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
194+
</TooltipTrigger>
195+
<TooltipContent className="max-w-[300px]">
196+
<p>
197+
Automatically trigger a cross-seed search when torrents complete downloading.
198+
Torrents already tagged <span className="font-semibold">cross-seed</span> are skipped.
199+
</p>
200+
</TooltipContent>
201+
</Tooltip>
202+
</div>
203+
<CardDescription>
204+
Kick off a cross-seed search the moment a torrent finishes.
205+
</CardDescription>
206+
</CardHeader>
207+
208+
<CardContent className="p-0">
209+
<Accordion
210+
type="multiple"
211+
value={expandedInstances}
212+
onValueChange={setExpandedInstances}
213+
className="border-t"
214+
>
215+
{activeInstances.map((instance, index) => {
216+
const query = settingsQueries[index]
217+
const isLoading = query?.isLoading ?? false
218+
const isError = query?.isError ?? false
219+
const form = formMap[instance.id] ?? settingsToForm(query?.data)
220+
const isEnabled = form.enabled
221+
const isDirty = dirtyMap[instance.id] ?? false
222+
const isSaving = updateMutation.isPending && updateMutation.variables?.instanceId === instance.id
223+
224+
return (
225+
<AccordionItem key={instance.id} value={String(instance.id)}>
226+
<AccordionTrigger className="px-6 py-4 hover:no-underline group">
227+
<div className="flex items-center justify-between w-full pr-4">
228+
<div className="flex items-center gap-3 min-w-0">
229+
<span className="font-medium truncate">{instance.name}</span>
230+
{isLoading && (
231+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
232+
)}
233+
{isError && (
234+
<AlertCircle className="h-4 w-4 text-destructive" />
235+
)}
236+
</div>
237+
238+
<div className="flex items-center gap-4">
239+
<div
240+
className="flex items-center gap-2"
241+
onClick={(e) => e.stopPropagation()}
242+
>
243+
<span className={cn(
244+
"text-xs font-medium",
245+
isEnabled ? "text-emerald-500" : "text-muted-foreground"
246+
)}>
247+
{isEnabled ? "On" : "Off"}
248+
</span>
249+
<Switch
250+
checked={isEnabled}
251+
onCheckedChange={(enabled) => handleToggleEnabled(instance, enabled, index)}
252+
disabled={isLoading || isSaving || isError}
253+
className="scale-90"
254+
/>
255+
</div>
256+
</div>
257+
</div>
258+
</AccordionTrigger>
259+
260+
<AccordionContent className="px-6 pb-4">
261+
<div className="space-y-4">
262+
{/* Error state */}
263+
{isError && (
264+
<div className="flex items-center gap-2 p-3 rounded-lg border border-destructive/30 bg-destructive/10">
265+
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
266+
<p className="text-sm text-destructive">
267+
Failed to load settings. Please try refreshing the page.
268+
</p>
269+
</div>
270+
)}
271+
272+
{/* Settings form */}
273+
{!isError && isEnabled && (
274+
<>
275+
<div className="grid gap-4 md:grid-cols-2">
276+
<div className="rounded-md border border-border/50 bg-muted/30 p-3 space-y-3">
277+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Include filters</p>
278+
<div className="space-y-2">
279+
<Label htmlFor={`categories-${instance.id}`} className="text-xs">Categories</Label>
280+
<Input
281+
id={`categories-${instance.id}`}
282+
placeholder="All categories"
283+
value={form.categories}
284+
onChange={(e) => handleFormChange(instance.id, "categories", e.target.value)}
285+
disabled={isSaving}
286+
/>
287+
<p className="text-xs text-muted-foreground">Comma-separated. Leave blank for all.</p>
288+
</div>
289+
<div className="space-y-2">
290+
<Label htmlFor={`tags-${instance.id}`} className="text-xs">Tags</Label>
291+
<Input
292+
id={`tags-${instance.id}`}
293+
placeholder="All tags"
294+
value={form.tags}
295+
onChange={(e) => handleFormChange(instance.id, "tags", e.target.value)}
296+
disabled={isSaving}
297+
/>
298+
<p className="text-xs text-muted-foreground">Comma-separated. Leave blank for all.</p>
299+
</div>
300+
</div>
301+
302+
<div className="rounded-md border border-border/50 bg-muted/30 p-3 space-y-3">
303+
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Exclude filters</p>
304+
<div className="space-y-2">
305+
<Label htmlFor={`exclude-categories-${instance.id}`} className="text-xs">Categories</Label>
306+
<Input
307+
id={`exclude-categories-${instance.id}`}
308+
placeholder="None"
309+
value={form.excludeCategories}
310+
onChange={(e) => handleFormChange(instance.id, "excludeCategories", e.target.value)}
311+
disabled={isSaving}
312+
/>
313+
<p className="text-xs text-muted-foreground">Skip torrents in these categories.</p>
314+
</div>
315+
<div className="space-y-2">
316+
<Label htmlFor={`exclude-tags-${instance.id}`} className="text-xs">Tags</Label>
317+
<Input
318+
id={`exclude-tags-${instance.id}`}
319+
placeholder="None"
320+
value={form.excludeTags}
321+
onChange={(e) => handleFormChange(instance.id, "excludeTags", e.target.value)}
322+
disabled={isSaving}
323+
/>
324+
<p className="text-xs text-muted-foreground">Skip torrents with these tags.</p>
325+
</div>
326+
</div>
327+
</div>
328+
329+
<div className="flex justify-end">
330+
<Button
331+
onClick={() => handleSave(instance, index)}
332+
disabled={isSaving || !isDirty}
333+
size="sm"
334+
>
335+
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
336+
{isDirty ? "Save changes" : "Saved"}
337+
</Button>
338+
</div>
339+
</>
340+
)}
341+
342+
{/* Disabled state */}
343+
{!isError && !isEnabled && (
344+
<div className="flex flex-col items-center justify-center py-6 text-center space-y-2 border border-dashed rounded-lg">
345+
<p className="text-sm text-muted-foreground">
346+
Enable auto-search to configure filters for this instance.
347+
</p>
348+
</div>
349+
)}
350+
</div>
351+
</AccordionContent>
352+
</AccordionItem>
353+
)
354+
})}
355+
</Accordion>
356+
</CardContent>
357+
</Card>
358+
)
359+
}

0 commit comments

Comments
 (0)