mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 17:57:50 +02:00
feat: daemon support, general improvement, and preparation for Windows release
This commit is contained in:
@@ -58,13 +58,8 @@ export function CommercialTrialModal({
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
If you are using Donut Browser for business purposes, you need to
|
||||
purchase a commercial license to continue.
|
||||
</p>
|
||||
<p className="text-sm font-medium">
|
||||
Personal use remains free and unrestricted.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Visit our website to learn more about commercial licensing options.
|
||||
purchase a commercial license to continue. You can still use it for
|
||||
personal use for free.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
@@ -37,6 +38,7 @@ const HomeHeader = ({
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const handleLogoClick = () => {
|
||||
// Trigger the same URL handling logic as if the URL came from the system
|
||||
const event = new CustomEvent("url-open-request", {
|
||||
@@ -61,7 +63,7 @@ const HomeHeader = ({
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search profiles..."
|
||||
placeholder={t("header.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
className="pr-8 pl-10 w-48"
|
||||
@@ -72,7 +74,7 @@ const HomeHeader = ({
|
||||
type="button"
|
||||
onClick={() => onSearchQueryChange("")}
|
||||
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
aria-label="Clear search"
|
||||
aria-label={t("header.clearSearch")}
|
||||
>
|
||||
<LuX className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
@@ -93,7 +95,7 @@ const HomeHeader = ({
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>More actions</TooltipContent>
|
||||
<TooltipContent>{t("header.moreActions")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -104,7 +106,7 @@ const HomeHeader = ({
|
||||
}}
|
||||
>
|
||||
<GoGear className="mr-2 w-4 h-4" />
|
||||
Settings
|
||||
{t("header.menu.settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -112,7 +114,7 @@ const HomeHeader = ({
|
||||
}}
|
||||
>
|
||||
<FiWifi className="mr-2 w-4 h-4" />
|
||||
Proxies
|
||||
{t("header.menu.proxies")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -120,7 +122,7 @@ const HomeHeader = ({
|
||||
}}
|
||||
>
|
||||
<LuUsers className="mr-2 w-4 h-4" />
|
||||
Groups
|
||||
{t("header.menu.groups")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -128,7 +130,7 @@ const HomeHeader = ({
|
||||
}}
|
||||
>
|
||||
<LuCloud className="mr-2 w-4 h-4" />
|
||||
Sync Service
|
||||
{t("header.menu.syncService")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -136,7 +138,7 @@ const HomeHeader = ({
|
||||
}}
|
||||
>
|
||||
<LuPlug className="mr-2 w-4 h-4" />
|
||||
Integrations
|
||||
{t("header.menu.integrations")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -144,7 +146,7 @@ const HomeHeader = ({
|
||||
}}
|
||||
>
|
||||
<FaDownload className="mr-2 w-4 h-4" />
|
||||
Import Profile
|
||||
{t("header.menu.importProfile")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -166,7 +168,7 @@ const HomeHeader = ({
|
||||
arrowOffset={-8}
|
||||
style={{ transform: "translateX(-8px)" }}
|
||||
>
|
||||
Create a new profile
|
||||
{t("header.createProfile")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n, { getLanguageWithFallback, SUPPORTED_LANGUAGES } from "@/i18n";
|
||||
|
||||
interface AppSettings {
|
||||
language?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface I18nProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: I18nProviderProps) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeLanguage = async () => {
|
||||
try {
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
let language = settings.language;
|
||||
|
||||
if (!language) {
|
||||
const systemLanguage = await invoke<string>("get_system_language");
|
||||
language = getLanguageWithFallback(systemLanguage);
|
||||
}
|
||||
|
||||
if (
|
||||
language &&
|
||||
SUPPORTED_LANGUAGES.some((lang) => lang.code === language)
|
||||
) {
|
||||
await i18n.changeLanguage(language);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize language:", error);
|
||||
} finally {
|
||||
setIsReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
void initializeLanguage();
|
||||
}, []);
|
||||
|
||||
if (!isReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LuCheck, LuCopy, LuDownload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxyExportDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
const [format, setFormat] = useState<"json" | "txt">("json");
|
||||
const [exportContent, setExportContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const loadExportContent = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const content = await invoke<string>("export_proxies", { format });
|
||||
setExportContent(content);
|
||||
} catch (error) {
|
||||
console.error("Failed to export proxies:", error);
|
||||
toast.error("Failed to export proxies");
|
||||
setExportContent("");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [format]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadExportContent();
|
||||
}
|
||||
}, [isOpen, loadExportContent]);
|
||||
|
||||
const handleCopyToClipboard = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(exportContent);
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
toast.error("Failed to copy to clipboard");
|
||||
}
|
||||
}, [exportContent]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const filename = format === "json" ? "proxies.json" : "proxies.txt";
|
||||
const mimeType = format === "json" ? "application/json" : "text/plain";
|
||||
|
||||
const blob = new Blob([exportContent], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Downloaded ${filename}`);
|
||||
}, [format, exportContent]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setFormat("json");
|
||||
setExportContent("");
|
||||
setCopied(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Proxies</DialogTitle>
|
||||
<DialogDescription>
|
||||
Export your proxy configurations to a file
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Export Format</Label>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => setFormat(value as "json" | "txt")}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="json" id="format-json" />
|
||||
<Label htmlFor="format-json" className="cursor-pointer">
|
||||
JSON
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="txt" id="format-txt" />
|
||||
<Label htmlFor="format-txt" className="cursor-pointer">
|
||||
TXT (URL format)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<ScrollArea className="h-[200px] border rounded-md bg-muted/30">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : exportContent ? (
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{exportContent}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
No proxies to export
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => void handleCopyToClipboard()}
|
||||
disabled={!exportContent || isLoading}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
{copied ? (
|
||||
<LuCheck className="w-4 h-4" />
|
||||
) : (
|
||||
<LuCopy className="w-4 h-4" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={handleDownload}
|
||||
disabled={!exportContent || isLoading}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Download
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LuShield, 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,
|
||||
VpnImportResult,
|
||||
VpnType,
|
||||
} from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxyImportDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ImportStep =
|
||||
| "dropzone"
|
||||
| "preview"
|
||||
| "ambiguous"
|
||||
| "result"
|
||||
| "vpn-preview"
|
||||
| "vpn-result";
|
||||
|
||||
interface AmbiguousProxy {
|
||||
line: string;
|
||||
possible_formats: string[];
|
||||
selectedFormat?: string;
|
||||
}
|
||||
|
||||
interface VpnPreviewData {
|
||||
content: string;
|
||||
filename: string;
|
||||
detectedType: VpnType | null;
|
||||
endpoint: string | null;
|
||||
}
|
||||
|
||||
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");
|
||||
// VPN import state
|
||||
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
|
||||
const [vpnName, setVpnName] = useState("");
|
||||
const [vpnImportResult, setVpnImportResult] =
|
||||
useState<VpnImportResult | null>(null);
|
||||
|
||||
const os = getCurrentOS();
|
||||
const modKey = os === "macos" ? "⌘" : "Ctrl";
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setStep("dropzone");
|
||||
setIsDragOver(false);
|
||||
setParsedProxies([]);
|
||||
setAmbiguousProxies([]);
|
||||
setInvalidProxies([]);
|
||||
setImportResult(null);
|
||||
setIsImporting(false);
|
||||
setNamePrefix("Imported");
|
||||
// Reset VPN state
|
||||
setVpnPreview(null);
|
||||
setVpnName("");
|
||||
setVpnImportResult(null);
|
||||
}, []);
|
||||
|
||||
// Detect VPN type from content
|
||||
const detectVpnType = useCallback(
|
||||
(
|
||||
content: string,
|
||||
filename: string,
|
||||
): { isVpn: boolean; type: VpnType | null; endpoint: string | null } => {
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
|
||||
// Check for WireGuard config
|
||||
if (
|
||||
lowerFilename.endsWith(".conf") &&
|
||||
content.includes("[Interface]") &&
|
||||
content.includes("[Peer]")
|
||||
) {
|
||||
// Extract endpoint from WireGuard config
|
||||
const endpointMatch = content.match(/Endpoint\s*=\s*([^\s\n]+)/i);
|
||||
return {
|
||||
isVpn: true,
|
||||
type: "WireGuard",
|
||||
endpoint: endpointMatch ? endpointMatch[1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for OpenVPN config
|
||||
if (
|
||||
lowerFilename.endsWith(".ovpn") ||
|
||||
(content.includes("remote ") &&
|
||||
(content.includes("client") || content.includes("dev tun")))
|
||||
) {
|
||||
// Extract remote from OpenVPN config
|
||||
const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i);
|
||||
const endpoint = remoteMatch
|
||||
? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}`
|
||||
: null;
|
||||
return { isVpn: true, type: "OpenVPN", endpoint };
|
||||
}
|
||||
|
||||
return { isVpn: false, type: null, endpoint: null };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const processContent = useCallback(
|
||||
async (content: string, isJson: boolean, filename: string = "") => {
|
||||
try {
|
||||
// Check if it's a VPN config
|
||||
const vpnDetection = detectVpnType(content, filename);
|
||||
if (vpnDetection.isVpn) {
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: vpnDetection.type,
|
||||
endpoint: vpnDetection.endpoint,
|
||||
});
|
||||
// Generate default name from filename
|
||||
const baseName = filename
|
||||
.replace(/\.(conf|ovpn)$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${vpnDetection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
[detectVpnType],
|
||||
);
|
||||
|
||||
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") ||
|
||||
f.name.endsWith(".conf") || // WireGuard
|
||||
f.name.endsWith(".ovpn"), // OpenVPN
|
||||
);
|
||||
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a .json, .txt, .conf, or .ovpn 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 = async (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
|
||||
await 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 handleVpnImport = useCallback(async () => {
|
||||
if (!vpnPreview) return;
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const result = await invoke<VpnImportResult>("import_vpn_config", {
|
||||
content: vpnPreview.content,
|
||||
filename: vpnPreview.filename,
|
||||
name: vpnName.trim() || null,
|
||||
});
|
||||
|
||||
setVpnImportResult(result);
|
||||
setStep("vpn-result");
|
||||
|
||||
if (result.success) {
|
||||
await emit("vpn-configs-changed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to import VPN config:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import VPN config",
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [vpnPreview, vpnName]);
|
||||
|
||||
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>
|
||||
{step === "vpn-preview" || step === "vpn-result"
|
||||
? "Import VPN Config"
|
||||
: "Import Proxies"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" &&
|
||||
"Import proxies from a JSON or TXT file, or VPN configs (.conf/.ovpn)"}
|
||||
{step === "preview" && "Review the proxies to import"}
|
||||
{step === "ambiguous" &&
|
||||
"Some proxies have ambiguous formats. Please select the correct format."}
|
||||
{step === "result" && "Import completed"}
|
||||
{step === "vpn-preview" && "Review the VPN configuration to import"}
|
||||
{step === "vpn-result" && "VPN 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 or VPN config file
|
||||
<br />
|
||||
<span className="text-xs">(.json, .txt, .conf, .ovpn)</span>
|
||||
</p>
|
||||
<input
|
||||
id="proxy-file-input"
|
||||
type="file"
|
||||
accept=".json,.txt,.conf,.ovpn"
|
||||
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 "{namePrefix || "Imported"} Proxy
|
||||
1", "{namePrefix || "Imported"} Proxy 2", 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-green-600 dark:text-green-400">
|
||||
{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-yellow-600 dark:text-yellow-400">
|
||||
{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-red-600 dark:text-red-400">
|
||||
{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-red-600 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && vpnPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
||||
<LuShield className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{vpnPreview.detectedType} Configuration
|
||||
</div>
|
||||
{vpnPreview.endpoint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Endpoint: {vpnPreview.endpoint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpn-name">VPN Name</Label>
|
||||
<Input
|
||||
id="vpn-name"
|
||||
placeholder="My VPN"
|
||||
value={vpnName}
|
||||
onChange={(e) => setVpnName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Config Preview</Label>
|
||||
<ScrollArea className="h-[150px] border rounded-md">
|
||||
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{vpnPreview.content.slice(0, 1000)}
|
||||
{vpnPreview.content.length > 1000 && "..."}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && vpnImportResult && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-green-500/10" : "bg-red-500/10"}`}
|
||||
>
|
||||
{vpnImportResult.success ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<LuShield className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<div className="font-medium text-green-600 dark:text-green-400">
|
||||
VPN Imported Successfully
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vpnImportResult.name} ({vpnImportResult.vpn_type})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-red-600 dark:text-red-400">
|
||||
Import Failed
|
||||
</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
{vpnImportResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleVpnImport()}
|
||||
>
|
||||
Import VPN
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { ProxyExportDialog } from "@/components/proxy-export-dialog";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { ProxyImportDialog } from "@/components/proxy-import-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -19,7 +21,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -82,6 +83,8 @@ export function ProxyManagementDialog({
|
||||
onClose,
|
||||
}: ProxyManagementDialogProps) {
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
|
||||
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -221,9 +224,29 @@ export function ProxyManagementDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Create new proxy button */}
|
||||
{/* Proxy actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxies</Label>
|
||||
<div className="flex gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Export
|
||||
</RippleButton>
|
||||
</div>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
@@ -414,6 +437,14 @@ export function ProxyManagementDialog({
|
||||
confirmButtonText="Delete"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
<ProxyImportDialog
|
||||
isOpen={showImportDialog}
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
/>
|
||||
<ProxyExportDialog
|
||||
isOpen={showExportDialog}
|
||||
onClose={() => setShowExportDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import Color from "color";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
||||
import { useLanguage } from "@/hooks/use-language";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import {
|
||||
@@ -112,6 +114,7 @@ export function SettingsDialog({
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
const {
|
||||
requestPermission,
|
||||
@@ -119,6 +122,14 @@ export function SettingsDialog({
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
const { trialStatus } = useCommercialTrial();
|
||||
const {
|
||||
currentLanguage,
|
||||
changeLanguage,
|
||||
supportedLanguages,
|
||||
isLoading: isLanguageLoading,
|
||||
} = useLanguage();
|
||||
const [selectedLanguage, setSelectedLanguage] = useState<string | null>(null);
|
||||
const [originalLanguage, setOriginalLanguage] = useState<string | null>(null);
|
||||
|
||||
const getPermissionIcon = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
@@ -316,9 +327,26 @@ export function SettingsDialog({
|
||||
: settings.custom_theme,
|
||||
};
|
||||
|
||||
console.log("[settings-dialog] Saving settings:", {
|
||||
theme: settingsToSave.theme,
|
||||
hasCustomTheme: !!settingsToSave.custom_theme,
|
||||
customThemeKeys: settingsToSave.custom_theme
|
||||
? Object.keys(settingsToSave.custom_theme).length
|
||||
: 0,
|
||||
});
|
||||
|
||||
const savedSettings = await invoke<AppSettings>("save_app_settings", {
|
||||
settings: settingsToSave,
|
||||
});
|
||||
|
||||
console.log("[settings-dialog] Saved settings response:", {
|
||||
theme: savedSettings.theme,
|
||||
hasCustomTheme: !!savedSettings.custom_theme,
|
||||
customThemeKeys: savedSettings.custom_theme
|
||||
? Object.keys(savedSettings.custom_theme).length
|
||||
: 0,
|
||||
});
|
||||
|
||||
// Update settings with any generated tokens
|
||||
setSettings(savedSettings);
|
||||
settingsToSave = savedSettings;
|
||||
@@ -350,6 +378,23 @@ export function SettingsDialog({
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Save language if changed
|
||||
if (selectedLanguage !== originalLanguage) {
|
||||
await changeLanguage(
|
||||
selectedLanguage === "system"
|
||||
? null
|
||||
: (selectedLanguage as
|
||||
| "en"
|
||||
| "es"
|
||||
| "pt"
|
||||
| "fr"
|
||||
| "zh"
|
||||
| "ja"
|
||||
| "ru"),
|
||||
);
|
||||
setOriginalLanguage(selectedLanguage);
|
||||
}
|
||||
|
||||
setOriginalSettings(settingsToSave);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -357,7 +402,15 @@ export function SettingsDialog({
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [onClose, setTheme, settings, customThemeState]);
|
||||
}, [
|
||||
onClose,
|
||||
setTheme,
|
||||
settings,
|
||||
customThemeState,
|
||||
selectedLanguage,
|
||||
originalLanguage,
|
||||
changeLanguage,
|
||||
]);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(
|
||||
@@ -428,6 +481,14 @@ export function SettingsDialog({
|
||||
}
|
||||
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
|
||||
|
||||
// Initialize language selection when dialog opens or language loads
|
||||
useEffect(() => {
|
||||
if (isOpen && !isLanguageLoading) {
|
||||
setSelectedLanguage(currentLanguage);
|
||||
setOriginalLanguage(currentLanguage);
|
||||
}
|
||||
}, [isOpen, currentLanguage, isLanguageLoading]);
|
||||
|
||||
// Update permissions when the permission states change
|
||||
useEffect(() => {
|
||||
if (isMacOS) {
|
||||
@@ -458,6 +519,7 @@ export function SettingsDialog({
|
||||
const hasChanges =
|
||||
settings.theme !== originalSettings.theme ||
|
||||
settings.api_enabled !== originalSettings.api_enabled ||
|
||||
selectedLanguage !== originalLanguage ||
|
||||
(settings.theme === "custom" &&
|
||||
JSON.stringify(customThemeState.colors) !==
|
||||
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
||||
@@ -469,7 +531,7 @@ export function SettingsDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
|
||||
@@ -625,6 +687,38 @@ export function SettingsDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Language</Label>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="language-select" className="text-sm">
|
||||
Interface Language
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedLanguage || "system"}
|
||||
onValueChange={(value) => setSelectedLanguage(value)}
|
||||
disabled={isLanguageLoading}
|
||||
>
|
||||
<SelectTrigger id="language-select">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">System Default</SelectItem>
|
||||
{supportedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.nativeName} ({lang.name})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred language for the application interface.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Default Browser Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
|
||||
@@ -31,6 +31,14 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
const themeValue = settings?.theme ?? "system";
|
||||
|
||||
console.log("[theme-provider] Loaded settings:", {
|
||||
theme: themeValue,
|
||||
hasCustomTheme: !!settings?.custom_theme,
|
||||
customThemeKeys: settings?.custom_theme
|
||||
? Object.keys(settings.custom_theme).length
|
||||
: 0,
|
||||
});
|
||||
|
||||
if (
|
||||
themeValue === "light" ||
|
||||
themeValue === "dark" ||
|
||||
|
||||
Reference in New Issue
Block a user