Files
donutbrowser/src/components/proxy-import-dialog.tsx
T

567 lines
19 KiB
TypeScript

"use client";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
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 { StepTransition } from "@/components/ui/step-transition";
import { getCurrentOS } from "@/lib/browser-utils";
import { resolveAmbiguousProxyLine } from "@/lib/proxy-string";
import type {
ParsedProxyLine,
ProxyImportResult,
ProxyParseResult,
} from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyImportDialogProps {
isOpen: boolean;
onClose: () => void;
}
type ImportStep = "dropzone" | "preview" | "ambiguous" | "result";
const STEP_ORDER: Record<ImportStep, number> = {
dropzone: 0,
ambiguous: 1,
preview: 2,
result: 3,
};
interface StepState {
step: ImportStep;
direction: 1 | -1;
}
function transitionStep(current: StepState, next: ImportStep): StepState {
return {
step: next,
direction: STEP_ORDER[next] >= STEP_ORDER[current.step] ? 1 : -1,
};
}
interface AmbiguousProxy {
line: string;
possible_formats: string[];
selectedFormat?: string;
}
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
const { t } = useTranslation();
const [{ step, direction: stepDirection }, setStep] = useReducer(
transitionStep,
{ step: "dropzone", direction: 1 },
);
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(
t("proxies.importDialog.namePrefixDefault"),
);
const os = getCurrentOS();
const modKey = os === "macos" ? "⌘" : "Ctrl";
const resetState = useCallback(() => {
setStep("dropzone");
setIsDragOver(false);
setParsedProxies([]);
setAmbiguousProxies([]);
setInvalidProxies([]);
setImportResult(null);
setIsImporting(false);
setNamePrefix(t("proxies.importDialog.namePrefixDefault"));
}, [t]);
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(t("proxies.importDialog.noValidProxies"));
}
}
} catch (error) {
console.error("Failed to process content:", error);
toast.error(
error instanceof Error
? error.message
: t("proxies.importDialog.fileProcessError"),
);
} finally {
setIsImporting(false);
}
},
[t],
);
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(t("proxies.importDialog.fileReadError"));
};
reader.readAsText(file);
},
[processContent, t],
);
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(t("proxies.importDialog.wrongFileType"));
}
},
[handleFileRead, t],
);
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() || t("proxies.importDialog.namePrefixDefault"),
},
);
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
: t("proxies.importDialog.failed"),
);
} finally {
setIsImporting(false);
}
}, [parsedProxies, namePrefix, t]);
const handleAmbiguousFormatSelect = useCallback(
(index: number, format: string) => {
setAmbiguousProxies((prev) =>
prev.map((p, i) =>
i === index ? { ...p, selectedFormat: format } : p,
),
);
},
[],
);
const handleResolveAmbiguous = useCallback(() => {
const resolved = ambiguousProxies.flatMap((p) => {
const parsed = p.selectedFormat
? resolveAmbiguousProxyLine(p.line, p.selectedFormat)
: null;
return parsed ? [parsed] : [];
});
setParsedProxies((prev) => [...prev, ...resolved]);
setStep("preview");
}, [ambiguousProxies]);
const handleClose = useCallback(() => {
resetState();
onClose();
}, [resetState, onClose]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
<StepTransition
transitionKey={`description-${step}`}
direction={stepDirection}
>
<DialogDescription>
{step === "dropzone" && t("proxies.importDialog.descDropzone")}
{step === "preview" && t("proxies.importDialog.descPreview")}
{step === "ambiguous" && t("proxies.importDialog.descAmbiguous")}
{step === "result" && t("proxies.importDialog.descResult")}
</DialogDescription>
</StepTransition>
</DialogHeader>
<StepTransition
transitionKey={step}
direction={stepDirection}
className="grid gap-4"
>
{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
cursor-pointer transition-[color,background-color,border-color,transform]
duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none
${isDragOver ? "scale-[1.005] 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={`mb-4 size-10 text-muted-foreground transition-transform duration-[160ms] ease-[var(--ease-out)] motion-reduce:transform-none ${
isDragOver ? "-translate-y-0.5 scale-105" : ""
}`}
/>
<p className="text-center text-sm text-muted-foreground">
{t("proxies.importDialog.dropzonePrompt")}
<br />
<span className="text-xs">
{t("proxies.importDialog.dropzoneFormats")}
</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-center text-xs text-muted-foreground">
{t("proxies.importDialog.pasteHint", { modKey })}
</p>
</div>
)}
{step === "preview" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name-prefix">
{t("proxies.importDialog.namePrefix")}
</Label>
<Input
id="name-prefix"
placeholder={t("proxies.importDialog.namePrefixDefault")}
value={namePrefix}
onChange={(e) => {
setNamePrefix(e.target.value);
}}
/>
<p className="text-xs text-muted-foreground">
{t("proxies.importDialog.namePrefixHint", {
prefix:
namePrefix || t("proxies.importDialog.namePrefixDefault"),
})}
</p>
</div>
<div className="space-y-2">
<Label>
{t("proxies.importDialog.proxiesToImport", {
count: parsedProxies.length,
})}
{invalidProxies.length > 0 && (
<span className="ml-2 text-muted-foreground">
{t("proxies.importDialog.invalidCount", {
count: invalidProxies.length,
})}
</span>
)}
</Label>
<ScrollArea className="h-[clamp(120px,30vh,400px)] rounded-md border">
<div className="space-y-1 p-2">
{parsedProxies.map((proxy, i) => (
<div
key={`${proxy.original_line}-${i}`}
className="rounded bg-muted/30 p-2 font-mono text-xs break-all"
>
<span className="text-primary-text">
{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">
{t("proxies.importDialog.ambiguousIntro")}
</p>
<ScrollArea className="h-[clamp(150px,35vh,450px)] rounded-md border">
<div className="space-y-4 p-3">
{ambiguousProxies.map((proxy, i) => (
<div
key={`${proxy.line}-${i}`}
className="space-y-2 border-b pb-3 last:border-0"
>
<code className="block rounded bg-muted px-2 py-1 text-xs break-all">
{proxy.line}
</code>
<div className="flex flex-col gap-2">
{proxy.possible_formats.map((format) => (
<label
key={format}
className="flex cursor-pointer items-center gap-2"
>
<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="space-y-2 rounded-lg bg-muted/30 p-4">
<div className="flex justify-between">
<span className="text-sm">
{t("proxies.importDialog.imported")}
</span>
<span className="text-sm font-medium text-success-text">
{importResult.imported_count}
</span>
</div>
{importResult.skipped_count > 0 && (
<div className="flex justify-between">
<span className="text-sm">
{t("proxies.importDialog.skippedDuplicates")}
</span>
<span className="text-sm font-medium text-warning-text">
{importResult.skipped_count}
</span>
</div>
)}
{importResult.errors.length > 0 && (
<div className="flex justify-between">
<span className="text-sm">
{t("proxies.importDialog.errors")}
</span>
<span className="text-sm font-medium text-destructive-text">
{importResult.errors.length}
</span>
</div>
)}
</div>
{importResult.errors.length > 0 && (
<div className="space-y-2">
<Label>{t("proxies.importDialog.errors")}</Label>
<ScrollArea className="h-[100px] rounded-md border">
<div className="space-y-1 p-2">
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-destructive-text"
>
{error}
</div>
))}
</div>
</ScrollArea>
</div>
)}
</div>
)}
<DialogFooter>
{step === "dropzone" && (
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
)}
{step === "preview" && (
<>
<RippleButton variant="outline" onClick={resetState}>
{t("common.buttons.back")}
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={parsedProxies.length === 0}
>
{t("proxies.importDialog.importButton", {
count: parsedProxies.length,
})}
</LoadingButton>
</>
)}
{step === "ambiguous" && (
<>
<RippleButton variant="outline" onClick={resetState}>
{t("common.buttons.back")}
</RippleButton>
<RippleButton
onClick={handleResolveAmbiguous}
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
>
{t("proxies.importDialog.continueButton")}
</RippleButton>
</>
)}
{step === "result" && (
<RippleButton onClick={handleClose}>
{t("proxies.importDialog.doneButton")}
</RippleButton>
)}
</DialogFooter>
</StepTransition>
</DialogContent>
</Dialog>
);
}