From 3d8379453e97ca3e37b10415512c18fb9364c25b Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:13:17 +0400 Subject: [PATCH] feat: settings and sounds --- e2e/tests/motion.test.mjs | 51 +- e2e/tests/ui.test.mjs | 77 +- src-tauri/src/settings_manager.rs | 98 +- src/app/page.tsx | 26 +- src/components/client-providers.tsx | 10 +- src/components/cookie-bot-enrol-dialog.tsx | 5 +- src/components/cookie-paste-panel.tsx | 2 + src/components/profile-info-dialog.tsx | 2 + src/components/proxy-check-button.tsx | 2 + src/components/proxy-export-dialog.tsx | 2 + src/components/proxy-import-dialog.tsx | 2 + src/components/settings-dialog.tsx | 2271 +++++++---------- .../settings/encryption-settings.tsx | 332 +++ .../settings/settings-primitives.tsx | 475 ++++ src/components/settings/settings-save-bar.tsx | 183 ++ src/components/settings/sound-settings.tsx | 303 +++ src/components/settings/theme-settings.tsx | 292 +++ src/components/trash-page.tsx | 9 +- src/components/ui/copy-to-clipboard.tsx | 4 +- src/components/vpn-import-dialog.tsx | 2 + src/hooks/use-agent-console.ts | 2 + src/hooks/use-app-update-notifications.tsx | 2 + src/i18n/locales/de.json | 74 +- src/i18n/locales/en.json | 74 +- src/i18n/locales/es.json | 74 +- src/i18n/locales/fr.json | 74 +- src/i18n/locales/ja.json | 74 +- src/i18n/locales/ko.json | 74 +- src/i18n/locales/pt.json | 74 +- src/i18n/locales/ru.json | 74 +- src/i18n/locales/tr.json | 74 +- src/i18n/locales/vi.json | 74 +- src/i18n/locales/zh.json | 74 +- src/lib/toast-utils.ts | 28 +- src/lib/ui-sounds.test.mjs | 144 +- src/lib/ui-sounds.ts | 794 ++++-- src/styles/globals.css | 4 +- 37 files changed, 4146 insertions(+), 1790 deletions(-) create mode 100644 src/components/settings/encryption-settings.tsx create mode 100644 src/components/settings/settings-primitives.tsx create mode 100644 src/components/settings/settings-save-bar.tsx create mode 100644 src/components/settings/sound-settings.tsx create mode 100644 src/components/settings/theme-settings.tsx diff --git a/e2e/tests/motion.test.mjs b/e2e/tests/motion.test.mjs index 0cb39ab8..eda824ee 100644 --- a/e2e/tests/motion.test.mjs +++ b/e2e/tests/motion.test.mjs @@ -4,7 +4,6 @@ import path from "node:path"; import test from "node:test"; import Color from "color"; import en from "../../src/i18n/locales/en.json" with { type: "json" }; -import { THEMES } from "../../src/lib/themes.ts"; import { withApp } from "../lib/app.mjs"; import { extensionZipBase64 } from "../lib/fixtures.mjs"; @@ -294,16 +293,19 @@ test("paused CSS animations cannot retain dismissed selects or dropdowns", async await resize(app, 1100, 760); await createProfile(app, "Motion popup Alpha"); await createProfile(app, "Motion popup Beta"); - await app.clickSelector(`[aria-label="${en.rail.settings}"]`); + await app.clickSelector(`[aria-label="${en.rail.network}"]`); + await app.clickSelector( + `[aria-label="${en.proxies.management.newProxy}"]`, + ); await app.waitFor( () => app.execute(` - const trigger = document.querySelector('#theme-select'); + const trigger = document.querySelector('#proxy-type'); if (!trigger || trigger.matches(':disabled')) return false; trigger.focus(); return document.activeElement === trigger; `), - { description: "enabled theme select" }, + { description: "enabled proxy type select" }, ); await app.pressShortcut({ key: "\uE007" }); await assertPopupReadable(app, slot("select-content")); @@ -324,7 +326,7 @@ test("paused CSS animations cannot retain dismissed selects or dropdowns", async ), "paused", ); - await app.clickText(en.common.labels.custom, { roles: ["option"] }); + await app.clickText(en.proxies.types.socks5, { roles: ["option"] }); await app.waitFor( () => app.execute( @@ -339,27 +341,19 @@ test("paused CSS animations cannot retain dismissed selects or dropdowns", async // The next popup also opens under the paused clock: both its content // and its pointer targets must be available at the initial frame. - await app.clickSelector("#theme-preset-select"); + await app.clickSelector("#proxy-type"); await assertPopupReadable(app, slot("select-content")); - await app.capture("paused-css-preset-select"); - const preset = THEMES.find((theme) => theme.id === "dracula"); - assert.ok(preset); - await app.clickText(preset.name, { roles: ["option"] }); + await app.capture("paused-css-proxy-type-select"); + await app.clickText(en.proxies.types.http, { roles: ["option"] }); await waitForSelector(app, slot("select-content"), false); assert.equal( await app.execute( - `return document.querySelector("#theme-preset-select")?.textContent.trim();`, + `return document.querySelector("#proxy-type")?.textContent.trim();`, ), - preset.name, + en.proxies.types.http, ); - await app.clickText(en.common.buttons.saveSettings, { - roles: ["button"], - }); - await app.waitFor( - () => app.visibleTextIncludes(en.common.buttons.saved), - { description: "saved settings feedback" }, - ); - await waitForSelector(app, "#theme-select", true); + await app.pressShortcut({ key: "\uE00C" }); + await waitForSelector(app, "#proxy-type", false); await app.clickSelector(`[aria-label="${en.rail.profiles}"]`); const openSort = () => @@ -1344,12 +1338,21 @@ test("settings save a RAM-only edit, preserve location, and expose review and di const initial = await app.invoke("get_app_settings"); const selector = "#keep-decrypted-profiles-in-ram"; await app.clickSelector(selector); - await app.clickSelector(`${slot("settings-feedback")} summary`); - assert.equal( - await app.visibleTextIncludes(en.settings.keepDecryptedProfilesInRam), - true, + await app.clickSelector(slot("settings-review")); + await app.waitFor( + () => + app.execute( + `return document.querySelector(arguments[0])?.textContent.includes(arguments[1]);`, + [ + slot("settings-review-list"), + en.settings.keepDecryptedProfilesInRam, + ], + ), + { description: "the review list names the RAM change" }, ); await app.capture("review-ram-change"); + await app.pressShortcut({ key: "\uE00C" }); + await waitForSelector(app, slot("settings-review-list"), false); await app.clickSelector(slot("settings-discard")); assert.equal( await app.execute( diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 6e3dae5e..552c2202 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -1271,6 +1271,26 @@ async function chooseSelectOption(app, triggerSelector, option) { ); } +async function chooseThemeCard(app, slotName, value) { + const card = `[data-slot="${slotName}"][data-value="${value}"]`; + await app.clickSelector(card); + await app.waitFor( + () => + app.execute( + `return document.querySelector(arguments[0])?.getAttribute("aria-checked") === "true";`, + [card], + ), + { description: `${value} chosen in ${slotName}` }, + ); +} + +function checkedThemeCard(app, slotName) { + return app.execute( + `return document.querySelector('[data-slot="' + arguments[0] + '"][aria-checked="true"]')?.dataset.value ?? null;`, + [slotName], + ); +} + async function saveSettings(app) { await app.clickText("Save Settings", { roles: ["button"] }); await app.waitFor( @@ -1376,7 +1396,7 @@ async function dragBackgroundColorPicker(app) { await app.waitFor( () => app.execute( - `return document.querySelector("#theme-preset-select")?.textContent?.includes("Your Own") === true;`, + `return document.querySelector('[data-slot="settings-theme-preset"][data-value="custom"]')?.getAttribute("aria-checked") === "true";`, ), { description: `customized theme to be marked as Your Own after ${JSON.stringify(pointerEvents)}`, @@ -2368,21 +2388,44 @@ test("first-run onboarding stays recoverable, responsive, and platform-aware", a ); }); -test("interface sounds turn on and off in Appearance and stay as saved", async () => { +test("interface sounds, their pack, volume and kinds stay as saved", async () => { await withApp("ui-sounds-setting", async (app) => { const sounds = async () => (await app.invoke("get_app_settings")).ui_sounds; assert.equal(await sounds(), false, "the harness keeps the suite silent"); await app.clickSelector('[aria-label="Settings"]'); - await app.waitForText(en.settings.appearance.soundsDescription); - const toggle = `[role="switch"][aria-label="${en.settings.appearance.sounds}"]`; + await app.waitForText(en.settings.sounds.enabledDescription); + const toggle = `[role="switch"][aria-label="${en.settings.sounds.enabled}"]`; + const pack = '[data-slot="settings-sound-pack-option"][data-value="retro"]'; + assert.equal( + await app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [pack], + ), + false, + "the sound options stay hidden while sounds are off", + ); await app.clickSelector(toggle); + await app.clickSelector(pack); + await app.clickSelector( + `[role="switch"][aria-label="${en.settings.sounds.categories.events.label}"]`, + ); + await app.execute( + `document.querySelector('[data-slot="settings-sound-volume"] [role="slider"]').focus();`, + ); + await app.pressShortcut({ key: "\uE012" }); await saveSettings(app); - assert.equal(await sounds(), true); + const saved = await app.invoke("get_app_settings"); + assert.equal(saved.ui_sounds, true); + assert.equal(saved.ui_sound_pack, "retro"); + assert.equal(saved.ui_sound_events, false); + assert.equal(saved.ui_sound_volume, 55); await app.clickSelector(toggle); await saveSettings(app); assert.equal(await sounds(), false); await app.restart(); - assert.equal(await sounds(), false, "the choice survives a restart"); + const restarted = await app.invoke("get_app_settings"); + assert.equal(restarted.ui_sounds, false, "the choice survives a restart"); + assert.equal(restarted.ui_sound_pack, "retro"); }); }); @@ -2390,7 +2433,7 @@ test("predefined theme remains rendered across navigation and restart", async () await withApp("ui-theme-predefined", async (app) => { await app.clickSelector('[aria-label="Settings"]'); await app.waitForText("Appearance"); - await chooseSelectOption(app, "#theme-select", "Light"); + await chooseThemeCard(app, "settings-theme-option", "light"); await saveSettings(app); const persisted = await app.invoke("get_app_settings"); @@ -2420,8 +2463,8 @@ test("preset and manually customized themes survive navigation and restart", asy await withApp("ui-theme-custom", async (app) => { await app.clickSelector('[aria-label="Settings"]'); await app.waitForText("Appearance"); - await chooseSelectOption(app, "#theme-select", "Custom"); - await chooseSelectOption(app, "#theme-preset-select", "Dracula"); + await chooseThemeCard(app, "settings-theme-option", "custom"); + await chooseThemeCard(app, "settings-theme-preset", "dracula"); await saveSettings(app); const presetSettings = await app.invoke("get_app_settings"); @@ -2451,16 +2494,12 @@ test("preset and manually customized themes survive navigation and restart", asy await app.clickSelector('[aria-label="Settings"]'); await app.waitForText("Appearance"); assert.equal( - await app.execute( - `return document.querySelector("#theme-select")?.textContent?.trim();`, - ), - "Custom", + await checkedThemeCard(app, "settings-theme-option"), + "custom", ); assert.equal( - await app.execute( - `return document.querySelector("#theme-preset-select")?.textContent?.trim();`, - ), - "Dracula", + await checkedThemeCard(app, "settings-theme-preset"), + "dracula", ); await dragBackgroundColorPicker(app); await saveSettings(app); @@ -2503,8 +2542,8 @@ test("a light custom preset keeps light component behavior after restart", async await withApp("ui-theme-custom-light", async (app) => { await app.clickSelector('[aria-label="Settings"]'); await app.waitForText("Appearance"); - await chooseSelectOption(app, "#theme-select", "Custom"); - await chooseSelectOption(app, "#theme-preset-select", "Ayu Light"); + await chooseThemeCard(app, "settings-theme-option", "custom"); + await chooseThemeCard(app, "settings-theme-preset", "ayu-light"); await saveSettings(app); const selected = await waitForTheme( diff --git a/src-tauri/src/settings_manager.rs b/src-tauri/src/settings_manager.rs index e7ba1784..3558f601 100644 --- a/src-tauri/src/settings_manager.rs +++ b/src-tauri/src/settings_manager.rs @@ -244,9 +244,29 @@ pub struct AppSettings { /// byte counts only, so nothing on disk lists where a profile went. #[serde(default = "default_record_traffic_domains")] pub record_traffic_domains: bool, - /// Soft interface sounds for presses, toggles and results. + /// Soft interface sounds: the master switch over the fields below. #[serde(default = "default_ui_sounds")] pub ui_sounds: bool, + /// Interface sound volume, 0 to 100. Out-of-range numbers are clamped as + /// they are read, so a bad value never resets the whole settings file. + #[serde( + default = "default_ui_sound_volume", + deserialize_with = "deserialize_ui_sound_volume" + )] + pub ui_sound_volume: u8, + /// Sounds for presses, toggles and navigation. + #[serde(default = "default_ui_sounds")] + pub ui_sound_interface: bool, + /// Sounds for results: success, error, warning, copy, delete, restore. + #[serde(default = "default_ui_sounds")] + pub ui_sound_feedback: bool, + /// Sounds for events: launch, stop, sync, downloads, notifications. + #[serde(default = "default_ui_sounds")] + pub ui_sound_events: bool, + /// The sound pack id. The frontend plays an unknown id as the default + /// pack and keeps it stored, so a downgrade does not lose the choice. + #[serde(default = "default_ui_sound_pack")] + pub ui_sound_pack: String, /// The optional MCP toolsets written into clients' endpoint URLs. Empty is /// every tool. #[serde(default)] @@ -287,6 +307,26 @@ fn default_ui_sounds() -> bool { true } +pub const DEFAULT_UI_SOUND_VOLUME: u8 = 60; +pub const MAX_UI_SOUND_VOLUME: u8 = 100; +pub const DEFAULT_UI_SOUND_PACK: &str = "soft"; + +fn default_ui_sound_volume() -> u8 { + DEFAULT_UI_SOUND_VOLUME +} + +fn default_ui_sound_pack() -> String { + DEFAULT_UI_SOUND_PACK.to_string() +} + +fn deserialize_ui_sound_volume<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let volume = f64::deserialize(deserializer)?; + Ok(volume.round().clamp(0.0, f64::from(MAX_UI_SOUND_VOLUME)) as u8) +} + /// The automatic tip flow waits a random gap between two tips, drawn from /// this range (both ends included), so tips arrive now and then instead of on /// a fixed day and a busy day of restarts never turns into a tip per launch. @@ -331,6 +371,11 @@ impl Default for AppSettings { trash_retention_days: DEFAULT_TRASH_RETENTION_DAYS, record_traffic_domains: true, ui_sounds: true, + ui_sound_volume: DEFAULT_UI_SOUND_VOLUME, + ui_sound_interface: true, + ui_sound_feedback: true, + ui_sound_events: true, + ui_sound_pack: DEFAULT_UI_SOUND_PACK.to_string(), mcp_toolsets: Vec::new(), } } @@ -1491,6 +1536,11 @@ mod tests { trash_retention_days: 7, record_traffic_domains: false, ui_sounds: false, + ui_sound_volume: 25, + ui_sound_interface: false, + ui_sound_feedback: true, + ui_sound_events: false, + ui_sound_pack: "retro".to_string(), mcp_toolsets: vec!["network".to_string()], }; @@ -1513,6 +1563,11 @@ mod tests { assert_eq!(loaded_settings.trash_retention_days, 7); assert!(!loaded_settings.record_traffic_domains); assert!(!loaded_settings.ui_sounds); + assert_eq!(loaded_settings.ui_sound_volume, 25); + assert!(!loaded_settings.ui_sound_interface); + assert!(loaded_settings.ui_sound_feedback); + assert!(!loaded_settings.ui_sound_events); + assert_eq!(loaded_settings.ui_sound_pack, "retro"); } #[test] @@ -1522,6 +1577,47 @@ mod tests { assert_eq!(settings.trash_retention_days, DEFAULT_TRASH_RETENTION_DAYS); assert!(settings.record_traffic_domains); assert!(settings.ui_sounds, "sounds are on until turned off"); + assert_eq!(settings.ui_sound_volume, DEFAULT_UI_SOUND_VOLUME); + assert!(settings.ui_sound_interface); + assert!(settings.ui_sound_feedback); + assert!(settings.ui_sound_events); + assert_eq!(settings.ui_sound_pack, DEFAULT_UI_SOUND_PACK); + } + + #[test] + fn default_sound_settings_match_a_missing_file() { + let defaults = AppSettings::default(); + let parsed: AppSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(defaults.ui_sound_volume, 60); + assert_eq!(defaults.ui_sound_pack, "soft"); + assert_eq!(parsed.ui_sound_volume, defaults.ui_sound_volume); + assert_eq!(parsed.ui_sound_pack, defaults.ui_sound_pack); + assert_eq!(parsed.ui_sound_interface, defaults.ui_sound_interface); + assert_eq!(parsed.ui_sound_feedback, defaults.ui_sound_feedback); + assert_eq!(parsed.ui_sound_events, defaults.ui_sound_events); + } + + #[test] + fn sound_volume_is_clamped_instead_of_resetting_the_settings() { + let volume = |json: &str| { + serde_json::from_str::(json) + .map(|settings| (settings.ui_sound_volume, settings.theme)) + .unwrap() + }; + assert_eq!( + volume(r#"{"theme":"dark","ui_sound_volume":250}"#), + (100, "dark".to_string()) + ); + assert_eq!( + volume(r#"{"theme":"dark","ui_sound_volume":-5}"#), + (0, "dark".to_string()) + ); + assert_eq!( + volume(r#"{"theme":"dark","ui_sound_volume":42.6}"#), + (43, "dark".to_string()) + ); + let unknown: AppSettings = serde_json::from_str(r#"{"ui_sound_pack":"future"}"#).unwrap(); + assert_eq!(unknown.ui_sound_pack, "future", "an unknown pack is kept"); } #[test] diff --git a/src/app/page.tsx b/src/app/page.tsx index 4d603e51..127b6a31 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -110,6 +110,7 @@ import { showSyncProgressToast, showToast, } from "@/lib/toast-utils"; +import { playUiSound } from "@/lib/ui-sounds"; import type { BrowserProfile, ConsistencyResult, @@ -855,6 +856,7 @@ export default function Home() { return; } e.preventDefault(); + playUiSound("open"); runShortcut(s.id); return; } @@ -1349,6 +1351,7 @@ export default function Home() { ackExtensionKeys: [], }); } + playUiSound("warning"); return new Promise((resolve) => { gateRequestSeqRef.current += 1; gateQueueRef.current.push({ @@ -1541,12 +1544,14 @@ export default function Home() { console.warn("Pre-launch checks failed, launching anyway:", err); } + const causedAt = performance.now(); try { const result = await invoke("launch_browser_profile", { profile, consentToken, }); console.log("Successfully launched profile:", result.name); + playUiSound("launch", { causedAt, batch: opts?.bulkRunId }); return { status: "launched" }; } catch (err: unknown) { // Tier 2: the enforcing gate measured the exit mid-launch and stopped @@ -1577,10 +1582,15 @@ export default function Home() { return { status: "cancelled" }; } try { + const retriedAt = performance.now(); await invoke("launch_browser_profile", { profile, consentToken: parsed.params?.token ?? null, }); + playUiSound("launch", { + causedAt: retriedAt, + batch: opts?.bulkRunId, + }); return { status: "launched" }; } catch (retryErr: unknown) { showErrorToast( @@ -1642,6 +1652,7 @@ export default function Home() { // Attempt to delete the profile await invoke("delete_profile", { profileId: profile.id }); + playUiSound("trash"); console.log("Profile deletion command completed successfully"); // No need to manually reload - useProfileEvents will handle the update @@ -1675,11 +1686,12 @@ export default function Home() { ); const handleKillProfile = useCallback( - async (profile: BrowserProfile) => { + async (profile: BrowserProfile, opts?: { batch?: number }) => { console.log("Starting kill for profile:", profile.name); try { await invoke("kill_browser_profile", { profile }); + playUiSound("stop", { batch: opts?.batch }); console.log("Successfully killed profile:", profile.name); // No need to manually reload - useProfileEvents will handle the update } catch (err: unknown) { @@ -1697,6 +1709,7 @@ export default function Home() { async (profileIds: string[]) => { try { await invoke("delete_selected_profiles", { profileIds }); + playUiSound("trash"); // No need to manually reload - useProfileEvents will handle the update } catch (err: unknown) { console.error("Failed to delete selected profiles:", err); @@ -1743,6 +1756,7 @@ export default function Home() { groupsData.find((group) => group.id === groupId)?.name ?? t("groups.noGroup"), }), + { sound: "drop" }, ); return true; } catch (error) { @@ -1770,6 +1784,7 @@ export default function Home() { await invoke("delete_selected_profiles", { profileIds: selectedProfiles, }); + playUiSound("trash"); // No need to manually reload - useProfileEvents will handle the update setSelectedProfiles([]); setShowBulkDeleteConfirmation(false); @@ -1879,8 +1894,11 @@ export default function Home() { const executeBulkStop = useCallback( async (targets: BrowserProfile[]) => { setIsBulkActing(true); + const batch = performance.now(); try { - await Promise.allSettled(targets.map((p) => handleKillProfile(p))); + await Promise.allSettled( + targets.map((p) => handleKillProfile(p, { batch })), + ); setSelectedProfiles([]); } finally { setIsBulkActing(false); @@ -2003,7 +2021,9 @@ export default function Home() { dismissToast(toastId); if (profilesWithTransfer.has(profile_id)) { profilesWithTransfer.delete(profile_id); - showSuccessToast(t("sync.toast.profileSynced", { name })); + showSuccessToast(t("sync.toast.profileSynced", { name }), { + sound: "synced", + }); } } else if (status === "error") { dismissToast(toastId); diff --git a/src/components/client-providers.tsx b/src/components/client-providers.tsx index c34a17ff..37df814b 100644 --- a/src/components/client-providers.tsx +++ b/src/components/client-providers.tsx @@ -12,7 +12,11 @@ import { WindowDragArea } from "@/components/window-drag-area"; import { InputModalityProvider } from "@/hooks/use-input-modality"; import { LaunchActivityProvider } from "@/hooks/use-launch-activity"; import { setupLogging } from "@/lib/logger"; -import { installUiSounds, setUiSoundsEnabled } from "@/lib/ui-sounds"; +import { + applySoundSettings, + installUiSounds, + type SoundSettings, +} from "@/lib/ui-sounds"; export function ClientProviders({ children }: { children: React.ReactNode }) { useEffect(() => { @@ -20,8 +24,8 @@ export function ClientProviders({ children }: { children: React.ReactNode }) { }, []); useEffect(() => { - void invoke<{ ui_sounds?: boolean }>("get_app_settings") - .then((settings) => setUiSoundsEnabled(settings.ui_sounds ?? true)) + void invoke("get_app_settings") + .then(applySoundSettings) .catch(() => {}); return installUiSounds(); }, []); diff --git a/src/components/cookie-bot-enrol-dialog.tsx b/src/components/cookie-bot-enrol-dialog.tsx index 6b47e6bd..b3d80edd 100644 --- a/src/components/cookie-bot-enrol-dialog.tsx +++ b/src/components/cookie-bot-enrol-dialog.tsx @@ -1026,7 +1026,10 @@ export function CookieBotEnrolDialog({ void navigator.clipboard .writeText(conflict.email) .then(() => { - showSuccessToast(t("cookieBot.conflict.emailCopied")); + showSuccessToast( + t("cookieBot.conflict.emailCopied"), + { sound: "copy" }, + ); }) .catch(() => { showErrorToast(t("cookieBot.conflict.copyFailed")); diff --git a/src/components/cookie-paste-panel.tsx b/src/components/cookie-paste-panel.tsx index e4860f06..9f330f4b 100644 --- a/src/components/cookie-paste-panel.tsx +++ b/src/components/cookie-paste-panel.tsx @@ -19,6 +19,7 @@ import { TableRow, } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; +import { playUiSound } from "@/lib/ui-sounds"; import type { CookieAnalysis, CookieIssue, @@ -142,6 +143,7 @@ export function CookiePastePanel({ const file = event.dataTransfer.files[0]; if (!file) return; event.preventDefault(); + playUiSound("drop"); readFile(file); }, [readFile], diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 5ebc1b32..e14dadd1 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -104,6 +104,7 @@ import { rotateProxyIps } from "@/lib/proxy-rotation"; import type { RemoteHandoffState } from "@/lib/remote-sessions"; import { startPagesFromText } from "@/lib/start-pages"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { playUiSound } from "@/lib/ui-sounds"; import { cn } from "@/lib/utils"; import type { BrowserProfile, @@ -691,6 +692,7 @@ function ProfileInfoContents({ const handleCopyId = async () => { try { await navigator.clipboard.writeText(profile.id); + playUiSound("copy"); setCopied(true); setTimeout(() => { setCopied(false); diff --git a/src/components/proxy-check-button.tsx b/src/components/proxy-check-button.tsx index 3002a83a..03199514 100644 --- a/src/components/proxy-check-button.tsx +++ b/src/components/proxy-check-button.tsx @@ -23,6 +23,7 @@ import { import { translateBackendError } from "@/lib/backend-errors"; import { formatRelativeTime } from "@/lib/flag-utils"; import { runProxyCheck, useProxyCheck } from "@/lib/proxy-check-store"; +import { playUiSound } from "@/lib/ui-sounds"; import { cn } from "@/lib/utils"; import type { ProxyCheckHistoryEntry, StoredProxy, UdpSupport } from "@/types"; @@ -276,6 +277,7 @@ export function ProxyCheckButton({ } catch { return; } + playUiSound("copy"); setCopied(true); if (copiedTimer.current !== null) { window.clearTimeout(copiedTimer.current); diff --git a/src/components/proxy-export-dialog.tsx b/src/components/proxy-export-dialog.tsx index 525e9b2e..e7138fb3 100644 --- a/src/components/proxy-export-dialog.tsx +++ b/src/components/proxy-export-dialog.tsx @@ -16,6 +16,7 @@ import { import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { playUiSound } from "@/lib/ui-sounds"; import { RippleButton } from "./ui/ripple"; interface ProxyExportDialogProps { @@ -53,6 +54,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) { const handleCopyToClipboard = useCallback(async () => { try { await navigator.clipboard.writeText(exportContent); + playUiSound("copy"); setCopied(true); toast.success(t("toasts.success.copied")); setTimeout(() => { diff --git a/src/components/proxy-import-dialog.tsx b/src/components/proxy-import-dialog.tsx index e9c1dec7..98476906 100644 --- a/src/components/proxy-import-dialog.tsx +++ b/src/components/proxy-import-dialog.tsx @@ -25,6 +25,7 @@ import { resolveAmbiguousProxyLine, } from "@/lib/proxy-string"; import { canonicalProxyType } from "@/lib/proxy-type"; +import { playUiSound } from "@/lib/ui-sounds"; import type { ParsedProxyLine, ProxyImportResult, @@ -209,6 +210,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) { ); if (validFile) { + playUiSound("drop"); handleFileRead(validFile); } else { toast.error(t("proxies.importDialog.wrongFileType")); diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 16453f66..07e1ee81 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -3,77 +3,103 @@ import { invoke } from "@tauri-apps/api/core"; import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager"; import { openUrl } from "@tauri-apps/plugin-opener"; -import Color from "color"; -import { motion, useReducedMotion } from "motion/react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { motion } from "motion/react"; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; -import { BsCamera, BsMic } from "react-icons/bs"; +import { + LuArrowUpRight, + LuBriefcaseBusiness, + LuCamera, + LuCopy, + LuEyeOff, + LuGlobe, + LuLanguages, + LuLockKeyhole, + LuMic, + LuMonitor, + LuPalette, + LuPlug, + LuSearch, + LuShieldBan, + LuShieldCheck, + LuTrash2, + LuVolume2, + LuWrench, + LuX, +} from "react-icons/lu"; import { DnsBlocklistDialog } from "@/components/dns-blocklist-dialog"; import { LoadingButton } from "@/components/loading-button"; -import { useTheme } from "@/components/theme-provider"; -import { AnimatedSwitch } from "@/components/ui/animated-switch"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; +import { EncryptionSettings } from "@/components/settings/encryption-settings"; import { - ColorPicker, - ColorPickerAlpha, - ColorPickerEyeDropper, - ColorPickerFormat, - ColorPickerHue, - ColorPickerOutput, - ColorPickerSelection, -} from "@/components/ui/color-picker"; -import { ConfirmationMark } from "@/components/ui/confirmation-mark"; + ChoiceCard, + ChoiceGroup, + RowIcon, + SegmentedControl, + SettingBlock, + SettingRow, + SettingsCard, + SettingsSection, + useSettingsMotion, +} from "@/components/settings/settings-primitives"; +import { + type PendingChange, + SettingsSaveBar, +} from "@/components/settings/settings-save-bar"; +import { + SoundSettings, + soundDraft, +} from "@/components/settings/sound-settings"; +import { + ThemeColorEditor, + ThemeModePicker, + ThemePresetGallery, +} from "@/components/settings/theme-settings"; +import { useTheme } from "@/components/theme-provider"; +import { AnimatedDisclosureContent } from "@/components/ui/animated-disclosure"; +import { AnimatedSwitch } from "@/components/ui/animated-switch"; +import { Button } from "@/components/ui/button"; +import { CopyToClipboard } from "@/components/ui/copy-to-clipboard"; import { Dialog, DialogContent, - DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; import { ScrollStrip } from "@/components/ui/scroll-strip"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { StatusLight } from "@/components/ui/settings-panel"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { useCommercialTrial } from "@/hooks/use-commercial-trial"; -import { useInputModality } from "@/hooks/use-input-modality"; import { useLanguage } from "@/hooks/use-language"; import type { PermissionType } from "@/hooks/use-permissions"; import { usePermissions } from "@/hooks/use-permissions"; +import type { SupportedLanguage } from "@/i18n"; import { translateBackendError } from "@/lib/backend-errors"; import { effectivePlanOf } from "@/lib/entitlements"; -import { MOTION_SPRING_POSITION } from "@/lib/motion"; +import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion"; import { applyThemeColors, clearThemeColors, getThemeByColors, getThemeById, normalizeThemeColors, - THEME_VARIABLES, - THEMES, withThemeTransition, } from "@/lib/themes"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; -import { setUiSoundsEnabled } from "@/lib/ui-sounds"; +import { + applySoundSettings, + type SoundSettings as SoundFields, +} from "@/lib/ui-sounds"; import { cn } from "@/lib/utils"; import type { SetDefaultBrowserOutcome } from "@/types"; -import { RippleButton } from "./ui/ripple"; -interface AppSettings { +interface AppSettings extends SoundFields { set_as_default_browser: boolean; theme: string; custom_theme?: Record; @@ -87,25 +113,109 @@ interface AppSettings { require_route_for_launch?: boolean; trash_retention_days?: number; record_traffic_domains?: boolean; - ui_sounds?: boolean; } /** The retention choices offered, in days. 0 turns the trash off. */ const TRASH_RETENTION_DAYS = [0, 1, 7, 14, 30, 90] as const; const DEFAULT_TRASH_RETENTION_DAYS = 30; +/** The commercial trial length, to draw how much of it is left. */ +const TRIAL_DAYS = 14; interface CustomThemeState { selectedThemeId: string | null; colors: Record; } -interface PermissionInfo { - permission_type: PermissionType; - isGranted: boolean; - description: string; -} +const PERMISSION_TYPES: PermissionType[] = ["microphone", "camera"]; -// Version update progress toasts are handled globally via useVersionUpdater +/** Each section's nav id, title, icon and the translation keys search reads. */ +const SECTIONS: Array<{ + id: string; + title: string; + icon: ReactNode; + search: string[]; +}> = [ + { + id: "appearance", + title: "settings.appearance.title", + icon: , + search: ["settings.appearance"], + }, + { + id: "language", + title: "settings.language.title", + icon: , + search: ["settings.language"], + }, + { + id: "sounds", + title: "settings.sounds.title", + icon: , + search: ["settings.sounds"], + }, + { + id: "default", + title: "settings.defaultBrowser.title", + icon: , + search: ["settings.defaultBrowser"], + }, + { + id: "permissions", + title: "settings.permissions.title", + icon: , + search: ["settings.permissions"], + }, + { + id: "integrations", + title: "settings.integrations.title", + icon: , + search: ["settings.integrations"], + }, + { + id: "dns", + title: "dnsBlocklist.title", + icon: , + search: [ + "dnsBlocklist.title", + "dnsBlocklist.settingsDescription", + "settings.dns", + ], + }, + { + id: "encryption", + title: "settings.encryption.title", + icon: , + search: ["settings.encryption"], + }, + { + id: "commercial", + title: "settings.commercial.title", + icon: , + search: ["settings.commercial"], + }, + { + id: "privacy", + title: "settings.privacy.title", + icon: , + search: [ + "settings.privacy", + "settings.trashRetention", + "settings.trashRetentionDescription", + ], + }, + { + id: "advanced", + title: "settings.advanced.title", + icon: , + search: [ + "settings.advanced", + "settings.disableAutoUpdates", + "settings.disableAutoUpdatesDescription", + "settings.keepDecryptedProfilesInRam", + "settings.keepDecryptedProfilesInRamDescription", + ], + }, +]; interface SettingsDialogProps { isOpen: boolean; @@ -152,6 +262,7 @@ export function SettingsDialog({ const [jumpTo, setJumpTo] = useState(initialSection); const sectionsRef = useRef(null); const scrollerRef = useRef(null); + const searchRef = useRef(null); // The section the reader is in, so the nav can point at it. A click pins // its target until the reader scrolls away, because the last sections can // never reach the top of the scroller on their own. @@ -159,26 +270,14 @@ export function SettingsDialog({ const pinnedSectionRef = useRef<{ id: string; scrollTop: number } | null>( null, ); - const reduceMotion = useReducedMotion(); - const inputModality = useInputModality(); - const animateNav = !reduceMotion && inputModality === "pointer"; + const animateNav = useSettingsMotion(); const [isSettingDefault, setIsSettingDefault] = useState(false); const [isClearingCache, setIsClearingCache] = useState(false); const [isClearingTraffic, setIsClearingTraffic] = useState(false); - const [permissions, setPermissions] = useState([]); - const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); const [requestingPermission, setRequestingPermission] = useState(null); const [dnsBlocklistDialogOpen, setDnsBlocklistDialogOpen] = useState(false); const [hasE2ePassword, setHasE2ePassword] = useState(false); - const [e2ePassword, setE2ePassword] = useState(""); - const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState(""); - const [e2eError, setE2eError] = useState(""); - const [isSavingE2e, setIsSavingE2e] = useState(false); - const [isRemovingE2e, setIsRemovingE2e] = useState(false); - const [isVerifyE2eOpen, setIsVerifyE2eOpen] = useState(false); - const [verifyE2ePassword, setVerifyE2ePassword] = useState(""); - const [isVerifyingE2e, setIsVerifyingE2e] = useState(false); // Theme-restore guards: settings load asynchronously, and the dialog can be // unmounted directly by rail navigation (bypassing handleClose). Refs mirror // the loaded state so the unmount cleanup can restore the persisted theme. @@ -218,56 +317,6 @@ export function SettingsDialog({ const [selectedLanguage, setSelectedLanguage] = useState(null); const [originalLanguage, setOriginalLanguage] = useState(null); - const getPermissionIcon = useCallback((type: PermissionType) => { - switch (type) { - case "microphone": - return ; - case "camera": - return ; - } - }, []); - - 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 ( - - {t("common.status.granted")} - - ); - } - return {t("common.status.notGranted")}; - }, - [t], - ); - - const getPermissionDescription = useCallback( - (type: PermissionType) => { - switch (type) { - case "microphone": - return t("settings.permissions.microphoneDescription"); - case "camera": - return t("settings.permissions.cameraDescription"); - } - }, - [t], - ); - // `animate: false` on the restore paths. Opening Settings re-applies the // theme already on screen, so a whole-document cross-fade to an identical // palette animates nothing. Worse, the mount effect below did it twice in a @@ -378,41 +427,6 @@ export function SettingsDialog({ } }, [applyCustomTheme]); - const loadPermissions = useCallback(() => { - setIsLoadingPermissions(true); - try { - if (!isMacOS) { - // On non-macOS platforms, don't show permissions - setPermissions([]); - return; - } - - const permissionList: PermissionInfo[] = [ - { - permission_type: "microphone", - isGranted: isMicrophoneAccessGranted, - description: getPermissionDescription("microphone"), - }, - { - permission_type: "camera", - isGranted: isCameraAccessGranted, - description: getPermissionDescription("camera"), - }, - ]; - - setPermissions(permissionList); - } catch (error) { - console.error("Failed to load permissions:", error); - } finally { - setIsLoadingPermissions(false); - } - }, [ - getPermissionDescription, - isCameraAccessGranted, - isMacOS, - isMicrophoneAccessGranted, - ]); - // The Linux implementation shells out to `which` plus two `xdg-mime query` // calls, and `xdg-mime` is a shell script that forks further. Without this // guard a slow desktop lets the poll below stack one unfinished call on top @@ -553,29 +567,13 @@ export function SettingsDialog({ : settings.custom_theme, }; - console.log("[settings-dialog] Saving settings:", { - theme: settingsToSave.theme, - hasCustomTheme: !!settingsToSave.custom_theme, - customThemeKeys: settingsToSave.custom_theme - ? Object.keys(settingsToSave.custom_theme).length - : 0, - }); - const savedSettings = await invoke("save_app_settings", { settings: settingsToSave, }); - console.log("[settings-dialog] Saved settings response:", { - theme: savedSettings.theme, - hasCustomTheme: !!savedSettings.custom_theme, - customThemeKeys: savedSettings.custom_theme - ? Object.keys(savedSettings.custom_theme).length - : 0, - }); - // Update settings with any generated tokens setSettings(savedSettings); - setUiSoundsEnabled(savedSettings.ui_sounds ?? true); + applySoundSettings(savedSettings); settingsToSave = savedSettings; setOriginalSettings(savedSettings); originalSettingsRef.current = savedSettings; @@ -609,18 +607,7 @@ export function SettingsDialog({ await changeLanguage( selectedLanguage === "system" ? null - : (selectedLanguage as - | "en" - | "es" - | "pt" - | "fr" - | "de" - | "zh" - | "ja" - | "ko" - | "ru" - | "tr" - | "vi"), + : (selectedLanguage as SupportedLanguage), ); setOriginalLanguage(selectedLanguage); } @@ -749,10 +736,6 @@ export function SettingsDialog({ console.error(err); }); - if (isMacOS) { - loadPermissions(); - } - // Re-check periodically so the badge follows a change the user made in // their desktop settings. Ten seconds rather than two: on Linux each // check is three subprocesses, and nobody flips their default browser @@ -768,13 +751,7 @@ export function SettingsDialog({ clearInterval(intervalId); }; } - }, [ - isOpen, - isMacOS, - loadPermissions, - checkDefaultBrowserStatus, - loadSettings, - ]); + }, [isOpen, checkDefaultBrowserStatus, loadSettings]); // Initialize language selection when dialog opens or language loads useEffect(() => { @@ -784,32 +761,6 @@ export function SettingsDialog({ } }, [isOpen, currentLanguage, isLanguageLoading]); - // Update permissions when the permission states change - useEffect(() => { - if (isMacOS) { - const permissionList: PermissionInfo[] = [ - { - permission_type: "microphone", - isGranted: isMicrophoneAccessGranted, - description: getPermissionDescription("microphone"), - }, - { - permission_type: "camera", - isGranted: isCameraAccessGranted, - description: getPermissionDescription("camera"), - }, - ]; - setPermissions(permissionList); - } else { - setPermissions([]); - } - }, [ - isMacOS, - isMicrophoneAccessGranted, - isCameraAccessGranted, - getPermissionDescription, - ]); - const draftColors = settings.theme === "custom" ? customThemeState.colors @@ -821,101 +772,120 @@ export function SettingsDialog({ Object.entries(draftColors).some( ([key, value]) => savedColors[key] !== value, ); - const pendingChanges = [ - ...(themeChanged ? [t("settings.appearance.title")] : []), - ...(selectedLanguage !== originalLanguage - ? [t("settings.language.title")] - : []), + const sounds = soundDraft(settings); + const savedSounds = soundDraft(originalSettings); + const flagChanged = (key: keyof AppSettings, fallback = false) => + Boolean(settings[key] ?? fallback) !== + Boolean(originalSettings[key] ?? fallback); + const changes: Array = [ + { + key: "theme", + changed: themeChanged, + label: t("settings.appearance.title"), + section: "appearance", + }, + { + key: "language", + changed: selectedLanguage !== originalLanguage, + label: t("settings.language.title"), + section: "language", + }, ...( [ - ["api_enabled", "settings.integrations.title"], - ["disable_auto_updates", "settings.disableAutoUpdates"], - [ - "keep_decrypted_profiles_in_ram", - "settings.keepDecryptedProfilesInRam", - ], - ["fingerprint_gate_disabled", "settings.privacy.consistencyWarning"], - [ - "vpn_extension_warning_disabled", - "settings.privacy.vpnExtensionWarning", - ], - ["require_route_for_launch", "settings.privacy.requireRoute"], + ["ui_sounds", "settings.sounds.enabled"], + ["ui_sound_volume", "settings.sounds.volume"], + ["ui_sound_pack", "settings.sounds.pack"], + ["ui_sound_interface", "settings.sounds.categories.interface.label"], + ["ui_sound_feedback", "settings.sounds.categories.feedback.label"], + ["ui_sound_events", "settings.sounds.categories.events.label"], ] as const - ) - .filter( - ([key]) => Boolean(settings[key]) !== Boolean(originalSettings[key]), - ) - .map(([, key]) => t(key)), - ...((settings.record_traffic_domains ?? true) !== - (originalSettings.record_traffic_domains ?? true) - ? [t("settings.privacy.recordSites")] - : []), - ...((settings.ui_sounds ?? true) !== (originalSettings.ui_sounds ?? true) - ? [t("settings.appearance.sounds")] - : []), - ...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !== - (originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) - ? [t("settings.trashRetention")] - : []), - ]; - const hasChanges = pendingChanges.length > 0; - const sections = [ - ["appearance", "settings.appearance.title", ["settings.appearance"]], - ["language", "settings.language.title", ["settings.language"]], - ...(!systemInfo?.portable - ? [ - [ - "default", - "settings.defaultBrowser.title", - ["settings.defaultBrowser"], - ], - ] - : []), - ...(isMacOS - ? [ - [ - "permissions", - "settings.permissions.title", - ["settings.permissions"], - ], - ] - : []), - ["integrations", "settings.integrations.title", ["settings.integrations"]], - ["dns", "dnsBlocklist.title", ["dnsBlocklist"]], - ["encryption", "settings.encryption.title", ["settings.encryption"]], - ["commercial", "settings.commercial.title", ["settings.commercial"]], - [ - "privacy", - "settings.privacy.title", - [ - "settings.privacy", - "settings.trashRetention", - "settings.trashRetentionDescription", - ], - ], - [ - "advanced", - "settings.advanced.title", - [ - "settings.advanced", - "settings.disableAutoUpdates", - "settings.disableAutoUpdatesDescription", - "settings.keepDecryptedProfilesInRam", - "settings.keepDecryptedProfilesInRamDescription", - ], - ], - ] as Array<[string, string, string[]]>; + ).map(([key, label]) => ({ + key, + changed: sounds[key] !== savedSounds[key], + label: t(label), + section: "sounds", + })), + { + key: "api_enabled", + changed: flagChanged("api_enabled"), + label: t("settings.integrations.title"), + section: "integrations", + }, + { + key: "require_route_for_launch", + changed: flagChanged("require_route_for_launch"), + label: t("settings.privacy.requireRoute"), + section: "privacy", + }, + { + key: "fingerprint_gate_disabled", + changed: flagChanged("fingerprint_gate_disabled"), + label: t("settings.privacy.consistencyWarning"), + section: "privacy", + }, + { + key: "vpn_extension_warning_disabled", + changed: flagChanged("vpn_extension_warning_disabled"), + label: t("settings.privacy.vpnExtensionWarning"), + section: "privacy", + }, + { + key: "record_traffic_domains", + changed: flagChanged("record_traffic_domains", true), + label: t("settings.privacy.recordSites"), + section: "privacy", + }, + { + key: "trash_retention_days", + changed: + (settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !== + (originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS), + label: t("settings.trashRetention"), + section: "privacy", + }, + { + key: "disable_auto_updates", + changed: flagChanged("disable_auto_updates"), + label: t("settings.disableAutoUpdates"), + section: "advanced", + }, + { + key: "keep_decrypted_profiles_in_ram", + changed: flagChanged("keep_decrypted_profiles_in_ram"), + label: t("settings.keepDecryptedProfilesInRam"), + section: "advanced", + }, + ].filter((change) => change.changed); + const changedKeys = new Set(changes.map((change) => change.key)); + const changedSections = new Set(changes.map((change) => change.section)); + const isChanged = (key: string) => changedKeys.has(key); + + const sections = SECTIONS.filter( + ({ id }) => + (id !== "default" || !systemInfo?.portable) && + (id !== "permissions" || isMacOS), + ); const needle = search.trim().toLocaleLowerCase(); - const matchingSections = sections.filter(([, , keys]) => { - const text = keys + const matchingSections = sections.filter(({ title, search: keys }) => { + const text = [title, ...keys] .map((key) => JSON.stringify(t(key, { returnObjects: true }))) .join(" ") .toLocaleLowerCase(); return needle.split(/\s+/).every((word) => text.includes(word)); }); const sectionVisible = (id: string) => - matchingSections.some(([key]) => key === id); - const visibleSectionIds = matchingSections.map(([id]) => id).join(","); + matchingSections.some((section) => section.id === id); + const visibleSectionIds = matchingSections.map(({ id }) => id).join(","); + const sectionProps = (id: string) => { + const section = SECTIONS.find((entry) => entry.id === id); + return { + id, + icon: section?.icon, + title: t(section?.title ?? id), + active: activeSection === id, + hidden: !sectionVisible(id), + }; + }; const syncActiveSection = useCallback(() => { const scroller = scrollerRef.current; @@ -971,33 +941,81 @@ export function SettingsDialog({ setJumpTo(null); }, [jumpTo]); + const jump = (id: string) => { + setSearch(""); + setJumpTo(id); + }; + + const pickTheme = (value: string) => { + updateSetting("theme", value); + if (value === "custom" && settings.theme !== "custom") { + const colors = + settings.custom_theme ?? getThemeById("tokyo-night")?.colors ?? {}; + setCustomThemeState({ + selectedThemeId: getThemeByColors(colors)?.id ?? null, + colors, + }); + } + }; + + const plan = cloudUser ? effectivePlanOf(cloudUser) : "free"; + const appliesImmediately = ( + + {t("appFeedback.appliesImmediately")} + + ); + const systemLine = systemInfo + ? `Donut Browser ${systemInfo.app_version} · ${systemInfo.os} ${systemInfo.arch}${systemInfo.portable ? " (portable)" : ""}` + : ""; + return ( <> - + {!subPage && ( {t("settings.title")} )} -
- setSearch(event.target.value)} - onKeyDown={(event) => { - // Escape empties the filter before it can close the page. - if (event.key === "Escape" && search) { - event.preventDefault(); - event.stopPropagation(); - setSearch(""); - } - }} - placeholder={t("appFeedback.searchSettings")} - aria-label={t("appFeedback.searchSettings")} - className="h-8" - /> +
+
+
setDnsBlocklistDialogOpen(false)} /> - { - if (!isVerifyingE2e) { - setIsVerifyE2eOpen(open); - if (!open) setVerifyE2ePassword(""); - } - }} - > - - - - {t("settings.encryption.validateDialog.title")} - - - {t("settings.encryption.validateDialog.description")} - - -
- setVerifyE2ePassword(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && verifyE2ePassword.length > 0) { - e.preventDefault(); - void (async () => { - setIsVerifyingE2e(true); - try { - const ok = await invoke("verify_e2e_password", { - password: verifyE2ePassword, - }); - if (ok) { - showSuccessToast( - t("settings.encryption.validateDialog.matchToast"), - ); - setIsVerifyE2eOpen(false); - setVerifyE2ePassword(""); - } else { - showErrorToast( - t("settings.encryption.validateDialog.mismatchToast"), - ); - } - } catch (error) { - showErrorToast(String(error)); - } finally { - setIsVerifyingE2e(false); - } - })(); - } - }} - /> -
- - - { - setIsVerifyingE2e(true); - try { - const ok = await invoke("verify_e2e_password", { - password: verifyE2ePassword, - }); - if (ok) { - showSuccessToast( - t("settings.encryption.validateDialog.matchToast"), - ); - setIsVerifyE2eOpen(false); - setVerifyE2ePassword(""); - } else { - showErrorToast( - t("settings.encryption.validateDialog.mismatchToast"), - ); - } - } catch (error) { - showErrorToast(String(error)); - } finally { - setIsVerifyingE2e(false); - } - }} - > - {t("settings.encryption.validateDialog.submit")} - - -
-
); } + +function SwitchRow({ + id, + label, + description, + changed, + checked, + onCheckedChange, +}: { + id: string; + label: string; + description: string; + changed: boolean; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + return ( + + onCheckedChange(v === true)} + /> + + ); +} + +function StatusChip({ + tone, + children, +}: { + tone: "success"; + children: ReactNode; +}) { + return ( + + + {children} + + ); +} + +function SectionNote({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +/** How much of the commercial trial is left, as a bar that fills in. */ +function TrialMeter({ remainingSeconds }: { remainingSeconds: number }) { + const animate = useSettingsMotion(); + const share = Math.min( + 1, + Math.max(0, remainingSeconds / (TRIAL_DAYS * 24 * 60 * 60)), + ); + return ( + + ); +} diff --git a/src/components/settings/encryption-settings.tsx b/src/components/settings/encryption-settings.tsx new file mode 100644 index 00000000..cc7b4fa1 --- /dev/null +++ b/src/components/settings/encryption-settings.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuLock, LuLockKeyhole } from "react-icons/lu"; +import { LoadingButton } from "@/components/loading-button"; +import { + RowIcon, + RuleCheck, + SettingRow, + SettingsCard, +} from "@/components/settings/settings-primitives"; +import { AnimatedDisclosureContent } from "@/components/ui/animated-disclosure"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; + +const MIN_PASSWORD_LENGTH = 8; + +/** The sync encryption password: set, check, change or remove it. */ +export function EncryptionSettings({ + canUseEncryption, + hasPassword, + onHasPasswordChange, +}: { + canUseEncryption: boolean; + hasPassword: boolean; + onHasPasswordChange: (hasPassword: boolean) => void; +}) { + const { t } = useTranslation(); + const [changing, setChanging] = useState(false); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + const [isVerifyOpen, setIsVerifyOpen] = useState(false); + const [verifyPassword, setVerifyPassword] = useState(""); + const [isVerifying, setIsVerifying] = useState(false); + + const longEnough = password.length >= MIN_PASSWORD_LENGTH; + const matches = password.length > 0 && password === confirm; + const showForm = !hasPassword || changing; + + // Every path awaits the rollover so a failed re-sync is shown, not lost. + const rollover = useCallback(async (context: string) => { + try { + await invoke("rollover_encryption_for_all_entities"); + } catch (rolloverErr) { + console.error(`Rollover after ${context} failed:`, rolloverErr); + showErrorToast(String(rolloverErr)); + } + }, []); + + const savePassword = async () => { + if (!longEnough) { + setError(t("settings.encryption.passwordTooShort")); + return; + } + if (!matches) { + setError(t("settings.encryption.passwordMismatch")); + return; + } + setIsSaving(true); + try { + await invoke("set_e2e_password", { password }); + onHasPasswordChange(true); + setChanging(false); + setPassword(""); + setConfirm(""); + await rollover("password set"); + showSuccessToast(t("settings.encryption.passwordSaved")); + } catch (err) { + showErrorToast(String(err)); + } finally { + setIsSaving(false); + } + }; + + const removePassword = async () => { + setIsRemoving(true); + try { + await invoke("delete_e2e_password"); + onHasPasswordChange(false); + setChanging(false); + await rollover("password removal"); + showSuccessToast(t("settings.encryption.removed")); + } catch (err) { + showErrorToast(String(err)); + } finally { + setIsRemoving(false); + } + }; + + const verify = async () => { + setIsVerifying(true); + try { + const ok = await invoke("verify_e2e_password", { + password: verifyPassword, + }); + if (ok) { + showSuccessToast(t("settings.encryption.validateDialog.matchToast")); + setIsVerifyOpen(false); + setVerifyPassword(""); + } else { + showErrorToast(t("settings.encryption.validateDialog.mismatchToast")); + } + } catch (err) { + showErrorToast(String(err)); + } finally { + setIsVerifying(false); + } + }; + + if (!canUseEncryption) { + return ( + + + + + } + label={t("settings.encryption.noPassword")} + description={t("settings.encryption.requiresProOrOwner")} + /> + + ); + } + + return ( + <> + + + {hasPassword ? : } + + } + label={ + hasPassword + ? t("settings.encryption.passwordSetDescription") + : t("settings.encryption.noPassword") + } + description={t("settings.encryption.description")} + > + {hasPassword && ( + <> + + + { + void removePassword(); + }} + > + {t("settings.encryption.removePassword")} + + + )} + + +
{ + event.preventDefault(); + void savePassword(); + }} + > +
+ { + setPassword(event.target.value); + setError(""); + }} + /> + { + setConfirm(event.target.value); + setError(""); + }} + /> +
+
+
    + + +
