simplified the proxy import menu
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { createProfile, patchProfile } from "@/services/cmds";
|
||||
import { createProfile, patchProfile, importProfile } from "@/services/cmds";
|
||||
import { useProfiles } from "@/hooks/use-profiles";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
import { version } from "@root/package.json";
|
||||
|
||||
// --- Новые импорты ---
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -35,7 +34,9 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {ClipboardPaste, Loader2, X} from "lucide-react";
|
||||
import {readText} from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { cn } from "@root/lib/utils";
|
||||
|
||||
|
||||
interface Props {
|
||||
@@ -51,10 +52,16 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>((props, ref) =>
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [openType, setOpenType] = useState<"new" | "edit">("new");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { profiles } = useProfiles();
|
||||
const fileDataRef = useRef<string | null>(null);
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
const [isUrlValid, setIsUrlValid] = useState(true);
|
||||
const [isCheckingUrl, setIsCheckingUrl] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm<IProfileItem>({
|
||||
defaultValues: {
|
||||
type: "remote",
|
||||
@@ -75,12 +82,16 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>((props, ref) =>
|
||||
create: () => {
|
||||
reset({ type: "remote", name: "", desc: "", url: "", option: { with_proxy: false, self_proxy: false, danger_accept_invalid_certs: false } });
|
||||
fileDataRef.current = null;
|
||||
setImportUrl("");
|
||||
setShowAdvanced(false);
|
||||
setOpenType("new");
|
||||
setOpen(true);
|
||||
},
|
||||
edit: (item) => {
|
||||
reset(item);
|
||||
fileDataRef.current = null;
|
||||
setImportUrl(item.url || "");
|
||||
setShowAdvanced(true);
|
||||
setOpenType("edit");
|
||||
setOpen(true);
|
||||
},
|
||||
@@ -88,71 +99,82 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>((props, ref) =>
|
||||
|
||||
const selfProxy = watch("option.self_proxy");
|
||||
const withProxy = watch("option.with_proxy");
|
||||
useEffect(() => { if (selfProxy) setValue("option.with_proxy", false); }, [selfProxy, setValue]);
|
||||
useEffect(() => { if (withProxy) setValue("option.self_proxy", false); }, [withProxy, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selfProxy) setValue("option.with_proxy", false);
|
||||
}, [selfProxy, setValue]);
|
||||
if (!importUrl) {
|
||||
setIsUrlValid(true);
|
||||
setIsCheckingUrl(false);
|
||||
return;
|
||||
}
|
||||
setIsCheckingUrl(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (withProxy) setValue("option.self_proxy", false);
|
||||
}, [withProxy, setValue]);
|
||||
|
||||
const handleOk = useLockFn(
|
||||
handleSubmit(async (form) => {
|
||||
if (form.option?.timeout_seconds) {
|
||||
form.option.timeout_seconds = +form.option.timeout_seconds;
|
||||
const handler = setTimeout(() => {
|
||||
try {
|
||||
new URL(importUrl);
|
||||
setIsUrlValid(true);
|
||||
} catch (error) {
|
||||
setIsUrlValid(false);
|
||||
} finally {
|
||||
setIsCheckingUrl(false);
|
||||
}
|
||||
}, 500);
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [importUrl]);
|
||||
|
||||
const handleImport = useLockFn(async () => {
|
||||
if (!importUrl) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
await importProfile(importUrl);
|
||||
showNotice("success", t("Profile Imported Successfully"));
|
||||
props.onChange();
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
showNotice("info", t("Import failed, retrying with Clash proxy..."));
|
||||
try {
|
||||
await importProfile(importUrl, { with_proxy: false, self_proxy: true });
|
||||
showNotice("success", t("Profile Imported with Clash proxy"));
|
||||
props.onChange();
|
||||
setOpen(false);
|
||||
} catch (retryErr: any) {
|
||||
showNotice("error", `${t("Import failed even with Clash proxy")}: ${retryErr?.message || retryErr.toString()}`);
|
||||
}
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const onCopyLink = async () => {
|
||||
const text = await readText();
|
||||
if (text) setImportUrl(text);
|
||||
};
|
||||
|
||||
const handleSaveAdvanced = useLockFn(
|
||||
handleSubmit(async (formData) => {
|
||||
const form = { ...formData, url: formData.url || importUrl };
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (!form.type) throw new Error("`Type` should not be null");
|
||||
if (form.type === "remote" && !form.url) {
|
||||
throw new Error("The URL should not be null");
|
||||
}
|
||||
|
||||
if (form.option?.update_interval) {
|
||||
form.option.update_interval = +form.option.update_interval;
|
||||
} else {
|
||||
delete form.option?.update_interval;
|
||||
}
|
||||
if (form.option?.user_agent === "") {
|
||||
delete form.option.user_agent;
|
||||
}
|
||||
if (form.type === "remote" && !form.url) throw new Error("The URL should not be null");
|
||||
if (form.option?.update_interval) form.option.update_interval = +form.option.update_interval;
|
||||
else delete form.option?.update_interval;
|
||||
if (form.option?.user_agent === "") delete form.option.user_agent;
|
||||
|
||||
const name = form.name || `${form.type} file`;
|
||||
const item = { ...form, name };
|
||||
const isRemote = form.type === "remote";
|
||||
const isUpdate = openType === "edit";
|
||||
const isActivating = isUpdate && form.uid === (profiles?.current ?? "");
|
||||
const originalOptions = { with_proxy: form.option?.with_proxy, self_proxy: form.option?.self_proxy };
|
||||
|
||||
if (!isRemote) {
|
||||
if (openType === "new") {
|
||||
await createProfile(item, fileDataRef.current);
|
||||
} else {
|
||||
if (!form.uid) throw new Error("UID not found");
|
||||
await patchProfile(form.uid, item);
|
||||
}
|
||||
if (openType === "new") {
|
||||
await createProfile(item, fileDataRef.current);
|
||||
} else {
|
||||
try {
|
||||
if (openType === "new") {
|
||||
await createProfile(item, fileDataRef.current);
|
||||
} else {
|
||||
if (!form.uid) throw new Error("UID not found");
|
||||
await patchProfile(form.uid, item);
|
||||
}
|
||||
} catch (err) {
|
||||
showNotice("info", t("Profile creation failed, retrying with Clash proxy..."));
|
||||
const retryItem = { ...item, option: { ...item.option, with_proxy: false, self_proxy: true } };
|
||||
if (openType === "new") {
|
||||
await createProfile(retryItem, fileDataRef.current);
|
||||
} else {
|
||||
if (!form.uid) throw new Error("UID not found");
|
||||
await patchProfile(form.uid, retryItem);
|
||||
await patchProfile(form.uid, { option: originalOptions });
|
||||
}
|
||||
showNotice("success", t("Profile creation succeeded with Clash proxy"));
|
||||
}
|
||||
if (!form.uid) throw new Error("UID not found");
|
||||
await patchProfile(form.uid, item);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
@@ -171,127 +193,151 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>((props, ref) =>
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="w-[95vw] sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{openType === "new" ? t("Create Profile") : t("Edit Profile")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={e => { e.preventDefault(); handleOk(); }} className="space-y-4 max-h-[70vh] overflow-y-auto px-1">
|
||||
<FormField control={control} name="type" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Type")}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl><SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger></FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="remote">Remote</SelectItem>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}/>
|
||||
{openType === "new" && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-1 flex-grow sm:flex-grow-0">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("Profile URL")}
|
||||
value={importUrl}
|
||||
onChange={(e) => setImportUrl(e.target.value)}
|
||||
disabled={isImporting}
|
||||
className={cn(
|
||||
"h-9 min-w-[200px] flex-grow sm:w-65",
|
||||
!isUrlValid && "border-destructive focus-visible:ring-destructive"
|
||||
)}
|
||||
/>
|
||||
{importUrl ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t("Clear")}
|
||||
onClick={() => setImportUrl("")}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t("Paste")}
|
||||
onClick={onCopyLink}
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={!importUrl || isCheckingUrl || !isUrlValid || isImporting}
|
||||
className="flex-shrink-0 min-w-[5.5rem]"
|
||||
>
|
||||
{(isCheckingUrl || isImporting) ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
t("Import")
|
||||
)}
|
||||
</Button>
|
||||
{!isUrlValid && importUrl && (
|
||||
<p className="text-sm text-destructive px-1">
|
||||
{t("Please enter a valid URL")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField control={control} name="name" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Name")}</FormLabel>
|
||||
<FormControl><Input placeholder={t("Profile Name")} {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}/>
|
||||
<Button variant="outline" onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||
{showAdvanced ? t("Hide Advanced Settings") : t("Show Advanced Settings")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField control={control} name="desc" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Descriptions")}</FormLabel>
|
||||
<FormControl><Input placeholder={t("Profile Description")} {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}/>
|
||||
|
||||
{isRemote && (
|
||||
<>
|
||||
<FormField control={control} name="url" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Subscription URL")}</FormLabel>
|
||||
<FormControl><Textarea placeholder="https://example.com/profile.yaml" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.user_agent" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Agent</FormLabel>
|
||||
<FormControl><Input placeholder={`clash-verge/v${version}`} {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.update_interval" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Update Interval")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="number" placeholder="1440" {...field} onChange={event => field.onChange(parseInt(event.target.value, 10) || 0)} />
|
||||
<span className="text-sm text-muted-foreground">{t("mins")}</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLocal && openType === "new" && (
|
||||
{(openType === 'edit' || showAdvanced) && (
|
||||
<Form {...form}>
|
||||
<form onSubmit={e => { e.preventDefault(); handleSaveAdvanced(); }} className="space-y-4 max-h-[60vh] overflow-y-auto px-1 pt-4">
|
||||
<FormField control={control} name="type" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("File")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="file" accept=".yml,.yaml" onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setValue("name", form.getValues("name") || file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
fileDataRef.current = event.target?.result as string;
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormLabel>{t("Type")}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={openType === "edit"}>
|
||||
<FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="remote">Remote</SelectItem>
|
||||
<SelectItem value="local">Local</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
)}/>
|
||||
|
||||
{isRemote && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<FormField control={control} name="name" render={({ field }) => (
|
||||
<FormItem><FormLabel>{t("Name")}</FormLabel><FormControl><Input placeholder={t("Profile Name")} {...field} /></FormControl></FormItem>
|
||||
)}/>
|
||||
|
||||
<FormField control={control} name="desc" render={({ field }) => (
|
||||
<FormItem><FormLabel>{t("Descriptions")}</FormLabel><FormControl><Input placeholder={t("Profile Description")} {...field} /></FormControl></FormItem>
|
||||
)}/>
|
||||
|
||||
{isRemote && (
|
||||
<FormField control={control} name="url" render={({ field }) => (
|
||||
<FormItem><FormLabel>{t("Subscription URL")}</FormLabel><FormControl><Textarea placeholder={t("Leave blank to use the URL above")} {...field} /></FormControl></FormItem>
|
||||
)}/>
|
||||
)}
|
||||
|
||||
{isLocal && openType === "new" && (
|
||||
<FormItem>
|
||||
<FormLabel>{t("File")}</FormLabel>
|
||||
<FormControl><Input type="file" accept=".yml,.yaml" onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setValue("name", form.getValues("name") || file.name);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => { fileDataRef.current = event.target?.result as string; };
|
||||
reader.readAsText(file);
|
||||
}
|
||||
}} /></FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
{isRemote && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<FormField control={control} name="option.update_interval" render={({ field }) => (
|
||||
<FormItem><FormLabel>{t("Update Interval (mins)")}</FormLabel><FormControl><Input type="number" placeholder="1440" {...field} onChange={e => field.onChange(+e.target.value)} /></FormControl></FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.user_agent" render={({ field }) => (
|
||||
<FormItem><FormLabel>User Agent</FormLabel><FormControl><Input placeholder={`clash-verge/v${version}`} {...field} /></FormControl></FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.with_proxy" render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between">
|
||||
<FormLabel>{t("Use System Proxy")}</FormLabel>
|
||||
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center justify-between"><FormLabel>{t("Use System Proxy")}</FormLabel><FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl></FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.self_proxy" render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between">
|
||||
<FormLabel>{t("Use Clash Proxy")}</FormLabel>
|
||||
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center justify-between"><FormLabel>{t("Use Clash Proxy")}</FormLabel><FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl></FormItem>
|
||||
)}/>
|
||||
<FormField control={control} name="option.danger_accept_invalid_certs" render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between">
|
||||
<FormLabel className="text-destructive">{t("Accept Invalid Certs (Danger)")}</FormLabel>
|
||||
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center justify-between"><FormLabel className="text-destructive">{t("Accept Invalid Certs (Danger)")}</FormLabel><FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl></FormItem>
|
||||
)}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="hidden" />
|
||||
</form>
|
||||
</Form>
|
||||
<button type="submit" className="hidden" />
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
|
||||
<Button type="button" onClick={handleOk} disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
{(openType === 'edit' || showAdvanced) && (
|
||||
<Button type="button" onClick={handleSaveAdvanced} disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("Save")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user