mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-05 02:38:44 +02:00
chore: i18n
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -51,6 +52,7 @@ export function CamoufoxConfigDialog({
|
||||
isRunning = false,
|
||||
crossOsUnlocked = false,
|
||||
}: CamoufoxConfigDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Use union type to support both Camoufox and Wayfern configs
|
||||
const [config, setConfig] = useState<CamoufoxConfig | WayfernConfig>(() => ({
|
||||
geoip: true,
|
||||
@@ -93,9 +95,8 @@ export function CamoufoxConfigDialog({
|
||||
JSON.parse(config.fingerprint);
|
||||
} catch (_error) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error("Invalid fingerprint configuration", {
|
||||
description:
|
||||
"The fingerprint configuration contains invalid JSON. Please check your advanced settings.",
|
||||
toast.error(t("camoufoxDialog.invalidFingerprint"), {
|
||||
description: t("camoufoxDialog.invalidFingerprintDescription"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -112,9 +113,11 @@ export function CamoufoxConfigDialog({
|
||||
} catch (error) {
|
||||
console.error("Failed to save config:", error);
|
||||
const { toast } = await import("sonner");
|
||||
toast.error("Failed to save configuration", {
|
||||
toast.error(t("camoufoxDialog.saveFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("camoufoxDialog.unknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -149,8 +152,15 @@ export function CamoufoxConfigDialog({
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{isRunning ? "View" : "Configure"} Fingerprint Settings -{" "}
|
||||
{profile.name} ({browserName})
|
||||
{isRunning
|
||||
? t("camoufoxDialog.titleView", {
|
||||
name: profile.name,
|
||||
browser: browserName,
|
||||
})
|
||||
: t("camoufoxDialog.titleConfigure", {
|
||||
name: profile.name,
|
||||
browser: browserName,
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -185,7 +195,7 @@ export function CamoufoxConfigDialog({
|
||||
|
||||
<DialogFooter className="shrink-0 pt-4 border-t">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{isRunning ? "Close" : "Cancel"}
|
||||
{isRunning ? t("common.buttons.close") : t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
{!isRunning && (
|
||||
<LoadingButton
|
||||
@@ -193,7 +203,7 @@ export function CamoufoxConfigDialog({
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function CloneProfileDialog({
|
||||
onCloneComplete?.();
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to clone profile: ${errorMessage}`);
|
||||
showErrorToast(t("errors.cloneProfileFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,6 +23,7 @@ export function CommercialTrialModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: CommercialTrialModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isAcknowledging, setIsAcknowledging] = useState(false);
|
||||
|
||||
const handleAcknowledge = useCallback(async () => {
|
||||
@@ -31,14 +33,16 @@ export function CommercialTrialModal({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to acknowledge trial expiration:", error);
|
||||
showErrorToast("Failed to save acknowledgment", {
|
||||
showErrorToast(t("commercialTrial.failed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("commercialTrial.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsAcknowledging(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [onClose, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
@@ -55,17 +59,15 @@ export function CommercialTrialModal({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Commercial Trial Expired</DialogTitle>
|
||||
<DialogTitle>{t("commercialTrial.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your 2-week commercial trial period has ended.
|
||||
{t("commercialTrial.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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. You can still use it for
|
||||
personal use for free.
|
||||
{t("commercialTrial.body")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +76,7 @@ export function CommercialTrialModal({
|
||||
onClick={handleAcknowledge}
|
||||
isLoading={isAcknowledging}
|
||||
>
|
||||
I Understand
|
||||
{t("commercialTrial.understandButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
@@ -66,6 +67,7 @@ export function CookieCopyDialog({
|
||||
runningProfiles,
|
||||
onCopyComplete,
|
||||
}: CookieCopyDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sourceProfileId, setSourceProfileId] = useState<string | null>(null);
|
||||
const [cookieData, setCookieData] = useState<CookieReadResult | null>(null);
|
||||
const [isLoadingCookies, setIsLoadingCookies] = useState(false);
|
||||
@@ -243,10 +245,11 @@ export function CookieCopyDialog({
|
||||
runningProfiles.has(p.id),
|
||||
);
|
||||
if (runningTargets.length > 0) {
|
||||
const names = runningTargets.map((p) => p.name).join(", ");
|
||||
toast.error(
|
||||
`Cannot copy cookies: ${runningTargets.map((p) => p.name).join(", ")} ${
|
||||
runningTargets.length === 1 ? "is" : "are"
|
||||
} still running`,
|
||||
runningTargets.length === 1
|
||||
? t("cookies.copy.cannotCopyRunningOne", { names })
|
||||
: t("cookies.copy.cannotCopyRunningMany", { names }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -277,10 +280,15 @@ export function CookieCopyDialog({
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
toast.error(`Some errors occurred: ${errors.join(", ")}`);
|
||||
toast.error(
|
||||
t("cookies.copy.someErrors", { errors: errors.join(", ") }),
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Successfully copied ${totalCopied + totalReplaced} cookies (${totalReplaced} replaced)`,
|
||||
t("cookies.copy.successMessage", {
|
||||
copied: totalCopied + totalReplaced,
|
||||
replaced: totalReplaced,
|
||||
}),
|
||||
);
|
||||
onCopyComplete?.();
|
||||
onClose();
|
||||
@@ -288,7 +296,9 @@ export function CookieCopyDialog({
|
||||
} catch (err) {
|
||||
console.error("Failed to copy cookies:", err);
|
||||
toast.error(
|
||||
`Failed to copy cookies: ${err instanceof Error ? err.message : String(err)}`,
|
||||
t("cookies.copy.failedMessage", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
@@ -300,6 +310,7 @@ export function CookieCopyDialog({
|
||||
buildSelectedCookies,
|
||||
onCopyComplete,
|
||||
onClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -325,23 +336,30 @@ export function CookieCopyDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuCookie className="w-5 h-5" />
|
||||
Copy Cookies
|
||||
{t("cookies.copy.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy cookies from a source profile to {selectedProfiles.length}{" "}
|
||||
selected profile{selectedProfiles.length !== 1 ? "s" : ""}.
|
||||
{selectedProfiles.length === 1
|
||||
? t("cookies.copy.dialogDescription_one", {
|
||||
count: selectedProfiles.length,
|
||||
})
|
||||
: t("cookies.copy.dialogDescription_other", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Source Profile</Label>
|
||||
<Label>{t("cookies.copy.sourceProfile")}</Label>
|
||||
<Select
|
||||
value={sourceProfileId ?? undefined}
|
||||
onValueChange={handleSourceChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a profile to copy cookies from" />
|
||||
<SelectValue
|
||||
placeholder={t("cookies.copy.sourcePlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eligibleSourceProfiles.map((profile) => {
|
||||
@@ -358,7 +376,7 @@ export function CookieCopyDialog({
|
||||
<span>{profile.name}</span>
|
||||
{isRunning && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(running)
|
||||
{t("cookies.copy.running")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -370,13 +388,17 @@ export function CookieCopyDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Target Profiles ({targetProfiles.length})</Label>
|
||||
<Label>
|
||||
{t("cookies.copy.targetProfiles", {
|
||||
count: targetProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<div className="p-2 bg-muted rounded-md max-h-20 overflow-y-auto">
|
||||
{targetProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{sourceProfileId
|
||||
? "No other Wayfern/Camoufox profiles selected"
|
||||
: "Select a source profile first"}
|
||||
? t("cookies.copy.noOtherTargets")
|
||||
: t("cookies.copy.selectSourceFirst")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -388,7 +410,7 @@ export function CookieCopyDialog({
|
||||
{p.name}
|
||||
{runningProfiles.has(p.id) && (
|
||||
<span className="text-xs text-destructive">
|
||||
(running)
|
||||
{t("cookies.copy.running")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -402,11 +424,13 @@ export function CookieCopyDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Select Cookies{" "}
|
||||
{t("cookies.copy.selectCookies")}{" "}
|
||||
{cookieData && (
|
||||
<span className="text-muted-foreground">
|
||||
({selectedCookieCount} of {cookieData.total_count}{" "}
|
||||
selected)
|
||||
{t("cookies.copy.selectionStatus", {
|
||||
selected: selectedCookieCount,
|
||||
total: cookieData.total_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -415,7 +439,7 @@ export function CookieCopyDialog({
|
||||
<div className="relative">
|
||||
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search domains or cookies..."
|
||||
placeholder={t("cookies.copy.searchPlaceholder")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
@@ -435,8 +459,8 @@ export function CookieCopyDialog({
|
||||
) : filteredDomains.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
{searchQuery
|
||||
? "No matching cookies found"
|
||||
: "No cookies found"}
|
||||
? t("cookies.copy.noMatching")
|
||||
: t("cookies.copy.noFound")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
@@ -457,8 +481,7 @@ export function CookieCopyDialog({
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Existing cookies with the same name and domain will be replaced.
|
||||
Other cookies will be kept.
|
||||
{t("cookies.copy.replaceNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -470,15 +493,22 @@ export function CookieCopyDialog({
|
||||
onClick={onClose}
|
||||
disabled={isCopying}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isCopying}
|
||||
onClick={() => void handleCopy()}
|
||||
disabled={!canCopy}
|
||||
>
|
||||
Copy {selectedCookieCount > 0 ? `${selectedCookieCount} ` : ""}
|
||||
Cookie{selectedCookieCount !== 1 ? "s" : ""}
|
||||
{selectedCookieCount === 0
|
||||
? t("cookies.copy.copyButtonEmpty")
|
||||
: selectedCookieCount === 1
|
||||
? t("cookies.copy.copyButton_one", {
|
||||
count: selectedCookieCount,
|
||||
})
|
||||
: t("cookies.copy.copyButton_other", {
|
||||
count: selectedCookieCount,
|
||||
})}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -122,6 +123,7 @@ export function CookieManagementDialog({
|
||||
profile,
|
||||
initialTab = "import",
|
||||
}: CookieManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Import state
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
@@ -171,13 +173,15 @@ export function CookieManagementDialog({
|
||||
setExportSelection(initSelectionFromCookieData(result));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Failed to load cookies: ${err instanceof Error ? err.message : String(err)}`,
|
||||
t("cookies.management.loadFailed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingExportCookies(false);
|
||||
}
|
||||
},
|
||||
[exportCookieData],
|
||||
[exportCookieData, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -220,19 +224,22 @@ export function CookieManagementDialog({
|
||||
[resetImportState, resetExportState],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, []);
|
||||
const handleFileRead = useCallback(
|
||||
(file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error(t("cookies.management.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!fileContent || !profile) return;
|
||||
@@ -297,14 +304,14 @@ export function CookieManagementDialog({
|
||||
}
|
||||
|
||||
await writeTextFile(filePath, content);
|
||||
toast.success("Cookies exported successfully");
|
||||
toast.success(t("cookies.export.success"));
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [profile, format, getSelectedCookies, handleClose]);
|
||||
}, [profile, format, getSelectedCookies, handleClose, t]);
|
||||
|
||||
const toggleDomain = useCallback(
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
@@ -385,7 +392,7 @@ export function CookieManagementDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cookie Management</DialogTitle>
|
||||
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
@@ -394,15 +401,19 @@ export function CookieManagementDialog({
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="import">Import</TabsTrigger>
|
||||
<TabsTrigger value="export">Export</TabsTrigger>
|
||||
<TabsTrigger value="import">
|
||||
{t("cookies.management.tabImport")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="export">
|
||||
{t("cookies.management.tabExport")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="import" className="space-y-4 mt-4">
|
||||
{!fileContent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Import cookies from a Netscape or JSON format file.
|
||||
{t("cookies.management.importDescription")}
|
||||
</p>
|
||||
<div
|
||||
role="button"
|
||||
@@ -420,9 +431,11 @@ export function CookieManagementDialog({
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a cookie file
|
||||
{t("cookies.management.dropPrompt")}
|
||||
<br />
|
||||
<span className="text-xs">(.txt, .cookies, or .json)</span>
|
||||
<span className="text-xs">
|
||||
{t("cookies.management.fileFormats")}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="cookie-file-input"
|
||||
@@ -445,20 +458,22 @@ export function CookieManagementDialog({
|
||||
<div>
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{cookieCount} cookies found
|
||||
{t("cookies.management.cookiesFound", {
|
||||
count: cookieCount,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={resetImportState}>
|
||||
Back
|
||||
{t("cookies.management.backButton")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={cookieCount === 0}
|
||||
>
|
||||
Import
|
||||
{t("cookies.management.importButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -468,17 +483,23 @@ export function CookieManagementDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-success/10">
|
||||
<div className="font-medium text-success">
|
||||
Successfully imported {importResult.cookies_imported}{" "}
|
||||
cookies ({importResult.cookies_replaced} replaced)
|
||||
{t("cookies.management.importedSuccess", {
|
||||
imported: importResult.cookies_imported,
|
||||
replaced: importResult.cookies_replaced,
|
||||
})}
|
||||
</div>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{importResult.errors.length} line(s) skipped
|
||||
{t("cookies.management.linesSkipped", {
|
||||
count: importResult.errors.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("cookies.management.doneButton")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -486,7 +507,7 @@ export function CookieManagementDialog({
|
||||
|
||||
<TabsContent value="export" className="space-y-3 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Format</Label>
|
||||
<Label>{t("cookies.export.formatLabel")}</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => {
|
||||
@@ -497,8 +518,12 @@ export function CookieManagementDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="netscape">Netscape TXT</SelectItem>
|
||||
<SelectItem value="json">
|
||||
{t("cookies.export.json")}
|
||||
</SelectItem>
|
||||
<SelectItem value="netscape">
|
||||
{t("cookies.export.netscape")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -506,11 +531,13 @@ export function CookieManagementDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Cookies{" "}
|
||||
{t("cookies.management.cookiesLabel")}{" "}
|
||||
{exportCookieData && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({selectedExportCount} of {exportCookieData.total_count}{" "}
|
||||
selected)
|
||||
{t("cookies.management.selectionStatus", {
|
||||
selected: selectedExportCount,
|
||||
total: exportCookieData.total_count,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -521,8 +548,8 @@ export function CookieManagementDialog({
|
||||
onClick={toggleSelectAll}
|
||||
>
|
||||
{selectedExportCount === exportCookieData.total_count
|
||||
? "Deselect all"
|
||||
: "Select all"}
|
||||
? t("cookies.management.deselectAll")
|
||||
: t("cookies.management.selectAll")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -533,7 +560,7 @@ export function CookieManagementDialog({
|
||||
</div>
|
||||
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
|
||||
No cookies found in this profile
|
||||
{t("cookies.management.noCookies")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[200px] border rounded-md">
|
||||
@@ -556,14 +583,14 @@ export function CookieManagementDialog({
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isExporting}
|
||||
onClick={() => void handleExport()}
|
||||
disabled={selectedExportCount === 0}
|
||||
>
|
||||
Export
|
||||
{t("cookies.management.exportButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ export function CreateGroupDialog({
|
||||
onClose,
|
||||
onGroupCreated,
|
||||
}: CreateGroupDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -42,20 +44,20 @@ export function CreateGroupDialog({
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group created successfully");
|
||||
toast.success(t("groups.createSuccess"));
|
||||
onGroupCreated(newGroup);
|
||||
setGroupName("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to create group";
|
||||
err instanceof Error ? err.message : t("groups.createFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [groupName, onGroupCreated, onClose]);
|
||||
}, [groupName, onGroupCreated, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setGroupName("");
|
||||
@@ -67,18 +69,16 @@ export function CreateGroupDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new group to organize your browser profiles.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("groups.createTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("groups.createDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Label htmlFor="group-name">{t("groups.form.name")}</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
placeholder={t("groups.form.namePlaceholder")}
|
||||
value={groupName}
|
||||
onChange={(e) => {
|
||||
setGroupName(e.target.value);
|
||||
@@ -105,14 +105,14 @@ export function CreateGroupDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isCreating}
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!groupName.trim()}
|
||||
>
|
||||
Create
|
||||
{t("common.buttons.create")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -625,10 +625,10 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
Regular Browsers
|
||||
{t("createProfile.regular.title")}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose from supported regular browsers
|
||||
{t("createProfile.regular.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -655,7 +655,7 @@ export function CreateProfileDialog({
|
||||
{browser.label}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Regular Browser
|
||||
{t("createProfile.regular.badge")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -672,7 +672,9 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Label htmlFor="profile-name">
|
||||
{t("createProfile.profileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
@@ -688,7 +690,9 @@ export function CreateProfileDialog({
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
placeholder={t(
|
||||
"createProfile.profileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -722,7 +726,7 @@ export function CreateProfileDialog({
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching available versions...
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -739,7 +743,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -748,8 +752,9 @@ export function CreateProfileDialog({
|
||||
!getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<p className="text-sm text-warning">
|
||||
Wayfern is not available on your platform
|
||||
yet.
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Wayfern",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -760,11 +765,12 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion("wayfern") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("wayfern");
|
||||
return `Wayfern version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")
|
||||
?.version,
|
||||
})}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -779,8 +785,8 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("wayfern")
|
||||
? "Downloading..."
|
||||
: "Download"}
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -789,20 +795,22 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading("wayfern") &&
|
||||
isBrowserVersionAvailable("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("wayfern");
|
||||
return `✓ Wayfern version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("wayfern") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("wayfern");
|
||||
return `Downloading Wayfern version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Wayfern",
|
||||
version:
|
||||
getBestAvailableVersion("wayfern")?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -826,7 +834,7 @@ export function CreateProfileDialog({
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<div className="w-4 h-4 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching available versions...
|
||||
{t("createProfile.version.fetching")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -843,7 +851,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -852,8 +860,9 @@ export function CreateProfileDialog({
|
||||
!getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border border-warning/50 bg-warning/10">
|
||||
<p className="text-sm text-warning">
|
||||
Camoufox is not available on your platform
|
||||
yet.
|
||||
{t("createProfile.platformUnavailable", {
|
||||
browser: "Camoufox",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -864,11 +873,12 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Camoufox version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t("createProfile.version.needsDownload", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -883,8 +893,8 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? "Downloading..."
|
||||
: "Download"}
|
||||
? t("common.buttons.downloading")
|
||||
: t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -893,20 +903,23 @@ export function CreateProfileDialog({
|
||||
!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
isBrowserVersionAvailable("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `✓ Camoufox version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t("createProfile.version.available", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Downloading Camoufox version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t("createProfile.version.downloading", {
|
||||
browser: "Camoufox",
|
||||
version:
|
||||
getBestAvailableVersion("camoufox")
|
||||
?.version,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -971,13 +984,15 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -992,7 +1007,7 @@ export function CreateProfileDialog({
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
{t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1005,26 +1020,31 @@ export function CreateProfileDialog({
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t(
|
||||
"createProfile.version.latestAvailable",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestDownloading",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1035,7 +1055,7 @@ export function CreateProfileDialog({
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy / VPN</Label>
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1044,7 +1064,8 @@ export function CreateProfileDialog({
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
<GoPlus className="mr-1 w-3 h-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
@@ -1061,7 +1082,7 @@ export function CreateProfileDialog({
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
return t("createProfile.proxy.noProxy");
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
@@ -1069,12 +1090,15 @@ export function CreateProfileDialog({
|
||||
);
|
||||
return vpn
|
||||
? `WG — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
: t("createProfile.proxy.noProxy");
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
return (
|
||||
proxy?.name ??
|
||||
t("createProfile.proxy.noProxy")
|
||||
);
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -1084,10 +1108,14 @@ export function CreateProfileDialog({
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"createProfile.proxy.search",
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
{t("createProfile.proxy.notFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
@@ -1105,7 +1133,7 @@ export function CreateProfileDialog({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("common.labels.none")}
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
@@ -1167,8 +1195,7 @@ export function CreateProfileDialog({
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1265,7 +1292,9 @@ export function CreateProfileDialog({
|
||||
<div className="space-y-6">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Label htmlFor="profile-name">
|
||||
{t("createProfile.profileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
@@ -1281,7 +1310,9 @@ export function CreateProfileDialog({
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
placeholder={t(
|
||||
"createProfile.profileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1310,7 +1341,7 @@ export function CreateProfileDialog({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Retry
|
||||
{t("common.buttons.retry")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1323,13 +1354,15 @@ export function CreateProfileDialog({
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestNeedsDownload",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => {
|
||||
@@ -1344,7 +1377,7 @@ export function CreateProfileDialog({
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
{t("common.buttons.download")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
@@ -1355,24 +1388,30 @@ export function CreateProfileDialog({
|
||||
) &&
|
||||
isBrowserVersionAvailable(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
✓{" "}
|
||||
{t(
|
||||
"createProfile.version.latestAvailable",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
)?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
{t(
|
||||
"createProfile.version.latestDownloading",
|
||||
{
|
||||
version:
|
||||
getBestAvailableVersion(selectedBrowser)
|
||||
?.version,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1382,7 +1421,7 @@ export function CreateProfileDialog({
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy / VPN</Label>
|
||||
<Label>{t("createProfile.proxy.title")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1391,7 +1430,8 @@ export function CreateProfileDialog({
|
||||
}}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
<GoPlus className="mr-1 w-3 h-3" />{" "}
|
||||
{t("createProfile.proxy.addProxy")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
@@ -1408,7 +1448,7 @@ export function CreateProfileDialog({
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
return t("createProfile.proxy.noProxy");
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
@@ -1416,12 +1456,15 @@ export function CreateProfileDialog({
|
||||
);
|
||||
return vpn
|
||||
? `WG — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
: t("createProfile.proxy.noProxy");
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
return (
|
||||
proxy?.name ??
|
||||
t("createProfile.proxy.noProxy")
|
||||
);
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -1431,10 +1474,14 @@ export function CreateProfileDialog({
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"createProfile.proxy.search",
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
{t("createProfile.proxy.notFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
@@ -1452,7 +1499,7 @@ export function CreateProfileDialog({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("common.labels.none")}
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
@@ -1514,8 +1561,7 @@ export function CreateProfileDialog({
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
{t("createProfile.proxy.noProxiesAvailable")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1549,19 +1595,19 @@ export function CreateProfileDialog({
|
||||
{currentStep === "browser-config" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleBack}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={handleCreate}
|
||||
isLoading={isCreating}
|
||||
disabled={isCreateDisabled}
|
||||
>
|
||||
Create
|
||||
{t("common.buttons.create")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
*/
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: TODO */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuCheckCheck,
|
||||
LuDownload,
|
||||
@@ -214,6 +215,7 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
}
|
||||
|
||||
export function UnifiedToast(props: ToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const { title, description, type, action, onCancel } = props;
|
||||
const stage = "stage" in props ? props.stage : undefined;
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
@@ -231,7 +233,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
|
||||
aria-label="Cancel"
|
||||
aria-label={t("common.buttons.cancel")}
|
||||
>
|
||||
<LuX className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Table } from "@tanstack/react-table";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -134,6 +135,7 @@ interface DataTableActionBarSelectionProps<TData> {
|
||||
function DataTableActionBarSelection<TData>({
|
||||
table,
|
||||
}: DataTableActionBarSelectionProps<TData>) {
|
||||
const { t } = useTranslation();
|
||||
const onClearSelection = React.useCallback(() => {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}, [table]);
|
||||
@@ -141,7 +143,9 @@ function DataTableActionBarSelection<TData>({
|
||||
return (
|
||||
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
{t("dataTableActionBar.selected", {
|
||||
count: table.getFilteredSelectedRowModel().rows.length,
|
||||
})}
|
||||
</span>
|
||||
<div className="mr-1 ml-2 h-4 w-px bg-border" />
|
||||
<Tooltip>
|
||||
@@ -159,9 +163,9 @@ function DataTableActionBarSelection<TData>({
|
||||
sideOffset={10}
|
||||
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
|
||||
>
|
||||
<p>Clear selection</p>
|
||||
<p>{t("dataTableActionBar.clearSelection")}</p>
|
||||
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
|
||||
<abbr title="Escape" className="no-underline">
|
||||
<abbr title={t("common.keys.escape")} className="no-underline">
|
||||
Esc
|
||||
</abbr>
|
||||
</kbd>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -29,11 +30,12 @@ export function DeleteConfirmationDialog({
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmButtonText = "Delete",
|
||||
confirmButtonText,
|
||||
isLoading = false,
|
||||
profileIds,
|
||||
profiles = [],
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
};
|
||||
@@ -47,7 +49,7 @@ export function DeleteConfirmationDialog({
|
||||
{profileIds && profileIds.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Profiles to be deleted:
|
||||
{t("deleteDialog.profilesToDelete")}
|
||||
</p>
|
||||
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
@@ -71,14 +73,14 @@ export function DeleteConfirmationDialog({
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
onClick={() => void handleConfirm()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{confirmButtonText}
|
||||
{confirmButtonText ?? t("common.buttons.delete")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -56,11 +56,13 @@ export function DeleteGroupDialog({
|
||||
setAssociatedProfiles(groupProfiles);
|
||||
} catch (err) {
|
||||
console.error("Failed to load associated profiles:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load profiles");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("groups.loadProfilesFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [group]);
|
||||
}, [group, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && group) {
|
||||
@@ -90,19 +92,19 @@ export function DeleteGroupDialog({
|
||||
// Delete the group
|
||||
await invoke("delete_profile_group", { groupId: group.id });
|
||||
|
||||
toast.success("Group deleted successfully");
|
||||
toast.success(t("groups.deleteSuccess"));
|
||||
onGroupDeleted();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to delete group";
|
||||
err instanceof Error ? err.message : t("groups.deleteFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose]);
|
||||
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -115,17 +117,14 @@ export function DeleteGroupDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the group
|
||||
"{group?.name}".
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("groups.deleteTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("groups.deleteDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading associated profiles...
|
||||
{t("groups.loadingProfiles")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -133,7 +132,9 @@ export function DeleteGroupDialog({
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Associated Profiles ({associatedProfiles.length})
|
||||
{t("groups.associatedProfiles", {
|
||||
count: associatedProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
||||
<div className="space-y-1">
|
||||
@@ -147,7 +148,7 @@ export function DeleteGroupDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>What should happen to these profiles?</Label>
|
||||
<Label>{t("groups.whatToDoWithProfiles")}</Label>
|
||||
<RadioGroup
|
||||
value={deleteAction}
|
||||
onValueChange={(value) => {
|
||||
@@ -166,7 +167,7 @@ export function DeleteGroupDialog({
|
||||
htmlFor="delete"
|
||||
className="text-sm text-destructive"
|
||||
>
|
||||
Delete profiles along with the group
|
||||
{t("groups.deleteAlongWithGroup")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
@@ -176,7 +177,7 @@ export function DeleteGroupDialog({
|
||||
|
||||
{associatedProfiles.length === 0 && !isLoading && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This group has no associated profiles.
|
||||
{t("groups.noAssociatedProfiles")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -195,7 +196,7 @@ export function DeleteGroupDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
@@ -203,10 +204,9 @@ export function DeleteGroupDialog({
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete Group
|
||||
{deleteAction === "delete" &&
|
||||
associatedProfiles.length > 0 &&
|
||||
" & Profiles"}
|
||||
{deleteAction === "delete" && associatedProfiles.length > 0
|
||||
? t("groups.deleteGroupAndProfiles")
|
||||
: t("groups.deleteGroup")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ export function EditGroupDialog({
|
||||
group,
|
||||
onGroupUpdated,
|
||||
}: EditGroupDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -54,19 +56,19 @@ export function EditGroupDialog({
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group updated successfully");
|
||||
toast.success(t("groups.updateSuccess"));
|
||||
onGroupUpdated(updatedGroup);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to update group";
|
||||
err instanceof Error ? err.message : t("groups.updateFailed");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [group, groupName, onGroupUpdated, onClose]);
|
||||
}, [group, groupName, onGroupUpdated, onClose, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
@@ -77,18 +79,16 @@ export function EditGroupDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the name of the group "{group?.name}".
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("groups.editTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("groups.editDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Label htmlFor="group-name">{t("groups.form.name")}</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
placeholder={t("groups.form.namePlaceholder")}
|
||||
value={groupName}
|
||||
onChange={(e) => {
|
||||
setGroupName(e.target.value);
|
||||
@@ -115,14 +115,14 @@ export function EditGroupDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isUpdating}
|
||||
onClick={() => void handleUpdate()}
|
||||
disabled={!groupName.trim() || groupName === group?.name}
|
||||
>
|
||||
Update Group
|
||||
{t("groups.edit")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -57,11 +57,13 @@ export function GroupAssignmentDialog({
|
||||
setGroups(groupList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("groupManagement.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
@@ -73,7 +75,8 @@ export function GroupAssignmentDialog({
|
||||
});
|
||||
|
||||
const groupName = selectedGroupId
|
||||
? groups.find((g) => g.id === selectedGroupId)?.name || "Unknown Group"
|
||||
? groups.find((g) => g.id === selectedGroupId)?.name ||
|
||||
t("groups.unknownGroup")
|
||||
: t("groups.defaultGroup");
|
||||
|
||||
toast.success(
|
||||
@@ -89,7 +92,7 @@ export function GroupAssignmentDialog({
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign profiles to group";
|
||||
: t("groupAssignment.failedFallback");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -116,15 +119,21 @@ export function GroupAssignmentDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Group</DialogTitle>
|
||||
<DialogTitle>{t("groupAssignment.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProfiles.length} selected profile(s) to a group.
|
||||
{selectedProfiles.length === 1
|
||||
? t("groupAssignment.description_one", {
|
||||
count: selectedProfiles.length,
|
||||
})
|
||||
: t("groupAssignment.description_other", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Selected Profiles:</Label>
|
||||
<Label>{t("groupAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
@@ -145,7 +154,9 @@ export function GroupAssignmentDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="group-select">Assign to Group:</Label>
|
||||
<Label htmlFor="group-select">
|
||||
{t("groupAssignment.assignGroupLabel")}
|
||||
</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -154,12 +165,13 @@ export function GroupAssignmentDialog({
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Create Group
|
||||
<GoPlus className="mr-1 w-3 h-3" />{" "}
|
||||
{t("groupManagement.createGroup")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
{t("groupManagement.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
@@ -169,7 +181,7 @@ export function GroupAssignmentDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a group" />
|
||||
<SelectValue placeholder={t("groupAssignment.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
@@ -198,14 +210,14 @@ export function GroupAssignmentDialog({
|
||||
onClick={onClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isAssigning}
|
||||
onClick={() => void handleAssign()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Assign
|
||||
{t("groupAssignment.assignButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -156,7 +156,7 @@ export function GroupBadges({
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
role="region"
|
||||
aria-label="Profile groups"
|
||||
aria-label={t("groups.profileGroupsAriaLabel")}
|
||||
className={`flex gap-2 overflow-x-auto pb-2 -mb-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
onScroll={checkScrollPosition}
|
||||
onMouseDown={handleMouseDown}
|
||||
|
||||
@@ -44,37 +44,46 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
group: GroupWithCount,
|
||||
liveStatus: SyncStatus | undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
errorMessage?: string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: t("syncTooltips.syncing"),
|
||||
animate: true,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: group.last_sync
|
||||
? `Synced ${new Date(group.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("syncTooltips.syncedAt", {
|
||||
time: new Date(group.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("syncTooltips.synced"),
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Waiting to sync",
|
||||
tooltip: t("syncTooltips.waiting"),
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
tooltip: errorMessage
|
||||
? t("syncTooltips.errorWith", { error: errorMessage })
|
||||
: t("syncTooltips.error"),
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Not synced",
|
||||
tooltip: t("syncTooltips.notSynced"),
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
@@ -165,11 +174,13 @@ export function GroupManagementDialog({
|
||||
setGroupInUse(inUse);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("groupManagement.loadFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleGroupCreated = useCallback(
|
||||
(_newGroup: ProfileGroup) => {
|
||||
@@ -210,18 +221,24 @@ export function GroupManagementDialog({
|
||||
groupId: group.id,
|
||||
enabled: !group.sync_enabled,
|
||||
});
|
||||
showSuccessToast(group.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
showSuccessToast(
|
||||
group.sync_enabled
|
||||
? t("proxies.management.syncDisabled")
|
||||
: t("proxies.management.syncEnabled"),
|
||||
);
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [group.id]: false }));
|
||||
}
|
||||
},
|
||||
[loadGroups],
|
||||
[loadGroups, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -244,7 +261,7 @@ export function GroupManagementDialog({
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<Label>{t("groupManagement.groupsLabel")}</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
@@ -253,7 +270,7 @@ export function GroupManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
{t("proxies.management.create")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
@@ -278,10 +295,16 @@ export function GroupManagementDialog({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="w-20">
|
||||
{t("groupManagement.profilesCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -289,6 +312,7 @@ export function GroupManagementDialog({
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
t,
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
@@ -332,14 +356,13 @@ export function GroupManagementDialog({
|
||||
<TooltipContent>
|
||||
{groupInUse[group.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
{t("groupManagement.syncCannotDisable")}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
? t("proxies.management.disableSync")
|
||||
: t("proxies.management.enableSync")}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
@@ -360,7 +383,9 @@ export function GroupManagementDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
<p>
|
||||
{t("groupManagement.editGroupTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -376,7 +401,9 @@ export function GroupManagementDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
<p>
|
||||
{t("groupManagement.deleteGroupTooltip")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -393,7 +420,7 @@ export function GroupManagementDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -49,6 +50,7 @@ export function ImportProfileDialog({
|
||||
onClose,
|
||||
crossOsUnlocked,
|
||||
}: ImportProfileDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
|
||||
[],
|
||||
);
|
||||
@@ -103,11 +105,11 @@ export function ImportProfileDialog({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to detect existing profiles:", error);
|
||||
toast.error("Failed to detect existing browser profiles");
|
||||
toast.error(t("importProfile.detectFailed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const selectedProfile = detectedProfiles.find(
|
||||
(p) => p.path === selectedDetectedProfile,
|
||||
@@ -118,7 +120,7 @@ export function ImportProfileDialog({
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Select Browser Profile Folder",
|
||||
title: t("importProfile.selectFolderTitle"),
|
||||
});
|
||||
|
||||
if (selected && typeof selected === "string") {
|
||||
@@ -126,7 +128,7 @@ export function ImportProfileDialog({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open folder dialog:", error);
|
||||
toast.error("Failed to open folder dialog");
|
||||
toast.error(t("importProfile.folderDialogFailed"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,14 +139,14 @@ export function ImportProfileDialog({
|
||||
|
||||
if (importMode === "auto-detect") {
|
||||
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
|
||||
toast.error("Please select a profile and provide a name");
|
||||
toast.error(t("importProfile.selectAndName"));
|
||||
return;
|
||||
}
|
||||
const profile = detectedProfiles.find(
|
||||
(p) => p.path === selectedDetectedProfile,
|
||||
);
|
||||
if (!profile) {
|
||||
toast.error("Selected profile not found");
|
||||
toast.error(t("importProfile.profileNotFound"));
|
||||
return;
|
||||
}
|
||||
sourcePath = profile.path;
|
||||
@@ -156,7 +158,7 @@ export function ImportProfileDialog({
|
||||
!manualProfilePath.trim() ||
|
||||
!manualProfileName.trim()
|
||||
) {
|
||||
toast.error("Please fill in all fields");
|
||||
toast.error(t("importProfile.fillFields"));
|
||||
return;
|
||||
}
|
||||
sourcePath = manualProfilePath.trim();
|
||||
@@ -180,7 +182,9 @@ export function ImportProfileDialog({
|
||||
wayfernConfig: mappedBrowser === "wayfern" ? wayfernConfig : null,
|
||||
});
|
||||
|
||||
toast.success(`Successfully imported profile "${newProfileName}"`);
|
||||
toast.success(
|
||||
t("importProfile.importedSuccess", { name: newProfileName }),
|
||||
);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to import profile:", error);
|
||||
@@ -190,13 +194,13 @@ export function ImportProfileDialog({
|
||||
if (errorMessage.includes("No downloaded versions found")) {
|
||||
const browserDisplayName = getBrowserDisplayName(browserType);
|
||||
toast.error(
|
||||
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
|
||||
t("importProfile.notInstalled", { browser: browserDisplayName }),
|
||||
{
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to import profile: ${errorMessage}`);
|
||||
toast.error(t("importProfile.importFailed", { error: errorMessage }));
|
||||
}
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
@@ -214,6 +218,7 @@ export function ImportProfileDialog({
|
||||
wayfernConfig,
|
||||
onClose,
|
||||
selectedProfile,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -290,7 +295,7 @@ export function ImportProfileDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Import Browser Profile</DialogTitle>
|
||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
|
||||
@@ -305,7 +310,7 @@ export function ImportProfileDialog({
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Auto-Detect
|
||||
{t("importProfile.autoDetect")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant={importMode === "manual" ? "default" : "outline"}
|
||||
@@ -315,30 +320,29 @@ export function ImportProfileDialog({
|
||||
className="flex-1"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Manual Import
|
||||
{t("importProfile.manualImport")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{importMode === "auto-detect" && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
Detected Browser Profiles
|
||||
{t("importProfile.detectedProfilesTitle")}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Scanning for browser profiles...
|
||||
{t("importProfile.scanning")}
|
||||
</p>
|
||||
</div>
|
||||
) : detectedProfiles.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No browser profiles found on your system.
|
||||
{t("importProfile.noneFound")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Try the manual import option if you have profiles in
|
||||
custom locations.
|
||||
{t("importProfile.noneFoundHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -348,7 +352,7 @@ export function ImportProfileDialog({
|
||||
htmlFor="detected-profile-select"
|
||||
className="mb-2"
|
||||
>
|
||||
Select Profile:
|
||||
{t("importProfile.selectProfile")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedDetectedProfile ?? undefined}
|
||||
@@ -357,7 +361,11 @@ export function ImportProfileDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="detected-profile-select">
|
||||
<SelectValue placeholder="Choose a detected profile" />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"importProfile.selectProfilePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{detectedProfiles.map((profile) => {
|
||||
@@ -395,11 +403,15 @@ export function ImportProfileDialog({
|
||||
{selectedProfile && (
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Path:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("importProfile.pathLabel")}
|
||||
</span>{" "}
|
||||
{selectedProfile.path}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">Browser:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("importProfile.browserLabel")}
|
||||
</span>{" "}
|
||||
{getBrowserDisplayName(selectedProfile.browser)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -407,7 +419,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div>
|
||||
<Label htmlFor="auto-profile-name" className="mb-2">
|
||||
New Profile Name:
|
||||
{t("importProfile.newProfileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="auto-profile-name"
|
||||
@@ -415,7 +427,9 @@ export function ImportProfileDialog({
|
||||
onChange={(e) => {
|
||||
setAutoDetectProfileName(e.target.value);
|
||||
}}
|
||||
placeholder="Enter a name for the imported profile"
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -425,12 +439,14 @@ export function ImportProfileDialog({
|
||||
|
||||
{importMode === "manual" && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Manual Profile Import</h3>
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.manualTitle")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="manual-browser-select" className="mb-2">
|
||||
Browser Type:
|
||||
{t("importProfile.browserType")}
|
||||
</Label>
|
||||
<Select
|
||||
value={manualBrowserType ?? undefined}
|
||||
@@ -443,8 +459,8 @@ export function ImportProfileDialog({
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingSupport
|
||||
? "Loading browsers..."
|
||||
: "Select browser type"
|
||||
? t("importProfile.loadingBrowsers")
|
||||
: t("importProfile.selectBrowserType")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
@@ -468,7 +484,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-path" className="mb-2">
|
||||
Profile Folder Path:
|
||||
{t("importProfile.profileFolderPath")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -477,19 +493,21 @@ export function ImportProfileDialog({
|
||||
onChange={(e) => {
|
||||
setManualProfilePath(e.target.value);
|
||||
}}
|
||||
placeholder="Enter the full path to the profile folder"
|
||||
placeholder={t(
|
||||
"importProfile.profileFolderPlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseFolder()}
|
||||
title="Browse for folder"
|
||||
title={t("importProfile.browseFolderTitle")}
|
||||
>
|
||||
<FaFolder className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Example paths:
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application
|
||||
Support/Firefox/Profiles/xxx.default
|
||||
@@ -502,7 +520,7 @@ export function ImportProfileDialog({
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-name" className="mb-2">
|
||||
New Profile Name:
|
||||
{t("importProfile.newProfileName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="manual-profile-name"
|
||||
@@ -510,7 +528,9 @@ export function ImportProfileDialog({
|
||||
onChange={(e) => {
|
||||
setManualProfileName(e.target.value);
|
||||
}}
|
||||
placeholder="Enter a name for the imported profile"
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -523,14 +543,16 @@ export function ImportProfileDialog({
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
This profile will be imported as a{" "}
|
||||
{t("importProfile.importedAsPrefix")}{" "}
|
||||
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "}
|
||||
profile.
|
||||
{t("importProfile.importedAsSuffix")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">Proxy (Optional)</Label>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.proxyOptional")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedProxyId ?? "none"}
|
||||
onValueChange={(value) => {
|
||||
@@ -538,10 +560,12 @@ export function ImportProfileDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
<SelectValue placeholder={t("importProfile.noProxy")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noProxy")}
|
||||
</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
@@ -580,7 +604,7 @@ export function ImportProfileDialog({
|
||||
{currentStep === "select" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
disabled={!canProceedToNext}
|
||||
@@ -588,7 +612,7 @@ export function ImportProfileDialog({
|
||||
setCurrentStep("configure");
|
||||
}}
|
||||
>
|
||||
Next
|
||||
{t("importProfile.nextButton")}
|
||||
</RippleButton>
|
||||
</>
|
||||
) : (
|
||||
@@ -599,7 +623,7 @@ export function ImportProfileDialog({
|
||||
setCurrentStep("select");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
@@ -607,7 +631,7 @@ export function ImportProfileDialog({
|
||||
void handleImport();
|
||||
}}
|
||||
>
|
||||
Import
|
||||
{t("importProfile.importButton")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function IntegrationsDialog({
|
||||
settings: { ...settings, api_enabled: true },
|
||||
});
|
||||
setSettings(next);
|
||||
showSuccessToast(`API server started on port ${port}`);
|
||||
showSuccessToast(t("integrations.apiStarted", { port }));
|
||||
} else {
|
||||
await invoke("stop_api_server");
|
||||
setApiServerPort(null);
|
||||
@@ -156,12 +156,13 @@ export function IntegrationsDialog({
|
||||
settings: { ...settings, api_enabled: false, api_token: null },
|
||||
});
|
||||
setSettings(next);
|
||||
showSuccessToast("API server stopped");
|
||||
showSuccessToast(t("integrations.apiStopped"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle API:", e);
|
||||
showErrorToast("Failed to toggle API server", {
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
showErrorToast(t("integrations.apiToggleFailed"), {
|
||||
description:
|
||||
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
@@ -178,7 +179,7 @@ export function IntegrationsDialog({
|
||||
});
|
||||
setSettings(next);
|
||||
void loadMcpConfig();
|
||||
showSuccessToast(`MCP server started on port ${port}`);
|
||||
showSuccessToast(t("integrations.mcpStarted", { port }));
|
||||
} else {
|
||||
await invoke("stop_mcp_server");
|
||||
const next = await invoke<AppSettings>("save_app_settings", {
|
||||
@@ -186,12 +187,13 @@ export function IntegrationsDialog({
|
||||
});
|
||||
setSettings(next);
|
||||
setMcpConfig(null);
|
||||
showSuccessToast("MCP server stopped");
|
||||
showSuccessToast(t("integrations.mcpStopped"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle MCP server:", e);
|
||||
showErrorToast("Failed to toggle MCP server", {
|
||||
description: e instanceof Error ? e.message : "Unknown error",
|
||||
showErrorToast(t("integrations.mcpToggleFailed"), {
|
||||
description:
|
||||
e instanceof Error ? e.message : t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsMcpStarting(false);
|
||||
@@ -207,14 +209,14 @@ export function IntegrationsDialog({
|
||||
>
|
||||
<DialogContent className="max-w-xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>Integrations</DialogTitle>
|
||||
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<Tabs defaultValue="api" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="api">Local API</TabsTrigger>
|
||||
<TabsTrigger value="mcp">MCP (AI Assistants)</TabsTrigger>
|
||||
<TabsTrigger value="api">{t("integrations.tabApi")}</TabsTrigger>
|
||||
<TabsTrigger value="mcp">{t("integrations.tabMcp")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="api" className="space-y-4 mt-4">
|
||||
@@ -230,10 +232,10 @@ export function IntegrationsDialog({
|
||||
htmlFor="api-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable Local API Server
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow managing profiles, groups, and proxies via REST API.
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,7 +243,9 @@ export function IntegrationsDialog({
|
||||
{settings.api_enabled && (
|
||||
<div className="space-y-4 p-4 rounded-md border bg-muted/40">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Port</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -251,8 +255,10 @@ export function IntegrationsDialog({
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast("Invalid port", {
|
||||
description: "Port must be between 1 and 65535",
|
||||
showErrorToast(t("integrations.apiInvalidPort"), {
|
||||
description: t(
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -270,20 +276,28 @@ export function IntegrationsDialog({
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(`Port ${port} is already in use`, {
|
||||
description: `Server started on fallback port ${actualPort}`,
|
||||
});
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
`API server running on port ${actualPort}`,
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast("Failed to start API server", {
|
||||
showErrorToast(t("integrations.apiStartFailed"), {
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: "Unknown error",
|
||||
: t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
@@ -315,7 +329,7 @@ export function IntegrationsDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
Authentication Token
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -343,11 +357,13 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage="Token copied"
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include in Authorization header: Bearer {"<token>"}
|
||||
{t("integrations.apiTokenHint", {
|
||||
tokenSlot: "<token>",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -367,13 +383,13 @@ export function IntegrationsDialog({
|
||||
htmlFor="mcp-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable MCP Server (Model Context Protocol)
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow AI assistants like Claude Desktop to control browsers.
|
||||
{t("integrations.mcpEnableDescription")}
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-warning">
|
||||
(Accept Wayfern terms in Settings first)
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ export function LaunchOnLoginDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LaunchOnLoginDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isEnabling, setIsEnabling] = useState(false);
|
||||
const [isDeclining, setIsDeclining] = useState(false);
|
||||
|
||||
@@ -29,18 +31,18 @@ export function LaunchOnLoginDialog({
|
||||
setIsEnabling(true);
|
||||
try {
|
||||
await invoke("enable_launch_on_login");
|
||||
showSuccessToast("Launch on login enabled");
|
||||
showSuccessToast(t("launchOnLogin.enableSuccess"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to enable launch on login:", error);
|
||||
showErrorToast("Failed to enable launch on login", {
|
||||
showErrorToast(t("launchOnLogin.enableFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsEnabling(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [onClose, t]);
|
||||
|
||||
const handleDecline = useCallback(async () => {
|
||||
setIsDeclining(true);
|
||||
@@ -49,14 +51,14 @@ export function LaunchOnLoginDialog({
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to decline launch on login:", error);
|
||||
showErrorToast("Failed to save preference", {
|
||||
showErrorToast(t("launchOnLogin.declineFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error ? error.message : t("launchOnLogin.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsDeclining(false);
|
||||
}
|
||||
}, [onClose]);
|
||||
}, [onClose, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
@@ -73,11 +75,11 @@ export function LaunchOnLoginDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable Launch on Login?</DialogTitle>
|
||||
<DialogTitle>{t("launchOnLogin.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Running in the background helps keep your proxies and browsers alive.
|
||||
{t("launchOnLogin.description")}
|
||||
</p>
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||
@@ -86,14 +88,16 @@ export function LaunchOnLoginDialog({
|
||||
onClick={handleDecline}
|
||||
disabled={isEnabling || isDeclining}
|
||||
>
|
||||
{isDeclining ? "..." : "Don't Ask Again"}
|
||||
{isDeclining
|
||||
? t("launchOnLogin.declining")
|
||||
: t("launchOnLogin.declineButton")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleEnable}
|
||||
isLoading={isEnabling}
|
||||
disabled={isDeclining}
|
||||
>
|
||||
Enable
|
||||
{t("launchOnLogin.enableButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
@@ -29,6 +30,7 @@ export function LocationProxyDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: LocationProxyDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [countries, setCountries] = useState<LocationItem[]>([]);
|
||||
const [regions, setRegions] = useState<LocationItem[]>([]);
|
||||
const [cities, setCities] = useState<LocationItem[]>([]);
|
||||
@@ -68,12 +70,12 @@ export function LocationProxyDialog({
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch countries:", err);
|
||||
toast.error("Failed to load countries");
|
||||
toast.error(t("locationProxy.loadFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingCountries(false);
|
||||
});
|
||||
}, [isOpen]);
|
||||
}, [isOpen, t]);
|
||||
|
||||
// Fetch regions when country changes
|
||||
useEffect(() => {
|
||||
@@ -188,13 +190,13 @@ export function LocationProxyDialog({
|
||||
city: selectedCity || null,
|
||||
isp: selectedIsp || null,
|
||||
});
|
||||
toast.success("Location proxy created");
|
||||
toast.success(t("locationProxy.createSuccess"));
|
||||
await emit("stored-proxies-changed");
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create location proxy:", error);
|
||||
toast.error(
|
||||
typeof error === "string" ? error : "Failed to create location proxy",
|
||||
typeof error === "string" ? error : t("locationProxy.createFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
@@ -206,6 +208,7 @@ export function LocationProxyDialog({
|
||||
selectedIsp,
|
||||
proxyName,
|
||||
handleClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
const countryOptions = countries.map((c) => ({
|
||||
@@ -224,9 +227,9 @@ export function LocationProxyDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Location Proxy</DialogTitle>
|
||||
<DialogTitle>{t("locationProxy.titleCreate")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a geo-targeted proxy with a 24-hour sticky session
|
||||
{t("locationProxy.descriptionCreate")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -234,7 +237,7 @@ export function LocationProxyDialog({
|
||||
{/* Country - always visible */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
Country (required)
|
||||
{t("locationProxy.countryLabel")}
|
||||
{isLoadingCountries && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -242,9 +245,11 @@ export function LocationProxyDialog({
|
||||
value={selectedCountry}
|
||||
onValueChange={setSelectedCountry}
|
||||
placeholder={
|
||||
isLoadingCountries ? "Loading countries..." : "Select country"
|
||||
isLoadingCountries
|
||||
? t("locationProxy.loadingCountries")
|
||||
: t("locationProxy.selectCountryPh")
|
||||
}
|
||||
searchPlaceholder="Search countries..."
|
||||
searchPlaceholder={t("locationProxy.searchCountries")}
|
||||
disabled={isLoadingCountries}
|
||||
/>
|
||||
</div>
|
||||
@@ -252,7 +257,7 @@ export function LocationProxyDialog({
|
||||
{/* Region - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
Region (optional)
|
||||
{t("locationProxy.regionLabel")}
|
||||
{isLoadingRegions && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -261,14 +266,14 @@ export function LocationProxyDialog({
|
||||
onValueChange={setSelectedRegion}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingRegions
|
||||
? "Loading regions..."
|
||||
? t("locationProxy.loadingRegions")
|
||||
: regionOptions.length === 0
|
||||
? "No regions available"
|
||||
: "Select region"
|
||||
? t("locationProxy.noRegions")
|
||||
: t("locationProxy.selectRegion")
|
||||
}
|
||||
searchPlaceholder="Search regions..."
|
||||
searchPlaceholder={t("locationProxy.searchRegions")}
|
||||
disabled={!selectedCountry || isLoadingRegions}
|
||||
/>
|
||||
</div>
|
||||
@@ -276,7 +281,7 @@ export function LocationProxyDialog({
|
||||
{/* City - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
City (optional)
|
||||
{t("locationProxy.cityLabel")}
|
||||
{isLoadingCities && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -285,14 +290,14 @@ export function LocationProxyDialog({
|
||||
onValueChange={setSelectedCity}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingCities
|
||||
? "Loading cities..."
|
||||
? t("locationProxy.loadingCities")
|
||||
: cityOptions.length === 0
|
||||
? "No cities available"
|
||||
: "Select city"
|
||||
? t("locationProxy.noCities")
|
||||
: t("locationProxy.selectCity")
|
||||
}
|
||||
searchPlaceholder="Search cities..."
|
||||
searchPlaceholder={t("locationProxy.searchCities")}
|
||||
disabled={!selectedCountry || isLoadingCities}
|
||||
/>
|
||||
</div>
|
||||
@@ -300,7 +305,7 @@ export function LocationProxyDialog({
|
||||
{/* ISP - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
ISP (optional)
|
||||
{t("locationProxy.ispLabel")}
|
||||
{isLoadingIsps && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
@@ -309,40 +314,42 @@ export function LocationProxyDialog({
|
||||
onValueChange={setSelectedIsp}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
? t("locationProxy.selectCountryFirst")
|
||||
: isLoadingIsps
|
||||
? "Loading ISPs..."
|
||||
? t("locationProxy.loadingIsps")
|
||||
: ispOptions.length === 0
|
||||
? "No ISPs available"
|
||||
: "Select ISP"
|
||||
? t("locationProxy.noIsps")
|
||||
: t("locationProxy.selectIsp")
|
||||
}
|
||||
searchPlaceholder="Search ISPs..."
|
||||
searchPlaceholder={t("locationProxy.searchIsps")}
|
||||
disabled={!selectedCountry || isLoadingIsps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Label>{t("locationProxy.nameLabel")}</Label>
|
||||
<Input
|
||||
value={proxyName}
|
||||
onChange={(e) => {
|
||||
setProxyName(e.target.value);
|
||||
}}
|
||||
placeholder="Proxy name"
|
||||
placeholder={t("locationProxy.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<RippleButton
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedCountry || !proxyName.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create"}
|
||||
{isCreating
|
||||
? t("locationProxy.creatingButton")
|
||||
: t("locationProxy.createButton")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ export function PermissionDialog({
|
||||
permissionType,
|
||||
onPermissionGranted,
|
||||
}: PermissionDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const {
|
||||
@@ -74,18 +76,18 @@ export function PermissionDialog({
|
||||
const getPermissionTitle = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone Access Required";
|
||||
return t("permissionDialog.titleMicrophone");
|
||||
case "camera":
|
||||
return "Camera Access Required";
|
||||
return t("permissionDialog.titleCamera");
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionDescription = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Donut Browser needs access to your microphone to enable microphone functionality in web browsers. Each website that wants to use your microphone will still ask for your permission individually.";
|
||||
return t("permissionDialog.descMicrophone");
|
||||
case "camera":
|
||||
return "Donut Browser needs access to your camera to enable camera functionality in web browsers. Each website that wants to use your camera will still ask for your permission individually.";
|
||||
return t("permissionDialog.descCamera");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -94,14 +96,13 @@ export function PermissionDialog({
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionTitle(permissionType).replace(
|
||||
" Required",
|
||||
"",
|
||||
)} permission requested`,
|
||||
permissionType === "microphone"
|
||||
? t("permissionDialog.requestSuccessMicrophone")
|
||||
: t("permissionDialog.requestSuccessCamera"),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
showErrorToast("Failed to request permission");
|
||||
showErrorToast(t("permissionDialog.requestFailed"));
|
||||
} finally {
|
||||
setIsRequesting(false);
|
||||
}
|
||||
@@ -131,8 +132,9 @@ export function PermissionDialog({
|
||||
{isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-success/10 rounded-lg">
|
||||
<p className="text-sm text-success">
|
||||
✅ Permission granted! Browsers launched from Donut Browser can
|
||||
now access your {permissionType}.
|
||||
{permissionType === "microphone"
|
||||
? t("permissionDialog.grantedMicrophone")
|
||||
: t("permissionDialog.grantedCamera")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -140,8 +142,9 @@ export function PermissionDialog({
|
||||
{!isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-warning/10 rounded-lg">
|
||||
<p className="text-sm text-warning">
|
||||
⚠️ Permission not granted. Click the button below to request
|
||||
access to your {permissionType}.
|
||||
{permissionType === "microphone"
|
||||
? t("permissionDialog.notGrantedMicrophone")
|
||||
: t("permissionDialog.notGrantedCamera")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -149,7 +152,9 @@ export function PermissionDialog({
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{isCurrentPermissionGranted ? "Done" : "Cancel"}
|
||||
{isCurrentPermissionGranted
|
||||
? t("permissionDialog.doneButton")
|
||||
: t("permissionDialog.cancelButton")}
|
||||
</RippleButton>
|
||||
|
||||
{!isCurrentPermissionGranted && (
|
||||
@@ -162,7 +167,7 @@ export function PermissionDialog({
|
||||
}}
|
||||
className="min-w-24"
|
||||
>
|
||||
Grant Access
|
||||
{t("permissionDialog.grantAccessButton")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -236,6 +236,7 @@ function getProfileSyncStatusDot(
|
||||
| "error"
|
||||
| "disabled"
|
||||
| undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
errorMessage?: string,
|
||||
): SyncStatusDot | null {
|
||||
const encrypted = profile.sync_mode === "Encrypted";
|
||||
@@ -249,14 +250,14 @@ function getProfileSyncStatusDot(
|
||||
case "syncing":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Syncing...",
|
||||
tooltip: t("profileTable.syncTooltipSyncing"),
|
||||
animate: true,
|
||||
encrypted,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Close the profile to sync",
|
||||
tooltip: t("profileTable.syncTooltipCloseToSync"),
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
@@ -264,15 +265,19 @@ function getProfileSyncStatusDot(
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: profile.last_sync
|
||||
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("profileTable.syncTooltipSyncedAt", {
|
||||
time: new Date(profile.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("profileTable.syncTooltipSynced"),
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
tooltip: errorMessage
|
||||
? t("profileTable.syncTooltipErrorWith", { error: errorMessage })
|
||||
: t("profileTable.syncTooltipError"),
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
@@ -280,7 +285,9 @@ function getProfileSyncStatusDot(
|
||||
if (profile.last_sync) {
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
|
||||
tooltip: t("profileTable.syncTooltipDisabledWithLast", {
|
||||
time: formatRelativeTime(profile.last_sync),
|
||||
}),
|
||||
animate: false,
|
||||
encrypted: false,
|
||||
};
|
||||
@@ -313,6 +320,7 @@ const TagsCell = React.memo<{
|
||||
setOpenTagsEditorFor,
|
||||
setTagsOverrides,
|
||||
}) => {
|
||||
const { t: translate } = useTranslation();
|
||||
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.id)
|
||||
? tagsOverrides[profile.id]
|
||||
: (profile.tags ?? []);
|
||||
@@ -475,7 +483,9 @@ const TagsCell = React.memo<{
|
||||
</Badge>
|
||||
))}
|
||||
{effectiveTags.length === 0 && (
|
||||
<span className="text-muted-foreground">No tags</span>
|
||||
<span className="text-muted-foreground">
|
||||
{translate("profileTable.noTags")}
|
||||
</span>
|
||||
)}
|
||||
{hiddenCount > 0 && (
|
||||
<Badge variant="outline" className="px-2 py-0 text-xs">
|
||||
@@ -630,6 +640,7 @@ const NoteCell = React.memo<{
|
||||
setOpenNoteEditorFor,
|
||||
setNoteOverrides,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const effectiveNote: string | null = Object.hasOwn(
|
||||
noteOverrides,
|
||||
profile.id,
|
||||
@@ -745,14 +756,14 @@ const NoteCell = React.memo<{
|
||||
!effectiveNote && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{effectiveNote ? trimmedNote : "No Note"}
|
||||
{effectiveNote ? trimmedNote : t("profiles.note.empty")}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{showTooltip && (
|
||||
<TooltipContent className="max-w-[320px]">
|
||||
<p className="whitespace-pre-wrap wrap-break-word">
|
||||
{effectiveNote ?? "No Note"}
|
||||
{effectiveNote ?? t("profiles.note.empty")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
@@ -789,7 +800,7 @@ const NoteCell = React.memo<{
|
||||
void onNoteChange(noteValue);
|
||||
setOpenNoteEditorFor(null);
|
||||
}}
|
||||
placeholder="Add a note..."
|
||||
placeholder={t("profiles.note.placeholder")}
|
||||
className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0"
|
||||
style={{
|
||||
overflow: "auto",
|
||||
@@ -1334,12 +1345,14 @@ export function ProfilesDataTable({
|
||||
setRenameError(null);
|
||||
} catch (error) {
|
||||
setRenameError(
|
||||
error instanceof Error ? error.message : "Failed to rename profile",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("errors.renameProfileFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsRenamingSaving(false);
|
||||
}
|
||||
}, [profileToRename, newProfileName, onRenameProfile]);
|
||||
}, [profileToRename, newProfileName, onRenameProfile, t]);
|
||||
|
||||
// Cancel inline rename on outside click
|
||||
React.useEffect(() => {
|
||||
@@ -1661,7 +1674,7 @@ export function ProfilesDataTable({
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleToggleAll(!!value);
|
||||
}}
|
||||
aria-label="Select all"
|
||||
aria-label={t("common.aria.selectAll")}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</span>
|
||||
@@ -1707,7 +1720,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label="Select profile"
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
|
||||
@@ -1745,7 +1758,7 @@ export function ProfilesDataTable({
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleCheckboxChange(profile.id, !!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
aria-label={t("common.aria.selectRow")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</span>
|
||||
@@ -1793,7 +1806,7 @@ export function ProfilesDataTable({
|
||||
onCheckedChange={(value) => {
|
||||
meta.handleCheckboxChange(profile.id, !!value);
|
||||
}}
|
||||
aria-label="Select row"
|
||||
aria-label={t("common.aria.selectRow")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</span>
|
||||
@@ -1814,7 +1827,7 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.handleIconClick(profile.id);
|
||||
}}
|
||||
aria-label="Select profile"
|
||||
aria-label={t("common.aria.selectProfile")}
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
{IconComponent && (
|
||||
@@ -2299,8 +2312,8 @@ export function ProfilesDataTable({
|
||||
<CommandInput
|
||||
placeholder={
|
||||
meta.canCreateLocationProxy
|
||||
? "Search proxies, VPNs, or countries..."
|
||||
: "Search proxies or VPNs..."
|
||||
? t("createProfile.proxy.searchWithCountries")
|
||||
: t("createProfile.proxy.search")
|
||||
}
|
||||
onFocus={() => {
|
||||
if (meta.canCreateLocationProxy)
|
||||
@@ -2308,7 +2321,9 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{t("createProfile.proxy.notFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -2324,7 +2339,7 @@ export function ProfilesDataTable({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("common.labels.none")}
|
||||
</CommandItem>
|
||||
{meta.storedProxies
|
||||
.filter(
|
||||
@@ -2466,6 +2481,7 @@ export function ProfilesDataTable({
|
||||
const dot = getProfileSyncStatusDot(
|
||||
profile,
|
||||
liveStatus,
|
||||
meta.t,
|
||||
syncEntry?.error,
|
||||
);
|
||||
if (!dot) return null;
|
||||
@@ -2507,7 +2523,9 @@ export function ProfilesDataTable({
|
||||
setProfileForInfoDialog(profile);
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Profile info</span>
|
||||
<span className="sr-only">
|
||||
{t("profiles.aria.profileInfo")}
|
||||
</span>
|
||||
<LuInfo className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2626,7 +2644,7 @@ export function ProfilesDataTable({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No profiles found.
|
||||
{t("profiles.table.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -2639,9 +2657,11 @@ export function ProfilesDataTable({
|
||||
setProfileToDelete(null);
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Profile"
|
||||
description={`This action cannot be undone. This will permanently delete the profile "${profileToDelete?.name}" and all its associated data.`}
|
||||
confirmButtonText="Delete Profile"
|
||||
title={t("profiles.delete.title")}
|
||||
description={t("profiles.delete.description", {
|
||||
profileName: profileToDelete?.name ?? "",
|
||||
})}
|
||||
confirmButtonText={t("profiles.delete.confirmButton")}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
{profileForInfoDialog &&
|
||||
@@ -2700,7 +2720,7 @@ export function ProfilesDataTable({
|
||||
<DataTableActionBarSelection table={table} />
|
||||
{onBulkGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign to Group"
|
||||
tooltip={t("profiles.actionBar.assignToGroup")}
|
||||
onClick={onBulkGroupAssignment}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2709,7 +2729,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkProxyAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign Proxy"
|
||||
tooltip={t("profiles.actionBar.assignProxy")}
|
||||
onClick={onBulkProxyAssignment}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2718,7 +2738,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkExtensionGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Assign Extension Group"
|
||||
tooltip={t("profiles.actionBar.assignExtensionGroup")}
|
||||
onClick={onBulkExtensionGroupAssignment}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2727,7 +2747,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkCopyCookies && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Copy Cookies"
|
||||
tooltip={t("profiles.actionBar.copyCookies")}
|
||||
onClick={onBulkCopyCookies}
|
||||
size="icon"
|
||||
>
|
||||
@@ -2736,7 +2756,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkDelete && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Delete"
|
||||
tooltip={t("common.buttons.delete")}
|
||||
onClick={onBulkDelete}
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -47,6 +48,7 @@ export function ProfileSelectorDialog({
|
||||
runningProfiles: externalRunningProfiles,
|
||||
isUpdating,
|
||||
}: ProfileSelectorDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Use the centralized profile events hook
|
||||
const { profiles, runningProfiles: hookRunningProfiles } = useProfileEvents();
|
||||
|
||||
@@ -159,17 +161,19 @@ export function ProfileSelectorDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Choose Profile</DialogTitle>
|
||||
<DialogTitle>{t("profileSelector.chooseProfileTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{url && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm font-medium">Opening URL:</Label>
|
||||
<Label className="text-sm font-medium">
|
||||
{t("profileSelector.openingUrl")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={url}
|
||||
successMessage="URL copied to clipboard!"
|
||||
successMessage={t("profileSelector.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 text-sm break-all rounded bg-muted">
|
||||
@@ -179,15 +183,16 @@ export function ProfileSelectorDialog({
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-select">Select Profile:</Label>
|
||||
<Label htmlFor="profile-select">
|
||||
{t("profileSelector.selectProfileLabel")}
|
||||
</Label>
|
||||
{profiles.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No profiles available. Please create a profile first.
|
||||
{t("profileSelector.noneAvailableShort")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Close this dialog and create a profile from the main window to
|
||||
get started.
|
||||
{t("profileSelector.noneAvailableLong")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -196,7 +201,9 @@ export function ProfileSelectorDialog({
|
||||
onValueChange={setSelectedProfile}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a profile" />
|
||||
<SelectValue
|
||||
placeholder={t("profileSelector.chooseAProfile")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => {
|
||||
@@ -241,12 +248,12 @@ export function ProfileSelectorDialog({
|
||||
</Badge>
|
||||
{hasProxy(profile) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Proxy
|
||||
{t("profileSelector.badgeProxy")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Running
|
||||
{t("profileSelector.badgeRunning")}
|
||||
</Badge>
|
||||
)}
|
||||
{!canUseForLinks && (
|
||||
@@ -254,7 +261,7 @@ export function ProfileSelectorDialog({
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
>
|
||||
Unavailable
|
||||
{t("profileSelector.badgeUnavailable")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -275,7 +282,7 @@ export function ProfileSelectorDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -289,7 +296,7 @@ export function ProfileSelectorDialog({
|
||||
!canOpenWithSelectedProfile()
|
||||
}
|
||||
>
|
||||
Open
|
||||
{t("profileSelector.openButton")}
|
||||
</LoadingButton>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -53,6 +54,7 @@ export function ProxyAssignmentDialog({
|
||||
storedProxies = [],
|
||||
vpnConfigs = [],
|
||||
}: ProxyAssignmentDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
|
||||
"none",
|
||||
@@ -84,7 +86,7 @@ export function ProxyAssignmentDialog({
|
||||
});
|
||||
|
||||
if (validProfiles.length === 0) {
|
||||
setError("No valid profiles selected.");
|
||||
setError(t("proxyAssignment.noValidProfiles"));
|
||||
setIsAssigning(false);
|
||||
return;
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export function ProxyAssignmentDialog({
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign proxy/VPN to profiles";
|
||||
: t("proxyAssignment.failedFallback");
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -124,6 +126,7 @@ export function ProxyAssignmentDialog({
|
||||
profiles,
|
||||
onAssignmentComplete,
|
||||
onClose,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,16 +141,21 @@ export function ProxyAssignmentDialog({
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Proxy / VPN</DialogTitle>
|
||||
<DialogTitle>{t("proxyAssignment.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign a proxy or VPN to {selectedProfiles.length} selected
|
||||
profile(s).
|
||||
{selectedProfiles.length === 1
|
||||
? t("proxyAssignment.description_one", {
|
||||
count: selectedProfiles.length,
|
||||
})
|
||||
: t("proxyAssignment.description_other", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Selected Profiles:</Label>
|
||||
<Label>{t("proxyAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
@@ -166,7 +174,9 @@ export function ProxyAssignmentDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
|
||||
<Label htmlFor="proxy-vpn-select">
|
||||
{t("proxyAssignment.assignProxyVpnLabel")}
|
||||
</Label>
|
||||
<Popover open={proxyPopoverOpen} onOpenChange={setProxyPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -176,24 +186,29 @@ export function ProxyAssignmentDialog({
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{(() => {
|
||||
if (selectionType === "none") return "None";
|
||||
if (selectionType === "none")
|
||||
return t("proxyAssignment.noneOption");
|
||||
if (selectionType === "vpn") {
|
||||
const vpn = vpnConfigs.find((v) => v.id === selectedId);
|
||||
return vpn ? `WG — ${vpn.name}` : "None";
|
||||
return vpn
|
||||
? `WG — ${vpn.name}`
|
||||
: t("proxyAssignment.noneOption");
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedId,
|
||||
);
|
||||
return proxy ? proxy.name : "None";
|
||||
return proxy ? proxy.name : t("proxyAssignment.noneOption");
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandInput
|
||||
placeholder={t("proxyAssignment.searchPlaceholder")}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandEmpty>{t("proxyAssignment.notFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -210,7 +225,7 @@ export function ProxyAssignmentDialog({
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
{t("proxyAssignment.noneOption")}
|
||||
</CommandItem>
|
||||
{storedProxies
|
||||
.filter(
|
||||
@@ -240,7 +255,9 @@ export function ProxyAssignmentDialog({
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
<CommandGroup
|
||||
heading={t("proxyAssignment.vpnGroupHeading")}
|
||||
>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
@@ -288,13 +305,13 @@ export function ProxyAssignmentDialog({
|
||||
onClick={onClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isAssigning}
|
||||
onClick={() => void handleAssign()}
|
||||
>
|
||||
Assign
|
||||
{t("proxyAssignment.assignButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { FlagIcon } from "@/components/flag-icon";
|
||||
@@ -35,6 +36,7 @@ export function ProxyCheckButton({
|
||||
disabled = false,
|
||||
setCheckingProfileId,
|
||||
}: ProxyCheckButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [localResult, setLocalResult] = React.useState<
|
||||
ProxyCheckResult | undefined
|
||||
>(cachedResult);
|
||||
@@ -60,11 +62,13 @@ export function ProxyCheckButton({
|
||||
if (result.city) locationParts.push(result.city);
|
||||
if (result.country) locationParts.push(result.country);
|
||||
const location =
|
||||
locationParts.length > 0 ? locationParts.join(", ") : "Unknown";
|
||||
locationParts.length > 0
|
||||
? locationParts.join(", ")
|
||||
: t("proxyCheck.unknownLocation");
|
||||
|
||||
toast.success(
|
||||
<div className="flex flex-col">
|
||||
Your proxy location is:
|
||||
{t("proxyCheck.locationToast")}
|
||||
<div className="flex items-center whitespace-nowrap">
|
||||
{location}
|
||||
{result.country_code && (
|
||||
@@ -79,7 +83,7 @@ export function ProxyCheckButton({
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Proxy check failed: ${errorMessage}`);
|
||||
toast.error(t("proxyCheck.failed", { error: errorMessage }));
|
||||
|
||||
// Save failed check result
|
||||
const failedResult: ProxyCheckResult = {
|
||||
@@ -102,6 +106,7 @@ export function ProxyCheckButton({
|
||||
onCheckComplete,
|
||||
onCheckFailed,
|
||||
setCheckingProfileId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const isCurrentlyChecking = checkingProfileId === profileId;
|
||||
@@ -133,7 +138,7 @@ export function ProxyCheckButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentlyChecking ? (
|
||||
<p>Checking proxy...</p>
|
||||
<p>{t("proxyCheck.tooltipChecking")}</p>
|
||||
) : result?.is_valid ? (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1">
|
||||
@@ -141,24 +146,28 @@ export function ProxyCheckButton({
|
||||
<FlagIcon countryCode={result.country_code} />
|
||||
)}
|
||||
{[result.city, result.country].filter(Boolean).join(", ") ||
|
||||
"Unknown"}
|
||||
t("proxyCheck.unknownLocation")}
|
||||
</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
IP: {result.ip}
|
||||
{t("proxyCheck.tooltipIp", { ip: result.ip })}
|
||||
</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
{t("proxyCheck.tooltipChecked", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : result && !result.is_valid ? (
|
||||
<div>
|
||||
<p>Proxy check failed</p>
|
||||
<p>{t("proxyCheck.tooltipFailedTitle")}</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Failed {formatRelativeTime(result.timestamp)}
|
||||
{t("proxyCheck.tooltipFailed", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Check proxy validity</p>
|
||||
<p>{t("proxyCheck.tooltipDefault")}</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuCopy, LuDownload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -23,6 +24,7 @@ interface ProxyExportDialogProps {
|
||||
}
|
||||
|
||||
export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [format, setFormat] = useState<"json" | "txt">("json");
|
||||
const [exportContent, setExportContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -35,12 +37,12 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
setExportContent(content);
|
||||
} catch (error) {
|
||||
console.error("Failed to export proxies:", error);
|
||||
toast.error("Failed to export proxies");
|
||||
toast.error(t("proxies.exportDialog.failed"));
|
||||
setExportContent("");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [format]);
|
||||
}, [format, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -52,15 +54,15 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(exportContent);
|
||||
setCopied(true);
|
||||
toast.success("Copied to clipboard");
|
||||
toast.success(t("toasts.success.copied"));
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy to clipboard:", error);
|
||||
toast.error("Failed to copy to clipboard");
|
||||
toast.error(t("toasts.error.copyFailed"));
|
||||
}
|
||||
}, [exportContent]);
|
||||
}, [exportContent, t]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const filename = format === "json" ? "proxies.json" : "proxies.txt";
|
||||
@@ -76,8 +78,8 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Downloaded ${filename}`);
|
||||
}, [format, exportContent]);
|
||||
toast.success(t("proxies.exportDialog.downloaded", { filename }));
|
||||
}, [format, exportContent, t]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setFormat("json");
|
||||
@@ -90,15 +92,15 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Proxies</DialogTitle>
|
||||
<DialogTitle>{t("proxies.exportDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Export your proxy configurations to a file
|
||||
{t("proxies.exportDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Export Format</Label>
|
||||
<Label>{t("proxies.exportDialog.format")}</Label>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => {
|
||||
@@ -109,24 +111,24 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="json" id="format-json" />
|
||||
<Label htmlFor="format-json" className="cursor-pointer">
|
||||
JSON
|
||||
{t("proxies.exportDialog.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)
|
||||
{t("proxies.exportDialog.txt")}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<Label>{t("proxies.exportDialog.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...
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : exportContent ? (
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
@@ -134,7 +136,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
</pre>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
No proxies to export
|
||||
{t("proxies.exportDialog.noProxies")}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
@@ -143,7 +145,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
@@ -156,7 +158,9 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
) : (
|
||||
<LuCopy className="w-4 h-4" />
|
||||
)}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
{copied
|
||||
? t("proxies.exportDialog.copied")
|
||||
: t("common.buttons.copy")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={handleDownload}
|
||||
@@ -164,7 +168,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Download
|
||||
{t("common.buttons.download")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -83,14 +83,12 @@ export function ProxyFormDialog({
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t("proxies.form.nameRequired", "Proxy name is required"));
|
||||
toast.error(t("proxies.form.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.host.trim() || !form.port) {
|
||||
toast.error(
|
||||
t("proxies.form.hostPortRequired", "Host and port are required"),
|
||||
);
|
||||
toast.error(t("proxies.form.hostPortRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,12 +96,7 @@ export function ProxyFormDialog({
|
||||
form.proxy_type === "ss" &&
|
||||
(!form.username.trim() || !form.password.trim())
|
||||
) {
|
||||
toast.error(
|
||||
t(
|
||||
"proxies.form.ssCipherRequired",
|
||||
"Cipher and password are required for Shadowsocks",
|
||||
),
|
||||
);
|
||||
toast.error(t("proxies.form.ssCipherRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +129,7 @@ export function ProxyFormDialog({
|
||||
console.error("Failed to save proxy:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to save proxy: ${errorMessage}`);
|
||||
toast.error(t("proxies.form.saveFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -189,7 +182,7 @@ export function ProxyFormDialog({
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select proxy type" />
|
||||
<SelectValue placeholder={t("proxies.form.selectType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5", "ss"].map((type) => (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -39,6 +40,7 @@ interface AmbiguousProxy {
|
||||
}
|
||||
|
||||
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [parsedProxies, setParsedProxies] = useState<ParsedProxyLine[]>([]);
|
||||
@@ -52,7 +54,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
null,
|
||||
);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [namePrefix, setNamePrefix] = useState("Imported");
|
||||
const [namePrefix, setNamePrefix] = useState(
|
||||
t("proxies.importDialog.namePrefixDefault"),
|
||||
);
|
||||
|
||||
const os = getCurrentOS();
|
||||
const modKey = os === "macos" ? "⌘" : "Ctrl";
|
||||
@@ -65,8 +69,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
setInvalidProxies([]);
|
||||
setImportResult(null);
|
||||
setIsImporting(false);
|
||||
setNamePrefix("Imported");
|
||||
}, []);
|
||||
setNamePrefix(t("proxies.importDialog.namePrefixDefault"));
|
||||
}, [t]);
|
||||
|
||||
const processContent = useCallback(
|
||||
async (content: string, isJson: boolean, _filename = "") => {
|
||||
@@ -116,19 +120,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
} else if (parsed.length > 0) {
|
||||
setStep("preview");
|
||||
} else {
|
||||
toast.error("No valid proxies found in the file");
|
||||
toast.error(t("proxies.importDialog.noValidProxies"));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process content:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to process file",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.importDialog.fileProcessError"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
@@ -140,11 +146,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
void processContent(content, isJson, file.name);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
toast.error(t("proxies.importDialog.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[processContent],
|
||||
[processContent, t],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -160,10 +166,10 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a .json or .txt file");
|
||||
toast.error(t("proxies.importDialog.wrongFileType"));
|
||||
}
|
||||
},
|
||||
[handleFileRead],
|
||||
[handleFileRead, t],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -206,7 +212,8 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
"import_proxies_from_parsed",
|
||||
{
|
||||
parsedProxies,
|
||||
namePrefix: namePrefix.trim() || "Imported",
|
||||
namePrefix:
|
||||
namePrefix.trim() || t("proxies.importDialog.namePrefixDefault"),
|
||||
},
|
||||
);
|
||||
setImportResult(result);
|
||||
@@ -215,12 +222,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
} catch (error) {
|
||||
console.error("Failed to import proxies:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import proxies",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.importDialog.failed"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [parsedProxies, namePrefix]);
|
||||
}, [parsedProxies, namePrefix, t]);
|
||||
|
||||
const handleAmbiguousFormatSelect = useCallback(
|
||||
(index: number, format: string) => {
|
||||
@@ -273,13 +282,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Proxies</DialogTitle>
|
||||
<DialogTitle>{t("proxies.importDialog.title")}</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"}
|
||||
{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>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -309,9 +317,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
>
|
||||
<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
|
||||
{t("proxies.importDialog.dropzonePrompt")}
|
||||
<br />
|
||||
<span className="text-xs">(.json, .txt)</span>
|
||||
<span className="text-xs">
|
||||
{t("proxies.importDialog.dropzoneFormats")}
|
||||
</span>
|
||||
</p>
|
||||
<input
|
||||
id="proxy-file-input"
|
||||
@@ -326,7 +336,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Paste from clipboard with {modKey}+V
|
||||
{t("proxies.importDialog.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -334,27 +344,35 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "preview" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name-prefix">Name Prefix</Label>
|
||||
<Label htmlFor="name-prefix">
|
||||
{t("proxies.importDialog.namePrefix")}
|
||||
</Label>
|
||||
<Input
|
||||
id="name-prefix"
|
||||
placeholder="Imported"
|
||||
placeholder={t("proxies.importDialog.namePrefixDefault")}
|
||||
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.
|
||||
{t("proxies.importDialog.namePrefixHint", {
|
||||
prefix:
|
||||
namePrefix || t("proxies.importDialog.namePrefixDefault"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Proxies to import ({parsedProxies.length})
|
||||
{t("proxies.importDialog.proxiesToImport", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
{invalidProxies.length > 0 && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({invalidProxies.length} invalid)
|
||||
{t("proxies.importDialog.invalidCount", {
|
||||
count: invalidProxies.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -387,8 +405,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{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.
|
||||
{t("proxies.importDialog.ambiguousIntro")}
|
||||
</p>
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<div className="p-3 space-y-4">
|
||||
@@ -430,14 +447,18 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<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">
|
||||
{t("proxies.importDialog.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">
|
||||
{t("proxies.importDialog.skippedDuplicates")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-warning">
|
||||
{importResult.skipped_count}
|
||||
</span>
|
||||
@@ -445,7 +466,9 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
)}
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm">Errors:</span>
|
||||
<span className="text-sm">
|
||||
{t("proxies.importDialog.errors")}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-destructive">
|
||||
{importResult.errors.length}
|
||||
</span>
|
||||
@@ -455,7 +478,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Errors</Label>
|
||||
<Label>{t("proxies.importDialog.errors")}</Label>
|
||||
<ScrollArea className="h-[100px] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
{importResult.errors.map((error, i) => (
|
||||
@@ -476,21 +499,23 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={parsedProxies.length === 0}
|
||||
>
|
||||
Import {parsedProxies.length} Proxies
|
||||
{t("proxies.importDialog.importButton", {
|
||||
count: parsedProxies.length,
|
||||
})}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
@@ -498,19 +523,21 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "ambiguous" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
onClick={handleResolveAmbiguous}
|
||||
disabled={ambiguousProxies.some((p) => !p.selectedFormat)}
|
||||
>
|
||||
Continue
|
||||
{t("proxies.importDialog.continueButton")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("proxies.importDialog.doneButton")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuDownload, LuPencil, LuTrash2, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
@@ -51,37 +52,46 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
item: { sync_enabled?: boolean; last_sync?: number },
|
||||
liveStatus: SyncStatus | undefined,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
errorMessage?: string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-warning", tooltip: "Syncing...", animate: true };
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: t("syncTooltips.syncing"),
|
||||
animate: true,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-success",
|
||||
tooltip: item.last_sync
|
||||
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
? t("syncTooltips.syncedAt", {
|
||||
time: new Date(item.last_sync * 1000).toLocaleString(),
|
||||
})
|
||||
: t("syncTooltips.synced"),
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-warning",
|
||||
tooltip: "Waiting to sync",
|
||||
tooltip: t("syncTooltips.waiting"),
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-destructive",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
tooltip: errorMessage
|
||||
? t("syncTooltips.errorWith", { error: errorMessage })
|
||||
: t("syncTooltips.error"),
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "bg-muted-foreground",
|
||||
tooltip: "Not synced",
|
||||
tooltip: t("syncTooltips.notSynced"),
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
@@ -96,6 +106,7 @@ export function ProxyManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ProxyManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// Proxy state
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
@@ -260,16 +271,16 @@ export function ProxyManagementDialog({
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id });
|
||||
toast.success("Proxy deleted successfully");
|
||||
toast.success(t("proxies.management.deleteSuccess"));
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete proxy:", error);
|
||||
toast.error("Failed to delete proxy");
|
||||
toast.error(t("proxies.management.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setProxyToDelete(null);
|
||||
}
|
||||
}, [proxyToDelete]);
|
||||
}, [proxyToDelete, t]);
|
||||
|
||||
const handleCreateProxy = useCallback(() => {
|
||||
setEditingProxy(null);
|
||||
@@ -286,24 +297,33 @@ export function ProxyManagementDialog({
|
||||
setEditingProxy(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleSync = useCallback(async (proxy: StoredProxy) => {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true }));
|
||||
try {
|
||||
await invoke("set_proxy_sync_enabled", {
|
||||
proxyId: proxy.id,
|
||||
enabled: !proxy.sync_enabled,
|
||||
});
|
||||
showSuccessToast(proxy.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false }));
|
||||
}
|
||||
}, []);
|
||||
const handleToggleSync = useCallback(
|
||||
async (proxy: StoredProxy) => {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true }));
|
||||
try {
|
||||
await invoke("set_proxy_sync_enabled", {
|
||||
proxyId: proxy.id,
|
||||
enabled: !proxy.sync_enabled,
|
||||
});
|
||||
showSuccessToast(
|
||||
proxy.sync_enabled
|
||||
? t("proxies.management.syncDisabled")
|
||||
: t("proxies.management.syncEnabled"),
|
||||
);
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false }));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// VPN handlers
|
||||
const handleDeleteVpn = useCallback((vpn: VpnConfig) => {
|
||||
@@ -315,16 +335,16 @@ export function ProxyManagementDialog({
|
||||
setIsDeletingVpn(true);
|
||||
try {
|
||||
await invoke("delete_vpn_config", { vpnId: vpnToDelete.id });
|
||||
toast.success("VPN deleted successfully");
|
||||
toast.success(t("vpns.management.deleteSuccess"));
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete VPN:", error);
|
||||
toast.error("Failed to delete VPN");
|
||||
toast.error(t("vpns.management.deleteFailed"));
|
||||
} finally {
|
||||
setIsDeletingVpn(false);
|
||||
setVpnToDelete(null);
|
||||
}
|
||||
}, [vpnToDelete]);
|
||||
}, [vpnToDelete, t]);
|
||||
|
||||
const handleCreateVpn = useCallback(() => {
|
||||
setEditingVpn(null);
|
||||
@@ -341,33 +361,42 @@ export function ProxyManagementDialog({
|
||||
setEditingVpn(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleVpnSync = useCallback(async (vpn: VpnConfig) => {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
|
||||
try {
|
||||
await invoke("set_vpn_sync_enabled", {
|
||||
vpnId: vpn.id,
|
||||
enabled: !vpn.sync_enabled,
|
||||
});
|
||||
showSuccessToast(vpn.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle VPN sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
|
||||
}
|
||||
}, []);
|
||||
const handleToggleVpnSync = useCallback(
|
||||
async (vpn: VpnConfig) => {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
|
||||
try {
|
||||
await invoke("set_vpn_sync_enabled", {
|
||||
vpnId: vpn.id,
|
||||
enabled: !vpn.sync_enabled,
|
||||
});
|
||||
showSuccessToast(
|
||||
vpn.sync_enabled
|
||||
? t("proxies.management.syncDisabled")
|
||||
: t("proxies.management.syncEnabled"),
|
||||
);
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle VPN sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxies.management.updateSyncFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Proxies & VPNs</DialogTitle>
|
||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage your proxy and VPN configurations for reuse across profiles
|
||||
{t("proxies.management.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -375,10 +404,10 @@ export function ProxyManagementDialog({
|
||||
<Tabs defaultValue="proxies">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="proxies" className="flex-1">
|
||||
Proxies
|
||||
{t("proxies.management.tabProxies")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vpns" className="flex-1">
|
||||
VPNs
|
||||
{t("proxies.management.tabVpns")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -395,7 +424,7 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
@@ -407,7 +436,7 @@ export function ProxyManagementDialog({
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Export
|
||||
{t("common.buttons.export")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -417,19 +446,18 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
{t("proxies.management.create")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
{t("proxies.management.loading")}
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No proxies created yet. Create your first proxy using the
|
||||
button above.
|
||||
{t("proxies.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
@@ -437,10 +465,16 @@ export function ProxyManagementDialog({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="w-20">
|
||||
{t("proxies.management.usage")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -448,6 +482,7 @@ export function ProxyManagementDialog({
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
t,
|
||||
proxySyncErrors[proxy.id],
|
||||
);
|
||||
return (
|
||||
@@ -495,14 +530,19 @@ export function ProxyManagementDialog({
|
||||
<TooltipContent>
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this
|
||||
proxy is used by synced profiles
|
||||
{t(
|
||||
"proxies.management.syncCannotDisable",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
? t(
|
||||
"proxies.management.disableSync",
|
||||
)
|
||||
: t(
|
||||
"proxies.management.enableSync",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
@@ -546,7 +586,9 @@ export function ProxyManagementDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
<p>
|
||||
{t("proxies.management.editProxy")}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -569,14 +611,28 @@ export function ProxyManagementDialog({
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1
|
||||
? "s"
|
||||
: ""}
|
||||
{(proxyUsage[proxy.id] ?? 0) === 1
|
||||
? t(
|
||||
"proxies.management.cannotDelete_one",
|
||||
{
|
||||
count:
|
||||
proxyUsage[proxy.id],
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"proxies.management.cannotDelete_other",
|
||||
{
|
||||
count:
|
||||
proxyUsage[proxy.id],
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
<p>
|
||||
{t(
|
||||
"proxies.management.deleteProxy",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -606,7 +662,7 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
<RippleButton
|
||||
@@ -615,18 +671,17 @@ export function ProxyManagementDialog({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
{t("proxies.management.create")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading VPNs...
|
||||
{t("vpns.management.loading")}
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No VPN configs created yet. Import or create one using the
|
||||
buttons above.
|
||||
{t("vpns.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
@@ -634,11 +689,19 @@ export function ProxyManagementDialog({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-16">Type</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
<TableHead>{t("common.labels.name")}</TableHead>
|
||||
<TableHead className="w-16">
|
||||
{t("common.labels.type")}
|
||||
</TableHead>
|
||||
<TableHead className="w-20">
|
||||
{t("proxies.management.usage")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("proxies.management.syncCol")}
|
||||
</TableHead>
|
||||
<TableHead className="w-24">
|
||||
{t("common.labels.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -646,6 +709,7 @@ export function ProxyManagementDialog({
|
||||
const syncDot = getSyncStatusDot(
|
||||
vpn,
|
||||
vpnSyncStatus[vpn.id],
|
||||
t,
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
@@ -696,14 +760,19 @@ export function ProxyManagementDialog({
|
||||
<TooltipContent>
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this
|
||||
VPN is used by synced profiles
|
||||
{t(
|
||||
"vpns.management.syncCannotDisable",
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
? t(
|
||||
"proxies.management.disableSync",
|
||||
)
|
||||
: t(
|
||||
"proxies.management.enableSync",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
@@ -730,7 +799,7 @@ export function ProxyManagementDialog({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit VPN</p>
|
||||
<p>{t("vpns.management.editVpn")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -753,12 +822,20 @@ export function ProxyManagementDialog({
|
||||
<TooltipContent>
|
||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{vpnUsage[vpn.id]} profile
|
||||
{vpnUsage[vpn.id] > 1 ? "s" : ""}
|
||||
{(vpnUsage[vpn.id] ?? 0) === 1
|
||||
? t(
|
||||
"vpns.management.cannotDelete_one",
|
||||
{ count: vpnUsage[vpn.id] },
|
||||
)
|
||||
: t(
|
||||
"vpns.management.cannotDelete_other",
|
||||
{ count: vpnUsage[vpn.id] },
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete VPN</p>
|
||||
<p>
|
||||
{t("vpns.management.deleteVpn")}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -779,7 +856,7 @@ export function ProxyManagementDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -796,9 +873,11 @@ export function ProxyManagementDialog({
|
||||
setProxyToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Proxy"
|
||||
description={`This action cannot be undone. This will permanently delete the proxy "${proxyToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
title={t("proxies.management.deleteTitle")}
|
||||
description={t("proxies.management.deleteDescription", {
|
||||
name: proxyToDelete?.name ?? "",
|
||||
})}
|
||||
confirmButtonText={t("common.buttons.delete")}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
<ProxyImportDialog
|
||||
@@ -824,9 +903,11 @@ export function ProxyManagementDialog({
|
||||
setVpnToDelete(null);
|
||||
}}
|
||||
onConfirm={handleConfirmDeleteVpn}
|
||||
title="Delete VPN"
|
||||
description={`This action cannot be undone. This will permanently delete the VPN "${vpnToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
title={t("vpns.management.deleteTitle")}
|
||||
description={t("vpns.management.deleteDescription", {
|
||||
name: vpnToDelete?.name ?? "",
|
||||
})}
|
||||
confirmButtonText={t("common.buttons.delete")}
|
||||
isLoading={isDeletingVpn}
|
||||
/>
|
||||
<VpnImportDialog
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -37,11 +38,14 @@ export function ReleaseTypeSelector({
|
||||
availableReleaseTypes,
|
||||
isDownloading,
|
||||
onDownload,
|
||||
placeholder = "Select release type...",
|
||||
placeholder,
|
||||
showDownloadButton = true,
|
||||
downloadedVersions = [],
|
||||
}: ReleaseTypeSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const effectivePlaceholder =
|
||||
placeholder ?? t("releaseTypeSelector.placeholder");
|
||||
|
||||
const releaseOptions = [
|
||||
...(availableReleaseTypes.stable
|
||||
@@ -64,9 +68,9 @@ export function ReleaseTypeSelector({
|
||||
|
||||
const selectedDisplayText = selectedReleaseType
|
||||
? selectedReleaseType === "stable"
|
||||
? "Stable"
|
||||
: "Nightly"
|
||||
: placeholder;
|
||||
? t("releaseTypeSelector.stable")
|
||||
: t("releaseTypeSelector.nightly")
|
||||
: effectivePlaceholder;
|
||||
|
||||
const selectedVersion =
|
||||
selectedReleaseType === "stable"
|
||||
@@ -95,7 +99,9 @@ export function ReleaseTypeSelector({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandEmpty>No release types available.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{t("releaseTypeSelector.noReleaseTypes")}
|
||||
</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{releaseOptions.map((option) => {
|
||||
@@ -130,7 +136,7 @@ export function ReleaseTypeSelector({
|
||||
<span className="capitalize">{option.type}</span>
|
||||
{option.type === "nightly" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Nightly
|
||||
{t("releaseTypeSelector.nightly")}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
@@ -138,7 +144,7 @@ export function ReleaseTypeSelector({
|
||||
</Badge>
|
||||
{isDownloaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Downloaded
|
||||
{t("releaseTypeSelector.downloaded")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,7 +168,7 @@ export function ReleaseTypeSelector({
|
||||
</Badge>
|
||||
{downloadedVersions.includes(releaseOptions[0].version) && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Downloaded
|
||||
{t("releaseTypeSelector.downloaded")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -182,7 +188,9 @@ export function ReleaseTypeSelector({
|
||||
className="w-full"
|
||||
>
|
||||
<LuDownload className="mr-2 w-4 h-4" />
|
||||
{isDownloading ? "Downloading..." : "Download Browser"}
|
||||
{isDownloading
|
||||
? t("releaseTypeSelector.downloading")
|
||||
: t("releaseTypeSelector.downloadBrowser")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -165,34 +165,46 @@ export function SettingsDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPermissionDisplayName = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone";
|
||||
case "camera":
|
||||
return "Camera";
|
||||
}
|
||||
}, []);
|
||||
const getPermissionDisplayName = useCallback(
|
||||
(type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return t("settings.permissions.microphone");
|
||||
case "camera":
|
||||
return t("settings.permissions.camera");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const getStatusBadge = useCallback((isGranted: boolean) => {
|
||||
if (isGranted) {
|
||||
return (
|
||||
<Badge variant="default" className="text-success-foreground bg-success">
|
||||
Granted
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">Not Granted</Badge>;
|
||||
}, []);
|
||||
const getStatusBadge = useCallback(
|
||||
(isGranted: boolean) => {
|
||||
if (isGranted) {
|
||||
return (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-success-foreground bg-success"
|
||||
>
|
||||
{t("common.status.granted")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">{t("common.status.notGranted")}</Badge>;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const getPermissionDescription = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Access to microphone for browser applications";
|
||||
case "camera":
|
||||
return "Access to camera for browser applications";
|
||||
}
|
||||
}, []);
|
||||
const getPermissionDescription = useCallback(
|
||||
(type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return t("settings.permissions.microphoneDescription");
|
||||
case "camera":
|
||||
return t("settings.permissions.cameraDescription");
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -332,15 +344,15 @@ export function SettingsDialog({
|
||||
// Don't show immediate success toast - let the version update progress events handle it
|
||||
} catch (error) {
|
||||
console.error("Failed to clear cache:", error);
|
||||
showErrorToast("Failed to clear cache", {
|
||||
showErrorToast(t("settings.advanced.clearCacheFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error instanceof Error ? error.message : t("common.errors.unknown"),
|
||||
duration: 4000,
|
||||
});
|
||||
} finally {
|
||||
setIsClearingCache(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleRequestPermission = useCallback(
|
||||
async (permissionType: PermissionType) => {
|
||||
@@ -348,7 +360,9 @@ export function SettingsDialog({
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionDisplayName(permissionType)} access requested`,
|
||||
t("settings.permissions.accessRequested", {
|
||||
permission: getPermissionDisplayName(permissionType),
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
@@ -356,7 +370,7 @@ export function SettingsDialog({
|
||||
setRequestingPermission(null);
|
||||
}
|
||||
},
|
||||
[getPermissionDisplayName, requestPermission],
|
||||
[getPermissionDisplayName, requestPermission, t],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
@@ -592,11 +606,13 @@ export function SettingsDialog({
|
||||
<div className="grid overflow-y-auto flex-1 gap-6 py-4 min-h-0">
|
||||
{/* Appearance Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Appearance</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.appearance.title")}
|
||||
</Label>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="theme-select" className="text-sm">
|
||||
Theme
|
||||
{t("settings.appearance.theme")}
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.theme}
|
||||
@@ -614,20 +630,29 @@ export function SettingsDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-select">
|
||||
<SelectValue placeholder="Select theme" />
|
||||
<SelectValue
|
||||
placeholder={t("settings.appearance.selectTheme")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
<SelectItem value="light">
|
||||
{t("settings.appearance.light")}
|
||||
</SelectItem>
|
||||
<SelectItem value="dark">
|
||||
{t("settings.appearance.dark")}
|
||||
</SelectItem>
|
||||
<SelectItem value="system">
|
||||
{t("settings.appearance.system")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("common.labels.custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred theme or follow your system settings.
|
||||
Custom theme changes are applied only when you save.
|
||||
{t("settings.appearance.themeDescription")}
|
||||
</p>
|
||||
|
||||
{settings.theme === "custom" && (
|
||||
@@ -637,7 +662,7 @@ export function SettingsDialog({
|
||||
htmlFor="theme-preset-select"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Theme Preset
|
||||
{t("settings.appearance.themePreset")}
|
||||
</Label>
|
||||
<Select
|
||||
value={customThemeState.selectedThemeId ?? "custom"}
|
||||
@@ -659,7 +684,11 @@ export function SettingsDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-preset-select">
|
||||
<SelectValue placeholder="Select a theme preset" />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"settings.appearance.selectThemePreset",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THEMES.map((theme) => (
|
||||
@@ -667,12 +696,16 @@ export function SettingsDialog({
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Your Own</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("settings.appearance.yourOwn")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium">Custom Colors</div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("settings.appearance.customColors")}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{THEME_VARIABLES.map(({ key, label }) => {
|
||||
const colorValue =
|
||||
@@ -744,11 +777,13 @@ export function SettingsDialog({
|
||||
|
||||
{/* Language Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Language</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.language.title")}
|
||||
</Label>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="language-select" className="text-sm">
|
||||
Interface Language
|
||||
{t("settings.language.interface")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedLanguage ?? "system"}
|
||||
@@ -758,10 +793,14 @@ export function SettingsDialog({
|
||||
disabled={isLanguageLoading}
|
||||
>
|
||||
<SelectTrigger id="language-select">
|
||||
<SelectValue placeholder="Select language" />
|
||||
<SelectValue
|
||||
placeholder={t("settings.language.selectLanguage")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="system">System Default</SelectItem>
|
||||
<SelectItem value="system">
|
||||
{t("settings.language.systemDefault")}
|
||||
</SelectItem>
|
||||
{supportedLanguages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.nativeName} ({lang.name})
|
||||
@@ -878,10 +917,11 @@ export function SettingsDialog({
|
||||
|
||||
{/* Integrations Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Integrations</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.integrations.title")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure Local API and MCP (Model Context Protocol) for
|
||||
integrating with external tools and AI assistants.
|
||||
{t("settings.integrations.description")}
|
||||
</p>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
@@ -1064,28 +1104,29 @@ export function SettingsDialog({
|
||||
{/* Commercial License Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
Commercial License
|
||||
{t("settings.commercial.title")}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/40">
|
||||
{trialStatus?.type === "Active" ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Trial: {trialStatus.days_remaining} days,{" "}
|
||||
{trialStatus.hours_remaining} hours remaining
|
||||
{t("settings.commercial.trialActive", {
|
||||
days: trialStatus.days_remaining,
|
||||
hours: trialStatus.hours_remaining,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Commercial use is free during the trial period
|
||||
{t("settings.commercial.trialActiveDescription")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-warning">
|
||||
Trial expired
|
||||
{t("settings.commercial.trialExpired")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Personal use remains free. Commercial use requires a
|
||||
license.
|
||||
{t("settings.commercial.trialExpiredDescription")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1094,7 +1135,9 @@ export function SettingsDialog({
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.advanced.title")}
|
||||
</Label>
|
||||
|
||||
{!isLinux && (
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg border">
|
||||
|
||||
@@ -74,6 +74,7 @@ function ObjectEditor({
|
||||
title,
|
||||
readOnly = false,
|
||||
}: ObjectEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [jsonString, setJsonString] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -111,7 +112,7 @@ function ObjectEditor({
|
||||
onChange={(e) => {
|
||||
handleChange(e.target.value);
|
||||
}}
|
||||
placeholder={`Enter ${title} as JSON`}
|
||||
placeholder={t("fingerprint.enterAsJson", { title })}
|
||||
className="font-mono text-sm"
|
||||
rows={6}
|
||||
disabled={readOnly}
|
||||
@@ -465,7 +466,9 @@ export function SharedCamoufoxConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Intel Mac OS X 10.15"
|
||||
placeholder={t(
|
||||
"config.camoufox.fingerprint.osCpuPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -904,7 +907,9 @@ export function SharedCamoufoxConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., llvmpipe, or similar"
|
||||
placeholder={t(
|
||||
"config.camoufox.fingerprint.webglRendererPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1010,7 +1015,7 @@ export function SharedCamoufoxConfigForm({
|
||||
selected.map((s: Option) => s.value),
|
||||
);
|
||||
}}
|
||||
placeholder="Add fonts..."
|
||||
placeholder={t("fingerprint.addFontsPlaceholder")}
|
||||
creatable
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!serverUrl) {
|
||||
showErrorToast("Please enter a server URL");
|
||||
showErrorToast(t("sync.config.serverUrlRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,18 +137,18 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const response = await fetch(healthUrl);
|
||||
if (response.ok) {
|
||||
setConnectionStatus("connected");
|
||||
showSuccessToast("Connection successful!");
|
||||
showSuccessToast(t("sync.config.connectionSuccess"));
|
||||
} else {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast("Server responded with an error");
|
||||
showErrorToast(t("sync.config.serverError"));
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast("Failed to connect to server");
|
||||
showErrorToast(t("sync.config.connectFailed"));
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [serverUrl]);
|
||||
}, [serverUrl, t]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -162,15 +162,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
showSuccessToast("Sync settings saved");
|
||||
showSuccessToast(t("sync.config.settingsSaved"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save sync settings:", error);
|
||||
showErrorToast("Failed to save settings");
|
||||
showErrorToast(t("sync.config.saveFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [serverUrl, token, onClose]);
|
||||
}, [serverUrl, token, onClose, t]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -187,14 +187,14 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setServerUrl("");
|
||||
setToken("");
|
||||
setConnectionStatus("unknown");
|
||||
showSuccessToast("Sync disconnected");
|
||||
showSuccessToast(t("sync.config.disconnected"));
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
showErrorToast("Failed to disconnect");
|
||||
showErrorToast(t("sync.config.disconnectFailed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleOpenLogin = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -152,6 +153,7 @@ export function TrafficDetailsDialog({
|
||||
profileId,
|
||||
profileName,
|
||||
}: TrafficDetailsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
|
||||
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
|
||||
|
||||
@@ -211,7 +213,9 @@ export function TrafficDetailsDialog({
|
||||
{payload.map((entry) => (
|
||||
<p key={String(entry.dataKey)} className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{entry.dataKey === "sent" ? "↑ Sent: " : "↓ Received: "}
|
||||
{entry.dataKey === "sent"
|
||||
? t("traffic.tooltipSent")
|
||||
: t("traffic.tooltipReceived")}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatBytesPerSecond(
|
||||
@@ -223,7 +227,7 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
// Top domains sorted by total traffic
|
||||
@@ -255,7 +259,7 @@ export function TrafficDetailsDialog({
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Traffic Details
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="text-muted-foreground font-normal ml-2">
|
||||
— {profileName}
|
||||
@@ -269,7 +273,9 @@ export function TrafficDetailsDialog({
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium">Bandwidth Over Time</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => {
|
||||
@@ -277,19 +283,21 @@ export function TrafficDetailsDialog({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px] h-8">
|
||||
<SelectValue placeholder="Time period" />
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1m">Last 1 min</SelectItem>
|
||||
<SelectItem value="5m">Last 5 min</SelectItem>
|
||||
<SelectItem value="30m">Last 30 min</SelectItem>
|
||||
<SelectItem value="1h">Last 1 hour</SelectItem>
|
||||
<SelectItem value="2h">Last 2 hours</SelectItem>
|
||||
<SelectItem value="4h">Last 4 hours</SelectItem>
|
||||
<SelectItem value="1d">Last 1 day</SelectItem>
|
||||
<SelectItem value="7d">Last 7 days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 days</SelectItem>
|
||||
<SelectItem value="all">All time</SelectItem>
|
||||
<SelectItem value="1m">{t("traffic.last1m")}</SelectItem>
|
||||
<SelectItem value="5m">{t("traffic.last5m")}</SelectItem>
|
||||
<SelectItem value="30m">{t("traffic.last30m")}</SelectItem>
|
||||
<SelectItem value="1h">{t("traffic.last1h")}</SelectItem>
|
||||
<SelectItem value="2h">{t("traffic.last2h")}</SelectItem>
|
||||
<SelectItem value="4h">{t("traffic.last4h")}</SelectItem>
|
||||
<SelectItem value="1d">{t("traffic.last1d")}</SelectItem>
|
||||
<SelectItem value="7d">{t("traffic.last7d")}</SelectItem>
|
||||
<SelectItem value="30d">{t("traffic.last30d")}</SelectItem>
|
||||
<SelectItem value="all">{t("traffic.allTime")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -393,7 +401,9 @@ export function TrafficDetailsDialog({
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Sent</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLegend")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
@@ -401,7 +411,7 @@ export function TrafficDetailsDialog({
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Received
|
||||
{t("traffic.receivedLegend")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -411,7 +421,12 @@ export function TrafficDetailsDialog({
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sent ({timePeriod === "all" ? "total" : timePeriod})
|
||||
{t("traffic.sentLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-1">
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
@@ -419,7 +434,12 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Received ({timePeriod === "all" ? "total" : timePeriod})
|
||||
{t("traffic.receivedLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-2">
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
@@ -427,7 +447,12 @@ export function TrafficDetailsDialog({
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requests ({timePeriod === "all" ? "total" : timePeriod})
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
@@ -438,38 +463,50 @@ export function TrafficDetailsDialog({
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t pt-4">
|
||||
<div>
|
||||
<span className="font-medium">All-time traffic:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeTraffic")}
|
||||
</span>{" "}
|
||||
{formatBytes(
|
||||
(stats?.total_bytes_sent ?? 0) +
|
||||
(stats?.total_bytes_received ?? 0),
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">All-time requests:</span>{" "}
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeRequests")}
|
||||
</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer about proxy/VPN traffic calculation */}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Note: If you are using a proxy, VPN, or similar service, your
|
||||
provider may calculate traffic differently due to encryption
|
||||
overhead and protocol differences.
|
||||
{t("traffic.proxyDisclaimer")}
|
||||
</p>
|
||||
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Top Domains by Traffic (
|
||||
{timePeriod === "all" ? "all time" : timePeriod})
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||
<span>Domain</span>
|
||||
<span className="text-right">Requests</span>
|
||||
<span className="text-right">Sent</span>
|
||||
<span className="text-right">Received</span>
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnSent")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnReceived")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
@@ -503,14 +540,22 @@ export function TrafficDetailsDialog({
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Top Domains by Requests (
|
||||
{timePeriod === "all" ? "all time" : timePeriod})
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="border rounded-md">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
|
||||
<span>Domain</span>
|
||||
<span className="text-right">Requests</span>
|
||||
<span className="text-right">Total Traffic</span>
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnTotal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
@@ -543,7 +588,7 @@ export function TrafficDetailsDialog({
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
Unique IPs ({stats.unique_ips.length})
|
||||
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
||||
</h3>
|
||||
<div className="border rounded-md p-3 max-h-[120px] overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -563,10 +608,8 @@ export function TrafficDetailsDialog({
|
||||
{/* No data state */}
|
||||
{!stats && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No traffic data available for this profile.</p>
|
||||
<p className="text-sm mt-1">
|
||||
Traffic data will appear after you launch the profile.
|
||||
</p>
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="text-sm mt-1">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuPipette } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -366,12 +367,13 @@ export const ColorPickerOutput = ({
|
||||
className: _className,
|
||||
...props
|
||||
}: ColorPickerOutputProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { mode, setMode } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Select onValueChange={setMode} value={mode}>
|
||||
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
|
||||
<SelectValue placeholder="Mode" />
|
||||
<SelectValue placeholder={t("common.labels.mode")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formats.map((format) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -39,13 +40,18 @@ export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search...",
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
className,
|
||||
disabled,
|
||||
}: ComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const resolvedPlaceholder = placeholder ?? t("common.buttons.select");
|
||||
const resolvedSearchPlaceholder =
|
||||
searchPlaceholder ?? t("common.buttons.search");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -58,15 +64,15 @@ export function Combobox({
|
||||
>
|
||||
{value
|
||||
? options.find((option) => option.value === value)?.label
|
||||
: placeholder}
|
||||
: resolvedPlaceholder}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandInput placeholder={resolvedSearchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No option found.</CommandEmpty>
|
||||
<CommandEmpty>{t("common.noResults")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
@@ -100,77 +106,3 @@ export function Combobox({
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
value: "next.js",
|
||||
label: "Next.js",
|
||||
},
|
||||
{
|
||||
value: "sveltekit",
|
||||
label: "SvelteKit",
|
||||
},
|
||||
{
|
||||
value: "nuxt.js",
|
||||
label: "Nuxt.js",
|
||||
},
|
||||
{
|
||||
value: "remix",
|
||||
label: "Remix",
|
||||
},
|
||||
{
|
||||
value: "astro",
|
||||
label: "Astro",
|
||||
},
|
||||
];
|
||||
|
||||
export function ComboboxDemo() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [value, setValue] = React.useState("");
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{value
|
||||
? frameworks.find((framework) => framework.value === value)?.label
|
||||
: "Select framework..."}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search framework..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No framework found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{frameworks.map((framework) => (
|
||||
<CommandItem
|
||||
key={framework.value}
|
||||
value={framework.value}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === framework.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{framework.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSearch } from "react-icons/lu";
|
||||
|
||||
import {
|
||||
@@ -30,19 +31,23 @@ function Command({
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t("common.commandPalette.title");
|
||||
const resolvedDescription =
|
||||
description ?? t("common.commandPalette.description");
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
<DialogTitle>{resolvedTitle}</DialogTitle>
|
||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RxCross2 } from "react-icons/rx";
|
||||
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
@@ -115,6 +116,7 @@ function DialogContent({
|
||||
transition = { type: "spring", stiffness: 150, damping: 25 },
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const initialRotation =
|
||||
from === "bottom" || from === "left" ? "20deg" : "-20deg";
|
||||
const isVertical = from === "top" || from === "bottom";
|
||||
@@ -166,7 +168,7 @@ function DialogContent({
|
||||
{children}
|
||||
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<RxCross2 />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t("common.buttons.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</motion.div>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -28,6 +29,7 @@ export function VpnCheckButton({
|
||||
setCheckingVpnId,
|
||||
disabled = false,
|
||||
}: VpnCheckButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [result, setResult] = React.useState<ProxyCheckResult | undefined>();
|
||||
|
||||
const handleCheck = React.useCallback(async () => {
|
||||
@@ -41,14 +43,14 @@ export function VpnCheckButton({
|
||||
setResult(checkResult);
|
||||
|
||||
if (checkResult.is_valid) {
|
||||
toast.success(`VPN "${vpnName}" configuration is valid`);
|
||||
toast.success(t("vpnCheck.valid", { name: vpnName }));
|
||||
} else {
|
||||
toast.error(`VPN "${vpnName}" configuration is invalid`);
|
||||
toast.error(t("vpnCheck.invalid", { name: vpnName }));
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`VPN check failed: ${errorMessage}`);
|
||||
toast.error(t("vpnCheck.failed", { error: errorMessage }));
|
||||
|
||||
setResult({
|
||||
ip: "",
|
||||
@@ -58,7 +60,7 @@ export function VpnCheckButton({
|
||||
} finally {
|
||||
setCheckingVpnId(null);
|
||||
}
|
||||
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId]);
|
||||
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId, t]);
|
||||
|
||||
const isCurrentlyChecking = checkingVpnId === vpnId;
|
||||
|
||||
@@ -85,23 +87,27 @@ export function VpnCheckButton({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentlyChecking ? (
|
||||
<p>Checking VPN config...</p>
|
||||
<p>{t("vpnCheck.tooltipChecking")}</p>
|
||||
) : result?.is_valid ? (
|
||||
<div className="space-y-1">
|
||||
<p>Configuration valid</p>
|
||||
<p>{t("vpnCheck.tooltipValid")}</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
{t("vpnCheck.tooltipChecked", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : result && !result.is_valid ? (
|
||||
<div>
|
||||
<p>Configuration invalid</p>
|
||||
<p>{t("vpnCheck.tooltipInvalid")}</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
{t("vpnCheck.tooltipChecked", {
|
||||
time: formatRelativeTime(result.timestamp),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Check VPN config validity</p>
|
||||
<p>{t("vpnCheck.tooltipDefault")}</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -74,6 +75,7 @@ export function VpnFormDialog({
|
||||
onClose,
|
||||
editingVpn,
|
||||
}: VpnFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [wireGuardForm, setWireGuardForm] =
|
||||
useState<WireGuardFormData>(defaultWireGuardForm);
|
||||
@@ -103,7 +105,7 @@ export function VpnFormDialog({
|
||||
const name = wireGuardForm.name.trim();
|
||||
|
||||
if (!name) {
|
||||
toast.error("VPN name is required");
|
||||
toast.error(t("vpns.form.nameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,12 +116,12 @@ export function VpnFormDialog({
|
||||
name,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("VPN updated successfully");
|
||||
toast.success(t("vpns.form.updated"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to update VPN: ${errorMessage}`);
|
||||
toast.error(t("vpns.form.updateFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -130,23 +132,23 @@ export function VpnFormDialog({
|
||||
wireGuardForm;
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("VPN name is required");
|
||||
toast.error(t("vpns.form.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!privateKey.trim()) {
|
||||
toast.error("Private key is required");
|
||||
toast.error(t("vpns.form.privateKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!address.trim()) {
|
||||
toast.error("Address is required");
|
||||
toast.error(t("vpns.form.addressRequired"));
|
||||
return;
|
||||
}
|
||||
if (!peerPublicKey.trim()) {
|
||||
toast.error("Peer public key is required");
|
||||
toast.error(t("vpns.form.peerPublicKeyRequired"));
|
||||
return;
|
||||
}
|
||||
if (!peerEndpoint.trim()) {
|
||||
toast.error("Peer endpoint is required");
|
||||
toast.error(t("vpns.form.peerEndpointRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,16 +161,16 @@ export function VpnFormDialog({
|
||||
configData,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("WireGuard VPN created successfully");
|
||||
toast.success(t("vpns.form.created"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to create VPN: ${errorMessage}`);
|
||||
toast.error(t("vpns.form.createFailed", { error: errorMessage }));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [editingVpn, wireGuardForm, onClose]);
|
||||
}, [editingVpn, wireGuardForm, onClose, t]);
|
||||
|
||||
const updateWireGuard = useCallback(
|
||||
(field: keyof WireGuardFormData, value: string) => {
|
||||
@@ -177,10 +179,12 @@ export function VpnFormDialog({
|
||||
[],
|
||||
);
|
||||
|
||||
const dialogTitle = editingVpn ? "Edit VPN" : "Create WireGuard VPN";
|
||||
const dialogTitle = editingVpn
|
||||
? t("vpns.form.titleEdit")
|
||||
: t("vpns.form.titleCreate");
|
||||
const dialogDescription = editingVpn
|
||||
? "Update the name of your VPN configuration."
|
||||
: "Enter your WireGuard interface and peer details.";
|
||||
? t("vpns.form.descEdit")
|
||||
: t("vpns.form.descCreate");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -193,14 +197,14 @@ export function VpnFormDialog({
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-name">Name</Label>
|
||||
<Label htmlFor="wg-name">{t("vpns.form.name")}</Label>
|
||||
<Input
|
||||
id="wg-name"
|
||||
value={wireGuardForm.name}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("name", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Home WireGuard"
|
||||
placeholder={t("vpns.form.namePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
@@ -208,47 +212,49 @@ export function VpnFormDialog({
|
||||
{!editingVpn && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-private-key">Private Key</Label>
|
||||
<Label htmlFor="wg-private-key">
|
||||
{t("vpns.form.privateKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-private-key"
|
||||
value={wireGuardForm.privateKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("privateKey", e.target.value);
|
||||
}}
|
||||
placeholder="Base64-encoded private key"
|
||||
placeholder={t("vpns.form.privateKeyPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-address">Address</Label>
|
||||
<Label htmlFor="wg-address">{t("vpns.form.address")}</Label>
|
||||
<Input
|
||||
id="wg-address"
|
||||
value={wireGuardForm.address}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("address", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 10.0.0.2/24"
|
||||
placeholder={t("vpns.form.addressPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-dns">DNS (optional)</Label>
|
||||
<Label htmlFor="wg-dns">{t("vpns.form.dnsOptional")}</Label>
|
||||
<Input
|
||||
id="wg-dns"
|
||||
value={wireGuardForm.dns}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("dns", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 1.1.1.1"
|
||||
placeholder={t("vpns.form.dnsPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-mtu">MTU (optional)</Label>
|
||||
<Label htmlFor="wg-mtu">{t("vpns.form.mtuOptional")}</Label>
|
||||
<Input
|
||||
id="wg-mtu"
|
||||
type="number"
|
||||
@@ -256,47 +262,53 @@ export function VpnFormDialog({
|
||||
onChange={(e) => {
|
||||
updateWireGuard("mtu", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 1420"
|
||||
placeholder={t("vpns.form.mtuPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-public-key">Peer Public Key</Label>
|
||||
<Label htmlFor="wg-peer-public-key">
|
||||
{t("vpns.form.peerPublicKey")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-public-key"
|
||||
value={wireGuardForm.peerPublicKey}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerPublicKey", e.target.value);
|
||||
}}
|
||||
placeholder="Base64-encoded peer public key"
|
||||
placeholder={t("vpns.form.peerPublicKeyPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-endpoint">Peer Endpoint</Label>
|
||||
<Label htmlFor="wg-peer-endpoint">
|
||||
{t("vpns.form.peerEndpoint")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-endpoint"
|
||||
value={wireGuardForm.peerEndpoint}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("peerEndpoint", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. vpn.example.com:51820"
|
||||
placeholder={t("vpns.form.peerEndpointPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-allowed-ips">Allowed IPs</Label>
|
||||
<Label htmlFor="wg-allowed-ips">
|
||||
{t("vpns.form.allowedIps")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-allowed-ips"
|
||||
value={wireGuardForm.allowedIps}
|
||||
onChange={(e) => {
|
||||
updateWireGuard("allowedIps", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 0.0.0.0/0, ::/0"
|
||||
placeholder={t("vpns.form.allowedIpsPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
@@ -304,7 +316,7 @@ export function VpnFormDialog({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-keepalive">
|
||||
Persistent Keepalive (optional)
|
||||
{t("vpns.form.keepaliveOptional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-keepalive"
|
||||
@@ -313,14 +325,14 @@ export function VpnFormDialog({
|
||||
onChange={(e) => {
|
||||
updateWireGuard("persistentKeepalive", e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 25"
|
||||
placeholder={t("vpns.form.keepalivePlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-preshared-key">
|
||||
Preshared Key (optional)
|
||||
{t("vpns.form.presharedKeyOptional")}
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-preshared-key"
|
||||
@@ -328,7 +340,7 @@ export function VpnFormDialog({
|
||||
onChange={(e) => {
|
||||
updateWireGuard("presharedKey", e.target.value);
|
||||
}}
|
||||
placeholder="Base64-encoded preshared key"
|
||||
placeholder={t("vpns.form.presharedKeyPlaceholder")}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
@@ -344,10 +356,12 @@ export function VpnFormDialog({
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton isLoading={isSubmitting} onClick={handleSubmit}>
|
||||
{editingVpn ? "Update VPN" : "Create VPN"}
|
||||
{editingVpn
|
||||
? t("vpns.form.updateButton")
|
||||
: t("vpns.form.createButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuShield, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
@@ -56,6 +57,7 @@ const detectVpnType = (
|
||||
};
|
||||
|
||||
export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
|
||||
@@ -81,25 +83,28 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
const processContent = useCallback((content: string, filename: string) => {
|
||||
const detection = detectVpnType(content, filename);
|
||||
if (!detection.isVpn) {
|
||||
toast.error("Content does not appear to be a valid VPN configuration");
|
||||
return;
|
||||
}
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: detection.type,
|
||||
endpoint: detection.endpoint,
|
||||
});
|
||||
const baseName = filename
|
||||
.replace(/\.conf$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${detection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
}, []);
|
||||
const processContent = useCallback(
|
||||
(content: string, filename: string) => {
|
||||
const detection = detectVpnType(content, filename);
|
||||
if (!detection.isVpn) {
|
||||
toast.error(t("vpns.import.invalidContent"));
|
||||
return;
|
||||
}
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: detection.type,
|
||||
endpoint: detection.endpoint,
|
||||
});
|
||||
const baseName = filename
|
||||
.replace(/\.conf$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${detection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
(file: File) => {
|
||||
@@ -109,11 +114,11 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
processContent(content, file.name);
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
toast.error(t("vpns.import.fileReadError"));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
},
|
||||
[processContent],
|
||||
[processContent, t],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
@@ -125,10 +130,10 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a WireGuard .conf file");
|
||||
toast.error(t("vpns.import.wrongFileType"));
|
||||
}
|
||||
},
|
||||
[handleFileRead],
|
||||
[handleFileRead, t],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
@@ -173,23 +178,22 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import VPN config",
|
||||
error instanceof Error ? error.message : t("vpns.import.failedGeneric"),
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [vpnPreview, vpnName]);
|
||||
}, [vpnPreview, vpnName, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import VPN Config</DialogTitle>
|
||||
<DialogTitle>{t("vpns.import.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" &&
|
||||
"Import a WireGuard (.conf) configuration file"}
|
||||
{step === "vpn-preview" && "Review the VPN configuration to import"}
|
||||
{step === "vpn-result" && "VPN import completed"}
|
||||
{step === "dropzone" && t("vpns.import.descDropzone")}
|
||||
{step === "vpn-preview" && t("vpns.import.descPreview")}
|
||||
{step === "vpn-result" && t("vpns.import.descResult")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -217,7 +221,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Drop a WireGuard .conf file here or click to browse
|
||||
{t("vpns.import.dropzonePrompt")}
|
||||
</p>
|
||||
<input
|
||||
id="vpn-file-input"
|
||||
@@ -232,7 +236,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Paste from clipboard with {modKey}+V
|
||||
{t("vpns.import.pasteHint", { modKey })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -243,21 +247,25 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<LuShield className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{vpnPreview.detectedType} Configuration
|
||||
{t("vpns.import.configurationLabel", {
|
||||
type: vpnPreview.detectedType,
|
||||
})}
|
||||
</div>
|
||||
{vpnPreview.endpoint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Endpoint: {vpnPreview.endpoint}
|
||||
{t("vpns.import.endpointLabel", {
|
||||
endpoint: vpnPreview.endpoint,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpn-name">VPN Name</Label>
|
||||
<Label htmlFor="vpn-name">{t("vpns.import.vpnNameLabel")}</Label>
|
||||
<Input
|
||||
id="vpn-name"
|
||||
placeholder="My VPN"
|
||||
placeholder={t("vpns.import.vpnNamePlaceholder")}
|
||||
value={vpnName}
|
||||
onChange={(e) => {
|
||||
setVpnName(e.target.value);
|
||||
@@ -266,7 +274,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Config Preview</Label>
|
||||
<Label>{t("vpns.import.configPreview")}</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)}
|
||||
@@ -287,7 +295,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<LuShield className="w-8 h-8 text-success" />
|
||||
<div>
|
||||
<div className="font-medium text-success">
|
||||
VPN Imported Successfully
|
||||
{t("vpns.import.importedSuccess")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vpnImportResult.name} ({vpnImportResult.vpn_type})
|
||||
@@ -297,7 +305,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-destructive">
|
||||
Import Failed
|
||||
{t("vpns.import.importFailed")}
|
||||
</div>
|
||||
<div className="text-sm text-destructive">
|
||||
{vpnImportResult.error}
|
||||
@@ -311,26 +319,28 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
{t("common.buttons.back")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
Import VPN
|
||||
{t("vpns.import.importButton")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
<RippleButton onClick={handleClose}>
|
||||
{t("vpns.import.doneButton")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -316,7 +316,9 @@ export function WayfernConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Win32, MacIntel, Linux x86_64"
|
||||
placeholder={t(
|
||||
"config.wayfern.fingerprint.platformPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -755,7 +757,9 @@ export function WayfernConfigForm({
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., 300 for EST (UTC-5)"
|
||||
placeholder={t(
|
||||
"config.wayfern.fingerprint.timezoneOffsetPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -841,7 +845,9 @@ export function WayfernConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Intel(R) HD Graphics"
|
||||
placeholder={t(
|
||||
"config.wayfern.fingerprint.webglRendererPlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -880,7 +886,7 @@ export function WayfernConfigForm({
|
||||
e.target.value || undefined,
|
||||
);
|
||||
}}
|
||||
placeholder="Enter a seed string for canvas fingerprint"
|
||||
placeholder={t("fingerprint.canvasNoiseSeedPlaceholder")}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fingerprint.canvasNoiseSeedDescription")}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -22,24 +23,25 @@ export function WayfernTermsDialog({
|
||||
isOpen,
|
||||
onAccepted,
|
||||
}: WayfernTermsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isAccepting, setIsAccepting] = useState(false);
|
||||
|
||||
const handleAccept = useCallback(async () => {
|
||||
setIsAccepting(true);
|
||||
try {
|
||||
await invoke("accept_wayfern_terms");
|
||||
showSuccessToast("Terms accepted successfully");
|
||||
showSuccessToast(t("wayfernTerms.acceptSuccess"));
|
||||
onAccepted();
|
||||
} catch (error) {
|
||||
console.error("Failed to accept terms:", error);
|
||||
showErrorToast("Failed to accept terms", {
|
||||
showErrorToast(t("wayfernTerms.acceptFailed"), {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Please try again",
|
||||
error instanceof Error ? error.message : t("wayfernTerms.tryAgain"),
|
||||
});
|
||||
} finally {
|
||||
setIsAccepting(false);
|
||||
}
|
||||
}, [onAccepted]);
|
||||
}, [onAccepted, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
@@ -56,16 +58,13 @@ export function WayfernTermsDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wayfern Terms and Conditions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Before using Donut Browser, you must read and agree to Wayfern's
|
||||
Terms and Conditions.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("wayfernTerms.title")}</DialogTitle>
|
||||
<DialogDescription>{t("wayfernTerms.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please review the Terms and Conditions at:
|
||||
{t("wayfernTerms.reviewLabel")}
|
||||
</p>
|
||||
<a
|
||||
href="https://wayfern.com/tos"
|
||||
@@ -76,13 +75,13 @@ export function WayfernTermsDialog({
|
||||
https://wayfern.com/tos
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
By clicking "I Accept", you agree to be bound by these terms.
|
||||
{t("wayfernTerms.agreeNotice")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<LoadingButton onClick={handleAccept} isLoading={isAccepting}>
|
||||
I Accept
|
||||
{t("wayfernTerms.acceptButton")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Platform = "macos" | "windows" | "linux";
|
||||
|
||||
@@ -13,6 +14,7 @@ function detectPlatform(): Platform {
|
||||
}
|
||||
|
||||
export function WindowDragArea() {
|
||||
const { t } = useTranslation();
|
||||
const [platform, setPlatform] = useState<Platform | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,7 +106,7 @@ export function WindowDragArea() {
|
||||
viewBox="0 0 10 1"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-label="Minimize"
|
||||
aria-label={t("common.window.minimize")}
|
||||
>
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
@@ -124,7 +126,7 @@ export function WindowDragArea() {
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label="Close"
|
||||
aria-label={t("common.buttons.close")}
|
||||
>
|
||||
<line x1="1" y1="1" x2="9" y2="9" />
|
||||
<line x1="9" y1="1" x2="1" y2="9" />
|
||||
|
||||
Reference in New Issue
Block a user