Files
donutbrowser/src/components/proxy-import-dialog.tsx
T
2026-03-28 20:55:10 +04:00

520 lines
17 KiB
TypeScript

"use client";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { getCurrentOS } from "@/lib/browser-utils";
import type {
ParsedProxyLine,
ProxyImportResult,
ProxyParseResult,
} from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyImportDialogProps {
isOpen: boolean;
onClose: () => void;
}
type ImportStep = "dropzone" | "preview" | "ambiguous" | "result";
interface AmbiguousProxy {
line: string;
possible_formats: string[];
selectedFormat?: string;
}
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
const [step, setStep] = useState<ImportStep>("dropzone");
const [isDragOver, setIsDragOver] = useState(false);
const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]);
const [ambiguousProxies, setAmbiguousProxies] = useState<AmbiguousProxy[]>(
[],
);
const [invalidProxies, setInvalidProxies] = useState<
{ line: string; reason: string }[]
>([]);
const [importResult, setImportResult] = useState<ProxyImportResult | null>(
null,
);
const [isImporting, setIsImporting] = useState(false);
const [namePrefix, setNamePrefix] = useState("Imported");
const os = getCurrentOS();
const modKey = os === "macos" ? "⌘" : "Ctrl";
const resetState = useCallback(() => {
setStep("dropzone");
setIsDragOver(false);
setParsedProxies([]);
setAmbiguousProxies([]);
setInvalidProxies([]);
setImportResult(null);
setIsImporting(false);
setNamePrefix("Imported");
}, []);
const processContent = useCallback(
async (content: string, isJson: boolean, _filename = "") => {
try {
if (isJson) {
setIsImporting(true);
const result = await invoke<ProxyImportResult>(
"import_proxies_json",
{
content,
},
);
setImportResult(result);
setStep("result");
await emit("stored-proxies-changed");
} else {
const results = await invoke<ProxyParseResult[]>(
"parse_txt_proxies",
{
content,
},
);
const parsed: ParsedProxyLine[] = [];
const ambiguous: AmbiguousProxy[] = [];
const invalid: { line: string; reason: string }[] = [];
for (const result of results) {
if (result.status === "parsed") {
parsed.push(result);
} else if (result.status === "ambiguous") {
ambiguous.push({
line: result.line,
possible_formats: result.possible_formats,
});
} else if (result.status === "invalid") {
invalid.push({ line: result.line, reason: result.reason });
}
}
setParsedProxies(parsed);
setAmbiguousProxies(ambiguous);
setInvalidProxies(invalid);
if (ambiguous.length > 0) {
setStep("ambiguous");
} else if (parsed.length > 0) {
setStep("preview");
} else {
toast.error("No valid proxies found in the file");
}
}
} catch (error) {
console.error("Failed to process content:", error);
toast.error(
error instanceof Error ? error.message : "Failed to process file",
);
} finally {
setIsImporting(false);
}
},
[],
);
const handleFileRead = useCallback(
(file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
const isJson = file.name.endsWith(".json");
void processContent(content, isJson, file.name);
};
reader.onerror = () => {
toast.error("Failed to read file");
};
reader.readAsText(file);
},
[processContent],
);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
const validFile = files.find(
(f) => f.name.endsWith(".json") || f.name.endsWith(".txt"),
);
if (validFile) {
handleFileRead(validFile);
} else {
toast.error("Please drop a .json or .txt file");
}
},
[handleFileRead],
);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
}, []);
// Handle paste from clipboard
useEffect(() => {
if (!isOpen || step !== "dropzone") return;
const handlePaste = (e: ClipboardEvent) => {
const text = e.clipboardData?.getData("text");
if (text) {
// Try to detect if it's JSON
const trimmed = text.trim();
const isJson =
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
// Use "pasted.txt" as filename to trigger content-based detection
void processContent(text, isJson, "pasted.txt");
}
};
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("paste", handlePaste);
};
}, [isOpen, step, processContent]);
const handleImport = useCallback(async () => {
setIsImporting(true);
try {
const result = await invoke<ProxyImportResult>(
"import_proxies_from_parsed",
{
parsedProxies,
namePrefix: namePrefix.trim() || "Imported",
},
);
setImportResult(result);
setStep("result");
await emit("stored-proxies-changed");
} catch (error) {
console.error("Failed to import proxies:", error);
toast.error(
error instanceof Error ? error.message : "Failed to import proxies",
);
} finally {
setIsImporting(false);
}
}, [parsedProxies, namePrefix]);
const handleAmbiguousFormatSelect = useCallback(
(index: number, format: string) => {
setAmbiguousProxies((prev) =>
prev.map((p, i) =>
i === index ? { ...p, selectedFormat: format } : p,
),
);
},
[],
);
const handleResolveAmbiguous = useCallback(() => {
// Convert ambiguous proxies to parsed based on selected format
const resolved: ParsedProxyLine[] = ambiguousProxies
.filter((p) => p.selectedFormat)
.map((p) => {
const parts = p.line.split(":");
if (p.selectedFormat === "host:port:username:password") {
return {
proxy_type: "http",
host: parts[0],
port: Number.parseInt(parts[1], 10),
username: parts[2],
password: parts[3],
original_line: p.line,
};
}
// username:password:host:port
return {
proxy_type: "http",
host: parts[2],
port: Number.parseInt(parts[3], 10),
username: parts[0],
password: parts[1],
original_line: p.line,
};
});
setParsedProxies((prev) => [...prev, ...resolved]);
setStep("preview");
}, [ambiguousProxies]);
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Import Proxies</DialogTitle>
<DialogDescription>
{step === "dropzone" && "Import proxies from a JSON or TXT file"}
{step === "preview" && "Review the proxies to import"}
{step === "ambiguous" &&
"Some proxies have ambiguous formats. Please select the correct format."}
{step === "result" && "Import completed"}
</DialogDescription>
</DialogHeader>
{step === "dropzone" && (
<div className="space-y-4">
<div
role="button"
tabIndex={0}
className={`
flex flex-col items-center justify-center
border-2 border-dashed rounded-lg p-8
transition-colors cursor-pointer
${isDragOver ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-muted-foreground/50"}
`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() =>
document.getElementById("proxy-file-input")?.click()
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById("proxy-file-input")?.click();
}
}}
>
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
Drop a proxy config file
<br />
<span className="text-xs">(.json, .txt)</span>
</p>
<input
id="proxy-file-input"
type="file"
accept=".json,.txt"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileRead(file);
e.target.value = "";
}}
/>
</div>
<p className="text-xs text-muted-foreground text-center">
Paste from clipboard with {modKey}+V
</p>
</div>
)}
{step === "preview" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name-prefix">Name Prefix</Label>
<Input
id="name-prefix"
placeholder="Imported"
value={namePrefix}
onChange={(e) => {
setNamePrefix(e.target.value);
}}
/>
<p className="text-xs text-muted-foreground">
Proxies will be named &quot;{namePrefix || "Imported"} Proxy
1&quot;, &quot;{namePrefix || "Imported"} Proxy 2&quot;, etc.
</p>
</div>
<div className="space-y-2">
<Label>
Proxies to import ({parsedProxies.length})
{invalidProxies.length > 0 && (
<span className="text-muted-foreground ml-2">
({invalidProxies.length} invalid)
</span>
)}
</Label>
<ScrollArea className="h-[200px] border rounded-md">
<div className="p-2 space-y-1">
{parsedProxies.map((proxy, i) => (
<div
key={`${proxy.original_line}-${i}`}
className="text-xs font-mono p-2 bg-muted/30 rounded"
>
<span className="text-primary">
{proxy.proxy_type}://
</span>
{proxy.username && (
<span className="text-muted-foreground">
{proxy.username}:***@
</span>
)}
<span>
{proxy.host}:{proxy.port}
</span>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
)}
{step === "ambiguous" && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
The following proxies have an ambiguous format. Please select the
correct interpretation for each.
</p>
<ScrollArea className="h-[250px] border rounded-md">
<div className="p-3 space-y-4">
{ambiguousProxies.map((proxy, i) => (
<div
key={`${proxy.line}-${i}`}
className="space-y-2 pb-3 border-b last:border-0"
>
<code className="text-xs bg-muted px-2 py-1 rounded block">
{proxy.line}
</code>
<div className="flex flex-col gap-2">
{proxy.possible_formats.map((format) => (
<label
key={format}
className="flex items-center gap-2 cursor-pointer"
>
<input
type="radio"
name={`format-${i}`}
checked={proxy.selectedFormat === format}
onChange={() => {
handleAmbiguousFormatSelect(i, format);
}}
className="accent-primary"
/>
<span className="text-xs">{format}</span>
</label>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{step === "result" && importResult && (
<div className="space-y-4">
<div className="p-4 bg-muted/30 rounded-lg space-y-2">
<div className="flex justify-between">
<span className="text-sm">Imported:</span>
<span className="text-sm font-medium text-success">
{importResult.imported_count}
</span>
</div>
{importResult.skipped_count > 0 && (
<div className="flex justify-between">
<span className="text-sm">Skipped (duplicates):</span>
<span className="text-sm font-medium text-warning">
{importResult.skipped_count}
</span>
</div>
)}
{importResult.errors.length > 0 && (
<div className="flex justify-between">
<span className="text-sm">Errors:</span>
<span className="text-sm font-medium text-destructive">
{importResult.errors.length}
</span>
</div>
)}
</div>
{importResult.errors.length > 0 && (
<div className="space-y-2">
<Label>Errors</Label>
<ScrollArea className="h-[100px] border rounded-md">
<div className="p-2 space-y-1">
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-destructive"
>
{error}
</div>
))}
</div>
</ScrollArea>
</div>
)}
</div>
)}
<DialogFooter>
{step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
)}
{step === "preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={parsedProxies.length === 0}
>
Import {parsedProxies.length} Proxies
</LoadingButton>
</>
)}
{step === "ambiguous" && (
<>
<RippleButton variant="outline" onClick={resetState}>
Back
</RippleButton>
<RippleButton
onClick={handleResolveAmbiguous}
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
>
Continue
</RippleButton>
</>
)}
{step === "result" && (
<RippleButton onClick={handleClose}>Done</RippleButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}