Compare commits

..

14 Commits

Author SHA1 Message Date
github-actions[bot] 04297fc27d chore: update flake.nix for v0.22.5 [skip ci] (#328)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-29 21:54:30 +00:00
github-actions[bot] 1d404833ad docs: update CHANGELOG.md and README.md for v0.22.5 [skip ci] (#327)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-29 21:54:07 +00:00
andy f61a3905fa Merge pull request #326 from zhom/contributors-readme-action-EteKcTv4vm
docs(contributor): contributors readme action update
2026-04-29 22:26:06 +02:00
github-actions[bot] 79d8b83b57 docs(contributor): contrib-readme-action has updated readme 2026-04-29 20:23:54 +00:00
zhom e700b47b4c chore: version bump 2026-04-30 00:23:20 +04:00
zhom 57167b979f chore: copy 2026-04-30 00:23:20 +04:00
andy 571bfcb213 Merge pull request #325 from ThiagoMafra-Integrare/fix/missing-libxdo3-dependency
fix(deb,rpm): declare libxdo as runtime dependency
2026-04-29 19:20:42 +02:00
ThiagoMafra-Integrare 6721444822 fix(deb,rpm): declare libxdo as runtime dependency
Donut Browser uses libxdo at runtime (loaded via dlopen, not directly
linked), so Tauri's auto-dependency detection misses it. As a result,
the DEB/RPM packages install cleanly but launching the app fails
silently with:

  /usr/bin/donutbrowser: error while loading shared libraries:
  libxdo.so.3: cannot open shared object file: No such file or directory

Declare libxdo3 (Debian/Ubuntu) and libxdo (Fedora/openSUSE) explicitly
in bundle.linux.{deb,rpm}.depends so package managers pull the library
during install.
2026-04-29 10:49:33 -03:00
github-actions[bot] ef1dc3407f chore: update flake.nix for v0.22.4 [skip ci] (#324)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-28 21:24:36 +00:00
github-actions[bot] 1162f1e9f3 docs: update CHANGELOG.md and README.md for v0.22.4 [skip ci] (#323)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-28 21:24:17 +00:00
zhom 8d524e07f4 chore: version bump 2026-04-28 23:55:15 +04:00
zhom f8ce56481f chore: i18n 2026-04-28 23:50:56 +04:00
github-actions[bot] 97d01e4b54 chore: update flake.nix for v0.22.3 [skip ci] (#321)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 19:59:16 +00:00
github-actions[bot] 5980ce5e8d docs: update CHANGELOG.md and README.md for v0.22.3 [skip ci] (#320)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-27 19:58:56 +00:00
75 changed files with 7444 additions and 1987 deletions
+10
View File
@@ -60,6 +60,16 @@ donutbrowser/
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
## Translations (mandatory)
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
- Adding a new string means adding the key to ALL seven locale files in `src/i18n/locales/` (en, es, fr, ja, pt, ru, zh) — not just `en.json`. The English version alone is incomplete work.
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
## Singletons
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
+34
View File
@@ -1,6 +1,40 @@
# Changelog
## v0.22.5 (2026-04-29)
### Bug Fixes
- declare libxdo as runtime dependency
### Maintenance
- chore: version bump
- chore: copy
- chore: update flake.nix for v0.22.4 [skip ci] (#324)
## v0.22.4 (2026-04-28)
### Maintenance
- chore: version bump
- chore: i18n
- chore: update flake.nix for v0.22.3 [skip ci] (#321)
## v0.22.3 (2026-04-27)
### Bug Fixes
- correct browser port mapping
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.22.2 [skip ci] (#315)
## v0.22.2 (2026-04-27)
### Refactoring
+12 -5
View File
@@ -51,7 +51,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_x64.dmg) |
Or install via Homebrew:
@@ -61,15 +61,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut-0.22.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut-0.22.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut-0.22.5-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut-0.22.5-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
@@ -160,6 +160,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<br />
<sub><b>Jory Severijnse</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ThiagoMafra-Integrare">
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
<br />
<sub><b>Thiago Mafra</b></sub>
</a>
</td>
</tr>
<tbody>
+5 -5
View File
@@ -94,17 +94,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.22.2";
releaseVersion = "0.22.5";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_amd64.AppImage";
hash = "sha256-90JcXImed7Ct+RYY41iG96ytFsGHAvfeNGlpjuGkeQI=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_amd64.AppImage";
hash = "sha256-709vcQ3SsFxsZEmDkuamlbHVsbFhGBAb3x59YvTehl4=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.2/Donut_0.22.2_aarch64.AppImage";
hash = "sha256-46QFVm4OgbZgmkCiHcMJjv3O1sAGlelAEirhycmKlLo=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.22.5/Donut_0.22.5_aarch64.AppImage";
hash = "sha256-T7ZrRvo7gM5mnzmXfLQXVMekf28jVOgFlfAAi89huMY=";
}
else
null;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.22.3",
"version": "0.22.5",
"type": "module",
"scripts": {
"dev": "next dev --turbopack -p 12341",
+1 -1
View File
@@ -1780,7 +1780,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.22.3"
version = "0.22.5"
dependencies = [
"aes 0.9.0",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.22.3"
version = "0.22.5"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+1 -1
View File
@@ -1232,7 +1232,7 @@ pub fn run() {
#[allow(unused_variables)]
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
.title("Donut Browser")
.inner_size(800.0, 500.0)
.inner_size(840.0, 500.0)
.resizable(false)
.fullscreen(false)
.center()
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.22.3",
"version": "0.22.5",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -42,11 +42,11 @@
"linux": {
"deb": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"]
"depends": ["xdg-utils", "libxdo3"]
},
"rpm": {
"desktopTemplate": "donutbrowser.desktop",
"depends": ["xdg-utils"]
"depends": ["xdg-utils", "libxdo"]
},
"appimage": {
"files": {
+128 -101
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
@@ -67,6 +68,7 @@ interface PendingUrl {
}
export default function Home() {
const { t } = useTranslation();
// Mount global version update listener/toasts
useVersionUpdater();
@@ -428,9 +430,7 @@ export default function Home() {
"Received show create profile dialog request:",
event.payload,
);
showErrorToast(
"No profiles available. Please create a profile first before opening URLs.",
);
showErrorToast(t("errors.noProfilesForUrl"));
setCreateProfileDialogOpen(true);
});
@@ -455,7 +455,7 @@ export default function Home() {
} catch (error) {
console.error("Failed to setup URL listener:", error);
}
}, [handleUrlOpen]);
}, [handleUrlOpen, t]);
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
setCurrentProfileForCamoufoxConfig(profile);
@@ -474,12 +474,14 @@ export default function Home() {
} catch (err: unknown) {
console.error("Failed to update camoufox config:", err);
showErrorToast(
`Failed to update camoufox config: ${JSON.stringify(err)}`,
t("errors.updateCamoufoxConfigFailed", {
error: JSON.stringify(err),
}),
);
throw err;
}
},
[],
[t],
);
const handleSaveWayfernConfig = useCallback(
@@ -494,12 +496,12 @@ export default function Home() {
} catch (err: unknown) {
console.error("Failed to update wayfern config:", err);
showErrorToast(
`Failed to update wayfern config: ${JSON.stringify(err)}`,
t("errors.updateWayfernConfigFailed", { error: JSON.stringify(err) }),
);
throw err;
}
},
[],
[t],
);
const handleCreateProfile = useCallback(
@@ -553,84 +555,92 @@ export default function Home() {
// No need to manually reload - useProfileEvents will handle the update
} catch (error) {
showErrorToast(
`Failed to create profile: ${
error instanceof Error ? error.message : String(error)
}`,
t("errors.createProfileFailed", {
error: error instanceof Error ? error.message : String(error),
}),
);
}
},
[selectedGroupId],
[selectedGroupId, t],
);
const launchProfile = useCallback(async (profile: BrowserProfile) => {
console.log("Starting launch for profile:", profile.name);
const launchProfile = useCallback(
async (profile: BrowserProfile) => {
console.log("Starting launch for profile:", profile.name);
// Show one-time warning about window resizing for fingerprinted browsers
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
try {
const dismissed = await invoke<boolean>(
"get_window_resize_warning_dismissed",
);
if (!dismissed) {
const proceed = await new Promise<boolean>((resolve) => {
windowResizeWarningResolver.current = resolve;
setWindowResizeWarningBrowserType(profile.browser);
setWindowResizeWarningOpen(true);
});
if (!proceed) {
return;
// Show one-time warning about window resizing for fingerprinted browsers
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
try {
const dismissed = await invoke<boolean>(
"get_window_resize_warning_dismissed",
);
if (!dismissed) {
const proceed = await new Promise<boolean>((resolve) => {
windowResizeWarningResolver.current = resolve;
setWindowResizeWarningBrowserType(profile.browser);
setWindowResizeWarningOpen(true);
});
if (!proceed) {
return;
}
}
} catch (error) {
console.error("Failed to check window resize warning:", error);
}
} catch (error) {
console.error("Failed to check window resize warning:", error);
}
}
try {
const result = await invoke<BrowserProfile>("launch_browser_profile", {
profile,
});
console.log("Successfully launched profile:", result.name);
} catch (err: unknown) {
console.error("Failed to launch browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to launch browser: ${errorMessage}`);
throw err;
}
}, []);
try {
const result = await invoke<BrowserProfile>("launch_browser_profile", {
profile,
});
console.log("Successfully launched profile:", result.name);
} catch (err: unknown) {
console.error("Failed to launch browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(
t("errors.launchBrowserFailed", { error: errorMessage }),
);
throw err;
}
},
[t],
);
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
setCloneProfile(profile);
}, []);
const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => {
console.log("Attempting to delete profile:", profile.name);
const handleDeleteProfile = useCallback(
async (profile: BrowserProfile) => {
console.log("Attempting to delete profile:", profile.name);
try {
// First check if the browser is running for this profile
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
try {
// First check if the browser is running for this profile
const isRunning = await invoke<boolean>("check_browser_status", {
profile,
});
if (isRunning) {
if (isRunning) {
showErrorToast(t("errors.cannotDeleteRunningProfile"));
return;
}
// Attempt to delete the profile
await invoke("delete_profile", { profileId: profile.id });
console.log("Profile deletion command completed successfully");
// No need to manually reload - useProfileEvents will handle the update
console.log("Profile deleted successfully");
} catch (err: unknown) {
console.error("Failed to delete profile:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(
"Cannot delete profile while browser is running. Please stop the browser first.",
t("errors.deleteProfileFailed", { error: errorMessage }),
);
return;
}
// Attempt to delete the profile
await invoke("delete_profile", { profileId: profile.id });
console.log("Profile deletion command completed successfully");
// No need to manually reload - useProfileEvents will handle the update
console.log("Profile deleted successfully");
} catch (err: unknown) {
console.error("Failed to delete profile:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to delete profile: ${errorMessage}`);
}
}, []);
},
[t],
);
const handleRenameProfile = useCallback(
async (profileId: string, newName: string) => {
@@ -639,28 +649,33 @@ export default function Home() {
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
console.error("Failed to rename profile:", err);
showErrorToast(`Failed to rename profile: ${JSON.stringify(err)}`);
showErrorToast(
t("errors.renameProfileFailed", { error: JSON.stringify(err) }),
);
throw err;
}
},
[],
[t],
);
const handleKillProfile = useCallback(async (profile: BrowserProfile) => {
console.log("Starting kill for profile:", profile.name);
const handleKillProfile = useCallback(
async (profile: BrowserProfile) => {
console.log("Starting kill for profile:", profile.name);
try {
await invoke("kill_browser_profile", { profile });
console.log("Successfully killed profile:", profile.name);
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
console.error("Failed to kill browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(`Failed to kill browser: ${errorMessage}`);
// Re-throw the error so the table component can handle loading state cleanup
throw err;
}
}, []);
try {
await invoke("kill_browser_profile", { profile });
console.log("Successfully killed profile:", profile.name);
// No need to manually reload - useProfileEvents will handle the update
} catch (err: unknown) {
console.error("Failed to kill browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
showErrorToast(t("errors.killBrowserFailed", { error: errorMessage }));
// Re-throw the error so the table component can handle loading state cleanup
throw err;
}
},
[t],
);
const handleDeleteSelectedProfiles = useCallback(
async (profileIds: string[]) => {
@@ -670,11 +685,13 @@ export default function Home() {
} catch (err: unknown) {
console.error("Failed to delete selected profiles:", err);
showErrorToast(
`Failed to delete selected profiles: ${JSON.stringify(err)}`,
t("errors.deleteSelectedProfilesFailed", {
error: JSON.stringify(err),
}),
);
}
},
[],
[t],
);
const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => {
@@ -701,12 +718,14 @@ export default function Home() {
} catch (error) {
console.error("Failed to delete selected profiles:", error);
showErrorToast(
`Failed to delete selected profiles: ${JSON.stringify(error)}`,
t("errors.deleteSelectedProfilesFailed", {
error: JSON.stringify(error),
}),
);
} finally {
setIsBulkDeleting(false);
}
}, [selectedProfiles]);
}, [selectedProfiles, t]);
const handleBulkGroupAssignment = useCallback(() => {
if (selectedProfiles.length === 0) return;
@@ -749,14 +768,12 @@ export default function Home() {
(p.browser === "wayfern" || p.browser === "camoufox"),
);
if (eligibleProfiles.length === 0) {
showErrorToast(
"Cookie copy only works with Wayfern and Camoufox profiles",
);
showErrorToast(t("errors.cookieCopyUnsupportedBrowser"));
return;
}
setSelectedProfilesForCookies(eligibleProfiles.map((p) => p.id));
setCookieCopyDialogOpen(true);
}, [selectedProfiles, profiles]);
}, [selectedProfiles, profiles, t]);
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
setSelectedProfilesForCookies([profile.id]);
@@ -804,10 +821,10 @@ export default function Home() {
});
} catch (error) {
console.error("Failed to toggle sync:", error);
showErrorToast("Failed to update sync settings");
showErrorToast(t("errors.updateSyncSettingsFailed"));
}
},
[],
[t],
);
useEffect(() => {
@@ -825,19 +842,22 @@ export default function Home() {
const { profile_id, status, error, profile_name } = event.payload;
const toastId = `sync-${profile_id}`;
const profile = profiles.find((p) => p.id === profile_id);
const name = profile_name || profile?.name || "Unknown";
const name =
profile_name || profile?.name || t("common.labels.unknownProfile");
if (status === "synced") {
dismissToast(toastId);
if (profilesWithTransfer.has(profile_id)) {
profilesWithTransfer.delete(profile_id);
showSuccessToast(`Profile '${name}' synced successfully`);
showSuccessToast(t("sync.toast.profileSynced", { name }));
}
} else if (status === "error") {
dismissToast(toastId);
profilesWithTransfer.delete(profile_id);
showErrorToast(
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
error
? t("sync.toast.profileSyncFailedWithError", { name, error })
: t("sync.toast.profileSyncFailed", { name }),
);
}
});
@@ -857,7 +877,10 @@ export default function Home() {
const payload = event.payload;
const toastId = `sync-${payload.profile_id}`;
const profile = profiles.find((p) => p.id === payload.profile_id);
const name = payload.profile_name || profile?.name || "Unknown";
const name =
payload.profile_name ||
profile?.name ||
t("common.labels.unknownProfile");
if (
payload.phase === "started" ||
@@ -889,7 +912,7 @@ export default function Home() {
if (unlistenStatus) unlistenStatus();
if (unlistenProgress) unlistenProgress();
};
}, [profiles]);
}, [profiles, t]);
useEffect(() => {
// Check for startup default browser prompt
@@ -1047,7 +1070,7 @@ export default function Home() {
return (
<div className="grid items-center justify-items-center min-h-screen gap-8 font-(family-name:--font-geist-sans) bg-background">
<main className="flex flex-col items-center w-full max-w-3xl">
<main className="flex flex-col items-center w-full max-w-4xl px-3">
<div className="w-full">
<HomeHeader
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
@@ -1272,9 +1295,13 @@ export default function Home() {
setShowBulkDeleteConfirmation(false);
}}
onConfirm={confirmBulkDelete}
title="Delete Selected Profiles"
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
confirmButtonText={`Delete ${selectedProfiles.length} Profile${selectedProfiles.length !== 1 ? "s" : ""}`}
title={t("profiles.bulkDelete.title")}
description={t("profiles.bulkDelete.description", {
count: selectedProfiles.length,
})}
confirmButtonText={t("profiles.bulkDelete.confirmButton", {
count: selectedProfiles.length,
})}
isLoading={isBulkDeleting}
profileIds={selectedProfiles}
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
+7 -5
View File
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { FaExternalLinkAlt, FaTimes } from "react-icons/fa";
import { LuCheckCheck } from "react-icons/lu";
import { Button } from "@/components/ui/button";
@@ -19,6 +20,7 @@ export function AppUpdateToast({
onDismiss,
updateReady = false,
}: AppUpdateToastProps) {
const { t } = useTranslation();
const handleRestartClick = async () => {
await onRestart();
};
@@ -43,10 +45,10 @@ export function AppUpdateToast({
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-foreground">
{updateReady
? "Update ready, restart to apply"
? t("appUpdate.toast.updateReady")
: updateInfo.repo_update
? "Update available via package manager"
: "Manual download required"}
: t("appUpdate.toast.manualDownloadRequired")}
</span>
<div className="text-xs text-muted-foreground">
{updateInfo.current_version} {updateInfo.new_version}
@@ -71,7 +73,7 @@ export function AppUpdateToast({
className="flex gap-2 items-center text-xs"
>
<LuCheckCheck className="w-3 h-3" />
Restart Now
{t("appUpdate.toast.restartNow")}
</RippleButton>
) : (
!updateInfo.repo_update &&
@@ -82,7 +84,7 @@ export function AppUpdateToast({
className="flex gap-2 items-center text-xs"
>
<FaExternalLinkAlt className="w-3 h-3" />
View Release
{t("appUpdate.toast.viewRelease")}
</RippleButton>
)
)}
@@ -92,7 +94,7 @@ export function AppUpdateToast({
size="sm"
className="text-xs"
>
Later
{t("appUpdate.toast.later")}
</RippleButton>
</div>
</div>
+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>
+162 -116
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>
)}
@@ -940,7 +953,7 @@ export function CreateProfileDialog({
<div className="flex gap-3 items-center">
<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>
)}
@@ -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>
+9 -5
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>
@@ -292,7 +294,9 @@ export function UnifiedToast(props: ToastProps) {
"completed_files" in progress && (
<div className="mt-1">
<p className="text-xs text-muted-foreground">
{progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "}
{progress.phase === "uploading"
? t("appUpdate.toast.uploading")
: t("appUpdate.toast.downloading")}{" "}
{progress.completed_files}/{progress.total_files} files
{" \u2022 "}
{formatBytesCompact(progress.completed_bytes)} /{" "}
@@ -347,17 +351,17 @@ export function UnifiedToast(props: ToastProps) {
<>
{stage === "extracting" && (
<p className="mt-1 text-xs text-muted-foreground">
Extracting browser files... Please do not close the app.
{t("browserDownload.toast.extracting")}
</p>
)}
{stage === "verifying" && (
<p className="mt-1 text-xs text-muted-foreground">
Verifying browser files...
{t("browserDownload.toast.verifying")}
</p>
)}
{stage === "downloading (twilight rolling release)" && (
<p className="mt-1 text-xs text-muted-foreground">
Downloading rolling release build...
{t("browserDownload.toast.downloadingRolling")}
</p>
)}
</>
+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>
@@ -55,12 +55,12 @@ export function ExtensionGroupAssignmentDialog({
} catch (err) {
console.error("Failed to load extension groups:", err);
setError(
err instanceof Error ? err.message : "Failed to load extension groups",
err instanceof Error ? err.message : t("extensions.loadGroupsFailed"),
);
} finally {
setIsLoading(false);
}
}, []);
}, [t]);
const handleAssign = useCallback(async () => {
setIsAssigning(true);
@@ -79,7 +79,7 @@ export function ExtensionGroupAssignmentDialog({
} catch (err) {
console.error("Failed to assign extension group:", err);
const errorMessage =
err instanceof Error ? err.message : "Failed to assign extension group";
err instanceof Error ? err.message : t("extensions.assignGroupFailed");
setError(errorMessage);
toast.error(errorMessage);
} finally {
+208 -193
View File
@@ -50,36 +50,43 @@ 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,
): { 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("profileTable.syncTooltipSyncing"),
animate: true,
};
case "synced":
return {
color: "bg-success",
tooltip: item.last_sync
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
: "Synced",
? t("profileTable.syncTooltipSyncedAt", {
time: new Date(item.last_sync * 1000).toLocaleString(),
})
: t("profileTable.syncTooltipSynced"),
animate: false,
};
case "waiting":
return {
color: "bg-warning",
tooltip: "Waiting to sync",
tooltip: t("profileTable.syncTooltipWaiting"),
animate: false,
};
case "error":
return {
color: "bg-destructive",
tooltip: "Sync error",
tooltip: t("profileTable.syncTooltipError"),
animate: false,
};
default:
return {
color: "bg-muted-foreground",
tooltip: "Not synced",
tooltip: t("profileTable.syncTooltipNotSynced"),
animate: false,
};
}
@@ -674,6 +681,7 @@ export function ExtensionManagementDialog({
const syncDot = getSyncStatusDot(
ext,
extSyncStatus[ext.id],
t,
);
return (
<div
@@ -840,6 +848,7 @@ export function ExtensionManagementDialog({
const groupSyncDot = getSyncStatusDot(
group,
extSyncStatus[group.id],
t,
);
return (
@@ -995,7 +1004,7 @@ export function ExtensionManagementDialog({
}
}}
>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("extensions.editGroup")}</DialogTitle>
<DialogDescription>
@@ -1003,87 +1012,89 @@ export function ExtensionManagementDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("common.labels.name")}</Label>
<Input
value={editGroupName}
onChange={(e) => {
setEditGroupName(e.target.value);
}}
placeholder={t("extensions.groupNamePlaceholder")}
/>
</div>
{extensions.filter((e) => !editGroupExtensionIds.includes(e.id))
.length > 0 && (
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("extensions.addToGroup")}</Label>
<Select
value=""
onValueChange={(extId) => {
setEditGroupExtensionIds((prev) => [...prev, extId]);
<Label>{t("common.labels.name")}</Label>
<Input
value={editGroupName}
onChange={(e) => {
setEditGroupName(e.target.value);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("extensions.addToGroup")} />
</SelectTrigger>
<SelectContent>
{extensions
.filter((e) => !editGroupExtensionIds.includes(e.id))
.map((ext) => (
<SelectItem key={ext.id} value={ext.id}>
<div className="flex items-center gap-2">
{renderExtensionIcon(ext, "sm")}
{ext.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
placeholder={t("extensions.groupNamePlaceholder")}
/>
</div>
)}
<div className="space-y-2">
<Label>{t("extensions.groupExtensions")}</Label>
{editGroupExtensionIds.length === 0 ? (
<div className="text-sm text-muted-foreground py-2">
{t("extensions.noExtensionsInGroup")}
</div>
) : (
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{editGroupExtensionIds.map((extId) => {
const ext = extensions.find((e) => e.id === extId);
if (!ext) return null;
return (
<div
key={extId}
className="flex items-center gap-2 rounded-md border px-2 py-1.5"
>
{renderExtensionIcon(ext, "sm")}
<span className="text-sm flex-1 truncate min-w-0">
{ext.name}
</span>
{renderCompatIcons(ext.browser_compatibility)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() => {
setEditGroupExtensionIds((prev) =>
prev.filter((id) => id !== extId),
);
}}
>
<LuTrash2 className="w-3 h-3" />
</Button>
</div>
);
})}
{extensions.filter((e) => !editGroupExtensionIds.includes(e.id))
.length > 0 && (
<div className="space-y-2">
<Label>{t("extensions.addToGroup")}</Label>
<Select
value=""
onValueChange={(extId) => {
setEditGroupExtensionIds((prev) => [...prev, extId]);
}}
>
<SelectTrigger>
<SelectValue placeholder={t("extensions.addToGroup")} />
</SelectTrigger>
<SelectContent>
{extensions
.filter((e) => !editGroupExtensionIds.includes(e.id))
.map((ext) => (
<SelectItem key={ext.id} value={ext.id}>
<div className="flex items-center gap-2">
{renderExtensionIcon(ext, "sm")}
{ext.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label>{t("extensions.groupExtensions")}</Label>
{editGroupExtensionIds.length === 0 ? (
<div className="text-sm text-muted-foreground py-2">
{t("extensions.noExtensionsInGroup")}
</div>
) : (
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{editGroupExtensionIds.map((extId) => {
const ext = extensions.find((e) => e.id === extId);
if (!ext) return null;
return (
<div
key={extId}
className="flex items-center gap-2 rounded-md border px-2 py-1.5"
>
{renderExtensionIcon(ext, "sm")}
<span className="text-sm flex-1 truncate min-w-0">
{ext.name}
</span>
{renderCompatIcons(ext.browser_compatibility)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() => {
setEditGroupExtensionIds((prev) =>
prev.filter((id) => id !== extId),
);
}}
>
<LuTrash2 className="w-3 h-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Button
@@ -1117,7 +1128,7 @@ export function ExtensionManagementDialog({
}
}}
>
<DialogContent className="max-w-lg">
<DialogContent className="max-w-lg max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t("extensions.editExtension")}</DialogTitle>
<DialogDescription>
@@ -1125,123 +1136,127 @@ export function ExtensionManagementDialog({
</DialogDescription>
</DialogHeader>
{editingExtension && (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("common.labels.name")}</Label>
<Input
value={editExtensionName}
onChange={(e) => {
setEditExtensionName(e.target.value);
}}
placeholder={t("extensions.namePlaceholder")}
onKeyDown={(e) => {
if (e.key === "Enter") void handleUpdateExtension();
}}
/>
</div>
<ScrollArea className="overflow-y-auto flex-1 -mx-6 px-6">
{editingExtension && (
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("common.labels.name")}</Label>
<Input
value={editExtensionName}
onChange={(e) => {
setEditExtensionName(e.target.value);
}}
placeholder={t("extensions.namePlaceholder")}
onKeyDown={(e) => {
if (e.key === "Enter") void handleUpdateExtension();
}}
/>
</div>
{/* Metadata from manifest.json */}
<div className="rounded-md border p-3 space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
{t("extensions.metadata")}
</Label>
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
{editingExtension.version && (
<>
<span className="text-muted-foreground">
{t("extensions.version")}
</span>
<span>{editingExtension.version}</span>
</>
)}
{editingExtension.author && (
<>
<span className="text-muted-foreground">
{t("extensions.author")}
</span>
<span>{editingExtension.author}</span>
</>
)}
{editingExtension.description && (
<>
<span className="text-muted-foreground">
{t("common.labels.description")}
</span>
<span className="line-clamp-3">
{editingExtension.description}
</span>
</>
)}
<span className="text-muted-foreground">
{t("extensions.compatibility.label")}
</span>
<div className="flex items-center gap-1">
{renderCompatIcons(editingExtension.browser_compatibility)}
</div>
<span className="text-muted-foreground">
{t("common.labels.type")}
</span>
<span>.{editingExtension.file_type}</span>
{editingExtension.homepage_url && (
<>
<span className="text-muted-foreground">
{t("extensions.homepage")}
</span>
<a
href={editingExtension.homepage_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1 truncate"
>
<span className="truncate">
{editingExtension.homepage_url}
{/* Metadata from manifest.json */}
<div className="rounded-md border p-3 space-y-2">
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
{t("extensions.metadata")}
</Label>
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
{editingExtension.version && (
<>
<span className="text-muted-foreground">
{t("extensions.version")}
</span>
<LuExternalLink className="w-3 h-3 shrink-0" />
</a>
</>
)}
{!editingExtension.version &&
!editingExtension.author &&
!editingExtension.description &&
!editingExtension.homepage_url && (
<span className="col-span-2 text-muted-foreground text-xs">
{t("extensions.noMetadata")}
<span>{editingExtension.version}</span>
</>
)}
{editingExtension.author && (
<>
<span className="text-muted-foreground">
{t("extensions.author")}
</span>
<span>{editingExtension.author}</span>
</>
)}
{editingExtension.description && (
<>
<span className="text-muted-foreground">
{t("common.labels.description")}
</span>
<span className="line-clamp-3">
{editingExtension.description}
</span>
</>
)}
<span className="text-muted-foreground">
{t("extensions.compatibility.label")}
</span>
<div className="flex items-center gap-1">
{renderCompatIcons(
editingExtension.browser_compatibility,
)}
</div>
<span className="text-muted-foreground">
{t("common.labels.type")}
</span>
<span>.{editingExtension.file_type}</span>
{editingExtension.homepage_url && (
<>
<span className="text-muted-foreground">
{t("extensions.homepage")}
</span>
<a
href={editingExtension.homepage_url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline flex items-center gap-1 truncate"
>
<span className="truncate">
{editingExtension.homepage_url}
</span>
<LuExternalLink className="w-3 h-3 shrink-0" />
</a>
</>
)}
{!editingExtension.version &&
!editingExtension.author &&
!editingExtension.description &&
!editingExtension.homepage_url && (
<span className="col-span-2 text-muted-foreground text-xs">
{t("extensions.noMetadata")}
</span>
)}
</div>
</div>
{/* Re-upload */}
<div className="space-y-2">
<Label>{t("extensions.reupload")}</Label>
<div className="flex gap-2 items-center">
<RippleButton
size="sm"
variant="outline"
onClick={() =>
document.getElementById("ext-edit-file-input")?.click()
}
>
<LuUpload className="w-3 h-3 mr-1" />
{t("extensions.selectFile")}
</RippleButton>
<input
id="ext-edit-file-input"
type="file"
accept=".xpi,.crx,.zip"
className="hidden"
onChange={handleEditFileSelect}
/>
{pendingUpdateFile && (
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
{pendingUpdateFile.name}
</span>
)}
</div>
</div>
</div>
{/* Re-upload */}
<div className="space-y-2">
<Label>{t("extensions.reupload")}</Label>
<div className="flex gap-2 items-center">
<RippleButton
size="sm"
variant="outline"
onClick={() =>
document.getElementById("ext-edit-file-input")?.click()
}
>
<LuUpload className="w-3 h-3 mr-1" />
{t("extensions.selectFile")}
</RippleButton>
<input
id="ext-edit-file-input"
type="file"
accept=".xpi,.crx,.zip"
className="hidden"
onChange={handleEditFileSelect}
/>
{pendingUpdateFile && (
<span className="text-xs text-muted-foreground truncate max-w-[200px]">
{pendingUpdateFile.name}
</span>
)}
</div>
</div>
</div>
)}
)}
</ScrollArea>
<DialogFooter>
<Button
+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>
+2 -2
View File
@@ -139,7 +139,7 @@ export function GroupBadges({
return (
<div className="flex gap-2 mb-4">
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
Loading groups...
{t("groups.loading")}
</div>
</div>
);
@@ -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}
+52 -25
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>
@@ -266,7 +283,7 @@ export function GroupManagementDialog({
{/* Groups list */}
{isLoading ? (
<div className="text-sm text-muted-foreground">
{t("common.loading")}
{t("common.buttons.loading")}
</div>
) : groups.length === 0 ? (
<div className="text-sm text-muted-foreground">
@@ -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>
+68 -44
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{" "}
<strong>{getBrowserDisplayName(currentMappedBrowser)}</strong>{" "}
profile.
{t("importProfile.importedAs", {
browser: getBrowserDisplayName(currentMappedBrowser),
})}
</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>
+85 -44
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">
@@ -526,7 +536,11 @@ const TagsCell = React.memo<{
onChange={(opts) => void handleChange(opts)}
creatable
selectFirstItem={false}
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
placeholder={
effectiveTags.length === 0
? translate("profileTable.addTagsPlaceholder")
: ""
}
className={cn(
"bg-transparent border-0! focus-within:ring-0!",
"[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!",
@@ -630,6 +644,7 @@ const NoteCell = React.memo<{
setOpenNoteEditorFor,
setNoteOverrides,
}) => {
const { t } = useTranslation();
const effectiveNote: string | null = Object.hasOwn(
noteOverrides,
profile.id,
@@ -745,14 +760,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 +804,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 +1349,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 +1678,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 +1724,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 +1762,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 +1810,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 +1831,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 && (
@@ -1833,6 +1850,7 @@ export function ProfilesDataTable({
},
{
id: "actions",
size: 100,
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
@@ -1951,7 +1969,7 @@ export function ProfilesDataTable({
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
className={cn(
"min-w-[70px] h-7",
"min-w-[80px] h-7 px-3",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
isFollower && "border-accent",
@@ -1967,9 +1985,9 @@ export function ProfilesDataTable({
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
</div>
) : isRunning ? (
"Stop"
meta.t("profiles.actions.stop")
) : (
"Launch"
meta.t("profiles.actions.launch")
)}
</RippleButton>
</span>
@@ -1986,7 +2004,9 @@ export function ProfilesDataTable({
},
{
accessorKey: "name",
header: ({ column }) => {
size: 130,
header: ({ column, table }) => {
const meta = table.options.meta as TableMeta;
return (
<Button
variant="ghost"
@@ -1995,7 +2015,7 @@ export function ProfilesDataTable({
}}
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
>
Name
{meta.t("common.labels.name")}
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 w-4 h-4" />
) : column.getIsSorted() === "desc" ? (
@@ -2124,7 +2144,11 @@ export function ProfilesDataTable({
},
{
id: "tags",
header: "Tags",
size: 110,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.tagsHeader");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
@@ -2153,7 +2177,11 @@ export function ProfilesDataTable({
},
{
id: "note",
header: "Note",
size: 110,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileTable.noteHeader");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
@@ -2180,7 +2208,11 @@ export function ProfilesDataTable({
},
{
id: "proxy",
header: "Proxy / VPN",
size: 130,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.proxy");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
@@ -2218,7 +2250,7 @@ export function ProfilesDataTable({
? effectiveVpn.name
: effectiveProxy
? effectiveProxy.name
: "Not Selected";
: meta.t("profiles.table.notSelected");
const vpnBadge = effectiveVpn ? "WG" : null;
const tooltipText = hasAssignment ? displayName : null;
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
@@ -2299,8 +2331,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 +2340,9 @@ export function ProfilesDataTable({
}}
/>
<CommandList>
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
<CommandEmpty>
{t("createProfile.proxy.notFound")}
</CommandEmpty>
<CommandGroup>
<CommandItem
value="__none__"
@@ -2324,7 +2358,7 @@ export function ProfilesDataTable({
: "opacity-0",
)}
/>
None
{t("common.labels.none")}
</CommandItem>
{meta.storedProxies
.filter(
@@ -2357,7 +2391,7 @@ export function ProfilesDataTable({
))}
</CommandGroup>
{meta.vpnConfigs.length > 0 && (
<CommandGroup heading="VPNs">
<CommandGroup heading={t("profileTable.vpnsHeading")}>
{meta.vpnConfigs.map((vpn) => (
<CommandItem
key={vpn.id}
@@ -2390,7 +2424,9 @@ export function ProfilesDataTable({
)}
{meta.canCreateLocationProxy &&
meta.countries.length > 0 && (
<CommandGroup heading="Create by country">
<CommandGroup
heading={t("profileTable.createByCountryHeading")}
>
{meta.countries
.filter(
(c) =>
@@ -2466,6 +2502,7 @@ export function ProfilesDataTable({
const dot = getProfileSyncStatusDot(
profile,
liveStatus,
meta.t,
syncEntry?.error,
);
if (!dot) return null;
@@ -2507,7 +2544,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>
@@ -2551,7 +2590,7 @@ export function ProfilesDataTable({
platform === "macos" ? "h-[340px]" : "h-[280px]",
)}
>
<Table className="overflow-visible">
<Table className="overflow-visible table-fixed">
<TableHeader className="overflow-visible">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="overflow-visible">
@@ -2626,7 +2665,7 @@ export function ProfilesDataTable({
colSpan={columns.length}
className="h-24 text-center"
>
No profiles found.
{t("profiles.table.empty")}
</TableCell>
</TableRow>
)}
@@ -2639,9 +2678,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 +2741,7 @@ export function ProfilesDataTable({
<DataTableActionBarSelection table={table} />
{onBulkGroupAssignment && (
<DataTableActionBarAction
tooltip="Assign to Group"
tooltip={t("profiles.actionBar.assignToGroup")}
onClick={onBulkGroupAssignment}
size="icon"
>
@@ -2709,7 +2750,7 @@ export function ProfilesDataTable({
)}
{onBulkProxyAssignment && (
<DataTableActionBarAction
tooltip="Assign Proxy"
tooltip={t("profiles.actionBar.assignProxy")}
onClick={onBulkProxyAssignment}
size="icon"
>
@@ -2718,7 +2759,7 @@ export function ProfilesDataTable({
)}
{onBulkExtensionGroupAssignment && (
<DataTableActionBarAction
tooltip="Assign Extension Group"
tooltip={t("profiles.actionBar.assignExtensionGroup")}
onClick={onBulkExtensionGroupAssignment}
size="icon"
>
@@ -2727,7 +2768,7 @@ export function ProfilesDataTable({
)}
{onBulkCopyCookies && (
<DataTableActionBarAction
tooltip="Copy Cookies"
tooltip={t("profiles.actionBar.copyCookies")}
onClick={onBulkCopyCookies}
size="icon"
>
@@ -2736,7 +2777,7 @@ export function ProfilesDataTable({
)}
{onBulkDelete && (
<DataTableActionBarAction
tooltip="Delete"
tooltip={t("common.buttons.delete")}
onClick={onBulkDelete}
size="icon"
variant="destructive"
+31 -20
View File
@@ -1,7 +1,8 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import {
@@ -47,8 +48,17 @@ export function ProfileSelectorDialog({
runningProfiles: externalRunningProfiles,
isUpdating,
}: ProfileSelectorDialogProps) {
const { t } = useTranslation();
// Use the centralized profile events hook
const { profiles, runningProfiles: hookRunningProfiles } = useProfileEvents();
const { profiles: rawProfiles, runningProfiles: hookRunningProfiles } =
useProfileEvents();
const profiles = useMemo(
() =>
[...rawProfiles].sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
),
[rawProfiles],
);
// Use external runningProfiles if provided, otherwise use hook's runningProfiles
const runningProfiles = externalRunningProfiles ?? hookRunningProfiles;
@@ -146,11 +156,7 @@ export function ProfileSelectorDialog({
if (runningAvailableProfile) {
setSelectedProfile(runningAvailableProfile.name);
} else {
// Sort profiles by name and select first
const sortedProfiles = [...profiles].sort((a, b) =>
a.name.localeCompare(b.name),
);
setSelectedProfile(sortedProfiles[0].name);
setSelectedProfile(profiles[0].name);
}
}
}, [isOpen, profiles, selectedProfile, runningProfiles]);
@@ -159,17 +165,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 +187,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 +205,9 @@ export function ProfileSelectorDialog({
onValueChange={setSelectedProfile}
>
<SelectTrigger>
<SelectValue placeholder="Choose a profile" />
<SelectValue
placeholder={t("profileSelector.chooseAProfile")}
/>
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => {
@@ -241,12 +252,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 +265,7 @@ export function ProfileSelectorDialog({
variant="destructive"
className="text-xs"
>
Unavailable
{t("profileSelector.badgeUnavailable")}
</Badge>
)}
</div>
@@ -275,7 +286,7 @@ export function ProfileSelectorDialog({
<DialogFooter>
<RippleButton variant="outline" onClick={handleCancel}>
Cancel
{t("common.buttons.cancel")}
</RippleButton>
<Tooltip>
<TooltipTrigger asChild>
@@ -289,7 +300,7 @@ export function ProfileSelectorDialog({
!canOpenWithSelectedProfile()
}
>
Open
{t("profileSelector.openButton")}
</LoadingButton>
</span>
</TooltipTrigger>
+14 -31
View File
@@ -166,7 +166,7 @@ export function ProfileSyncDialog({
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
const formatLastSync = (timestamp?: number) => {
if (!timestamp) return t("common.labels.never", "Never");
if (!timestamp) return t("common.labels.never");
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
@@ -177,7 +177,7 @@ export function ProfileSyncDialog({
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.mode.title", "Profile Sync")}</DialogTitle>
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
<DialogDescription>
{t("sync.mode.description", {
name: profile.name,
@@ -194,9 +194,7 @@ export function ProfileSyncDialog({
<div className="grid gap-4 py-4">
{!hasConfig && (
<div className="p-3 text-sm rounded-md bg-muted">
<p className="mb-2">
{t("sync.mode.notConfigured", "Sync service not configured.")}
</p>
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
<Button
variant="outline"
size="sm"
@@ -205,7 +203,7 @@ export function ProfileSyncDialog({
onClose();
}}
>
{t("sync.mode.configureService", "Configure Sync Service")}
{t("sync.mode.configureService")}
</Button>
</div>
)}
@@ -222,13 +220,10 @@ export function ProfileSyncDialog({
<RadioGroupItem value="Disabled" id="sync-disabled" />
<Label htmlFor="sync-disabled" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.disabled", "Disabled")}
{t("sync.mode.disabled")}
</span>
<p className="text-sm text-muted-foreground">
{t(
"sync.mode.disabledDescription",
"No sync for this profile",
)}
{t("sync.mode.disabledDescription")}
</p>
</Label>
</div>
@@ -237,13 +232,10 @@ export function ProfileSyncDialog({
<RadioGroupItem value="Regular" id="sync-regular" />
<Label htmlFor="sync-regular" className="cursor-pointer">
<span className="font-medium">
{t("sync.mode.regular", "Regular Sync")}
{t("sync.mode.regular")}
</span>
<p className="text-sm text-muted-foreground">
{t(
"sync.mode.regularDescription",
"Fast sync, unencrypted",
)}
{t("sync.mode.regularDescription")}
</p>
</Label>
</div>
@@ -263,18 +255,12 @@ export function ProfileSyncDialog({
}
>
<span className="font-medium">
{t("sync.mode.encrypted", "E2E Encrypted Sync")}
{t("sync.mode.encrypted")}
</span>
<p className="text-sm text-muted-foreground">
{canUseEncryption
? t(
"sync.mode.encryptedDescription",
"Encrypted before upload. Server never sees plaintext data.",
)
: t(
"settings.encryption.requiresProOrOwner",
"Profile encryption is available for Pro users and team owners.",
)}
? t("sync.mode.encryptedDescription")
: t("settings.encryption.requiresProOrOwner")}
</p>
</Label>
</div>
@@ -284,15 +270,12 @@ export function ProfileSyncDialog({
!hasE2ePassword &&
userChangedMode && (
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
{t(
"sync.mode.noPasswordWarning",
"E2E password not set. Please set a password in Settings.",
)}
{t("sync.mode.noPasswordWarning")}
</div>
)}
<div className="space-y-2">
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label>
<Label>{t("sync.mode.lastSynced")}</Label>
<div className="flex gap-2 items-center">
<Badge variant="outline">
{formatLastSync(profile.last_sync)}
@@ -319,7 +302,7 @@ export function ProfileSyncDialog({
</Button>
{hasConfig && isSyncEnabled(profile) && (
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
{t("sync.mode.syncNow", "Sync Now")}
{t("sync.mode.syncNow")}
</LoadingButton>
)}
</DialogFooter>
+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>
+415 -344
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">
<DialogContent className="max-w-[min(95vw,1600px)] 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,14 +404,14 @@ 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>
<TabsContent value="proxies">
<TabsContent value="proxies" className="mt-4">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
@@ -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,183 +446,202 @@ 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">
<ScrollArea className="h-[240px]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="w-20">Usage</TableHead>
<TableHead className="w-24">Sync</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => {
const syncDot = getSyncStatusDot(
proxy,
proxySyncStatus[proxy.id],
proxySyncErrors[proxy.id],
);
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{proxy.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<div className="border rounded-md max-h-[240px] overflow-auto">
<Table className="min-w-max">
<TableHeader>
<TableRow>
<TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="whitespace-nowrap w-px">
{t("proxies.management.usage")}
</TableHead>
<TableHead className="whitespace-nowrap w-px">
{t("proxies.management.syncCol")}
</TableHead>
<TableHead className="whitespace-nowrap w-px">
{t("common.labels.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{storedProxies.map((proxy) => {
const syncDot = getSyncStatusDot(
proxy,
proxySyncStatus[proxy.id],
t,
proxySyncErrors[proxy.id],
);
return (
<TableRow key={proxy.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={proxy.sync_enabled}
onCheckedChange={() =>
void handleToggleSync(proxy)
}
disabled={
isTogglingSync[proxy.id] ||
proxyInUse[proxy.id]
}
/>
</div>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
{proxyInUse[proxy.id] ? (
<p>
Sync cannot be disabled while this
proxy is used by synced profiles
</p>
) : (
<p>
{proxy.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={
proxyCheckResults[proxy.id]
}
setCheckingProfileId={
setCheckingProxyId
}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
<Tooltip>
<TooltipTrigger asChild>
{proxy.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{proxyUsage[proxy.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={proxy.sync_enabled}
onCheckedChange={() =>
void handleToggleSync(proxy)
}
disabled={
isTogglingSync[proxy.id] ||
proxyInUse[proxy.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{proxyInUse[proxy.id] ? (
<p>
{t(
"proxies.management.syncCannotDisable",
)}
</p>
) : (
<p>
{proxy.sync_enabled
? t(
"proxies.management.disableSync",
)
: t(
"proxies.management.enableSync",
)}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<ProxyCheckButton
proxy={proxy}
profileId={proxy.id}
checkingProfileId={checkingProxyId}
cachedResult={proxyCheckResults[proxy.id]}
setCheckingProfileId={setCheckingProxyId}
onCheckComplete={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
onCheckFailed={(result) => {
setProxyCheckResults((prev) => ({
...prev,
[proxy.id]: result,
}));
}}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditProxy(proxy);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("proxies.management.editProxy")}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditProxy(proxy);
handleDeleteProxy(proxy);
}}
disabled={
(proxyUsage[proxy.id] ?? 0) > 0
}
>
<LuPencil className="w-4 h-4" />
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit proxy</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteProxy(proxy);
}}
disabled={
(proxyUsage[proxy.id] ?? 0) > 0
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{proxyUsage[proxy.id]} profile
{proxyUsage[proxy.id] > 1
? "s"
: ""}
</p>
) : (
<p>Delete proxy</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</span>
</TooltipTrigger>
<TooltipContent>
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
<p>
{(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>
{t(
"proxies.management.deleteProxy",
)}
</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
<TabsContent value="vpns">
<TabsContent value="vpns" className="mt-4">
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
@@ -606,7 +654,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,161 +663,180 @@ 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">
<ScrollArea className="h-[240px]">
<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>
</TableRow>
</TableHeader>
<TableBody>
{vpnConfigs.map((vpn) => {
const syncDot = getSyncStatusDot(
vpn,
vpnSyncStatus[vpn.id],
vpnSyncErrors[vpn.id],
);
return (
<TableRow key={vpn.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
{vpn.name}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">WG</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">
{vpnUsage[vpn.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<div className="border rounded-md max-h-[240px] overflow-auto">
<Table className="min-w-max">
<TableHeader>
<TableRow>
<TableHead>{t("common.labels.name")}</TableHead>
<TableHead className="whitespace-nowrap w-px">
{t("common.labels.type")}
</TableHead>
<TableHead className="whitespace-nowrap w-px">
{t("proxies.management.usage")}
</TableHead>
<TableHead className="whitespace-nowrap w-px">
{t("proxies.management.syncCol")}
</TableHead>
<TableHead className="whitespace-nowrap w-px">
{t("common.labels.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vpnConfigs.map((vpn) => {
const syncDot = getSyncStatusDot(
vpn,
vpnSyncStatus[vpn.id],
t,
vpnSyncErrors[vpn.id],
);
return (
<TableRow key={vpn.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={vpn.sync_enabled}
onCheckedChange={() =>
void handleToggleVpnSync(vpn)
}
disabled={
isTogglingVpnSync[vpn.id] ||
vpnInUse[vpn.id]
}
/>
</div>
<div
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
syncDot.animate
? "animate-pulse"
: ""
}`}
/>
</TooltipTrigger>
<TooltipContent>
{vpnInUse[vpn.id] ? (
<p>
Sync cannot be disabled while this
VPN is used by synced profiles
</p>
) : (
<p>
{vpn.sync_enabled
? "Disable sync"
: "Enable sync"}
</p>
)}
<p>{syncDot.tooltip}</p>
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<VpnCheckButton
vpnId={vpn.id}
vpnName={vpn.name}
checkingVpnId={checkingVpnId}
setCheckingVpnId={setCheckingVpnId}
/>
<Tooltip>
<TooltipTrigger asChild>
{vpn.name}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">WG</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary">
{vpnUsage[vpn.id] ?? 0}
</Badge>
</TableCell>
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Checkbox
checked={vpn.sync_enabled}
onCheckedChange={() =>
void handleToggleVpnSync(vpn)
}
disabled={
isTogglingVpnSync[vpn.id] ||
vpnInUse[vpn.id]
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
{vpnInUse[vpn.id] ? (
<p>
{t(
"vpns.management.syncCannotDisable",
)}
</p>
) : (
<p>
{vpn.sync_enabled
? t(
"proxies.management.disableSync",
)
: t(
"proxies.management.enableSync",
)}
</p>
)}
</TooltipContent>
</Tooltip>
</TableCell>
<TableCell>
<div className="flex gap-1">
<VpnCheckButton
vpnId={vpn.id}
vpnName={vpn.name}
checkingVpnId={checkingVpnId}
setCheckingVpnId={setCheckingVpnId}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditVpn(vpn);
}}
>
<LuPencil className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("vpns.management.editVpn")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleEditVpn(vpn);
handleDeleteVpn(vpn);
}}
disabled={
(vpnUsage[vpn.id] ?? 0) > 0
}
>
<LuPencil className="w-4 h-4" />
<LuTrash2 className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Edit VPN</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
onClick={() => {
handleDeleteVpn(vpn);
}}
disabled={
(vpnUsage[vpn.id] ?? 0) > 0
}
>
<LuTrash2 className="w-4 h-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
<p>
Cannot delete: in use by{" "}
{vpnUsage[vpn.id]} profile
{vpnUsage[vpn.id] > 1 ? "s" : ""}
</p>
) : (
<p>Delete VPN</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</ScrollArea>
</span>
</TooltipTrigger>
<TooltipContent>
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
<p>
{(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>
{t("vpns.management.deleteVpn")}
</p>
)}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
@@ -779,7 +846,7 @@ export function ProxyManagementDialog({
<DialogFooter>
<RippleButton variant="outline" onClick={onClose}>
Close
{t("common.buttons.close")}
</RippleButton>
</DialogFooter>
</DialogContent>
@@ -796,9 +863,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 +893,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>
+131 -128
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})
@@ -772,7 +811,7 @@ export function SettingsDialog({
</div>
<p className="text-xs text-muted-foreground">
Choose your preferred language for the application interface.
{t("settings.language.description")}
</p>
</div>
@@ -781,10 +820,12 @@ export function SettingsDialog({
<div className="space-y-4">
<div className="flex justify-between items-center">
<Label className="text-base font-medium">
Default Browser
{t("settings.defaultBrowser.title")}
</Label>
<Badge variant={isDefaultBrowser ? "default" : "secondary"}>
{isDefaultBrowser ? "Active" : "Inactive"}
{isDefaultBrowser
? t("common.status.active")
: t("common.status.inactive")}
</Badge>
</div>
@@ -800,13 +841,12 @@ export function SettingsDialog({
className="w-full"
>
{isDefaultBrowser
? "Already Default Browser"
: "Set as Default Browser"}
? t("settings.defaultBrowser.alreadyDefault")
: t("settings.defaultBrowser.setAsDefault")}
</LoadingButton>
<p className="text-xs text-muted-foreground">
When set as default, Donut Browser will handle web links and
allow you to choose which profile to use.
{t("settings.defaultBrowser.description")}
</p>
</div>
)}
@@ -815,12 +855,12 @@ export function SettingsDialog({
{isMacOS && (
<div className="space-y-4">
<Label className="text-base font-medium">
System Permissions
{t("settings.permissions.title")}
</Label>
{isLoadingPermissions ? (
<div className="text-sm text-muted-foreground">
Loading permissions...
{t("settings.permissions.loading")}
</div>
) : (
<div className="space-y-3">
@@ -878,17 +918,18 @@ 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"
className="w-full"
onClick={onIntegrationsOpen}
>
Open Integrations Settings
{t("integrations.openSettings")}
</RippleButton>
</div>
@@ -912,33 +953,24 @@ export function SettingsDialog({
{/* Sync Encryption Section */}
<div className="space-y-4">
<Label className="text-base font-medium">
{t("settings.encryption.title", "Sync Encryption")}
{t("settings.encryption.title")}
</Label>
<p className="text-xs text-muted-foreground">
{t(
"settings.encryption.description",
"Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
)}
{t("settings.encryption.description")}
</p>
{!canUseEncryption ? (
<p className="text-sm text-muted-foreground">
{t(
"settings.encryption.requiresProOrOwner",
"Profile encryption is available for Pro users and team owners.",
)}
{t("settings.encryption.requiresProOrOwner")}
</p>
) : hasE2ePassword ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant="default">
{t("settings.encryption.passwordSet", "Active")}
{t("settings.encryption.passwordSet")}
</Badge>
<span className="text-sm text-muted-foreground">
{t(
"settings.encryption.passwordSetDescription",
"E2E encryption password is set",
)}
{t("settings.encryption.passwordSetDescription")}
</span>
</div>
<div className="flex gap-2">
@@ -952,10 +984,7 @@ export function SettingsDialog({
setE2eError("");
}}
>
{t(
"settings.encryption.changePassword",
"Change Password",
)}
{t("settings.encryption.changePassword")}
</Button>
<Button
variant="destructive"
@@ -964,21 +993,13 @@ export function SettingsDialog({
try {
await invoke("delete_e2e_password");
setHasE2ePassword(false);
showSuccessToast(
t(
"settings.encryption.removed",
"Encryption password removed",
),
);
showSuccessToast(t("settings.encryption.removed"));
} catch (error) {
showErrorToast(String(error));
}
}}
>
{t(
"settings.encryption.removePassword",
"Remove Password",
)}
{t("settings.encryption.removePassword")}
</Button>
</div>
</div>
@@ -986,10 +1007,7 @@ export function SettingsDialog({
<div className="space-y-3">
<Input
type="password"
placeholder={t(
"settings.encryption.passwordPlaceholder",
"Password (min 8 characters)",
)}
placeholder={t("settings.encryption.passwordPlaceholder")}
value={e2ePassword}
onChange={(e) => {
setE2ePassword(e.target.value);
@@ -998,10 +1016,7 @@ export function SettingsDialog({
/>
<Input
type="password"
placeholder={t(
"settings.encryption.confirmPlaceholder",
"Confirm password",
)}
placeholder={t("settings.encryption.confirmPlaceholder")}
value={e2ePasswordConfirm}
onChange={(e) => {
setE2ePasswordConfirm(e.target.value);
@@ -1017,21 +1032,11 @@ export function SettingsDialog({
isLoading={isSavingE2e}
onClick={async () => {
if (e2ePassword.length < 8) {
setE2eError(
t(
"settings.encryption.passwordTooShort",
"Password must be at least 8 characters",
),
);
setE2eError(t("settings.encryption.passwordTooShort"));
return;
}
if (e2ePassword !== e2ePasswordConfirm) {
setE2eError(
t(
"settings.encryption.passwordMismatch",
"Passwords do not match",
),
);
setE2eError(t("settings.encryption.passwordMismatch"));
return;
}
setIsSavingE2e(true);
@@ -1043,10 +1048,7 @@ export function SettingsDialog({
setE2ePassword("");
setE2ePasswordConfirm("");
showSuccessToast(
t(
"settings.encryption.passwordSaved",
"Encryption password set",
),
t("settings.encryption.passwordSaved"),
);
} catch (error) {
showErrorToast(String(error));
@@ -1055,7 +1057,7 @@ export function SettingsDialog({
}
}}
>
{t("settings.encryption.setPassword", "Set Password")}
{t("settings.encryption.setPassword")}
</LoadingButton>
</div>
)}
@@ -1064,28 +1066,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 +1097,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">
@@ -1129,13 +1134,11 @@ export function SettingsDialog({
variant="outline"
className="w-full"
>
Clear All Version Cache
{t("settings.advanced.clearCache")}
</LoadingButton>
<p className="text-xs text-muted-foreground">
Clear all cached browser version data and refresh all browser
versions from their sources. This will force a fresh download of
version information for all browsers.
{t("settings.advanced.clearCacheDescription")}
</p>
</div>
@@ -1151,7 +1154,7 @@ export function SettingsDialog({
<DialogFooter className="shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
Cancel
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isSaving}
@@ -1162,7 +1165,7 @@ export function SettingsDialog({
}}
disabled={isLoading || !hasChanges}
>
Save Settings
{t("common.buttons.saveSettings")}
</LoadingButton>
</DialogFooter>
</DialogContent>
@@ -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>
+16 -12
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 {
@@ -452,7 +452,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
setShowToken(!showToken);
}}
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
aria-label={showToken ? "Hide token" : "Show token"}
aria-label={
showToken
? t("common.aria.hideToken")
: t("common.aria.showToken")
}
>
{showToken ? (
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
+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">
+6 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuCheck, LuCopy } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { showSuccessToast } from "@/lib/toast-utils";
@@ -26,6 +27,7 @@ export function CopyToClipboard({
className,
successMessage = "Copied to clipboard",
}: CopyToClipboardProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyToClipboard = useCallback(async () => {
@@ -47,9 +49,11 @@ export function CopyToClipboard({
size={size}
className={`relative ${className ?? ""}`}
onClick={copyToClipboard}
aria-label={copied ? "Copied" : "Copy to clipboard"}
aria-label={copied ? t("common.aria.copied") : t("common.aria.copy")}
>
<span className="sr-only">{copied ? "Copied" : "Copy"}</span>
<span className="sr-only">
{copied ? t("common.srOnly.copied") : t("common.srOnly.copy")}
</span>
<LuCopy
className={`h-4 w-4 transition-all duration-300 ${
copied ? "scale-0" : "scale-100"
+4 -2
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";
@@ -158,7 +160,7 @@ function DialogContent({
}}
transition={transition}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg",
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
className,
)}
{...props}
@@ -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" />
+32 -27
View File
@@ -3,12 +3,14 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AppUpdateToast } from "@/components/app-update-toast";
import { showToast } from "@/lib/toast-utils";
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
export function useAppUpdateNotifications() {
const { t } = useTranslation();
const [updateInfo, setUpdateInfo] = useState<AppUpdateInfo | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const [updateProgress, setUpdateProgress] =
@@ -60,32 +62,35 @@ export function useAppUpdateNotifications() {
}
}, [isClient]);
const handleAppUpdate = useCallback(async (appUpdateInfo: AppUpdateInfo) => {
try {
setIsUpdating(true);
setUpdateProgress({
stage: "downloading",
percentage: 0,
speed: undefined,
eta: undefined,
message: "Starting update...",
});
const handleAppUpdate = useCallback(
async (appUpdateInfo: AppUpdateInfo) => {
try {
setIsUpdating(true);
setUpdateProgress({
stage: "downloading",
percentage: 0,
speed: undefined,
eta: undefined,
message: "Starting update...",
});
await invoke("download_and_prepare_app_update", {
updateInfo: appUpdateInfo,
});
} catch (error) {
console.error("Failed to update app:", error);
showToast({
type: "error",
title: "Failed to update Donut Browser",
description: String(error),
duration: 6000,
});
setIsUpdating(false);
setUpdateProgress(null);
}
}, []);
await invoke("download_and_prepare_app_update", {
updateInfo: appUpdateInfo,
});
} catch (error) {
console.error("Failed to update app:", error);
showToast({
type: "error",
title: t("appUpdate.toast.updateFailed"),
description: String(error),
duration: 6000,
});
setIsUpdating(false);
setUpdateProgress(null);
}
},
[t],
);
const handleRestart = useCallback(async () => {
try {
@@ -94,12 +99,12 @@ export function useAppUpdateNotifications() {
console.error("Failed to restart app:", error);
showToast({
type: "error",
title: "Failed to restart",
title: t("appUpdate.toast.restartFailed"),
description: String(error),
duration: 6000,
});
}
}, []);
}, [t]);
const dismissAppUpdate = useCallback(() => {
if (!isClient) return;
+52 -21
View File
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import type { Event as TauriEvent } from "@tauri-apps/api/event";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
dismissToast,
@@ -106,11 +107,18 @@ export function useBrowserDownload() {
return githubReleases;
} catch (error) {
console.error("Failed to load versions:", error);
showErrorToast(`Failed to fetch ${browserName} versions`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
duration: 4000,
});
showErrorToast(
i18n.t("browserDownload.toast.fetchVersionsFailed", {
browser: browserName,
}),
{
description:
error instanceof Error
? error.message
: i18n.t("common.errors.unknown"),
duration: 4000,
},
);
throw error;
}
}, []);
@@ -146,10 +154,16 @@ export function useBrowserDownload() {
// Show notification about new versions if any were found
if (result.new_versions_count && result.new_versions_count > 0) {
showSuccessToast(
`Found ${result.new_versions_count} new ${browserName} versions!`,
i18n.t("browserDownload.toast.foundNewVersions", {
count: result.new_versions_count,
browser: browserName,
}),
{
duration: 3000,
description: `Total available: ${result.total_versions_count} versions`,
description: i18n.t(
"browserDownload.toast.totalAvailableVersions",
{ count: result.total_versions_count },
),
},
);
}
@@ -157,11 +171,18 @@ export function useBrowserDownload() {
return githubReleases;
} catch (error) {
console.error("Failed to load versions:", error);
showErrorToast(`Failed to fetch ${browserName} versions`, {
description:
error instanceof Error ? error.message : "Unknown error occurred",
duration: 4000,
});
showErrorToast(
i18n.t("browserDownload.toast.fetchVersionsFailed", {
browser: browserName,
}),
{
description:
error instanceof Error
? error.message
: i18n.t("common.errors.unknown"),
duration: 4000,
},
);
throw error;
}
}, []);
@@ -215,7 +236,7 @@ export function useBrowserDownload() {
// Dismiss any existing download toast and show error
dismissToast(`download-${browserStr}-${version}`);
let errorMessage = "Unknown error occurred";
let errorMessage = i18n.t("common.errors.unknown");
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === "string") {
@@ -226,10 +247,16 @@ export function useBrowserDownload() {
// Ensure the long-running download toast is dismissed, and show a finite error toast
dismissToast(`download-${browserStr}-${version}`);
showErrorToast(`Failed to download ${browserName} ${version}`, {
description: errorMessage,
duration: 8000,
});
showErrorToast(
i18n.t("browserDownload.toast.downloadFailed", {
browser: browserName,
version,
}),
{
description: errorMessage,
duration: 8000,
},
);
}
throw error;
} finally {
@@ -297,7 +324,7 @@ export function useBrowserDownload() {
).toFixed(1);
const etaText = progress.eta_seconds
? formatTime(progress.eta_seconds)
: "calculating...";
: i18n.t("browserDownload.toast.calculating");
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
showDownloadToast(
@@ -346,10 +373,14 @@ export function useBrowserDownload() {
);
setDownloadProgress(null);
showErrorToast(
`${browserName} ${progress.version}: extraction failed`,
i18n.t("browserDownload.toast.extractionFailed", {
browser: browserName,
version: progress.version,
}),
{
description:
"The corrupt file was deleted. It will be re-downloaded on next attempt.",
description: i18n.t(
"browserDownload.toast.extractionFailedDescription",
),
},
);
} else if (progress.stage === "completed") {
+2 -1
View File
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import i18n from "@/i18n";
export function useBrowserSupport() {
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
@@ -18,7 +19,7 @@ export function useBrowserSupport() {
setError(
err instanceof Error
? err.message
: "Failed to load supported browsers",
: i18n.t("errors.loadSupportedBrowsersFailed"),
);
} finally {
setIsLoading(false);
+4 -1
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import type { Extension, ExtensionGroup } from "@/types";
export function useExtensionEvents() {
@@ -47,7 +48,9 @@ export function useExtensionEvents() {
} catch (err) {
console.error("Failed to setup extension event listeners:", err);
setError(
`Failed to setup extension event listeners: ${JSON.stringify(err)}`,
i18n.t("errors.setupExtensionListenersFailed", {
error: JSON.stringify(err),
}),
);
} finally {
setIsLoading(false);
+7 -2
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import type { GroupWithCount } from "@/types";
/**
@@ -23,7 +24,9 @@ export function useGroupEvents() {
setError(null);
} catch (err: unknown) {
console.error("Failed to load groups:", err);
setError(`Failed to load groups: ${JSON.stringify(err)}`);
setError(
i18n.t("errors.loadGroupsFailed", { error: JSON.stringify(err) }),
);
}
}, []);
@@ -65,7 +68,9 @@ export function useGroupEvents() {
} catch (err) {
console.error("Failed to setup group event listeners:", err);
setError(
`Failed to setup group event listeners: ${JSON.stringify(err)}`,
i18n.t("errors.setupGroupListenersFailed", {
error: JSON.stringify(err),
}),
);
} finally {
setIsLoading(false);
+7 -2
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import type { BrowserProfile, GroupWithCount } from "@/types";
interface UseProfileEventsReturn {
@@ -38,7 +39,9 @@ export function useProfileEvents(): UseProfileEventsReturn {
setError(null);
} catch (err: unknown) {
console.error("Failed to load profiles:", err);
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
setError(
i18n.t("errors.loadProfilesFailed", { error: JSON.stringify(err) }),
);
}
}, []);
@@ -101,7 +104,9 @@ export function useProfileEvents(): UseProfileEventsReturn {
} catch (err) {
console.error("Failed to setup profile event listeners:", err);
setError(
`Failed to setup profile event listeners: ${JSON.stringify(err)}`,
i18n.t("errors.setupProfileListenersFailed", {
error: JSON.stringify(err),
}),
);
} finally {
setIsLoading(false);
+7 -2
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import type { StoredProxy } from "@/types";
/**
@@ -40,7 +41,9 @@ export function useProxyEvents() {
setError(null);
} catch (err: unknown) {
console.error("Failed to load proxies:", err);
setError(`Failed to load proxies: ${JSON.stringify(err)}`);
setError(
i18n.t("errors.loadProxiesFailed", { error: JSON.stringify(err) }),
);
}
}, [loadProxyUsage]);
@@ -84,7 +87,9 @@ export function useProxyEvents() {
} catch (err) {
console.error("Failed to setup proxy event listeners:", err);
setError(
`Failed to setup proxy event listeners: ${JSON.stringify(err)}`,
i18n.t("errors.setupProxyListenersFailed", {
error: JSON.stringify(err),
}),
);
} finally {
setIsLoading(false);
+5 -1
View File
@@ -1,5 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useRef, useState } from "react";
import i18n from "@/i18n";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import { dismissToast, showToast } from "@/lib/toast-utils";
@@ -147,7 +148,10 @@ export function useUpdateNotifications(
showToast({
id: `auto-update-error-${browser}-${newVersion}`,
type: "error",
title: `Failed to download ${browserDisplayName} ${newVersion}`,
title: i18n.t("browserDownload.toast.downloadFailed", {
browser: browserDisplayName,
version: newVersion,
}),
description: String(downloadError),
duration: 8000,
});
+73 -28
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useRef, useState } from "react";
import i18n from "@/i18n";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
showAutoUpdateToast,
@@ -162,9 +163,14 @@ export function useVersionUpdater() {
);
showSuccessToast(
`${browserDisplayName} ${new_version} already available`,
i18n.t("versionUpdater.toast.alreadyAvailable", {
browser: browserDisplayName,
version: new_version,
}),
{
description: "Updating profile configurations...",
description: i18n.t(
"versionUpdater.toast.updatingProfiles",
),
duration: 3000,
},
);
@@ -187,25 +193,44 @@ export function useVersionUpdater() {
// Show success message based on whether profiles were updated
if (updatedProfiles.length > 0) {
const profileText =
const description =
updatedProfiles.length === 1
? `Profile "${updatedProfiles[0]}" has been updated`
: `${updatedProfiles.length} profiles have been updated`;
? i18n.t("versionUpdater.toast.singleProfileUpdated", {
name: updatedProfiles[0],
version: new_version,
})
: i18n.t("versionUpdater.toast.multipleProfilesUpdated", {
count: updatedProfiles.length,
version: new_version,
});
showSuccessToast(`${browserDisplayName} update completed`, {
description: `${profileText} to version ${new_version}. You can now launch your browsers with the latest version.`,
duration: 6000,
});
showSuccessToast(
i18n.t("versionUpdater.toast.updateCompleted", {
browser: browserDisplayName,
}),
{
description,
duration: 6000,
},
);
} else {
showSuccessToast(`${browserDisplayName} update completed`, {
description: `Version ${new_version} is now available. Running profiles will use the new version when restarted.`,
duration: 6000,
});
showSuccessToast(
i18n.t("versionUpdater.toast.updateCompleted", {
browser: browserDisplayName,
}),
{
description: i18n.t(
"versionUpdater.toast.versionAvailable",
{ version: new_version },
),
duration: 6000,
},
);
}
} catch (error) {
console.error("Failed to handle browser auto-update:", error);
let errorMessage = "Unknown error occurred";
let errorMessage = i18n.t("common.errors.unknown");
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === "string") {
@@ -218,10 +243,15 @@ export function useVersionUpdater() {
errorMessage = String(error.message);
}
showErrorToast(`Failed to auto-update ${browserDisplayName}`, {
description: errorMessage,
duration: 8000,
});
showErrorToast(
i18n.t("versionUpdater.toast.autoUpdateFailed", {
browser: browserDisplayName,
}),
{
description: errorMessage,
duration: 8000,
},
);
} finally {
// Remove from active downloads
activeDownloads.current.delete(downloadKey);
@@ -286,18 +316,27 @@ export function useVersionUpdater() {
).length;
if (failedUpdates > 0) {
showErrorToast("Update completed with some errors", {
description: `${totalNewVersions} new versions found, ${failedUpdates} browsers failed to update`,
showErrorToast(i18n.t("versionUpdater.toast.updateWithErrors"), {
description: i18n.t(
"versionUpdater.toast.updateWithErrorsDescription",
{
newVersions: totalNewVersions,
failedUpdates,
},
),
duration: 5000,
});
} else if (totalNewVersions > 0) {
showSuccessToast("Browser versions updated successfully", {
description: `Found ${totalNewVersions} new versions across ${successfulUpdates} browsers. Auto-downloads will start shortly.`,
showSuccessToast(i18n.t("versionUpdater.toast.updateSuccess"), {
description: i18n.t("versionUpdater.toast.updateSuccessDescription", {
newVersions: totalNewVersions,
successfulUpdates,
}),
duration: 4000,
});
} else {
showSuccessToast("No new browser versions found", {
description: "All browser versions are up to date",
showSuccessToast(i18n.t("versionUpdater.toast.upToDate"), {
description: i18n.t("versionUpdater.toast.upToDateDescription"),
duration: 3000,
});
}
@@ -306,7 +345,7 @@ export function useVersionUpdater() {
return results;
} catch (error) {
console.error("Failed to trigger manual update:", error);
let errorMessage = "Unknown error occurred";
let errorMessage = i18n.t("common.errors.unknown");
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === "string") {
@@ -315,7 +354,7 @@ export function useVersionUpdater() {
errorMessage = String(error.message);
}
showErrorToast("Failed to update browser versions", {
showErrorToast(i18n.t("versionUpdater.toast.updateAllFailed"), {
description: errorMessage,
duration: 4000,
});
@@ -337,10 +376,16 @@ export function useVersionUpdater() {
if (result.new_versions_count && result.new_versions_count > 0) {
const browserName = getBrowserDisplayName(browserStr);
showSuccessToast(
`Found ${result.new_versions_count} new ${browserName} versions!`,
i18n.t("browserDownload.toast.foundNewVersions", {
count: result.new_versions_count,
browser: browserName,
}),
{
duration: 3000,
description: `Total available: ${result.total_versions_count} versions`,
description: i18n.t(
"browserDownload.toast.totalAvailableVersions",
{ count: result.total_versions_count },
),
},
);
}
+9 -2
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import i18n from "@/i18n";
import type { VpnConfig } from "@/types";
/**
@@ -37,7 +38,9 @@ export function useVpnEvents() {
setError(null);
} catch (err: unknown) {
console.error("Failed to load VPN configs:", err);
setError(`Failed to load VPN configs: ${JSON.stringify(err)}`);
setError(
i18n.t("errors.loadVpnConfigsFailed", { error: JSON.stringify(err) }),
);
}
}, [loadVpnUsage]);
@@ -62,7 +65,11 @@ export function useVpnEvents() {
});
} catch (err) {
console.error("Failed to setup VPN event listeners:", err);
setError(`Failed to setup VPN event listeners: ${JSON.stringify(err)}`);
setError(
i18n.t("errors.setupVpnListenersFailed", {
error: JSON.stringify(err),
}),
);
} finally {
setIsLoading(false);
}
+711 -21
View File
@@ -28,7 +28,9 @@
"refresh": "Refresh",
"loading": "Loading...",
"saveSettings": "Save Settings",
"moreInfo": "More info"
"moreInfo": "More info",
"downloading": "Downloading...",
"minimize": "Minimize"
},
"status": {
"active": "Active",
@@ -56,7 +58,10 @@
"default": "Default",
"custom": "Custom",
"optional": "Optional",
"required": "Required"
"required": "Required",
"unknownProfile": "Unknown",
"mode": "Mode",
"never": "Never"
},
"time": {
"days": "days",
@@ -64,6 +69,33 @@
"minutes": "minutes",
"seconds": "seconds",
"remaining": "remaining"
},
"aria": {
"selectAll": "Select all",
"selectRow": "Select row",
"selectProfile": "Select profile",
"copy": "Copy to clipboard",
"copied": "Copied",
"showToken": "Show token",
"hideToken": "Hide token"
},
"keys": {
"escape": "Escape"
},
"errors": {
"unknown": "Unknown error occurred"
},
"window": {
"minimize": "Minimize"
},
"commandPalette": {
"title": "Command Palette",
"description": "Search for a command to run..."
},
"noResults": "No results found.",
"srOnly": {
"copy": "Copy",
"copied": "Copied"
}
},
"settings": {
@@ -85,7 +117,8 @@
"title": "Language",
"description": "Choose your preferred language for the application interface.",
"systemDefault": "System Default",
"selectLanguage": "Select language"
"selectLanguage": "Select language",
"interface": "Interface Language"
},
"defaultBrowser": {
"title": "Default Browser",
@@ -100,7 +133,8 @@
"microphone": "Microphone",
"microphoneDescription": "Access to microphone for browser applications",
"camera": "Camera",
"cameraDescription": "Access to camera for browser applications"
"cameraDescription": "Access to camera for browser applications",
"accessRequested": "{{permission}} access requested"
},
"integrations": {
"title": "Integrations",
@@ -134,7 +168,8 @@
"advanced": {
"title": "Advanced",
"clearCache": "Clear All Version Cache",
"clearCacheDescription": "Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers."
"clearCacheDescription": "Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers.",
"clearCacheFailed": "Failed to clear cache"
},
"disableAutoUpdates": "Disable App Auto Updates",
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected."
@@ -169,7 +204,9 @@
"note": "Note",
"group": "Group",
"proxy": "Proxy / VPN",
"lastLaunch": "Last Launch"
"lastLaunch": "Last Launch",
"empty": "No profiles found.",
"notSelected": "Not Selected"
},
"actions": {
"launch": "Launch",
@@ -205,7 +242,30 @@
"ephemeral": "Ephemeral",
"ephemeralDescription": "The browser is forced to write profile data into memory instead of disk. Data is deleted when the browser is closed.",
"ephemeralBadge": "Ephemeral",
"ephemeralAlpha": "Alpha"
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Delete Selected Profiles",
"description": "This action cannot be undone. This will permanently delete {{count}} profile(s) and all associated data.",
"confirmButton": "Delete {{count}} Profile(s)"
},
"note": {
"empty": "No Note",
"placeholder": "Add a note..."
},
"aria": {
"profileInfo": "Profile info"
},
"delete": {
"title": "Delete Profile",
"description": "This action cannot be undone. This will permanently delete the profile \"{{profileName}}\" and all its associated data.",
"confirmButton": "Delete Profile"
},
"actionBar": {
"assignToGroup": "Assign to Group",
"assignProxy": "Assign Proxy",
"assignExtensionGroup": "Assign Extension Group",
"copyCookies": "Copy Cookies"
}
},
"createProfile": {
"title": "Create New Profile",
@@ -228,7 +288,10 @@
"title": "Proxy / VPN",
"addProxy": "Add Proxy",
"noProxy": "No proxy / VPN",
"noProxiesAvailable": "No proxies or VPNs available. Add one to route this profile's traffic."
"noProxiesAvailable": "No proxies or VPNs available. Add one to route this profile's traffic.",
"search": "Search proxies or VPNs...",
"notFound": "No proxies or VPNs found.",
"searchWithCountries": "Search proxies, VPNs, or countries..."
},
"launchHook": {
"label": "Launch Hook URL",
@@ -248,7 +311,8 @@
"chromiumSubtitle": "Powered by Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Powered by Camoufox",
"camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium."
"camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium.",
"platformUnavailable": "{{browser}} is not available on your platform yet."
},
"deleteDialog": {
"title": "Delete Profile",
@@ -259,7 +323,31 @@
},
"proxies": {
"title": "Proxies",
"management": "Proxies & VPNs",
"management": {
"description": "Manage your proxy and VPN configurations for reuse across profiles",
"tabProxies": "Proxies",
"tabVpns": "VPNs",
"create": "Create",
"loading": "Loading proxies...",
"noneCreated": "No proxies created yet. Create your first proxy using the button above.",
"usage": "Usage",
"syncCol": "Sync",
"syncCannotDisable": "Sync cannot be disabled while this proxy is used by synced profiles",
"enableSync": "Enable sync",
"disableSync": "Disable sync",
"editProxy": "Edit proxy",
"deleteProxy": "Delete proxy",
"cannotDelete_one": "Cannot delete: in use by {{count}} profile",
"cannotDelete_other": "Cannot delete: in use by {{count}} profiles",
"syncEnabled": "Sync enabled",
"syncDisabled": "Sync disabled",
"updateSyncFailed": "Failed to update sync",
"deleteSuccess": "Proxy deleted successfully",
"deleteFailed": "Failed to delete proxy",
"deleteTitle": "Delete Proxy",
"deleteDescription": "This action cannot be undone. This will permanently delete the proxy \"{{name}}\".",
"title": "Proxies & VPNs"
},
"add": "Add Proxy",
"edit": "Edit Proxy",
"delete": "Delete Proxy",
@@ -280,7 +368,12 @@
"password": "Password",
"passwordPlaceholder": "Optional",
"cipher": "Cipher",
"cipherPlaceholder": "aes-256-gcm"
"cipherPlaceholder": "aes-256-gcm",
"nameRequired": "Proxy name is required",
"hostPortRequired": "Host and port are required",
"ssCipherRequired": "Cipher and password are required for Shadowsocks",
"selectType": "Select proxy type",
"saveFailed": "Failed to save proxy: {{error}}"
},
"types": {
"http": "HTTP",
@@ -318,6 +411,45 @@
"sync": {
"enabled": "Sync Enabled",
"disabled": "Sync Disabled"
},
"exportDialog": {
"title": "Export Proxies",
"description": "Export your proxy configurations to a file",
"format": "Export Format",
"json": "JSON",
"txt": "TXT (URL format)",
"preview": "Preview",
"noProxies": "No proxies to export",
"downloaded": "Downloaded {{filename}}",
"failed": "Failed to export proxies",
"copied": "Copied"
},
"importDialog": {
"title": "Import Proxies",
"descDropzone": "Import proxies from a JSON or TXT file",
"descPreview": "Review the proxies to import",
"descAmbiguous": "Some proxies have ambiguous formats. Please select the correct format.",
"descResult": "Import completed",
"dropzonePrompt": "Drop a proxy config file",
"dropzoneFormats": "(.json, .txt)",
"pasteHint": "Paste from clipboard with {{modKey}}+V",
"wrongFileType": "Please drop a .json or .txt file",
"fileReadError": "Failed to read file",
"fileProcessError": "Failed to process file",
"noValidProxies": "No valid proxies found in the file",
"namePrefix": "Name Prefix",
"namePrefixDefault": "Imported",
"namePrefixHint": "Proxies will be named \"{{prefix}} Proxy 1\", \"{{prefix}} Proxy 2\", etc.",
"proxiesToImport": "Proxies to import ({{count}})",
"invalidCount": "({{count}} invalid)",
"ambiguousIntro": "The following proxies have an ambiguous format. Please select the correct interpretation for each.",
"imported": "Imported:",
"skippedDuplicates": "Skipped (duplicates):",
"errors": "Errors",
"importButton": "Import {{count}} Proxies",
"continueButton": "Continue",
"doneButton": "Done",
"failed": "Failed to import proxies"
}
},
"groups": {
@@ -343,7 +475,31 @@
"sync": {
"enabled": "Sync Enabled",
"disabled": "Sync Disabled"
}
},
"createTitle": "Create New Group",
"createDescription": "Create a new group to organize your browser profiles.",
"editTitle": "Edit Group",
"editDescription": "Update the name of your group.",
"createSuccess": "Group created successfully",
"createFailed": "Failed to create group",
"updateSuccess": "Group updated successfully",
"updateFailed": "Failed to update group",
"deleteTitle": "Delete Group",
"deleteDescription": "This action cannot be undone. This will permanently delete the group.",
"deleteSuccess": "Group deleted successfully",
"deleteFailed": "Failed to delete group",
"loadingProfiles": "Loading associated profiles...",
"associatedProfiles": "Associated Profiles ({{count}})",
"whatToDoWithProfiles": "What should happen to these profiles?",
"moveToDefaultOption": "Move profiles to Default group",
"deleteAlongWithGroup": "Delete profiles along with the group",
"noAssociatedProfiles": "This group has no associated profiles.",
"deleteGroup": "Delete Group",
"deleteGroupAndProfiles": "Delete Group & Profiles",
"loadProfilesFailed": "Failed to load profiles",
"unknownGroup": "Unknown Group",
"profileGroupsAriaLabel": "Profile groups",
"loading": "Loading groups..."
},
"sync": {
"mode": {
@@ -366,7 +522,16 @@
"configureService": "Configure Sync Service"
},
"title": "Account",
"config": "Sync Configuration",
"config": {
"serverUrlRequired": "Please enter a server URL",
"connectionSuccess": "Connection successful!",
"serverError": "Server responded with an error",
"connectFailed": "Failed to connect to server",
"settingsSaved": "Sync settings saved",
"saveFailed": "Failed to save settings",
"disconnected": "Sync disconnected",
"disconnectFailed": "Failed to disconnect"
},
"serverUrl": "Server URL",
"serverUrlPlaceholder": "https://sync.example.com",
"token": "Sync Token",
@@ -410,6 +575,12 @@
"profileLockedShort": "In use",
"cannotLaunchLocked": "Cannot launch — profile is in use by {{email}}",
"createdBy": "Created by {{email}}"
},
"disabled": "Disabled",
"toast": {
"profileSynced": "Profile '{{name}}' synced successfully",
"profileSyncFailed": "Failed to sync profile '{{name}}'",
"profileSyncFailedWithError": "Failed to sync profile '{{name}}': {{error}}"
}
},
"integrations": {
@@ -447,7 +618,32 @@
"removedFromClaudeCode": "Removed from Claude Code",
"config": "MCP Configuration",
"copyConfig": "Copy Configuration"
}
},
"tabApi": "Local API",
"tabMcp": "MCP (AI Assistants)",
"apiEnableLabel": "Enable Local API Server",
"apiEnableDescription": "Allow managing profiles, groups, and proxies via REST API.",
"apiPortLabel": "Port",
"apiTokenLabel": "Authentication Token",
"apiTokenHint": "Include in Authorization header: Bearer {{tokenSlot}}",
"apiInvalidPort": "Invalid port",
"apiInvalidPortDescription": "Port must be between 1 and 65535",
"apiPortInUse": "Port {{port}} is already in use",
"apiFallbackPort": "Server started on fallback port {{port}}",
"apiStarted": "API server started on port {{port}}",
"apiRunning": "API server running on port {{port}}",
"apiStopped": "API server stopped",
"apiToggleFailed": "Failed to toggle API server",
"apiStartFailed": "Failed to start API server",
"apiUnknownError": "Unknown error",
"tokenCopied": "Token copied",
"mcpEnableLabel": "Enable MCP Server (Model Context Protocol)",
"mcpEnableDescription": "Allow AI assistants like Claude Desktop to control browsers.",
"mcpAcceptTermsFirst": "(Accept Wayfern terms in Settings first)",
"mcpStarted": "MCP server started on port {{port}}",
"mcpStopped": "MCP server stopped",
"mcpToggleFailed": "Failed to toggle MCP server",
"openSettings": "Open Integrations Settings"
},
"import": {
"title": "Import Profile",
@@ -465,7 +661,9 @@
"fingerprint": {
"title": "Fingerprint",
"randomize": "Randomize on Launch",
"randomizeDescription": "Generate a new fingerprint each time the browser is launched."
"randomizeDescription": "Generate a new fingerprint each time the browser is launched.",
"osCpuPlaceholder": "e.g., Intel Mac OS X 10.15",
"webglRendererPlaceholder": "e.g., llvmpipe, or similar"
},
"os": {
"title": "Operating System",
@@ -499,7 +697,10 @@
"fingerprint": {
"title": "Fingerprint",
"randomize": "Randomize on Launch",
"randomizeDescription": "Generate a new fingerprint each time the browser is launched."
"randomizeDescription": "Generate a new fingerprint each time the browser is launched.",
"platformPlaceholder": "e.g., Win32, MacIntel, Linux x86_64",
"timezoneOffsetPlaceholder": "e.g., 300 for EST (UTC-5)",
"webglRendererPlaceholder": "e.g., Intel(R) HD Graphics"
},
"os": {
"title": "Operating System",
@@ -522,6 +723,10 @@
"webrtc": "Block WebRTC",
"webgl": "Block WebGL"
}
},
"shared": {
"browserBehavior": "Browser Behavior",
"allowAddonsOpenTabs": "Allow browser addons to open new tabs automatically"
}
},
"cookies": {
@@ -534,13 +739,53 @@
"selectCookies": "Select Cookies",
"allDomains": "All Domains",
"selectedCount": "{{count}} cookie selected",
"selectedCount_plural": "{{count}} cookies selected"
"selectedCount_plural": "{{count}} cookies selected",
"dialogDescription_one": "Copy cookies from a source profile to {{count}} selected profile.",
"dialogDescription_other": "Copy cookies from a source profile to {{count}} selected profiles.",
"sourceProfile": "Source Profile",
"sourcePlaceholder": "Select a profile to copy cookies from",
"running": "(running)",
"targetProfiles": "Target Profiles ({{count}})",
"noOtherTargets": "No other Wayfern/Camoufox profiles selected",
"selectSourceFirst": "Select a source profile first",
"selectionStatus": "({{selected}} of {{total}} selected)",
"searchPlaceholder": "Search domains or cookies...",
"noMatching": "No matching cookies found",
"noFound": "No cookies found",
"replaceNote": "Existing cookies with the same name and domain will be replaced. Other cookies will be kept.",
"cannotCopyRunningOne": "Cannot copy cookies: {{names}} is still running",
"cannotCopyRunningMany": "Cannot copy cookies: {{names}} are still running",
"someErrors": "Some errors occurred: {{errors}}",
"successMessage": "Successfully copied {{copied}} cookies ({{replaced}} replaced)",
"failedMessage": "Failed to copy cookies: {{error}}",
"copyButton_one": "Copy {{count}} Cookie",
"copyButton_other": "Copy {{count}} Cookies",
"copyButtonEmpty": "Copy Cookies"
},
"success": "Cookies copied successfully",
"error": "Failed to copy cookies",
"management": {
"title": "Cookie Management",
"menuItem": "Cookie Management"
"menuItem": "Cookie Management",
"tabImport": "Import",
"tabExport": "Export",
"importDescription": "Import cookies from a Netscape or JSON format file.",
"dropPrompt": "Click to choose a cookie file",
"fileFormats": "(.txt, .cookies, or .json)",
"cookiesFound": "{{count}} cookies found",
"importedSuccess": "Successfully imported {{imported}} cookies ({{replaced}} replaced)",
"linesSkipped": "{{count}} line(s) skipped",
"fileReadError": "Failed to read file",
"loadFailed": "Failed to load cookies: {{error}}",
"cookiesLabel": "Cookies",
"selectionStatus": "({{selected}} of {{total}} selected)",
"selectAll": "Select all",
"deselectAll": "Deselect all",
"noCookies": "No cookies found in this profile",
"doneButton": "Done",
"importButton": "Import",
"exportButton": "Export",
"backButton": "Back"
},
"import": {
"title": "Import Cookies",
@@ -623,7 +868,31 @@
"maxLength": "Must be at most {{max}} characters",
"networkError": "Network error. Please check your connection.",
"serverError": "Server error. Please try again later.",
"unknownError": "An unknown error occurred. Please try again."
"unknownError": "An unknown error occurred. Please try again.",
"noProfilesForUrl": "No profiles available. Please create a profile first before opening URLs.",
"updateCamoufoxConfigFailed": "Failed to update camoufox config: {{error}}",
"updateWayfernConfigFailed": "Failed to update wayfern config: {{error}}",
"createProfileFailed": "Failed to create profile: {{error}}",
"launchBrowserFailed": "Failed to launch browser: {{error}}",
"cannotDeleteRunningProfile": "Cannot delete profile while browser is running. Please stop the browser first.",
"deleteProfileFailed": "Failed to delete profile: {{error}}",
"renameProfileFailed": "Failed to rename profile: {{error}}",
"killBrowserFailed": "Failed to kill browser: {{error}}",
"deleteSelectedProfilesFailed": "Failed to delete selected profiles: {{error}}",
"cookieCopyUnsupportedBrowser": "Cookie copy only works with Wayfern and Camoufox profiles",
"updateSyncSettingsFailed": "Failed to update sync settings",
"cloneProfileFailed": "Failed to clone profile: {{error}}",
"loadSupportedBrowsersFailed": "Failed to load supported browsers",
"setupExtensionListenersFailed": "Failed to setup extension event listeners: {{error}}",
"loadGroupsFailed": "Failed to load groups: {{error}}",
"setupGroupListenersFailed": "Failed to setup group event listeners: {{error}}",
"loadProfilesFailed": "Failed to load profiles: {{error}}",
"setupProfileListenersFailed": "Failed to setup profile event listeners: {{error}}",
"loadProxiesFailed": "Failed to load proxies: {{error}}",
"setupProxyListenersFailed": "Failed to setup proxy event listeners: {{error}}",
"loadVpnConfigsFailed": "Failed to load VPN configs: {{error}}",
"setupVpnListenersFailed": "Failed to setup VPN event listeners: {{error}}",
"themeNotFound": "Tokyo Night theme not found"
},
"browser": {
"camoufox": "Camoufox",
@@ -729,7 +998,10 @@
"brandVersion": "Brand Version",
"proFeature": "This is a Pro feature",
"generateFingerprint": "Generate Fingerprint",
"refreshFingerprint": "Refresh Fingerprint"
"refreshFingerprint": "Refresh Fingerprint",
"canvasNoiseSeedPlaceholder": "Enter a seed string for canvas fingerprint",
"addFontsPlaceholder": "Add fonts...",
"enterAsJson": "Enter {{title}} as JSON"
},
"warnings": {
"windowResizeTitle": "Custom Window Dimensions",
@@ -869,7 +1141,9 @@
"syncEnabled": "Sync enabled",
"syncDisabled": "Sync disabled",
"syncEnableTooltip": "Enable sync",
"syncDisableTooltip": "Disable sync"
"syncDisableTooltip": "Disable sync",
"loadGroupsFailed": "Failed to load extension groups",
"assignGroupFailed": "Failed to assign extension group"
},
"pro": {
"badge": "PRO",
@@ -895,5 +1169,421 @@
"fresh": "Fresh",
"stale": "Stale",
"notCached": "Not cached"
},
"vpns": {
"form": {
"titleEdit": "Edit VPN",
"titleCreate": "Create WireGuard VPN",
"descEdit": "Update the name of your VPN configuration.",
"descCreate": "Enter your WireGuard interface and peer details.",
"name": "Name",
"namePlaceholder": "e.g. Home WireGuard",
"privateKey": "Private Key",
"privateKeyPlaceholder": "Base64-encoded private key",
"address": "Address",
"addressPlaceholder": "e.g. 10.0.0.2/24",
"dnsOptional": "DNS (optional)",
"dnsPlaceholder": "e.g. 1.1.1.1",
"mtuOptional": "MTU (optional)",
"mtuPlaceholder": "e.g. 1420",
"peerPublicKey": "Peer Public Key",
"peerPublicKeyPlaceholder": "Base64-encoded peer public key",
"peerEndpoint": "Peer Endpoint",
"peerEndpointPlaceholder": "e.g. vpn.example.com:51820",
"allowedIps": "Allowed IPs",
"allowedIpsPlaceholder": "e.g. 0.0.0.0/0, ::/0",
"keepaliveOptional": "Persistent Keepalive (optional)",
"keepalivePlaceholder": "e.g. 25",
"presharedKeyOptional": "Preshared Key (optional)",
"presharedKeyPlaceholder": "Base64-encoded preshared key",
"updateButton": "Update VPN",
"createButton": "Create VPN",
"nameRequired": "VPN name is required",
"privateKeyRequired": "Private key is required",
"addressRequired": "Address is required",
"peerPublicKeyRequired": "Peer public key is required",
"peerEndpointRequired": "Peer endpoint is required",
"updated": "VPN updated successfully",
"created": "WireGuard VPN created successfully",
"updateFailed": "Failed to update VPN: {{error}}",
"createFailed": "Failed to create VPN: {{error}}"
},
"import": {
"title": "Import VPN Config",
"descDropzone": "Import a WireGuard (.conf) configuration file",
"descPreview": "Review the VPN configuration to import",
"descResult": "VPN import completed",
"dropzonePrompt": "Drop a WireGuard .conf file here or click to browse",
"pasteHint": "Paste from clipboard with {{modKey}}+V",
"invalidContent": "Content does not appear to be a valid VPN configuration",
"fileReadError": "Failed to read file",
"wrongFileType": "Please drop a WireGuard .conf file",
"configurationLabel": "{{type}} Configuration",
"endpointLabel": "Endpoint: {{endpoint}}",
"vpnNameLabel": "VPN Name",
"vpnNamePlaceholder": "My VPN",
"configPreview": "Config Preview",
"importedSuccess": "VPN Imported Successfully",
"importFailed": "Import Failed",
"importButton": "Import VPN",
"doneButton": "Done",
"failedGeneric": "Failed to import VPN config",
"defaultName": "{{type}} VPN"
},
"management": {
"loading": "Loading VPNs...",
"noneCreated": "No VPN configs created yet. Import or create one using the buttons above.",
"editVpn": "Edit VPN",
"deleteVpn": "Delete VPN",
"cannotDelete_one": "Cannot delete: in use by {{count}} profile",
"cannotDelete_other": "Cannot delete: in use by {{count}} profiles",
"syncCannotDisable": "Sync cannot be disabled while this VPN is used by synced profiles",
"deleteSuccess": "VPN deleted successfully",
"deleteFailed": "Failed to delete VPN",
"deleteTitle": "Delete VPN",
"deleteDescription": "This action cannot be undone. This will permanently delete the VPN \"{{name}}\"."
}
},
"importProfile": {
"title": "Import Browser Profile",
"autoDetect": "Auto-Detect",
"manualImport": "Manual Import",
"detectedProfilesTitle": "Detected Browser Profiles",
"scanning": "Scanning for browser profiles...",
"noneFound": "No browser profiles found on your system.",
"noneFoundHint": "Try the manual import option if you have profiles in custom locations.",
"selectProfile": "Select Profile:",
"selectProfilePlaceholder": "Choose a detected profile",
"pathLabel": "Path:",
"browserLabel": "Browser:",
"newProfileName": "New Profile Name:",
"newProfileNamePlaceholder": "Enter a name for the imported profile",
"manualTitle": "Manual Profile Import",
"browserType": "Browser Type:",
"loadingBrowsers": "Loading browsers...",
"selectBrowserType": "Select browser type",
"profileFolderPath": "Profile Folder Path:",
"profileFolderPlaceholder": "Enter the full path to the profile folder",
"browseFolderTitle": "Browse for folder",
"examplePaths": "Example paths:",
"selectFolderTitle": "Select Browser Profile Folder",
"folderDialogFailed": "Failed to open folder dialog",
"detectFailed": "Failed to detect existing browser profiles",
"fillFields": "Please fill in all fields",
"selectAndName": "Please select a profile and provide a name",
"profileNotFound": "Selected profile not found",
"importedSuccess": "Successfully imported profile \"{{name}}\"",
"notInstalled": "{{browser}} is not installed. Please download {{browser}} first from the main window, then try importing again.",
"importFailed": "Failed to import profile: {{error}}",
"proxyOptional": "Proxy (Optional)",
"noProxy": "No proxy",
"nextButton": "Next",
"importButton": "Import",
"importedAs": "This profile will be imported as a {{browser}} profile."
},
"syncTooltips": {
"syncing": "Syncing...",
"syncedAt": "Synced {{time}}",
"synced": "Synced",
"waiting": "Waiting to sync",
"errorWith": "Sync error: {{error}}",
"error": "Sync error",
"notSynced": "Not synced"
},
"groupManagement": {
"description": "Manage your profile groups",
"createGroup": "Create Group",
"noGroups": "No groups created yet. Create your first group using the button above.",
"loading": "Loading groups...",
"profileCount_one": "{{count}} profile",
"profileCount_other": "{{count}} profiles",
"groupsLabel": "Groups",
"profilesCol": "Profiles",
"syncCannotDisable": "Sync cannot be disabled while this group is used by synced profiles",
"editGroupTooltip": "Edit group",
"deleteGroupTooltip": "Delete group",
"loadFailed": "Failed to load groups"
},
"proxyAssignment": {
"title": "Assign Proxy / VPN",
"description_one": "Assign a proxy or VPN to {{count}} selected profile.",
"description_other": "Assign a proxy or VPN to {{count}} selected profiles.",
"selectLabel": "Proxy / VPN",
"placeholder": "Select a proxy or VPN",
"noProxy": "No proxy / VPN",
"searchPlaceholder": "Search proxies or VPNs...",
"notFound": "No proxies or VPNs found.",
"assignButton": "Assign",
"success": "Successfully assigned proxy/VPN to {{count}} profile(s)",
"failed": "Failed to assign proxy/VPN",
"selectedProfilesLabel": "Selected Profiles:",
"assignProxyVpnLabel": "Assign Proxy / VPN:",
"noneOption": "None",
"noValidProfiles": "No valid profiles selected.",
"vpnGroupHeading": "VPNs",
"failedFallback": "Failed to assign proxy/VPN to profiles"
},
"groupAssignment": {
"title": "Assign Group",
"description_one": "Assign a group to {{count}} selected profile.",
"description_other": "Assign a group to {{count}} selected profiles.",
"selectLabel": "Group",
"placeholder": "Select a group",
"noGroup": "No Group (Default)",
"assignButton": "Assign",
"success": "Successfully assigned group to {{count}} profile(s)",
"failed": "Failed to assign group",
"selectedProfilesLabel": "Selected Profiles:",
"assignGroupLabel": "Assign to Group:",
"noValidProfiles": "No valid profiles selected.",
"failedFallback": "Failed to assign group to profiles"
},
"profileSelector": {
"title": "Select Profile",
"description": "Choose a profile to launch with this URL",
"searchPlaceholder": "Search profiles...",
"noProfiles": "No profiles available",
"noResults": "No profiles match your search",
"selectButton": "Select",
"launching": "Launching...",
"chooseProfileTitle": "Choose Profile",
"openingUrl": "Opening URL:",
"urlCopied": "URL copied to clipboard!",
"selectProfileLabel": "Select Profile:",
"noneAvailableShort": "No profiles available. Please create a profile first.",
"noneAvailableLong": "Close this dialog and create a profile from the main window to get started.",
"chooseAProfile": "Choose a profile",
"badgeProxy": "Proxy",
"badgeRunning": "Running",
"badgeUnavailable": "Unavailable",
"openButton": "Open"
},
"locationProxy": {
"title": "Quick Location Proxy",
"description": "Choose a country to route this profile through. A proxy will be created automatically.",
"country": "Country",
"selectCountry": "Select a country",
"searchCountry": "Search country...",
"noCountriesFound": "No countries found.",
"apply": "Apply",
"creating": "Creating proxy...",
"success": "Location proxy applied",
"failed": "Failed to apply location proxy",
"titleCreate": "Create Location Proxy",
"descriptionCreate": "Create a geo-targeted proxy with a 24-hour sticky session",
"countryLabel": "Country (required)",
"regionLabel": "Region (optional)",
"cityLabel": "City (optional)",
"ispLabel": "ISP (optional)",
"nameLabel": "Name",
"namePlaceholder": "Proxy name",
"loadingCountries": "Loading countries...",
"selectCountryPh": "Select country",
"searchCountries": "Search countries...",
"loadFailed": "Failed to load countries",
"selectCountryFirst": "Select a country first",
"loadingRegions": "Loading regions...",
"noRegions": "No regions available",
"selectRegion": "Select region",
"searchRegions": "Search regions...",
"loadingCities": "Loading cities...",
"noCities": "No cities available",
"selectCity": "Select city",
"searchCities": "Search cities...",
"loadingIsps": "Loading ISPs...",
"noIsps": "No ISPs available",
"selectIsp": "Select ISP",
"searchIsps": "Search ISPs...",
"createSuccess": "Location proxy created",
"createFailed": "Failed to create location proxy",
"creatingButton": "Creating...",
"createButton": "Create"
},
"launchOnLogin": {
"title": "Enable Launch on Login?",
"description": "Running in the background helps keep your proxies and browsers alive.",
"declineButton": "Don't Ask Again",
"declining": "...",
"enableButton": "Enable",
"enableSuccess": "Launch on login enabled",
"enableFailed": "Failed to enable launch on login",
"declineFailed": "Failed to save preference",
"tryAgain": "Please try again"
},
"wayfernTerms": {
"title": "Wayfern Terms and Conditions",
"description": "Before using Donut Browser, you must read and agree to Wayfern's Terms and Conditions.",
"reviewLabel": "Please review the Terms and Conditions at:",
"agreeNotice": "By clicking \"I Accept\", you agree to be bound by these terms.",
"acceptButton": "I Accept",
"acceptSuccess": "Terms accepted successfully",
"acceptFailed": "Failed to accept terms",
"tryAgain": "Please try again"
},
"commercialTrial": {
"title": "Commercial Trial Expired",
"description": "Your 2-week commercial trial period has ended.",
"body": "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.",
"understandButton": "I Understand",
"failed": "Failed to save acknowledgment",
"tryAgain": "Please try again"
},
"permissionDialog": {
"titleMicrophone": "Microphone Access Required",
"titleCamera": "Camera Access Required",
"descMicrophone": "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.",
"descCamera": "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.",
"grantedMicrophone": "Permission granted! Browsers launched from Donut Browser can now access your microphone.",
"grantedCamera": "Permission granted! Browsers launched from Donut Browser can now access your camera.",
"notGrantedMicrophone": "Permission not granted. Click the button below to request access to your microphone.",
"notGrantedCamera": "Permission not granted. Click the button below to request access to your camera.",
"doneButton": "Done",
"cancelButton": "Cancel",
"grantAccessButton": "Grant Access",
"requestSuccessMicrophone": "Microphone Access permission requested",
"requestSuccessCamera": "Camera Access permission requested",
"requestFailed": "Failed to request permission"
},
"traffic": {
"title": "Traffic Details",
"bandwidthOverTime": "Bandwidth Over Time",
"timePeriodPlaceholder": "Time period",
"last1m": "Last 1 min",
"last5m": "Last 5 min",
"last30m": "Last 30 min",
"last1h": "Last 1 hour",
"last2h": "Last 2 hours",
"last4h": "Last 4 hours",
"last1d": "Last 1 day",
"last7d": "Last 7 days",
"last30d": "Last 30 days",
"allTime": "All time",
"allTimeShort": "all time",
"totalSuffix": "total",
"sentLabel": "Sent ({{period}})",
"receivedLabel": "Received ({{period}})",
"requestsLabel": "Requests ({{period}})",
"allTimeTraffic": "All-time traffic:",
"allTimeRequests": "All-time requests:",
"proxyDisclaimer": "Note: If you are using a proxy, VPN, or similar service, your provider may calculate traffic differently due to encryption overhead and protocol differences.",
"topByTraffic": "Top Domains by Traffic ({{period}})",
"topByRequests": "Top Domains by Requests ({{period}})",
"columnDomain": "Domain",
"columnRequests": "Requests",
"columnSent": "Sent",
"columnReceived": "Received",
"columnTotal": "Total Traffic",
"uniqueIps": "Unique IPs ({{count}})",
"noData": "No traffic data available for this profile.",
"noDataHint": "Traffic data will appear after you launch the profile.",
"sentLegend": "Sent",
"receivedLegend": "Received",
"tooltipSent": "↑ Sent: ",
"tooltipReceived": "↓ Received: "
},
"camoufoxDialog": {
"titleView": "View Fingerprint Settings - {{name}} ({{browser}})",
"titleConfigure": "Configure Fingerprint Settings - {{name}} ({{browser}})",
"invalidFingerprint": "Invalid fingerprint configuration",
"invalidFingerprintDescription": "The fingerprint configuration contains invalid JSON. Please check your advanced settings.",
"saveFailed": "Failed to save configuration",
"unknownError": "Unknown error occurred"
},
"proxyCheck": {
"unknownLocation": "Unknown",
"locationToast": "Your proxy location is:",
"failed": "Proxy check failed: {{error}}",
"tooltipChecking": "Checking proxy...",
"tooltipIp": "IP: {{ip}}",
"tooltipChecked": "Checked {{time}}",
"tooltipFailed": "Failed {{time}}",
"tooltipFailedTitle": "Proxy check failed",
"tooltipDefault": "Check proxy validity"
},
"vpnCheck": {
"valid": "VPN \"{{name}}\" configuration is valid",
"invalid": "VPN \"{{name}}\" configuration is invalid",
"failed": "VPN check failed: {{error}}",
"tooltipChecking": "Checking VPN config...",
"tooltipValid": "Configuration valid",
"tooltipInvalid": "Configuration invalid",
"tooltipChecked": "Checked {{time}}",
"tooltipDefault": "Check VPN config validity"
},
"profileTable": {
"syncTooltipDisabled": "Sync disabled",
"syncTooltipSyncing": "Syncing...",
"syncTooltipSyncedAt": "Synced {{time}}",
"syncTooltipSynced": "Synced",
"syncTooltipWaiting": "Waiting to sync",
"syncTooltipErrorWith": "Sync error: {{error}}",
"syncTooltipError": "Sync error",
"syncTooltipNotSynced": "Not synced",
"noTags": "No tags",
"syncTooltipCloseToSync": "Close the profile to sync",
"syncTooltipDisabledWithLast": "Sync disabled, last sync {{time}}",
"addTagsPlaceholder": "Add tags",
"tagsHeader": "Tags",
"noteHeader": "Note",
"vpnsHeading": "VPNs",
"createByCountryHeading": "Create by country"
},
"releaseTypeSelector": {
"noReleaseTypes": "No release types available.",
"placeholder": "Select release type...",
"stable": "Stable",
"nightly": "Nightly",
"downloaded": "Downloaded",
"downloadBrowser": "Download Browser",
"downloading": "Downloading..."
},
"dataTableActionBar": {
"selected": "{{count}} selected",
"clearSelection": "Clear selection"
},
"appUpdate": {
"toast": {
"updateFailed": "Failed to update Donut Browser",
"restartFailed": "Failed to restart",
"updateReady": "Update ready, restart to apply",
"manualDownloadRequired": "Manual download required",
"restartNow": "Restart Now",
"viewRelease": "View Release",
"later": "Later",
"uploading": "Uploading",
"downloading": "Downloading"
}
},
"browserDownload": {
"toast": {
"fetchVersionsFailed": "Failed to fetch {{browser}} versions",
"foundNewVersions": "Found {{count}} new {{browser}} versions!",
"totalAvailableVersions": "Total available: {{count}} versions",
"downloadFailed": "Failed to download {{browser}} {{version}}",
"calculating": "calculating...",
"extractionFailed": "{{browser}} {{version}}: extraction failed",
"extractionFailedDescription": "The corrupt file was deleted. It will be re-downloaded on next attempt.",
"extracting": "Extracting browser files... Please do not close the app.",
"verifying": "Verifying browser files...",
"downloadingRolling": "Downloading rolling release build..."
}
},
"versionUpdater": {
"toast": {
"alreadyAvailable": "{{browser}} {{version}} already available",
"updatingProfiles": "Updating profile configurations...",
"updateCompleted": "{{browser}} update completed",
"singleProfileUpdated": "Profile \"{{name}}\" has been updated to version {{version}}. You can now launch your browsers with the latest version.",
"multipleProfilesUpdated": "{{count}} profiles have been updated to version {{version}}. You can now launch your browsers with the latest version.",
"versionAvailable": "Version {{version}} is now available. Running profiles will use the new version when restarted.",
"autoUpdateFailed": "Failed to auto-update {{browser}}",
"updateWithErrors": "Update completed with some errors",
"updateWithErrorsDescription": "{{newVersions}} new versions found, {{failedUpdates}} browsers failed to update",
"updateSuccess": "Browser versions updated successfully",
"updateSuccessDescription": "Found {{newVersions}} new versions across {{successfulUpdates}} browsers. Auto-downloads will start shortly.",
"upToDate": "No new browser versions found",
"upToDateDescription": "All browser versions are up to date",
"updateAllFailed": "Failed to update browser versions"
}
}
}
+725 -35
View File
@@ -28,7 +28,9 @@
"refresh": "Actualizar",
"loading": "Cargando...",
"saveSettings": "Guardar Configuración",
"moreInfo": "Más información"
"moreInfo": "Más información",
"downloading": "Descargando...",
"minimize": "Minimizar"
},
"status": {
"active": "Activo",
@@ -56,7 +58,10 @@
"default": "Predeterminado",
"custom": "Personalizado",
"optional": "Opcional",
"required": "Requerido"
"required": "Requerido",
"unknownProfile": "Desconocido",
"mode": "Modo",
"never": "Nunca"
},
"time": {
"days": "días",
@@ -64,6 +69,33 @@
"minutes": "minutos",
"seconds": "segundos",
"remaining": "restantes"
},
"aria": {
"selectAll": "Seleccionar todo",
"selectRow": "Seleccionar fila",
"selectProfile": "Seleccionar perfil",
"copy": "Copiar al portapapeles",
"copied": "Copiado",
"showToken": "Mostrar token",
"hideToken": "Ocultar token"
},
"keys": {
"escape": "Escape"
},
"errors": {
"unknown": "Ocurrió un error desconocido"
},
"window": {
"minimize": "Minimizar"
},
"commandPalette": {
"title": "Paleta de comandos",
"description": "Busca un comando para ejecutar..."
},
"noResults": "No se encontraron resultados.",
"srOnly": {
"copy": "Copiar",
"copied": "Copiado"
}
},
"settings": {
@@ -85,7 +117,8 @@
"title": "Idioma",
"description": "Elige tu idioma preferido para la interfaz de la aplicación.",
"systemDefault": "Predeterminado del Sistema",
"selectLanguage": "Seleccionar idioma"
"selectLanguage": "Seleccionar idioma",
"interface": "Idioma de la interfaz"
},
"defaultBrowser": {
"title": "Navegador Predeterminado",
@@ -100,7 +133,8 @@
"microphone": "Micrófono",
"microphoneDescription": "Acceso al micrófono para aplicaciones del navegador",
"camera": "Cámara",
"cameraDescription": "Acceso a la cámara para aplicaciones del navegador"
"cameraDescription": "Acceso a la cámara para aplicaciones del navegador",
"accessRequested": "Acceso a {{permission}} solicitado"
},
"integrations": {
"title": "Integraciones",
@@ -134,7 +168,8 @@
"advanced": {
"title": "Avanzado",
"clearCache": "Limpiar Toda la Caché de Versiones",
"clearCacheDescription": "Limpia todos los datos de versiones de navegadores en caché y actualiza todas las versiones desde sus fuentes. Esto forzará una descarga nueva de información de versiones para todos los navegadores."
"clearCacheDescription": "Limpia todos los datos de versiones de navegadores en caché y actualiza todas las versiones desde sus fuentes. Esto forzará una descarga nueva de información de versiones para todos los navegadores.",
"clearCacheFailed": "Error al limpiar la caché"
},
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App",
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas."
@@ -169,7 +204,9 @@
"note": "Nota",
"group": "Grupo",
"proxy": "Proxy / VPN",
"lastLaunch": "Último Inicio"
"lastLaunch": "Último Inicio",
"empty": "No se encontraron perfiles.",
"notSelected": "No seleccionado"
},
"actions": {
"launch": "Iniciar",
@@ -205,7 +242,30 @@
"ephemeral": "Efímero",
"ephemeralDescription": "El navegador es forzado a escribir los datos del perfil en memoria en lugar del disco. Los datos se eliminan al cerrar el navegador.",
"ephemeralBadge": "Efímero",
"ephemeralAlpha": "Alpha"
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Eliminar perfiles seleccionados",
"description": "Esta acción no se puede deshacer. Eliminará permanentemente {{count}} perfil(es) y todos los datos asociados.",
"confirmButton": "Eliminar {{count}} perfil(es)"
},
"note": {
"empty": "Sin nota",
"placeholder": "Añadir una nota..."
},
"aria": {
"profileInfo": "Información del perfil"
},
"delete": {
"title": "Eliminar perfil",
"description": "Esta acción no se puede deshacer. Eliminará permanentemente el perfil \"{{profileName}}\" y todos sus datos asociados.",
"confirmButton": "Eliminar perfil"
},
"actionBar": {
"assignToGroup": "Asignar a grupo",
"assignProxy": "Asignar proxy",
"assignExtensionGroup": "Asignar grupo de extensiones",
"copyCookies": "Copiar cookies"
}
},
"createProfile": {
"title": "Crear Nuevo Perfil",
@@ -228,7 +288,10 @@
"title": "Proxy / VPN",
"addProxy": "Agregar Proxy",
"noProxy": "Sin proxy / VPN",
"noProxiesAvailable": "No hay proxies o VPNs disponibles. Agrega uno para enrutar el tráfico de este perfil."
"noProxiesAvailable": "No hay proxies o VPNs disponibles. Agrega uno para enrutar el tráfico de este perfil.",
"search": "Buscar proxies o VPN...",
"notFound": "No se encontraron proxies o VPN.",
"searchWithCountries": "Buscar proxies, VPN o países..."
},
"launchHook": {
"label": "URL del hook de inicio",
@@ -248,7 +311,8 @@
"chromiumSubtitle": "Impulsado por Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Impulsado por Camoufox",
"camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium."
"camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium.",
"platformUnavailable": "{{browser}} aún no está disponible en tu plataforma."
},
"deleteDialog": {
"title": "Eliminar Perfil",
@@ -259,7 +323,31 @@
},
"proxies": {
"title": "Proxies",
"management": "Proxies y VPNs",
"management": {
"description": "Administra tus configuraciones de proxy y VPN para reutilizarlas en los perfiles",
"tabProxies": "Proxies",
"tabVpns": "VPN",
"create": "Crear",
"loading": "Cargando proxies...",
"noneCreated": "Aún no hay proxies. Crea tu primer proxy usando el botón de arriba.",
"usage": "Uso",
"syncCol": "Sincronizar",
"syncCannotDisable": "No se puede desactivar la sincronización mientras este proxy esté en uso por perfiles sincronizados",
"enableSync": "Activar sincronización",
"disableSync": "Desactivar sincronización",
"editProxy": "Editar proxy",
"deleteProxy": "Eliminar proxy",
"cannotDelete_one": "No se puede eliminar: en uso por {{count}} perfil",
"cannotDelete_other": "No se puede eliminar: en uso por {{count}} perfiles",
"syncEnabled": "Sincronización activada",
"syncDisabled": "Sincronización desactivada",
"updateSyncFailed": "Error al actualizar la sincronización",
"deleteSuccess": "Proxy eliminado correctamente",
"deleteFailed": "Error al eliminar el proxy",
"deleteTitle": "Eliminar proxy",
"deleteDescription": "Esta acción no se puede deshacer. Se eliminará permanentemente el proxy \"{{name}}\".",
"title": "Proxies y VPN"
},
"add": "Agregar Proxy",
"edit": "Editar Proxy",
"delete": "Eliminar Proxy",
@@ -280,7 +368,12 @@
"password": "Contraseña",
"passwordPlaceholder": "Opcional",
"cipher": "Cifrado",
"cipherPlaceholder": "aes-256-gcm"
"cipherPlaceholder": "aes-256-gcm",
"nameRequired": "El nombre del proxy es obligatorio",
"hostPortRequired": "Host y puerto son obligatorios",
"ssCipherRequired": "Para Shadowsocks se requieren cifrado y contraseña",
"selectType": "Selecciona el tipo de proxy",
"saveFailed": "Error al guardar el proxy: {{error}}"
},
"types": {
"http": "HTTP",
@@ -318,6 +411,45 @@
"sync": {
"enabled": "Sincronización Habilitada",
"disabled": "Sincronización Deshabilitada"
},
"exportDialog": {
"title": "Exportar proxies",
"description": "Exporta tus configuraciones de proxy a un archivo",
"format": "Formato de exportación",
"json": "JSON",
"txt": "TXT (formato URL)",
"preview": "Vista previa",
"noProxies": "No hay proxies para exportar",
"downloaded": "{{filename}} descargado",
"failed": "Error al exportar los proxies",
"copied": "Copiado"
},
"importDialog": {
"title": "Importar proxies",
"descDropzone": "Importar proxies desde un archivo JSON o TXT",
"descPreview": "Revisa los proxies a importar",
"descAmbiguous": "Algunos proxies tienen formatos ambiguos. Selecciona el formato correcto.",
"descResult": "Importación completada",
"dropzonePrompt": "Suelta un archivo de configuración de proxy",
"dropzoneFormats": "(.json, .txt)",
"pasteHint": "Pega desde el portapapeles con {{modKey}}+V",
"wrongFileType": "Por favor, suelta un archivo .json o .txt",
"fileReadError": "Error al leer el archivo",
"fileProcessError": "Error al procesar el archivo",
"noValidProxies": "No se encontraron proxies válidos en el archivo",
"namePrefix": "Prefijo de nombre",
"namePrefixDefault": "Imported",
"namePrefixHint": "Los proxies se nombrarán \"{{prefix}} Proxy 1\", \"{{prefix}} Proxy 2\", etc.",
"proxiesToImport": "Proxies a importar ({{count}})",
"invalidCount": "({{count}} inválidos)",
"ambiguousIntro": "Los siguientes proxies tienen un formato ambiguo. Selecciona la interpretación correcta para cada uno.",
"imported": "Importados:",
"skippedDuplicates": "Omitidos (duplicados):",
"errors": "Errores",
"importButton": "Importar {{count}} proxies",
"continueButton": "Continuar",
"doneButton": "Hecho",
"failed": "Error al importar los proxies"
}
},
"groups": {
@@ -343,7 +475,31 @@
"sync": {
"enabled": "Sincronización Habilitada",
"disabled": "Sincronización Deshabilitada"
}
},
"createTitle": "Crear Nuevo Grupo",
"createDescription": "Crea un nuevo grupo para organizar tus perfiles de navegador.",
"editTitle": "Editar Grupo",
"editDescription": "Actualiza el nombre de tu grupo.",
"createSuccess": "Grupo creado correctamente",
"createFailed": "Error al crear el grupo",
"updateSuccess": "Grupo actualizado correctamente",
"updateFailed": "Error al actualizar el grupo",
"deleteTitle": "Eliminar Grupo",
"deleteDescription": "Esta acción no se puede deshacer. Eliminará permanentemente el grupo.",
"deleteSuccess": "Grupo eliminado correctamente",
"deleteFailed": "Error al eliminar el grupo",
"loadingProfiles": "Cargando perfiles asociados...",
"associatedProfiles": "Perfiles Asociados ({{count}})",
"whatToDoWithProfiles": "¿Qué hacer con estos perfiles?",
"moveToDefaultOption": "Mover perfiles al grupo Predeterminado",
"deleteAlongWithGroup": "Eliminar perfiles junto con el grupo",
"noAssociatedProfiles": "Este grupo no tiene perfiles asociados.",
"deleteGroup": "Eliminar Grupo",
"deleteGroupAndProfiles": "Eliminar Grupo y Perfiles",
"loadProfilesFailed": "Error al cargar los perfiles",
"unknownGroup": "Grupo desconocido",
"profileGroupsAriaLabel": "Grupos de perfiles",
"loading": "Cargando grupos..."
},
"sync": {
"mode": {
@@ -366,7 +522,16 @@
"configureService": "Configurar servicio de sincronización"
},
"title": "Servicio de Sincronización",
"config": "Configuración de Sincronización",
"config": {
"serverUrlRequired": "Introduce la URL del servidor",
"connectionSuccess": "¡Conexión exitosa!",
"serverError": "El servidor respondió con un error",
"connectFailed": "Error al conectar con el servidor",
"settingsSaved": "Ajustes de sincronización guardados",
"saveFailed": "Error al guardar los ajustes",
"disconnected": "Sincronización desconectada",
"disconnectFailed": "Error al desconectar"
},
"serverUrl": "URL del Servidor",
"serverUrlPlaceholder": "https://sync.ejemplo.com",
"token": "Token de Sincronización",
@@ -410,6 +575,12 @@
"profileLockedShort": "En uso",
"cannotLaunchLocked": "No se puede iniciar — el perfil está en uso por {{email}}",
"createdBy": "Creado por {{email}}"
},
"disabled": "Desactivada",
"toast": {
"profileSynced": "Perfil '{{name}}' sincronizado correctamente",
"profileSyncFailed": "Error al sincronizar el perfil '{{name}}'",
"profileSyncFailedWithError": "Error al sincronizar el perfil '{{name}}': {{error}}"
}
},
"integrations": {
@@ -447,7 +618,32 @@
"removedFromClaudeCode": "Eliminado de Claude Code",
"config": "Configuración MCP",
"copyConfig": "Copiar Configuración"
}
},
"tabApi": "API local",
"tabMcp": "MCP (asistentes IA)",
"apiEnableLabel": "Activar servidor API local",
"apiEnableDescription": "Permite gestionar perfiles, grupos y proxies vía API REST.",
"apiPortLabel": "Puerto",
"apiTokenLabel": "Token de autenticación",
"apiTokenHint": "Incluir en cabecera Authorization: Bearer {{tokenSlot}}",
"apiInvalidPort": "Puerto inválido",
"apiInvalidPortDescription": "El puerto debe estar entre 1 y 65535",
"apiPortInUse": "El puerto {{port}} ya está en uso",
"apiFallbackPort": "Servidor iniciado en puerto alternativo {{port}}",
"apiStarted": "Servidor API iniciado en puerto {{port}}",
"apiRunning": "Servidor API ejecutándose en puerto {{port}}",
"apiStopped": "Servidor API detenido",
"apiToggleFailed": "Error al alternar el servidor API",
"apiStartFailed": "Error al iniciar el servidor API",
"apiUnknownError": "Error desconocido",
"tokenCopied": "Token copiado",
"mcpEnableLabel": "Activar servidor MCP (Model Context Protocol)",
"mcpEnableDescription": "Permite que asistentes IA como Claude Desktop controlen los navegadores.",
"mcpAcceptTermsFirst": "(Acepta primero los términos de Wayfern en Configuración)",
"mcpStarted": "Servidor MCP iniciado en puerto {{port}}",
"mcpStopped": "Servidor MCP detenido",
"mcpToggleFailed": "Error al alternar el servidor MCP",
"openSettings": "Abrir configuración de integraciones"
},
"import": {
"title": "Importar Perfil",
@@ -465,7 +661,9 @@
"fingerprint": {
"title": "Huella Digital",
"randomize": "Aleatorizar al Iniciar",
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador."
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador.",
"osCpuPlaceholder": "p. ej., Intel Mac OS X 10.15",
"webglRendererPlaceholder": "p. ej., llvmpipe, o similar"
},
"os": {
"title": "Sistema Operativo",
@@ -499,7 +697,10 @@
"fingerprint": {
"title": "Huella Digital",
"randomize": "Aleatorizar al Iniciar",
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador."
"randomizeDescription": "Genera una nueva huella digital cada vez que se inicia el navegador.",
"platformPlaceholder": "p. ej., Win32, MacIntel, Linux x86_64",
"timezoneOffsetPlaceholder": "p. ej., 300 para EST (UTC-5)",
"webglRendererPlaceholder": "p. ej., Intel(R) HD Graphics"
},
"os": {
"title": "Sistema Operativo",
@@ -522,6 +723,10 @@
"webrtc": "Bloquear WebRTC",
"webgl": "Bloquear WebGL"
}
},
"shared": {
"browserBehavior": "Comportamiento del navegador",
"allowAddonsOpenTabs": "Permitir que los complementos abran nuevas pestañas automáticamente"
}
},
"cookies": {
@@ -534,13 +739,53 @@
"selectCookies": "Seleccionar Cookies",
"allDomains": "Todos los Dominios",
"selectedCount": "{{count}} cookie seleccionada",
"selectedCount_plural": "{{count}} cookies seleccionadas"
"selectedCount_plural": "{{count}} cookies seleccionadas",
"dialogDescription_one": "Copiar cookies de un perfil de origen a {{count}} perfil seleccionado.",
"dialogDescription_other": "Copiar cookies de un perfil de origen a {{count}} perfiles seleccionados.",
"sourceProfile": "Perfil de origen",
"sourcePlaceholder": "Selecciona un perfil del que copiar cookies",
"running": "(en ejecución)",
"targetProfiles": "Perfiles de destino ({{count}})",
"noOtherTargets": "No hay otros perfiles Wayfern/Camoufox seleccionados",
"selectSourceFirst": "Selecciona primero un perfil de origen",
"selectionStatus": "({{selected}} de {{total}} seleccionadas)",
"searchPlaceholder": "Buscar dominios o cookies...",
"noMatching": "No se encontraron cookies coincidentes",
"noFound": "No se encontraron cookies",
"replaceNote": "Las cookies existentes con el mismo nombre y dominio serán reemplazadas. El resto se conservará.",
"cannotCopyRunningOne": "No se pueden copiar las cookies: {{names}} aún en ejecución",
"cannotCopyRunningMany": "No se pueden copiar las cookies: {{names}} aún en ejecución",
"someErrors": "Ocurrieron algunos errores: {{errors}}",
"successMessage": "Se copiaron {{copied}} cookies correctamente ({{replaced}} reemplazadas)",
"failedMessage": "Error al copiar las cookies: {{error}}",
"copyButton_one": "Copiar {{count}} cookie",
"copyButton_other": "Copiar {{count}} cookies",
"copyButtonEmpty": "Copiar cookies"
},
"success": "Cookies copiadas exitosamente",
"error": "Error al copiar cookies",
"management": {
"title": "Gestión de Cookies",
"menuItem": "Gestión de Cookies"
"menuItem": "Gestión de Cookies",
"tabImport": "Importar",
"tabExport": "Exportar",
"importDescription": "Importa cookies desde un archivo en formato Netscape o JSON.",
"dropPrompt": "Haz clic para elegir un archivo de cookies",
"fileFormats": "(.txt, .cookies o .json)",
"cookiesFound": "{{count}} cookies encontradas",
"importedSuccess": "{{imported}} cookies importadas correctamente ({{replaced}} reemplazadas)",
"linesSkipped": "{{count}} línea(s) omitidas",
"fileReadError": "Error al leer el archivo",
"loadFailed": "Error al cargar las cookies: {{error}}",
"cookiesLabel": "Cookies",
"selectionStatus": "({{selected}} de {{total}} seleccionadas)",
"selectAll": "Seleccionar todo",
"deselectAll": "Deseleccionar todo",
"noCookies": "No se encontraron cookies en este perfil",
"doneButton": "Hecho",
"importButton": "Importar",
"exportButton": "Exportar",
"backButton": "Atrás"
},
"import": {
"title": "Importar Cookies",
@@ -623,7 +868,31 @@
"maxLength": "Debe tener como máximo {{max}} caracteres",
"networkError": "Error de red. Por favor verifica tu conexión.",
"serverError": "Error del servidor. Por favor intenta de nuevo más tarde.",
"unknownError": "Ocurrió un error desconocido. Por favor intenta de nuevo."
"unknownError": "Ocurrió un error desconocido. Por favor intenta de nuevo.",
"noProfilesForUrl": "No hay perfiles disponibles. Crea un perfil antes de abrir URLs.",
"updateCamoufoxConfigFailed": "Error al actualizar la configuración de camoufox: {{error}}",
"updateWayfernConfigFailed": "Error al actualizar la configuración de wayfern: {{error}}",
"createProfileFailed": "Error al crear el perfil: {{error}}",
"launchBrowserFailed": "Error al iniciar el navegador: {{error}}",
"cannotDeleteRunningProfile": "No se puede eliminar el perfil mientras el navegador esté en ejecución. Detén el navegador primero.",
"deleteProfileFailed": "Error al eliminar el perfil: {{error}}",
"renameProfileFailed": "Error al renombrar el perfil: {{error}}",
"killBrowserFailed": "Error al detener el navegador: {{error}}",
"deleteSelectedProfilesFailed": "Error al eliminar los perfiles seleccionados: {{error}}",
"cookieCopyUnsupportedBrowser": "La copia de cookies sólo funciona con perfiles Wayfern y Camoufox",
"updateSyncSettingsFailed": "Error al actualizar los ajustes de sincronización",
"cloneProfileFailed": "Error al clonar el perfil: {{error}}",
"loadSupportedBrowsersFailed": "Error al cargar los navegadores compatibles",
"setupExtensionListenersFailed": "Error al configurar los listeners de eventos de extensiones: {{error}}",
"loadGroupsFailed": "Error al cargar los grupos: {{error}}",
"setupGroupListenersFailed": "Error al configurar los listeners de eventos de grupos: {{error}}",
"loadProfilesFailed": "Error al cargar los perfiles: {{error}}",
"setupProfileListenersFailed": "Error al configurar los listeners de eventos de perfiles: {{error}}",
"loadProxiesFailed": "Error al cargar los proxies: {{error}}",
"setupProxyListenersFailed": "Error al configurar los listeners de eventos de proxies: {{error}}",
"loadVpnConfigsFailed": "Error al cargar las configuraciones de VPN: {{error}}",
"setupVpnListenersFailed": "Error al configurar los listeners de eventos de VPN: {{error}}",
"themeNotFound": "Tema Tokyo Night no encontrado"
},
"browser": {
"camoufox": "Camoufox",
@@ -649,15 +918,15 @@
"blockWebRTC": "Bloquear WebRTC",
"blockWebGL": "Bloquear WebGL",
"navigatorProperties": "Propiedades del navegador",
"userAgent": "User Agent",
"userAgent": "Agente de usuario",
"userAgentAndPlatform": "User Agent y plataforma",
"platform": "Plataforma",
"platformVersion": "Versión de plataforma",
"appVersion": "Versión de la aplicación",
"osCpu": "OS CPU",
"osCpu": "CPU del SO",
"hardwareConcurrency": "Concurrencia de hardware",
"maxTouchPoints": "Puntos táctiles máximos",
"doNotTrack": "Do Not Track",
"doNotTrack": "No rastrear",
"selectDntPlaceholder": "Seleccionar valor DNT",
"dntAllowed": "0 (rastreo permitido)",
"dntNotAllowed": "1 (rastreo no permitido)",
@@ -679,8 +948,8 @@
"outerHeight": "Alto exterior",
"innerWidth": "Ancho interior",
"innerHeight": "Alto interior",
"screenX": "Screen X",
"screenY": "Screen Y",
"screenX": "Pantalla X",
"screenY": "Pantalla Y",
"geolocation": "Geolocalización",
"timezoneAndGeolocation": "Zona horaria y geolocalización",
"timezoneGeolocationDescription": "Estos valores anulan las APIs de zona horaria y geolocalización del navegador.",
@@ -694,15 +963,15 @@
"region": "Región",
"script": "Script",
"webglProperties": "Propiedades de WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglVendor": "Proveedor WebGL",
"webglRenderer": "Renderizador WebGL",
"webglParameters": "Parámetros de WebGL",
"webglParametersJson": "Parámetros de WebGL (JSON)",
"webgl2Parameters": "Parámetros de WebGL2",
"webglShaderPrecisionFormats": "Formatos de precisión de WebGL Shader",
"webgl2ShaderPrecisionFormats": "Formatos de precisión de WebGL2 Shader",
"webglShaderPrecisionFormats": "Formatos de precisión de shader WebGL",
"webgl2ShaderPrecisionFormats": "Formatos de precisión de shader WebGL2",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeed": "Semilla de ruido de Canvas",
"canvasNoiseSeedDescription": "Esta semilla se usa para generar una huella digital de Canvas consistente pero única. Cada perfil debe tener una semilla diferente.",
"fonts": "Fuentes",
"fontsJson": "Fuentes (JSON array)",
@@ -723,13 +992,16 @@
"maxChannelCount": "Número máximo de canales",
"vendorInfo": "Información del proveedor",
"vendor": "Proveedor",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"vendorSub": "Proveedor Sub",
"productSub": "Producto Sub",
"brand": "Marca",
"brandVersion": "Versión de marca",
"proFeature": "Esta es una función Pro",
"generateFingerprint": "Generar Huella Digital",
"refreshFingerprint": "Actualizar Huella Digital"
"refreshFingerprint": "Actualizar Huella Digital",
"canvasNoiseSeedPlaceholder": "Introduce una semilla para la huella digital del canvas",
"addFontsPlaceholder": "Agregar fuentes...",
"enterAsJson": "Ingresa {{title}} como JSON"
},
"warnings": {
"windowResizeTitle": "Dimensiones de ventana personalizadas",
@@ -869,7 +1141,9 @@
"syncEnabled": "Sincronización habilitada",
"syncDisabled": "Sincronización deshabilitada",
"syncEnableTooltip": "Habilitar sincronización",
"syncDisableTooltip": "Deshabilitar sincronización"
"syncDisableTooltip": "Deshabilitar sincronización",
"loadGroupsFailed": "Error al cargar grupos de extensiones",
"assignGroupFailed": "Error al asignar grupo de extensiones"
},
"pro": {
"badge": "PRO",
@@ -882,11 +1156,11 @@
"dnsBlocklist": {
"title": "Lista de bloqueo DNS",
"none": "Ninguno",
"light": "Light",
"light": "Ligero",
"normal": "Normal",
"pro": "Pro",
"proPlus": "Pro++",
"ultimate": "Ultimate",
"ultimate": "Definitivo",
"settingsDescription": "Las listas de bloqueo DNS bloquean anuncios, rastreadores y dominios de malware a nivel de proxy. Las listas se actualizan automáticamente cada 12 horas.",
"manageLists": "Gestionar listas de bloqueo DNS",
"refreshAll": "Actualizar todas las listas",
@@ -895,5 +1169,421 @@
"fresh": "Actualizado",
"stale": "Desactualizado",
"notCached": "Sin caché"
},
"vpns": {
"form": {
"titleEdit": "Editar VPN",
"titleCreate": "Crear VPN WireGuard",
"descEdit": "Actualiza el nombre de tu configuración VPN.",
"descCreate": "Introduce los detalles de la interfaz y el par de WireGuard.",
"name": "Nombre",
"namePlaceholder": "p. ej. WireGuard Casa",
"privateKey": "Clave Privada",
"privateKeyPlaceholder": "Clave privada codificada en Base64",
"address": "Dirección",
"addressPlaceholder": "p. ej. 10.0.0.2/24",
"dnsOptional": "DNS (opcional)",
"dnsPlaceholder": "p. ej. 1.1.1.1",
"mtuOptional": "MTU (opcional)",
"mtuPlaceholder": "p. ej. 1420",
"peerPublicKey": "Clave Pública del Par",
"peerPublicKeyPlaceholder": "Clave pública del par codificada en Base64",
"peerEndpoint": "Endpoint del Par",
"peerEndpointPlaceholder": "p. ej. vpn.example.com:51820",
"allowedIps": "IPs Permitidas",
"allowedIpsPlaceholder": "p. ej. 0.0.0.0/0, ::/0",
"keepaliveOptional": "Keepalive Persistente (opcional)",
"keepalivePlaceholder": "p. ej. 25",
"presharedKeyOptional": "Clave Precompartida (opcional)",
"presharedKeyPlaceholder": "Clave precompartida codificada en Base64",
"updateButton": "Actualizar VPN",
"createButton": "Crear VPN",
"nameRequired": "El nombre de la VPN es obligatorio",
"privateKeyRequired": "La clave privada es obligatoria",
"addressRequired": "La dirección es obligatoria",
"peerPublicKeyRequired": "La clave pública del par es obligatoria",
"peerEndpointRequired": "El endpoint del par es obligatorio",
"updated": "VPN actualizada correctamente",
"created": "VPN WireGuard creada correctamente",
"updateFailed": "Error al actualizar la VPN: {{error}}",
"createFailed": "Error al crear la VPN: {{error}}"
},
"import": {
"title": "Importar Configuración VPN",
"descDropzone": "Importa un archivo de configuración WireGuard (.conf)",
"descPreview": "Revisa la configuración VPN a importar",
"descResult": "Importación VPN completada",
"dropzonePrompt": "Suelta un archivo .conf de WireGuard aquí o haz clic para buscar",
"pasteHint": "Pegar desde el portapapeles con {{modKey}}+V",
"invalidContent": "El contenido no parece ser una configuración VPN válida",
"fileReadError": "Error al leer el archivo",
"wrongFileType": "Suelta un archivo .conf de WireGuard",
"configurationLabel": "Configuración {{type}}",
"endpointLabel": "Endpoint: {{endpoint}}",
"vpnNameLabel": "Nombre de la VPN",
"vpnNamePlaceholder": "Mi VPN",
"configPreview": "Vista Previa de la Configuración",
"importedSuccess": "VPN Importada Correctamente",
"importFailed": "Importación Fallida",
"importButton": "Importar VPN",
"doneButton": "Listo",
"failedGeneric": "Error al importar la configuración de VPN",
"defaultName": "VPN {{type}}"
},
"management": {
"loading": "Cargando VPN...",
"noneCreated": "Aún no hay configuraciones de VPN. Importa o crea una usando los botones de arriba.",
"editVpn": "Editar VPN",
"deleteVpn": "Eliminar VPN",
"cannotDelete_one": "No se puede eliminar: en uso por {{count}} perfil",
"cannotDelete_other": "No se puede eliminar: en uso por {{count}} perfiles",
"syncCannotDisable": "No se puede desactivar la sincronización mientras esta VPN esté en uso por perfiles sincronizados",
"deleteSuccess": "VPN eliminada correctamente",
"deleteFailed": "Error al eliminar la VPN",
"deleteTitle": "Eliminar VPN",
"deleteDescription": "Esta acción no se puede deshacer. Se eliminará permanentemente la VPN \"{{name}}\"."
}
},
"importProfile": {
"title": "Importar perfil de navegador",
"autoDetect": "Detección automática",
"manualImport": "Importación manual",
"detectedProfilesTitle": "Perfiles de navegador detectados",
"scanning": "Buscando perfiles de navegador...",
"noneFound": "No se encontraron perfiles de navegador en tu sistema.",
"noneFoundHint": "Prueba la importación manual si tienes perfiles en ubicaciones personalizadas.",
"selectProfile": "Seleccionar perfil:",
"selectProfilePlaceholder": "Elige un perfil detectado",
"pathLabel": "Ruta:",
"browserLabel": "Navegador:",
"newProfileName": "Nombre del nuevo perfil:",
"newProfileNamePlaceholder": "Introduce un nombre para el perfil importado",
"manualTitle": "Importación manual de perfil",
"browserType": "Tipo de navegador:",
"loadingBrowsers": "Cargando navegadores...",
"selectBrowserType": "Selecciona el tipo de navegador",
"profileFolderPath": "Ruta de la carpeta del perfil:",
"profileFolderPlaceholder": "Introduce la ruta completa a la carpeta del perfil",
"browseFolderTitle": "Buscar carpeta",
"examplePaths": "Rutas de ejemplo:",
"selectFolderTitle": "Seleccionar carpeta de perfil",
"folderDialogFailed": "Error al abrir el diálogo de carpeta",
"detectFailed": "Error al detectar los perfiles de navegador existentes",
"fillFields": "Por favor, completa todos los campos",
"selectAndName": "Selecciona un perfil y proporciona un nombre",
"profileNotFound": "Perfil seleccionado no encontrado",
"importedSuccess": "Perfil \"{{name}}\" importado correctamente",
"notInstalled": "{{browser}} no está instalado. Por favor descarga {{browser}} primero desde la ventana principal y luego intenta importar de nuevo.",
"importFailed": "Error al importar el perfil: {{error}}",
"proxyOptional": "Proxy (Opcional)",
"noProxy": "Sin proxy",
"nextButton": "Siguiente",
"importButton": "Importar",
"importedAs": "Este perfil se importará como un perfil de {{browser}}."
},
"syncTooltips": {
"syncing": "Sincronizando...",
"syncedAt": "Sincronizado {{time}}",
"synced": "Sincronizado",
"waiting": "En espera de sincronización",
"errorWith": "Error de sincronización: {{error}}",
"error": "Error de sincronización",
"notSynced": "Sin sincronizar"
},
"groupManagement": {
"description": "Administra tus grupos de perfiles",
"createGroup": "Crear grupo",
"noGroups": "Aún no hay grupos. Crea tu primer grupo usando el botón de arriba.",
"loading": "Cargando grupos...",
"profileCount_one": "{{count}} perfil",
"profileCount_other": "{{count}} perfiles",
"groupsLabel": "Grupos",
"profilesCol": "Perfiles",
"syncCannotDisable": "No se puede desactivar la sincronización mientras este grupo esté en uso por perfiles sincronizados",
"editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Eliminar grupo",
"loadFailed": "Error al cargar los grupos"
},
"proxyAssignment": {
"title": "Asignar proxy / VPN",
"description_one": "Asigna un proxy o VPN a {{count}} perfil seleccionado.",
"description_other": "Asigna un proxy o VPN a {{count}} perfiles seleccionados.",
"selectLabel": "Proxy / VPN",
"placeholder": "Selecciona un proxy o VPN",
"noProxy": "Sin proxy / VPN",
"searchPlaceholder": "Buscar proxies o VPN...",
"notFound": "No se encontraron proxies ni VPN.",
"assignButton": "Asignar",
"success": "Proxy/VPN asignado correctamente a {{count}} perfil(es)",
"failed": "Error al asignar proxy/VPN",
"selectedProfilesLabel": "Perfiles seleccionados:",
"assignProxyVpnLabel": "Asignar proxy / VPN:",
"noneOption": "Ninguno",
"noValidProfiles": "No hay perfiles válidos seleccionados.",
"vpnGroupHeading": "VPN",
"failedFallback": "Error al asignar proxy/VPN a los perfiles"
},
"groupAssignment": {
"title": "Asignar grupo",
"description_one": "Asigna un grupo a {{count}} perfil seleccionado.",
"description_other": "Asigna un grupo a {{count}} perfiles seleccionados.",
"selectLabel": "Grupo",
"placeholder": "Selecciona un grupo",
"noGroup": "Sin grupo (Predeterminado)",
"assignButton": "Asignar",
"success": "Grupo asignado correctamente a {{count}} perfil(es)",
"failed": "Error al asignar grupo",
"selectedProfilesLabel": "Perfiles seleccionados:",
"assignGroupLabel": "Asignar a grupo:",
"noValidProfiles": "No hay perfiles válidos seleccionados.",
"failedFallback": "Error al asignar grupo a los perfiles"
},
"profileSelector": {
"title": "Seleccionar perfil",
"description": "Elige un perfil para abrir con esta URL",
"searchPlaceholder": "Buscar perfiles...",
"noProfiles": "No hay perfiles disponibles",
"noResults": "Ningún perfil coincide con tu búsqueda",
"selectButton": "Seleccionar",
"launching": "Abriendo...",
"chooseProfileTitle": "Elegir perfil",
"openingUrl": "Abriendo URL:",
"urlCopied": "¡URL copiada al portapapeles!",
"selectProfileLabel": "Seleccionar perfil:",
"noneAvailableShort": "No hay perfiles disponibles. Crea un perfil primero.",
"noneAvailableLong": "Cierra este diálogo y crea un perfil desde la ventana principal para empezar.",
"chooseAProfile": "Elige un perfil",
"badgeProxy": "Proxy",
"badgeRunning": "En ejecución",
"badgeUnavailable": "No disponible",
"openButton": "Abrir"
},
"locationProxy": {
"title": "Proxy rápido por ubicación",
"description": "Elige un país por el que enrutar este perfil. Se creará un proxy automáticamente.",
"country": "País",
"selectCountry": "Selecciona un país",
"searchCountry": "Buscar país...",
"noCountriesFound": "No se encontraron países.",
"apply": "Aplicar",
"creating": "Creando proxy...",
"success": "Proxy de ubicación aplicado",
"failed": "Error al aplicar el proxy de ubicación",
"titleCreate": "Crear proxy por ubicación",
"descriptionCreate": "Crea un proxy geolocalizado con una sesión persistente de 24 horas",
"countryLabel": "País (obligatorio)",
"regionLabel": "Región (opcional)",
"cityLabel": "Ciudad (opcional)",
"ispLabel": "ISP (opcional)",
"nameLabel": "Nombre",
"namePlaceholder": "Nombre del proxy",
"loadingCountries": "Cargando países...",
"selectCountryPh": "Selecciona país",
"searchCountries": "Buscar países...",
"loadFailed": "Error al cargar los países",
"selectCountryFirst": "Selecciona primero un país",
"loadingRegions": "Cargando regiones...",
"noRegions": "No hay regiones disponibles",
"selectRegion": "Selecciona región",
"searchRegions": "Buscar regiones...",
"loadingCities": "Cargando ciudades...",
"noCities": "No hay ciudades disponibles",
"selectCity": "Selecciona ciudad",
"searchCities": "Buscar ciudades...",
"loadingIsps": "Cargando ISP...",
"noIsps": "No hay ISP disponibles",
"selectIsp": "Selecciona ISP",
"searchIsps": "Buscar ISP...",
"createSuccess": "Proxy de ubicación creado",
"createFailed": "Error al crear el proxy de ubicación",
"creatingButton": "Creando...",
"createButton": "Crear"
},
"launchOnLogin": {
"title": "¿Activar inicio al iniciar sesión?",
"description": "Ejecutarse en segundo plano ayuda a mantener vivos los proxies y navegadores.",
"declineButton": "No volver a preguntar",
"declining": "...",
"enableButton": "Activar",
"enableSuccess": "Inicio al iniciar sesión activado",
"enableFailed": "Error al activar el inicio al iniciar sesión",
"declineFailed": "Error al guardar la preferencia",
"tryAgain": "Por favor, inténtalo de nuevo"
},
"wayfernTerms": {
"title": "Términos y condiciones de Wayfern",
"description": "Antes de usar Donut Browser, debes leer y aceptar los Términos y Condiciones de Wayfern.",
"reviewLabel": "Por favor, revisa los Términos y Condiciones en:",
"agreeNotice": "Al hacer clic en \"Acepto\", aceptas estos términos.",
"acceptButton": "Acepto",
"acceptSuccess": "Términos aceptados correctamente",
"acceptFailed": "Error al aceptar los términos",
"tryAgain": "Por favor, inténtalo de nuevo"
},
"commercialTrial": {
"title": "Periodo de prueba comercial expirado",
"description": "Tu periodo de prueba comercial de 2 semanas ha terminado.",
"body": "Si usas Donut Browser con fines comerciales, debes adquirir una licencia comercial para continuar. Puedes seguir usándolo de forma personal gratis.",
"understandButton": "Entendido",
"failed": "Error al guardar el reconocimiento",
"tryAgain": "Por favor, inténtalo de nuevo"
},
"permissionDialog": {
"titleMicrophone": "Se requiere acceso al micrófono",
"titleCamera": "Se requiere acceso a la cámara",
"descMicrophone": "Donut Browser necesita acceso a tu micrófono para activar la funcionalidad de micrófono en los navegadores. Cada sitio web que quiera usar tu micrófono te lo pedirá individualmente.",
"descCamera": "Donut Browser necesita acceso a tu cámara para activar la funcionalidad de cámara en los navegadores. Cada sitio web que quiera usar tu cámara te lo pedirá individualmente.",
"grantedMicrophone": "¡Permiso concedido! Los navegadores lanzados desde Donut Browser ya pueden acceder a tu micrófono.",
"grantedCamera": "¡Permiso concedido! Los navegadores lanzados desde Donut Browser ya pueden acceder a tu cámara.",
"notGrantedMicrophone": "Permiso no concedido. Haz clic en el botón para solicitar acceso a tu micrófono.",
"notGrantedCamera": "Permiso no concedido. Haz clic en el botón para solicitar acceso a tu cámara.",
"doneButton": "Hecho",
"cancelButton": "Cancelar",
"grantAccessButton": "Conceder acceso",
"requestSuccessMicrophone": "Acceso al micrófono solicitado",
"requestSuccessCamera": "Acceso a la cámara solicitado",
"requestFailed": "Error al solicitar el permiso"
},
"traffic": {
"title": "Detalles de tráfico",
"bandwidthOverTime": "Ancho de banda en el tiempo",
"timePeriodPlaceholder": "Periodo",
"last1m": "Último 1 min",
"last5m": "Últimos 5 min",
"last30m": "Últimos 30 min",
"last1h": "Última 1 hora",
"last2h": "Últimas 2 horas",
"last4h": "Últimas 4 horas",
"last1d": "Último día",
"last7d": "Últimos 7 días",
"last30d": "Últimos 30 días",
"allTime": "Todo el tiempo",
"allTimeShort": "todo el tiempo",
"totalSuffix": "total",
"sentLabel": "Enviado ({{period}})",
"receivedLabel": "Recibido ({{period}})",
"requestsLabel": "Solicitudes ({{period}})",
"allTimeTraffic": "Tráfico total:",
"allTimeRequests": "Solicitudes totales:",
"proxyDisclaimer": "Nota: Si usas un proxy, VPN o servicio similar, tu proveedor podría calcular el tráfico de forma diferente debido a la sobrecarga de cifrado y diferencias de protocolo.",
"topByTraffic": "Principales dominios por tráfico ({{period}})",
"topByRequests": "Principales dominios por solicitudes ({{period}})",
"columnDomain": "Dominio",
"columnRequests": "Solicitudes",
"columnSent": "Enviado",
"columnReceived": "Recibido",
"columnTotal": "Tráfico total",
"uniqueIps": "IPs únicas ({{count}})",
"noData": "No hay datos de tráfico disponibles para este perfil.",
"noDataHint": "Los datos de tráfico aparecerán cuando lances el perfil.",
"sentLegend": "Enviado",
"receivedLegend": "Recibido",
"tooltipSent": "↑ Enviado: ",
"tooltipReceived": "↓ Recibido: "
},
"camoufoxDialog": {
"titleView": "Ver configuración de huella - {{name}} ({{browser}})",
"titleConfigure": "Configurar huella - {{name}} ({{browser}})",
"invalidFingerprint": "Configuración de huella inválida",
"invalidFingerprintDescription": "La configuración de huella contiene JSON inválido. Revisa la configuración avanzada.",
"saveFailed": "Error al guardar la configuración",
"unknownError": "Ocurrió un error desconocido"
},
"proxyCheck": {
"unknownLocation": "Desconocido",
"locationToast": "La ubicación de tu proxy es:",
"failed": "Falló la verificación del proxy: {{error}}",
"tooltipChecking": "Comprobando proxy...",
"tooltipIp": "IP: {{ip}}",
"tooltipChecked": "Comprobado {{time}}",
"tooltipFailed": "Fallo {{time}}",
"tooltipFailedTitle": "Falló la verificación del proxy",
"tooltipDefault": "Comprobar validez del proxy"
},
"vpnCheck": {
"valid": "La configuración de VPN \"{{name}}\" es válida",
"invalid": "La configuración de VPN \"{{name}}\" no es válida",
"failed": "Falló la verificación de la VPN: {{error}}",
"tooltipChecking": "Comprobando configuración de VPN...",
"tooltipValid": "Configuración válida",
"tooltipInvalid": "Configuración inválida",
"tooltipChecked": "Comprobado {{time}}",
"tooltipDefault": "Comprobar validez de la configuración de VPN"
},
"profileTable": {
"syncTooltipDisabled": "Sincronización desactivada",
"syncTooltipSyncing": "Sincronizando...",
"syncTooltipSyncedAt": "Sincronizado {{time}}",
"syncTooltipSynced": "Sincronizado",
"syncTooltipWaiting": "Esperando para sincronizar",
"syncTooltipErrorWith": "Error de sincronización: {{error}}",
"syncTooltipError": "Error de sincronización",
"syncTooltipNotSynced": "No sincronizado",
"noTags": "Sin etiquetas",
"syncTooltipCloseToSync": "Cierra el perfil para sincronizar",
"syncTooltipDisabledWithLast": "Sincronización desactivada, última sincronización {{time}}",
"addTagsPlaceholder": "Añadir etiquetas",
"tagsHeader": "Etiquetas",
"noteHeader": "Nota",
"vpnsHeading": "VPN",
"createByCountryHeading": "Crear por país"
},
"releaseTypeSelector": {
"noReleaseTypes": "No hay tipos de versión disponibles.",
"placeholder": "Selecciona el tipo de versión...",
"stable": "Estable",
"nightly": "Nightly",
"downloaded": "Descargado",
"downloadBrowser": "Descargar navegador",
"downloading": "Descargando..."
},
"dataTableActionBar": {
"selected": "{{count}} seleccionados",
"clearSelection": "Limpiar selección"
},
"appUpdate": {
"toast": {
"updateFailed": "Error al actualizar Donut Browser",
"restartFailed": "Error al reiniciar",
"updateReady": "Actualización lista, reinicia para aplicar",
"manualDownloadRequired": "Descarga manual requerida",
"restartNow": "Reiniciar ahora",
"viewRelease": "Ver lanzamiento",
"later": "Más tarde",
"uploading": "Subiendo",
"downloading": "Descargando"
}
},
"browserDownload": {
"toast": {
"fetchVersionsFailed": "Error al obtener las versiones de {{browser}}",
"foundNewVersions": "¡Se encontraron {{count}} nuevas versiones de {{browser}}!",
"totalAvailableVersions": "Total disponible: {{count}} versiones",
"downloadFailed": "Error al descargar {{browser}} {{version}}",
"calculating": "calculando...",
"extractionFailed": "{{browser}} {{version}}: error de extracción",
"extractionFailedDescription": "El archivo dañado fue eliminado. Se volverá a descargar en el próximo intento.",
"extracting": "Extrayendo archivos del navegador... No cierre la aplicación.",
"verifying": "Verificando archivos del navegador...",
"downloadingRolling": "Descargando compilación rolling release..."
}
},
"versionUpdater": {
"toast": {
"alreadyAvailable": "{{browser}} {{version}} ya disponible",
"updatingProfiles": "Actualizando configuraciones de perfil...",
"updateCompleted": "Actualización de {{browser}} completada",
"singleProfileUpdated": "El perfil \"{{name}}\" se ha actualizado a la versión {{version}}. Ya puedes iniciar tus navegadores con la última versión.",
"multipleProfilesUpdated": "Se han actualizado {{count}} perfiles a la versión {{version}}. Ya puedes iniciar tus navegadores con la última versión.",
"versionAvailable": "La versión {{version}} ya está disponible. Los perfiles en ejecución usarán la nueva versión al reiniciarse.",
"autoUpdateFailed": "Error al auto-actualizar {{browser}}",
"updateWithErrors": "Actualización completada con algunos errores",
"updateWithErrorsDescription": "Se encontraron {{newVersions}} nuevas versiones, {{failedUpdates}} navegadores no se pudieron actualizar",
"updateSuccess": "Versiones del navegador actualizadas correctamente",
"updateSuccessDescription": "Se encontraron {{newVersions}} nuevas versiones en {{successfulUpdates}} navegadores. Las descargas automáticas comenzarán pronto.",
"upToDate": "No se encontraron nuevas versiones del navegador",
"upToDateDescription": "Todas las versiones del navegador están actualizadas",
"updateAllFailed": "Error al actualizar las versiones del navegador"
}
}
}
+724 -34
View File
@@ -28,7 +28,9 @@
"refresh": "Actualiser",
"loading": "Chargement...",
"saveSettings": "Enregistrer les paramètres",
"moreInfo": "En savoir plus"
"moreInfo": "En savoir plus",
"downloading": "Téléchargement...",
"minimize": "Réduire"
},
"status": {
"active": "Actif",
@@ -56,7 +58,10 @@
"default": "Par défaut",
"custom": "Personnalisé",
"optional": "Optionnel",
"required": "Requis"
"required": "Requis",
"unknownProfile": "Inconnu",
"mode": "Mode",
"never": "Jamais"
},
"time": {
"days": "jours",
@@ -64,6 +69,33 @@
"minutes": "minutes",
"seconds": "secondes",
"remaining": "restants"
},
"aria": {
"selectAll": "Tout sélectionner",
"selectRow": "Sélectionner la ligne",
"selectProfile": "Sélectionner le profil",
"copy": "Copier dans le presse-papiers",
"copied": "Copié",
"showToken": "Afficher le jeton",
"hideToken": "Masquer le jeton"
},
"keys": {
"escape": "Échap"
},
"errors": {
"unknown": "Une erreur inconnue est survenue"
},
"window": {
"minimize": "Réduire"
},
"commandPalette": {
"title": "Palette de commandes",
"description": "Rechercher une commande à exécuter..."
},
"noResults": "Aucun résultat trouvé.",
"srOnly": {
"copy": "Copier",
"copied": "Copié"
}
},
"settings": {
@@ -85,7 +117,8 @@
"title": "Langue",
"description": "Choisissez votre langue préférée pour l'interface de l'application.",
"systemDefault": "Par défaut du système",
"selectLanguage": "Sélectionner la langue"
"selectLanguage": "Sélectionner la langue",
"interface": "Langue de l'interface"
},
"defaultBrowser": {
"title": "Navigateur par défaut",
@@ -100,7 +133,8 @@
"microphone": "Microphone",
"microphoneDescription": "Accès au microphone pour les applications du navigateur",
"camera": "Caméra",
"cameraDescription": "Accès à la caméra pour les applications du navigateur"
"cameraDescription": "Accès à la caméra pour les applications du navigateur",
"accessRequested": "Accès {{permission}} demandé"
},
"integrations": {
"title": "Intégrations",
@@ -134,7 +168,8 @@
"advanced": {
"title": "Avancé",
"clearCache": "Effacer tout le cache des versions",
"clearCacheDescription": "Efface toutes les données de versions de navigateurs en cache et actualise toutes les versions depuis leurs sources. Cela forcera un nouveau téléchargement des informations de version pour tous les navigateurs."
"clearCacheDescription": "Efface toutes les données de versions de navigateurs en cache et actualise toutes les versions depuis leurs sources. Cela forcera un nouveau téléchargement des informations de version pour tous les navigateurs.",
"clearCacheFailed": "Échec de la suppression du cache"
},
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées."
@@ -169,7 +204,9 @@
"note": "Note",
"group": "Groupe",
"proxy": "Proxy / VPN",
"lastLaunch": "Dernier lancement"
"lastLaunch": "Dernier lancement",
"empty": "Aucun profil trouvé.",
"notSelected": "Non sélectionné"
},
"actions": {
"launch": "Lancer",
@@ -205,7 +242,30 @@
"ephemeral": "Éphémère",
"ephemeralDescription": "Le navigateur est forcé d'écrire les données du profil en mémoire au lieu du disque. Les données sont supprimées à la fermeture du navigateur.",
"ephemeralBadge": "Éphémère",
"ephemeralAlpha": "Alpha"
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Supprimer les profils sélectionnés",
"description": "Cette action est irréversible. Elle supprimera définitivement {{count}} profil(s) et toutes les données associées.",
"confirmButton": "Supprimer {{count}} profil(s)"
},
"note": {
"empty": "Pas de note",
"placeholder": "Ajouter une note..."
},
"aria": {
"profileInfo": "Informations sur le profil"
},
"delete": {
"title": "Supprimer le profil",
"description": "Cette action est irréversible. Elle supprimera définitivement le profil « {{profileName}} » et toutes ses données associées.",
"confirmButton": "Supprimer le profil"
},
"actionBar": {
"assignToGroup": "Assigner à un groupe",
"assignProxy": "Assigner un proxy",
"assignExtensionGroup": "Assigner un groupe dextensions",
"copyCookies": "Copier les cookies"
}
},
"createProfile": {
"title": "Créer un nouveau profil",
@@ -228,7 +288,10 @@
"title": "Proxy / VPN",
"addProxy": "Ajouter un proxy",
"noProxy": "Pas de proxy / VPN",
"noProxiesAvailable": "Aucun proxy ou VPN disponible. Ajoutez-en un pour router le trafic de ce profil."
"noProxiesAvailable": "Aucun proxy ou VPN disponible. Ajoutez-en un pour router le trafic de ce profil.",
"search": "Rechercher proxies ou VPN...",
"notFound": "Aucun proxy ou VPN trouvé.",
"searchWithCountries": "Rechercher des proxies, VPN ou pays..."
},
"launchHook": {
"label": "URL du hook de lancement",
@@ -248,7 +311,8 @@
"chromiumSubtitle": "Propulsé par Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Propulsé par Camoufox",
"camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium."
"camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium.",
"platformUnavailable": "{{browser}} n'est pas encore disponible sur votre plateforme."
},
"deleteDialog": {
"title": "Supprimer le profil",
@@ -259,7 +323,31 @@
},
"proxies": {
"title": "Proxies",
"management": "Proxys et VPNs",
"management": {
"description": "Gérez vos configurations de proxys et VPN pour les réutiliser sur les profils",
"tabProxies": "Proxys",
"tabVpns": "VPN",
"create": "Créer",
"loading": "Chargement des proxys...",
"noneCreated": "Aucun proxy créé. Créez votre premier proxy avec le bouton ci-dessus.",
"usage": "Utilisation",
"syncCol": "Sync",
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce proxy est utilisé par des profils synchronisés",
"enableSync": "Activer la sync",
"disableSync": "Désactiver la sync",
"editProxy": "Modifier le proxy",
"deleteProxy": "Supprimer le proxy",
"cannotDelete_one": "Suppression impossible : utilisé par {{count}} profil",
"cannotDelete_other": "Suppression impossible : utilisé par {{count}} profils",
"syncEnabled": "Sync activée",
"syncDisabled": "Sync désactivée",
"updateSyncFailed": "Échec de la mise à jour de la sync",
"deleteSuccess": "Proxy supprimé avec succès",
"deleteFailed": "Échec de la suppression du proxy",
"deleteTitle": "Supprimer le proxy",
"deleteDescription": "Cette action est irréversible. Le proxy « {{name}} » sera supprimé définitivement.",
"title": "Proxys et VPN"
},
"add": "Ajouter un proxy",
"edit": "Modifier le proxy",
"delete": "Supprimer le proxy",
@@ -280,7 +368,12 @@
"password": "Mot de passe",
"passwordPlaceholder": "Optionnel",
"cipher": "Chiffrement",
"cipherPlaceholder": "aes-256-gcm"
"cipherPlaceholder": "aes-256-gcm",
"nameRequired": "Le nom du proxy est requis",
"hostPortRequired": "Hôte et port sont requis",
"ssCipherRequired": "Le chiffrement et le mot de passe sont requis pour Shadowsocks",
"selectType": "Sélectionnez le type de proxy",
"saveFailed": "Échec de la sauvegarde du proxy : {{error}}"
},
"types": {
"http": "HTTP",
@@ -318,6 +411,45 @@
"sync": {
"enabled": "Synchronisation activée",
"disabled": "Synchronisation désactivée"
},
"exportDialog": {
"title": "Exporter les proxys",
"description": "Exportez vos configurations de proxy dans un fichier",
"format": "Format d'export",
"json": "JSON",
"txt": "TXT (format URL)",
"preview": "Aperçu",
"noProxies": "Aucun proxy à exporter",
"downloaded": "{{filename}} téléchargé",
"failed": "Échec de l'export des proxys",
"copied": "Copié"
},
"importDialog": {
"title": "Importer des proxys",
"descDropzone": "Importer des proxys depuis un fichier JSON ou TXT",
"descPreview": "Vérifiez les proxys à importer",
"descAmbiguous": "Certains proxys ont des formats ambigus. Sélectionnez le bon format.",
"descResult": "Import terminé",
"dropzonePrompt": "Déposez un fichier de configuration de proxy",
"dropzoneFormats": "(.json, .txt)",
"pasteHint": "Coller depuis le presse-papiers avec {{modKey}}+V",
"wrongFileType": "Veuillez déposer un fichier .json ou .txt",
"fileReadError": "Échec de la lecture du fichier",
"fileProcessError": "Échec du traitement du fichier",
"noValidProxies": "Aucun proxy valide trouvé dans le fichier",
"namePrefix": "Préfixe du nom",
"namePrefixDefault": "Importé",
"namePrefixHint": "Les proxys seront nommés « {{prefix}} Proxy 1 », « {{prefix}} Proxy 2 », etc.",
"proxiesToImport": "Proxys à importer ({{count}})",
"invalidCount": "({{count}} invalides)",
"ambiguousIntro": "Les proxys suivants ont un format ambigu. Sélectionnez la bonne interprétation pour chacun.",
"imported": "Importés :",
"skippedDuplicates": "Ignorés (doublons) :",
"errors": "Erreurs",
"importButton": "Importer {{count}} proxys",
"continueButton": "Continuer",
"doneButton": "Terminé",
"failed": "Échec de l'import des proxys"
}
},
"groups": {
@@ -343,7 +475,31 @@
"sync": {
"enabled": "Synchronisation activée",
"disabled": "Synchronisation désactivée"
}
},
"createTitle": "Créer un Nouveau Groupe",
"createDescription": "Créez un nouveau groupe pour organiser vos profils de navigateur.",
"editTitle": "Modifier le Groupe",
"editDescription": "Mettez à jour le nom de votre groupe.",
"createSuccess": "Groupe créé avec succès",
"createFailed": "Échec de la création du groupe",
"updateSuccess": "Groupe mis à jour avec succès",
"updateFailed": "Échec de la mise à jour du groupe",
"deleteTitle": "Supprimer le Groupe",
"deleteDescription": "Cette action est irréversible. Cela supprimera définitivement le groupe.",
"deleteSuccess": "Groupe supprimé avec succès",
"deleteFailed": "Échec de la suppression du groupe",
"loadingProfiles": "Chargement des profils associés...",
"associatedProfiles": "Profils Associés ({{count}})",
"whatToDoWithProfiles": "Que faire de ces profils ?",
"moveToDefaultOption": "Déplacer les profils vers le groupe Par défaut",
"deleteAlongWithGroup": "Supprimer les profils avec le groupe",
"noAssociatedProfiles": "Ce groupe n'a pas de profils associés.",
"deleteGroup": "Supprimer le Groupe",
"deleteGroupAndProfiles": "Supprimer le Groupe et les Profils",
"loadProfilesFailed": "Échec du chargement des profils",
"unknownGroup": "Groupe inconnu",
"profileGroupsAriaLabel": "Groupes de profils",
"loading": "Chargement des groupes..."
},
"sync": {
"mode": {
@@ -366,7 +522,16 @@
"configureService": "Configurer le service de synchronisation"
},
"title": "Service de synchronisation",
"config": "Configuration de la synchronisation",
"config": {
"serverUrlRequired": "Veuillez saisir une URL de serveur",
"connectionSuccess": "Connexion réussie !",
"serverError": "Le serveur a répondu avec une erreur",
"connectFailed": "Échec de la connexion au serveur",
"settingsSaved": "Paramètres de synchronisation enregistrés",
"saveFailed": "Échec de lenregistrement des paramètres",
"disconnected": "Synchronisation déconnectée",
"disconnectFailed": "Échec de la déconnexion"
},
"serverUrl": "URL du serveur",
"serverUrlPlaceholder": "https://sync.exemple.com",
"token": "Jeton de synchronisation",
@@ -410,6 +575,12 @@
"profileLockedShort": "En cours d'utilisation",
"cannotLaunchLocked": "Impossible de lancer — le profil est utilisé par {{email}}",
"createdBy": "Créé par {{email}}"
},
"disabled": "Désactivée",
"toast": {
"profileSynced": "Profil '{{name}}' synchronisé avec succès",
"profileSyncFailed": "Échec de la synchronisation du profil '{{name}}'",
"profileSyncFailedWithError": "Échec de la synchronisation du profil '{{name}}' : {{error}}"
}
},
"integrations": {
@@ -447,7 +618,32 @@
"removedFromClaudeCode": "Supprimé de Claude Code",
"config": "Configuration MCP",
"copyConfig": "Copier la configuration"
}
},
"tabApi": "API locale",
"tabMcp": "MCP (Assistants IA)",
"apiEnableLabel": "Activer le serveur API local",
"apiEnableDescription": "Permet de gérer les profils, groupes et proxys via l'API REST.",
"apiPortLabel": "Port",
"apiTokenLabel": "Jeton d'authentification",
"apiTokenHint": "Inclure dans l'en-tête Authorization : Bearer {{tokenSlot}}",
"apiInvalidPort": "Port invalide",
"apiInvalidPortDescription": "Le port doit être entre 1 et 65535",
"apiPortInUse": "Le port {{port}} est déjà utilisé",
"apiFallbackPort": "Serveur démarré sur le port alternatif {{port}}",
"apiStarted": "Serveur API démarré sur le port {{port}}",
"apiRunning": "Serveur API en cours sur le port {{port}}",
"apiStopped": "Serveur API arrêté",
"apiToggleFailed": "Échec du basculement du serveur API",
"apiStartFailed": "Échec du démarrage du serveur API",
"apiUnknownError": "Erreur inconnue",
"tokenCopied": "Jeton copié",
"mcpEnableLabel": "Activer le serveur MCP (Model Context Protocol)",
"mcpEnableDescription": "Permet aux assistants IA comme Claude Desktop de contrôler les navigateurs.",
"mcpAcceptTermsFirst": "(Acceptez d'abord les conditions Wayfern dans les Paramètres)",
"mcpStarted": "Serveur MCP démarré sur le port {{port}}",
"mcpStopped": "Serveur MCP arrêté",
"mcpToggleFailed": "Échec du basculement du serveur MCP",
"openSettings": "Ouvrir les paramètres d'intégrations"
},
"import": {
"title": "Importer un profil",
@@ -465,7 +661,9 @@
"fingerprint": {
"title": "Empreinte digitale",
"randomize": "Randomiser au lancement",
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur."
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur.",
"osCpuPlaceholder": "p. ex., Intel Mac OS X 10.15",
"webglRendererPlaceholder": "p. ex., llvmpipe, ou similaire"
},
"os": {
"title": "Système d'exploitation",
@@ -499,7 +697,10 @@
"fingerprint": {
"title": "Empreinte digitale",
"randomize": "Randomiser au lancement",
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur."
"randomizeDescription": "Génère une nouvelle empreinte digitale à chaque lancement du navigateur.",
"platformPlaceholder": "p. ex., Win32, MacIntel, Linux x86_64",
"timezoneOffsetPlaceholder": "p. ex., 300 pour EST (UTC-5)",
"webglRendererPlaceholder": "p. ex., Intel(R) HD Graphics"
},
"os": {
"title": "Système d'exploitation",
@@ -522,6 +723,10 @@
"webrtc": "Bloquer WebRTC",
"webgl": "Bloquer WebGL"
}
},
"shared": {
"browserBehavior": "Comportement du navigateur",
"allowAddonsOpenTabs": "Autoriser les modules complémentaires à ouvrir automatiquement de nouveaux onglets"
}
},
"cookies": {
@@ -534,13 +739,53 @@
"selectCookies": "Sélectionner les cookies",
"allDomains": "Tous les domaines",
"selectedCount": "{{count}} cookie sélectionné",
"selectedCount_plural": "{{count}} cookies sélectionnés"
"selectedCount_plural": "{{count}} cookies sélectionnés",
"dialogDescription_one": "Copier les cookies d'un profil source vers {{count}} profil sélectionné.",
"dialogDescription_other": "Copier les cookies d'un profil source vers {{count}} profils sélectionnés.",
"sourceProfile": "Profil source",
"sourcePlaceholder": "Sélectionnez un profil pour copier les cookies",
"running": "(en cours)",
"targetProfiles": "Profils cibles ({{count}})",
"noOtherTargets": "Aucun autre profil Wayfern/Camoufox sélectionné",
"selectSourceFirst": "Sélectionnez d'abord un profil source",
"selectionStatus": "({{selected}} sur {{total}} sélectionnés)",
"searchPlaceholder": "Rechercher des domaines ou cookies...",
"noMatching": "Aucun cookie correspondant trouvé",
"noFound": "Aucun cookie trouvé",
"replaceNote": "Les cookies existants avec le même nom et domaine seront remplacés. Les autres cookies seront conservés.",
"cannotCopyRunningOne": "Impossible de copier les cookies : {{names}} est encore en cours.",
"cannotCopyRunningMany": "Impossible de copier les cookies : {{names}} sont encore en cours.",
"someErrors": "Des erreurs sont survenues : {{errors}}",
"successMessage": "{{copied}} cookies copiés avec succès ({{replaced}} remplacés)",
"failedMessage": "Échec de la copie des cookies : {{error}}",
"copyButton_one": "Copier {{count}} cookie",
"copyButton_other": "Copier {{count}} cookies",
"copyButtonEmpty": "Copier les cookies"
},
"success": "Cookies copiés avec succès",
"error": "Échec de la copie des cookies",
"management": {
"title": "Gestion des Cookies",
"menuItem": "Gestion des Cookies"
"menuItem": "Gestion des Cookies",
"tabImport": "Importer",
"tabExport": "Exporter",
"importDescription": "Importer des cookies depuis un fichier au format Netscape ou JSON.",
"dropPrompt": "Cliquez pour choisir un fichier de cookies",
"fileFormats": "(.txt, .cookies ou .json)",
"cookiesFound": "{{count}} cookies trouvés",
"importedSuccess": "{{imported}} cookies importés avec succès ({{replaced}} remplacés)",
"linesSkipped": "{{count}} ligne(s) ignorée(s)",
"fileReadError": "Échec de la lecture du fichier",
"loadFailed": "Échec du chargement des cookies : {{error}}",
"cookiesLabel": "Cookies",
"selectionStatus": "({{selected}} sur {{total}} sélectionnés)",
"selectAll": "Tout sélectionner",
"deselectAll": "Tout désélectionner",
"noCookies": "Aucun cookie trouvé dans ce profil",
"doneButton": "Terminé",
"importButton": "Importer",
"exportButton": "Exporter",
"backButton": "Retour"
},
"import": {
"title": "Importer des Cookies",
@@ -623,7 +868,31 @@
"maxLength": "Doit contenir au maximum {{max}} caractères",
"networkError": "Erreur réseau. Veuillez vérifier votre connexion.",
"serverError": "Erreur serveur. Veuillez réessayer plus tard.",
"unknownError": "Une erreur inconnue s'est produite. Veuillez réessayer."
"unknownError": "Une erreur inconnue s'est produite. Veuillez réessayer.",
"noProfilesForUrl": "Aucun profil disponible. Veuillez créer un profil avant douvrir des URL.",
"updateCamoufoxConfigFailed": "Échec de la mise à jour de la configuration camoufox : {{error}}",
"updateWayfernConfigFailed": "Échec de la mise à jour de la configuration wayfern : {{error}}",
"createProfileFailed": "Échec de la création du profil : {{error}}",
"launchBrowserFailed": "Échec du lancement du navigateur : {{error}}",
"cannotDeleteRunningProfile": "Impossible de supprimer le profil pendant que le navigateur est en cours dexécution. Arrêtez dabord le navigateur.",
"deleteProfileFailed": "Échec de la suppression du profil : {{error}}",
"renameProfileFailed": "Échec du renommage du profil : {{error}}",
"killBrowserFailed": "Échec de larrêt du navigateur : {{error}}",
"deleteSelectedProfilesFailed": "Échec de la suppression des profils sélectionnés : {{error}}",
"cookieCopyUnsupportedBrowser": "La copie de cookies ne fonctionne quavec les profils Wayfern et Camoufox",
"updateSyncSettingsFailed": "Échec de la mise à jour des paramètres de synchronisation",
"cloneProfileFailed": "Échec du clonage du profil : {{error}}",
"loadSupportedBrowsersFailed": "Échec du chargement des navigateurs pris en charge",
"setupExtensionListenersFailed": "Échec de la configuration des écouteurs d’événements dextensions : {{error}}",
"loadGroupsFailed": "Échec du chargement des groupes : {{error}}",
"setupGroupListenersFailed": "Échec de la configuration des écouteurs d’événements de groupes : {{error}}",
"loadProfilesFailed": "Échec du chargement des profils : {{error}}",
"setupProfileListenersFailed": "Échec de la configuration des écouteurs d’événements de profils : {{error}}",
"loadProxiesFailed": "Échec du chargement des proxies : {{error}}",
"setupProxyListenersFailed": "Échec de la configuration des écouteurs d’événements de proxies : {{error}}",
"loadVpnConfigsFailed": "Échec du chargement des configurations VPN : {{error}}",
"setupVpnListenersFailed": "Échec de la configuration des écouteurs d’événements VPN : {{error}}",
"themeNotFound": "Thème Tokyo Night introuvable"
},
"browser": {
"camoufox": "Camoufox",
@@ -654,10 +923,10 @@
"platform": "Plateforme",
"platformVersion": "Version de la plateforme",
"appVersion": "Version de l'application",
"osCpu": "OS CPU",
"osCpu": "CPU OS",
"hardwareConcurrency": "Concurrence matérielle",
"maxTouchPoints": "Points tactiles maximum",
"doNotTrack": "Do Not Track",
"doNotTrack": "Ne pas suivre",
"selectDntPlaceholder": "Sélectionner la valeur DNT",
"dntAllowed": "0 (suivi autorisé)",
"dntNotAllowed": "1 (suivi non autorisé)",
@@ -679,8 +948,8 @@
"outerHeight": "Hauteur extérieure",
"innerWidth": "Largeur intérieure",
"innerHeight": "Hauteur intérieure",
"screenX": "Screen X",
"screenY": "Screen Y",
"screenX": "Écran X",
"screenY": "Écran Y",
"geolocation": "Géolocalisation",
"timezoneAndGeolocation": "Fuseau horaire et géolocalisation",
"timezoneGeolocationDescription": "Ces valeurs remplacent les APIs de fuseau horaire et de géolocalisation du navigateur.",
@@ -694,15 +963,15 @@
"region": "Région",
"script": "Script",
"webglProperties": "Propriétés WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglVendor": "Fournisseur WebGL",
"webglRenderer": "Moteur de rendu WebGL",
"webglParameters": "Paramètres WebGL",
"webglParametersJson": "Paramètres WebGL (JSON)",
"webgl2Parameters": "Paramètres WebGL2",
"webglShaderPrecisionFormats": "Formats de précision WebGL Shader",
"webgl2ShaderPrecisionFormats": "Formats de précision WebGL2 Shader",
"webglShaderPrecisionFormats": "Formats de précision shader WebGL",
"webgl2ShaderPrecisionFormats": "Formats de précision shader WebGL2",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeed": "Graine de bruit Canvas",
"canvasNoiseSeedDescription": "Cette graine est utilisée pour générer une empreinte Canvas cohérente mais unique. Chaque profil doit avoir une graine différente.",
"fonts": "Polices",
"fontsJson": "Polices (JSON array)",
@@ -723,13 +992,16 @@
"maxChannelCount": "Nombre maximum de canaux",
"vendorInfo": "Informations du fournisseur",
"vendor": "Fournisseur",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"vendorSub": "Fournisseur Sub",
"productSub": "Produit Sub",
"brand": "Marque",
"brandVersion": "Version de la marque",
"proFeature": "Ceci est une fonctionnalité Pro",
"generateFingerprint": "Générer l'empreinte",
"refreshFingerprint": "Actualiser l'empreinte"
"refreshFingerprint": "Actualiser l'empreinte",
"canvasNoiseSeedPlaceholder": "Entrez une graine pour l'empreinte canvas",
"addFontsPlaceholder": "Ajouter des polices...",
"enterAsJson": "Entrez {{title}} en JSON"
},
"warnings": {
"windowResizeTitle": "Dimensions de fenêtre personnalisées",
@@ -869,7 +1141,9 @@
"syncEnabled": "Synchronisation activée",
"syncDisabled": "Synchronisation désactivée",
"syncEnableTooltip": "Activer la synchronisation",
"syncDisableTooltip": "Désactiver la synchronisation"
"syncDisableTooltip": "Désactiver la synchronisation",
"loadGroupsFailed": "Échec du chargement des groupes d'extensions",
"assignGroupFailed": "Échec de l'attribution du groupe d'extensions"
},
"pro": {
"badge": "PRO",
@@ -882,11 +1156,11 @@
"dnsBlocklist": {
"title": "Liste de blocage DNS",
"none": "Aucun",
"light": "Light",
"light": "Léger",
"normal": "Normal",
"pro": "Pro",
"proPlus": "Pro++",
"ultimate": "Ultimate",
"ultimate": "Ultime",
"settingsDescription": "Les listes de blocage DNS bloquent les publicités, les traqueurs et les domaines malveillants au niveau du proxy. Les listes sont automatiquement rafraîchies toutes les 12 heures.",
"manageLists": "Gérer les listes de blocage DNS",
"refreshAll": "Rafraîchir toutes les listes",
@@ -895,5 +1169,421 @@
"fresh": "À jour",
"stale": "Obsolète",
"notCached": "Non mis en cache"
},
"vpns": {
"form": {
"titleEdit": "Modifier le VPN",
"titleCreate": "Créer un VPN WireGuard",
"descEdit": "Mettez à jour le nom de votre configuration VPN.",
"descCreate": "Saisissez les détails de l'interface et du pair WireGuard.",
"name": "Nom",
"namePlaceholder": "ex. WireGuard Maison",
"privateKey": "Clé Privée",
"privateKeyPlaceholder": "Clé privée encodée en Base64",
"address": "Adresse",
"addressPlaceholder": "ex. 10.0.0.2/24",
"dnsOptional": "DNS (optionnel)",
"dnsPlaceholder": "ex. 1.1.1.1",
"mtuOptional": "MTU (optionnel)",
"mtuPlaceholder": "ex. 1420",
"peerPublicKey": "Clé Publique du Pair",
"peerPublicKeyPlaceholder": "Clé publique du pair encodée en Base64",
"peerEndpoint": "Endpoint du Pair",
"peerEndpointPlaceholder": "ex. vpn.example.com:51820",
"allowedIps": "IPs Autorisées",
"allowedIpsPlaceholder": "ex. 0.0.0.0/0, ::/0",
"keepaliveOptional": "Keepalive Persistant (optionnel)",
"keepalivePlaceholder": "ex. 25",
"presharedKeyOptional": "Clé Pré-Partagée (optionnel)",
"presharedKeyPlaceholder": "Clé pré-partagée encodée en Base64",
"updateButton": "Mettre à Jour le VPN",
"createButton": "Créer le VPN",
"nameRequired": "Le nom du VPN est requis",
"privateKeyRequired": "La clé privée est requise",
"addressRequired": "L'adresse est requise",
"peerPublicKeyRequired": "La clé publique du pair est requise",
"peerEndpointRequired": "L'endpoint du pair est requis",
"updated": "VPN mis à jour avec succès",
"created": "VPN WireGuard créé avec succès",
"updateFailed": "Échec de la mise à jour du VPN : {{error}}",
"createFailed": "Échec de la création du VPN : {{error}}"
},
"import": {
"title": "Importer la Configuration VPN",
"descDropzone": "Importez un fichier de configuration WireGuard (.conf)",
"descPreview": "Vérifiez la configuration VPN à importer",
"descResult": "Importation VPN terminée",
"dropzonePrompt": "Déposez un fichier .conf WireGuard ici ou cliquez pour parcourir",
"pasteHint": "Coller depuis le presse-papiers avec {{modKey}}+V",
"invalidContent": "Le contenu ne semble pas être une configuration VPN valide",
"fileReadError": "Échec de la lecture du fichier",
"wrongFileType": "Veuillez déposer un fichier .conf WireGuard",
"configurationLabel": "Configuration {{type}}",
"endpointLabel": "Endpoint : {{endpoint}}",
"vpnNameLabel": "Nom du VPN",
"vpnNamePlaceholder": "Mon VPN",
"configPreview": "Aperçu de la Configuration",
"importedSuccess": "VPN Importé avec Succès",
"importFailed": "Échec de l'Importation",
"importButton": "Importer le VPN",
"doneButton": "Terminé",
"failedGeneric": "Échec de l'import de la configuration VPN",
"defaultName": "VPN {{type}}"
},
"management": {
"loading": "Chargement des VPN...",
"noneCreated": "Aucune configuration VPN. Importez ou créez-en une avec les boutons ci-dessus.",
"editVpn": "Modifier le VPN",
"deleteVpn": "Supprimer le VPN",
"cannotDelete_one": "Suppression impossible : utilisé par {{count}} profil",
"cannotDelete_other": "Suppression impossible : utilisé par {{count}} profils",
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce VPN est utilisé par des profils synchronisés",
"deleteSuccess": "VPN supprimé avec succès",
"deleteFailed": "Échec de la suppression du VPN",
"deleteTitle": "Supprimer le VPN",
"deleteDescription": "Cette action est irréversible. Le VPN « {{name}} » sera supprimé définitivement."
}
},
"importProfile": {
"title": "Importer un profil de navigateur",
"autoDetect": "Détection automatique",
"manualImport": "Import manuel",
"detectedProfilesTitle": "Profils de navigateur détectés",
"scanning": "Recherche de profils de navigateur...",
"noneFound": "Aucun profil de navigateur trouvé sur votre système.",
"noneFoundHint": "Essayez l'import manuel si vos profils sont dans des emplacements personnalisés.",
"selectProfile": "Sélectionner un profil :",
"selectProfilePlaceholder": "Choisissez un profil détecté",
"pathLabel": "Chemin :",
"browserLabel": "Navigateur :",
"newProfileName": "Nom du nouveau profil :",
"newProfileNamePlaceholder": "Entrez un nom pour le profil importé",
"manualTitle": "Import manuel de profil",
"browserType": "Type de navigateur :",
"loadingBrowsers": "Chargement des navigateurs...",
"selectBrowserType": "Sélectionnez le type de navigateur",
"profileFolderPath": "Chemin du dossier du profil :",
"profileFolderPlaceholder": "Entrez le chemin complet vers le dossier du profil",
"browseFolderTitle": "Parcourir le dossier",
"examplePaths": "Exemples de chemins :",
"selectFolderTitle": "Sélectionnez un dossier de profil de navigateur",
"folderDialogFailed": "Échec de l'ouverture de la boîte de dialogue de dossier",
"detectFailed": "Échec de la détection des profils de navigateur existants",
"fillFields": "Veuillez remplir tous les champs",
"selectAndName": "Sélectionnez un profil et fournissez un nom",
"profileNotFound": "Profil sélectionné introuvable",
"importedSuccess": "Profil « {{name}} » importé avec succès",
"notInstalled": "{{browser}} n'est pas installé. Veuillez télécharger {{browser}} depuis la fenêtre principale puis réessayer.",
"importFailed": "Échec de l'import du profil : {{error}}",
"proxyOptional": "Proxy (optionnel)",
"noProxy": "Aucun proxy",
"nextButton": "Suivant",
"importButton": "Importer",
"importedAs": "Ce profil sera importé en tant que profil {{browser}}."
},
"syncTooltips": {
"syncing": "Synchronisation...",
"syncedAt": "Synchronisé {{time}}",
"synced": "Synchronisé",
"waiting": "En attente de synchronisation",
"errorWith": "Erreur de synchronisation : {{error}}",
"error": "Erreur de synchronisation",
"notSynced": "Non synchronisé"
},
"groupManagement": {
"description": "Gérez vos groupes de profils",
"createGroup": "Créer un groupe",
"noGroups": "Aucun groupe créé. Créez votre premier groupe avec le bouton ci-dessus.",
"loading": "Chargement des groupes...",
"profileCount_one": "{{count}} profil",
"profileCount_other": "{{count}} profils",
"groupsLabel": "Groupes",
"profilesCol": "Profils",
"syncCannotDisable": "La sync ne peut pas être désactivée tant que ce groupe est utilisé par des profils synchronisés",
"editGroupTooltip": "Modifier le groupe",
"deleteGroupTooltip": "Supprimer le groupe",
"loadFailed": "Échec du chargement des groupes"
},
"proxyAssignment": {
"title": "Assigner un proxy / VPN",
"description_one": "Assigner un proxy ou VPN à {{count}} profil sélectionné.",
"description_other": "Assigner un proxy ou VPN à {{count}} profils sélectionnés.",
"selectLabel": "Proxy / VPN",
"placeholder": "Sélectionnez un proxy ou VPN",
"noProxy": "Aucun proxy / VPN",
"searchPlaceholder": "Rechercher des proxys ou VPN...",
"notFound": "Aucun proxy ou VPN trouvé.",
"assignButton": "Assigner",
"success": "Proxy/VPN assigné à {{count}} profil(s)",
"failed": "Échec de l'assignation du proxy/VPN",
"selectedProfilesLabel": "Profils sélectionnés :",
"assignProxyVpnLabel": "Assigner un proxy / VPN :",
"noneOption": "Aucun",
"noValidProfiles": "Aucun profil valide sélectionné.",
"vpnGroupHeading": "VPN",
"failedFallback": "Échec de l'assignation du proxy/VPN aux profils"
},
"groupAssignment": {
"title": "Assigner un groupe",
"description_one": "Assigner un groupe à {{count}} profil sélectionné.",
"description_other": "Assigner un groupe à {{count}} profils sélectionnés.",
"selectLabel": "Groupe",
"placeholder": "Sélectionnez un groupe",
"noGroup": "Aucun groupe (par défaut)",
"assignButton": "Assigner",
"success": "Groupe assigné à {{count}} profil(s)",
"failed": "Échec de l'assignation du groupe",
"selectedProfilesLabel": "Profils sélectionnés :",
"assignGroupLabel": "Assigner à un groupe :",
"noValidProfiles": "Aucun profil valide sélectionné.",
"failedFallback": "Échec de l'assignation du groupe aux profils"
},
"profileSelector": {
"title": "Sélectionner un profil",
"description": "Choisissez un profil pour ouvrir cette URL",
"searchPlaceholder": "Rechercher des profils...",
"noProfiles": "Aucun profil disponible",
"noResults": "Aucun profil ne correspond à votre recherche",
"selectButton": "Sélectionner",
"launching": "Lancement...",
"chooseProfileTitle": "Choisir un profil",
"openingUrl": "Ouverture de l'URL :",
"urlCopied": "URL copiée dans le presse-papiers !",
"selectProfileLabel": "Sélectionner un profil :",
"noneAvailableShort": "Aucun profil disponible. Veuillez d'abord créer un profil.",
"noneAvailableLong": "Fermez cette boîte de dialogue et créez un profil depuis la fenêtre principale pour commencer.",
"chooseAProfile": "Choisissez un profil",
"badgeProxy": "Proxy",
"badgeRunning": "En cours",
"badgeUnavailable": "Indisponible",
"openButton": "Ouvrir"
},
"locationProxy": {
"title": "Proxy rapide par lieu",
"description": "Choisissez un pays pour router ce profil. Un proxy sera créé automatiquement.",
"country": "Pays",
"selectCountry": "Sélectionnez un pays",
"searchCountry": "Rechercher un pays...",
"noCountriesFound": "Aucun pays trouvé.",
"apply": "Appliquer",
"creating": "Création du proxy...",
"success": "Proxy de localisation appliqué",
"failed": "Échec de l'application du proxy de localisation",
"titleCreate": "Créer un proxy de localisation",
"descriptionCreate": "Créez un proxy géolocalisé avec une session persistante de 24 heures",
"countryLabel": "Pays (obligatoire)",
"regionLabel": "Région (optionnel)",
"cityLabel": "Ville (optionnel)",
"ispLabel": "FAI (optionnel)",
"nameLabel": "Nom",
"namePlaceholder": "Nom du proxy",
"loadingCountries": "Chargement des pays...",
"selectCountryPh": "Sélectionnez un pays",
"searchCountries": "Rechercher des pays...",
"loadFailed": "Échec du chargement des pays",
"selectCountryFirst": "Sélectionnez d'abord un pays",
"loadingRegions": "Chargement des régions...",
"noRegions": "Aucune région disponible",
"selectRegion": "Sélectionnez une région",
"searchRegions": "Rechercher des régions...",
"loadingCities": "Chargement des villes...",
"noCities": "Aucune ville disponible",
"selectCity": "Sélectionnez une ville",
"searchCities": "Rechercher des villes...",
"loadingIsps": "Chargement des FAI...",
"noIsps": "Aucun FAI disponible",
"selectIsp": "Sélectionnez un FAI",
"searchIsps": "Rechercher des FAI...",
"createSuccess": "Proxy de localisation créé",
"createFailed": "Échec de la création du proxy de localisation",
"creatingButton": "Création...",
"createButton": "Créer"
},
"launchOnLogin": {
"title": "Activer le démarrage à la connexion ?",
"description": "Tourner en arrière-plan permet de garder vos proxys et navigateurs actifs.",
"declineButton": "Ne plus demander",
"declining": "...",
"enableButton": "Activer",
"enableSuccess": "Démarrage à la connexion activé",
"enableFailed": "Échec de l'activation du démarrage à la connexion",
"declineFailed": "Échec de l'enregistrement de la préférence",
"tryAgain": "Veuillez réessayer"
},
"wayfernTerms": {
"title": "Conditions générales de Wayfern",
"description": "Avant d'utiliser Donut Browser, vous devez lire et accepter les Conditions Générales de Wayfern.",
"reviewLabel": "Veuillez consulter les Conditions Générales sur :",
"agreeNotice": "En cliquant sur « J'accepte », vous acceptez ces conditions.",
"acceptButton": "J'accepte",
"acceptSuccess": "Conditions acceptées avec succès",
"acceptFailed": "Échec de l'acceptation des conditions",
"tryAgain": "Veuillez réessayer"
},
"commercialTrial": {
"title": "Période d'essai commerciale expirée",
"description": "Votre période d'essai commercial de 2 semaines est terminée.",
"body": "Si vous utilisez Donut Browser à des fins professionnelles, vous devez acheter une licence commerciale pour continuer. Vous pouvez toujours l'utiliser gratuitement à des fins personnelles.",
"understandButton": "J'ai compris",
"failed": "Échec de l'enregistrement de la confirmation",
"tryAgain": "Veuillez réessayer"
},
"permissionDialog": {
"titleMicrophone": "Accès au microphone requis",
"titleCamera": "Accès à la caméra requis",
"descMicrophone": "Donut Browser a besoin d'accéder à votre microphone pour activer la fonctionnalité microphone dans les navigateurs. Chaque site web qui voudra utiliser votre microphone vous demandera quand même votre permission individuellement.",
"descCamera": "Donut Browser a besoin d'accéder à votre caméra pour activer la fonctionnalité caméra dans les navigateurs. Chaque site web qui voudra utiliser votre caméra vous demandera quand même votre permission individuellement.",
"grantedMicrophone": "Permission accordée ! Les navigateurs lancés depuis Donut Browser peuvent maintenant accéder à votre microphone.",
"grantedCamera": "Permission accordée ! Les navigateurs lancés depuis Donut Browser peuvent maintenant accéder à votre caméra.",
"notGrantedMicrophone": "Permission non accordée. Cliquez sur le bouton pour demander l'accès à votre microphone.",
"notGrantedCamera": "Permission non accordée. Cliquez sur le bouton pour demander l'accès à votre caméra.",
"doneButton": "Terminé",
"cancelButton": "Annuler",
"grantAccessButton": "Accorder l'accès",
"requestSuccessMicrophone": "Accès au microphone demandé",
"requestSuccessCamera": "Accès à la caméra demandé",
"requestFailed": "Échec de la demande de permission"
},
"traffic": {
"title": "Détails du trafic",
"bandwidthOverTime": "Bande passante au fil du temps",
"timePeriodPlaceholder": "Période",
"last1m": "Dernière 1 min",
"last5m": "Dernières 5 min",
"last30m": "Dernières 30 min",
"last1h": "Dernière 1 h",
"last2h": "Dernières 2 h",
"last4h": "Dernières 4 h",
"last1d": "Dernier jour",
"last7d": "Derniers 7 jours",
"last30d": "Derniers 30 jours",
"allTime": "Tout",
"allTimeShort": "tout le temps",
"totalSuffix": "total",
"sentLabel": "Envoyé ({{period}})",
"receivedLabel": "Reçu ({{period}})",
"requestsLabel": "Requêtes ({{period}})",
"allTimeTraffic": "Trafic total :",
"allTimeRequests": "Requêtes totales :",
"proxyDisclaimer": "Note : Si vous utilisez un proxy, VPN ou service similaire, votre fournisseur peut calculer le trafic différemment en raison du surcoût de chiffrement et des différences de protocole.",
"topByTraffic": "Principaux domaines par trafic ({{period}})",
"topByRequests": "Principaux domaines par requêtes ({{period}})",
"columnDomain": "Domaine",
"columnRequests": "Requêtes",
"columnSent": "Envoyé",
"columnReceived": "Reçu",
"columnTotal": "Trafic total",
"uniqueIps": "IPs uniques ({{count}})",
"noData": "Aucune donnée de trafic disponible pour ce profil.",
"noDataHint": "Les données de trafic apparaîtront après le lancement du profil.",
"sentLegend": "Envoyé",
"receivedLegend": "Reçu",
"tooltipSent": "↑ Envoyé : ",
"tooltipReceived": "↓ Reçu : "
},
"camoufoxDialog": {
"titleView": "Voir les paramètres d'empreinte - {{name}} ({{browser}})",
"titleConfigure": "Configurer les paramètres d'empreinte - {{name}} ({{browser}})",
"invalidFingerprint": "Configuration d'empreinte invalide",
"invalidFingerprintDescription": "La configuration d'empreinte contient du JSON invalide. Vérifiez vos paramètres avancés.",
"saveFailed": "Échec de la sauvegarde de la configuration",
"unknownError": "Une erreur inconnue s'est produite"
},
"proxyCheck": {
"unknownLocation": "Inconnu",
"locationToast": "L'emplacement de votre proxy est :",
"failed": "Échec de la vérification du proxy : {{error}}",
"tooltipChecking": "Vérification du proxy...",
"tooltipIp": "IP : {{ip}}",
"tooltipChecked": "Vérifié {{time}}",
"tooltipFailed": "Échec {{time}}",
"tooltipFailedTitle": "Échec de la vérification du proxy",
"tooltipDefault": "Vérifier la validité du proxy"
},
"vpnCheck": {
"valid": "La configuration VPN « {{name}} » est valide",
"invalid": "La configuration VPN « {{name}} » est invalide",
"failed": "Échec de la vérification du VPN : {{error}}",
"tooltipChecking": "Vérification de la configuration VPN...",
"tooltipValid": "Configuration valide",
"tooltipInvalid": "Configuration invalide",
"tooltipChecked": "Vérifié {{time}}",
"tooltipDefault": "Vérifier la validité de la configuration VPN"
},
"profileTable": {
"syncTooltipDisabled": "Sync désactivée",
"syncTooltipSyncing": "Synchronisation...",
"syncTooltipSyncedAt": "Synchronisé {{time}}",
"syncTooltipSynced": "Synchronisé",
"syncTooltipWaiting": "En attente de synchronisation",
"syncTooltipErrorWith": "Erreur de sync : {{error}}",
"syncTooltipError": "Erreur de synchronisation",
"syncTooltipNotSynced": "Non synchronisé",
"noTags": "Aucune étiquette",
"syncTooltipCloseToSync": "Fermez le profil pour synchroniser",
"syncTooltipDisabledWithLast": "Sync désactivée, dernière sync {{time}}",
"addTagsPlaceholder": "Ajouter des étiquettes",
"tagsHeader": "Étiquettes",
"noteHeader": "Note",
"vpnsHeading": "VPN",
"createByCountryHeading": "Créer par pays"
},
"releaseTypeSelector": {
"noReleaseTypes": "Aucun type de version disponible.",
"placeholder": "Sélectionnez le type de version...",
"stable": "Stable",
"nightly": "Nightly",
"downloaded": "Téléchargé",
"downloadBrowser": "Télécharger le navigateur",
"downloading": "Téléchargement..."
},
"dataTableActionBar": {
"selected": "{{count}} sélectionné(s)",
"clearSelection": "Effacer la sélection"
},
"appUpdate": {
"toast": {
"updateFailed": "Échec de la mise à jour de Donut Browser",
"restartFailed": "Échec du redémarrage",
"updateReady": "Mise à jour prête, redémarrer pour appliquer",
"manualDownloadRequired": "Téléchargement manuel requis",
"restartNow": "Redémarrer maintenant",
"viewRelease": "Voir la version",
"later": "Plus tard",
"uploading": "Envoi",
"downloading": "Téléchargement"
}
},
"browserDownload": {
"toast": {
"fetchVersionsFailed": "Échec de la récupération des versions de {{browser}}",
"foundNewVersions": "{{count}} nouvelles versions de {{browser}} trouvées !",
"totalAvailableVersions": "Total disponible : {{count}} versions",
"downloadFailed": "Échec du téléchargement de {{browser}} {{version}}",
"calculating": "calcul en cours...",
"extractionFailed": "{{browser}} {{version}} : échec de lextraction",
"extractionFailedDescription": "Le fichier corrompu a été supprimé. Il sera retéléchargé lors de la prochaine tentative.",
"extracting": "Extraction des fichiers du navigateur... Ne fermez pas l'application.",
"verifying": "Vérification des fichiers du navigateur...",
"downloadingRolling": "Téléchargement de la version rolling release..."
}
},
"versionUpdater": {
"toast": {
"alreadyAvailable": "{{browser}} {{version}} déjà disponible",
"updatingProfiles": "Mise à jour des configurations de profil...",
"updateCompleted": "Mise à jour de {{browser}} terminée",
"singleProfileUpdated": "Le profil « {{name}} » a été mis à jour vers la version {{version}}. Vous pouvez maintenant lancer vos navigateurs avec la dernière version.",
"multipleProfilesUpdated": "{{count}} profils ont été mis à jour vers la version {{version}}. Vous pouvez maintenant lancer vos navigateurs avec la dernière version.",
"versionAvailable": "La version {{version}} est désormais disponible. Les profils en cours dexécution utiliseront la nouvelle version au redémarrage.",
"autoUpdateFailed": "Échec de la mise à jour automatique de {{browser}}",
"updateWithErrors": "Mise à jour terminée avec des erreurs",
"updateWithErrorsDescription": "{{newVersions}} nouvelles versions trouvées, {{failedUpdates}} navigateurs nont pas pu être mis à jour",
"updateSuccess": "Versions de navigateur mises à jour avec succès",
"updateSuccessDescription": "{{newVersions}} nouvelles versions trouvées sur {{successfulUpdates}} navigateurs. Les téléchargements automatiques commenceront sous peu.",
"upToDate": "Aucune nouvelle version de navigateur trouvée",
"upToDateDescription": "Toutes les versions des navigateurs sont à jour",
"updateAllFailed": "Échec de la mise à jour des versions des navigateurs"
}
}
}
+725 -35
View File
@@ -28,7 +28,9 @@
"refresh": "更新",
"loading": "読み込み中...",
"saveSettings": "設定を保存",
"moreInfo": "詳細"
"moreInfo": "詳細",
"downloading": "ダウンロード中...",
"minimize": "最小化"
},
"status": {
"active": "アクティブ",
@@ -56,7 +58,10 @@
"default": "デフォルト",
"custom": "カスタム",
"optional": "任意",
"required": "必須"
"required": "必須",
"unknownProfile": "不明",
"mode": "モード",
"never": "一度もありません"
},
"time": {
"days": "日",
@@ -64,6 +69,33 @@
"minutes": "分",
"seconds": "秒",
"remaining": "残り"
},
"aria": {
"selectAll": "すべて選択",
"selectRow": "行を選択",
"selectProfile": "プロファイルを選択",
"copy": "クリップボードにコピー",
"copied": "コピーしました",
"showToken": "トークンを表示",
"hideToken": "トークンを非表示"
},
"keys": {
"escape": "Esc"
},
"errors": {
"unknown": "不明なエラーが発生しました"
},
"window": {
"minimize": "最小化"
},
"commandPalette": {
"title": "コマンドパレット",
"description": "実行するコマンドを検索..."
},
"noResults": "結果が見つかりません。",
"srOnly": {
"copy": "コピー",
"copied": "コピーしました"
}
},
"settings": {
@@ -85,7 +117,8 @@
"title": "言語",
"description": "アプリケーションインターフェースの言語を選択します。",
"systemDefault": "システムデフォルト",
"selectLanguage": "言語を選択"
"selectLanguage": "言語を選択",
"interface": "インターフェース言語"
},
"defaultBrowser": {
"title": "デフォルトブラウザ",
@@ -100,7 +133,8 @@
"microphone": "マイク",
"microphoneDescription": "ブラウザアプリケーションのマイクアクセス",
"camera": "カメラ",
"cameraDescription": "ブラウザアプリケーションのカメラアクセス"
"cameraDescription": "ブラウザアプリケーションのカメラアクセス",
"accessRequested": "{{permission}} アクセスを要求しました"
},
"integrations": {
"title": "統合",
@@ -134,7 +168,8 @@
"advanced": {
"title": "詳細設定",
"clearCache": "すべてのバージョンキャッシュをクリア",
"clearCacheDescription": "キャッシュされたすべてのブラウザバージョンデータをクリアし、すべてのブラウザバージョンをソースから更新します。これにより、すべてのブラウザのバージョン情報が強制的に再ダウンロードされます。"
"clearCacheDescription": "キャッシュされたすべてのブラウザバージョンデータをクリアし、すべてのブラウザバージョンをソースから更新します。これにより、すべてのブラウザのバージョン情報が強制的に再ダウンロードされます。",
"clearCacheFailed": "キャッシュのクリアに失敗しました"
},
"disableAutoUpdates": "アプリの自動更新を無効にする",
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。"
@@ -169,7 +204,9 @@
"note": "メモ",
"group": "グループ",
"proxy": "プロキシ / VPN",
"lastLaunch": "最終起動"
"lastLaunch": "最終起動",
"empty": "プロファイルが見つかりません。",
"notSelected": "未選択"
},
"actions": {
"launch": "起動",
@@ -205,7 +242,30 @@
"ephemeral": "一時的",
"ephemeralDescription": "ブラウザはプロファイルデータをディスクではなくメモリに書き込むよう強制されます。ブラウザを閉じるとデータは削除されます。",
"ephemeralBadge": "一時的",
"ephemeralAlpha": "Alpha"
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "選択したプロファイルを削除",
"description": "この操作は取り消せません。{{count}} 個のプロファイルと関連するすべてのデータが永久に削除されます。",
"confirmButton": "{{count}} 個のプロファイルを削除"
},
"note": {
"empty": "メモなし",
"placeholder": "メモを追加..."
},
"aria": {
"profileInfo": "プロファイル情報"
},
"delete": {
"title": "プロファイルを削除",
"description": "この操作は取り消せません。プロファイル「{{profileName}}」と関連するすべてのデータが永久に削除されます。",
"confirmButton": "プロファイルを削除"
},
"actionBar": {
"assignToGroup": "グループに割り当て",
"assignProxy": "プロキシを割り当て",
"assignExtensionGroup": "拡張機能グループを割り当て",
"copyCookies": "Cookieをコピー"
}
},
"createProfile": {
"title": "新しいプロファイルを作成",
@@ -228,7 +288,10 @@
"title": "プロキシ / VPN",
"addProxy": "プロキシを追加",
"noProxy": "プロキシ / VPNなし",
"noProxiesAvailable": "利用可能なプロキシまたはVPNがありません。このプロファイルのトラフィックをルーティングするために追加してください。"
"noProxiesAvailable": "利用可能なプロキシまたはVPNがありません。このプロファイルのトラフィックをルーティングするために追加してください。",
"search": "プロキシまたは VPN を検索...",
"notFound": "プロキシまたは VPN が見つかりません。",
"searchWithCountries": "プロキシ、VPN、または国を検索..."
},
"launchHook": {
"label": "起動フックURL",
@@ -248,7 +311,8 @@
"chromiumSubtitle": "Wayfern搭載",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Camoufox搭載",
"camoufoxWarning": "FirefoxCamoufox)はサードパーティの組織によって管理されています。本番環境での使用にはChromiumをご利用ください。"
"camoufoxWarning": "FirefoxCamoufox)はサードパーティの組織によって管理されています。本番環境での使用にはChromiumをご利用ください。",
"platformUnavailable": "{{browser}} はまだお使いのプラットフォームで利用できません。"
},
"deleteDialog": {
"title": "プロファイルを削除",
@@ -259,7 +323,31 @@
},
"proxies": {
"title": "プロキシ",
"management": "プロキシ & VPN",
"management": {
"description": "プロキシと VPN の構成を管理し、プロファイル間で再利用します",
"tabProxies": "プロキシ",
"tabVpns": "VPN",
"create": "作成",
"loading": "プロキシを読み込み中...",
"noneCreated": "プロキシはまだありません。上のボタンから最初のプロキシを作成してください。",
"usage": "使用状況",
"syncCol": "同期",
"syncCannotDisable": "このプロキシが同期されたプロファイルで使用されている間は同期を無効にできません",
"enableSync": "同期を有効にする",
"disableSync": "同期を無効にする",
"editProxy": "プロキシを編集",
"deleteProxy": "プロキシを削除",
"cannotDelete_one": "削除できません: {{count}} プロファイルで使用中",
"cannotDelete_other": "削除できません: {{count}} プロファイルで使用中",
"syncEnabled": "同期が有効になりました",
"syncDisabled": "同期が無効になりました",
"updateSyncFailed": "同期の更新に失敗しました",
"deleteSuccess": "プロキシを削除しました",
"deleteFailed": "プロキシの削除に失敗しました",
"deleteTitle": "プロキシを削除",
"deleteDescription": "この操作は取り消せません。プロキシ「{{name}}」は完全に削除されます。",
"title": "プロキシと VPN"
},
"add": "プロキシを追加",
"edit": "プロキシを編集",
"delete": "プロキシを削除",
@@ -280,7 +368,12 @@
"password": "パスワード",
"passwordPlaceholder": "任意",
"cipher": "暗号方式",
"cipherPlaceholder": "aes-256-gcm"
"cipherPlaceholder": "aes-256-gcm",
"nameRequired": "プロキシ名が必要です",
"hostPortRequired": "ホストとポートが必要です",
"ssCipherRequired": "Shadowsocks には暗号とパスワードが必要です",
"selectType": "プロキシの種類を選択",
"saveFailed": "プロキシの保存に失敗しました: {{error}}"
},
"types": {
"http": "HTTP",
@@ -318,6 +411,45 @@
"sync": {
"enabled": "同期有効",
"disabled": "同期無効"
},
"exportDialog": {
"title": "プロキシをエクスポート",
"description": "プロキシ設定をファイルにエクスポートします",
"format": "エクスポート形式",
"json": "JSON",
"txt": "TXT (URL 形式)",
"preview": "プレビュー",
"noProxies": "エクスポートするプロキシがありません",
"downloaded": "{{filename}} をダウンロードしました",
"failed": "プロキシのエクスポートに失敗しました",
"copied": "コピーしました"
},
"importDialog": {
"title": "プロキシをインポート",
"descDropzone": "JSON または TXT ファイルからプロキシをインポート",
"descPreview": "インポートするプロキシを確認",
"descAmbiguous": "一部のプロキシに曖昧な形式があります。正しい形式を選択してください。",
"descResult": "インポートが完了しました",
"dropzonePrompt": "プロキシ構成ファイルをドロップしてください",
"dropzoneFormats": "(.json, .txt)",
"pasteHint": "{{modKey}}+V でクリップボードから貼り付け",
"wrongFileType": ".json または .txt ファイルをドロップしてください",
"fileReadError": "ファイルの読み込みに失敗しました",
"fileProcessError": "ファイルの処理に失敗しました",
"noValidProxies": "ファイルに有効なプロキシが見つかりませんでした",
"namePrefix": "名前のプレフィックス",
"namePrefixDefault": "Imported",
"namePrefixHint": "プロキシは「{{prefix}} Proxy 1」「{{prefix}} Proxy 2」などの名前になります。",
"proxiesToImport": "インポートするプロキシ ({{count}})",
"invalidCount": "({{count}} 個無効)",
"ambiguousIntro": "以下のプロキシは曖昧な形式です。それぞれ正しい解釈を選択してください。",
"imported": "インポート済み:",
"skippedDuplicates": "スキップ (重複):",
"errors": "エラー",
"importButton": "{{count}} 個のプロキシをインポート",
"continueButton": "続ける",
"doneButton": "完了",
"failed": "プロキシのインポートに失敗しました"
}
},
"groups": {
@@ -343,7 +475,31 @@
"sync": {
"enabled": "同期有効",
"disabled": "同期無効"
}
},
"createTitle": "新しいグループを作成",
"createDescription": "ブラウザプロファイルを整理する新しいグループを作成します。",
"editTitle": "グループを編集",
"editDescription": "グループの名前を更新します。",
"createSuccess": "グループを正常に作成しました",
"createFailed": "グループの作成に失敗しました",
"updateSuccess": "グループを正常に更新しました",
"updateFailed": "グループの更新に失敗しました",
"deleteTitle": "グループを削除",
"deleteDescription": "この操作は取り消せません。グループが完全に削除されます。",
"deleteSuccess": "グループを正常に削除しました",
"deleteFailed": "グループの削除に失敗しました",
"loadingProfiles": "関連するプロファイルを読み込んでいます...",
"associatedProfiles": "関連プロファイル ({{count}})",
"whatToDoWithProfiles": "これらのプロファイルをどうしますか?",
"moveToDefaultOption": "プロファイルをデフォルトグループに移動",
"deleteAlongWithGroup": "プロファイルもグループと一緒に削除",
"noAssociatedProfiles": "このグループには関連するプロファイルがありません。",
"deleteGroup": "グループを削除",
"deleteGroupAndProfiles": "グループとプロファイルを削除",
"loadProfilesFailed": "プロファイルの読み込みに失敗しました",
"unknownGroup": "不明なグループ",
"profileGroupsAriaLabel": "プロファイルグループ",
"loading": "グループを読み込み中..."
},
"sync": {
"mode": {
@@ -366,7 +522,16 @@
"configureService": "同期サービスを設定"
},
"title": "同期サービス",
"config": "同期設定",
"config": {
"serverUrlRequired": "サーバーURLを入力してください",
"connectionSuccess": "接続に成功しました!",
"serverError": "サーバーがエラーで応答しました",
"connectFailed": "サーバーへの接続に失敗しました",
"settingsSaved": "同期設定を保存しました",
"saveFailed": "設定の保存に失敗しました",
"disconnected": "同期を切断しました",
"disconnectFailed": "切断に失敗しました"
},
"serverUrl": "サーバーURL",
"serverUrlPlaceholder": "https://sync.example.com",
"token": "同期トークン",
@@ -410,6 +575,12 @@
"profileLockedShort": "使用中",
"cannotLaunchLocked": "起動できません — {{email}} がプロファイルを使用中です",
"createdBy": "{{email}} が作成"
},
"disabled": "無効",
"toast": {
"profileSynced": "プロファイル '{{name}}' を同期しました",
"profileSyncFailed": "プロファイル '{{name}}' の同期に失敗しました",
"profileSyncFailedWithError": "プロファイル '{{name}}' の同期に失敗しました: {{error}}"
}
},
"integrations": {
@@ -447,7 +618,32 @@
"removedFromClaudeCode": "Claude Code から削除しました",
"config": "MCP設定",
"copyConfig": "設定をコピー"
}
},
"tabApi": "ローカル API",
"tabMcp": "MCP (AI アシスタント)",
"apiEnableLabel": "ローカル API サーバーを有効にする",
"apiEnableDescription": "REST API でプロファイル、グループ、プロキシを管理できます。",
"apiPortLabel": "ポート",
"apiTokenLabel": "認証トークン",
"apiTokenHint": "Authorization ヘッダーに含めてください: Bearer {{tokenSlot}}",
"apiInvalidPort": "無効なポート",
"apiInvalidPortDescription": "ポートは 1 から 65535 の間で指定してください",
"apiPortInUse": "ポート {{port}} は既に使用中です",
"apiFallbackPort": "フォールバックポート {{port}} でサーバーを起動しました",
"apiStarted": "API サーバーをポート {{port}} で起動しました",
"apiRunning": "API サーバーはポート {{port}} で実行中",
"apiStopped": "API サーバーを停止しました",
"apiToggleFailed": "API サーバーの切り替えに失敗しました",
"apiStartFailed": "API サーバーの起動に失敗しました",
"apiUnknownError": "不明なエラー",
"tokenCopied": "トークンをコピーしました",
"mcpEnableLabel": "MCP サーバーを有効にする (Model Context Protocol)",
"mcpEnableDescription": "Claude Desktop などの AI アシスタントがブラウザを制御できます。",
"mcpAcceptTermsFirst": "(設定で先に Wayfern の規約に同意してください)",
"mcpStarted": "MCP サーバーをポート {{port}} で起動しました",
"mcpStopped": "MCP サーバーを停止しました",
"mcpToggleFailed": "MCP サーバーの切り替えに失敗しました",
"openSettings": "統合設定を開く"
},
"import": {
"title": "プロファイルをインポート",
@@ -465,7 +661,9 @@
"fingerprint": {
"title": "フィンガープリント",
"randomize": "起動時にランダム化",
"randomizeDescription": "ブラウザ起動時に新しいフィンガープリントを生成します。"
"randomizeDescription": "ブラウザ起動時に新しいフィンガープリントを生成します。",
"osCpuPlaceholder": "例: Intel Mac OS X 10.15",
"webglRendererPlaceholder": "例: llvmpipe など"
},
"os": {
"title": "オペレーティングシステム",
@@ -499,7 +697,10 @@
"fingerprint": {
"title": "フィンガープリント",
"randomize": "起動時にランダム化",
"randomizeDescription": "ブラウザ起動時に新しいフィンガープリントを生成します。"
"randomizeDescription": "ブラウザ起動時に新しいフィンガープリントを生成します。",
"platformPlaceholder": "例: Win32、MacIntel、Linux x86_64",
"timezoneOffsetPlaceholder": "例: EST (UTC-5) の場合は 300",
"webglRendererPlaceholder": "例: Intel(R) HD Graphics"
},
"os": {
"title": "オペレーティングシステム",
@@ -522,6 +723,10 @@
"webrtc": "WebRTCをブロック",
"webgl": "WebGLをブロック"
}
},
"shared": {
"browserBehavior": "ブラウザの動作",
"allowAddonsOpenTabs": "ブラウザアドオンが新しいタブを自動的に開くことを許可"
}
},
"cookies": {
@@ -534,13 +739,53 @@
"selectCookies": "Cookieを選択",
"allDomains": "すべてのドメイン",
"selectedCount": "{{count}} 個のCookieを選択",
"selectedCount_plural": "{{count}} 個のCookieを選択"
"selectedCount_plural": "{{count}} 個のCookieを選択",
"dialogDescription_one": "ソースプロファイルから {{count}} 件の選択されたプロファイルへ Cookie をコピーします。",
"dialogDescription_other": "ソースプロファイルから {{count}} 件の選択されたプロファイルへ Cookie をコピーします。",
"sourceProfile": "ソースプロファイル",
"sourcePlaceholder": "Cookie をコピーするプロファイルを選択",
"running": "(実行中)",
"targetProfiles": "対象プロファイル ({{count}})",
"noOtherTargets": "他に Wayfern/Camoufox プロファイルが選択されていません",
"selectSourceFirst": "最初にソースプロファイルを選択してください",
"selectionStatus": "({{selected}} / {{total}} 選択中)",
"searchPlaceholder": "ドメインまたは Cookie を検索...",
"noMatching": "一致する Cookie が見つかりません",
"noFound": "Cookie が見つかりません",
"replaceNote": "同じ名前とドメインの既存 Cookie は置き換えられます。その他の Cookie はそのまま保持されます。",
"cannotCopyRunningOne": "Cookie をコピーできません: {{names}} はまだ実行中です",
"cannotCopyRunningMany": "Cookie をコピーできません: {{names}} はまだ実行中です",
"someErrors": "いくつかのエラーが発生しました: {{errors}}",
"successMessage": "{{copied}} 件の Cookie をコピーしました ({{replaced}} 件置換)",
"failedMessage": "Cookie のコピーに失敗しました: {{error}}",
"copyButton_one": "{{count}} 件の Cookie をコピー",
"copyButton_other": "{{count}} 件の Cookie をコピー",
"copyButtonEmpty": "Cookie をコピー"
},
"success": "Cookieが正常にコピーされました",
"error": "Cookieのコピーに失敗しました",
"management": {
"title": "Cookie管理",
"menuItem": "Cookie管理"
"menuItem": "Cookie管理",
"tabImport": "インポート",
"tabExport": "エクスポート",
"importDescription": "Netscape または JSON 形式のファイルから Cookie をインポートします。",
"dropPrompt": "クリックして Cookie ファイルを選択",
"fileFormats": "(.txt, .cookies, または .json)",
"cookiesFound": "{{count}} 件の Cookie が見つかりました",
"importedSuccess": "{{imported}} 件の Cookie をインポートしました ({{replaced}} 件置換)",
"linesSkipped": "{{count}} 行をスキップ",
"fileReadError": "ファイルの読み込みに失敗しました",
"loadFailed": "Cookie の読み込みに失敗しました: {{error}}",
"cookiesLabel": "Cookies",
"selectionStatus": "({{selected}} / {{total}} 選択中)",
"selectAll": "すべて選択",
"deselectAll": "すべて解除",
"noCookies": "このプロファイルに Cookie はありません",
"doneButton": "完了",
"importButton": "インポート",
"exportButton": "エクスポート",
"backButton": "戻る"
},
"import": {
"title": "Cookieのインポート",
@@ -623,7 +868,31 @@
"maxLength": "{{max}} 文字以内で入力してください",
"networkError": "ネットワークエラー。接続を確認してください。",
"serverError": "サーバーエラー。後でもう一度お試しください。",
"unknownError": "不明なエラーが発生しました。もう一度お試しください。"
"unknownError": "不明なエラーが発生しました。もう一度お試しください。",
"noProfilesForUrl": "利用可能なプロファイルがありません。URLを開く前にプロファイルを作成してください。",
"updateCamoufoxConfigFailed": "camoufox設定の更新に失敗しました: {{error}}",
"updateWayfernConfigFailed": "wayfern設定の更新に失敗しました: {{error}}",
"createProfileFailed": "プロファイルの作成に失敗しました: {{error}}",
"launchBrowserFailed": "ブラウザの起動に失敗しました: {{error}}",
"cannotDeleteRunningProfile": "ブラウザの実行中にプロファイルを削除できません。先にブラウザを停止してください。",
"deleteProfileFailed": "プロファイルの削除に失敗しました: {{error}}",
"renameProfileFailed": "プロファイルの名前変更に失敗しました: {{error}}",
"killBrowserFailed": "ブラウザの停止に失敗しました: {{error}}",
"deleteSelectedProfilesFailed": "選択したプロファイルの削除に失敗しました: {{error}}",
"cookieCopyUnsupportedBrowser": "Cookieのコピーは Wayfern および Camoufox プロファイルでのみ機能します",
"updateSyncSettingsFailed": "同期設定の更新に失敗しました",
"cloneProfileFailed": "プロファイルの複製に失敗しました: {{error}}",
"loadSupportedBrowsersFailed": "サポートされているブラウザの読み込みに失敗しました",
"setupExtensionListenersFailed": "拡張機能イベントリスナーの設定に失敗しました: {{error}}",
"loadGroupsFailed": "グループの読み込みに失敗しました: {{error}}",
"setupGroupListenersFailed": "グループイベントリスナーの設定に失敗しました: {{error}}",
"loadProfilesFailed": "プロファイルの読み込みに失敗しました: {{error}}",
"setupProfileListenersFailed": "プロファイルイベントリスナーの設定に失敗しました: {{error}}",
"loadProxiesFailed": "プロキシの読み込みに失敗しました: {{error}}",
"setupProxyListenersFailed": "プロキシイベントリスナーの設定に失敗しました: {{error}}",
"loadVpnConfigsFailed": "VPN設定の読み込みに失敗しました: {{error}}",
"setupVpnListenersFailed": "VPNイベントリスナーの設定に失敗しました: {{error}}",
"themeNotFound": "Tokyo Night テーマが見つかりません"
},
"browser": {
"camoufox": "Camoufox",
@@ -649,7 +918,7 @@
"blockWebRTC": "WebRTCをブロック",
"blockWebGL": "WebGLをブロック",
"navigatorProperties": "Navigatorプロパティ",
"userAgent": "User Agent",
"userAgent": "ユーザーエージェント",
"userAgentAndPlatform": "User Agent & Platform",
"platform": "Platform",
"platformVersion": "Platform Version",
@@ -657,7 +926,7 @@
"osCpu": "OS CPU",
"hardwareConcurrency": "Hardware Concurrency",
"maxTouchPoints": "最大タッチポイント数",
"doNotTrack": "Do Not Track",
"doNotTrack": "追跡しない",
"selectDntPlaceholder": "DNT値を選択",
"dntAllowed": "0(トラッキング許可)",
"dntNotAllowed": "1(トラッキング不許可)",
@@ -679,8 +948,8 @@
"outerHeight": "外側の高さ",
"innerWidth": "内側の幅",
"innerHeight": "内側の高さ",
"screenX": "Screen X",
"screenY": "Screen Y",
"screenX": "画面 X",
"screenY": "画面 Y",
"geolocation": "ジオロケーション",
"timezoneAndGeolocation": "タイムゾーンとジオロケーション",
"timezoneGeolocationDescription": "これらの値はブラウザのタイムゾーンとジオロケーションAPIを上書きします。",
@@ -694,15 +963,15 @@
"region": "地域",
"script": "スクリプト",
"webglProperties": "WebGLプロパティ",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglVendor": "WebGL ベンダー",
"webglRenderer": "WebGL レンダラー",
"webglParameters": "WebGLパラメータ",
"webglParametersJson": "WebGLパラメータ (JSON)",
"webgl2Parameters": "WebGL2パラメータ",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"webglShaderPrecisionFormats": "WebGL シェーダー精度フォーマット",
"webgl2ShaderPrecisionFormats": "WebGL2 シェーダー精度フォーマット",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeed": "Canvas ノイズシード",
"canvasNoiseSeedDescription": "このシードは一貫性がありながらもユニークなCanvasフィンガープリントを生成するために使用されます。各プロファイルには異なるシードを設定してください。",
"fonts": "フォント",
"fontsJson": "フォント (JSON配列)",
@@ -723,13 +992,16 @@
"maxChannelCount": "最大チャンネル数",
"vendorInfo": "ベンダー情報",
"vendor": "ベンダー",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"vendorSub": "ベンダーサブ",
"productSub": "プロダクトサブ",
"brand": "ブランド",
"brandVersion": "ブランドバージョン",
"proFeature": "これはPro機能です",
"generateFingerprint": "フィンガープリントを生成",
"refreshFingerprint": "フィンガープリントを更新"
"refreshFingerprint": "フィンガープリントを更新",
"canvasNoiseSeedPlaceholder": "キャンバスフィンガープリント用のシード文字列を入力",
"addFontsPlaceholder": "フォントを追加...",
"enterAsJson": "{{title}} を JSON で入力"
},
"warnings": {
"windowResizeTitle": "カスタムウィンドウサイズ",
@@ -869,7 +1141,9 @@
"syncEnabled": "同期が有効",
"syncDisabled": "同期が無効",
"syncEnableTooltip": "同期を有効にする",
"syncDisableTooltip": "同期を無効にする"
"syncDisableTooltip": "同期を無効にする",
"loadGroupsFailed": "拡張機能グループの読み込みに失敗しました",
"assignGroupFailed": "拡張機能グループの割り当てに失敗しました"
},
"pro": {
"badge": "PRO",
@@ -882,11 +1156,11 @@
"dnsBlocklist": {
"title": "DNSブロックリスト",
"none": "なし",
"light": "Light",
"normal": "Normal",
"light": "ライト",
"normal": "標準",
"pro": "Pro",
"proPlus": "Pro++",
"ultimate": "Ultimate",
"ultimate": "アルティメット",
"settingsDescription": "DNSブロックリストは、プロキシレベルで広告、トラッカー、マルウェアドメインをブロックします。リストは12時間ごとに自動的に更新されます。",
"manageLists": "DNSブロックリストを管理",
"refreshAll": "すべてのリストを更新",
@@ -895,5 +1169,421 @@
"fresh": "最新",
"stale": "期限切れ",
"notCached": "キャッシュなし"
},
"vpns": {
"form": {
"titleEdit": "VPN を編集",
"titleCreate": "WireGuard VPN を作成",
"descEdit": "VPN 設定の名前を更新します。",
"descCreate": "WireGuard インターフェースとピアの詳細を入力してください。",
"name": "名前",
"namePlaceholder": "例: 自宅 WireGuard",
"privateKey": "秘密鍵",
"privateKeyPlaceholder": "Base64 エンコードされた秘密鍵",
"address": "アドレス",
"addressPlaceholder": "例: 10.0.0.2/24",
"dnsOptional": "DNS (任意)",
"dnsPlaceholder": "例: 1.1.1.1",
"mtuOptional": "MTU (任意)",
"mtuPlaceholder": "例: 1420",
"peerPublicKey": "ピア公開鍵",
"peerPublicKeyPlaceholder": "Base64 エンコードされたピア公開鍵",
"peerEndpoint": "ピアエンドポイント",
"peerEndpointPlaceholder": "例: vpn.example.com:51820",
"allowedIps": "許可された IP",
"allowedIpsPlaceholder": "例: 0.0.0.0/0, ::/0",
"keepaliveOptional": "永続キープアライブ (任意)",
"keepalivePlaceholder": "例: 25",
"presharedKeyOptional": "事前共有鍵 (任意)",
"presharedKeyPlaceholder": "Base64 エンコードされた事前共有鍵",
"updateButton": "VPN を更新",
"createButton": "VPN を作成",
"nameRequired": "VPN 名は必須です",
"privateKeyRequired": "秘密鍵は必須です",
"addressRequired": "アドレスは必須です",
"peerPublicKeyRequired": "ピア公開鍵は必須です",
"peerEndpointRequired": "ピアエンドポイントは必須です",
"updated": "VPN を正常に更新しました",
"created": "WireGuard VPN を正常に作成しました",
"updateFailed": "VPN の更新に失敗しました: {{error}}",
"createFailed": "VPN の作成に失敗しました: {{error}}"
},
"import": {
"title": "VPN 設定をインポート",
"descDropzone": "WireGuard (.conf) 設定ファイルをインポート",
"descPreview": "インポートする VPN 設定を確認してください",
"descResult": "VPN のインポートが完了しました",
"dropzonePrompt": "WireGuard .conf ファイルをここにドロップするか、クリックして参照してください",
"pasteHint": "{{modKey}}+V でクリップボードから貼り付け",
"invalidContent": "コンテンツは有効な VPN 設定ではないようです",
"fileReadError": "ファイルの読み込みに失敗しました",
"wrongFileType": "WireGuard .conf ファイルをドロップしてください",
"configurationLabel": "{{type}} 設定",
"endpointLabel": "エンドポイント: {{endpoint}}",
"vpnNameLabel": "VPN 名",
"vpnNamePlaceholder": "マイ VPN",
"configPreview": "設定プレビュー",
"importedSuccess": "VPN を正常にインポートしました",
"importFailed": "インポートに失敗しました",
"importButton": "VPN をインポート",
"doneButton": "完了",
"failedGeneric": "VPN 設定のインポートに失敗しました",
"defaultName": "{{type}} VPN"
},
"management": {
"loading": "VPN を読み込み中...",
"noneCreated": "VPN 構成はまだありません。上のボタンからインポートまたは作成してください。",
"editVpn": "VPN を編集",
"deleteVpn": "VPN を削除",
"cannotDelete_one": "削除できません: {{count}} プロファイルで使用中",
"cannotDelete_other": "削除できません: {{count}} プロファイルで使用中",
"syncCannotDisable": "この VPN が同期されたプロファイルで使用されている間は同期を無効にできません",
"deleteSuccess": "VPN を削除しました",
"deleteFailed": "VPN の削除に失敗しました",
"deleteTitle": "VPN を削除",
"deleteDescription": "この操作は取り消せません。VPN「{{name}}」は完全に削除されます。"
}
},
"importProfile": {
"title": "ブラウザプロファイルをインポート",
"autoDetect": "自動検出",
"manualImport": "手動インポート",
"detectedProfilesTitle": "検出されたブラウザプロファイル",
"scanning": "ブラウザプロファイルをスキャン中...",
"noneFound": "システムにブラウザプロファイルが見つかりませんでした。",
"noneFoundHint": "カスタムの場所にプロファイルがある場合は手動インポートをお試しください。",
"selectProfile": "プロファイルを選択:",
"selectProfilePlaceholder": "検出されたプロファイルを選択",
"pathLabel": "パス:",
"browserLabel": "ブラウザ:",
"newProfileName": "新しいプロファイル名:",
"newProfileNamePlaceholder": "インポートするプロファイルの名前を入力",
"manualTitle": "手動プロファイルインポート",
"browserType": "ブラウザの種類:",
"loadingBrowsers": "ブラウザを読み込み中...",
"selectBrowserType": "ブラウザの種類を選択",
"profileFolderPath": "プロファイルフォルダパス:",
"profileFolderPlaceholder": "プロファイルフォルダのフルパスを入力",
"browseFolderTitle": "フォルダを参照",
"examplePaths": "パスの例:",
"selectFolderTitle": "ブラウザプロファイルフォルダを選択",
"folderDialogFailed": "フォルダダイアログを開けませんでした",
"detectFailed": "既存のブラウザプロファイルの検出に失敗しました",
"fillFields": "すべてのフィールドを入力してください",
"selectAndName": "プロファイルを選択し、名前を入力してください",
"profileNotFound": "選択されたプロファイルが見つかりません",
"importedSuccess": "プロファイル「{{name}}」をインポートしました",
"notInstalled": "{{browser}} はインストールされていません。メインウィンドウから {{browser}} をダウンロードしてからもう一度インポートしてください。",
"importFailed": "プロファイルのインポートに失敗しました: {{error}}",
"proxyOptional": "プロキシ (任意)",
"noProxy": "プロキシなし",
"nextButton": "次へ",
"importButton": "インポート",
"importedAs": "このプロファイルは {{browser}} プロファイルとしてインポートされます。"
},
"syncTooltips": {
"syncing": "同期中...",
"syncedAt": "同期済み {{time}}",
"synced": "同期済み",
"waiting": "同期待ち",
"errorWith": "同期エラー: {{error}}",
"error": "同期エラー",
"notSynced": "未同期"
},
"groupManagement": {
"description": "プロファイルのグループを管理します",
"createGroup": "グループを作成",
"noGroups": "グループはまだありません。上のボタンから最初のグループを作成してください。",
"loading": "グループを読み込み中...",
"profileCount_one": "{{count}} プロファイル",
"profileCount_other": "{{count}} プロファイル",
"groupsLabel": "グループ",
"profilesCol": "プロファイル",
"syncCannotDisable": "このグループが同期されたプロファイルで使用されている間は同期を無効にできません",
"editGroupTooltip": "グループを編集",
"deleteGroupTooltip": "グループを削除",
"loadFailed": "グループの読み込みに失敗しました"
},
"proxyAssignment": {
"title": "プロキシ / VPN を割り当てる",
"description_one": "{{count}} 件の選択されたプロファイルにプロキシまたは VPN を割り当てます。",
"description_other": "{{count}} 件の選択されたプロファイルにプロキシまたは VPN を割り当てます。",
"selectLabel": "プロキシ / VPN",
"placeholder": "プロキシまたは VPN を選択",
"noProxy": "プロキシ / VPN なし",
"searchPlaceholder": "プロキシまたは VPN を検索...",
"notFound": "プロキシまたは VPN が見つかりません。",
"assignButton": "割り当てる",
"success": "{{count}} 件のプロファイルにプロキシ/VPN を割り当てました",
"failed": "プロキシ/VPN の割り当てに失敗しました",
"selectedProfilesLabel": "選択したプロファイル:",
"assignProxyVpnLabel": "プロキシ / VPN を割り当てる:",
"noneOption": "なし",
"noValidProfiles": "有効なプロファイルが選択されていません。",
"vpnGroupHeading": "VPN",
"failedFallback": "プロファイルへのプロキシ/VPN の割り当てに失敗しました"
},
"groupAssignment": {
"title": "グループを割り当てる",
"description_one": "{{count}} 件の選択されたプロファイルにグループを割り当てます。",
"description_other": "{{count}} 件の選択されたプロファイルにグループを割り当てます。",
"selectLabel": "グループ",
"placeholder": "グループを選択",
"noGroup": "グループなし (デフォルト)",
"assignButton": "割り当てる",
"success": "{{count}} 件のプロファイルにグループを割り当てました",
"failed": "グループの割り当てに失敗しました",
"selectedProfilesLabel": "選択したプロファイル:",
"assignGroupLabel": "グループに割り当てる:",
"noValidProfiles": "有効なプロファイルが選択されていません。",
"failedFallback": "プロファイルへのグループの割り当てに失敗しました"
},
"profileSelector": {
"title": "プロファイルを選択",
"description": "この URL を開くプロファイルを選択してください",
"searchPlaceholder": "プロファイルを検索...",
"noProfiles": "利用可能なプロファイルがありません",
"noResults": "検索条件に一致するプロファイルがありません",
"selectButton": "選択",
"launching": "起動中...",
"chooseProfileTitle": "プロファイルを選択",
"openingUrl": "URL を開く:",
"urlCopied": "URL をクリップボードにコピーしました!",
"selectProfileLabel": "プロファイルを選択:",
"noneAvailableShort": "利用可能なプロファイルがありません。まずプロファイルを作成してください。",
"noneAvailableLong": "このダイアログを閉じてメインウィンドウからプロファイルを作成してください。",
"chooseAProfile": "プロファイルを選択",
"badgeProxy": "プロキシ",
"badgeRunning": "実行中",
"badgeUnavailable": "利用不可",
"openButton": "開く"
},
"locationProxy": {
"title": "クイックロケーションプロキシ",
"description": "このプロファイルを経由する国を選択してください。プロキシが自動的に作成されます。",
"country": "国",
"selectCountry": "国を選択",
"searchCountry": "国を検索...",
"noCountriesFound": "国が見つかりません。",
"apply": "適用",
"creating": "プロキシを作成中...",
"success": "ロケーションプロキシを適用しました",
"failed": "ロケーションプロキシの適用に失敗しました",
"titleCreate": "位置プロキシを作成",
"descriptionCreate": "24 時間スティッキーセッションのジオターゲットプロキシを作成します",
"countryLabel": "国 (必須)",
"regionLabel": "地域 (任意)",
"cityLabel": "都市 (任意)",
"ispLabel": "ISP (任意)",
"nameLabel": "名前",
"namePlaceholder": "プロキシ名",
"loadingCountries": "国を読み込み中...",
"selectCountryPh": "国を選択",
"searchCountries": "国を検索...",
"loadFailed": "国の読み込みに失敗しました",
"selectCountryFirst": "まず国を選択してください",
"loadingRegions": "地域を読み込み中...",
"noRegions": "利用可能な地域がありません",
"selectRegion": "地域を選択",
"searchRegions": "地域を検索...",
"loadingCities": "都市を読み込み中...",
"noCities": "利用可能な都市がありません",
"selectCity": "都市を選択",
"searchCities": "都市を検索...",
"loadingIsps": "ISP を読み込み中...",
"noIsps": "利用可能な ISP がありません",
"selectIsp": "ISP を選択",
"searchIsps": "ISP を検索...",
"createSuccess": "位置プロキシを作成しました",
"createFailed": "位置プロキシの作成に失敗しました",
"creatingButton": "作成中...",
"createButton": "作成"
},
"launchOnLogin": {
"title": "ログイン時に起動しますか?",
"description": "バックグラウンドで実行することで、プロキシとブラウザを維持できます。",
"declineButton": "今後は表示しない",
"declining": "...",
"enableButton": "有効にする",
"enableSuccess": "ログイン時の起動を有効にしました",
"enableFailed": "ログイン時の起動を有効にできませんでした",
"declineFailed": "設定の保存に失敗しました",
"tryAgain": "もう一度お試しください"
},
"wayfernTerms": {
"title": "Wayfern 利用規約",
"description": "Donut Browser を使用する前に、Wayfern の利用規約を読み、同意する必要があります。",
"reviewLabel": "次の URL で利用規約をご確認ください:",
"agreeNotice": "「同意します」をクリックすると、これらの条項に拘束されることに同意します。",
"acceptButton": "同意します",
"acceptSuccess": "規約に同意しました",
"acceptFailed": "規約への同意に失敗しました",
"tryAgain": "もう一度お試しください"
},
"commercialTrial": {
"title": "商用試用期間が終了しました",
"description": "2 週間の商用試用期間が終了しました。",
"body": "商用目的で Donut Browser を使用する場合は、続行するために商用ライセンスを購入する必要があります。個人利用は引き続き無料でお使いいただけます。",
"understandButton": "了解しました",
"failed": "確認の保存に失敗しました",
"tryAgain": "もう一度お試しください"
},
"permissionDialog": {
"titleMicrophone": "マイクへのアクセスが必要です",
"titleCamera": "カメラへのアクセスが必要です",
"descMicrophone": "Donut Browser はブラウザのマイク機能を有効にするためにマイクへのアクセスを必要とします。マイクを使用するウェブサイトはそれぞれ個別に許可を求めます。",
"descCamera": "Donut Browser はブラウザのカメラ機能を有効にするためにカメラへのアクセスを必要とします。カメラを使用するウェブサイトはそれぞれ個別に許可を求めます。",
"grantedMicrophone": "許可が付与されました!Donut Browser から起動したブラウザはマイクにアクセスできます。",
"grantedCamera": "許可が付与されました!Donut Browser から起動したブラウザはカメラにアクセスできます。",
"notGrantedMicrophone": "許可されていません。下のボタンを押してマイクへのアクセスを要求してください。",
"notGrantedCamera": "許可されていません。下のボタンを押してカメラへのアクセスを要求してください。",
"doneButton": "完了",
"cancelButton": "キャンセル",
"grantAccessButton": "アクセスを許可",
"requestSuccessMicrophone": "マイクアクセスをリクエストしました",
"requestSuccessCamera": "カメラアクセスをリクエストしました",
"requestFailed": "許可のリクエストに失敗しました"
},
"traffic": {
"title": "トラフィックの詳細",
"bandwidthOverTime": "時間ごとの帯域幅",
"timePeriodPlaceholder": "期間",
"last1m": "直近 1 分",
"last5m": "直近 5 分",
"last30m": "直近 30 分",
"last1h": "直近 1 時間",
"last2h": "直近 2 時間",
"last4h": "直近 4 時間",
"last1d": "直近 1 日",
"last7d": "直近 7 日",
"last30d": "直近 30 日",
"allTime": "全期間",
"allTimeShort": "全期間",
"totalSuffix": "合計",
"sentLabel": "送信 ({{period}})",
"receivedLabel": "受信 ({{period}})",
"requestsLabel": "リクエスト ({{period}})",
"allTimeTraffic": "全期間トラフィック:",
"allTimeRequests": "全期間リクエスト:",
"proxyDisclaimer": "注意: プロキシ、VPN、または類似のサービスを使用している場合、暗号化のオーバーヘッドやプロトコルの違いによりプロバイダーがトラフィックを別の方法で計算する場合があります。",
"topByTraffic": "トラフィック別トップドメイン ({{period}})",
"topByRequests": "リクエスト別トップドメイン ({{period}})",
"columnDomain": "ドメイン",
"columnRequests": "リクエスト",
"columnSent": "送信",
"columnReceived": "受信",
"columnTotal": "合計トラフィック",
"uniqueIps": "ユニーク IP ({{count}})",
"noData": "このプロファイルのトラフィックデータはありません。",
"noDataHint": "プロファイルを起動するとトラフィックデータが表示されます。",
"sentLegend": "送信",
"receivedLegend": "受信",
"tooltipSent": "↑ 送信: ",
"tooltipReceived": "↓ 受信: "
},
"camoufoxDialog": {
"titleView": "フィンガープリント設定を表示 - {{name}} ({{browser}})",
"titleConfigure": "フィンガープリント設定 - {{name}} ({{browser}})",
"invalidFingerprint": "無効なフィンガープリント設定",
"invalidFingerprintDescription": "フィンガープリント設定に無効な JSON が含まれています。詳細設定を確認してください。",
"saveFailed": "設定の保存に失敗しました",
"unknownError": "不明なエラーが発生しました"
},
"proxyCheck": {
"unknownLocation": "不明",
"locationToast": "プロキシの場所:",
"failed": "プロキシのチェックに失敗しました: {{error}}",
"tooltipChecking": "プロキシをチェック中...",
"tooltipIp": "IP: {{ip}}",
"tooltipChecked": "チェック済み {{time}}",
"tooltipFailed": "失敗 {{time}}",
"tooltipFailedTitle": "プロキシのチェックに失敗しました",
"tooltipDefault": "プロキシの有効性をチェック"
},
"vpnCheck": {
"valid": "VPN「{{name}}」の構成は有効です",
"invalid": "VPN「{{name}}」の構成は無効です",
"failed": "VPN チェックに失敗しました: {{error}}",
"tooltipChecking": "VPN 構成をチェック中...",
"tooltipValid": "構成は有効",
"tooltipInvalid": "構成は無効",
"tooltipChecked": "チェック済み {{time}}",
"tooltipDefault": "VPN 構成の有効性をチェック"
},
"profileTable": {
"syncTooltipDisabled": "同期無効",
"syncTooltipSyncing": "同期中...",
"syncTooltipSyncedAt": "同期済み {{time}}",
"syncTooltipSynced": "同期済み",
"syncTooltipWaiting": "同期待ち",
"syncTooltipErrorWith": "同期エラー: {{error}}",
"syncTooltipError": "同期エラー",
"syncTooltipNotSynced": "未同期",
"noTags": "タグなし",
"syncTooltipCloseToSync": "プロファイルを閉じて同期",
"syncTooltipDisabledWithLast": "同期無効、最終同期 {{time}}",
"addTagsPlaceholder": "タグを追加",
"tagsHeader": "タグ",
"noteHeader": "メモ",
"vpnsHeading": "VPN",
"createByCountryHeading": "国別に作成"
},
"releaseTypeSelector": {
"noReleaseTypes": "利用可能なリリースタイプがありません。",
"placeholder": "リリースタイプを選択...",
"stable": "Stable",
"nightly": "Nightly",
"downloaded": "ダウンロード済み",
"downloadBrowser": "ブラウザをダウンロード",
"downloading": "ダウンロード中..."
},
"dataTableActionBar": {
"selected": "{{count}} 件選択",
"clearSelection": "選択を解除"
},
"appUpdate": {
"toast": {
"updateFailed": "Donut Browser の更新に失敗しました",
"restartFailed": "再起動に失敗しました",
"updateReady": "アップデートの準備完了。再起動して適用",
"manualDownloadRequired": "手動ダウンロードが必要です",
"restartNow": "今すぐ再起動",
"viewRelease": "リリースを見る",
"later": "後で",
"uploading": "アップロード中",
"downloading": "ダウンロード中"
}
},
"browserDownload": {
"toast": {
"fetchVersionsFailed": "{{browser}} のバージョン取得に失敗しました",
"foundNewVersions": "{{browser}} の新バージョン {{count}} 個が見つかりました!",
"totalAvailableVersions": "合計利用可能: {{count}} バージョン",
"downloadFailed": "{{browser}} {{version}} のダウンロードに失敗しました",
"calculating": "計算中...",
"extractionFailed": "{{browser}} {{version}}: 展開に失敗しました",
"extractionFailedDescription": "破損したファイルは削除されました。次回の試行時に再ダウンロードされます。",
"extracting": "ブラウザファイルを展開中... アプリを閉じないでください。",
"verifying": "ブラウザファイルを検証中...",
"downloadingRolling": "ローリングリリースビルドをダウンロード中..."
}
},
"versionUpdater": {
"toast": {
"alreadyAvailable": "{{browser}} {{version}} はすでに利用可能です",
"updatingProfiles": "プロファイル設定を更新中...",
"updateCompleted": "{{browser}} の更新が完了しました",
"singleProfileUpdated": "プロファイル「{{name}}」がバージョン {{version}} に更新されました。最新バージョンでブラウザを起動できます。",
"multipleProfilesUpdated": "{{count}} 個のプロファイルがバージョン {{version}} に更新されました。最新バージョンでブラウザを起動できます。",
"versionAvailable": "バージョン {{version}} が利用可能になりました。実行中のプロファイルは再起動時に新しいバージョンを使用します。",
"autoUpdateFailed": "{{browser}} の自動更新に失敗しました",
"updateWithErrors": "一部のエラーで更新が完了しました",
"updateWithErrorsDescription": "{{newVersions}} 個の新しいバージョンが見つかり、{{failedUpdates}} 個のブラウザの更新に失敗しました",
"updateSuccess": "ブラウザのバージョンを正常に更新しました",
"updateSuccessDescription": "{{successfulUpdates}} 個のブラウザに {{newVersions}} 個の新しいバージョンが見つかりました。自動ダウンロードがまもなく開始します。",
"upToDate": "新しいブラウザのバージョンは見つかりませんでした",
"upToDateDescription": "すべてのブラウザバージョンは最新です",
"updateAllFailed": "ブラウザバージョンの更新に失敗しました"
}
}
}
+725 -35
View File
@@ -28,7 +28,9 @@
"refresh": "Atualizar",
"loading": "Carregando...",
"saveSettings": "Salvar Configurações",
"moreInfo": "Mais informações"
"moreInfo": "Mais informações",
"downloading": "Baixando...",
"minimize": "Minimizar"
},
"status": {
"active": "Ativo",
@@ -56,7 +58,10 @@
"default": "Padrão",
"custom": "Personalizado",
"optional": "Opcional",
"required": "Obrigatório"
"required": "Obrigatório",
"unknownProfile": "Desconhecido",
"mode": "Modo",
"never": "Nunca"
},
"time": {
"days": "dias",
@@ -64,6 +69,33 @@
"minutes": "minutos",
"seconds": "segundos",
"remaining": "restantes"
},
"aria": {
"selectAll": "Selecionar tudo",
"selectRow": "Selecionar linha",
"selectProfile": "Selecionar perfil",
"copy": "Copiar para a área de transferência",
"copied": "Copiado",
"showToken": "Mostrar token",
"hideToken": "Ocultar token"
},
"keys": {
"escape": "Esc"
},
"errors": {
"unknown": "Ocorreu um erro desconhecido"
},
"window": {
"minimize": "Minimizar"
},
"commandPalette": {
"title": "Paleta de comandos",
"description": "Pesquise um comando para executar..."
},
"noResults": "Nenhum resultado encontrado.",
"srOnly": {
"copy": "Copiar",
"copied": "Copiado"
}
},
"settings": {
@@ -85,7 +117,8 @@
"title": "Idioma",
"description": "Escolha seu idioma preferido para a interface do aplicativo.",
"systemDefault": "Padrão do Sistema",
"selectLanguage": "Selecionar idioma"
"selectLanguage": "Selecionar idioma",
"interface": "Idioma da interface"
},
"defaultBrowser": {
"title": "Navegador Padrão",
@@ -100,7 +133,8 @@
"microphone": "Microfone",
"microphoneDescription": "Acesso ao microfone para aplicativos do navegador",
"camera": "Câmera",
"cameraDescription": "Acesso à câmera para aplicativos do navegador"
"cameraDescription": "Acesso à câmera para aplicativos do navegador",
"accessRequested": "Acesso a {{permission}} solicitado"
},
"integrations": {
"title": "Integrações",
@@ -134,7 +168,8 @@
"advanced": {
"title": "Avançado",
"clearCache": "Limpar Todo o Cache de Versões",
"clearCacheDescription": "Limpa todos os dados de versões de navegadores em cache e atualiza todas as versões de suas fontes. Isso forçará um novo download das informações de versão para todos os navegadores."
"clearCacheDescription": "Limpa todos os dados de versões de navegadores em cache e atualiza todas as versões de suas fontes. Isso forçará um novo download das informações de versão para todos os navegadores.",
"clearCacheFailed": "Falha ao limpar o cache"
},
"disableAutoUpdates": "Desativar Atualizações Automáticas do App",
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas."
@@ -169,7 +204,9 @@
"note": "Nota",
"group": "Grupo",
"proxy": "Proxy / VPN",
"lastLaunch": "Último Início"
"lastLaunch": "Último Início",
"empty": "Nenhum perfil encontrado.",
"notSelected": "Não selecionado"
},
"actions": {
"launch": "Iniciar",
@@ -205,7 +242,30 @@
"ephemeral": "Efêmero",
"ephemeralDescription": "O navegador é forçado a gravar os dados do perfil na memória em vez do disco. Os dados são excluídos ao fechar o navegador.",
"ephemeralBadge": "Efêmero",
"ephemeralAlpha": "Alpha"
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Excluir perfis selecionados",
"description": "Esta ação não pode ser desfeita. Excluirá permanentemente {{count}} perfil(is) e todos os dados associados.",
"confirmButton": "Excluir {{count}} perfil(is)"
},
"note": {
"empty": "Sem nota",
"placeholder": "Adicionar uma nota..."
},
"aria": {
"profileInfo": "Informações do perfil"
},
"delete": {
"title": "Excluir perfil",
"description": "Esta ação não pode ser desfeita. Excluirá permanentemente o perfil \"{{profileName}}\" e todos os seus dados associados.",
"confirmButton": "Excluir perfil"
},
"actionBar": {
"assignToGroup": "Atribuir a grupo",
"assignProxy": "Atribuir proxy",
"assignExtensionGroup": "Atribuir grupo de extensões",
"copyCookies": "Copiar cookies"
}
},
"createProfile": {
"title": "Criar Novo Perfil",
@@ -228,7 +288,10 @@
"title": "Proxy / VPN",
"addProxy": "Adicionar Proxy",
"noProxy": "Sem proxy / VPN",
"noProxiesAvailable": "Nenhum proxy ou VPN disponível. Adicione um para rotear o tráfego deste perfil."
"noProxiesAvailable": "Nenhum proxy ou VPN disponível. Adicione um para rotear o tráfego deste perfil.",
"search": "Pesquisar proxies ou VPN...",
"notFound": "Nenhum proxy ou VPN encontrado.",
"searchWithCountries": "Pesquisar proxies, VPNs ou países..."
},
"launchHook": {
"label": "URL do hook de inicialização",
@@ -248,7 +311,8 @@
"chromiumSubtitle": "Desenvolvido com Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "Desenvolvido com Camoufox",
"camoufoxWarning": "O Firefox (Camoufox) é mantido por uma organização terceira. Para uso em produção, utilize o Chromium."
"camoufoxWarning": "O Firefox (Camoufox) é mantido por uma organização terceira. Para uso em produção, utilize o Chromium.",
"platformUnavailable": "{{browser}} ainda não está disponível para sua plataforma."
},
"deleteDialog": {
"title": "Excluir Perfil",
@@ -259,7 +323,31 @@
},
"proxies": {
"title": "Proxies",
"management": "Proxies e VPNs",
"management": {
"description": "Gerencie suas configurações de proxy e VPN para reutilizar nos perfis",
"tabProxies": "Proxies",
"tabVpns": "VPNs",
"create": "Criar",
"loading": "Carregando proxies...",
"noneCreated": "Nenhum proxy criado ainda. Crie seu primeiro proxy usando o botão acima.",
"usage": "Uso",
"syncCol": "Sync",
"syncCannotDisable": "A sincronização não pode ser desativada enquanto este proxy estiver em uso por perfis sincronizados",
"enableSync": "Ativar sincronização",
"disableSync": "Desativar sincronização",
"editProxy": "Editar proxy",
"deleteProxy": "Excluir proxy",
"cannotDelete_one": "Não é possível excluir: em uso por {{count}} perfil",
"cannotDelete_other": "Não é possível excluir: em uso por {{count}} perfis",
"syncEnabled": "Sincronização ativada",
"syncDisabled": "Sincronização desativada",
"updateSyncFailed": "Falha ao atualizar a sincronização",
"deleteSuccess": "Proxy excluído com sucesso",
"deleteFailed": "Falha ao excluir proxy",
"deleteTitle": "Excluir proxy",
"deleteDescription": "Esta ação não pode ser desfeita. O proxy \"{{name}}\" será excluído permanentemente.",
"title": "Proxies e VPNs"
},
"add": "Adicionar Proxy",
"edit": "Editar Proxy",
"delete": "Excluir Proxy",
@@ -280,7 +368,12 @@
"password": "Senha",
"passwordPlaceholder": "Opcional",
"cipher": "Cifra",
"cipherPlaceholder": "aes-256-gcm"
"cipherPlaceholder": "aes-256-gcm",
"nameRequired": "O nome do proxy é obrigatório",
"hostPortRequired": "Host e porta são obrigatórios",
"ssCipherRequired": "Cifra e senha são obrigatórias para Shadowsocks",
"selectType": "Selecione o tipo de proxy",
"saveFailed": "Falha ao salvar o proxy: {{error}}"
},
"types": {
"http": "HTTP",
@@ -318,6 +411,45 @@
"sync": {
"enabled": "Sincronização Ativada",
"disabled": "Sincronização Desativada"
},
"exportDialog": {
"title": "Exportar proxies",
"description": "Exporte suas configurações de proxy para um arquivo",
"format": "Formato de exportação",
"json": "JSON",
"txt": "TXT (formato URL)",
"preview": "Pré-visualização",
"noProxies": "Sem proxies para exportar",
"downloaded": "{{filename}} baixado",
"failed": "Falha ao exportar proxies",
"copied": "Copiado"
},
"importDialog": {
"title": "Importar proxies",
"descDropzone": "Importe proxies de um arquivo JSON ou TXT",
"descPreview": "Revise os proxies a importar",
"descAmbiguous": "Alguns proxies têm formatos ambíguos. Selecione o formato correto.",
"descResult": "Importação concluída",
"dropzonePrompt": "Solte um arquivo de configuração de proxy",
"dropzoneFormats": "(.json, .txt)",
"pasteHint": "Cole da área de transferência com {{modKey}}+V",
"wrongFileType": "Por favor, solte um arquivo .json ou .txt",
"fileReadError": "Falha ao ler o arquivo",
"fileProcessError": "Falha ao processar o arquivo",
"noValidProxies": "Nenhum proxy válido encontrado no arquivo",
"namePrefix": "Prefixo de nome",
"namePrefixDefault": "Importado",
"namePrefixHint": "Os proxies serão nomeados como \"{{prefix}} Proxy 1\", \"{{prefix}} Proxy 2\", etc.",
"proxiesToImport": "Proxies a importar ({{count}})",
"invalidCount": "({{count}} inválidos)",
"ambiguousIntro": "Os proxies a seguir têm um formato ambíguo. Selecione a interpretação correta para cada um.",
"imported": "Importados:",
"skippedDuplicates": "Ignorados (duplicados):",
"errors": "Erros",
"importButton": "Importar {{count}} proxies",
"continueButton": "Continuar",
"doneButton": "Concluído",
"failed": "Falha ao importar proxies"
}
},
"groups": {
@@ -343,7 +475,31 @@
"sync": {
"enabled": "Sincronização Ativada",
"disabled": "Sincronização Desativada"
}
},
"createTitle": "Criar Novo Grupo",
"createDescription": "Crie um novo grupo para organizar seus perfis de navegador.",
"editTitle": "Editar Grupo",
"editDescription": "Atualize o nome do seu grupo.",
"createSuccess": "Grupo criado com sucesso",
"createFailed": "Falha ao criar o grupo",
"updateSuccess": "Grupo atualizado com sucesso",
"updateFailed": "Falha ao atualizar o grupo",
"deleteTitle": "Excluir Grupo",
"deleteDescription": "Esta ação não pode ser desfeita. Isto excluirá o grupo permanentemente.",
"deleteSuccess": "Grupo excluído com sucesso",
"deleteFailed": "Falha ao excluir o grupo",
"loadingProfiles": "Carregando perfis associados...",
"associatedProfiles": "Perfis Associados ({{count}})",
"whatToDoWithProfiles": "O que fazer com esses perfis?",
"moveToDefaultOption": "Mover perfis para o grupo Padrão",
"deleteAlongWithGroup": "Excluir perfis junto com o grupo",
"noAssociatedProfiles": "Este grupo não tem perfis associados.",
"deleteGroup": "Excluir Grupo",
"deleteGroupAndProfiles": "Excluir Grupo e Perfis",
"loadProfilesFailed": "Falha ao carregar os perfis",
"unknownGroup": "Grupo desconhecido",
"profileGroupsAriaLabel": "Grupos de perfis",
"loading": "Carregando grupos..."
},
"sync": {
"mode": {
@@ -366,7 +522,16 @@
"configureService": "Configurar serviço de sincronização"
},
"title": "Serviço de Sincronização",
"config": "Configuração de Sincronização",
"config": {
"serverUrlRequired": "Insira uma URL de servidor",
"connectionSuccess": "Conexão bem-sucedida!",
"serverError": "O servidor respondeu com um erro",
"connectFailed": "Falha ao conectar ao servidor",
"settingsSaved": "Configurações de sincronização salvas",
"saveFailed": "Falha ao salvar as configurações",
"disconnected": "Sincronização desconectada",
"disconnectFailed": "Falha ao desconectar"
},
"serverUrl": "URL do Servidor",
"serverUrlPlaceholder": "https://sync.exemplo.com",
"token": "Token de Sincronização",
@@ -410,6 +575,12 @@
"profileLockedShort": "Em uso",
"cannotLaunchLocked": "Não é possível iniciar — o perfil está em uso por {{email}}",
"createdBy": "Criado por {{email}}"
},
"disabled": "Desativada",
"toast": {
"profileSynced": "Perfil '{{name}}' sincronizado com sucesso",
"profileSyncFailed": "Falha ao sincronizar o perfil '{{name}}'",
"profileSyncFailedWithError": "Falha ao sincronizar o perfil '{{name}}': {{error}}"
}
},
"integrations": {
@@ -447,7 +618,32 @@
"removedFromClaudeCode": "Removido do Claude Code",
"config": "Configuração MCP",
"copyConfig": "Copiar Configuração"
}
},
"tabApi": "API local",
"tabMcp": "MCP (Assistentes de IA)",
"apiEnableLabel": "Ativar servidor API local",
"apiEnableDescription": "Permite gerenciar perfis, grupos e proxies via API REST.",
"apiPortLabel": "Porta",
"apiTokenLabel": "Token de autenticação",
"apiTokenHint": "Incluir no cabeçalho Authorization: Bearer {{tokenSlot}}",
"apiInvalidPort": "Porta inválida",
"apiInvalidPortDescription": "A porta deve estar entre 1 e 65535",
"apiPortInUse": "A porta {{port}} já está em uso",
"apiFallbackPort": "Servidor iniciado na porta alternativa {{port}}",
"apiStarted": "Servidor API iniciado na porta {{port}}",
"apiRunning": "Servidor API em execução na porta {{port}}",
"apiStopped": "Servidor API parado",
"apiToggleFailed": "Falha ao alternar o servidor API",
"apiStartFailed": "Falha ao iniciar o servidor API",
"apiUnknownError": "Erro desconhecido",
"tokenCopied": "Token copiado",
"mcpEnableLabel": "Ativar servidor MCP (Model Context Protocol)",
"mcpEnableDescription": "Permite que assistentes de IA como o Claude Desktop controlem navegadores.",
"mcpAcceptTermsFirst": "(Aceite primeiro os termos da Wayfern nas Configurações)",
"mcpStarted": "Servidor MCP iniciado na porta {{port}}",
"mcpStopped": "Servidor MCP parado",
"mcpToggleFailed": "Falha ao alternar o servidor MCP",
"openSettings": "Abrir configurações de integrações"
},
"import": {
"title": "Importar Perfil",
@@ -465,7 +661,9 @@
"fingerprint": {
"title": "Impressão Digital",
"randomize": "Aleatorizar ao Iniciar",
"randomizeDescription": "Gera uma nova impressão digital cada vez que o navegador é iniciado."
"randomizeDescription": "Gera uma nova impressão digital cada vez que o navegador é iniciado.",
"osCpuPlaceholder": "ex., Intel Mac OS X 10.15",
"webglRendererPlaceholder": "ex., llvmpipe ou similar"
},
"os": {
"title": "Sistema Operacional",
@@ -499,7 +697,10 @@
"fingerprint": {
"title": "Impressão Digital",
"randomize": "Aleatorizar ao Iniciar",
"randomizeDescription": "Gera uma nova impressão digital cada vez que o navegador é iniciado."
"randomizeDescription": "Gera uma nova impressão digital cada vez que o navegador é iniciado.",
"platformPlaceholder": "ex., Win32, MacIntel, Linux x86_64",
"timezoneOffsetPlaceholder": "ex., 300 para EST (UTC-5)",
"webglRendererPlaceholder": "ex., Intel(R) HD Graphics"
},
"os": {
"title": "Sistema Operacional",
@@ -522,6 +723,10 @@
"webrtc": "Bloquear WebRTC",
"webgl": "Bloquear WebGL"
}
},
"shared": {
"browserBehavior": "Comportamento do navegador",
"allowAddonsOpenTabs": "Permitir que extensões abram novas abas automaticamente"
}
},
"cookies": {
@@ -534,13 +739,53 @@
"selectCookies": "Selecionar Cookies",
"allDomains": "Todos os Domínios",
"selectedCount": "{{count}} cookie selecionado",
"selectedCount_plural": "{{count}} cookies selecionados"
"selectedCount_plural": "{{count}} cookies selecionados",
"dialogDescription_one": "Copiar cookies de um perfil de origem para {{count}} perfil selecionado.",
"dialogDescription_other": "Copiar cookies de um perfil de origem para {{count}} perfis selecionados.",
"sourceProfile": "Perfil de origem",
"sourcePlaceholder": "Selecione um perfil para copiar os cookies",
"running": "(em execução)",
"targetProfiles": "Perfis de destino ({{count}})",
"noOtherTargets": "Nenhum outro perfil Wayfern/Camoufox selecionado",
"selectSourceFirst": "Selecione primeiro um perfil de origem",
"selectionStatus": "({{selected}} de {{total}} selecionados)",
"searchPlaceholder": "Buscar domínios ou cookies...",
"noMatching": "Nenhum cookie correspondente encontrado",
"noFound": "Nenhum cookie encontrado",
"replaceNote": "Cookies existentes com o mesmo nome e domínio serão substituídos. Os demais serão mantidos.",
"cannotCopyRunningOne": "Não é possível copiar cookies: {{names}} ainda em execução",
"cannotCopyRunningMany": "Não é possível copiar cookies: {{names}} ainda em execução",
"someErrors": "Ocorreram alguns erros: {{errors}}",
"successMessage": "{{copied}} cookies copiados com sucesso ({{replaced}} substituídos)",
"failedMessage": "Falha ao copiar cookies: {{error}}",
"copyButton_one": "Copiar {{count}} cookie",
"copyButton_other": "Copiar {{count}} cookies",
"copyButtonEmpty": "Copiar cookies"
},
"success": "Cookies copiados com sucesso",
"error": "Falha ao copiar cookies",
"management": {
"title": "Gerenciamento de Cookies",
"menuItem": "Gerenciamento de Cookies"
"menuItem": "Gerenciamento de Cookies",
"tabImport": "Importar",
"tabExport": "Exportar",
"importDescription": "Importe cookies de um arquivo no formato Netscape ou JSON.",
"dropPrompt": "Clique para escolher um arquivo de cookies",
"fileFormats": "(.txt, .cookies ou .json)",
"cookiesFound": "{{count}} cookies encontrados",
"importedSuccess": "{{imported}} cookies importados com sucesso ({{replaced}} substituídos)",
"linesSkipped": "{{count}} linha(s) ignoradas",
"fileReadError": "Falha ao ler o arquivo",
"loadFailed": "Falha ao carregar cookies: {{error}}",
"cookiesLabel": "Cookies",
"selectionStatus": "({{selected}} de {{total}} selecionados)",
"selectAll": "Selecionar tudo",
"deselectAll": "Desmarcar tudo",
"noCookies": "Nenhum cookie encontrado neste perfil",
"doneButton": "Concluído",
"importButton": "Importar",
"exportButton": "Exportar",
"backButton": "Voltar"
},
"import": {
"title": "Importar Cookies",
@@ -623,7 +868,31 @@
"maxLength": "Deve ter no máximo {{max}} caracteres",
"networkError": "Erro de rede. Por favor, verifique sua conexão.",
"serverError": "Erro do servidor. Por favor, tente novamente mais tarde.",
"unknownError": "Ocorreu um erro desconhecido. Por favor, tente novamente."
"unknownError": "Ocorreu um erro desconhecido. Por favor, tente novamente.",
"noProfilesForUrl": "Sem perfis disponíveis. Crie um perfil primeiro antes de abrir URLs.",
"updateCamoufoxConfigFailed": "Falha ao atualizar a configuração do camoufox: {{error}}",
"updateWayfernConfigFailed": "Falha ao atualizar a configuração do wayfern: {{error}}",
"createProfileFailed": "Falha ao criar o perfil: {{error}}",
"launchBrowserFailed": "Falha ao iniciar o navegador: {{error}}",
"cannotDeleteRunningProfile": "Não é possível excluir o perfil enquanto o navegador estiver em execução. Pare o navegador primeiro.",
"deleteProfileFailed": "Falha ao excluir o perfil: {{error}}",
"renameProfileFailed": "Falha ao renomear o perfil: {{error}}",
"killBrowserFailed": "Falha ao encerrar o navegador: {{error}}",
"deleteSelectedProfilesFailed": "Falha ao excluir os perfis selecionados: {{error}}",
"cookieCopyUnsupportedBrowser": "A cópia de cookies só funciona com perfis Wayfern e Camoufox",
"updateSyncSettingsFailed": "Falha ao atualizar as configurações de sincronização",
"cloneProfileFailed": "Falha ao clonar o perfil: {{error}}",
"loadSupportedBrowsersFailed": "Falha ao carregar os navegadores suportados",
"setupExtensionListenersFailed": "Falha ao configurar os listeners de eventos de extensões: {{error}}",
"loadGroupsFailed": "Falha ao carregar os grupos: {{error}}",
"setupGroupListenersFailed": "Falha ao configurar os listeners de eventos de grupos: {{error}}",
"loadProfilesFailed": "Falha ao carregar os perfis: {{error}}",
"setupProfileListenersFailed": "Falha ao configurar os listeners de eventos de perfis: {{error}}",
"loadProxiesFailed": "Falha ao carregar os proxies: {{error}}",
"setupProxyListenersFailed": "Falha ao configurar os listeners de eventos de proxies: {{error}}",
"loadVpnConfigsFailed": "Falha ao carregar as configurações de VPN: {{error}}",
"setupVpnListenersFailed": "Falha ao configurar os listeners de eventos de VPN: {{error}}",
"themeNotFound": "Tema Tokyo Night não encontrado"
},
"browser": {
"camoufox": "Camoufox",
@@ -649,15 +918,15 @@
"blockWebRTC": "Bloquear WebRTC",
"blockWebGL": "Bloquear WebGL",
"navigatorProperties": "Propriedades do Navigator",
"userAgent": "User Agent",
"userAgent": "Agente do usuário",
"userAgentAndPlatform": "User Agent & Platform",
"platform": "Platform",
"platformVersion": "Platform Version",
"appVersion": "App Version",
"osCpu": "OS CPU",
"osCpu": "CPU do SO",
"hardwareConcurrency": "Hardware Concurrency",
"maxTouchPoints": "Pontos de Toque Máximos",
"doNotTrack": "Do Not Track",
"doNotTrack": "Não rastrear",
"selectDntPlaceholder": "Selecionar valor DNT",
"dntAllowed": "0 (rastreamento permitido)",
"dntNotAllowed": "1 (rastreamento não permitido)",
@@ -679,8 +948,8 @@
"outerHeight": "Altura Externa",
"innerWidth": "Largura Interna",
"innerHeight": "Altura Interna",
"screenX": "Screen X",
"screenY": "Screen Y",
"screenX": "Tela X",
"screenY": "Tela Y",
"geolocation": "Geolocalização",
"timezoneAndGeolocation": "Fuso Horário e Geolocalização",
"timezoneGeolocationDescription": "Estes valores substituem as APIs de fuso horário e geolocalização do navegador.",
@@ -694,15 +963,15 @@
"region": "Região",
"script": "Script",
"webglProperties": "Propriedades WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglVendor": "Fornecedor WebGL",
"webglRenderer": "Renderizador WebGL",
"webglParameters": "Parâmetros WebGL",
"webglParametersJson": "Parâmetros WebGL (JSON)",
"webgl2Parameters": "Parâmetros WebGL2",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"webglShaderPrecisionFormats": "Formatos de precisão de shader WebGL",
"webgl2ShaderPrecisionFormats": "Formatos de precisão de shader WebGL2",
"canvasFingerprint": "Canvas Fingerprint",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeed": "Semente de ruído Canvas",
"canvasNoiseSeedDescription": "Este seed é usado para gerar uma impressão digital Canvas consistente, mas única. Cada perfil deve ter um seed diferente.",
"fonts": "Fontes",
"fontsJson": "Fontes (JSON array)",
@@ -723,13 +992,16 @@
"maxChannelCount": "Contagem Máxima de Canais",
"vendorInfo": "Informações do Fabricante",
"vendor": "Fabricante",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"vendorSub": "Fornecedor Sub",
"productSub": "Produto Sub",
"brand": "Marca",
"brandVersion": "Versão da Marca",
"proFeature": "Este é um recurso Pro",
"generateFingerprint": "Gerar Impressão Digital",
"refreshFingerprint": "Atualizar Impressão Digital"
"refreshFingerprint": "Atualizar Impressão Digital",
"canvasNoiseSeedPlaceholder": "Insira uma string seed para a impressão digital do canvas",
"addFontsPlaceholder": "Adicionar fontes...",
"enterAsJson": "Insira {{title}} como JSON"
},
"warnings": {
"windowResizeTitle": "Dimensões de janela personalizadas",
@@ -869,7 +1141,9 @@
"syncEnabled": "Sincronização ativada",
"syncDisabled": "Sincronização desativada",
"syncEnableTooltip": "Ativar sincronização",
"syncDisableTooltip": "Desativar sincronização"
"syncDisableTooltip": "Desativar sincronização",
"loadGroupsFailed": "Falha ao carregar grupos de extensões",
"assignGroupFailed": "Falha ao atribuir grupo de extensões"
},
"pro": {
"badge": "PRO",
@@ -882,11 +1156,11 @@
"dnsBlocklist": {
"title": "Lista de bloqueio DNS",
"none": "Nenhum",
"light": "Light",
"light": "Leve",
"normal": "Normal",
"pro": "Pro",
"proPlus": "Pro++",
"ultimate": "Ultimate",
"ultimate": "Definitivo",
"settingsDescription": "As listas de bloqueio DNS bloqueiam anúncios, rastreadores e domínios de malware no nível do proxy. As listas são atualizadas automaticamente a cada 12 horas.",
"manageLists": "Gerenciar listas de bloqueio DNS",
"refreshAll": "Atualizar todas as listas",
@@ -895,5 +1169,421 @@
"fresh": "Atualizado",
"stale": "Desatualizado",
"notCached": "Sem cache"
},
"vpns": {
"form": {
"titleEdit": "Editar VPN",
"titleCreate": "Criar VPN WireGuard",
"descEdit": "Atualize o nome da sua configuração VPN.",
"descCreate": "Insira os detalhes da interface e do par WireGuard.",
"name": "Nome",
"namePlaceholder": "p. ex. WireGuard Casa",
"privateKey": "Chave Privada",
"privateKeyPlaceholder": "Chave privada codificada em Base64",
"address": "Endereço",
"addressPlaceholder": "p. ex. 10.0.0.2/24",
"dnsOptional": "DNS (opcional)",
"dnsPlaceholder": "p. ex. 1.1.1.1",
"mtuOptional": "MTU (opcional)",
"mtuPlaceholder": "p. ex. 1420",
"peerPublicKey": "Chave Pública do Par",
"peerPublicKeyPlaceholder": "Chave pública do par codificada em Base64",
"peerEndpoint": "Endpoint do Par",
"peerEndpointPlaceholder": "p. ex. vpn.example.com:51820",
"allowedIps": "IPs Permitidos",
"allowedIpsPlaceholder": "p. ex. 0.0.0.0/0, ::/0",
"keepaliveOptional": "Keepalive Persistente (opcional)",
"keepalivePlaceholder": "p. ex. 25",
"presharedKeyOptional": "Chave Pré-Compartilhada (opcional)",
"presharedKeyPlaceholder": "Chave pré-compartilhada codificada em Base64",
"updateButton": "Atualizar VPN",
"createButton": "Criar VPN",
"nameRequired": "O nome da VPN é obrigatório",
"privateKeyRequired": "A chave privada é obrigatória",
"addressRequired": "O endereço é obrigatório",
"peerPublicKeyRequired": "A chave pública do par é obrigatória",
"peerEndpointRequired": "O endpoint do par é obrigatório",
"updated": "VPN atualizada com sucesso",
"created": "VPN WireGuard criada com sucesso",
"updateFailed": "Falha ao atualizar a VPN: {{error}}",
"createFailed": "Falha ao criar a VPN: {{error}}"
},
"import": {
"title": "Importar Configuração VPN",
"descDropzone": "Importe um arquivo de configuração WireGuard (.conf)",
"descPreview": "Revise a configuração VPN a importar",
"descResult": "Importação de VPN concluída",
"dropzonePrompt": "Solte um arquivo .conf WireGuard aqui ou clique para navegar",
"pasteHint": "Colar da área de transferência com {{modKey}}+V",
"invalidContent": "O conteúdo não parece ser uma configuração VPN válida",
"fileReadError": "Falha ao ler o arquivo",
"wrongFileType": "Por favor solte um arquivo .conf WireGuard",
"configurationLabel": "Configuração {{type}}",
"endpointLabel": "Endpoint: {{endpoint}}",
"vpnNameLabel": "Nome da VPN",
"vpnNamePlaceholder": "Minha VPN",
"configPreview": "Visualização da Configuração",
"importedSuccess": "VPN Importada com Sucesso",
"importFailed": "Importação Falhou",
"importButton": "Importar VPN",
"doneButton": "Concluído",
"failedGeneric": "Falha ao importar a configuração de VPN",
"defaultName": "VPN {{type}}"
},
"management": {
"loading": "Carregando VPNs...",
"noneCreated": "Nenhuma configuração de VPN ainda. Importe ou crie uma usando os botões acima.",
"editVpn": "Editar VPN",
"deleteVpn": "Excluir VPN",
"cannotDelete_one": "Não é possível excluir: em uso por {{count}} perfil",
"cannotDelete_other": "Não é possível excluir: em uso por {{count}} perfis",
"syncCannotDisable": "A sincronização não pode ser desativada enquanto esta VPN estiver em uso por perfis sincronizados",
"deleteSuccess": "VPN excluída com sucesso",
"deleteFailed": "Falha ao excluir VPN",
"deleteTitle": "Excluir VPN",
"deleteDescription": "Esta ação não pode ser desfeita. A VPN \"{{name}}\" será excluída permanentemente."
}
},
"importProfile": {
"title": "Importar perfil do navegador",
"autoDetect": "Detecção automática",
"manualImport": "Importação manual",
"detectedProfilesTitle": "Perfis de navegador detectados",
"scanning": "Procurando perfis de navegador...",
"noneFound": "Nenhum perfil de navegador encontrado no seu sistema.",
"noneFoundHint": "Tente a importação manual se você tem perfis em locais personalizados.",
"selectProfile": "Selecionar perfil:",
"selectProfilePlaceholder": "Escolha um perfil detectado",
"pathLabel": "Caminho:",
"browserLabel": "Navegador:",
"newProfileName": "Nome do novo perfil:",
"newProfileNamePlaceholder": "Insira um nome para o perfil importado",
"manualTitle": "Importação manual de perfil",
"browserType": "Tipo de navegador:",
"loadingBrowsers": "Carregando navegadores...",
"selectBrowserType": "Selecione o tipo de navegador",
"profileFolderPath": "Caminho da pasta do perfil:",
"profileFolderPlaceholder": "Insira o caminho completo para a pasta do perfil",
"browseFolderTitle": "Procurar pasta",
"examplePaths": "Caminhos de exemplo:",
"selectFolderTitle": "Selecionar pasta de perfil do navegador",
"folderDialogFailed": "Falha ao abrir o diálogo de pasta",
"detectFailed": "Falha ao detectar perfis de navegador existentes",
"fillFields": "Por favor, preencha todos os campos",
"selectAndName": "Selecione um perfil e forneça um nome",
"profileNotFound": "Perfil selecionado não encontrado",
"importedSuccess": "Perfil \"{{name}}\" importado com sucesso",
"notInstalled": "{{browser}} não está instalado. Baixe {{browser}} primeiro pela janela principal e tente importar novamente.",
"importFailed": "Falha ao importar perfil: {{error}}",
"proxyOptional": "Proxy (Opcional)",
"noProxy": "Sem proxy",
"nextButton": "Próximo",
"importButton": "Importar",
"importedAs": "Este perfil será importado como um perfil {{browser}}."
},
"syncTooltips": {
"syncing": "Sincronizando...",
"syncedAt": "Sincronizado {{time}}",
"synced": "Sincronizado",
"waiting": "Aguardando sincronização",
"errorWith": "Erro de sincronização: {{error}}",
"error": "Erro de sincronização",
"notSynced": "Não sincronizado"
},
"groupManagement": {
"description": "Gerencie seus grupos de perfis",
"createGroup": "Criar grupo",
"noGroups": "Nenhum grupo criado ainda. Crie seu primeiro grupo usando o botão acima.",
"loading": "Carregando grupos...",
"profileCount_one": "{{count}} perfil",
"profileCount_other": "{{count}} perfis",
"groupsLabel": "Grupos",
"profilesCol": "Perfis",
"syncCannotDisable": "A sincronização não pode ser desativada enquanto este grupo estiver em uso por perfis sincronizados",
"editGroupTooltip": "Editar grupo",
"deleteGroupTooltip": "Excluir grupo",
"loadFailed": "Falha ao carregar grupos"
},
"proxyAssignment": {
"title": "Atribuir proxy / VPN",
"description_one": "Atribuir um proxy ou VPN a {{count}} perfil selecionado.",
"description_other": "Atribuir um proxy ou VPN a {{count}} perfis selecionados.",
"selectLabel": "Proxy / VPN",
"placeholder": "Selecione um proxy ou VPN",
"noProxy": "Sem proxy / VPN",
"searchPlaceholder": "Buscar proxies ou VPNs...",
"notFound": "Nenhum proxy ou VPN encontrado.",
"assignButton": "Atribuir",
"success": "Proxy/VPN atribuído a {{count}} perfil(is)",
"failed": "Falha ao atribuir proxy/VPN",
"selectedProfilesLabel": "Perfis selecionados:",
"assignProxyVpnLabel": "Atribuir proxy / VPN:",
"noneOption": "Nenhum",
"noValidProfiles": "Nenhum perfil válido selecionado.",
"vpnGroupHeading": "VPNs",
"failedFallback": "Falha ao atribuir proxy/VPN aos perfis"
},
"groupAssignment": {
"title": "Atribuir grupo",
"description_one": "Atribuir um grupo a {{count}} perfil selecionado.",
"description_other": "Atribuir um grupo a {{count}} perfis selecionados.",
"selectLabel": "Grupo",
"placeholder": "Selecione um grupo",
"noGroup": "Sem grupo (Padrão)",
"assignButton": "Atribuir",
"success": "Grupo atribuído a {{count}} perfil(is)",
"failed": "Falha ao atribuir grupo",
"selectedProfilesLabel": "Perfis selecionados:",
"assignGroupLabel": "Atribuir ao grupo:",
"noValidProfiles": "Nenhum perfil válido selecionado.",
"failedFallback": "Falha ao atribuir grupo aos perfis"
},
"profileSelector": {
"title": "Selecionar perfil",
"description": "Escolha um perfil para abrir com esta URL",
"searchPlaceholder": "Buscar perfis...",
"noProfiles": "Nenhum perfil disponível",
"noResults": "Nenhum perfil corresponde à sua busca",
"selectButton": "Selecionar",
"launching": "Abrindo...",
"chooseProfileTitle": "Escolher perfil",
"openingUrl": "Abrindo URL:",
"urlCopied": "URL copiada para a área de transferência!",
"selectProfileLabel": "Selecionar perfil:",
"noneAvailableShort": "Nenhum perfil disponível. Crie um perfil primeiro.",
"noneAvailableLong": "Feche este diálogo e crie um perfil pela janela principal para começar.",
"chooseAProfile": "Escolha um perfil",
"badgeProxy": "Proxy",
"badgeRunning": "Em execução",
"badgeUnavailable": "Indisponível",
"openButton": "Abrir"
},
"locationProxy": {
"title": "Proxy rápido por localização",
"description": "Escolha um país pelo qual rotear este perfil. Um proxy será criado automaticamente.",
"country": "País",
"selectCountry": "Selecione um país",
"searchCountry": "Buscar país...",
"noCountriesFound": "Nenhum país encontrado.",
"apply": "Aplicar",
"creating": "Criando proxy...",
"success": "Proxy de localização aplicado",
"failed": "Falha ao aplicar proxy de localização",
"titleCreate": "Criar proxy por localização",
"descriptionCreate": "Crie um proxy geolocalizado com sessão persistente de 24 horas",
"countryLabel": "País (obrigatório)",
"regionLabel": "Região (opcional)",
"cityLabel": "Cidade (opcional)",
"ispLabel": "ISP (opcional)",
"nameLabel": "Nome",
"namePlaceholder": "Nome do proxy",
"loadingCountries": "Carregando países...",
"selectCountryPh": "Selecione o país",
"searchCountries": "Buscar países...",
"loadFailed": "Falha ao carregar os países",
"selectCountryFirst": "Selecione primeiro um país",
"loadingRegions": "Carregando regiões...",
"noRegions": "Nenhuma região disponível",
"selectRegion": "Selecione a região",
"searchRegions": "Buscar regiões...",
"loadingCities": "Carregando cidades...",
"noCities": "Nenhuma cidade disponível",
"selectCity": "Selecione a cidade",
"searchCities": "Buscar cidades...",
"loadingIsps": "Carregando ISPs...",
"noIsps": "Nenhum ISP disponível",
"selectIsp": "Selecione o ISP",
"searchIsps": "Buscar ISPs...",
"createSuccess": "Proxy de localização criado",
"createFailed": "Falha ao criar proxy de localização",
"creatingButton": "Criando...",
"createButton": "Criar"
},
"launchOnLogin": {
"title": "Ativar inicialização no login?",
"description": "Rodar em segundo plano ajuda a manter seus proxies e navegadores ativos.",
"declineButton": "Não perguntar novamente",
"declining": "...",
"enableButton": "Ativar",
"enableSuccess": "Inicialização no login ativada",
"enableFailed": "Falha ao ativar a inicialização no login",
"declineFailed": "Falha ao salvar a preferência",
"tryAgain": "Tente novamente"
},
"wayfernTerms": {
"title": "Termos e condições da Wayfern",
"description": "Antes de usar o Donut Browser, você deve ler e concordar com os Termos e Condições da Wayfern.",
"reviewLabel": "Por favor, revise os Termos e Condições em:",
"agreeNotice": "Ao clicar em \"Aceito\", você concorda em ficar vinculado a esses termos.",
"acceptButton": "Aceito",
"acceptSuccess": "Termos aceitos com sucesso",
"acceptFailed": "Falha ao aceitar os termos",
"tryAgain": "Tente novamente"
},
"commercialTrial": {
"title": "Período de teste comercial expirado",
"description": "Seu período de teste comercial de 2 semanas terminou.",
"body": "Se você está usando o Donut Browser para fins comerciais, precisa adquirir uma licença comercial para continuar. Você ainda pode usá-lo gratuitamente para uso pessoal.",
"understandButton": "Entendi",
"failed": "Falha ao salvar a confirmação",
"tryAgain": "Tente novamente"
},
"permissionDialog": {
"titleMicrophone": "Acesso ao microfone necessário",
"titleCamera": "Acesso à câmera necessário",
"descMicrophone": "O Donut Browser precisa acessar seu microfone para habilitar a funcionalidade de microfone nos navegadores. Cada site que quiser usar seu microfone ainda pedirá sua permissão individualmente.",
"descCamera": "O Donut Browser precisa acessar sua câmera para habilitar a funcionalidade de câmera nos navegadores. Cada site que quiser usar sua câmera ainda pedirá sua permissão individualmente.",
"grantedMicrophone": "Permissão concedida! Os navegadores abertos pelo Donut Browser agora podem acessar seu microfone.",
"grantedCamera": "Permissão concedida! Os navegadores abertos pelo Donut Browser agora podem acessar sua câmera.",
"notGrantedMicrophone": "Permissão não concedida. Clique no botão abaixo para solicitar acesso ao seu microfone.",
"notGrantedCamera": "Permissão não concedida. Clique no botão abaixo para solicitar acesso à sua câmera.",
"doneButton": "Concluído",
"cancelButton": "Cancelar",
"grantAccessButton": "Conceder acesso",
"requestSuccessMicrophone": "Acesso ao microfone solicitado",
"requestSuccessCamera": "Acesso à câmera solicitado",
"requestFailed": "Falha ao solicitar permissão"
},
"traffic": {
"title": "Detalhes do tráfego",
"bandwidthOverTime": "Largura de banda ao longo do tempo",
"timePeriodPlaceholder": "Período",
"last1m": "Último 1 min",
"last5m": "Últimos 5 min",
"last30m": "Últimos 30 min",
"last1h": "Última 1 h",
"last2h": "Últimas 2 h",
"last4h": "Últimas 4 h",
"last1d": "Último dia",
"last7d": "Últimos 7 dias",
"last30d": "Últimos 30 dias",
"allTime": "Todo o tempo",
"allTimeShort": "todo o tempo",
"totalSuffix": "total",
"sentLabel": "Enviado ({{period}})",
"receivedLabel": "Recebido ({{period}})",
"requestsLabel": "Solicitações ({{period}})",
"allTimeTraffic": "Tráfego total:",
"allTimeRequests": "Solicitações totais:",
"proxyDisclaimer": "Nota: se você está usando proxy, VPN ou serviço similar, seu provedor pode calcular o tráfego de forma diferente devido a sobrecarga de criptografia e diferenças de protocolo.",
"topByTraffic": "Principais domínios por tráfego ({{period}})",
"topByRequests": "Principais domínios por solicitações ({{period}})",
"columnDomain": "Domínio",
"columnRequests": "Solicitações",
"columnSent": "Enviado",
"columnReceived": "Recebido",
"columnTotal": "Tráfego total",
"uniqueIps": "IPs únicos ({{count}})",
"noData": "Nenhum dado de tráfego disponível para este perfil.",
"noDataHint": "Os dados de tráfego aparecerão após você abrir o perfil.",
"sentLegend": "Enviado",
"receivedLegend": "Recebido",
"tooltipSent": "↑ Enviado: ",
"tooltipReceived": "↓ Recebido: "
},
"camoufoxDialog": {
"titleView": "Ver configurações de impressão digital - {{name}} ({{browser}})",
"titleConfigure": "Configurar impressão digital - {{name}} ({{browser}})",
"invalidFingerprint": "Configuração de impressão digital inválida",
"invalidFingerprintDescription": "A configuração de impressão digital contém JSON inválido. Verifique suas configurações avançadas.",
"saveFailed": "Falha ao salvar configuração",
"unknownError": "Ocorreu um erro desconhecido"
},
"proxyCheck": {
"unknownLocation": "Desconhecido",
"locationToast": "A localização do seu proxy é:",
"failed": "Falha na verificação do proxy: {{error}}",
"tooltipChecking": "Verificando proxy...",
"tooltipIp": "IP: {{ip}}",
"tooltipChecked": "Verificado {{time}}",
"tooltipFailed": "Falha {{time}}",
"tooltipFailedTitle": "Falha na verificação do proxy",
"tooltipDefault": "Verificar validade do proxy"
},
"vpnCheck": {
"valid": "Configuração de VPN \"{{name}}\" é válida",
"invalid": "Configuração de VPN \"{{name}}\" é inválida",
"failed": "Falha na verificação da VPN: {{error}}",
"tooltipChecking": "Verificando configuração de VPN...",
"tooltipValid": "Configuração válida",
"tooltipInvalid": "Configuração inválida",
"tooltipChecked": "Verificado {{time}}",
"tooltipDefault": "Verificar validade da configuração de VPN"
},
"profileTable": {
"syncTooltipDisabled": "Sincronização desativada",
"syncTooltipSyncing": "Sincronizando...",
"syncTooltipSyncedAt": "Sincronizado {{time}}",
"syncTooltipSynced": "Sincronizado",
"syncTooltipWaiting": "Aguardando sincronização",
"syncTooltipErrorWith": "Erro de sincronização: {{error}}",
"syncTooltipError": "Erro de sincronização",
"syncTooltipNotSynced": "Não sincronizado",
"noTags": "Sem tags",
"syncTooltipCloseToSync": "Feche o perfil para sincronizar",
"syncTooltipDisabledWithLast": "Sincronização desativada, última sincronização {{time}}",
"addTagsPlaceholder": "Adicionar etiquetas",
"tagsHeader": "Etiquetas",
"noteHeader": "Nota",
"vpnsHeading": "VPNs",
"createByCountryHeading": "Criar por país"
},
"releaseTypeSelector": {
"noReleaseTypes": "Nenhum tipo de versão disponível.",
"placeholder": "Selecione o tipo de versão...",
"stable": "Estável",
"nightly": "Nightly",
"downloaded": "Baixado",
"downloadBrowser": "Baixar navegador",
"downloading": "Baixando..."
},
"dataTableActionBar": {
"selected": "{{count}} selecionado(s)",
"clearSelection": "Limpar seleção"
},
"appUpdate": {
"toast": {
"updateFailed": "Falha ao atualizar o Donut Browser",
"restartFailed": "Falha ao reiniciar",
"updateReady": "Atualização pronta, reinicie para aplicar",
"manualDownloadRequired": "Download manual necessário",
"restartNow": "Reiniciar agora",
"viewRelease": "Ver lançamento",
"later": "Mais tarde",
"uploading": "Enviando",
"downloading": "Baixando"
}
},
"browserDownload": {
"toast": {
"fetchVersionsFailed": "Falha ao obter as versões de {{browser}}",
"foundNewVersions": "Foram encontradas {{count}} novas versões de {{browser}}!",
"totalAvailableVersions": "Total disponível: {{count}} versões",
"downloadFailed": "Falha ao baixar {{browser}} {{version}}",
"calculating": "calculando...",
"extractionFailed": "{{browser}} {{version}}: falha na extração",
"extractionFailedDescription": "O arquivo corrompido foi excluído. Será baixado novamente na próxima tentativa.",
"extracting": "Extraindo arquivos do navegador... Não feche o aplicativo.",
"verifying": "Verificando arquivos do navegador...",
"downloadingRolling": "Baixando build rolling release..."
}
},
"versionUpdater": {
"toast": {
"alreadyAvailable": "{{browser}} {{version}} já disponível",
"updatingProfiles": "Atualizando configurações dos perfis...",
"updateCompleted": "Atualização de {{browser}} concluída",
"singleProfileUpdated": "O perfil \"{{name}}\" foi atualizado para a versão {{version}}. Agora você pode iniciar seus navegadores com a versão mais recente.",
"multipleProfilesUpdated": "{{count}} perfis foram atualizados para a versão {{version}}. Agora você pode iniciar seus navegadores com a versão mais recente.",
"versionAvailable": "A versão {{version}} está disponível. Os perfis em execução usarão a nova versão ao reiniciar.",
"autoUpdateFailed": "Falha ao atualizar automaticamente {{browser}}",
"updateWithErrors": "Atualização concluída com alguns erros",
"updateWithErrorsDescription": "{{newVersions}} novas versões encontradas, {{failedUpdates}} navegadores não puderam ser atualizados",
"updateSuccess": "Versões dos navegadores atualizadas com sucesso",
"updateSuccessDescription": "Foram encontradas {{newVersions}} novas versões em {{successfulUpdates}} navegadores. Os downloads automáticos começarão em breve.",
"upToDate": "Nenhuma nova versão de navegador encontrada",
"upToDateDescription": "Todas as versões dos navegadores estão atualizadas",
"updateAllFailed": "Falha ao atualizar as versões dos navegadores"
}
}
}
+725 -35
View File
@@ -28,7 +28,9 @@
"refresh": "Обновить",
"loading": "Загрузка...",
"saveSettings": "Сохранить настройки",
"moreInfo": "Подробнее"
"moreInfo": "Подробнее",
"downloading": "Загрузка...",
"minimize": "Свернуть"
},
"status": {
"active": "Активен",
@@ -56,7 +58,10 @@
"default": "По умолчанию",
"custom": "Пользовательский",
"optional": "Необязательно",
"required": "Обязательно"
"required": "Обязательно",
"unknownProfile": "Неизвестный",
"mode": "Режим",
"never": "Никогда"
},
"time": {
"days": "дней",
@@ -64,6 +69,33 @@
"minutes": "минут",
"seconds": "секунд",
"remaining": "осталось"
},
"aria": {
"selectAll": "Выбрать все",
"selectRow": "Выбрать строку",
"selectProfile": "Выбрать профиль",
"copy": "Скопировать в буфер обмена",
"copied": "Скопировано",
"showToken": "Показать токен",
"hideToken": "Скрыть токен"
},
"keys": {
"escape": "Esc"
},
"errors": {
"unknown": "Произошла неизвестная ошибка"
},
"window": {
"minimize": "Свернуть"
},
"commandPalette": {
"title": "Палитра команд",
"description": "Найдите команду для выполнения..."
},
"noResults": "Результаты не найдены.",
"srOnly": {
"copy": "Скопировать",
"copied": "Скопировано"
}
},
"settings": {
@@ -85,7 +117,8 @@
"title": "Язык",
"description": "Выберите предпочитаемый язык интерфейса приложения.",
"systemDefault": "Системный по умолчанию",
"selectLanguage": "Выберите язык"
"selectLanguage": "Выберите язык",
"interface": "Язык интерфейса"
},
"defaultBrowser": {
"title": "Браузер по умолчанию",
@@ -100,7 +133,8 @@
"microphone": "Микрофон",
"microphoneDescription": "Доступ к микрофону для браузерных приложений",
"camera": "Камера",
"cameraDescription": "Доступ к камере для браузерных приложений"
"cameraDescription": "Доступ к камере для браузерных приложений",
"accessRequested": "Запрошен доступ к {{permission}}"
},
"integrations": {
"title": "Интеграции",
@@ -134,7 +168,8 @@
"advanced": {
"title": "Дополнительно",
"clearCache": "Очистить весь кэш версий",
"clearCacheDescription": "Очищает все кэшированные данные версий браузеров и обновляет все версии из источников. Это принудительно загрузит информацию о версиях для всех браузеров."
"clearCacheDescription": "Очищает все кэшированные данные версий браузеров и обновляет все версии из источников. Это принудительно загрузит информацию о версиях для всех браузеров.",
"clearCacheFailed": "Не удалось очистить кэш"
},
"disableAutoUpdates": "Отключить автообновление приложения",
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются."
@@ -169,7 +204,9 @@
"note": "Заметка",
"group": "Группа",
"proxy": "Прокси / VPN",
"lastLaunch": "Последний запуск"
"lastLaunch": "Последний запуск",
"empty": "Профили не найдены.",
"notSelected": "Не выбрано"
},
"actions": {
"launch": "Запустить",
@@ -205,7 +242,30 @@
"ephemeral": "Временный",
"ephemeralDescription": "Браузер принудительно записывает данные профиля в память вместо диска. Данные удаляются при закрытии браузера.",
"ephemeralBadge": "Временный",
"ephemeralAlpha": "Alpha"
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "Удалить выбранные профили",
"description": "Это действие нельзя отменить. Будет навсегда удалено {{count}} профил(ей) и все связанные данные.",
"confirmButton": "Удалить {{count}} профил(ей)"
},
"note": {
"empty": "Нет заметки",
"placeholder": "Добавить заметку..."
},
"aria": {
"profileInfo": "Информация о профиле"
},
"delete": {
"title": "Удалить профиль",
"description": "Это действие нельзя отменить. Профиль «{{profileName}}» и все связанные с ним данные будут навсегда удалены.",
"confirmButton": "Удалить профиль"
},
"actionBar": {
"assignToGroup": "Назначить группе",
"assignProxy": "Назначить прокси",
"assignExtensionGroup": "Назначить группу расширений",
"copyCookies": "Копировать cookies"
}
},
"createProfile": {
"title": "Создать новый профиль",
@@ -228,7 +288,10 @@
"title": "Прокси / VPN",
"addProxy": "Добавить прокси",
"noProxy": "Без прокси / VPN",
"noProxiesAvailable": "Нет доступных прокси или VPN. Добавьте один для маршрутизации трафика этого профиля."
"noProxiesAvailable": "Нет доступных прокси или VPN. Добавьте один для маршрутизации трафика этого профиля.",
"search": "Поиск прокси или VPN...",
"notFound": "Прокси или VPN не найдены.",
"searchWithCountries": "Поиск прокси, VPN или стран..."
},
"launchHook": {
"label": "URL хука запуска",
@@ -248,7 +311,8 @@
"chromiumSubtitle": "На базе Wayfern",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "На базе Camoufox",
"camoufoxWarning": "Firefox (Camoufox) поддерживается сторонней организацией. Для промышленного использования используйте Chromium."
"camoufoxWarning": "Firefox (Camoufox) поддерживается сторонней организацией. Для промышленного использования используйте Chromium.",
"platformUnavailable": "{{browser}} пока недоступен на вашей платформе."
},
"deleteDialog": {
"title": "Удалить профиль",
@@ -259,7 +323,31 @@
},
"proxies": {
"title": "Прокси",
"management": "Прокси и VPN",
"management": {
"description": "Управляйте конфигурациями прокси и VPN для переиспользования в профилях",
"tabProxies": "Прокси",
"tabVpns": "VPN",
"create": "Создать",
"loading": "Загрузка прокси...",
"noneCreated": "Прокси еще нет. Создайте первый прокси, используя кнопку выше.",
"usage": "Использование",
"syncCol": "Синхронизация",
"syncCannotDisable": "Нельзя отключить синхронизацию, пока этот прокси используется синхронизированными профилями",
"enableSync": "Включить синхронизацию",
"disableSync": "Отключить синхронизацию",
"editProxy": "Редактировать прокси",
"deleteProxy": "Удалить прокси",
"cannotDelete_one": "Невозможно удалить: используется {{count}} профилем",
"cannotDelete_other": "Невозможно удалить: используется {{count}} профилями",
"syncEnabled": "Синхронизация включена",
"syncDisabled": "Синхронизация отключена",
"updateSyncFailed": "Не удалось обновить синхронизацию",
"deleteSuccess": "Прокси успешно удален",
"deleteFailed": "Не удалось удалить прокси",
"deleteTitle": "Удалить прокси",
"deleteDescription": "Это действие нельзя отменить. Прокси «{{name}}» будет удален навсегда.",
"title": "Прокси и VPN"
},
"add": "Добавить прокси",
"edit": "Редактировать прокси",
"delete": "Удалить прокси",
@@ -280,7 +368,12 @@
"password": "Пароль",
"passwordPlaceholder": "Необязательно",
"cipher": "Шифр",
"cipherPlaceholder": "aes-256-gcm"
"cipherPlaceholder": "aes-256-gcm",
"nameRequired": "Требуется имя прокси",
"hostPortRequired": "Требуются хост и порт",
"ssCipherRequired": "Для Shadowsocks требуется шифр и пароль",
"selectType": "Выберите тип прокси",
"saveFailed": "Не удалось сохранить прокси: {{error}}"
},
"types": {
"http": "HTTP",
@@ -318,6 +411,45 @@
"sync": {
"enabled": "Синхронизация включена",
"disabled": "Синхронизация выключена"
},
"exportDialog": {
"title": "Экспорт прокси",
"description": "Экспортируйте конфигурации прокси в файл",
"format": "Формат экспорта",
"json": "JSON",
"txt": "TXT (формат URL)",
"preview": "Предпросмотр",
"noProxies": "Нет прокси для экспорта",
"downloaded": "Загружено: {{filename}}",
"failed": "Не удалось экспортировать прокси",
"copied": "Скопировано"
},
"importDialog": {
"title": "Импорт прокси",
"descDropzone": "Импорт прокси из JSON или TXT файла",
"descPreview": "Проверьте прокси для импорта",
"descAmbiguous": "Некоторые прокси имеют неоднозначный формат. Выберите правильный формат.",
"descResult": "Импорт завершен",
"dropzonePrompt": "Перетащите файл конфигурации прокси",
"dropzoneFormats": "(.json, .txt)",
"pasteHint": "Вставить из буфера обмена с {{modKey}}+V",
"wrongFileType": "Перетащите файл .json или .txt",
"fileReadError": "Не удалось прочитать файл",
"fileProcessError": "Не удалось обработать файл",
"noValidProxies": "В файле не найдено допустимых прокси",
"namePrefix": "Префикс имени",
"namePrefixDefault": "Imported",
"namePrefixHint": "Прокси будут названы «{{prefix}} Proxy 1», «{{prefix}} Proxy 2» и т.д.",
"proxiesToImport": "Прокси для импорта ({{count}})",
"invalidCount": "({{count}} недействительных)",
"ambiguousIntro": "Следующие прокси имеют неоднозначный формат. Выберите правильную интерпретацию для каждого.",
"imported": "Импортировано:",
"skippedDuplicates": "Пропущено (дубликаты):",
"errors": "Ошибки",
"importButton": "Импортировать {{count}} прокси",
"continueButton": "Продолжить",
"doneButton": "Готово",
"failed": "Не удалось импортировать прокси"
}
},
"groups": {
@@ -343,7 +475,31 @@
"sync": {
"enabled": "Синхронизация включена",
"disabled": "Синхронизация выключена"
}
},
"createTitle": "Создать новую группу",
"createDescription": "Создайте новую группу для организации профилей браузера.",
"editTitle": "Изменить группу",
"editDescription": "Обновите имя группы.",
"createSuccess": "Группа успешно создана",
"createFailed": "Не удалось создать группу",
"updateSuccess": "Группа успешно обновлена",
"updateFailed": "Не удалось обновить группу",
"deleteTitle": "Удалить группу",
"deleteDescription": "Это действие необратимо. Группа будет удалена навсегда.",
"deleteSuccess": "Группа успешно удалена",
"deleteFailed": "Не удалось удалить группу",
"loadingProfiles": "Загрузка связанных профилей...",
"associatedProfiles": "Связанные профили ({{count}})",
"whatToDoWithProfiles": "Что сделать с этими профилями?",
"moveToDefaultOption": "Переместить профили в группу По умолчанию",
"deleteAlongWithGroup": "Удалить профили вместе с группой",
"noAssociatedProfiles": "У этой группы нет связанных профилей.",
"deleteGroup": "Удалить группу",
"deleteGroupAndProfiles": "Удалить группу и профили",
"loadProfilesFailed": "Не удалось загрузить профили",
"unknownGroup": "Неизвестная группа",
"profileGroupsAriaLabel": "Группы профилей",
"loading": "Загрузка групп..."
},
"sync": {
"mode": {
@@ -366,7 +522,16 @@
"configureService": "Настроить сервис синхронизации"
},
"title": "Служба синхронизации",
"config": "Настройка синхронизации",
"config": {
"serverUrlRequired": "Введите URL сервера",
"connectionSuccess": "Подключение успешно!",
"serverError": "Сервер вернул ошибку",
"connectFailed": "Не удалось подключиться к серверу",
"settingsSaved": "Настройки синхронизации сохранены",
"saveFailed": "Не удалось сохранить настройки",
"disconnected": "Синхронизация отключена",
"disconnectFailed": "Не удалось отключиться"
},
"serverUrl": "URL сервера",
"serverUrlPlaceholder": "https://sync.example.com",
"token": "Токен синхронизации",
@@ -410,6 +575,12 @@
"profileLockedShort": "Используется",
"cannotLaunchLocked": "Невозможно запустить — профиль используется {{email}}",
"createdBy": "Создано {{email}}"
},
"disabled": "Отключена",
"toast": {
"profileSynced": "Профиль '{{name}}' успешно синхронизирован",
"profileSyncFailed": "Не удалось синхронизировать профиль '{{name}}'",
"profileSyncFailedWithError": "Не удалось синхронизировать профиль '{{name}}': {{error}}"
}
},
"integrations": {
@@ -447,7 +618,32 @@
"removedFromClaudeCode": "Удалено из Claude Code",
"config": "Конфигурация MCP",
"copyConfig": "Копировать конфигурацию"
}
},
"tabApi": "Локальный API",
"tabMcp": "MCP (ИИ-ассистенты)",
"apiEnableLabel": "Включить локальный API сервер",
"apiEnableDescription": "Позволяет управлять профилями, группами и прокси через REST API.",
"apiPortLabel": "Порт",
"apiTokenLabel": "Токен аутентификации",
"apiTokenHint": "Включите в заголовок Authorization: Bearer {{tokenSlot}}",
"apiInvalidPort": "Неверный порт",
"apiInvalidPortDescription": "Порт должен быть от 1 до 65535",
"apiPortInUse": "Порт {{port}} уже используется",
"apiFallbackPort": "Сервер запущен на резервном порту {{port}}",
"apiStarted": "API сервер запущен на порту {{port}}",
"apiRunning": "API сервер работает на порту {{port}}",
"apiStopped": "API сервер остановлен",
"apiToggleFailed": "Не удалось переключить API сервер",
"apiStartFailed": "Не удалось запустить API сервер",
"apiUnknownError": "Неизвестная ошибка",
"tokenCopied": "Токен скопирован",
"mcpEnableLabel": "Включить MCP сервер (Model Context Protocol)",
"mcpEnableDescription": "Позволяет ИИ-ассистентам, таким как Claude Desktop, управлять браузерами.",
"mcpAcceptTermsFirst": "(Сначала примите условия Wayfern в Настройках)",
"mcpStarted": "MCP сервер запущен на порту {{port}}",
"mcpStopped": "MCP сервер остановлен",
"mcpToggleFailed": "Не удалось переключить MCP сервер",
"openSettings": "Открыть настройки интеграций"
},
"import": {
"title": "Импорт профиля",
@@ -465,7 +661,9 @@
"fingerprint": {
"title": "Отпечаток",
"randomize": "Случайный при запуске",
"randomizeDescription": "Генерировать новый отпечаток при каждом запуске браузера."
"randomizeDescription": "Генерировать новый отпечаток при каждом запуске браузера.",
"osCpuPlaceholder": "напр., Intel Mac OS X 10.15",
"webglRendererPlaceholder": "напр., llvmpipe или похожее"
},
"os": {
"title": "Операционная система",
@@ -499,7 +697,10 @@
"fingerprint": {
"title": "Отпечаток",
"randomize": "Случайный при запуске",
"randomizeDescription": "Генерировать новый отпечаток при каждом запуске браузера."
"randomizeDescription": "Генерировать новый отпечаток при каждом запуске браузера.",
"platformPlaceholder": "напр., Win32, MacIntel, Linux x86_64",
"timezoneOffsetPlaceholder": "напр., 300 для EST (UTC-5)",
"webglRendererPlaceholder": "напр., Intel(R) HD Graphics"
},
"os": {
"title": "Операционная система",
@@ -522,6 +723,10 @@
"webrtc": "Блокировать WebRTC",
"webgl": "Блокировать WebGL"
}
},
"shared": {
"browserBehavior": "Поведение браузера",
"allowAddonsOpenTabs": "Разрешить расширениям браузера автоматически открывать новые вкладки"
}
},
"cookies": {
@@ -534,13 +739,53 @@
"selectCookies": "Выберите Cookie",
"allDomains": "Все домены",
"selectedCount": "Выбрано {{count}} cookie",
"selectedCount_plural": "Выбрано {{count}} cookie"
"selectedCount_plural": "Выбрано {{count}} cookie",
"dialogDescription_one": "Копировать cookies из исходного профиля в {{count}} выбранный профиль.",
"dialogDescription_other": "Копировать cookies из исходного профиля в {{count}} выбранных профилей.",
"sourceProfile": "Исходный профиль",
"sourcePlaceholder": "Выберите профиль для копирования cookies",
"running": "(запущен)",
"targetProfiles": "Целевые профили ({{count}})",
"noOtherTargets": "Других выбранных Wayfern/Camoufox профилей нет",
"selectSourceFirst": "Сначала выберите исходный профиль",
"selectionStatus": "(выбрано {{selected}} из {{total}})",
"searchPlaceholder": "Поиск доменов или cookies...",
"noMatching": "Совпадающие cookies не найдены",
"noFound": "Cookies не найдены",
"replaceNote": "Существующие cookies с тем же именем и доменом будут заменены. Остальные cookies сохранятся.",
"cannotCopyRunningOne": "Не удается скопировать cookies: {{names}} еще запущен",
"cannotCopyRunningMany": "Не удается скопировать cookies: {{names}} еще запущены",
"someErrors": "Произошли ошибки: {{errors}}",
"successMessage": "Скопировано {{copied}} cookies ({{replaced}} заменено)",
"failedMessage": "Не удалось скопировать cookies: {{error}}",
"copyButton_one": "Скопировать {{count}} cookie",
"copyButton_other": "Скопировать {{count}} cookies",
"copyButtonEmpty": "Скопировать cookies"
},
"success": "Cookie успешно скопированы",
"error": "Ошибка копирования cookie",
"management": {
"title": "Управление Cookies",
"menuItem": "Управление Cookies"
"menuItem": "Управление Cookies",
"tabImport": "Импорт",
"tabExport": "Экспорт",
"importDescription": "Импортируйте cookies из файла в формате Netscape или JSON.",
"dropPrompt": "Нажмите, чтобы выбрать файл cookies",
"fileFormats": "(.txt, .cookies или .json)",
"cookiesFound": "Найдено cookies: {{count}}",
"importedSuccess": "Импортировано {{imported}} cookies ({{replaced}} заменено)",
"linesSkipped": "Пропущено строк: {{count}}",
"fileReadError": "Не удалось прочитать файл",
"loadFailed": "Не удалось загрузить cookies: {{error}}",
"cookiesLabel": "Cookies",
"selectionStatus": "(выбрано {{selected}} из {{total}})",
"selectAll": "Выбрать все",
"deselectAll": "Снять выбор",
"noCookies": "Cookies в этом профиле не найдены",
"doneButton": "Готово",
"importButton": "Импорт",
"exportButton": "Экспорт",
"backButton": "Назад"
},
"import": {
"title": "Импорт Cookies",
@@ -623,7 +868,31 @@
"maxLength": "Максимум {{max}} символов",
"networkError": "Сетевая ошибка. Проверьте подключение.",
"serverError": "Ошибка сервера. Попробуйте позже.",
"unknownError": "Произошла неизвестная ошибка. Попробуйте снова."
"unknownError": "Произошла неизвестная ошибка. Попробуйте снова.",
"noProfilesForUrl": "Нет доступных профилей. Сначала создайте профиль, прежде чем открывать URL.",
"updateCamoufoxConfigFailed": "Не удалось обновить настройки camoufox: {{error}}",
"updateWayfernConfigFailed": "Не удалось обновить настройки wayfern: {{error}}",
"createProfileFailed": "Не удалось создать профиль: {{error}}",
"launchBrowserFailed": "Не удалось запустить браузер: {{error}}",
"cannotDeleteRunningProfile": "Невозможно удалить профиль, пока браузер запущен. Сначала остановите браузер.",
"deleteProfileFailed": "Не удалось удалить профиль: {{error}}",
"renameProfileFailed": "Не удалось переименовать профиль: {{error}}",
"killBrowserFailed": "Не удалось остановить браузер: {{error}}",
"deleteSelectedProfilesFailed": "Не удалось удалить выбранные профили: {{error}}",
"cookieCopyUnsupportedBrowser": "Копирование cookies работает только с профилями Wayfern и Camoufox",
"updateSyncSettingsFailed": "Не удалось обновить параметры синхронизации",
"cloneProfileFailed": "Не удалось клонировать профиль: {{error}}",
"loadSupportedBrowsersFailed": "Не удалось загрузить поддерживаемые браузеры",
"setupExtensionListenersFailed": "Не удалось настроить слушатели событий расширений: {{error}}",
"loadGroupsFailed": "Не удалось загрузить группы: {{error}}",
"setupGroupListenersFailed": "Не удалось настроить слушатели событий групп: {{error}}",
"loadProfilesFailed": "Не удалось загрузить профили: {{error}}",
"setupProfileListenersFailed": "Не удалось настроить слушатели событий профилей: {{error}}",
"loadProxiesFailed": "Не удалось загрузить прокси: {{error}}",
"setupProxyListenersFailed": "Не удалось настроить слушатели событий прокси: {{error}}",
"loadVpnConfigsFailed": "Не удалось загрузить конфигурации VPN: {{error}}",
"setupVpnListenersFailed": "Не удалось настроить слушатели событий VPN: {{error}}",
"themeNotFound": "Тема Tokyo Night не найдена"
},
"browser": {
"camoufox": "Camoufox",
@@ -654,10 +923,10 @@
"platform": "Платформа",
"platformVersion": "Версия платформы",
"appVersion": "Версия приложения",
"osCpu": "OS CPU",
"osCpu": "ЦП ОС",
"hardwareConcurrency": "Количество потоков процессора",
"maxTouchPoints": "Максимальное количество точек касания",
"doNotTrack": "Do Not Track",
"doNotTrack": "Не отслеживать",
"selectDntPlaceholder": "Выберите значение DNT",
"dntAllowed": "0 (отслеживание разрешено)",
"dntNotAllowed": "1 (отслеживание не разрешено)",
@@ -679,8 +948,8 @@
"outerHeight": "Внешняя высота",
"innerWidth": "Внутренняя ширина",
"innerHeight": "Внутренняя высота",
"screenX": "Screen X",
"screenY": "Screen Y",
"screenX": "Экран X",
"screenY": "Экран Y",
"geolocation": "Геолокация",
"timezoneAndGeolocation": "Часовой пояс и геолокация",
"timezoneGeolocationDescription": "Эти значения переопределяют API часового пояса и геолокации браузера.",
@@ -694,15 +963,15 @@
"region": "Регион",
"script": "Скрипт",
"webglProperties": "Свойства WebGL",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglVendor": "Производитель WebGL",
"webglRenderer": "Рендерер WebGL",
"webglParameters": "Параметры WebGL",
"webglParametersJson": "Параметры WebGL (JSON)",
"webgl2Parameters": "Параметры WebGL2",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"webglShaderPrecisionFormats": "Форматы точности шейдера WebGL",
"webgl2ShaderPrecisionFormats": "Форматы точности шейдера WebGL2",
"canvasFingerprint": "Отпечаток Canvas",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeed": "Сид шума Canvas",
"canvasNoiseSeedDescription": "Это зерно используется для генерации постоянного, но уникального отпечатка Canvas. У каждого профиля должно быть своё зерно.",
"fonts": "Шрифты",
"fontsJson": "Шрифты (JSON-массив)",
@@ -723,13 +992,16 @@
"maxChannelCount": "Максимальное количество каналов",
"vendorInfo": "Информация о производителе",
"vendor": "Производитель",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"vendorSub": "Подверсия производителя",
"productSub": "Подверсия продукта",
"brand": "Бренд",
"brandVersion": "Версия бренда",
"proFeature": "Это функция Pro",
"generateFingerprint": "Сгенерировать отпечаток",
"refreshFingerprint": "Обновить отпечаток"
"refreshFingerprint": "Обновить отпечаток",
"canvasNoiseSeedPlaceholder": "Введите строку-семя для отпечатка canvas",
"addFontsPlaceholder": "Добавить шрифты...",
"enterAsJson": "Введите {{title}} в формате JSON"
},
"warnings": {
"windowResizeTitle": "Пользовательские размеры окна",
@@ -869,7 +1141,9 @@
"syncEnabled": "Синхронизация включена",
"syncDisabled": "Синхронизация отключена",
"syncEnableTooltip": "Включить синхронизацию",
"syncDisableTooltip": "Отключить синхронизацию"
"syncDisableTooltip": "Отключить синхронизацию",
"loadGroupsFailed": "Не удалось загрузить группы расширений",
"assignGroupFailed": "Не удалось назначить группу расширений"
},
"pro": {
"badge": "PRO",
@@ -882,11 +1156,11 @@
"dnsBlocklist": {
"title": "Список блокировки DNS",
"none": "Нет",
"light": "Light",
"normal": "Normal",
"light": "Лёгкий",
"normal": "Обычный",
"pro": "Pro",
"proPlus": "Pro++",
"ultimate": "Ultimate",
"ultimate": "Максимум",
"settingsDescription": "Списки блокировки DNS блокируют рекламу, трекеры и вредоносные домены на уровне прокси. Списки автоматически обновляются каждые 12 часов.",
"manageLists": "Управление списками блокировки DNS",
"refreshAll": "Обновить все списки",
@@ -895,5 +1169,421 @@
"fresh": "Актуальный",
"stale": "Устаревший",
"notCached": "Не кэшировано"
},
"vpns": {
"form": {
"titleEdit": "Изменить VPN",
"titleCreate": "Создать VPN WireGuard",
"descEdit": "Обновите имя конфигурации VPN.",
"descCreate": "Укажите параметры интерфейса WireGuard и пира.",
"name": "Имя",
"namePlaceholder": "напр. Домашний WireGuard",
"privateKey": "Приватный ключ",
"privateKeyPlaceholder": "Приватный ключ в Base64",
"address": "Адрес",
"addressPlaceholder": "напр. 10.0.0.2/24",
"dnsOptional": "DNS (необязательно)",
"dnsPlaceholder": "напр. 1.1.1.1",
"mtuOptional": "MTU (необязательно)",
"mtuPlaceholder": "напр. 1420",
"peerPublicKey": "Публичный ключ пира",
"peerPublicKeyPlaceholder": "Публичный ключ пира в Base64",
"peerEndpoint": "Endpoint пира",
"peerEndpointPlaceholder": "напр. vpn.example.com:51820",
"allowedIps": "Разрешённые IP",
"allowedIpsPlaceholder": "напр. 0.0.0.0/0, ::/0",
"keepaliveOptional": "Persistent Keepalive (необязательно)",
"keepalivePlaceholder": "напр. 25",
"presharedKeyOptional": "Preshared Key (необязательно)",
"presharedKeyPlaceholder": "Preshared Key в Base64",
"updateButton": "Обновить VPN",
"createButton": "Создать VPN",
"nameRequired": "Имя VPN обязательно",
"privateKeyRequired": "Приватный ключ обязателен",
"addressRequired": "Адрес обязателен",
"peerPublicKeyRequired": "Публичный ключ пира обязателен",
"peerEndpointRequired": "Endpoint пира обязателен",
"updated": "VPN успешно обновлена",
"created": "VPN WireGuard успешно создана",
"updateFailed": "Не удалось обновить VPN: {{error}}",
"createFailed": "Не удалось создать VPN: {{error}}"
},
"import": {
"title": "Импорт конфигурации VPN",
"descDropzone": "Импортируйте файл конфигурации WireGuard (.conf)",
"descPreview": "Проверьте конфигурацию VPN перед импортом",
"descResult": "Импорт VPN завершён",
"dropzonePrompt": "Перетащите файл .conf WireGuard сюда или нажмите для выбора",
"pasteHint": "Вставить из буфера обмена через {{modKey}}+V",
"invalidContent": "Содержимое не похоже на валидную конфигурацию VPN",
"fileReadError": "Не удалось прочитать файл",
"wrongFileType": "Перетащите файл .conf WireGuard",
"configurationLabel": "Конфигурация {{type}}",
"endpointLabel": "Endpoint: {{endpoint}}",
"vpnNameLabel": "Имя VPN",
"vpnNamePlaceholder": "Моя VPN",
"configPreview": "Предпросмотр конфигурации",
"importedSuccess": "VPN успешно импортирована",
"importFailed": "Ошибка импорта",
"importButton": "Импортировать VPN",
"doneButton": "Готово",
"failedGeneric": "Не удалось импортировать конфигурацию VPN",
"defaultName": "{{type}} VPN"
},
"management": {
"loading": "Загрузка VPN...",
"noneCreated": "Конфигураций VPN еще нет. Импортируйте или создайте одну с помощью кнопок выше.",
"editVpn": "Редактировать VPN",
"deleteVpn": "Удалить VPN",
"cannotDelete_one": "Невозможно удалить: используется {{count}} профилем",
"cannotDelete_other": "Невозможно удалить: используется {{count}} профилями",
"syncCannotDisable": "Нельзя отключить синхронизацию, пока этот VPN используется синхронизированными профилями",
"deleteSuccess": "VPN успешно удален",
"deleteFailed": "Не удалось удалить VPN",
"deleteTitle": "Удалить VPN",
"deleteDescription": "Это действие нельзя отменить. VPN «{{name}}» будет удален навсегда."
}
},
"importProfile": {
"title": "Импорт профиля браузера",
"autoDetect": "Автоопределение",
"manualImport": "Ручной импорт",
"detectedProfilesTitle": "Обнаруженные профили браузера",
"scanning": "Поиск профилей браузера...",
"noneFound": "На вашей системе профили браузера не найдены.",
"noneFoundHint": "Попробуйте ручной импорт, если профили находятся в нестандартных местах.",
"selectProfile": "Выбрать профиль:",
"selectProfilePlaceholder": "Выберите обнаруженный профиль",
"pathLabel": "Путь:",
"browserLabel": "Браузер:",
"newProfileName": "Имя нового профиля:",
"newProfileNamePlaceholder": "Введите имя для импортированного профиля",
"manualTitle": "Ручной импорт профиля",
"browserType": "Тип браузера:",
"loadingBrowsers": "Загрузка браузеров...",
"selectBrowserType": "Выберите тип браузера",
"profileFolderPath": "Путь к папке профиля:",
"profileFolderPlaceholder": "Введите полный путь к папке профиля",
"browseFolderTitle": "Выбрать папку",
"examplePaths": "Примеры путей:",
"selectFolderTitle": "Выберите папку профиля браузера",
"folderDialogFailed": "Не удалось открыть диалог выбора папки",
"detectFailed": "Не удалось обнаружить существующие профили браузера",
"fillFields": "Пожалуйста, заполните все поля",
"selectAndName": "Выберите профиль и укажите имя",
"profileNotFound": "Выбранный профиль не найден",
"importedSuccess": "Профиль «{{name}}» успешно импортирован",
"notInstalled": "{{browser}} не установлен. Сначала загрузите {{browser}} из главного окна, затем попробуйте импортировать снова.",
"importFailed": "Не удалось импортировать профиль: {{error}}",
"proxyOptional": "Прокси (необязательно)",
"noProxy": "Без прокси",
"nextButton": "Далее",
"importButton": "Импорт",
"importedAs": "Этот профиль будет импортирован как профиль {{browser}}."
},
"syncTooltips": {
"syncing": "Синхронизация...",
"syncedAt": "Синхронизировано {{time}}",
"synced": "Синхронизировано",
"waiting": "Ожидание синхронизации",
"errorWith": "Ошибка синхронизации: {{error}}",
"error": "Ошибка синхронизации",
"notSynced": "Не синхронизировано"
},
"groupManagement": {
"description": "Управляйте группами профилей",
"createGroup": "Создать группу",
"noGroups": "Групп еще нет. Создайте первую группу, используя кнопку выше.",
"loading": "Загрузка групп...",
"profileCount_one": "{{count}} профиль",
"profileCount_other": "{{count}} профилей",
"groupsLabel": "Группы",
"profilesCol": "Профили",
"syncCannotDisable": "Нельзя отключить синхронизацию, пока эта группа используется синхронизированными профилями",
"editGroupTooltip": "Редактировать группу",
"deleteGroupTooltip": "Удалить группу",
"loadFailed": "Не удалось загрузить группы"
},
"proxyAssignment": {
"title": "Назначить прокси / VPN",
"description_one": "Назначить прокси или VPN для {{count}} выбранного профиля.",
"description_other": "Назначить прокси или VPN для {{count}} выбранных профилей.",
"selectLabel": "Прокси / VPN",
"placeholder": "Выберите прокси или VPN",
"noProxy": "Без прокси / VPN",
"searchPlaceholder": "Поиск прокси или VPN...",
"notFound": "Прокси или VPN не найдены.",
"assignButton": "Назначить",
"success": "Прокси/VPN назначен {{count}} профилю(ям)",
"failed": "Не удалось назначить прокси/VPN",
"selectedProfilesLabel": "Выбранные профили:",
"assignProxyVpnLabel": "Назначить прокси / VPN:",
"noneOption": "Нет",
"noValidProfiles": "Нет выбранных допустимых профилей.",
"vpnGroupHeading": "VPN",
"failedFallback": "Не удалось назначить прокси/VPN профилям"
},
"groupAssignment": {
"title": "Назначить группу",
"description_one": "Назначить группу для {{count}} выбранного профиля.",
"description_other": "Назначить группу для {{count}} выбранных профилей.",
"selectLabel": "Группа",
"placeholder": "Выберите группу",
"noGroup": "Без группы (по умолчанию)",
"assignButton": "Назначить",
"success": "Группа назначена {{count}} профилю(ям)",
"failed": "Не удалось назначить группу",
"selectedProfilesLabel": "Выбранные профили:",
"assignGroupLabel": "Назначить группу:",
"noValidProfiles": "Нет выбранных допустимых профилей.",
"failedFallback": "Не удалось назначить группу профилям"
},
"profileSelector": {
"title": "Выбрать профиль",
"description": "Выберите профиль для запуска с этим URL",
"searchPlaceholder": "Поиск профилей...",
"noProfiles": "Нет доступных профилей",
"noResults": "Профили не найдены",
"selectButton": "Выбрать",
"launching": "Запуск...",
"chooseProfileTitle": "Выбрать профиль",
"openingUrl": "Открытие URL:",
"urlCopied": "URL скопирован в буфер обмена!",
"selectProfileLabel": "Выберите профиль:",
"noneAvailableShort": "Профили недоступны. Сначала создайте профиль.",
"noneAvailableLong": "Закройте этот диалог и создайте профиль из главного окна, чтобы начать.",
"chooseAProfile": "Выберите профиль",
"badgeProxy": "Прокси",
"badgeRunning": "Запущен",
"badgeUnavailable": "Недоступен",
"openButton": "Открыть"
},
"locationProxy": {
"title": "Быстрый прокси по местоположению",
"description": "Выберите страну для маршрутизации этого профиля. Прокси будет создан автоматически.",
"country": "Страна",
"selectCountry": "Выберите страну",
"searchCountry": "Поиск страны...",
"noCountriesFound": "Страны не найдены.",
"apply": "Применить",
"creating": "Создание прокси...",
"success": "Прокси местоположения применен",
"failed": "Не удалось применить прокси местоположения",
"titleCreate": "Создать прокси по местоположению",
"descriptionCreate": "Создайте гео-прокси с 24-часовой сессией",
"countryLabel": "Страна (обязательно)",
"regionLabel": "Регион (необязательно)",
"cityLabel": "Город (необязательно)",
"ispLabel": "Провайдер (необязательно)",
"nameLabel": "Имя",
"namePlaceholder": "Имя прокси",
"loadingCountries": "Загрузка стран...",
"selectCountryPh": "Выберите страну",
"searchCountries": "Поиск стран...",
"loadFailed": "Не удалось загрузить страны",
"selectCountryFirst": "Сначала выберите страну",
"loadingRegions": "Загрузка регионов...",
"noRegions": "Нет доступных регионов",
"selectRegion": "Выберите регион",
"searchRegions": "Поиск регионов...",
"loadingCities": "Загрузка городов...",
"noCities": "Нет доступных городов",
"selectCity": "Выберите город",
"searchCities": "Поиск городов...",
"loadingIsps": "Загрузка провайдеров...",
"noIsps": "Нет доступных провайдеров",
"selectIsp": "Выберите провайдера",
"searchIsps": "Поиск провайдеров...",
"createSuccess": "Прокси местоположения создан",
"createFailed": "Не удалось создать прокси местоположения",
"creatingButton": "Создание...",
"createButton": "Создать"
},
"launchOnLogin": {
"title": "Запускать при входе?",
"description": "Работа в фоновом режиме помогает поддерживать прокси и браузеры активными.",
"declineButton": "Больше не спрашивать",
"declining": "...",
"enableButton": "Включить",
"enableSuccess": "Запуск при входе включен",
"enableFailed": "Не удалось включить запуск при входе",
"declineFailed": "Не удалось сохранить настройку",
"tryAgain": "Пожалуйста, попробуйте снова"
},
"wayfernTerms": {
"title": "Условия использования Wayfern",
"description": "Прежде чем использовать Donut Browser, необходимо прочитать и согласиться с Условиями использования Wayfern.",
"reviewLabel": "Пожалуйста, ознакомьтесь с Условиями использования по адресу:",
"agreeNotice": "Нажимая «Я принимаю», вы соглашаетесь с этими условиями.",
"acceptButton": "Я принимаю",
"acceptSuccess": "Условия успешно приняты",
"acceptFailed": "Не удалось принять условия",
"tryAgain": "Пожалуйста, попробуйте снова"
},
"commercialTrial": {
"title": "Срок коммерческой пробной версии истёк",
"description": "Ваш 2-недельный коммерческий пробный период закончился.",
"body": "Если вы используете Donut Browser в коммерческих целях, для продолжения вам нужно приобрести коммерческую лицензию. Вы можете продолжать использовать его бесплатно для личных целей.",
"understandButton": "Понятно",
"failed": "Не удалось сохранить подтверждение",
"tryAgain": "Пожалуйста, попробуйте снова"
},
"permissionDialog": {
"titleMicrophone": "Требуется доступ к микрофону",
"titleCamera": "Требуется доступ к камере",
"descMicrophone": "Donut Browser нужен доступ к вашему микрофону, чтобы включить функциональность микрофона в браузерах. Каждый сайт, который захочет использовать ваш микрофон, всё равно запросит разрешение отдельно.",
"descCamera": "Donut Browser нужен доступ к вашей камере, чтобы включить функциональность камеры в браузерах. Каждый сайт, который захочет использовать вашу камеру, всё равно запросит разрешение отдельно.",
"grantedMicrophone": "Разрешение предоставлено! Браузеры, запущенные через Donut Browser, теперь могут получить доступ к вашему микрофону.",
"grantedCamera": "Разрешение предоставлено! Браузеры, запущенные через Donut Browser, теперь могут получить доступ к вашей камере.",
"notGrantedMicrophone": "Разрешение не предоставлено. Нажмите кнопку ниже, чтобы запросить доступ к микрофону.",
"notGrantedCamera": "Разрешение не предоставлено. Нажмите кнопку ниже, чтобы запросить доступ к камере.",
"doneButton": "Готово",
"cancelButton": "Отмена",
"grantAccessButton": "Предоставить доступ",
"requestSuccessMicrophone": "Запрошен доступ к микрофону",
"requestSuccessCamera": "Запрошен доступ к камере",
"requestFailed": "Не удалось запросить разрешение"
},
"traffic": {
"title": "Подробности трафика",
"bandwidthOverTime": "Пропускная способность во времени",
"timePeriodPlaceholder": "Период",
"last1m": "Последняя 1 мин",
"last5m": "Последние 5 мин",
"last30m": "Последние 30 мин",
"last1h": "Последний час",
"last2h": "Последние 2 часа",
"last4h": "Последние 4 часа",
"last1d": "Последний день",
"last7d": "Последние 7 дней",
"last30d": "Последние 30 дней",
"allTime": "Всё время",
"allTimeShort": "всё время",
"totalSuffix": "всего",
"sentLabel": "Отправлено ({{period}})",
"receivedLabel": "Получено ({{period}})",
"requestsLabel": "Запросы ({{period}})",
"allTimeTraffic": "Трафик за всё время:",
"allTimeRequests": "Запросов за всё время:",
"proxyDisclaimer": "Примечание: если вы используете прокси, VPN или подобный сервис, ваш провайдер может рассчитывать трафик иначе из-за накладных расходов на шифрование и протокольных различий.",
"topByTraffic": "Главные домены по трафику ({{period}})",
"topByRequests": "Главные домены по запросам ({{period}})",
"columnDomain": "Домен",
"columnRequests": "Запросы",
"columnSent": "Отправлено",
"columnReceived": "Получено",
"columnTotal": "Всего трафика",
"uniqueIps": "Уникальные IP ({{count}})",
"noData": "Нет данных о трафике для этого профиля.",
"noDataHint": "Данные о трафике появятся после запуска профиля.",
"sentLegend": "Отправлено",
"receivedLegend": "Получено",
"tooltipSent": "↑ Отправлено: ",
"tooltipReceived": "↓ Получено: "
},
"camoufoxDialog": {
"titleView": "Просмотр настроек отпечатка - {{name}} ({{browser}})",
"titleConfigure": "Настроить отпечаток - {{name}} ({{browser}})",
"invalidFingerprint": "Неверная конфигурация отпечатка",
"invalidFingerprintDescription": "Конфигурация отпечатка содержит недопустимый JSON. Проверьте расширенные настройки.",
"saveFailed": "Не удалось сохранить конфигурацию",
"unknownError": "Произошла неизвестная ошибка"
},
"proxyCheck": {
"unknownLocation": "Неизвестно",
"locationToast": "Местоположение вашего прокси:",
"failed": "Не удалось проверить прокси: {{error}}",
"tooltipChecking": "Проверка прокси...",
"tooltipIp": "IP: {{ip}}",
"tooltipChecked": "Проверено {{time}}",
"tooltipFailed": "Ошибка {{time}}",
"tooltipFailedTitle": "Не удалось проверить прокси",
"tooltipDefault": "Проверить прокси"
},
"vpnCheck": {
"valid": "Конфигурация VPN «{{name}}» действительна",
"invalid": "Конфигурация VPN «{{name}}» недействительна",
"failed": "Не удалось проверить VPN: {{error}}",
"tooltipChecking": "Проверка конфигурации VPN...",
"tooltipValid": "Конфигурация валидна",
"tooltipInvalid": "Конфигурация невалидна",
"tooltipChecked": "Проверено {{time}}",
"tooltipDefault": "Проверить конфигурацию VPN"
},
"profileTable": {
"syncTooltipDisabled": "Синхронизация отключена",
"syncTooltipSyncing": "Синхронизация...",
"syncTooltipSyncedAt": "Синхронизировано {{time}}",
"syncTooltipSynced": "Синхронизировано",
"syncTooltipWaiting": "Ожидание синхронизации",
"syncTooltipErrorWith": "Ошибка синхронизации: {{error}}",
"syncTooltipError": "Ошибка синхронизации",
"syncTooltipNotSynced": "Не синхронизировано",
"noTags": "Нет тегов",
"syncTooltipCloseToSync": "Закройте профиль для синхронизации",
"syncTooltipDisabledWithLast": "Синхронизация отключена, последняя синхронизация {{time}}",
"addTagsPlaceholder": "Добавить теги",
"tagsHeader": "Теги",
"noteHeader": "Заметка",
"vpnsHeading": "VPN",
"createByCountryHeading": "Создать по стране"
},
"releaseTypeSelector": {
"noReleaseTypes": "Нет доступных типов выпусков.",
"placeholder": "Выберите тип выпуска...",
"stable": "Стабильный",
"nightly": "Nightly",
"downloaded": "Загружено",
"downloadBrowser": "Загрузить браузер",
"downloading": "Загрузка..."
},
"dataTableActionBar": {
"selected": "Выбрано: {{count}}",
"clearSelection": "Очистить выбор"
},
"appUpdate": {
"toast": {
"updateFailed": "Не удалось обновить Donut Browser",
"restartFailed": "Не удалось перезапустить",
"updateReady": "Обновление готово, перезапустите для применения",
"manualDownloadRequired": "Требуется ручная загрузка",
"restartNow": "Перезапустить сейчас",
"viewRelease": "Посмотреть релиз",
"later": "Позже",
"uploading": "Загрузка",
"downloading": "Скачивание"
}
},
"browserDownload": {
"toast": {
"fetchVersionsFailed": "Не удалось получить версии {{browser}}",
"foundNewVersions": "Найдено {{count}} новых версий {{browser}}!",
"totalAvailableVersions": "Всего доступно: {{count}} версий",
"downloadFailed": "Не удалось загрузить {{browser}} {{version}}",
"calculating": "вычисление...",
"extractionFailed": "{{browser}} {{version}}: ошибка распаковки",
"extractionFailedDescription": "Повреждённый файл удалён. Он будет повторно загружен при следующей попытке.",
"extracting": "Распаковка файлов браузера... Не закрывайте приложение.",
"verifying": "Проверка файлов браузера...",
"downloadingRolling": "Загрузка rolling release сборки..."
}
},
"versionUpdater": {
"toast": {
"alreadyAvailable": "{{browser}} {{version}} уже доступна",
"updatingProfiles": "Обновление настроек профилей...",
"updateCompleted": "Обновление {{browser}} завершено",
"singleProfileUpdated": "Профиль «{{name}}» обновлён до версии {{version}}. Теперь можно запускать браузеры с последней версией.",
"multipleProfilesUpdated": "{{count}} профилей обновлены до версии {{version}}. Теперь можно запускать браузеры с последней версией.",
"versionAvailable": "Версия {{version}} теперь доступна. Запущенные профили начнут использовать новую версию после перезапуска.",
"autoUpdateFailed": "Не удалось автоматически обновить {{browser}}",
"updateWithErrors": "Обновление завершено с ошибками",
"updateWithErrorsDescription": "Найдено {{newVersions}} новых версий, {{failedUpdates}} браузеров не удалось обновить",
"updateSuccess": "Версии браузеров успешно обновлены",
"updateSuccessDescription": "Найдено {{newVersions}} новых версий для {{successfulUpdates}} браузеров. Автоматическая загрузка начнётся в ближайшее время.",
"upToDate": "Новых версий браузеров не найдено",
"upToDateDescription": "Все версии браузеров актуальны",
"updateAllFailed": "Не удалось обновить версии браузеров"
}
}
}
+726 -36
View File
@@ -28,7 +28,9 @@
"refresh": "刷新",
"loading": "加载中...",
"saveSettings": "保存设置",
"moreInfo": "了解更多"
"moreInfo": "了解更多",
"downloading": "下载中...",
"minimize": "最小化"
},
"status": {
"active": "活跃",
@@ -56,7 +58,10 @@
"default": "默认",
"custom": "自定义",
"optional": "可选",
"required": "必填"
"required": "必填",
"unknownProfile": "未知",
"mode": "模式",
"never": "从不"
},
"time": {
"days": "天",
@@ -64,6 +69,33 @@
"minutes": "分钟",
"seconds": "秒",
"remaining": "剩余"
},
"aria": {
"selectAll": "全选",
"selectRow": "选择行",
"selectProfile": "选择配置文件",
"copy": "复制到剪贴板",
"copied": "已复制",
"showToken": "显示令牌",
"hideToken": "隐藏令牌"
},
"keys": {
"escape": "Esc"
},
"errors": {
"unknown": "发生未知错误"
},
"window": {
"minimize": "最小化"
},
"commandPalette": {
"title": "命令面板",
"description": "搜索要执行的命令..."
},
"noResults": "未找到结果。",
"srOnly": {
"copy": "复制",
"copied": "已复制"
}
},
"settings": {
@@ -85,7 +117,8 @@
"title": "语言",
"description": "选择应用程序界面的首选语言。",
"systemDefault": "系统默认",
"selectLanguage": "选择语言"
"selectLanguage": "选择语言",
"interface": "界面语言"
},
"defaultBrowser": {
"title": "默认浏览器",
@@ -100,7 +133,8 @@
"microphone": "麦克风",
"microphoneDescription": "浏览器应用程序的麦克风访问权限",
"camera": "摄像头",
"cameraDescription": "浏览器应用程序的摄像头访问权限"
"cameraDescription": "浏览器应用程序的摄像头访问权限",
"accessRequested": "已请求 {{permission}} 访问权限"
},
"integrations": {
"title": "集成",
@@ -134,7 +168,8 @@
"advanced": {
"title": "高级",
"clearCache": "清除所有版本缓存",
"clearCacheDescription": "清除所有缓存的浏览器版本数据并从源刷新所有浏览器版本。这将强制重新下载所有浏览器的版本信息。"
"clearCacheDescription": "清除所有缓存的浏览器版本数据并从源刷新所有浏览器版本。这将强制重新下载所有浏览器的版本信息。",
"clearCacheFailed": "清除缓存失败"
},
"disableAutoUpdates": "禁用应用自动更新",
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。"
@@ -169,7 +204,9 @@
"note": "备注",
"group": "分组",
"proxy": "代理 / VPN",
"lastLaunch": "最后启动"
"lastLaunch": "最后启动",
"empty": "未找到配置文件。",
"notSelected": "未选择"
},
"actions": {
"launch": "启动",
@@ -205,7 +242,30 @@
"ephemeral": "临时",
"ephemeralDescription": "浏览器被强制将配置数据写入内存而非磁盘。关闭浏览器时数据将被删除。",
"ephemeralBadge": "临时",
"ephemeralAlpha": "Alpha"
"ephemeralAlpha": "Alpha",
"bulkDelete": {
"title": "删除所选配置文件",
"description": "此操作无法撤销。这将永久删除 {{count}} 个配置文件及其关联的所有数据。",
"confirmButton": "删除 {{count}} 个配置文件"
},
"note": {
"empty": "无备注",
"placeholder": "添加备注..."
},
"aria": {
"profileInfo": "配置文件信息"
},
"delete": {
"title": "删除配置文件",
"description": "此操作无法撤销。这将永久删除配置文件 \"{{profileName}}\" 及其关联的所有数据。",
"confirmButton": "删除配置文件"
},
"actionBar": {
"assignToGroup": "分配到分组",
"assignProxy": "分配代理",
"assignExtensionGroup": "分配扩展分组",
"copyCookies": "复制 Cookie"
}
},
"createProfile": {
"title": "创建新配置文件",
@@ -228,7 +288,10 @@
"title": "代理 / VPN",
"addProxy": "添加代理",
"noProxy": "无代理 / VPN",
"noProxiesAvailable": "没有可用的代理或VPN。添加一个来路由此配置文件的流量。"
"noProxiesAvailable": "没有可用的代理或VPN。添加一个来路由此配置文件的流量。",
"search": "搜索代理或 VPN...",
"notFound": "未找到代理或 VPN。",
"searchWithCountries": "搜索代理、VPN 或国家/地区..."
},
"launchHook": {
"label": "启动钩子 URL",
@@ -248,7 +311,8 @@
"chromiumSubtitle": "由 Wayfern 驱动",
"firefoxLabel": "Firefox",
"firefoxSubtitle": "由 Camoufox 驱动",
"camoufoxWarning": "FirefoxCamoufox)由第三方组织维护。在生产环境中,请使用 Chromium。"
"camoufoxWarning": "FirefoxCamoufox)由第三方组织维护。在生产环境中,请使用 Chromium。",
"platformUnavailable": "{{browser}} 在您的平台上尚不可用。"
},
"deleteDialog": {
"title": "删除配置文件",
@@ -259,7 +323,31 @@
},
"proxies": {
"title": "代理",
"management": "代理和VPN",
"management": {
"description": "管理代理和 VPN 配置以便在配置文件中重复使用",
"tabProxies": "代理",
"tabVpns": "VPN",
"create": "创建",
"loading": "正在加载代理...",
"noneCreated": "尚未创建代理。使用上方按钮创建第一个代理。",
"usage": "使用情况",
"syncCol": "同步",
"syncCannotDisable": "此代理被同步的配置文件使用时无法禁用同步",
"enableSync": "启用同步",
"disableSync": "禁用同步",
"editProxy": "编辑代理",
"deleteProxy": "删除代理",
"cannotDelete_one": "无法删除: 被 {{count}} 个配置文件使用",
"cannotDelete_other": "无法删除: 被 {{count}} 个配置文件使用",
"syncEnabled": "已启用同步",
"syncDisabled": "已禁用同步",
"updateSyncFailed": "更新同步失败",
"deleteSuccess": "代理删除成功",
"deleteFailed": "删除代理失败",
"deleteTitle": "删除代理",
"deleteDescription": "此操作无法撤消。代理「{{name}}」将被永久删除。",
"title": "代理和 VPN"
},
"add": "添加代理",
"edit": "编辑代理",
"delete": "删除代理",
@@ -280,7 +368,12 @@
"password": "密码",
"passwordPlaceholder": "可选",
"cipher": "加密方式",
"cipherPlaceholder": "aes-256-gcm"
"cipherPlaceholder": "aes-256-gcm",
"nameRequired": "代理名称为必填项",
"hostPortRequired": "主机和端口为必填项",
"ssCipherRequired": "Shadowsocks 需要密码学和密码",
"selectType": "选择代理类型",
"saveFailed": "保存代理失败: {{error}}"
},
"types": {
"http": "HTTP",
@@ -318,6 +411,45 @@
"sync": {
"enabled": "同步已启用",
"disabled": "同步已禁用"
},
"exportDialog": {
"title": "导出代理",
"description": "将代理配置导出到文件",
"format": "导出格式",
"json": "JSON",
"txt": "TXT (URL 格式)",
"preview": "预览",
"noProxies": "没有可导出的代理",
"downloaded": "已下载 {{filename}}",
"failed": "导出代理失败",
"copied": "已复制"
},
"importDialog": {
"title": "导入代理",
"descDropzone": "从 JSON 或 TXT 文件导入代理",
"descPreview": "查看要导入的代理",
"descAmbiguous": "某些代理具有不明确的格式。请选择正确的格式。",
"descResult": "导入完成",
"dropzonePrompt": "拖放代理配置文件",
"dropzoneFormats": "(.json, .txt)",
"pasteHint": "使用 {{modKey}}+V 从剪贴板粘贴",
"wrongFileType": "请拖放 .json 或 .txt 文件",
"fileReadError": "读取文件失败",
"fileProcessError": "处理文件失败",
"noValidProxies": "在文件中未找到有效代理",
"namePrefix": "名称前缀",
"namePrefixDefault": "Imported",
"namePrefixHint": "代理将被命名为「{{prefix}} Proxy 1」、「{{prefix}} Proxy 2」等。",
"proxiesToImport": "要导入的代理 ({{count}})",
"invalidCount": "({{count}} 个无效)",
"ambiguousIntro": "以下代理格式不明确。请为每个选择正确的解释。",
"imported": "已导入:",
"skippedDuplicates": "已跳过 (重复):",
"errors": "错误",
"importButton": "导入 {{count}} 个代理",
"continueButton": "继续",
"doneButton": "完成",
"failed": "导入代理失败"
}
},
"groups": {
@@ -343,7 +475,31 @@
"sync": {
"enabled": "同步已启用",
"disabled": "同步已禁用"
}
},
"createTitle": "创建新组",
"createDescription": "创建新组以组织您的浏览器配置文件。",
"editTitle": "编辑组",
"editDescription": "更新组的名称。",
"createSuccess": "组创建成功",
"createFailed": "创建组失败",
"updateSuccess": "组更新成功",
"updateFailed": "更新组失败",
"deleteTitle": "删除组",
"deleteDescription": "此操作无法撤销。这将永久删除该组。",
"deleteSuccess": "组删除成功",
"deleteFailed": "删除组失败",
"loadingProfiles": "正在加载关联的配置文件...",
"associatedProfiles": "关联的配置文件 ({{count}})",
"whatToDoWithProfiles": "这些配置文件应该怎么办?",
"moveToDefaultOption": "将配置文件移至默认组",
"deleteAlongWithGroup": "将配置文件与组一起删除",
"noAssociatedProfiles": "此组没有关联的配置文件。",
"deleteGroup": "删除组",
"deleteGroupAndProfiles": "删除组和配置文件",
"loadProfilesFailed": "加载配置文件失败",
"unknownGroup": "未知分组",
"profileGroupsAriaLabel": "配置文件分组",
"loading": "正在加载组..."
},
"sync": {
"mode": {
@@ -366,7 +522,16 @@
"configureService": "配置同步服务"
},
"title": "同步服务",
"config": "同步配置",
"config": {
"serverUrlRequired": "请输入服务器 URL",
"connectionSuccess": "连接成功!",
"serverError": "服务器返回了错误",
"connectFailed": "连接服务器失败",
"settingsSaved": "同步设置已保存",
"saveFailed": "保存设置失败",
"disconnected": "已断开同步",
"disconnectFailed": "断开连接失败"
},
"serverUrl": "服务器 URL",
"serverUrlPlaceholder": "https://sync.example.com",
"token": "同步令牌",
@@ -410,6 +575,12 @@
"profileLockedShort": "使用中",
"cannotLaunchLocked": "无法启动 — 配置文件正被 {{email}} 使用",
"createdBy": "由 {{email}} 创建"
},
"disabled": "已禁用",
"toast": {
"profileSynced": "配置文件 '{{name}}' 同步成功",
"profileSyncFailed": "同步配置文件 '{{name}}' 失败",
"profileSyncFailedWithError": "同步配置文件 '{{name}}' 失败: {{error}}"
}
},
"integrations": {
@@ -447,7 +618,32 @@
"removedFromClaudeCode": "已从 Claude Code 移除",
"config": "MCP 配置",
"copyConfig": "复制配置"
}
},
"tabApi": "本地 API",
"tabMcp": "MCP (AI 助手)",
"apiEnableLabel": "启用本地 API 服务器",
"apiEnableDescription": "允许通过 REST API 管理配置文件、分组和代理。",
"apiPortLabel": "端口",
"apiTokenLabel": "认证令牌",
"apiTokenHint": "在 Authorization 头中包含: Bearer {{tokenSlot}}",
"apiInvalidPort": "无效的端口",
"apiInvalidPortDescription": "端口必须在 1 到 65535 之间",
"apiPortInUse": "端口 {{port}} 已被占用",
"apiFallbackPort": "服务器在备用端口 {{port}} 上启动",
"apiStarted": "API 服务器已在端口 {{port}} 上启动",
"apiRunning": "API 服务器在端口 {{port}} 上运行",
"apiStopped": "API 服务器已停止",
"apiToggleFailed": "切换 API 服务器失败",
"apiStartFailed": "启动 API 服务器失败",
"apiUnknownError": "未知错误",
"tokenCopied": "令牌已复制",
"mcpEnableLabel": "启用 MCP 服务器 (Model Context Protocol)",
"mcpEnableDescription": "允许 Claude Desktop 等 AI 助手控制浏览器。",
"mcpAcceptTermsFirst": "(请先在设置中接受 Wayfern 条款)",
"mcpStarted": "MCP 服务器已在端口 {{port}} 上启动",
"mcpStopped": "MCP 服务器已停止",
"mcpToggleFailed": "切换 MCP 服务器失败",
"openSettings": "打开集成设置"
},
"import": {
"title": "导入配置文件",
@@ -465,7 +661,9 @@
"fingerprint": {
"title": "指纹",
"randomize": "启动时随机化",
"randomizeDescription": "每次启动浏览器时生成新的指纹。"
"randomizeDescription": "每次启动浏览器时生成新的指纹。",
"osCpuPlaceholder": "例如: Intel Mac OS X 10.15",
"webglRendererPlaceholder": "例如: llvmpipe 或类似"
},
"os": {
"title": "操作系统",
@@ -499,7 +697,10 @@
"fingerprint": {
"title": "指纹",
"randomize": "启动时随机化",
"randomizeDescription": "每次启动浏览器时生成新的指纹。"
"randomizeDescription": "每次启动浏览器时生成新的指纹。",
"platformPlaceholder": "例如: Win32、MacIntel、Linux x86_64",
"timezoneOffsetPlaceholder": "例如: EST (UTC-5) 为 300",
"webglRendererPlaceholder": "例如: Intel(R) HD Graphics"
},
"os": {
"title": "操作系统",
@@ -522,6 +723,10 @@
"webrtc": "阻止 WebRTC",
"webgl": "阻止 WebGL"
}
},
"shared": {
"browserBehavior": "浏览器行为",
"allowAddonsOpenTabs": "允许浏览器附加组件自动打开新标签页"
}
},
"cookies": {
@@ -534,13 +739,53 @@
"selectCookies": "选择 Cookies",
"allDomains": "所有域名",
"selectedCount": "已选择 {{count}} 个 cookie",
"selectedCount_plural": "已选择 {{count}} 个 cookies"
"selectedCount_plural": "已选择 {{count}} 个 cookies",
"dialogDescription_one": "从源配置文件复制 Cookie 到 {{count}} 个选定的配置文件。",
"dialogDescription_other": "从源配置文件复制 Cookie 到 {{count}} 个选定的配置文件。",
"sourceProfile": "源配置文件",
"sourcePlaceholder": "选择要复制 Cookie 的配置文件",
"running": "(运行中)",
"targetProfiles": "目标配置文件 ({{count}})",
"noOtherTargets": "未选择其他 Wayfern/Camoufox 配置文件",
"selectSourceFirst": "请先选择源配置文件",
"selectionStatus": "(已选择 {{selected}} / {{total}})",
"searchPlaceholder": "搜索域名或 Cookie...",
"noMatching": "未找到匹配的 Cookie",
"noFound": "未找到 Cookie",
"replaceNote": "同名同域名的现有 Cookie 将被替换,其他 Cookie 将保留。",
"cannotCopyRunningOne": "无法复制 Cookie: {{names}} 仍在运行",
"cannotCopyRunningMany": "无法复制 Cookie: {{names}} 仍在运行",
"someErrors": "发生错误: {{errors}}",
"successMessage": "已成功复制 {{copied}} 个 Cookie (替换 {{replaced}} 个)",
"failedMessage": "复制 Cookie 失败: {{error}}",
"copyButton_one": "复制 {{count}} 个 Cookie",
"copyButton_other": "复制 {{count}} 个 Cookie",
"copyButtonEmpty": "复制 Cookies"
},
"success": "Cookies 复制成功",
"error": "复制 cookies 失败",
"management": {
"title": "Cookie 管理",
"menuItem": "Cookie 管理"
"menuItem": "Cookie 管理",
"tabImport": "导入",
"tabExport": "导出",
"importDescription": "从 Netscape 或 JSON 格式的文件导入 Cookies。",
"dropPrompt": "点击选择 Cookie 文件",
"fileFormats": "(.txt、.cookies 或 .json)",
"cookiesFound": "找到 {{count}} 个 Cookie",
"importedSuccess": "已成功导入 {{imported}} 个 Cookie (替换 {{replaced}} 个)",
"linesSkipped": "已跳过 {{count}} 行",
"fileReadError": "读取文件失败",
"loadFailed": "加载 Cookie 失败: {{error}}",
"cookiesLabel": "Cookies",
"selectionStatus": "(已选择 {{selected}} / {{total}})",
"selectAll": "全选",
"deselectAll": "取消全选",
"noCookies": "此配置文件中未找到 Cookie",
"doneButton": "完成",
"importButton": "导入",
"exportButton": "导出",
"backButton": "返回"
},
"import": {
"title": "导入 Cookies",
@@ -623,7 +868,31 @@
"maxLength": "最多 {{max}} 个字符",
"networkError": "网络错误。请检查您的连接。",
"serverError": "服务器错误。请稍后重试。",
"unknownError": "发生未知错误。请重试。"
"unknownError": "发生未知错误。请重试。",
"noProfilesForUrl": "没有可用的配置文件。请先创建配置文件再打开 URL。",
"updateCamoufoxConfigFailed": "更新 camoufox 配置失败: {{error}}",
"updateWayfernConfigFailed": "更新 wayfern 配置失败: {{error}}",
"createProfileFailed": "创建配置文件失败: {{error}}",
"launchBrowserFailed": "启动浏览器失败: {{error}}",
"cannotDeleteRunningProfile": "浏览器运行时无法删除配置文件。请先停止浏览器。",
"deleteProfileFailed": "删除配置文件失败: {{error}}",
"renameProfileFailed": "重命名配置文件失败: {{error}}",
"killBrowserFailed": "终止浏览器失败: {{error}}",
"deleteSelectedProfilesFailed": "删除所选配置文件失败: {{error}}",
"cookieCopyUnsupportedBrowser": "Cookie 复制仅适用于 Wayfern 和 Camoufox 配置文件",
"updateSyncSettingsFailed": "更新同步设置失败",
"cloneProfileFailed": "克隆配置文件失败: {{error}}",
"loadSupportedBrowsersFailed": "加载受支持的浏览器失败",
"setupExtensionListenersFailed": "设置扩展事件监听器失败: {{error}}",
"loadGroupsFailed": "加载分组失败: {{error}}",
"setupGroupListenersFailed": "设置分组事件监听器失败: {{error}}",
"loadProfilesFailed": "加载配置文件失败: {{error}}",
"setupProfileListenersFailed": "设置配置文件事件监听器失败: {{error}}",
"loadProxiesFailed": "加载代理失败: {{error}}",
"setupProxyListenersFailed": "设置代理事件监听器失败: {{error}}",
"loadVpnConfigsFailed": "加载 VPN 配置失败: {{error}}",
"setupVpnListenersFailed": "设置 VPN 事件监听器失败: {{error}}",
"themeNotFound": "未找到 Tokyo Night 主题"
},
"browser": {
"camoufox": "Camoufox",
@@ -649,15 +918,15 @@
"blockWebRTC": "阻止 WebRTC",
"blockWebGL": "阻止 WebGL",
"navigatorProperties": "Navigator 属性",
"userAgent": "User Agent",
"userAgent": "用户代理",
"userAgentAndPlatform": "User Agent 和平台",
"platform": "平台",
"platformVersion": "平台版本",
"appVersion": "应用版本",
"osCpu": "OS CPU",
"osCpu": "操作系统 CPU",
"hardwareConcurrency": "硬件并发数",
"maxTouchPoints": "最大触摸点数",
"doNotTrack": "Do Not Track",
"doNotTrack": "请勿跟踪",
"selectDntPlaceholder": "选择 DNT 值",
"dntAllowed": "0(允许跟踪)",
"dntNotAllowed": "1(不允许跟踪)",
@@ -679,8 +948,8 @@
"outerHeight": "外部高度",
"innerWidth": "内部宽度",
"innerHeight": "内部高度",
"screenX": "Screen X",
"screenY": "Screen Y",
"screenX": "屏幕 X",
"screenY": "屏幕 Y",
"geolocation": "地理位置",
"timezoneAndGeolocation": "时区和地理位置",
"timezoneGeolocationDescription": "这些值会覆盖浏览器的时区和地理位置 API。",
@@ -694,15 +963,15 @@
"region": "地区",
"script": "脚本",
"webglProperties": "WebGL 属性",
"webglVendor": "WebGL Vendor",
"webglRenderer": "WebGL Renderer",
"webglVendor": "WebGL 供应商",
"webglRenderer": "WebGL 渲染器",
"webglParameters": "WebGL 参数",
"webglParametersJson": "WebGL 参数 (JSON)",
"webgl2Parameters": "WebGL2 参数",
"webglShaderPrecisionFormats": "WebGL Shader Precision Formats",
"webgl2ShaderPrecisionFormats": "WebGL2 Shader Precision Formats",
"webglShaderPrecisionFormats": "WebGL 着色器精度格式",
"webgl2ShaderPrecisionFormats": "WebGL2 着色器精度格式",
"canvasFingerprint": "Canvas 指纹",
"canvasNoiseSeed": "Canvas Noise Seed",
"canvasNoiseSeed": "Canvas 噪声种子",
"canvasNoiseSeedDescription": "此种子用于生成一致但唯一的 Canvas 指纹。每个配置文件应使用不同的种子。",
"fonts": "字体",
"fontsJson": "字体 (JSON 数组)",
@@ -723,13 +992,16 @@
"maxChannelCount": "最大通道数",
"vendorInfo": "供应商信息",
"vendor": "供应商",
"vendorSub": "Vendor Sub",
"productSub": "Product Sub",
"vendorSub": "供应商子版本",
"productSub": "产品子版本",
"brand": "品牌",
"brandVersion": "品牌版本",
"proFeature": "这是 Pro 功能",
"generateFingerprint": "生成指纹",
"refreshFingerprint": "刷新指纹"
"refreshFingerprint": "刷新指纹",
"canvasNoiseSeedPlaceholder": "输入用于 canvas 指纹的种子字符串",
"addFontsPlaceholder": "添加字体...",
"enterAsJson": "以 JSON 格式输入 {{title}}"
},
"warnings": {
"windowResizeTitle": "自定义窗口尺寸",
@@ -869,7 +1141,9 @@
"syncEnabled": "同步已启用",
"syncDisabled": "同步已禁用",
"syncEnableTooltip": "启用同步",
"syncDisableTooltip": "禁用同步"
"syncDisableTooltip": "禁用同步",
"loadGroupsFailed": "加载扩展组失败",
"assignGroupFailed": "分配扩展组失败"
},
"pro": {
"badge": "PRO",
@@ -882,11 +1156,11 @@
"dnsBlocklist": {
"title": "DNS 拦截列表",
"none": "无",
"light": "Light",
"normal": "Normal",
"light": "轻量",
"normal": "标准",
"pro": "Pro",
"proPlus": "Pro++",
"ultimate": "Ultimate",
"ultimate": "终极",
"settingsDescription": "DNS 拦截列表在代理级别拦截广告、跟踪器和恶意软件域名。列表每 12 小时自动刷新一次。",
"manageLists": "管理 DNS 拦截列表",
"refreshAll": "刷新所有列表",
@@ -895,5 +1169,421 @@
"fresh": "最新",
"stale": "过期",
"notCached": "未缓存"
},
"vpns": {
"form": {
"titleEdit": "编辑 VPN",
"titleCreate": "创建 WireGuard VPN",
"descEdit": "更新 VPN 配置名称。",
"descCreate": "输入 WireGuard 接口和对等节点的详细信息。",
"name": "名称",
"namePlaceholder": "例如:家庭 WireGuard",
"privateKey": "私钥",
"privateKeyPlaceholder": "Base64 编码的私钥",
"address": "地址",
"addressPlaceholder": "例如:10.0.0.2/24",
"dnsOptional": "DNS(可选)",
"dnsPlaceholder": "例如:1.1.1.1",
"mtuOptional": "MTU(可选)",
"mtuPlaceholder": "例如:1420",
"peerPublicKey": "对等节点公钥",
"peerPublicKeyPlaceholder": "Base64 编码的对等节点公钥",
"peerEndpoint": "对等节点端点",
"peerEndpointPlaceholder": "例如:vpn.example.com:51820",
"allowedIps": "允许的 IP",
"allowedIpsPlaceholder": "例如:0.0.0.0/0, ::/0",
"keepaliveOptional": "持久心跳(可选)",
"keepalivePlaceholder": "例如:25",
"presharedKeyOptional": "预共享密钥(可选)",
"presharedKeyPlaceholder": "Base64 编码的预共享密钥",
"updateButton": "更新 VPN",
"createButton": "创建 VPN",
"nameRequired": "VPN 名称必填",
"privateKeyRequired": "私钥必填",
"addressRequired": "地址必填",
"peerPublicKeyRequired": "对等节点公钥必填",
"peerEndpointRequired": "对等节点端点必填",
"updated": "VPN 更新成功",
"created": "WireGuard VPN 创建成功",
"updateFailed": "更新 VPN 失败:{{error}}",
"createFailed": "创建 VPN 失败:{{error}}"
},
"import": {
"title": "导入 VPN 配置",
"descDropzone": "导入 WireGuard (.conf) 配置文件",
"descPreview": "查看要导入的 VPN 配置",
"descResult": "VPN 导入完成",
"dropzonePrompt": "将 WireGuard .conf 文件拖到这里或点击浏览",
"pasteHint": "使用 {{modKey}}+V 从剪贴板粘贴",
"invalidContent": "内容似乎不是有效的 VPN 配置",
"fileReadError": "读取文件失败",
"wrongFileType": "请放下 WireGuard .conf 文件",
"configurationLabel": "{{type}} 配置",
"endpointLabel": "端点:{{endpoint}}",
"vpnNameLabel": "VPN 名称",
"vpnNamePlaceholder": "我的 VPN",
"configPreview": "配置预览",
"importedSuccess": "VPN 导入成功",
"importFailed": "导入失败",
"importButton": "导入 VPN",
"doneButton": "完成",
"failedGeneric": "导入 VPN 配置失败",
"defaultName": "{{type}} VPN"
},
"management": {
"loading": "正在加载 VPN...",
"noneCreated": "尚未创建 VPN 配置。使用上方按钮导入或创建一个。",
"editVpn": "编辑 VPN",
"deleteVpn": "删除 VPN",
"cannotDelete_one": "无法删除: 被 {{count}} 个配置文件使用",
"cannotDelete_other": "无法删除: 被 {{count}} 个配置文件使用",
"syncCannotDisable": "此 VPN 被同步的配置文件使用时无法禁用同步",
"deleteSuccess": "VPN 删除成功",
"deleteFailed": "删除 VPN 失败",
"deleteTitle": "删除 VPN",
"deleteDescription": "此操作无法撤消。VPN「{{name}}」将被永久删除。"
}
},
"importProfile": {
"title": "导入浏览器配置文件",
"autoDetect": "自动检测",
"manualImport": "手动导入",
"detectedProfilesTitle": "检测到的浏览器配置文件",
"scanning": "正在扫描浏览器配置文件...",
"noneFound": "在你的系统上未找到浏览器配置文件。",
"noneFoundHint": "如果配置文件位于自定义位置,请尝试手动导入选项。",
"selectProfile": "选择配置文件:",
"selectProfilePlaceholder": "选择检测到的配置文件",
"pathLabel": "路径:",
"browserLabel": "浏览器:",
"newProfileName": "新配置文件名称:",
"newProfileNamePlaceholder": "输入要导入的配置文件名称",
"manualTitle": "手动配置文件导入",
"browserType": "浏览器类型:",
"loadingBrowsers": "正在加载浏览器...",
"selectBrowserType": "选择浏览器类型",
"profileFolderPath": "配置文件夹路径:",
"profileFolderPlaceholder": "输入配置文件夹的完整路径",
"browseFolderTitle": "浏览文件夹",
"examplePaths": "示例路径:",
"selectFolderTitle": "选择浏览器配置文件夹",
"folderDialogFailed": "打开文件夹对话框失败",
"detectFailed": "检测现有浏览器配置文件失败",
"fillFields": "请填写所有字段",
"selectAndName": "请选择配置文件并提供名称",
"profileNotFound": "未找到所选配置文件",
"importedSuccess": "已成功导入配置文件「{{name}}」",
"notInstalled": "{{browser}} 未安装。请先从主窗口下载 {{browser}},然后再尝试导入。",
"importFailed": "导入配置文件失败: {{error}}",
"proxyOptional": "代理 (可选)",
"noProxy": "无代理",
"nextButton": "下一步",
"importButton": "导入",
"importedAs": "此配置文件将作为 {{browser}} 配置文件导入。"
},
"syncTooltips": {
"syncing": "同步中...",
"syncedAt": "已同步 {{time}}",
"synced": "已同步",
"waiting": "等待同步",
"errorWith": "同步错误: {{error}}",
"error": "同步错误",
"notSynced": "未同步"
},
"groupManagement": {
"description": "管理你的配置文件分组",
"createGroup": "创建分组",
"noGroups": "尚未创建分组。使用上方按钮创建第一个分组。",
"loading": "正在加载分组...",
"profileCount_one": "{{count}} 个配置文件",
"profileCount_other": "{{count}} 个配置文件",
"groupsLabel": "分组",
"profilesCol": "配置文件",
"syncCannotDisable": "此分组被同步的配置文件使用时无法禁用同步",
"editGroupTooltip": "编辑分组",
"deleteGroupTooltip": "删除分组",
"loadFailed": "加载分组失败"
},
"proxyAssignment": {
"title": "分配代理 / VPN",
"description_one": "为 {{count}} 个选定的配置文件分配代理或 VPN。",
"description_other": "为 {{count}} 个选定的配置文件分配代理或 VPN。",
"selectLabel": "代理 / VPN",
"placeholder": "选择代理或 VPN",
"noProxy": "无代理 / VPN",
"searchPlaceholder": "搜索代理或 VPN...",
"notFound": "未找到代理或 VPN。",
"assignButton": "分配",
"success": "已成功为 {{count}} 个配置文件分配代理/VPN",
"failed": "分配代理/VPN 失败",
"selectedProfilesLabel": "选定的配置文件:",
"assignProxyVpnLabel": "分配代理 / VPN:",
"noneOption": "无",
"noValidProfiles": "未选择有效的配置文件。",
"vpnGroupHeading": "VPN",
"failedFallback": "为配置文件分配代理/VPN 失败"
},
"groupAssignment": {
"title": "分配分组",
"description_one": "为 {{count}} 个选定的配置文件分配分组。",
"description_other": "为 {{count}} 个选定的配置文件分配分组。",
"selectLabel": "分组",
"placeholder": "选择分组",
"noGroup": "无分组 (默认)",
"assignButton": "分配",
"success": "已成功为 {{count}} 个配置文件分配分组",
"failed": "分配分组失败",
"selectedProfilesLabel": "选定的配置文件:",
"assignGroupLabel": "分配到分组:",
"noValidProfiles": "未选择有效的配置文件。",
"failedFallback": "为配置文件分配分组失败"
},
"profileSelector": {
"title": "选择配置文件",
"description": "选择一个配置文件来打开此 URL",
"searchPlaceholder": "搜索配置文件...",
"noProfiles": "没有可用的配置文件",
"noResults": "没有匹配的配置文件",
"selectButton": "选择",
"launching": "正在启动...",
"chooseProfileTitle": "选择配置文件",
"openingUrl": "打开 URL:",
"urlCopied": "URL 已复制到剪贴板!",
"selectProfileLabel": "选择配置文件:",
"noneAvailableShort": "没有可用的配置文件。请先创建一个配置文件。",
"noneAvailableLong": "关闭此对话框并从主窗口创建配置文件以开始使用。",
"chooseAProfile": "选择一个配置文件",
"badgeProxy": "代理",
"badgeRunning": "运行中",
"badgeUnavailable": "不可用",
"openButton": "打开"
},
"locationProxy": {
"title": "快速位置代理",
"description": "选择一个国家来路由此配置文件。系统将自动创建代理。",
"country": "国家",
"selectCountry": "选择国家",
"searchCountry": "搜索国家...",
"noCountriesFound": "未找到国家。",
"apply": "应用",
"creating": "正在创建代理...",
"success": "已应用位置代理",
"failed": "应用位置代理失败",
"titleCreate": "创建位置代理",
"descriptionCreate": "创建带 24 小时粘性会话的地理定位代理",
"countryLabel": "国家 (必填)",
"regionLabel": "地区 (可选)",
"cityLabel": "城市 (可选)",
"ispLabel": "ISP (可选)",
"nameLabel": "名称",
"namePlaceholder": "代理名称",
"loadingCountries": "正在加载国家...",
"selectCountryPh": "选择国家",
"searchCountries": "搜索国家...",
"loadFailed": "加载国家失败",
"selectCountryFirst": "请先选择国家",
"loadingRegions": "正在加载地区...",
"noRegions": "没有可用的地区",
"selectRegion": "选择地区",
"searchRegions": "搜索地区...",
"loadingCities": "正在加载城市...",
"noCities": "没有可用的城市",
"selectCity": "选择城市",
"searchCities": "搜索城市...",
"loadingIsps": "正在加载 ISP...",
"noIsps": "没有可用的 ISP",
"selectIsp": "选择 ISP",
"searchIsps": "搜索 ISP...",
"createSuccess": "已创建位置代理",
"createFailed": "创建位置代理失败",
"creatingButton": "正在创建...",
"createButton": "创建"
},
"launchOnLogin": {
"title": "启用登录时启动?",
"description": "在后台运行有助于保持代理和浏览器存活。",
"declineButton": "不再询问",
"declining": "...",
"enableButton": "启用",
"enableSuccess": "已启用登录时启动",
"enableFailed": "启用登录时启动失败",
"declineFailed": "保存偏好失败",
"tryAgain": "请重试"
},
"wayfernTerms": {
"title": "Wayfern 条款和条件",
"description": "在使用 Donut Browser 之前,你必须阅读并同意 Wayfern 的条款和条件。",
"reviewLabel": "请查看以下条款和条件:",
"agreeNotice": "点击「我接受」即表示你同意受这些条款约束。",
"acceptButton": "我接受",
"acceptSuccess": "已成功接受条款",
"acceptFailed": "接受条款失败",
"tryAgain": "请重试"
},
"commercialTrial": {
"title": "商业试用期已过期",
"description": "你的 2 周商业试用期已结束。",
"body": "如果你将 Donut Browser 用于商业目的,需要购买商业许可证才能继续。你仍然可以免费用于个人用途。",
"understandButton": "我明白了",
"failed": "保存确认失败",
"tryAgain": "请重试"
},
"permissionDialog": {
"titleMicrophone": "需要麦克风访问权限",
"titleCamera": "需要摄像头访问权限",
"descMicrophone": "Donut Browser 需要访问你的麦克风以在浏览器中启用麦克风功能。每个想使用麦克风的网站仍会单独请求你的许可。",
"descCamera": "Donut Browser 需要访问你的摄像头以在浏览器中启用摄像头功能。每个想使用摄像头的网站仍会单独请求你的许可。",
"grantedMicrophone": "权限已授予!从 Donut Browser 启动的浏览器现在可以访问你的麦克风。",
"grantedCamera": "权限已授予!从 Donut Browser 启动的浏览器现在可以访问你的摄像头。",
"notGrantedMicrophone": "未授予权限。点击下方按钮请求访问你的麦克风。",
"notGrantedCamera": "未授予权限。点击下方按钮请求访问你的摄像头。",
"doneButton": "完成",
"cancelButton": "取消",
"grantAccessButton": "授予访问",
"requestSuccessMicrophone": "已请求麦克风访问",
"requestSuccessCamera": "已请求摄像头访问",
"requestFailed": "请求权限失败"
},
"traffic": {
"title": "流量详情",
"bandwidthOverTime": "随时间变化的带宽",
"timePeriodPlaceholder": "时间段",
"last1m": "最近 1 分钟",
"last5m": "最近 5 分钟",
"last30m": "最近 30 分钟",
"last1h": "最近 1 小时",
"last2h": "最近 2 小时",
"last4h": "最近 4 小时",
"last1d": "最近 1 天",
"last7d": "最近 7 天",
"last30d": "最近 30 天",
"allTime": "所有时间",
"allTimeShort": "所有时间",
"totalSuffix": "总计",
"sentLabel": "已发送 ({{period}})",
"receivedLabel": "已接收 ({{period}})",
"requestsLabel": "请求数 ({{period}})",
"allTimeTraffic": "总流量:",
"allTimeRequests": "总请求数:",
"proxyDisclaimer": "注意: 如果你使用代理、VPN 或类似服务,你的提供商可能会因加密开销和协议差异而以不同方式计算流量。",
"topByTraffic": "按流量排名的顶级域名 ({{period}})",
"topByRequests": "按请求数排名的顶级域名 ({{period}})",
"columnDomain": "域名",
"columnRequests": "请求数",
"columnSent": "已发送",
"columnReceived": "已接收",
"columnTotal": "总流量",
"uniqueIps": "独立 IP ({{count}})",
"noData": "此配置文件没有可用的流量数据。",
"noDataHint": "启动配置文件后,流量数据将会显示。",
"sentLegend": "已发送",
"receivedLegend": "已接收",
"tooltipSent": "↑ 已发送: ",
"tooltipReceived": "↓ 已接收: "
},
"camoufoxDialog": {
"titleView": "查看指纹设置 - {{name}} ({{browser}})",
"titleConfigure": "配置指纹设置 - {{name}} ({{browser}})",
"invalidFingerprint": "无效的指纹配置",
"invalidFingerprintDescription": "指纹配置包含无效的 JSON。请检查高级设置。",
"saveFailed": "保存配置失败",
"unknownError": "发生未知错误"
},
"proxyCheck": {
"unknownLocation": "未知",
"locationToast": "你的代理位置:",
"failed": "代理检查失败: {{error}}",
"tooltipChecking": "正在检查代理...",
"tooltipIp": "IP: {{ip}}",
"tooltipChecked": "已检查 {{time}}",
"tooltipFailed": "失败 {{time}}",
"tooltipFailedTitle": "代理检查失败",
"tooltipDefault": "检查代理有效性"
},
"vpnCheck": {
"valid": "VPN「{{name}}」配置有效",
"invalid": "VPN「{{name}}」配置无效",
"failed": "VPN 检查失败: {{error}}",
"tooltipChecking": "正在检查 VPN 配置...",
"tooltipValid": "配置有效",
"tooltipInvalid": "配置无效",
"tooltipChecked": "已检查 {{time}}",
"tooltipDefault": "检查 VPN 配置有效性"
},
"profileTable": {
"syncTooltipDisabled": "同步已禁用",
"syncTooltipSyncing": "同步中...",
"syncTooltipSyncedAt": "已同步 {{time}}",
"syncTooltipSynced": "已同步",
"syncTooltipWaiting": "等待同步",
"syncTooltipErrorWith": "同步错误: {{error}}",
"syncTooltipError": "同步错误",
"syncTooltipNotSynced": "未同步",
"noTags": "无标签",
"syncTooltipCloseToSync": "关闭配置文件以进行同步",
"syncTooltipDisabledWithLast": "同步已禁用,上次同步 {{time}}",
"addTagsPlaceholder": "添加标签",
"tagsHeader": "标签",
"noteHeader": "备注",
"vpnsHeading": "VPN",
"createByCountryHeading": "按国家创建"
},
"releaseTypeSelector": {
"noReleaseTypes": "没有可用的发布类型。",
"placeholder": "选择发布类型...",
"stable": "Stable",
"nightly": "Nightly",
"downloaded": "已下载",
"downloadBrowser": "下载浏览器",
"downloading": "正在下载..."
},
"dataTableActionBar": {
"selected": "已选择 {{count}}",
"clearSelection": "清除选择"
},
"appUpdate": {
"toast": {
"updateFailed": "更新 Donut Browser 失败",
"restartFailed": "重启失败",
"updateReady": "更新就绪,请重启以应用",
"manualDownloadRequired": "需要手动下载",
"restartNow": "立即重启",
"viewRelease": "查看版本",
"later": "稍后",
"uploading": "上传中",
"downloading": "下载中"
}
},
"browserDownload": {
"toast": {
"fetchVersionsFailed": "获取 {{browser}} 版本失败",
"foundNewVersions": "发现 {{count}} 个新的 {{browser}} 版本!",
"totalAvailableVersions": "总计可用: {{count}} 个版本",
"downloadFailed": "下载 {{browser}} {{version}} 失败",
"calculating": "计算中...",
"extractionFailed": "{{browser}} {{version}}: 解压失败",
"extractionFailedDescription": "损坏的文件已删除。下次尝试时将重新下载。",
"extracting": "正在提取浏览器文件...请不要关闭应用。",
"verifying": "正在验证浏览器文件...",
"downloadingRolling": "正在下载滚动发布版本..."
}
},
"versionUpdater": {
"toast": {
"alreadyAvailable": "{{browser}} {{version}} 已可用",
"updatingProfiles": "正在更新配置文件设置...",
"updateCompleted": "{{browser}} 更新完成",
"singleProfileUpdated": "配置文件 \"{{name}}\" 已更新到版本 {{version}}。现在可以使用最新版本启动浏览器。",
"multipleProfilesUpdated": "{{count}} 个配置文件已更新到版本 {{version}}。现在可以使用最新版本启动浏览器。",
"versionAvailable": "版本 {{version}} 现已可用。运行中的配置文件将在重启时使用新版本。",
"autoUpdateFailed": "自动更新 {{browser}} 失败",
"updateWithErrors": "更新完成,但有部分错误",
"updateWithErrorsDescription": "发现 {{newVersions}} 个新版本,{{failedUpdates}} 个浏览器更新失败",
"updateSuccess": "浏览器版本更新成功",
"updateSuccessDescription": "在 {{successfulUpdates}} 个浏览器中发现 {{newVersions}} 个新版本。自动下载即将开始。",
"upToDate": "未发现新的浏览器版本",
"upToDateDescription": "所有浏览器版本都是最新的",
"updateAllFailed": "更新浏览器版本失败"
}
}
}