-
-
Notifications
You must be signed in to change notification settings - Fork 130
feat(torrents): warn about cross-seeded torrents in delete dialogs #670
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 8 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
9eaf9e6
feat(torrents): warn about cross-seeded torrents in delete dialogs
s0up4200 a20190c
fix(torrents): avoid conditional hook calls in TorrentManagementBar
s0up4200 6070996
fix: wording
s0up4200 277b8c9
fix: improve wording in cross-seed warning messages
s0up4200 9035292
feat(delete): add option to delete cross-seeded torrents together
s0up4200 61eeeff
refactor(delete): extract DeleteTorrentDialog component
s0up4200 eb29241
fix(hooks): handle query error state in useCrossSeedWarning
s0up4200 f9a223c
refactor(delete): make cross-seed detection opt-in with manual search
s0up4200 8d5d78f
fix(delete): update displayed count in DeleteTorrentDialog to include…
s0up4200 726027c
feat(delete): add cross-seed check to mobile delete dialog
s0up4200 e6e0239
feat(delete): add cross-seed check to mobile delete dialog
s0up4200 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,326 @@ | ||
| /* | ||
| * Copyright (c) 2025, s0up and the autobrr contributors. | ||
| * SPDX-License-Identifier: GPL-2.0-or-later | ||
| */ | ||
|
|
||
| import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, GitBranch, Info, Loader2, Search, XCircle } from "lucide-react" | ||
| import { useState } from "react" | ||
|
|
||
| import { Button } from "@/components/ui/button" | ||
| import { Checkbox } from "@/components/ui/checkbox" | ||
| import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" | ||
| import type { CrossSeedSearchState } from "@/hooks/useCrossSeedWarning" | ||
| import type { CrossSeedTorrent } from "@/lib/cross-seed-utils" | ||
| import { getLinuxIsoName, useIncognitoMode } from "@/lib/incognito" | ||
| import { cn } from "@/lib/utils" | ||
|
|
||
| interface CrossSeedWarningProps { | ||
| affectedTorrents: CrossSeedTorrent[] | ||
| searchState: CrossSeedSearchState | ||
| hasWarning: boolean | ||
| deleteFiles: boolean | ||
| deleteCrossSeeds: boolean | ||
| onDeleteCrossSeedsChange: (checked: boolean) => void | ||
| onSearch: () => void | ||
| totalToCheck: number | ||
| checkedCount: number | ||
| className?: string | ||
| } | ||
|
|
||
| /** Extract tracker domain from URL, e.g. "https://tracker.example.com:443/announce" -> "tracker.example.com" */ | ||
| function getTrackerDomain(trackerUrl: string | undefined): string | null { | ||
| if (!trackerUrl) return null | ||
| try { | ||
| const url = new URL(trackerUrl) | ||
| return url.hostname | ||
| } catch { | ||
| // If not a valid URL, try to extract domain-like pattern | ||
| const match = trackerUrl.match(/(?:https?:\/\/)?([^:/]+)/) | ||
| return match?.[1] || null | ||
| } | ||
| } | ||
|
|
||
| export function CrossSeedWarning({ | ||
| affectedTorrents, | ||
| searchState, | ||
| hasWarning, | ||
| deleteFiles, | ||
| deleteCrossSeeds, | ||
| onDeleteCrossSeedsChange, | ||
| onSearch, | ||
| totalToCheck, | ||
| checkedCount, | ||
| className, | ||
| }: CrossSeedWarningProps) { | ||
| const [isExpanded, setIsExpanded] = useState(false) | ||
| const [incognitoMode] = useIncognitoMode() | ||
|
|
||
| // Idle state - show search prompt with contextual urgency | ||
| if (searchState === "idle") { | ||
| // More prominent when deleting files (higher risk of breaking cross-seeds) | ||
| const isHighRisk = deleteFiles | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "group relative rounded-lg border py-3 px-4 transition-all duration-300", | ||
| isHighRisk | ||
| ? "border-amber-500/40 bg-amber-500/5 hover:border-amber-500/60 hover:bg-amber-500/10" | ||
| : "border-border/50 bg-muted/30 hover:border-border hover:bg-muted/50", | ||
| className | ||
| )} | ||
| > | ||
| <div className="flex items-center justify-between gap-4"> | ||
| <div className="flex items-center gap-3 min-w-0"> | ||
| <div className={cn( | ||
| "flex items-center justify-center rounded-full p-2 transition-colors", | ||
| isHighRisk | ||
| ? "bg-amber-500/10 text-amber-600 dark:text-amber-400" | ||
| : "bg-muted text-muted-foreground" | ||
| )}> | ||
| {isHighRisk ? ( | ||
| <AlertTriangle className="h-4 w-4" /> | ||
| ) : ( | ||
| <Search className="h-4 w-4" /> | ||
| )} | ||
| </div> | ||
| <div className="min-w-0"> | ||
| <p className={cn( | ||
| "text-sm font-medium", | ||
| isHighRisk ? "text-amber-700 dark:text-amber-300" : "text-foreground" | ||
| )}> | ||
| {isHighRisk ? "Check for cross-seeds first" : "Cross-seeds not checked"} | ||
| </p> | ||
| <p className="text-xs text-muted-foreground truncate"> | ||
| {isHighRisk | ||
| ? "Deleting files may break other torrents sharing this data" | ||
| : totalToCheck > 1 | ||
| ? `${totalToCheck} torrents to scan for shared files` | ||
| : "Other torrents may be sharing these files"} | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <Button | ||
| type="button" | ||
| variant={isHighRisk ? "default" : "outline"} | ||
| size="sm" | ||
| onClick={onSearch} | ||
| className={cn( | ||
| "shrink-0 gap-2 text-xs transition-all", | ||
| isHighRisk | ||
| ? "bg-amber-600 hover:bg-amber-700 text-white shadow-sm" | ||
| : "hover:bg-accent" | ||
| )} | ||
| > | ||
| <Search className="h-3.5 w-3.5" /> | ||
| Check now | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| // Searching state - show progress | ||
| if (searchState === "searching") { | ||
| return ( | ||
| <div className={cn("flex items-center gap-2 py-2 text-xs text-muted-foreground", className)}> | ||
| <Loader2 className="h-3.5 w-3.5 animate-spin" /> | ||
| <span> | ||
| Checking for cross-seeds | ||
| {totalToCheck > 1 && ` (${checkedCount}/${totalToCheck})`} | ||
| ... | ||
| </span> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| // Error state | ||
| if (searchState === "error") { | ||
| return ( | ||
| <div className={cn("flex items-center gap-2 py-2 text-xs text-muted-foreground", className)}> | ||
| <XCircle className="h-3.5 w-3.5 text-destructive" /> | ||
| <span>Failed to check for cross-seeds</span> | ||
| <Button | ||
| type="button" | ||
| variant="ghost" | ||
| size="sm" | ||
| onClick={onSearch} | ||
| className="h-6 px-2 text-xs" | ||
| > | ||
| Retry | ||
| </Button> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| // Complete state - no cross-seeds found | ||
| if (searchState === "complete" && !hasWarning) { | ||
| return ( | ||
| <div className={cn("flex items-center gap-2 py-2 text-xs text-muted-foreground", className)}> | ||
| <CheckCircle2 className="h-3.5 w-3.5 text-green-500" /> | ||
| <span>No cross-seeds found</span> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| // Complete state - cross-seeds found, show warning | ||
| // Group by instance | ||
| const byInstance = affectedTorrents.reduce<Record<string, CrossSeedTorrent[]>>( | ||
| (acc, torrent) => { | ||
| const key = torrent.instanceName || `Instance ${torrent.instanceId}` | ||
| if (!acc[key]) { | ||
| acc[key] = [] | ||
| } | ||
| acc[key].push(torrent) | ||
| return acc | ||
| }, | ||
| {} | ||
| ) | ||
|
|
||
| const instanceCount = Object.keys(byInstance).length | ||
| // Show destructive styling if deleting files OR if user opted to delete cross-seeds | ||
| const isDestructive = deleteFiles || deleteCrossSeeds | ||
|
|
||
| // Collect unique trackers for summary | ||
| const uniqueTrackers = new Set<string>() | ||
| affectedTorrents.forEach((t) => { | ||
| const domain = getTrackerDomain(t.tracker) | ||
| if (domain) uniqueTrackers.add(domain) | ||
| }) | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "rounded-lg border py-3 px-4 overflow-hidden", | ||
| isDestructive | ||
| ? "border-destructive/40 bg-destructive/5" | ||
| : "border-blue-500/30 bg-blue-500/5", | ||
| className | ||
| )} | ||
| > | ||
| {/* Header */} | ||
| <div className="flex items-start gap-3"> | ||
| {isDestructive ? ( | ||
| <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive" /> | ||
| ) : ( | ||
| <Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-500" /> | ||
| )} | ||
| <div className="flex-1 min-w-0"> | ||
| <p className={cn( | ||
| "text-sm font-medium", | ||
| isDestructive ? "text-destructive" : "text-blue-600 dark:text-blue-400" | ||
| )}> | ||
| {deleteCrossSeeds | ||
| ? "These cross-seeds will also be deleted" | ||
| : deleteFiles | ||
| ? "Deleting files will break these cross-seeds" | ||
| : "Cross-seeds detected — data will be preserved"} | ||
| </p> | ||
| <p className="mt-0.5 text-xs text-muted-foreground"> | ||
| {affectedTorrents.length} {affectedTorrents.length === 1 ? "torrent shares" : "torrents share"} these files | ||
| {instanceCount > 1 && ` across ${instanceCount} instances`} | ||
| {uniqueTrackers.size > 0 && ( | ||
| <span className="ml-1"> | ||
| on {uniqueTrackers.size === 1 | ||
| ? Array.from(uniqueTrackers)[0] | ||
| : `${uniqueTrackers.size} trackers`} | ||
| </span> | ||
| )} | ||
| {deleteCrossSeeds | ||
| ? " — will be removed along with selection" | ||
| : deleteFiles | ||
| ? " — they will need to redownload" | ||
| : " — unaffected by this removal"} | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Delete cross-seeds option */} | ||
| <div className="mt-3 flex items-center gap-2"> | ||
| <Checkbox | ||
| id="deleteCrossSeeds" | ||
| checked={deleteCrossSeeds} | ||
| onCheckedChange={(checked) => onDeleteCrossSeedsChange(checked === true)} | ||
| /> | ||
| <label | ||
| htmlFor="deleteCrossSeeds" | ||
| className="text-xs cursor-pointer select-none" | ||
| > | ||
| Also delete these cross-seeded torrents | ||
| </label> | ||
| </div> | ||
|
|
||
| {/* Expandable torrent list */} | ||
| <div className="mt-3"> | ||
| <button | ||
| type="button" | ||
| onClick={() => setIsExpanded(!isExpanded)} | ||
| className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors" | ||
| > | ||
| {isExpanded ? ( | ||
| <ChevronDown className="h-3 w-3" /> | ||
| ) : ( | ||
| <ChevronRight className="h-3 w-3" /> | ||
| )} | ||
| <GitBranch className="h-3 w-3" /> | ||
| <span>{isExpanded ? "Hide" : "Show"} affected torrents</span> | ||
| </button> | ||
|
|
||
| {isExpanded && ( | ||
| <div className="mt-2 space-y-2 overflow-hidden"> | ||
| {Object.entries(byInstance).map(([instanceName, torrents]) => ( | ||
| <div key={instanceName} className="min-w-0"> | ||
| {instanceCount > 1 && ( | ||
| <p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide mb-1"> | ||
| {instanceName} | ||
| </p> | ||
| )} | ||
| <div className="space-y-1 min-w-0"> | ||
| {torrents.slice(0, 8).map((torrent) => { | ||
| const trackerDomain = getTrackerDomain(torrent.tracker) | ||
| return ( | ||
| <div | ||
| key={`${torrent.hash}-${torrent.instanceId}`} | ||
| className="flex items-center gap-2 py-0.5 text-xs min-w-0" | ||
| > | ||
| <span className="truncate min-w-0 flex-1"> | ||
| {incognitoMode | ||
| ? getLinuxIsoName(torrent.hash) | ||
| : torrent.name} | ||
| </span> | ||
| {trackerDomain && ( | ||
| <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"> | ||
| {trackerDomain} | ||
| </span> | ||
| )} | ||
| </div> | ||
| ) | ||
| })} | ||
| {torrents.length > 8 && ( | ||
| <Tooltip> | ||
| <TooltipTrigger asChild> | ||
| <p className="text-[10px] text-muted-foreground pt-0.5 cursor-help hover:text-foreground transition-colors w-fit"> | ||
| + {torrents.length - 8} more | ||
| </p> | ||
| </TooltipTrigger> | ||
| <TooltipContent side="bottom" align="start" className="max-w-sm"> | ||
| <div className="space-y-0.5"> | ||
| {torrents.slice(8).map((t) => ( | ||
| <p key={t.hash} className="truncate text-xs"> | ||
| {incognitoMode ? getLinuxIsoName(t.hash) : t.name} | ||
| </p> | ||
| ))} | ||
| </div> | ||
| </TooltipContent> | ||
| </Tooltip> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.