+
+ {changing && ( + + )} + + {t("settings.encryption.setPassword")} + +
+
+ {error && ( +

+ {error} +

+ )} +
+
+
+ + { + if (isVerifying) return; + setIsVerifyOpen(open); + if (!open) setVerifyPassword(""); + }} + > + + + + {t("settings.encryption.validateDialog.title")} + + + {t("settings.encryption.validateDialog.description")} + + +
{ + event.preventDefault(); + if (verifyPassword.length > 0) void verify(); + }} + > + setVerifyPassword(event.target.value)} + /> +
+ + + + {t("settings.encryption.validateDialog.submit")} + + +
+
+ + ); +} diff --git a/src/components/settings/settings-primitives.tsx b/src/components/settings/settings-primitives.tsx new file mode 100644 index 00000000..60cb58e4 --- /dev/null +++ b/src/components/settings/settings-primitives.tsx @@ -0,0 +1,475 @@ +"use client"; + +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + createContext, + type KeyboardEvent, + type ReactNode, + useContext, + useId, +} from "react"; +import { useTranslation } from "react-i18next"; +import { Label } from "@/components/ui/label"; +import { useInputModality } from "@/hooks/use-input-modality"; +import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion"; +import { cn } from "@/lib/utils"; + +/** Layout springs run only for pointer users who allow motion. */ +export function useSettingsMotion() { + const reduceMotion = useReducedMotion(); + const inputModality = useInputModality(); + return !reduceMotion && inputModality === "pointer"; +} + +/** A nav target: an icon that lights up while the reader is in it, and a title. */ +export function SettingsSection({ + id, + icon, + title, + active, + hidden, + aside, + children, +}: { + id: string; + icon: ReactNode; + title: string; + active: boolean; + hidden: boolean; + aside?: ReactNode; + children: ReactNode; +}) { + const titleId = `settings-section-${id}`; + return ( + + ); +} + +/** A quiet card that holds rows separated by hairlines. */ +export function SettingsCard({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +/** Marks a setting whose draft differs from what is saved. */ +export function ChangedDot({ show }: { show: boolean }) { + const { t } = useTranslation(); + const animate = useSettingsMotion(); + return ( + + {show && ( + + )} + + ); +} + +/** + * One setting: what it is on the left, its control on the right. The control + * drops under the text only when the two cannot share a line. + */ +export function SettingRow({ + label, + description, + htmlFor, + changed = false, + icon, + children, + className, + slot, +}: { + label: ReactNode; + description?: ReactNode; + htmlFor?: string; + changed?: boolean; + icon?: ReactNode; + children?: ReactNode; + className?: string; + slot?: string; +}) { + return ( +
+
+ {icon} +
+ + {description && ( +

+ {description} +

+ )} +
+
+ {children && ( +
+ {children} +
+ )} +
+ ); +} + +/** A setting whose control needs the full width, under its text. */ +export function SettingBlock({ + label, + description, + changed = false, + children, +}: { + label: ReactNode; + description?: ReactNode; + changed?: boolean; + children: ReactNode; +}) { + return ( +
+
+ + {label} + + + {description && ( +

+ {description} +

+ )} +
+ {children} +
+ ); +} + +/** A tinted square for a row's icon; `tone` colors it by state. */ +export function RowIcon({ + children, + tone = "muted", +}: { + children: ReactNode; + tone?: "muted" | "primary" | "success" | "warning"; +}) { + return ( + + ); +} + +interface ChoiceContextValue { + value: string; + select: (value: string) => void; + indicatorId: string; + animate: boolean; +} + +const ChoiceContext = createContext(null); + +function useChoice() { + const context = useContext(ChoiceContext); + if (!context) throw new Error("ChoiceCard must be inside a ChoiceGroup"); + return context; +} + +/** Arrow keys move the choice, as in any radio group. */ +function moveChoice(event: KeyboardEvent) { + const step = ( + { + ArrowRight: 1, + ArrowDown: 1, + ArrowLeft: -1, + ArrowUp: -1, + } as Record + )[event.key]; + if (!step) return; + const items = [ + ...event.currentTarget.querySelectorAll( + '[role="radio"]:not(:disabled)', + ), + ]; + const index = items.indexOf(document.activeElement as HTMLButtonElement); + if (index < 0) return; + event.preventDefault(); + const next = items[(index + step + items.length) % items.length]; + next.focus(); + next.click(); +} + +/** + * A radio group of cards. The selection ring slides from the old card to the + * new one, so the eye follows the change. + */ +export function ChoiceGroup({ + value, + onValueChange, + label, + className, + children, + slot, +}: { + value: string; + onValueChange: (value: string) => void; + label: string; + className?: string; + children: ReactNode; + slot?: string; +}) { + const indicatorId = useId(); + const animate = useSettingsMotion(); + return ( + +
+ {children} +
+
+ ); +} + +export function ChoiceCard({ + value, + children, + className, + slot, + label, +}: { + value: string; + children: ReactNode; + className?: string; + slot?: string; + label?: string; +}) { + const { value: selectedValue, select, indicatorId, animate } = useChoice(); + const selected = selectedValue === value; + return ( + + ); +} + +/** + * A row of short options with a pill that slides to the chosen one, the same + * motion as the section nav. + */ +export function SegmentedControl({ + value, + onValueChange, + options, + label, + id, + slot, +}: { + value: T; + onValueChange: (value: T) => void; + options: ReadonlyArray<{ value: T; label: string }>; + label: string; + id?: string; + slot?: string; +}) { + const indicatorId = useId(); + const animate = useSettingsMotion(); + return ( +
+ {options.map((option) => { + const selected = option.value === value; + return ( + + ); + })} +
+ ); +} + +/** A check that draws itself in when `done` turns true. */ +export function RuleCheck({ done, label }: { done: boolean; label: string }) { + const animate = useSettingsMotion(); + return ( +
  • + + {label} +
  • + ); +} diff --git a/src/components/settings/settings-save-bar.tsx b/src/components/settings/settings-save-bar.tsx new file mode 100644 index 00000000..7a3d73fd --- /dev/null +++ b/src/components/settings/settings-save-bar.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { motion } from "motion/react"; +import { useTranslation } from "react-i18next"; +import { LuChevronUp, LuCornerDownRight } from "react-icons/lu"; +import { LoadingButton } from "@/components/loading-button"; +import { useSettingsMotion } from "@/components/settings/settings-primitives"; +import { Button } from "@/components/ui/button"; +import { ConfirmationMark } from "@/components/ui/confirmation-mark"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +export interface PendingChange { + label: string; + section: string; +} + +/** + * The page's save state. It tints when there is something to save, and the + * review list jumps to the section of each change. + */ +export function SettingsSaveBar({ + pendingChanges, + isLoading, + isSaving, + saved, + saveError, + onSave, + onDiscard, + onJump, + onCancel, +}: { + pendingChanges: PendingChange[]; + isLoading: boolean; + isSaving: boolean; + saved: boolean; + saveError: string | null; + onSave: () => void; + onDiscard: () => void; + onJump: (section: string) => void; + /** Shown instead of Discard when Settings is a modal. */ + onCancel?: () => void; +}) { + const { t } = useTranslation(); + const animate = useSettingsMotion(); + const count = pendingChanges.length; + const hasChanges = count > 0; + + const status = isLoading + ? t("common.buttons.loading") + : isSaving + ? t("common.buttons.saving") + : hasChanges + ? t("appFeedback.pendingChanges", { count }) + : saved + ? t("common.buttons.saved") + : t("appFeedback.noChanges"); + + return ( +
    +
    + {saveError ? ( +

    + {saveError} +

    + ) : ( +

    + {hasChanges ? ( +

    + )} + + {hasChanges && !saveError && ( + + + + + +
      + {pendingChanges.map((change) => ( +
    • + +
    • + ))} +
    +
    +
    + )} + +
    + {onCancel ? ( + + ) : ( + + )} + + {t("common.buttons.saveSettings")} + +
    +
    +
    + ); +} diff --git a/src/components/settings/sound-settings.tsx b/src/components/settings/sound-settings.tsx new file mode 100644 index 00000000..9c9e9633 --- /dev/null +++ b/src/components/settings/sound-settings.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { motion } from "motion/react"; +import { Slider as SliderPrimitive } from "radix-ui"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuPlay } from "react-icons/lu"; +import { + ChoiceCard, + ChoiceGroup, + SettingBlock, + SettingRow, + useSettingsMotion, +} from "@/components/settings/settings-primitives"; +import { AnimatedDisclosureContent } from "@/components/ui/animated-disclosure"; +import { AnimatedSwitch } from "@/components/ui/animated-switch"; +import { Button } from "@/components/ui/button"; +import { MOTION_EASE_OUT } from "@/lib/motion"; +import { + DEFAULT_SOUND_PACK, + DEFAULT_SOUND_VOLUME, + previewUiSound, + SOUND_CATEGORIES, + SOUND_PACKS, + type SoundSettings, + type UiSound, +} from "@/lib/ui-sounds"; +import { cn } from "@/lib/utils"; + +/** The sound settings with their saved defaults filled in. */ +export function soundDraft(settings: SoundSettings): Required { + return { + ui_sounds: settings.ui_sounds ?? true, + ui_sound_volume: settings.ui_sound_volume ?? DEFAULT_SOUND_VOLUME, + ui_sound_interface: settings.ui_sound_interface ?? true, + ui_sound_feedback: settings.ui_sound_feedback ?? true, + ui_sound_events: settings.ui_sound_events ?? true, + ui_sound_pack: settings.ui_sound_pack ?? DEFAULT_SOUND_PACK, + }; +} + +const CATEGORY_KEYS = { + interface: "ui_sound_interface", + feedback: "ui_sound_feedback", + events: "ui_sound_events", +} as const; + +/** + * A speaker whose waves draw out when sound turns on and fold away when it + * turns off, with a cross in their place. + */ +function SpeakerGlyph({ on }: { on: boolean }) { + const animate = useSettingsMotion(); + const transition = (delay: number) => + animate + ? { duration: 0.28, delay, ease: MOTION_EASE_OUT } + : { duration: 0 }; + return ( + + ); +} + +/** Four bars that jump once each time `beat` changes, like a level meter. */ +function Bars({ beat, active }: { beat: number; active: boolean }) { + const animate = useSettingsMotion(); + return ( + + ); +} + +function VolumeSlider({ + value, + label, + onChange, + onCommit, +}: { + value: number; + label: string; + onChange: (value: number) => void; + onCommit: (value: number) => void; +}) { + return ( +
    + onChange(next)} + onValueCommit={([next]) => onCommit(next)} + > + + + + + + + {value}% + +
    + ); +} + +/** Interface sounds: on or off, how loud, which set, and which kinds. */ +export function SoundSettings({ + draft, + onChange, + isChanged, +}: { + draft: Required; + onChange: (patch: Partial) => void; + isChanged: (key: keyof SoundSettings) => boolean; +}) { + const { t } = useTranslation(); + const [beats, setBeats] = useState>({}); + const play = (sound: UiSound, next: Partial, beat: string) => { + previewUiSound(sound, { ...draft, ...next }); + setBeats((prev) => ({ ...prev, [beat]: (prev[beat] ?? 0) + 1 })); + }; + const sample = SOUND_CATEGORIES[0].sample; + + return ( + <> + } + > + onChange({ ui_sounds: v === true })} + /> + + + + onChange({ ui_sound_volume: value })} + onCommit={(value) => + play(sample, { ui_sound_volume: value }, "volume") + } + /> + + + {/* A pack card plays its own preview, so the press stays silent. */} +
    + { + onChange({ ui_sound_pack: id }); + play(sample, { ui_sound_pack: id }, id); + }} + label={t("settings.sounds.pack")} + slot="settings-sound-pack" + className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-1.5" + > + {SOUND_PACKS.map(({ id }) => ( + + + + {t(`settings.sounds.packs.${id}.label`)} + + + {t(`settings.sounds.packs.${id}.description`)} + + + + + ))} + +
    +
    + {SOUND_CATEGORIES.map((category) => { + const key = CATEGORY_KEYS[category.id]; + const id = `settings-sound-${category.id}`; + return ( + + + onChange({ [key]: v === true })} + /> + + ); + })} +
    + + ); +} diff --git a/src/components/settings/theme-settings.tsx b/src/components/settings/theme-settings.tsx new file mode 100644 index 00000000..f8e9ff36 --- /dev/null +++ b/src/components/settings/theme-settings.tsx @@ -0,0 +1,292 @@ +"use client"; + +import Color from "color"; +import type { CSSProperties, ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { LuMonitor, LuMoon, LuPalette, LuPipette, LuSun } from "react-icons/lu"; +import { + ChoiceCard, + ChoiceGroup, +} from "@/components/settings/settings-primitives"; +import { + ColorPicker, + ColorPickerAlpha, + ColorPickerEyeDropper, + ColorPickerFormat, + ColorPickerHue, + ColorPickerOutput, + ColorPickerSelection, +} from "@/components/ui/color-picker"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { getThemeByColors, THEME_VARIABLES, THEMES } from "@/lib/themes"; +import { cn } from "@/lib/utils"; + +/** + * A small drawing of the app in one palette: the rail, a header with its + * primary button, and a few profile rows. `scheme` scopes the built-in light + * or dark palette; `colors` scopes a preset's. + */ +export function ThemePreview({ + scheme, + colors, + className, +}: { + scheme?: "light" | "dark"; + colors?: Record; + className?: string; +}) { + return ( + + ); +} + +function SystemPreview() { + return ( +
    + + +
    + ); +} + +function CardCaption({ + icon, + children, +}: { + icon?: ReactNode; + children: ReactNode; +}) { + return ( + + {icon} + {children} + + ); +} + +const CARD_CLASS = + "p-1.5 hover:bg-foreground/4 data-[state=unchecked]:hover:-translate-y-px"; + +/** Light, Dark, System and Custom, each drawn in its own palette. */ +export function ThemeModePicker({ + value, + onValueChange, + customColors, +}: { + value: string; + onValueChange: (value: string) => void; + customColors: Record; +}) { + const { t } = useTranslation(); + return ( + + + + }> + {t("settings.appearance.light")} + + + + + }> + {t("settings.appearance.dark")} + + + + + }> + {t("settings.appearance.system")} + + + + + }> + {t("common.labels.custom")} + + + + ); +} + +/** Every preset drawn in its own colors, plus the user's own palette. */ +export function ThemePresetGallery({ + selectedId, + colors, + onSelect, +}: { + selectedId: string | null; + colors: Record; + onSelect: (id: string | null) => void; +}) { + const { t } = useTranslation(); + return ( + onSelect(value === "custom" ? null : value)} + label={t("settings.appearance.themePreset")} + slot="settings-theme-presets" + className="grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-1.5" + > + {THEMES.map((theme) => ( + + + {theme.name} + + ))} + + + }> + {t("settings.appearance.yourOwn")} + + + + ); +} + +/** Every theme color as a chip that opens a picker. */ +export function ThemeColorEditor({ + colors, + onChange, +}: { + colors: Record; + onChange: (next: { + colors: Record; + presetId: string | null; + }) => void; +}) { + return ( +
    + {THEME_VARIABLES.map(({ key, label }) => { + const value = colors[key] ?? "#000000"; + return ( + + + + + + { + const next = { + ...colors, + [key]: Color({ r, g, b }).alpha(a).hexa(), + }; + onChange({ + colors: next, + presetId: getThemeByColors(next)?.id ?? null, + }); + }} + > + +
    + +
    + + +
    +
    +
    + + +
    +
    +
    +
    + ); + })} +
    + ); +} diff --git a/src/components/trash-page.tsx b/src/components/trash-page.tsx index b41d8f34..fffa13a2 100644 --- a/src/components/trash-page.tsx +++ b/src/components/trash-page.tsx @@ -130,6 +130,7 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) { showSuccessToast(t("trash.restored", { name: restored.name }), { description: syncStayedOff ? t("trash.restoredSyncOff") : undefined, duration: syncStayedOff ? 10000 : undefined, + sound: "restore", }); } catch (err: unknown) { console.error("Failed to restore trashed profile:", err); @@ -149,7 +150,9 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) { setBusyId(entry.id); try { await invoke("purge_trashed_profile", { profileId: entry.id }); - showSuccessToast(t("trash.deletedForever", { name: entry.name })); + showSuccessToast(t("trash.deletedForever", { name: entry.name }), { + sound: "trash", + }); setEntryToPurge(null); } catch (err: unknown) { console.error("Failed to purge trashed profile:", err); @@ -167,7 +170,9 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) { setIsEmptying(true); try { const removed = await invoke("empty_trash"); - showSuccessToast(t("trash.emptied", { count: removed })); + showSuccessToast(t("trash.emptied", { count: removed }), { + sound: "trash", + }); setEmptyConfirmOpen(false); } catch (err: unknown) { console.error("Failed to empty trash:", err); diff --git a/src/components/ui/copy-to-clipboard.tsx b/src/components/ui/copy-to-clipboard.tsx index 42fff937..90e7d767 100644 --- a/src/components/ui/copy-to-clipboard.tsx +++ b/src/components/ui/copy-to-clipboard.tsx @@ -31,7 +31,9 @@ export function CopyToClipboard({ try { await navigator.clipboard.writeText(text); setCopied(true); - showSuccessToast(successMessage ?? t("toasts.success.copied")); + showSuccessToast(successMessage ?? t("toasts.success.copied"), { + sound: "copy", + }); setTimeout(() => { setCopied(false); }, 2000); diff --git a/src/components/vpn-import-dialog.tsx b/src/components/vpn-import-dialog.tsx index c6d90ed9..fab57b72 100644 --- a/src/components/vpn-import-dialog.tsx +++ b/src/components/vpn-import-dialog.tsx @@ -21,6 +21,7 @@ import { RippleButton } from "@/components/ui/ripple"; import { ScrollArea } from "@/components/ui/scroll-area"; import { StepTransition } from "@/components/ui/step-transition"; import { getCurrentOS } from "@/lib/browser-utils"; +import { playUiSound } from "@/lib/ui-sounds"; import type { VpnImportResult, VpnType } from "@/types"; interface VpnImportDialogProps { @@ -149,6 +150,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) { const files = Array.from(e.dataTransfer.files); const validFile = files.find((f) => f.name.endsWith(".conf")); if (validFile) { + playUiSound("drop"); handleFileRead(validFile); } else { toast.error(t("vpns.import.wrongFileType")); diff --git a/src/hooks/use-agent-console.ts b/src/hooks/use-agent-console.ts index c15efc82..afe90612 100644 --- a/src/hooks/use-agent-console.ts +++ b/src/hooks/use-agent-console.ts @@ -30,6 +30,7 @@ import { } from "@/lib/agent-console"; import { translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showToast } from "@/lib/toast-utils"; +import { playUiSound } from "@/lib/ui-sounds"; interface ConsoleData { loaded: boolean; @@ -123,6 +124,7 @@ export function useAgentConsole({ onOpenPage: open, } = optionsRef.current; const focused = typeof document !== "undefined" && document.hasFocus(); + playUiSound("attention"); if (!visible || !focused) { const session = sessionsRef.current.find( (entry) => entry.session_id === item.session_id, diff --git a/src/hooks/use-app-update-notifications.tsx b/src/hooks/use-app-update-notifications.tsx index 22e13c49..3b5352c5 100644 --- a/src/hooks/use-app-update-notifications.tsx +++ b/src/hooks/use-app-update-notifications.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { AppUpdateToast } from "@/components/app-update-toast"; import { translateBackendError } from "@/lib/backend-errors"; import { showToast } from "@/lib/toast-utils"; +import { playUiSound } from "@/lib/ui-sounds"; import type { AppUpdateInfo, AppUpdateProgress } from "@/types"; export function useAppUpdateNotifications() { @@ -152,6 +153,7 @@ export function useAppUpdateNotifications() { const unlistenReady = listen("app-update-ready", (event) => { console.log("App update ready:", event.payload); + playUiSound("attention"); setUpdateReady(true); setIsUpdating(false); setUpdateProgress(null); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6509d480..27bfe08c 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -38,13 +38,11 @@ }, "status": { "active": "Aktiv", - "inactive": "Inaktiv", "running": "Läuft", "stopped": "Gestoppt", "enabled": "Aktiviert", "disabled": "Deaktiviert", "granted": "Erteilt", - "notGranted": "Nicht erteilt", "connected": "Verbunden", "disconnected": "Getrennt", "synced": "Synchronisiert", @@ -116,35 +114,30 @@ "themeDescription": "Wählen Sie Ihr bevorzugtes Design oder übernehmen Sie die Systemeinstellung. Änderungen am eigenen Design werden erst beim Speichern übernommen.", "themePreset": "Design-Vorlage", "customColors": "Eigene Farben", - "selectTheme": "Design auswählen", - "selectThemePreset": "Design-Vorlage auswählen", "yourOwn": "Eigenes", "light": "Hell", "dark": "Dunkel", - "system": "System", - "sounds": "Oberflächenklänge", - "soundsDescription": "Leise, sanfte Klänge, wenn Sie eine Schaltfläche drücken, einen Schalter umlegen oder eine Aktion gelingt oder fehlschlägt." + "system": "System" }, "language": { "title": "Sprache", "description": "Wählen Sie die bevorzugte Sprache für die Benutzeroberfläche.", "systemDefault": "Systemstandard", - "selectLanguage": "Sprache auswählen", "interface": "Sprache der Oberfläche" }, "defaultBrowser": { "title": "Standardbrowser", "setAsDefault": "Als Standardbrowser festlegen", - "alreadyDefault": "Bereits Standardbrowser", "description": "Als Standardbrowser öffnet Donut Browser Weblinks und lässt Sie wählen, welches Profil verwendet wird.", "setSuccess": "Donut Browser ist jetzt Ihr Standardbrowser", "setFailed": "Der Standardbrowser konnte nicht festgelegt werden", "finishInSystemSettings": "In den Windows-Einstellungen abschließen", - "finishInSystemSettingsDescription": "Donut Browser ist registriert. Die Windows-Einstellungen sind geöffnet: Wählen Sie unter „Webbrowser“ Donut Browser aus, um den Vorgang abzuschließen." + "finishInSystemSettingsDescription": "Donut Browser ist registriert. Die Windows-Einstellungen sind geöffnet: Wählen Sie unter „Webbrowser“ Donut Browser aus, um den Vorgang abzuschließen.", + "isDefault": "Donut Browser ist Ihr Standardbrowser", + "notDefault": "Donut Browser ist nicht Ihr Standardbrowser" }, "permissions": { "title": "Systemberechtigungen", - "loading": "Berechtigungen werden geladen...", "description": "Diese Berechtigungen erlauben Browsern, die aus Donut Browser gestartet werden, den Zugriff auf Systemressourcen. Jede Website fragt trotzdem einzeln nach Ihrer Erlaubnis.", "microphone": "Mikrofon", "microphoneDescription": "Mikrofonzugriff für Browseranwendungen", @@ -155,12 +148,12 @@ "integrations": { "title": "Integrationen", "description": "Konfigurieren Sie die lokale API und MCP (Model Context Protocol) für die Anbindung externer Tools und KI-Assistenten.", - "openSettings": "Integrationseinstellungen öffnen" + "openSettings": "Integrationseinstellungen öffnen", + "label": "Lokale API und MCP" }, "encryption": { "title": "Sync-Verschlüsselung", "description": "Legen Sie ein Passwort fest, um die Ende-zu-Ende-verschlüsselte Synchronisierung zu aktivieren. Wenn Sie dieses Passwort verlieren, lassen sich verschlüsselte Profile nicht wiederherstellen.", - "passwordSet": "Aktiv", "passwordSetDescription": "E2E-Verschlüsselungspasswort ist festgelegt", "noPassword": "Kein Passwort festgelegt", "passwordPlaceholder": "Passwort (mind. 8 Zeichen)", @@ -180,7 +173,9 @@ "submit": "Prüfen", "matchToast": "Das Passwort ist korrekt", "mismatchToast": "Das Passwort stimmt nicht überein" - } + }, + "ruleLength": "Mindestens {{min}} Zeichen", + "ruleMatch": "Passwörter stimmen überein" }, "commercial": { "title": "Kommerzielle Lizenz", @@ -199,7 +194,8 @@ "copyLogs": "Protokolle kopieren", "openLogDir": "Protokollordner öffnen", "copyLogsSuccess": "Protokolle in die Zwischenablage kopiert", - "copyLogsDescription": "Kopiert ein bereinigtes Paket der aktuellen Protokolle (bis zu 5 MB). Prüfen Sie es vor dem Teilen, da die Bereinigung nicht jede Art personenbezogener Daten erkennen kann." + "copyLogsDescription": "Kopiert ein bereinigtes Paket der aktuellen Protokolle (bis zu 5 MB). Prüfen Sie es vor dem Teilen, da die Bereinigung nicht jede Art personenbezogener Daten erkennen kann.", + "logs": "Protokolle" }, "disableAutoUpdates": "Automatische App-Updates deaktivieren", "disableAutoUpdatesDescription": "Verhindert, dass die App automatisch nach Updates für Donut Browser sucht und sie installiert. Browser-Updates sind davon nicht betroffen.", @@ -250,7 +246,51 @@ }, "trashRetentionOff": "Sofort löschen", "scrollSectionsLeft": "Abschnitte nach links scrollen", - "scrollSectionsRight": "Abschnitte nach rechts scrollen" + "scrollSectionsRight": "Abschnitte nach rechts scrollen", + "changed": "Geändert", + "open": "Öffnen", + "manage": "Verwalten", + "clear": "Leeren", + "dns": { + "label": "Werbung, Tracker und Malware blockieren" + }, + "sounds": { + "title": "Klänge", + "description": "Kurze, leise Klänge, die zeigen, was passiert ist.", + "enabled": "Oberflächenklänge", + "enabledDescription": "Klänge werden nur abgespielt, wenn das Donut-Fenster im Vordergrund ist.", + "volume": "Lautstärke", + "preview": "Anhören", + "pack": "Klangpaket", + "packs": { + "soft": { + "label": "Sanft", + "description": "Warm und rund." + }, + "crisp": { + "label": "Klar", + "description": "Kurz, hell und deutlich." + }, + "retro": { + "label": "Retro", + "description": "Rechteckwellen wie bei einer alten Spielkonsole." + } + }, + "categories": { + "interface": { + "label": "Oberfläche", + "description": "Schaltflächen, Schalter und Seiten, die Sie öffnen oder schließen." + }, + "feedback": { + "label": "Ergebnisse", + "description": "Erfolg, Fehler, Warnungen, Kopieren, Löschen und Wiederherstellen." + }, + "events": { + "label": "Ereignisse", + "description": "Start und Stopp von Profilen, Synchronisierung, Downloads und Anfragen, die Ihre Aufmerksamkeit brauchen." + } + } + } }, "header": { "searchPlaceholder": "Profile durchsuchen...", @@ -940,7 +980,6 @@ "apiStartFailed": "Der API-Server konnte nicht gestartet werden", "tokenCopied": "Token kopiert", "mcpAcceptTermsFirst": "(Akzeptieren Sie zuerst die Wayfern-Bedingungen in den Einstellungen)", - "openSettings": "Integrationseinstellungen öffnen", "apiRunningOn": "Läuft auf", "apiExampleRequest": "Beispielanfrage", "examples": { @@ -1760,7 +1799,6 @@ "proPlus": "Pro++", "ultimate": "Ultimate", "settingsDescription": "DNS-Sperrlisten blockieren Werbung, Tracker und Malware-Domains auf Proxy-Ebene. Die Listen werden alle 12 Stunden automatisch aktualisiert.", - "manageLists": "DNS-Sperrlisten verwalten", "refreshAll": "Alle Listen aktualisieren", "refreshFailed": "Die DNS-Sperrlisten konnten nicht aktualisiert werden", "domains": "Domains", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index eb1f218b..712c2775 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -38,13 +38,11 @@ }, "status": { "active": "Active", - "inactive": "Inactive", "running": "Running", "stopped": "Stopped", "enabled": "Enabled", "disabled": "Disabled", "granted": "Granted", - "notGranted": "Not Granted", "connected": "Connected", "disconnected": "Disconnected", "synced": "Synced", @@ -116,35 +114,30 @@ "themeDescription": "Choose your preferred theme or follow your system settings. Custom theme changes are applied only when you save.", "themePreset": "Theme Preset", "customColors": "Custom Colors", - "selectTheme": "Select theme", - "selectThemePreset": "Select a theme preset", "yourOwn": "Your Own", "light": "Light", "dark": "Dark", - "system": "System", - "sounds": "Interface sounds", - "soundsDescription": "Soft, quiet sounds when you press a button, flip a switch, or when an action succeeds or fails." + "system": "System" }, "language": { "title": "Language", "description": "Choose your preferred language for the application interface.", "systemDefault": "System Default", - "selectLanguage": "Select language", "interface": "Interface Language" }, "defaultBrowser": { "title": "Default Browser", "setAsDefault": "Set as Default Browser", - "alreadyDefault": "Already Default Browser", "description": "When set as default, Donut Browser will handle web links and allow you to choose which profile to use.", "setSuccess": "Donut Browser is now your default browser", "setFailed": "Could not set the default browser", "finishInSystemSettings": "Finish in Windows Settings", - "finishInSystemSettingsDescription": "Donut Browser is registered. Windows Settings is open: choose Donut Browser under Web browser to finish." + "finishInSystemSettingsDescription": "Donut Browser is registered. Windows Settings is open: choose Donut Browser under Web browser to finish.", + "isDefault": "Donut Browser is your default browser", + "notDefault": "Donut Browser is not your default browser" }, "permissions": { "title": "System Permissions", - "loading": "Loading permissions...", "description": "These permissions allow browsers launched from Donut Browser to access system resources. Each website will still ask for your permission individually.", "microphone": "Microphone", "microphoneDescription": "Access to microphone for browser applications", @@ -155,12 +148,12 @@ "integrations": { "title": "Integrations", "description": "Configure Local API and MCP (Model Context Protocol) for integrating with external tools and AI assistants.", - "openSettings": "Open Integrations Settings" + "openSettings": "Open Integrations Settings", + "label": "Local API and MCP" }, "encryption": { "title": "Sync Encryption", "description": "Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.", - "passwordSet": "Active", "passwordSetDescription": "E2E encryption password is set", "noPassword": "No password set", "passwordPlaceholder": "Password (min 8 characters)", @@ -180,7 +173,9 @@ "submit": "Validate", "matchToast": "Password is correct", "mismatchToast": "Password does not match" - } + }, + "ruleLength": "At least {{min}} characters", + "ruleMatch": "Passwords match" }, "commercial": { "title": "Commercial License", @@ -199,7 +194,8 @@ "copyLogs": "Copy logs", "openLogDir": "Open log folder", "copyLogsSuccess": "Logs copied to clipboard", - "copyLogsDescription": "Copies a redacted bundle of recent logs (up to 5 MB). Review it before sharing because redaction cannot identify every kind of personal data." + "copyLogsDescription": "Copies a redacted bundle of recent logs (up to 5 MB). Review it before sharing because redaction cannot identify every kind of personal data.", + "logs": "Logs" }, "disableAutoUpdates": "Disable App Auto Updates", "disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.", @@ -250,7 +246,51 @@ }, "trashRetentionOff": "Delete at once", "scrollSectionsLeft": "Scroll sections left", - "scrollSectionsRight": "Scroll sections right" + "scrollSectionsRight": "Scroll sections right", + "changed": "Changed", + "open": "Open", + "manage": "Manage", + "clear": "Clear", + "dns": { + "label": "Block ads, trackers and malware" + }, + "sounds": { + "title": "Sounds", + "description": "Short, quiet sounds that tell you what happened.", + "enabled": "Interface sounds", + "enabledDescription": "Sounds play only while the Donut window is in front.", + "volume": "Volume", + "preview": "Preview", + "pack": "Sound pack", + "packs": { + "soft": { + "label": "Soft", + "description": "Warm and round." + }, + "crisp": { + "label": "Crisp", + "description": "Short, bright and clear." + }, + "retro": { + "label": "Retro", + "description": "Square waves, like an old game console." + } + }, + "categories": { + "interface": { + "label": "Interface", + "description": "Buttons, switches, and pages you open or close." + }, + "feedback": { + "label": "Results", + "description": "Success, errors, warnings, copy, delete and restore." + }, + "events": { + "label": "Events", + "description": "Profile launch and stop, sync, downloads, and requests for your attention." + } + } + } }, "header": { "searchPlaceholder": "Search profiles...", @@ -940,7 +980,6 @@ "apiStartFailed": "Failed to start API server", "tokenCopied": "Token copied", "mcpAcceptTermsFirst": "(Accept Wayfern terms in Settings first)", - "openSettings": "Open Integrations Settings", "apiRunningOn": "Running on", "apiExampleRequest": "Example request", "examples": { @@ -1760,7 +1799,6 @@ "proPlus": "Pro++", "ultimate": "Ultimate", "settingsDescription": "DNS blocklists block ads, trackers, and malware domains at the proxy level. Lists are automatically refreshed every 12 hours.", - "manageLists": "Manage DNS Blocklists", "refreshAll": "Refresh All Lists", "refreshFailed": "Failed to refresh DNS blocklists", "domains": "domains", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 7de26e25..115b76d0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -38,13 +38,11 @@ }, "status": { "active": "Activo", - "inactive": "Inactivo", "running": "Ejecutando", "stopped": "Detenido", "enabled": "Habilitado", "disabled": "Deshabilitado", "granted": "Otorgado", - "notGranted": "No Otorgado", "connected": "Conectado", "disconnected": "Desconectado", "synced": "Sincronizado", @@ -116,35 +114,30 @@ "themeDescription": "Elige tu tema preferido o sigue la configuración del sistema. Los cambios de tema personalizado se aplican solo al guardar.", "themePreset": "Tema Predefinido", "customColors": "Colores Personalizados", - "selectTheme": "Seleccionar tema", - "selectThemePreset": "Seleccionar tema predefinido", "yourOwn": "Tu Propio", "light": "Claro", "dark": "Oscuro", - "system": "Sistema", - "sounds": "Sonidos de la interfaz", - "soundsDescription": "Sonidos suaves y bajos al pulsar un botón, cambiar un interruptor o cuando una acción sale bien o falla." + "system": "Sistema" }, "language": { "title": "Idioma", "description": "Elige tu idioma preferido para la interfaz de la aplicación.", "systemDefault": "Predeterminado del Sistema", - "selectLanguage": "Seleccionar idioma", "interface": "Idioma de la interfaz" }, "defaultBrowser": { "title": "Navegador Predeterminado", "setAsDefault": "Establecer como Navegador Predeterminado", - "alreadyDefault": "Ya es el Navegador Predeterminado", "description": "Cuando se establece como predeterminado, Donut Browser manejará los enlaces web y te permitirá elegir qué perfil usar.", "setSuccess": "Donut Browser ya es tu navegador predeterminado", "setFailed": "No se pudo establecer el navegador predeterminado", "finishInSystemSettings": "Termina en la Configuración de Windows", - "finishInSystemSettingsDescription": "Donut Browser está registrado. Se abrió la Configuración de Windows: elige Donut Browser en Navegador web para terminar." + "finishInSystemSettingsDescription": "Donut Browser está registrado. Se abrió la Configuración de Windows: elige Donut Browser en Navegador web para terminar.", + "isDefault": "Donut Browser es tu navegador predeterminado", + "notDefault": "Donut Browser no es tu navegador predeterminado" }, "permissions": { "title": "Permisos del Sistema", - "loading": "Cargando permisos...", "description": "Estos permisos permiten que los navegadores iniciados desde Donut Browser accedan a los recursos del sistema. Cada sitio web seguirá pidiendo tu permiso individualmente.", "microphone": "Micrófono", "microphoneDescription": "Acceso al micrófono para aplicaciones del navegador", @@ -155,12 +148,12 @@ "integrations": { "title": "Integraciones", "description": "Configura la API Local y MCP (Protocolo de Contexto de Modelo) para integración con herramientas externas y asistentes de IA.", - "openSettings": "Abrir Configuración de Integraciones" + "openSettings": "Abrir Configuración de Integraciones", + "label": "API local y MCP" }, "encryption": { "title": "Cifrado de sincronización", "description": "Establece una contraseña para habilitar la sincronización cifrada E2E. Si pierdes esta contraseña, los perfiles cifrados no podrán recuperarse.", - "passwordSet": "Activo", "passwordSetDescription": "La contraseña de cifrado E2E está configurada", "noPassword": "Sin contraseña configurada", "passwordPlaceholder": "Contraseña (mín. 8 caracteres)", @@ -180,7 +173,9 @@ "submit": "Validar", "matchToast": "La contraseña es correcta", "mismatchToast": "La contraseña no coincide" - } + }, + "ruleLength": "Al menos {{min}} caracteres", + "ruleMatch": "Las contraseñas coinciden" }, "commercial": { "title": "Licencia Comercial", @@ -199,7 +194,8 @@ "copyLogs": "Copiar registros", "openLogDir": "Abrir carpeta de registros", "copyLogsSuccess": "Registros copiados al portapapeles", - "copyLogsDescription": "Copia un paquete censurado de los registros recientes (hasta 5 MB). Revísalo antes de compartirlo, ya que la censura no puede identificar todos los tipos de datos personales." + "copyLogsDescription": "Copia un paquete censurado de los registros recientes (hasta 5 MB). Revísalo antes de compartirlo, ya que la censura no puede identificar todos los tipos de datos personales.", + "logs": "Registros" }, "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.", @@ -251,7 +247,51 @@ }, "trashRetentionOff": "Eliminar al instante", "scrollSectionsLeft": "Desplazar secciones a la izquierda", - "scrollSectionsRight": "Desplazar secciones a la derecha" + "scrollSectionsRight": "Desplazar secciones a la derecha", + "changed": "Modificado", + "open": "Abrir", + "manage": "Gestionar", + "clear": "Borrar", + "dns": { + "label": "Bloquear anuncios, rastreadores y malware" + }, + "sounds": { + "title": "Sonidos", + "description": "Sonidos cortos y bajos que indican lo que ha pasado.", + "enabled": "Sonidos de la interfaz", + "enabledDescription": "Los sonidos solo suenan cuando la ventana de Donut está delante.", + "volume": "Volumen", + "preview": "Escuchar", + "pack": "Paquete de sonidos", + "packs": { + "soft": { + "label": "Suave", + "description": "Cálido y redondo." + }, + "crisp": { + "label": "Nítido", + "description": "Corto, brillante y claro." + }, + "retro": { + "label": "Retro", + "description": "Ondas cuadradas, como una consola de videojuegos antigua." + } + }, + "categories": { + "interface": { + "label": "Interfaz", + "description": "Botones, interruptores y páginas que abres o cierras." + }, + "feedback": { + "label": "Resultados", + "description": "Éxito, errores, avisos, copiar, eliminar y restaurar." + }, + "events": { + "label": "Eventos", + "description": "Inicio y parada de perfiles, sincronización, descargas y solicitudes que requieren tu atención." + } + } + } }, "header": { "searchPlaceholder": "Buscar perfiles...", @@ -949,7 +989,6 @@ "apiStartFailed": "Error al iniciar el servidor API", "tokenCopied": "Token copiado", "mcpAcceptTermsFirst": "(Acepta primero los términos de Wayfern en Configuración)", - "openSettings": "Abrir configuración de integraciones", "apiRunningOn": "Ejecutándose en", "apiExampleRequest": "Solicitud de ejemplo", "examples": { @@ -1771,7 +1810,6 @@ "proPlus": "Pro++", "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", "refreshFailed": "Error al actualizar las listas de bloqueo DNS", "domains": "dominios", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 207eb4a2..2d9336c0 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -38,13 +38,11 @@ }, "status": { "active": "Actif", - "inactive": "Inactif", "running": "En cours", "stopped": "Arrêté", "enabled": "Activé", "disabled": "Désactivé", "granted": "Accordé", - "notGranted": "Non accordé", "connected": "Connecté", "disconnected": "Déconnecté", "synced": "Synchronisé", @@ -116,35 +114,30 @@ "themeDescription": "Choisissez votre thème préféré ou suivez les paramètres du système. Les modifications de thème personnalisé ne sont appliquées qu'à l'enregistrement.", "themePreset": "Thème prédéfini", "customColors": "Couleurs personnalisées", - "selectTheme": "Sélectionner un thème", - "selectThemePreset": "Sélectionner un thème prédéfini", "yourOwn": "Le vôtre", "light": "Clair", "dark": "Sombre", - "system": "Système", - "sounds": "Sons de l'interface", - "soundsDescription": "Des sons doux et discrets quand vous appuyez sur un bouton, basculez un interrupteur, ou quand une action réussit ou échoue." + "system": "Système" }, "language": { "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", "interface": "Langue de l'interface" }, "defaultBrowser": { "title": "Navigateur par défaut", "setAsDefault": "Définir comme navigateur par défaut", - "alreadyDefault": "Déjà le navigateur par défaut", "description": "Lorsqu'il est défini par défaut, Donut Browser gérera les liens web et vous permettra de choisir quel profil utiliser.", "setSuccess": "Donut Browser est maintenant votre navigateur par défaut", "setFailed": "Impossible de définir le navigateur par défaut", "finishInSystemSettings": "Terminez dans les Paramètres Windows", - "finishInSystemSettingsDescription": "Donut Browser est enregistré. Les Paramètres Windows sont ouverts : choisissez Donut Browser sous Navigateur web pour terminer." + "finishInSystemSettingsDescription": "Donut Browser est enregistré. Les Paramètres Windows sont ouverts : choisissez Donut Browser sous Navigateur web pour terminer.", + "isDefault": "Donut Browser est votre navigateur par défaut", + "notDefault": "Donut Browser n’est pas votre navigateur par défaut" }, "permissions": { "title": "Permissions système", - "loading": "Chargement des permissions...", "description": "Ces permissions permettent aux navigateurs lancés depuis Donut Browser d'accéder aux ressources système. Chaque site web demandera toujours votre permission individuellement.", "microphone": "Microphone", "microphoneDescription": "Accès au microphone pour les applications du navigateur", @@ -155,12 +148,12 @@ "integrations": { "title": "Intégrations", "description": "Configurez l'API locale et MCP (Model Context Protocol) pour l'intégration avec des outils externes et des assistants IA.", - "openSettings": "Ouvrir les paramètres d'intégration" + "openSettings": "Ouvrir les paramètres d'intégration", + "label": "API locale et MCP" }, "encryption": { "title": "Chiffrement de synchronisation", "description": "Définissez un mot de passe pour activer la synchronisation chiffrée E2E. Si vous perdez ce mot de passe, les profils chiffrés ne pourront pas être récupérés.", - "passwordSet": "Actif", "passwordSetDescription": "Le mot de passe de chiffrement E2E est défini", "noPassword": "Aucun mot de passe défini", "passwordPlaceholder": "Mot de passe (min. 8 caractères)", @@ -180,7 +173,9 @@ "submit": "Valider", "matchToast": "Le mot de passe est correct", "mismatchToast": "Le mot de passe ne correspond pas" - } + }, + "ruleLength": "Au moins {{min}} caractères", + "ruleMatch": "Les mots de passe correspondent" }, "commercial": { "title": "Licence commerciale", @@ -199,7 +194,8 @@ "copyLogs": "Copier les journaux", "openLogDir": "Ouvrir le dossier des journaux", "copyLogsSuccess": "Journaux copiés dans le presse-papiers", - "copyLogsDescription": "Copie un lot expurgé des journaux récents (jusqu’à 5 Mo). Vérifiez-le avant de le partager, car l’expurgation ne peut pas identifier tous les types de données personnelles." + "copyLogsDescription": "Copie un lot expurgé des journaux récents (jusqu’à 5 Mo). Vérifiez-le avant de le partager, car l’expurgation ne peut pas identifier tous les types de données personnelles.", + "logs": "Journaux" }, "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.", @@ -251,7 +247,51 @@ }, "trashRetentionOff": "Supprimer aussitôt", "scrollSectionsLeft": "Faire défiler les sections vers la gauche", - "scrollSectionsRight": "Faire défiler les sections vers la droite" + "scrollSectionsRight": "Faire défiler les sections vers la droite", + "changed": "Modifié", + "open": "Ouvrir", + "manage": "Gérer", + "clear": "Effacer", + "dns": { + "label": "Bloquer les publicités, les traqueurs et les logiciels malveillants" + }, + "sounds": { + "title": "Sons", + "description": "Des sons courts et discrets qui indiquent ce qui s'est passé.", + "enabled": "Sons de l'interface", + "enabledDescription": "Les sons ne sont joués que lorsque la fenêtre Donut est au premier plan.", + "volume": "Volume", + "preview": "Écouter", + "pack": "Pack de sons", + "packs": { + "soft": { + "label": "Doux", + "description": "Chaud et rond." + }, + "crisp": { + "label": "Net", + "description": "Court, brillant et clair." + }, + "retro": { + "label": "Rétro", + "description": "Des ondes carrées, comme une ancienne console de jeu." + } + }, + "categories": { + "interface": { + "label": "Interface", + "description": "Boutons, interrupteurs et pages que vous ouvrez ou fermez." + }, + "feedback": { + "label": "Résultats", + "description": "Succès, erreurs, avertissements, copie, suppression et restauration." + }, + "events": { + "label": "Événements", + "description": "Lancement et arrêt des profils, synchronisation, téléchargements et demandes qui requièrent votre attention." + } + } + } }, "header": { "searchPlaceholder": "Rechercher des profils...", @@ -949,7 +989,6 @@ "apiStartFailed": "Échec du démarrage du serveur API", "tokenCopied": "Jeton copié", "mcpAcceptTermsFirst": "(Acceptez d'abord les conditions Wayfern dans les Paramètres)", - "openSettings": "Ouvrir les paramètres d'intégrations", "apiRunningOn": "En cours sur", "apiExampleRequest": "Exemple de requête", "examples": { @@ -1771,7 +1810,6 @@ "proPlus": "Pro++", "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", "refreshFailed": "Échec du rafraîchissement des listes de blocage DNS", "domains": "domaines", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0f05fb21..815ac9bc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -38,13 +38,11 @@ }, "status": { "active": "アクティブ", - "inactive": "非アクティブ", "running": "実行中", "stopped": "停止", "enabled": "有効", "disabled": "無効", "granted": "許可済み", - "notGranted": "未許可", "connected": "接続済み", "disconnected": "切断", "synced": "同期済み", @@ -116,35 +114,30 @@ "themeDescription": "お好みのテーマを選択するか、システム設定に従います。カスタムテーマの変更は保存時のみ適用されます。", "themePreset": "テーマプリセット", "customColors": "カスタムカラー", - "selectTheme": "テーマを選択", - "selectThemePreset": "テーマプリセットを選択", "yourOwn": "カスタム", "light": "ライト", "dark": "ダーク", - "system": "システム", - "sounds": "インターフェースの効果音", - "soundsDescription": "ボタンを押したとき、スイッチを切り替えたとき、操作が成功または失敗したときに、小さく柔らかい音を鳴らします。" + "system": "システム" }, "language": { "title": "言語", "description": "アプリケーションインターフェースの言語を選択します。", "systemDefault": "システムデフォルト", - "selectLanguage": "言語を選択", "interface": "インターフェース言語" }, "defaultBrowser": { "title": "デフォルトブラウザ", "setAsDefault": "デフォルトブラウザに設定", - "alreadyDefault": "既にデフォルトブラウザです", "description": "デフォルトに設定すると、Donut Browser がウェブリンクを処理し、使用するプロファイルを選択できます。", "setSuccess": "Donut Browser が既定のブラウザーになりました", "setFailed": "既定のブラウザーを設定できませんでした", "finishInSystemSettings": "Windows の設定で完了してください", - "finishInSystemSettingsDescription": "Donut Browser を登録しました。Windows の設定が開いています。「Web ブラウザー」で Donut Browser を選ぶと完了します。" + "finishInSystemSettingsDescription": "Donut Browser を登録しました。Windows の設定が開いています。「Web ブラウザー」で Donut Browser を選ぶと完了します。", + "isDefault": "Donut Browser は既定のブラウザーです", + "notDefault": "Donut Browser は既定のブラウザーではありません" }, "permissions": { "title": "システム権限", - "loading": "権限を読み込み中...", "description": "これらの権限により、Donut Browser から起動したブラウザがシステムリソースにアクセスできます。各ウェブサイトは個別に許可を求めます。", "microphone": "マイク", "microphoneDescription": "ブラウザアプリケーションのマイクアクセス", @@ -155,12 +148,12 @@ "integrations": { "title": "統合", "description": "外部ツールやAIアシスタントと統合するためのローカルAPIとMCP(モデルコンテキストプロトコル)を設定します。", - "openSettings": "統合設定を開く" + "openSettings": "統合設定を開く", + "label": "ローカル API と MCP" }, "encryption": { "title": "同期暗号化", "description": "E2E暗号化同期を有効にするためのパスワードを設定してください。このパスワードを紛失すると、暗号化されたプロファイルは復元できません。", - "passwordSet": "有効", "passwordSetDescription": "E2E暗号化パスワードが設定されています", "noPassword": "パスワード未設定", "passwordPlaceholder": "パスワード(8文字以上)", @@ -180,7 +173,9 @@ "submit": "確認", "matchToast": "パスワードが一致しました", "mismatchToast": "パスワードが一致しません" - } + }, + "ruleLength": "{{min}} 文字以上", + "ruleMatch": "パスワードが一致" }, "commercial": { "title": "商用ライセンス", @@ -199,7 +194,8 @@ "copyLogs": "ログをコピー", "openLogDir": "ログフォルダを開く", "copyLogsSuccess": "ログをクリップボードにコピーしました", - "copyLogsDescription": "最近のログを編集したバンドル(最大 5 MB)をコピーします。編集ではすべての種類の個人データを識別できないため、共有前に内容を確認してください。" + "copyLogsDescription": "最近のログを編集したバンドル(最大 5 MB)をコピーします。編集ではすべての種類の個人データを識別できないため、共有前に内容を確認してください。", + "logs": "ログ" }, "disableAutoUpdates": "アプリの自動更新を無効にする", "disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。", @@ -250,7 +246,51 @@ }, "trashRetentionOff": "すぐに削除", "scrollSectionsLeft": "セクションを左へスクロール", - "scrollSectionsRight": "セクションを右へスクロール" + "scrollSectionsRight": "セクションを右へスクロール", + "changed": "変更あり", + "open": "開く", + "manage": "管理", + "clear": "消去", + "dns": { + "label": "広告、トラッカー、マルウェアをブロック" + }, + "sounds": { + "title": "サウンド", + "description": "何が起きたかを伝える、短く小さな効果音です。", + "enabled": "インターフェースの効果音", + "enabledDescription": "効果音は Donut のウィンドウが前面にあるときだけ鳴ります。", + "volume": "音量", + "preview": "試聴", + "pack": "サウンドパック", + "packs": { + "soft": { + "label": "ソフト", + "description": "温かく丸みのある音。" + }, + "crisp": { + "label": "クリスプ", + "description": "短く明るく、はっきりした音。" + }, + "retro": { + "label": "レトロ", + "description": "昔のゲーム機のような矩形波の音。" + } + }, + "categories": { + "interface": { + "label": "インターフェース", + "description": "ボタン、スイッチ、ページの開閉。" + }, + "feedback": { + "label": "結果", + "description": "成功、エラー、警告、コピー、削除、復元。" + }, + "events": { + "label": "イベント", + "description": "プロファイルの起動と停止、同期、ダウンロード、対応が必要なリクエスト。" + } + } + } }, "header": { "searchPlaceholder": "プロファイルを検索...", @@ -940,7 +980,6 @@ "apiStartFailed": "API サーバーの起動に失敗しました", "tokenCopied": "トークンをコピーしました", "mcpAcceptTermsFirst": "(設定で先に Wayfern の規約に同意してください)", - "openSettings": "統合設定を開く", "apiRunningOn": "実行中", "apiExampleRequest": "リクエスト例", "examples": { @@ -1760,7 +1799,6 @@ "proPlus": "Pro++", "ultimate": "アルティメット", "settingsDescription": "DNSブロックリストは、プロキシレベルで広告、トラッカー、マルウェアドメインをブロックします。リストは12時間ごとに自動的に更新されます。", - "manageLists": "DNSブロックリストを管理", "refreshAll": "すべてのリストを更新", "refreshFailed": "DNSブロックリストの更新に失敗しました", "domains": "ドメイン", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index d09305f7..e41838cd 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -38,13 +38,11 @@ }, "status": { "active": "활성", - "inactive": "비활성", "running": "실행 중", "stopped": "중지됨", "enabled": "사용", "disabled": "사용 안 함", "granted": "허용됨", - "notGranted": "허용되지 않음", "connected": "연결됨", "disconnected": "연결 끊김", "synced": "동기화됨", @@ -116,35 +114,30 @@ "themeDescription": "원하는 테마를 선택하거나 시스템 설정을 따르세요. 사용자 지정 테마 변경 사항은 저장 시에만 적용됩니다.", "themePreset": "테마 프리셋", "customColors": "사용자 지정 색상", - "selectTheme": "테마 선택", - "selectThemePreset": "테마 프리셋 선택", "yourOwn": "직접 설정", "light": "라이트", "dark": "다크", - "system": "시스템", - "sounds": "인터페이스 소리", - "soundsDescription": "버튼을 누르거나 스위치를 전환하거나 작업이 성공 또는 실패할 때 작고 부드러운 소리를 냅니다." + "system": "시스템" }, "language": { "title": "언어", "description": "애플리케이션 인터페이스에 사용할 언어를 선택하세요.", "systemDefault": "시스템 기본값", - "selectLanguage": "언어 선택", "interface": "인터페이스 언어" }, "defaultBrowser": { "title": "기본 브라우저", "setAsDefault": "기본 브라우저로 설정", - "alreadyDefault": "이미 기본 브라우저입니다", "description": "기본 브라우저로 설정하면 Donut Browser가 웹 링크를 처리하고 사용할 프로필을 선택할 수 있습니다.", "setSuccess": "이제 Donut Browser가 기본 브라우저입니다", "setFailed": "기본 브라우저를 설정하지 못했습니다", "finishInSystemSettings": "Windows 설정에서 완료하세요", - "finishInSystemSettingsDescription": "Donut Browser가 등록되었습니다. Windows 설정이 열려 있습니다. '웹 브라우저'에서 Donut Browser를 선택하면 완료됩니다." + "finishInSystemSettingsDescription": "Donut Browser가 등록되었습니다. Windows 설정이 열려 있습니다. '웹 브라우저'에서 Donut Browser를 선택하면 완료됩니다.", + "isDefault": "Donut Browser가 기본 브라우저입니다", + "notDefault": "Donut Browser가 기본 브라우저가 아닙니다" }, "permissions": { "title": "시스템 권한", - "loading": "권한 불러오는 중...", "description": "이 권한은 Donut Browser에서 실행된 브라우저가 시스템 리소스에 액세스할 수 있도록 합니다. 각 웹사이트는 여전히 개별적으로 권한을 요청합니다.", "microphone": "마이크", "microphoneDescription": "브라우저 애플리케이션의 마이크 액세스", @@ -155,12 +148,12 @@ "integrations": { "title": "통합", "description": "외부 도구 및 AI 어시스턴트와 통합하기 위해 로컬 API와 MCP(Model Context Protocol)를 구성합니다.", - "openSettings": "통합 설정 열기" + "openSettings": "통합 설정 열기", + "label": "로컬 API 및 MCP" }, "encryption": { "title": "동기화 암호화", "description": "E2E 암호화 동기화를 사용하려면 비밀번호를 설정하세요. 이 비밀번호를 잃어버리면 암호화된 프로필을 복구할 수 없습니다.", - "passwordSet": "활성", "passwordSetDescription": "E2E 암호화 비밀번호가 설정되어 있습니다", "noPassword": "비밀번호가 설정되지 않았습니다", "passwordPlaceholder": "비밀번호 (8자 이상)", @@ -180,7 +173,9 @@ "submit": "확인", "matchToast": "비밀번호가 일치합니다", "mismatchToast": "비밀번호가 일치하지 않습니다" - } + }, + "ruleLength": "{{min}}자 이상", + "ruleMatch": "비밀번호 일치" }, "commercial": { "title": "상업용 라이선스", @@ -199,7 +194,8 @@ "copyLogs": "로그 복사", "openLogDir": "로그 폴더 열기", "copyLogsSuccess": "로그가 클립보드에 복사되었습니다", - "copyLogsDescription": "최근 로그를 민감 정보가 제거된 묶음으로 복사합니다(최대 5MB). 모든 유형의 개인 데이터를 식별할 수는 없으므로 공유하기 전에 검토하세요." + "copyLogsDescription": "최근 로그를 민감 정보가 제거된 묶음으로 복사합니다(최대 5MB). 모든 유형의 개인 데이터를 식별할 수는 없으므로 공유하기 전에 검토하세요.", + "logs": "로그" }, "disableAutoUpdates": "앱 자동 업데이트 사용 안 함", "disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.", @@ -250,7 +246,51 @@ }, "trashRetentionOff": "즉시 삭제", "scrollSectionsLeft": "섹션 왼쪽으로 스크롤", - "scrollSectionsRight": "섹션 오른쪽으로 스크롤" + "scrollSectionsRight": "섹션 오른쪽으로 스크롤", + "changed": "변경됨", + "open": "열기", + "manage": "관리", + "clear": "지우기", + "dns": { + "label": "광고, 추적기, 악성코드 차단" + }, + "sounds": { + "title": "소리", + "description": "무슨 일이 일어났는지 알려 주는 짧고 작은 소리입니다.", + "enabled": "인터페이스 소리", + "enabledDescription": "소리는 Donut 창이 맨 앞에 있을 때만 재생됩니다.", + "volume": "음량", + "preview": "미리 듣기", + "pack": "사운드 팩", + "packs": { + "soft": { + "label": "부드러움", + "description": "따뜻하고 둥근 소리." + }, + "crisp": { + "label": "선명함", + "description": "짧고 밝고 또렷한 소리." + }, + "retro": { + "label": "레트로", + "description": "옛 게임기 같은 사각파 소리." + } + }, + "categories": { + "interface": { + "label": "인터페이스", + "description": "버튼, 스위치, 페이지 열기와 닫기." + }, + "feedback": { + "label": "결과", + "description": "성공, 오류, 경고, 복사, 삭제, 복원." + }, + "events": { + "label": "이벤트", + "description": "프로필 실행과 중지, 동기화, 다운로드, 확인이 필요한 요청." + } + } + } }, "header": { "searchPlaceholder": "프로필 검색...", @@ -940,7 +980,6 @@ "apiStartFailed": "API 서버 시작 실패", "tokenCopied": "토큰 복사됨", "mcpAcceptTermsFirst": "(먼저 설정에서 Wayfern 약관에 동의하세요)", - "openSettings": "통합 설정 열기", "apiRunningOn": "실행 중", "apiExampleRequest": "예시 요청", "examples": { @@ -1760,7 +1799,6 @@ "proPlus": "Pro++", "ultimate": "얼티밋", "settingsDescription": "DNS 차단 목록은 프록시 수준에서 광고, 트래커 및 악성코드 도메인을 차단합니다. 목록은 12시간마다 자동으로 새로 고쳐집니다.", - "manageLists": "DNS 차단 목록 관리", "refreshAll": "모든 목록 새로 고침", "refreshFailed": "DNS 차단 목록 새로 고침 실패", "domains": "도메인", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index ffb3a1fa..5a1a6f4c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -38,13 +38,11 @@ }, "status": { "active": "Ativo", - "inactive": "Inativo", "running": "Executando", "stopped": "Parado", "enabled": "Ativado", "disabled": "Desativado", "granted": "Concedido", - "notGranted": "Não Concedido", "connected": "Conectado", "disconnected": "Desconectado", "synced": "Sincronizado", @@ -116,35 +114,30 @@ "themeDescription": "Escolha seu tema preferido ou siga as configurações do sistema. Alterações de tema personalizado são aplicadas apenas ao salvar.", "themePreset": "Tema Predefinido", "customColors": "Cores Personalizadas", - "selectTheme": "Selecionar tema", - "selectThemePreset": "Selecionar tema predefinido", "yourOwn": "Seu Próprio", "light": "Claro", "dark": "Escuro", - "system": "Sistema", - "sounds": "Sons da interface", - "soundsDescription": "Sons suaves e baixos ao pressionar um botão, mudar uma chave ou quando uma ação dá certo ou falha." + "system": "Sistema" }, "language": { "title": "Idioma", "description": "Escolha seu idioma preferido para a interface do aplicativo.", "systemDefault": "Padrão do Sistema", - "selectLanguage": "Selecionar idioma", "interface": "Idioma da interface" }, "defaultBrowser": { "title": "Navegador Padrão", "setAsDefault": "Definir como Navegador Padrão", - "alreadyDefault": "Já é o Navegador Padrão", "description": "Quando definido como padrão, o Donut Browser lidará com links da web e permitirá que você escolha qual perfil usar.", "setSuccess": "O Donut Browser agora é o seu navegador padrão", "setFailed": "Não foi possível definir o navegador padrão", "finishInSystemSettings": "Conclua nas Configurações do Windows", - "finishInSystemSettingsDescription": "O Donut Browser está registrado. As Configurações do Windows foram abertas: escolha o Donut Browser em Navegador da web para concluir." + "finishInSystemSettingsDescription": "O Donut Browser está registrado. As Configurações do Windows foram abertas: escolha o Donut Browser em Navegador da web para concluir.", + "isDefault": "O Donut Browser é o seu navegador padrão", + "notDefault": "O Donut Browser não é o seu navegador padrão" }, "permissions": { "title": "Permissões do Sistema", - "loading": "Carregando permissões...", "description": "Essas permissões permitem que navegadores iniciados pelo Donut Browser acessem recursos do sistema. Cada site ainda pedirá sua permissão individualmente.", "microphone": "Microfone", "microphoneDescription": "Acesso ao microfone para aplicativos do navegador", @@ -155,12 +148,12 @@ "integrations": { "title": "Integrações", "description": "Configure a API Local e MCP (Protocolo de Contexto de Modelo) para integração com ferramentas externas e assistentes de IA.", - "openSettings": "Abrir Configurações de Integrações" + "openSettings": "Abrir Configurações de Integrações", + "label": "API local e MCP" }, "encryption": { "title": "Criptografia de sincronização", "description": "Defina uma senha para habilitar a sincronização criptografada E2E. Se você perder esta senha, os perfis criptografados não poderão ser recuperados.", - "passwordSet": "Ativo", "passwordSetDescription": "A senha de criptografia E2E está definida", "noPassword": "Sem senha definida", "passwordPlaceholder": "Senha (mín. 8 caracteres)", @@ -180,7 +173,9 @@ "submit": "Validar", "matchToast": "A senha está correta", "mismatchToast": "A senha não corresponde" - } + }, + "ruleLength": "Pelo menos {{min}} caracteres", + "ruleMatch": "As senhas coincidem" }, "commercial": { "title": "Licença Comercial", @@ -199,7 +194,8 @@ "copyLogs": "Copiar logs", "openLogDir": "Abrir pasta de logs", "copyLogsSuccess": "Logs copiados para a área de transferência", - "copyLogsDescription": "Copia um pacote editado dos logs recentes (até 5 MB). Revise-o antes de compartilhar, pois a edição não consegue identificar todos os tipos de dados pessoais." + "copyLogsDescription": "Copia um pacote editado dos logs recentes (até 5 MB). Revise-o antes de compartilhar, pois a edição não consegue identificar todos os tipos de dados pessoais.", + "logs": "Logs" }, "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.", @@ -251,7 +247,51 @@ }, "trashRetentionOff": "Excluir na hora", "scrollSectionsLeft": "Rolar seções para a esquerda", - "scrollSectionsRight": "Rolar seções para a direita" + "scrollSectionsRight": "Rolar seções para a direita", + "changed": "Alterado", + "open": "Abrir", + "manage": "Gerenciar", + "clear": "Limpar", + "dns": { + "label": "Bloquear anúncios, rastreadores e malware" + }, + "sounds": { + "title": "Sons", + "description": "Sons curtos e baixos que mostram o que aconteceu.", + "enabled": "Sons da interface", + "enabledDescription": "Os sons só tocam quando a janela do Donut está na frente.", + "volume": "Volume", + "preview": "Ouvir", + "pack": "Pacote de sons", + "packs": { + "soft": { + "label": "Suave", + "description": "Quente e arredondado." + }, + "crisp": { + "label": "Nítido", + "description": "Curto, brilhante e claro." + }, + "retro": { + "label": "Retrô", + "description": "Ondas quadradas, como um console de videogame antigo." + } + }, + "categories": { + "interface": { + "label": "Interface", + "description": "Botões, chaves e páginas que você abre ou fecha." + }, + "feedback": { + "label": "Resultados", + "description": "Sucesso, erros, avisos, copiar, excluir e restaurar." + }, + "events": { + "label": "Eventos", + "description": "Início e parada de perfis, sincronização, downloads e pedidos que precisam da sua atenção." + } + } + } }, "header": { "searchPlaceholder": "Pesquisar perfis...", @@ -949,7 +989,6 @@ "apiStartFailed": "Falha ao iniciar o servidor API", "tokenCopied": "Token copiado", "mcpAcceptTermsFirst": "(Aceite primeiro os termos da Wayfern nas Configurações)", - "openSettings": "Abrir configurações de integrações", "apiRunningOn": "Em execução em", "apiExampleRequest": "Exemplo de solicitação", "examples": { @@ -1771,7 +1810,6 @@ "proPlus": "Pro++", "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", "refreshFailed": "Falha ao atualizar as listas de bloqueio DNS", "domains": "domínios", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 5a88a7f9..8f38eff1 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -38,13 +38,11 @@ }, "status": { "active": "Активен", - "inactive": "Неактивен", "running": "Запущен", "stopped": "Остановлен", "enabled": "Включено", "disabled": "Выключено", "granted": "Разрешено", - "notGranted": "Не разрешено", "connected": "Подключено", "disconnected": "Отключено", "synced": "Синхронизировано", @@ -116,35 +114,30 @@ "themeDescription": "Выберите предпочитаемую тему или следуйте системным настройкам. Изменения пользовательской темы применяются только при сохранении.", "themePreset": "Предустановка темы", "customColors": "Пользовательские цвета", - "selectTheme": "Выберите тему", - "selectThemePreset": "Выберите предустановку темы", "yourOwn": "Собственная", "light": "Светлая", "dark": "Тёмная", - "system": "Системная", - "sounds": "Звуки интерфейса", - "soundsDescription": "Тихие мягкие звуки при нажатии кнопки, переключении переключателя, а также при успехе или ошибке действия." + "system": "Системная" }, "language": { "title": "Язык", "description": "Выберите предпочитаемый язык интерфейса приложения.", "systemDefault": "Системный по умолчанию", - "selectLanguage": "Выберите язык", "interface": "Язык интерфейса" }, "defaultBrowser": { "title": "Браузер по умолчанию", "setAsDefault": "Установить браузером по умолчанию", - "alreadyDefault": "Уже браузер по умолчанию", "description": "При установке по умолчанию Donut Browser будет обрабатывать веб-ссылки и позволит выбрать профиль для использования.", "setSuccess": "Donut Browser теперь браузер по умолчанию", "setFailed": "Не удалось назначить браузер по умолчанию", "finishInSystemSettings": "Завершите в параметрах Windows", - "finishInSystemSettingsDescription": "Donut Browser зарегистрирован. Параметры Windows открыты: выберите Donut Browser в разделе «Веб-браузер», чтобы завершить." + "finishInSystemSettingsDescription": "Donut Browser зарегистрирован. Параметры Windows открыты: выберите Donut Browser в разделе «Веб-браузер», чтобы завершить.", + "isDefault": "Donut Browser — браузер по умолчанию", + "notDefault": "Donut Browser не браузер по умолчанию" }, "permissions": { "title": "Системные разрешения", - "loading": "Загрузка разрешений...", "description": "Эти разрешения позволяют браузерам, запущенным из Donut Browser, получать доступ к системным ресурсам. Каждый сайт всё равно будет запрашивать разрешение отдельно.", "microphone": "Микрофон", "microphoneDescription": "Доступ к микрофону для браузерных приложений", @@ -155,12 +148,12 @@ "integrations": { "title": "Интеграции", "description": "Настройте локальный API и MCP (Model Context Protocol) для интеграции с внешними инструментами и AI-ассистентами.", - "openSettings": "Открыть настройки интеграций" + "openSettings": "Открыть настройки интеграций", + "label": "Локальный API и MCP" }, "encryption": { "title": "Шифрование синхронизации", "description": "Установите пароль для включения E2E зашифрованной синхронизации. Если вы потеряете этот пароль, зашифрованные профили не могут быть восстановлены.", - "passwordSet": "Активно", "passwordSetDescription": "Пароль шифрования E2E установлен", "noPassword": "Пароль не установлен", "passwordPlaceholder": "Пароль (мин. 8 символов)", @@ -180,7 +173,9 @@ "submit": "Проверить", "matchToast": "Пароль верен", "mismatchToast": "Пароль не совпадает" - } + }, + "ruleLength": "Не менее {{min}} символов", + "ruleMatch": "Пароли совпадают" }, "commercial": { "title": "Коммерческая лицензия", @@ -199,7 +194,8 @@ "copyLogs": "Скопировать логи", "openLogDir": "Открыть папку логов", "copyLogsSuccess": "Логи скопированы в буфер обмена", - "copyLogsDescription": "Копирует отредактированный набор последних логов (до 5 МБ). Проверьте его перед отправкой: редактирование не может выявить все виды персональных данных." + "copyLogsDescription": "Копирует отредактированный набор последних логов (до 5 МБ). Проверьте его перед отправкой: редактирование не может выявить все виды персональных данных.", + "logs": "Журналы" }, "disableAutoUpdates": "Отключить автообновление приложения", "disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.", @@ -252,7 +248,51 @@ }, "trashRetentionOff": "Удалять сразу", "scrollSectionsLeft": "Прокрутить разделы влево", - "scrollSectionsRight": "Прокрутить разделы вправо" + "scrollSectionsRight": "Прокрутить разделы вправо", + "changed": "Изменено", + "open": "Открыть", + "manage": "Управлять", + "clear": "Очистить", + "dns": { + "label": "Блокировать рекламу, трекеры и вредоносные сайты" + }, + "sounds": { + "title": "Звуки", + "description": "Короткие тихие звуки, которые сообщают, что произошло.", + "enabled": "Звуки интерфейса", + "enabledDescription": "Звуки играют, только когда окно Donut на переднем плане.", + "volume": "Громкость", + "preview": "Прослушать", + "pack": "Набор звуков", + "packs": { + "soft": { + "label": "Мягкий", + "description": "Тёплый и округлый." + }, + "crisp": { + "label": "Чёткий", + "description": "Короткий, яркий и ясный." + }, + "retro": { + "label": "Ретро", + "description": "Прямоугольные волны, как в старой игровой приставке." + } + }, + "categories": { + "interface": { + "label": "Интерфейс", + "description": "Кнопки, переключатели, открытие и закрытие страниц." + }, + "feedback": { + "label": "Результаты", + "description": "Успех, ошибки, предупреждения, копирование, удаление и восстановление." + }, + "events": { + "label": "События", + "description": "Запуск и остановка профилей, синхронизация, загрузки и запросы, которым нужно ваше внимание." + } + } + } }, "header": { "searchPlaceholder": "Поиск профилей...", @@ -958,7 +998,6 @@ "apiStartFailed": "Не удалось запустить API сервер", "tokenCopied": "Токен скопирован", "mcpAcceptTermsFirst": "(Сначала примите условия Wayfern в Настройках)", - "openSettings": "Открыть настройки интеграций", "apiRunningOn": "Запущен на", "apiExampleRequest": "Пример запроса", "examples": { @@ -1782,7 +1821,6 @@ "proPlus": "Pro++", "ultimate": "Максимум", "settingsDescription": "Списки блокировки DNS блокируют рекламу, трекеры и вредоносные домены на уровне прокси. Списки автоматически обновляются каждые 12 часов.", - "manageLists": "Управление списками блокировки DNS", "refreshAll": "Обновить все списки", "refreshFailed": "Не удалось обновить списки блокировки DNS", "domains": "доменов", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 06586a00..3b4ed515 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -38,13 +38,11 @@ }, "status": { "active": "Etkin", - "inactive": "Devre Dışı", "running": "Çalışıyor", "stopped": "Durduruldu", "enabled": "Etkin", "disabled": "Devre Dışı", "granted": "İzin Verildi", - "notGranted": "İzin Verilmedi", "connected": "Bağlı", "disconnected": "Bağlantı Kesildi", "synced": "Eşitlendi", @@ -116,35 +114,30 @@ "themeDescription": "Tercih ettiğiniz temayı seçin veya sistem ayarlarınızı takip edin. Özel tema değişiklikleri yalnızca kaydettiğinizde uygulanır.", "themePreset": "Tema Ön Ayarı", "customColors": "Özel Renkler", - "selectTheme": "Tema seçin", - "selectThemePreset": "Bir tema ön ayarı seçin", "yourOwn": "Kendi Temanız", "light": "Açık", "dark": "Koyu", - "system": "Sistem", - "sounds": "Arayüz sesleri", - "soundsDescription": "Bir düğmeye bastığınızda, bir anahtarı değiştirdiğinizde veya bir işlem başarılı olduğunda ya da başarısız olduğunda yumuşak ve sessiz sesler." + "system": "Sistem" }, "language": { "title": "Dil", "description": "Uygulama arayüzü için tercih ettiğiniz dili seçin.", "systemDefault": "Sistem Varsayılanı", - "selectLanguage": "Dil seçin", "interface": "Arayüz Dili" }, "defaultBrowser": { "title": "Varsayılan Tarayıcı", "setAsDefault": "Varsayılan Tarayıcı Olarak Ayarla", - "alreadyDefault": "Zaten Varsayılan Tarayıcı", "description": "Varsayılan olarak ayarlandığında, Donut Browser web bağlantılarını yönetir ve hangi profilin kullanılacağını seçmenize olanak tanır.", "setSuccess": "Donut Browser artık varsayılan tarayıcınız", "setFailed": "Varsayılan tarayıcı ayarlanamadı", "finishInSystemSettings": "Windows Ayarları'nda tamamlayın", - "finishInSystemSettingsDescription": "Donut Browser kaydedildi. Windows Ayarları açıldı: tamamlamak için Web tarayıcısı bölümünden Donut Browser'ı seçin." + "finishInSystemSettingsDescription": "Donut Browser kaydedildi. Windows Ayarları açıldı: tamamlamak için Web tarayıcısı bölümünden Donut Browser'ı seçin.", + "isDefault": "Donut Browser varsayılan tarayıcınız", + "notDefault": "Donut Browser varsayılan tarayıcınız değil" }, "permissions": { "title": "Sistem İzinleri", - "loading": "İzinler yükleniyor...", "description": "Bu izinler, Donut Browser'dan başlatılan tarayıcıların sistem kaynaklarına erişmesine olanak tanır. Her web sitesi yine de sizden ayrı ayrı izin isteyecektir.", "microphone": "Mikrofon", "microphoneDescription": "Tarayıcı uygulamaları için mikrofon erişimi", @@ -155,12 +148,12 @@ "integrations": { "title": "Entegrasyonlar", "description": "Harici araçlar ve yapay zekâ asistanlarıyla entegrasyon için Yerel API ve MCP (Model Context Protocol) yapılandırın.", - "openSettings": "Entegrasyon Ayarlarını Aç" + "openSettings": "Entegrasyon Ayarlarını Aç", + "label": "Yerel API ve MCP" }, "encryption": { "title": "Eşitleme Şifrelemesi", "description": "Uçtan uca şifreli eşitlemeyi etkinleştirmek için bir parola belirleyin. Bu parolayı kaybederseniz şifrelenmiş profiller kurtarılamaz.", - "passwordSet": "Etkin", "passwordSetDescription": "Uçtan uca şifreleme parolası ayarlandı", "noPassword": "Parola ayarlanmadı", "passwordPlaceholder": "Parola (en az 8 karakter)", @@ -180,7 +173,9 @@ "submit": "Doğrula", "matchToast": "Parola doğru", "mismatchToast": "Parola eşleşmiyor" - } + }, + "ruleLength": "En az {{min}} karakter", + "ruleMatch": "Parolalar eşleşiyor" }, "commercial": { "title": "Ticari Lisans", @@ -199,7 +194,8 @@ "copyLogs": "Günlükleri kopyala", "openLogDir": "Günlük klasörünü aç", "copyLogsSuccess": "Günlükler panoya kopyalandı", - "copyLogsDescription": "Son günlüklerin hassas verileri ayıklanmış bir paketini kopyalar (en fazla 5 MB). Ayıklama her tür kişisel veriyi belirleyemeyeceğinden paylaşmadan önce inceleyin." + "copyLogsDescription": "Son günlüklerin hassas verileri ayıklanmış bir paketini kopyalar (en fazla 5 MB). Ayıklama her tür kişisel veriyi belirleyemeyeceğinden paylaşmadan önce inceleyin.", + "logs": "Günlükler" }, "disableAutoUpdates": "Uygulama Otomatik Güncellemelerini Devre Dışı Bırak", "disableAutoUpdatesDescription": "Uygulamanın Donut Browser güncellemelerini otomatik olarak denetlemesini ve yüklemesini engelleyin. Tarayıcı güncellemeleri bundan etkilenmez.", @@ -250,7 +246,51 @@ }, "trashRetentionOff": "Hemen sil", "scrollSectionsLeft": "Bölümleri sola kaydır", - "scrollSectionsRight": "Bölümleri sağa kaydır" + "scrollSectionsRight": "Bölümleri sağa kaydır", + "changed": "Değiştirildi", + "open": "Aç", + "manage": "Yönet", + "clear": "Temizle", + "dns": { + "label": "Reklamları, izleyicileri ve kötü amaçlı yazılımları engelle" + }, + "sounds": { + "title": "Sesler", + "description": "Ne olduğunu bildiren kısa ve sessiz sesler.", + "enabled": "Arayüz sesleri", + "enabledDescription": "Sesler yalnızca Donut penceresi öndeyken çalar.", + "volume": "Ses düzeyi", + "preview": "Dinle", + "pack": "Ses paketi", + "packs": { + "soft": { + "label": "Yumuşak", + "description": "Sıcak ve yuvarlak." + }, + "crisp": { + "label": "Net", + "description": "Kısa, parlak ve net." + }, + "retro": { + "label": "Retro", + "description": "Eski bir oyun konsolu gibi kare dalgalar." + } + }, + "categories": { + "interface": { + "label": "Arayüz", + "description": "Düğmeler, anahtarlar ve açıp kapattığınız sayfalar." + }, + "feedback": { + "label": "Sonuçlar", + "description": "Başarı, hatalar, uyarılar, kopyalama, silme ve geri yükleme." + }, + "events": { + "label": "Olaylar", + "description": "Profil başlatma ve durdurma, eşitleme, indirmeler ve ilginizi gerektiren istekler." + } + } + } }, "header": { "searchPlaceholder": "Profillerde ara...", @@ -940,7 +980,6 @@ "apiStartFailed": "API sunucusu başlatılamadı", "tokenCopied": "Jeton kopyalandı", "mcpAcceptTermsFirst": "(Önce Ayarlar'dan Wayfern koşullarını kabul edin)", - "openSettings": "Entegrasyon Ayarlarını Aç", "apiRunningOn": "Çalıştığı adres", "apiExampleRequest": "Örnek istek", "examples": { @@ -1760,7 +1799,6 @@ "proPlus": "Pro++", "ultimate": "Ultimate", "settingsDescription": "DNS engel listeleri; reklamları, izleyicileri ve kötü amaçlı yazılım alan adlarını proxy düzeyinde engeller. Listeler her 12 saatte bir otomatik olarak yenilenir.", - "manageLists": "DNS Engel Listelerini Yönet", "refreshAll": "Tüm Listeleri Yenile", "refreshFailed": "DNS engel listeleri yenilenemedi", "domains": "alan adı", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 048f46dd..d390595c 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -38,13 +38,11 @@ }, "status": { "active": "Đang hoạt động", - "inactive": "Không hoạt động", "running": "Đang chạy", "stopped": "Đã dừng", "enabled": "Đã bật", "disabled": "Đã tắt", "granted": "Đã cấp", - "notGranted": "Chưa cấp", "connected": "Đã kết nối", "disconnected": "Đã ngắt kết nối", "synced": "Đã đồng bộ", @@ -116,35 +114,30 @@ "themeDescription": "Chọn chủ đề bạn muốn hoặc theo cài đặt hệ thống. Thay đổi chủ đề tùy chỉnh chỉ được áp dụng khi bạn lưu.", "themePreset": "Mẫu chủ đề", "customColors": "Tùy chỉnh màu sắc", - "selectTheme": "Chọn chủ đề", - "selectThemePreset": "Chọn mẫu chủ đề", "yourOwn": "Của riêng bạn", "light": "Sáng", "dark": "Tối", - "system": "Theo hệ thống", - "sounds": "Âm thanh giao diện", - "soundsDescription": "Âm thanh nhỏ, nhẹ khi bạn nhấn nút, bật tắt công tắc, hoặc khi một thao tác thành công hay thất bại." + "system": "Theo hệ thống" }, "language": { "title": "Ngôn ngữ", "description": "Chọn ngôn ngữ hiển thị cho giao diện ứng dụng.", "systemDefault": "Mặc định hệ thống", - "selectLanguage": "Chọn ngôn ngữ", "interface": "Ngôn ngữ giao diện" }, "defaultBrowser": { "title": "Trình duyệt mặc định", "setAsDefault": "Đặt làm trình duyệt mặc định", - "alreadyDefault": "Đã là trình duyệt mặc định", "description": "Khi được đặt làm mặc định, Donut Browser sẽ xử lý các liên kết web và cho phép bạn chọn hồ sơ để sử dụng.", "setSuccess": "Donut Browser hiện là trình duyệt mặc định của bạn", "setFailed": "Không thể đặt trình duyệt mặc định", "finishInSystemSettings": "Hoàn tất trong Cài đặt Windows", - "finishInSystemSettingsDescription": "Donut Browser đã được đăng ký. Cài đặt Windows đang mở: chọn Donut Browser trong mục Trình duyệt web để hoàn tất." + "finishInSystemSettingsDescription": "Donut Browser đã được đăng ký. Cài đặt Windows đang mở: chọn Donut Browser trong mục Trình duyệt web để hoàn tất.", + "isDefault": "Donut Browser là trình duyệt mặc định của bạn", + "notDefault": "Donut Browser không phải là trình duyệt mặc định của bạn" }, "permissions": { "title": "Quyền hệ thống", - "loading": "Đang tải quyền...", "description": "Các quyền này cho phép trình duyệt được khởi chạy từ Donut Browser truy cập tài nguyên hệ thống. Mỗi trang web vẫn sẽ yêu cầu quyền riêng lẻ.", "microphone": "Micro", "microphoneDescription": "Quyền truy cập micro cho ứng dụng trình duyệt", @@ -155,12 +148,12 @@ "integrations": { "title": "Tích hợp", "description": "Cấu hình Local API và MCP (Model Context Protocol) để tích hợp với công cụ bên ngoài và trợ lý AI.", - "openSettings": "Mở cài đặt tích hợp" + "openSettings": "Mở cài đặt tích hợp", + "label": "API cục bộ và MCP" }, "encryption": { "title": "Mã hóa đồng bộ", "description": "Đặt mật khẩu để bật mã hóa đầu cuối (E2E) cho đồng bộ. Nếu mất mật khẩu này, các hồ sơ đã mã hóa sẽ không thể khôi phục.", - "passwordSet": "Đang hoạt động", "passwordSetDescription": "Mật khẩu mã hóa E2E đã được thiết lập", "noPassword": "Chưa đặt mật khẩu", "passwordPlaceholder": "Mật khẩu (tối thiểu 8 ký tự)", @@ -180,7 +173,9 @@ "submit": "Xác thực", "matchToast": "Mật khẩu chính xác", "mismatchToast": "Mật khẩu không khớp" - } + }, + "ruleLength": "Ít nhất {{min}} ký tự", + "ruleMatch": "Mật khẩu khớp" }, "commercial": { "title": "Giấy phép thương mại", @@ -199,7 +194,8 @@ "copyLogs": "Sao chép nhật ký", "openLogDir": "Mở thư mục nhật ký", "copyLogsSuccess": "Đã sao chép nhật ký vào clipboard", - "copyLogsDescription": "Sao chép gói nhật ký gần đây đã được che thông tin nhạy cảm (tối đa 5 MB). Hãy xem lại trước khi chia sẻ vì việc che dữ liệu không thể nhận diện mọi loại dữ liệu cá nhân." + "copyLogsDescription": "Sao chép gói nhật ký gần đây đã được che thông tin nhạy cảm (tối đa 5 MB). Hãy xem lại trước khi chia sẻ vì việc che dữ liệu không thể nhận diện mọi loại dữ liệu cá nhân.", + "logs": "Nhật ký" }, "disableAutoUpdates": "Tắt tự động cập nhật ứng dụng", "disableAutoUpdatesDescription": "Ngăn ứng dụng tự động kiểm tra và cài đặt bản cập nhật Donut Browser. Cập nhật trình duyệt không bị ảnh hưởng.", @@ -250,7 +246,51 @@ }, "trashRetentionOff": "Xóa ngay", "scrollSectionsLeft": "Cuộn các mục sang trái", - "scrollSectionsRight": "Cuộn các mục sang phải" + "scrollSectionsRight": "Cuộn các mục sang phải", + "changed": "Đã thay đổi", + "open": "Mở", + "manage": "Quản lý", + "clear": "Xóa", + "dns": { + "label": "Chặn quảng cáo, trình theo dõi và phần mềm độc hại" + }, + "sounds": { + "title": "Âm thanh", + "description": "Âm thanh ngắn, nhỏ cho bạn biết điều gì vừa xảy ra.", + "enabled": "Âm thanh giao diện", + "enabledDescription": "Âm thanh chỉ phát khi cửa sổ Donut ở phía trước.", + "volume": "Âm lượng", + "preview": "Nghe thử", + "pack": "Gói âm thanh", + "packs": { + "soft": { + "label": "Êm dịu", + "description": "Ấm và tròn." + }, + "crisp": { + "label": "Sắc nét", + "description": "Ngắn, sáng và rõ." + }, + "retro": { + "label": "Cổ điển", + "description": "Sóng vuông, như máy chơi game đời cũ." + } + }, + "categories": { + "interface": { + "label": "Giao diện", + "description": "Nút, công tắc và các trang bạn mở hoặc đóng." + }, + "feedback": { + "label": "Kết quả", + "description": "Thành công, lỗi, cảnh báo, sao chép, xóa và khôi phục." + }, + "events": { + "label": "Sự kiện", + "description": "Khởi chạy và dừng hồ sơ, đồng bộ, tải xuống và các yêu cầu cần bạn chú ý." + } + } + } }, "header": { "searchPlaceholder": "Tìm kiếm hồ sơ...", @@ -940,7 +980,6 @@ "apiStartFailed": "Khởi động máy chủ API thất bại", "tokenCopied": "Đã sao chép token", "mcpAcceptTermsFirst": "(Chấp nhận điều khoản Wayfern trong Cài đặt trước)", - "openSettings": "Mở cài đặt tích hợp", "apiRunningOn": "Đang chạy trên", "apiExampleRequest": "Ví dụ yêu cầu", "examples": { @@ -1760,7 +1799,6 @@ "proPlus": "Pro++", "ultimate": "Tối đa", "settingsDescription": "Danh sách chặn DNS chặn quảng cáo, trình theo dõi và tên miền độc hại ở cấp proxy. Danh sách được tự động làm mới mỗi 12 giờ.", - "manageLists": "Quản lý danh sách chặn DNS", "refreshAll": "Làm mới tất cả danh sách", "refreshFailed": "Làm mới danh sách chặn DNS thất bại", "domains": "tên miền", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 67959d88..b358e309 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -38,13 +38,11 @@ }, "status": { "active": "活跃", - "inactive": "未活跃", "running": "运行中", "stopped": "已停止", "enabled": "已启用", "disabled": "已禁用", "granted": "已授权", - "notGranted": "未授权", "connected": "已连接", "disconnected": "已断开", "synced": "已同步", @@ -116,35 +114,30 @@ "themeDescription": "选择您喜欢的主题或跟随系统设置。自定义主题更改仅在保存时应用。", "themePreset": "主题预设", "customColors": "自定义颜色", - "selectTheme": "选择主题", - "selectThemePreset": "选择主题预设", "yourOwn": "自定义", "light": "浅色", "dark": "深色", - "system": "跟随系统", - "sounds": "界面音效", - "soundsDescription": "按下按钮、切换开关,或操作成功或失败时,播放轻柔安静的声音。" + "system": "跟随系统" }, "language": { "title": "语言", "description": "选择应用程序界面的首选语言。", "systemDefault": "系统默认", - "selectLanguage": "选择语言", "interface": "界面语言" }, "defaultBrowser": { "title": "默认浏览器", "setAsDefault": "设为默认浏览器", - "alreadyDefault": "已是默认浏览器", "description": "设为默认后,Donut Browser 将处理网页链接并允许您选择使用哪个配置文件。", "setSuccess": "Donut Browser 现在是您的默认浏览器", "setFailed": "无法设置默认浏览器", "finishInSystemSettings": "请在 Windows 设置中完成", - "finishInSystemSettingsDescription": "Donut Browser 已注册。Windows 设置已打开:在“Web 浏览器”中选择 Donut Browser 即可完成。" + "finishInSystemSettingsDescription": "Donut Browser 已注册。Windows 设置已打开:在“Web 浏览器”中选择 Donut Browser 即可完成。", + "isDefault": "Donut Browser 是你的默认浏览器", + "notDefault": "Donut Browser 不是你的默认浏览器" }, "permissions": { "title": "系统权限", - "loading": "加载权限中...", "description": "这些权限允许从 Donut Browser 启动的浏览器访问系统资源。每个网站仍会单独请求您的权限。", "microphone": "麦克风", "microphoneDescription": "浏览器应用程序的麦克风访问权限", @@ -155,12 +148,12 @@ "integrations": { "title": "集成", "description": "配置本地 API 和 MCP(模型上下文协议)以与外部工具和 AI 助手集成。", - "openSettings": "打开集成设置" + "openSettings": "打开集成设置", + "label": "本地 API 和 MCP" }, "encryption": { "title": "同步加密", "description": "设置密码以启用E2E加密同步。如果您丢失此密码,加密的配置文件将无法恢复。", - "passwordSet": "已启用", "passwordSetDescription": "E2E加密密码已设置", "noPassword": "未设置密码", "passwordPlaceholder": "密码(至少8个字符)", @@ -180,7 +173,9 @@ "submit": "验证", "matchToast": "密码正确", "mismatchToast": "密码不匹配" - } + }, + "ruleLength": "至少 {{min}} 个字符", + "ruleMatch": "密码一致" }, "commercial": { "title": "商业许可", @@ -199,7 +194,8 @@ "copyLogs": "复制日志", "openLogDir": "打开日志文件夹", "copyLogsSuccess": "日志已复制到剪贴板", - "copyLogsDescription": "复制经过脱敏的近期日志包(最多 5 MB)。脱敏无法识别所有类型的个人数据,请在分享前检查内容。" + "copyLogsDescription": "复制经过脱敏的近期日志包(最多 5 MB)。脱敏无法识别所有类型的个人数据,请在分享前检查内容。", + "logs": "日志" }, "disableAutoUpdates": "禁用应用自动更新", "disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。", @@ -250,7 +246,51 @@ }, "trashRetentionOff": "立即删除", "scrollSectionsLeft": "向左滚动分区", - "scrollSectionsRight": "向右滚动分区" + "scrollSectionsRight": "向右滚动分区", + "changed": "已更改", + "open": "打开", + "manage": "管理", + "clear": "清除", + "dns": { + "label": "拦截广告、跟踪器和恶意软件" + }, + "sounds": { + "title": "声音", + "description": "简短、安静的声音,告诉你发生了什么。", + "enabled": "界面音效", + "enabledDescription": "只有 Donut 窗口在前台时才会播放声音。", + "volume": "音量", + "preview": "试听", + "pack": "音效包", + "packs": { + "soft": { + "label": "柔和", + "description": "温暖圆润。" + }, + "crisp": { + "label": "清脆", + "description": "短促、明亮、清晰。" + }, + "retro": { + "label": "复古", + "description": "方波音色,像老式游戏机。" + } + }, + "categories": { + "interface": { + "label": "界面", + "description": "按钮、开关,以及打开或关闭页面。" + }, + "feedback": { + "label": "结果", + "description": "成功、错误、警告、复制、删除和恢复。" + }, + "events": { + "label": "事件", + "description": "配置文件启动和停止、同步、下载,以及需要你处理的请求。" + } + } + } }, "header": { "searchPlaceholder": "搜索配置文件...", @@ -940,7 +980,6 @@ "apiStartFailed": "启动 API 服务器失败", "tokenCopied": "令牌已复制", "mcpAcceptTermsFirst": "(请先在设置中接受 Wayfern 条款)", - "openSettings": "打开集成设置", "apiRunningOn": "运行于", "apiExampleRequest": "示例请求", "examples": { @@ -1760,7 +1799,6 @@ "proPlus": "Pro++", "ultimate": "终极", "settingsDescription": "DNS 拦截列表在代理级别拦截广告、跟踪器和恶意软件域名。列表每 12 小时自动刷新一次。", - "manageLists": "管理 DNS 拦截列表", "refreshAll": "刷新所有列表", "refreshFailed": "刷新 DNS 拦截列表失败", "domains": "个域名", diff --git a/src/lib/toast-utils.ts b/src/lib/toast-utils.ts index 5e9bb0ee..aa21eef0 100644 --- a/src/lib/toast-utils.ts +++ b/src/lib/toast-utils.ts @@ -3,7 +3,7 @@ import React from "react"; import { type ExternalToast, toast as sonnerToast } from "sonner"; import { UnifiedToast } from "@/components/custom-toast"; import i18n from "@/i18n"; -import { playUiSound } from "@/lib/ui-sounds"; +import { playUiSound, type UiSound } from "@/lib/ui-sounds"; interface BaseToastProps { id?: string; @@ -181,6 +181,8 @@ export function showDownloadToast( return; } + if (stage === "completed") playUiSound("download"); + // Only show cancel button during active downloading, not for completed/extracting/verifying const showCancel = stage === "downloading"; @@ -194,15 +196,19 @@ export function showDownloadToast( }); } +interface ResultToastOptions { + id?: string; + description?: string; + duration?: number; + /** The sound to play instead of the toast's own; null plays none. */ + sound?: UiSound | null; +} + export function showSuccessToast( title: string, - options?: { - id?: string; - description?: string; - duration?: number; - }, + { sound = "success", ...options }: ResultToastOptions = {}, ) { - playUiSound("success"); + if (sound) playUiSound(sound); return showToast({ type: "success", title, @@ -212,13 +218,9 @@ export function showSuccessToast( export function showErrorToast( title: string, - options?: { - id?: string; - description?: string; - duration?: number; - }, + { sound = "error", ...options }: ResultToastOptions = {}, ) { - playUiSound("error"); + if (sound) playUiSound(sound); return showToast({ type: "error", title, diff --git a/src/lib/ui-sounds.test.mjs b/src/lib/ui-sounds.test.mjs index 8908be6a..dfe03085 100644 --- a/src/lib/ui-sounds.test.mjs +++ b/src/lib/ui-sounds.test.mjs @@ -1,38 +1,150 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + clampVolume, + compileSound, + DEFAULT_SOUND_PACK, + DEFAULT_SOUND_VOLUME, + noteFrequency, + planSound, + resolvePack, + SOUND_CATEGORIES, + SOUND_CATEGORY, SOUND_CONTROL_SELECTOR, SOUND_GAP_MS, - SOUNDS, + SOUND_PACKS, soundForControl, + soundLength, + UI_SOUNDS, + volumeGain, } from "./ui-sounds.ts"; /** - * What is pinned here is "never annoying": every sound is short and quiet, - * starts without a click, can not repeat faster than its gap, and only the - * controls a person deliberately used make one. + * What is pinned here is "never annoying": every sound in every pack is + * short and quiet, starts without a click, can not repeat faster than its + * gap, only the controls a person deliberately used make one, and every + * setting that turns a sound off does turn it off. */ -const length = (voices) => - Math.max(...voices.map((v) => v.delay + v.attack + v.decay)); - -test("every sound is short, quiet and starts softly", () => { - for (const [name, voices] of Object.entries(SOUNDS)) { - assert.ok(voices.length > 0, name); - assert.ok(length(voices) <= 0.3, `${name} lasts ${length(voices)} s`); - for (const voice of voices) { - assert.ok(voice.peak > 0 && voice.peak <= 0.1, `${name} peak`); - assert.ok(voice.attack >= 0.001, `${name} must not start with a click`); +test("every sound exists in every pack, short, quiet and starting softly", () => { + assert.ok(UI_SOUNDS.length >= 15); + assert.deepEqual( + SOUND_PACKS.map((pack) => pack.id), + ["soft", "crisp", "retro"], + ); + for (const { id: pack } of SOUND_PACKS) { + for (const sound of UI_SOUNDS) { + const name = `${pack}/${sound}`; + const compiled = compileSound(sound, pack); + assert.ok(compiled.voices.length > 0, name); + assert.ok( + soundLength(compiled) <= 0.45, + `${name} lasts ${soundLength(compiled)} s`, + ); + for (const voice of compiled.voices) { + assert.ok(voice.peak > 0 && voice.peak <= 0.1, `${name} peak`); + assert.ok(voice.attack >= 0.001, `${name} must not start with a click`); + assert.ok(voice.pan >= -1 && voice.pan <= 1, `${name} pan`); + if (voice.wave !== "noise") { + assert.ok( + voice.frequency >= 150 && voice.frequency <= 7000, + `${name} pitch ${voice.frequency}`, + ); + } + } + assert.ok(compiled.lowpass > 0 && compiled.room >= 0, name); + assert.ok(SOUND_GAP_MS[sound] > 0, `${sound} needs a gap`); } - assert.ok(SOUND_GAP_MS[name] > 0, `${name} needs a gap`); + assert.ok( + soundLength(compileSound("tap", pack)) <= 0.04, + `a ${pack} tap is a tick, not a tone`, + ); } - assert.ok(length(SOUNDS.tap) <= 0.04, "a tap is a tick, not a tone"); assert.ok( SOUND_GAP_MS.error >= 1000 && SOUND_GAP_MS.success >= 1000, "a run of results makes one sound, not a burst", ); }); +test("the packs sound different from each other", () => { + const taps = SOUND_PACKS.map(({ id }) => compileSound("tap", id)); + const waves = new Set(taps.map((tap) => tap.voices[0].wave)); + const pitches = new Set(taps.map((tap) => tap.voices[0].frequency)); + assert.ok(waves.size > 1 || pitches.size > 1); + assert.ok(new Set(taps.map((tap) => tap.lowpass)).size === taps.length); +}); + +test("the notes stay on one pentatonic scale", () => { + assert.equal(noteFrequency(440, 0), 440); + assert.equal(noteFrequency(440, 5), 880); + assert.equal(noteFrequency(440, -5), 220); + assert.ok(Math.abs(noteFrequency(440, 3) - 440 * 2 ** (7 / 12)) < 1e-9); +}); + +test("an unknown pack falls back to the default", () => { + assert.equal(DEFAULT_SOUND_PACK, "soft"); + assert.equal(resolvePack("nope"), "soft"); + assert.equal(resolvePack(undefined), "soft"); + assert.equal(resolvePack("retro"), "retro"); + assert.deepEqual(compileSound("success", "nope"), compileSound("success")); + assert.deepEqual( + planSound("tap", { ui_sound_pack: "gone" })?.compiled, + compileSound("tap", "soft"), + ); +}); + +test("every sound belongs to a category, and each category has a sample", () => { + const ids = SOUND_CATEGORIES.map((category) => category.id); + assert.deepEqual(ids, ["interface", "feedback", "events"]); + for (const sound of UI_SOUNDS) { + assert.ok(ids.includes(SOUND_CATEGORY[sound]), sound); + } + for (const { id, sample } of SOUND_CATEGORIES) { + assert.equal(SOUND_CATEGORY[sample], id); + } +}); + +test("the switches turn sounds off", () => { + assert.ok(planSound("tap", {}), "everything is on by default"); + assert.equal(planSound("tap", { ui_sounds: false }), null); + assert.equal(planSound("launch", { ui_sounds: false }), null); + + const noInterface = { ui_sound_interface: false }; + assert.equal(planSound("tap", noInterface), null); + assert.equal(planSound("toggleOn", noInterface), null); + assert.ok(planSound("success", noInterface)); + assert.ok(planSound("launch", noInterface)); + + const noFeedback = { ui_sound_feedback: false }; + assert.equal(planSound("error", noFeedback), null); + assert.equal(planSound("copy", noFeedback), null); + assert.ok(planSound("tap", noFeedback)); + + const noEvents = { ui_sound_events: false }; + assert.equal(planSound("launch", noEvents), null); + assert.equal(planSound("attention", noEvents), null); + assert.ok(planSound("success", noEvents)); +}); + +test("volume 0 is silent, and the default is the tuned level", () => { + assert.equal(DEFAULT_SOUND_VOLUME, 60); + assert.equal(volumeGain(0), 0); + assert.equal(planSound("success", { ui_sound_volume: 0 }), null); + assert.equal(planSound("success", { ui_sound_volume: -20 }), null); + assert.equal(volumeGain(DEFAULT_SOUND_VOLUME), 0.6); + assert.ok(volumeGain(30) < volumeGain(60)); + assert.ok(volumeGain(100) > volumeGain(60)); + assert.equal(volumeGain(500), volumeGain(100)); + assert.equal(clampVolume(undefined), DEFAULT_SOUND_VOLUME); + assert.equal(clampVolume(Number.NaN), DEFAULT_SOUND_VOLUME); + assert.equal(clampVolume(42.6), 43); + assert.equal(clampVolume(140), 100); + assert.equal( + planSound("tap", { ui_sound_volume: 100 })?.gain, + volumeGain(100), + ); +}); + const control = (overrides) => ({ role: null, tag: "button", diff --git a/src/lib/ui-sounds.ts b/src/lib/ui-sounds.ts index 6a3c3e94..b1544c41 100644 --- a/src/lib/ui-sounds.ts +++ b/src/lib/ui-sounds.ts @@ -2,133 +2,496 @@ * Interface sounds: short, quiet, synthesized at play time. Nothing is * recorded or loaded, and a sound only plays while the window is in front. * - * Each sound is a few voices: a sine, triangle or noise burst with a soft - * attack (no click at the start) and an exponential decay, like a small - * physical switch. Every play shifts the pitch and level a little, so a run - * of presses never sounds like a machine repeating one sample. + * Every sound is a short phrase on one major pentatonic scale, so two sounds + * that overlap never clash. A pack gives the phrases their timbre: the wave, + * an overtone, how fast a note starts and fades, how bright it is, and how + * much short room it has. The phrases are the same in every pack, so a sound + * keeps its meaning when the pack changes. Every play shifts the pitch and + * level a little, so a run of presses never sounds like one sample repeated. */ -export type UiSound = "tap" | "toggleOn" | "toggleOff" | "success" | "error"; +export type UiSound = + | "tap" + | "toggleOn" + | "toggleOff" + | "open" + | "close" + | "drop" + | "success" + | "error" + | "warning" + | "copy" + | "trash" + | "restore" + | "launch" + | "stop" + | "synced" + | "download" + | "attention"; + +export type SoundCategory = "interface" | "feedback" | "events"; + +/** Which switch in Settings turns a sound on or off. */ +export const SOUND_CATEGORY: Record = { + tap: "interface", + toggleOn: "interface", + toggleOff: "interface", + open: "interface", + close: "interface", + drop: "interface", + success: "feedback", + error: "feedback", + warning: "feedback", + copy: "feedback", + trash: "feedback", + restore: "feedback", + launch: "events", + stop: "events", + synced: "events", + download: "events", + attention: "events", +}; + +export const UI_SOUNDS = Object.keys(SOUND_CATEGORY) as UiSound[]; + +/** The categories in display order, each with the sound its preview plays. */ +export const SOUND_CATEGORIES: readonly { + id: SoundCategory; + sample: UiSound; +}[] = [ + { id: "interface", sample: "toggleOn" }, + { id: "feedback", sample: "success" }, + { id: "events", sample: "launch" }, +]; + +export type SoundPackId = "soft" | "crisp" | "retro"; + +/** The packs in display order. The first one is the default. */ +export const SOUND_PACKS: readonly { id: SoundPackId }[] = [ + { id: "soft" }, + { id: "crisp" }, + { id: "retro" }, +]; + +export const DEFAULT_SOUND_PACK: SoundPackId = "soft"; +export const DEFAULT_SOUND_VOLUME = 60; + +/** The sound fields of the app settings. A missing field is its default. */ +export interface SoundSettings { + ui_sounds?: boolean; + ui_sound_volume?: number; + ui_sound_interface?: boolean; + ui_sound_feedback?: boolean; + ui_sound_events?: boolean; + ui_sound_pack?: string; +} export interface SoundVoice { - wave: "sine" | "triangle" | "noise"; + wave: "sine" | "triangle" | "square" | "noise"; /** Hz at the start. Ignored for noise. */ frequency: number; - /** Hz at the end of the decay, for a slight glide. */ + /** Hz at the end of the decay, for a slide. */ endFrequency?: number; /** Seconds after the sound starts. */ delay: number; attack: number; decay: number; - /** Peak level before the master level. */ + /** Peak level before the volume. */ peak: number; - /** A band-pass that shapes a noise burst into a soft tick. */ + /** A band-pass that shapes a noise burst into a tick or a swish. */ bandPass?: { frequency: number; q: number }; + /** -1 is left, 1 is right. */ + pan: number; } -export const SOUNDS: Record = { - tap: [ - { - wave: "sine", - frequency: 1750, - delay: 0, - attack: 0.002, - decay: 0.03, - peak: 0.07, - }, - { +/** One sound in one pack, ready to schedule. */ +export interface CompiledSound { + voices: SoundVoice[]; + /** Cut-off of the low-pass that the whole sound goes through. */ + lowpass: number; + /** Level sent into the short room. */ + room: number; + /** How far one play may shift the pitch, as a ratio either way. */ + jitter: number; +} + +interface Note { + /** Steps on the pack's pentatonic scale: 0 is the root, 5 an octave up. */ + step: number; + /** Seconds after the sound starts. */ + at: number; + /** Seconds the note takes to fade. */ + length: number; + /** 0 to 1, before the pack level. */ + level: number; + /** Semitones the pitch slides over the note. */ + slide?: number; +} + +interface Phrase { + notes: Note[]; + /** A noise tick at the start, 0 to 1: the feel of a key going down. */ + touch?: number; + /** A band of noise that swells and fades under the notes: a swish. */ + air?: { length: number; level: number; frequency: number }; + /** Pitch variation per play. A tick varies more than a tune. */ + jitter?: number; +} + +/** + * The phrases. Each sound has its own contour, so it is recognized without + * looking: a press is one falling tick, a result goes up (success) or down + * (error) a short way, a launch climbs four notes and a stop comes back down, + * a warning repeats one unresolved note, and a copy repeats one high note. + */ +const PHRASES: Record = { + tap: { + notes: [{ step: 4, at: 0, length: 0.026, level: 0.75, slide: -2 }], + touch: 0.9, + jitter: 0.035, + }, + toggleOn: { + notes: [ + { step: 4, at: 0, length: 0.03, level: 0.7 }, + { step: 7, at: 0.045, length: 0.045, level: 0.65 }, + ], + touch: 0.5, + }, + toggleOff: { + notes: [ + { step: 7, at: 0, length: 0.03, level: 0.65 }, + { step: 4, at: 0.045, length: 0.045, level: 0.6 }, + ], + touch: 0.5, + }, + open: { + notes: [{ step: 3, at: 0, length: 0.07, level: 0.3, slide: 2 }], + air: { length: 0.07, level: 0.3, frequency: 1800 }, + }, + close: { + notes: [{ step: 3, at: 0, length: 0.06, level: 0.28, slide: -2 }], + air: { length: 0.06, level: 0.28, frequency: 1400 }, + }, + drop: { + notes: [ + { step: -3, at: 0, length: 0.07, level: 0.9, slide: -3 }, + { step: 2, at: 0, length: 0.03, level: 0.25 }, + ], + touch: 0.7, + jitter: 0.02, + }, + success: { + notes: [ + { step: 3, at: 0, length: 0.08, level: 0.65 }, + { step: 5, at: 0.06, length: 0.09, level: 0.65 }, + { step: 7, at: 0.12, length: 0.18, level: 0.6 }, + ], + }, + error: { + notes: [ + { step: -2, at: 0, length: 0.11, level: 0.9, slide: -0.6 }, + { step: -3, at: 0.1, length: 0.17, level: 0.85, slide: -0.8 }, + ], + }, + warning: { + notes: [ + { step: 1, at: 0, length: 0.09, level: 0.7 }, + { step: 1, at: 0.12, length: 0.12, level: 0.65 }, + ], + }, + copy: { + notes: [ + { step: 7, at: 0, length: 0.03, level: 0.5 }, + { step: 7, at: 0.055, length: 0.04, level: 0.45 }, + ], + touch: 0.35, + }, + trash: { + notes: [{ step: 0, at: 0, length: 0.12, level: 0.5, slide: -5 }], + air: { length: 0.09, level: 0.5, frequency: 1100 }, + }, + restore: { + notes: [ + { step: 0, at: 0, length: 0.1, level: 0.45, slide: 5 }, + { step: 7, at: 0.08, length: 0.12, level: 0.4 }, + ], + air: { length: 0.07, level: 0.3, frequency: 1600 }, + }, + launch: { + notes: [ + { step: 0, at: 0, length: 0.06, level: 0.5 }, + { step: 3, at: 0.04, length: 0.06, level: 0.55 }, + { step: 5, at: 0.08, length: 0.07, level: 0.55 }, + { step: 8, at: 0.12, length: 0.2, level: 0.5 }, + ], + }, + stop: { + notes: [ + { step: 5, at: 0, length: 0.07, level: 0.5 }, + { step: 3, at: 0.05, length: 0.08, level: 0.5 }, + { step: 0, at: 0.1, length: 0.16, level: 0.5 }, + ], + }, + synced: { + notes: [ + { step: 5, at: 0, length: 0.08, level: 0.5 }, + { step: 3, at: 0.07, length: 0.08, level: 0.45 }, + { step: 7, at: 0.14, length: 0.18, level: 0.5 }, + ], + }, + download: { + notes: [ + { step: 8, at: 0, length: 0.08, level: 0.45 }, + { step: 5, at: 0.08, length: 0.24, level: 0.6 }, + ], + touch: 0.25, + }, + attention: { + notes: [ + { step: 5, at: 0, length: 0.12, level: 0.55 }, + { step: 9, at: 0.11, length: 0.26, level: 0.55 }, + ], + }, +}; + +interface Pack { + /** Hz of step 0. */ + root: number; + wave: "sine" | "triangle" | "square"; + /** One more sine on every note: its ratio to the note, its level, and how much sooner it fades. */ + partial: { ratio: number; level: number; length: number } | null; + attack: number; + /** Multiplies every note length. */ + length: number; + /** Multiplies every note start, so a pack can play a phrase tighter. */ + tempo: number; + /** Multiplies every peak: a square sounds louder than a sine at one peak. */ + level: number; + touch: { frequency: number; q: number; level: number; length: number }; + lowpass: number; + room: number; + /** How far notes spread left (low) and right (high), 0 to 1. */ + width: number; +} + +/** + * Soft is round and warm: sines with a quiet octave, slow starts, a dark + * low-pass and a little room. Crisp is a tone higher, short and glassy: a + * bell-like overtone, quick starts, no low-pass to speak of and almost dry. + * Retro is an old game console: square waves, filtered so they never buzz, + * tight timing, centered and dry. + */ +const PACKS: Record = { + soft: { + root: 587.33, + wave: "sine", + partial: { ratio: 2, level: 0.2, length: 0.45 }, + attack: 0.004, + length: 1, + tempo: 1, + level: 1, + touch: { frequency: 2000, q: 0.8, level: 1, length: 0.012 }, + lowpass: 5200, + room: 0.2, + width: 0.35, + }, + crisp: { + root: 659.25, + wave: "sine", + partial: { ratio: 2.76, level: 0.3, length: 0.3 }, + attack: 0.0015, + length: 0.7, + tempo: 0.85, + level: 0.8, + touch: { frequency: 4200, q: 1.6, level: 1.1, length: 0.007 }, + lowpass: 11000, + room: 0.1, + width: 0.2, + }, + retro: { + root: 587.33, + wave: "square", + partial: null, + attack: 0.002, + length: 0.85, + tempo: 0.9, + level: 0.45, + touch: { frequency: 5200, q: 0.6, level: 0.6, length: 0.008 }, + lowpass: 3000, + room: 0, + width: 0, + }, +}; + +const NOTE_PEAK = 0.08; +const TOUCH_PEAK = 0.045; +const AIR_PEAK = 0.06; +const PENTATONIC = [0, 2, 4, 7, 9]; + +function semitones(step: number) { + const octave = Math.floor(step / 5); + return octave * 12 + PENTATONIC[step - octave * 5]; +} + +/** The pitch of `step` on the major pentatonic scale of `root`. */ +export function noteFrequency(root: number, step: number) { + return root * 2 ** (semitones(step) / 12); +} + +/** A known pack id, or the default pack. */ +export function resolvePack(id: string | undefined): SoundPackId { + return SOUND_PACKS.find((pack) => pack.id === id)?.id ?? DEFAULT_SOUND_PACK; +} + +export function compileSound(sound: UiSound, packId?: string): CompiledSound { + const pack = PACKS[resolvePack(packId)]; + const phrase = PHRASES[sound]; + const voices: SoundVoice[] = []; + for (const note of phrase.notes) { + const frequency = noteFrequency(pack.root, note.step); + const slide = note.slide ? 2 ** (note.slide / 12) : null; + const delay = note.at * pack.tempo; + const decay = note.length * pack.length; + const peak = NOTE_PEAK * note.level * pack.level; + const pan = Math.max(-1, Math.min(1, (note.step - 4) / 8)) * pack.width; + voices.push({ + wave: pack.wave, + frequency, + ...(slide ? { endFrequency: frequency * slide } : {}), + delay, + attack: pack.attack, + decay, + peak, + pan, + }); + if (pack.partial) { + const { ratio, level, length } = pack.partial; + voices.push({ + wave: "sine", + frequency: frequency * ratio, + ...(slide ? { endFrequency: frequency * ratio * slide } : {}), + delay, + attack: pack.attack, + decay: decay * length, + peak: peak * level, + pan, + }); + } + } + if (phrase.touch) { + voices.push({ wave: "noise", frequency: 0, delay: 0, attack: 0.001, - decay: 0.012, - peak: 0.035, - bandPass: { frequency: 3600, q: 1.1 }, - }, - ], - toggleOn: [ - { - wave: "sine", - frequency: 1100, + decay: pack.touch.length, + peak: TOUCH_PEAK * phrase.touch * pack.touch.level, + bandPass: { frequency: pack.touch.frequency, q: pack.touch.q }, + pan: 0, + }); + } + if (phrase.air) { + const { length, level, frequency } = phrase.air; + voices.push({ + wave: "noise", + frequency: 0, delay: 0, - attack: 0.002, - decay: 0.035, - peak: 0.06, + attack: length * 0.35, + decay: length * 0.65, + peak: AIR_PEAK * level, + bandPass: { frequency, q: 0.7 }, + pan: 0, + }); + } + return { + voices, + lowpass: pack.lowpass, + room: pack.room, + jitter: phrase.jitter ?? 0.008, + }; +} + +/** Seconds from the start of a sound to the end of its last voice. */ +export function soundLength(compiled: CompiledSound) { + return Math.max( + ...compiled.voices.map((voice) => voice.delay + voice.attack + voice.decay), + ); +} + +/** 0 to 100, whole numbers. Anything else is the default. */ +export function clampVolume(volume: number | undefined) { + if (typeof volume !== "number" || !Number.isFinite(volume)) { + return DEFAULT_SOUND_VOLUME; + } + return Math.round(Math.max(0, Math.min(100, volume))); +} + +/** + * The output level for a volume. Loudness follows the square of the + * setting, so the low half of the slider is usable, and the default is the + * level the sounds were tuned at. + */ +export function volumeGain(volume: number) { + const ratio = clampVolume(volume) / DEFAULT_SOUND_VOLUME; + return 0.6 * ratio * ratio; +} + +export interface ResolvedSoundSettings { + enabled: boolean; + volume: number; + pack: SoundPackId; + categories: Record; +} + +export function resolveSoundSettings( + settings: SoundSettings, +): ResolvedSoundSettings { + return { + enabled: settings.ui_sounds ?? true, + volume: clampVolume(settings.ui_sound_volume), + pack: resolvePack(settings.ui_sound_pack), + categories: { + interface: settings.ui_sound_interface ?? true, + feedback: settings.ui_sound_feedback ?? true, + events: settings.ui_sound_events ?? true, }, - { - wave: "sine", - frequency: 1650, - delay: 0.045, - attack: 0.002, - decay: 0.05, - peak: 0.06, - }, - ], - toggleOff: [ - { - wave: "sine", - frequency: 1650, - delay: 0, - attack: 0.002, - decay: 0.035, - peak: 0.055, - }, - { - wave: "sine", - frequency: 1100, - delay: 0.045, - attack: 0.002, - decay: 0.05, - peak: 0.055, - }, - ], - success: [ - { - wave: "sine", - frequency: 880, - delay: 0, - attack: 0.004, - decay: 0.16, - peak: 0.05, - }, - { - wave: "sine", - frequency: 1318.5, - delay: 0.07, - attack: 0.004, - decay: 0.22, - peak: 0.045, - }, - ], - error: [ - { - wave: "triangle", - frequency: 330, - endFrequency: 247, - delay: 0, - attack: 0.004, - decay: 0.14, - peak: 0.08, - }, - { - wave: "triangle", - frequency: 262, - endFrequency: 196, - delay: 0.09, - attack: 0.004, - decay: 0.16, - peak: 0.06, - }, - ], -}; + }; +} + +/** What one play of `sound` would be, or null when the settings keep it silent. */ +export function planSound( + sound: UiSound, + settings: SoundSettings | ResolvedSoundSettings, +): { compiled: CompiledSound; gain: number } | null { + const resolved = + "categories" in settings ? settings : resolveSoundSettings(settings); + if (!resolved.enabled || !resolved.categories[SOUND_CATEGORY[sound]]) { + return null; + } + const gain = volumeGain(resolved.volume); + if (gain <= 0) return null; + return { compiled: compileSound(sound, resolved.pack), gain }; +} /** The shortest time between two plays of one sound, in ms. */ export const SOUND_GAP_MS: Record = { tap: 35, toggleOn: 60, toggleOff: 60, + open: 120, + close: 120, + drop: 150, + copy: 250, + trash: 400, + restore: 400, success: 1200, error: 1200, + warning: 1200, + launch: 1500, + stop: 1500, + synced: 2000, + download: 2000, + attention: 3000, }; /** What the sound picker needs to know about the control that was used. */ @@ -197,65 +560,103 @@ export const SOUND_CONTROL_SELECTOR = [ ...[...PRESSABLE_ROLES, ...TOGGLE_ROLES].map((role) => `[role="${role}"]`), ].join(","); -let enabled = true; -let context: AudioContext | null = null; -let output: GainNode | null = null; -let noise: AudioBuffer | null = null; -const lastPlayed = new Map(); +/** + * How long after the window lost focus a sound the person caused may still + * play: a launch hands the focus to the browser it opened before it returns. + */ +const HANDOFF_MS = 3000; +const PREVIEW_GAP_MS = 80; -export function setUiSoundsEnabled(on: boolean) { - enabled = on; +let active = resolveSoundSettings({}); +let lastBlurAt = Number.NEGATIVE_INFINITY; +let lastPreviewAt = Number.NEGATIVE_INFINITY; +const lastPlayed = new Map(); +const lastBatch = new Map(); + +interface Engine { + context: AudioContext; + output: AudioNode; + room: AudioNode | null; + noise: AudioBuffer; +} +let engine: Engine | null = null; + +/** Use the sound fields of the app settings from now on. */ +export function applySoundSettings(settings: SoundSettings) { + active = resolveSoundSettings(settings); } -function audio(): AudioContext | null { - if (context) return context; +/** A short, dark room: decaying noise, different in each ear so it is wide. */ +function roomImpulse(context: AudioContext) { + const length = Math.ceil(context.sampleRate * 0.4); + const buffer = context.createBuffer(2, length, context.sampleRate); + for (let channel = 0; channel < 2; channel++) { + const samples = buffer.getChannelData(channel); + let smooth = 0; + for (let i = 0; i < length; i++) { + smooth += 0.25 * (Math.random() * 2 - 1 - smooth); + samples[i] = smooth * Math.exp(-i / context.sampleRate / 0.085); + } + } + return buffer; +} + +function audio(): Engine | null { + if (engine) return engine; const Context = window.AudioContext; if (!Context) return null; - context = new Context({ latencyHint: "interactive" }); - // A gentle low-pass takes the edge off every voice; the master level keeps - // the whole set well under anything else a computer plays. - const soften = context.createBiquadFilter(); - soften.type = "lowpass"; - soften.frequency.value = 7000; - output = context.createGain(); - output.gain.value = 0.6; - output.connect(soften); - soften.connect(context.destination); - noise = context.createBuffer( + const context = new Context({ latencyHint: "interactive" }); + // A limiter keeps overlapping sounds at full volume from clipping. + const limiter = context.createDynamicsCompressor(); + limiter.threshold.value = -6; + limiter.knee.value = 6; + limiter.ratio.value = 12; + limiter.attack.value = 0.002; + limiter.release.value = 0.12; + limiter.connect(context.destination); + let room: AudioNode | null = null; + try { + const convolver = context.createConvolver(); + convolver.buffer = roomImpulse(context); + convolver.connect(limiter); + room = convolver; + } catch { + room = null; + } + const noise = context.createBuffer( 1, - Math.ceil(context.sampleRate * 0.05), + Math.ceil(context.sampleRate * 0.25), context.sampleRate, ); const samples = noise.getChannelData(0); for (let i = 0; i < samples.length; i++) samples[i] = Math.random() * 2 - 1; - return context; + engine = { context, output: limiter, room, noise }; + return engine; } function schedule( - ctx: AudioContext, + { context, noise }: Engine, voice: SoundVoice, at: number, pitch: number, - level: number, + bus: AudioNode, ) { - if (!output) return; const start = at + voice.delay; const end = start + voice.attack + voice.decay; - const envelope = ctx.createGain(); + const envelope = context.createGain(); envelope.gain.setValueAtTime(0, start); - envelope.gain.linearRampToValueAtTime( - voice.peak * level, - start + voice.attack, - ); + envelope.gain.linearRampToValueAtTime(voice.peak, start + voice.attack); envelope.gain.exponentialRampToValueAtTime(0.0001, end); let source: AudioScheduledSourceNode; if (voice.wave === "noise") { - const buffer = ctx.createBufferSource(); + const buffer = context.createBufferSource(); buffer.buffer = noise; + // A different stretch of the noise each play. + buffer.start(start, Math.random() * 0.1); source = buffer; } else { - const oscillator = ctx.createOscillator(); + const oscillator = context.createOscillator(); oscillator.type = voice.wave; oscillator.frequency.setValueAtTime(voice.frequency * pitch, start); if (voice.endFrequency) { @@ -264,11 +665,12 @@ function schedule( end, ); } + oscillator.start(start); source = oscillator; } let last: AudioNode = source; if (voice.bandPass) { - const band = ctx.createBiquadFilter(); + const band = context.createBiquadFilter(); band.type = "bandpass"; band.frequency.value = voice.bandPass.frequency * pitch; band.Q.value = voice.bandPass.q; @@ -276,14 +678,74 @@ function schedule( last = band; } last.connect(envelope); - envelope.connect(output); - source.start(start); + last = envelope; + if (voice.pan !== 0 && typeof context.createStereoPanner === "function") { + const panner = context.createStereoPanner(); + panner.pan.value = voice.pan; + last.connect(panner); + last = panner; + } + last.connect(bus); source.stop(end + 0.02); } -export function playUiSound(sound: UiSound) { - if (!enabled || typeof window === "undefined") return; - if (document.visibilityState !== "visible" || !document.hasFocus()) return; +function render(compiled: CompiledSound, gain: number) { + const sound = audio(); + if (!sound) return; + const { context } = sound; + if (context.state === "suspended") void context.resume(); + const pitch = 1 + (Math.random() - 0.5) * 2 * compiled.jitter; + const at = context.currentTime + 0.004; + const bus = context.createGain(); + bus.gain.value = gain * (1 + (Math.random() - 0.5) * 0.16); + const tone = context.createBiquadFilter(); + tone.type = "lowpass"; + tone.frequency.value = compiled.lowpass; + tone.Q.value = 0.5; + bus.connect(tone); + tone.connect(sound.output); + if (sound.room && compiled.room > 0) { + const send = context.createGain(); + send.gain.value = compiled.room; + tone.connect(send); + send.connect(sound.room); + } + for (const voice of compiled.voices) { + schedule(sound, voice, at, pitch, bus); + } + window.setTimeout( + () => tone.disconnect(), + (soundLength(compiled) + 0.5) * 1000, + ); +} + +function inFront(causedAt: number | undefined) { + if (document.visibilityState !== "visible") return false; + if (document.hasFocus()) return true; + return ( + causedAt !== undefined && + lastBlurAt >= causedAt && + performance.now() - lastBlurAt < HANDOFF_MS + ); +} + +export interface PlayOptions { + /** + * `performance.now()` when the person started the action. The sound still + * plays when the action itself took the focus away a moment ago, as a + * launch does when its browser window opens. + */ + causedAt?: number; + /** One id per bulk run: only the first play of a sound in a run sounds. */ + batch?: string | number; +} + +export function playUiSound(sound: UiSound, options: PlayOptions = {}) { + if (typeof window === "undefined") return; + const plan = planSound(sound, active); + if (!plan || !inFront(options.causedAt)) return; + const { batch } = options; + if (batch !== undefined && lastBatch.get(sound) === batch) return; const now = performance.now(); if ( now - (lastPlayed.get(sound) ?? Number.NEGATIVE_INFINITY) < @@ -292,19 +754,34 @@ export function playUiSound(sound: UiSound) { return; } lastPlayed.set(sound, now); + if (batch !== undefined) lastBatch.set(sound, batch); try { - const ctx = audio(); - if (!ctx) return; - if (ctx.state === "suspended") void ctx.resume(); - const pitch = 1 + (Math.random() - 0.5) * 0.08; - const level = 1 + (Math.random() - 0.5) * 0.2; - const at = ctx.currentTime + 0.004; - for (const voice of SOUNDS[sound]) schedule(ctx, voice, at, pitch, level); + render(plan.compiled, plan.gain); } catch (error) { console.debug("UI sound not played:", error); } } +/** + * Plays `sound` with the draft's pack and volume, ignoring the master switch + * and categories. Used by Settings previews. + */ +export function previewUiSound(sound: UiSound, draft: SoundSettings) { + if (typeof window === "undefined") return; + if (document.visibilityState !== "visible" || !document.hasFocus()) return; + const settings = resolveSoundSettings(draft); + const gain = volumeGain(settings.volume); + if (gain <= 0) return; + const now = performance.now(); + if (now - lastPreviewAt < PREVIEW_GAP_MS) return; + lastPreviewAt = now; + try { + render(compileSound(sound, settings.pack), gain); + } catch (error) { + console.debug("UI sound preview not played:", error); + } +} + function controlInfo(element: Element): ControlInfo { return { role: element.getAttribute("role"), @@ -318,7 +795,10 @@ function controlInfo(element: Element): ControlInfo { }; } -/** Listen for presses and toggles across the whole window. */ +/** + * Listen across the whole window for presses, toggles, and Escape closing a + * dialog or page. + */ export function installUiSounds(): () => void { const control = (target: EventTarget | null) => target instanceof Element ? target.closest(SOUND_CONTROL_SELECTOR) : null; @@ -345,14 +825,40 @@ export function installUiSounds(): () => void { if (sound) playUiSound(sound); }); }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.repeat) return; + const dialog = + event.target instanceof Element + ? event.target.closest('[role="dialog"],[role="alertdialog"]') + : null; + if (!dialog) return; + requestAnimationFrame(() => { + if ( + !dialog.isConnected || + dialog.getAttribute("data-state") === "closed" + ) { + playUiSound("close"); + } + }); + }; + const onBlur = (event: FocusEvent) => { + if (event.target === window) lastBlurAt = performance.now(); + }; window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: true, }); window.addEventListener("click", onClick, { capture: true, passive: true }); + window.addEventListener("keydown", onKeyDown, { + capture: true, + passive: true, + }); + window.addEventListener("blur", onBlur); return () => { window.removeEventListener("pointerdown", onPointerDown, { capture: true }); window.removeEventListener("click", onClick, { capture: true }); + window.removeEventListener("keydown", onKeyDown, { capture: true }); + window.removeEventListener("blur", onBlur); }; } diff --git a/src/styles/globals.css b/src/styles/globals.css index 7c04ebed..7b338d44 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -66,7 +66,9 @@ --color-sidebar-ring: var(--sidebar-ring); } -:root { +/* `.light` scopes the light palette to one element, for theme previews. */ +:root, +.light { --radius: 0.625rem; --ease-out: cubic-bezier(0.23, 1, 0.32, 1); --background: oklch(1 0 0);