chore: i18n

This commit is contained in:
zhom
2026-04-28 23:50:56 +04:00
parent 97d01e4b54
commit f8ce56481f
62 changed files with 6520 additions and 1322 deletions
+19 -9
View File
@@ -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>
+1 -1
View File
@@ -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);
}
+11 -9
View File
@@ -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>
+57 -27
View File
@@ -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>
+68 -41
View File
@@ -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>
+11 -11
View File
@@ -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>
+161 -115
View File
@@ -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>
+3 -1
View File
@@ -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>
+7 -3
View File
@@ -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>
+20 -20
View File
@@ -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>
+11 -11
View File
@@ -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>
+25 -13
View File
@@ -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>
+1 -1
View File
@@ -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}
+51 -24
View File
@@ -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>
+67 -43
View File
@@ -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>
</>
)}
+44 -28
View File
@@ -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>
+15 -11
View File
@@ -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>
+38 -31
View File
@@ -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>
+20 -15
View File
@@ -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>
+51 -31
View File
@@ -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"
+20 -13
View File
@@ -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>
+33 -16
View File
@@ -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>
+19 -10
View File
@@ -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>
+22 -18
View File
@@ -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>
+5 -12
View File
@@ -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) => (
+67 -40
View File
@@ -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 &quot;{namePrefix || "Imported"} Proxy
1&quot;, &quot;{namePrefix || "Imported"} Proxy 2&quot;, 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>
+180 -99
View File
@@ -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
+17 -9
View File
@@ -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>
+102 -59
View File
@@ -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>
+11 -11
View File
@@ -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 {
+84 -41
View File
@@ -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>
+3 -1
View File
@@ -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) => (
+11 -79
View File
@@ -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>
);
}
+9 -4
View File
@@ -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 -1
View File
@@ -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>
+16 -10
View File
@@ -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>
+50 -36
View File
@@ -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>
+53 -43
View File
@@ -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>
+10 -4
View File
@@ -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")}
+11 -12
View File
@@ -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>
+4 -2
View File
@@ -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" />