mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 06:51:24 +02:00
feat: settings and sounds
This commit is contained in:
1 parent
3be7658e49
commit
3d8379453e
37 files changed
+4146
-1790
No files matched your search
+27
-24
@@ -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(
|
||||
|
||||
+58
-19
@@ -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(
|
||||
|
||||
@@ -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<u8, D::Error>
|
||||
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::<AppSettings>(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]
|
||||
|
||||
+23
-3
@@ -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<GateDecision>((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<BrowserProfile>("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<BrowserProfile>("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);
|
||||
|
||||
@@ -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<SoundSettings>("get_app_settings")
|
||||
.then(applySoundSettings)
|
||||
.catch(() => {});
|
||||
return installUiSounds();
|
||||
}, []);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
+907
-1364
File diff suppressed because it is too large.
Load diff
@@ -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<boolean>("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 (
|
||||
<SettingsCard>
|
||||
<SettingRow
|
||||
icon={
|
||||
<RowIcon>
|
||||
<LuLock />
|
||||
</RowIcon>
|
||||
}
|
||||
label={t("settings.encryption.noPassword")}
|
||||
description={t("settings.encryption.requiresProOrOwner")}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCard>
|
||||
<SettingRow
|
||||
slot="settings-encryption-status"
|
||||
icon={
|
||||
<RowIcon tone={hasPassword ? "success" : "muted"}>
|
||||
{hasPassword ? <LuLockKeyhole /> : <LuLock />}
|
||||
</RowIcon>
|
||||
}
|
||||
label={
|
||||
hasPassword
|
||||
? t("settings.encryption.passwordSetDescription")
|
||||
: t("settings.encryption.noPassword")
|
||||
}
|
||||
description={t("settings.encryption.description")}
|
||||
>
|
||||
{hasPassword && (
|
||||
<>
|
||||
<Button
|
||||
variant="soft"
|
||||
size="sm"
|
||||
disabled={isRemoving}
|
||||
onClick={() => {
|
||||
setVerifyPassword("");
|
||||
setIsVerifyOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.validatePassword")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="soft"
|
||||
size="sm"
|
||||
disabled={isRemoving}
|
||||
aria-expanded={changing}
|
||||
onClick={() => {
|
||||
setChanging((open) => !open);
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.changePassword")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="soft"
|
||||
size="sm"
|
||||
className="h-8 text-destructive-text hover:bg-destructive/10"
|
||||
isLoading={isRemoving}
|
||||
onClick={() => {
|
||||
void removePassword();
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.removePassword")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
</SettingRow>
|
||||
<AnimatedDisclosureContent open={showForm}>
|
||||
<form
|
||||
className="flex flex-col gap-3 px-4 py-3.5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void savePassword();
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-2 @lg:grid-cols-2">
|
||||
<Input
|
||||
type="password"
|
||||
variant="soft"
|
||||
autoComplete="new-password"
|
||||
aria-label={t("settings.encryption.passwordPlaceholder")}
|
||||
placeholder={t("settings.encryption.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
variant="soft"
|
||||
autoComplete="new-password"
|
||||
aria-label={t("settings.encryption.confirmPlaceholder")}
|
||||
placeholder={t("settings.encryption.confirmPlaceholder")}
|
||||
value={confirm}
|
||||
onChange={(event) => {
|
||||
setConfirm(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<ul className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<RuleCheck
|
||||
done={longEnough}
|
||||
label={t("settings.encryption.ruleLength", {
|
||||
min: MIN_PASSWORD_LENGTH,
|
||||
})}
|
||||
/>
|
||||
<RuleCheck
|
||||
done={matches}
|
||||
label={t("settings.encryption.ruleMatch")}
|
||||
/>
|
||||
</ul>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{changing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => setChanging(false)}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
isLoading={isSaving}
|
||||
disabled={!longEnough || !matches}
|
||||
>
|
||||
{t("settings.encryption.setPassword")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-destructive-text">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</AnimatedDisclosureContent>
|
||||
</SettingsCard>
|
||||
|
||||
<Dialog
|
||||
open={isVerifyOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (isVerifying) return;
|
||||
setIsVerifyOpen(open);
|
||||
if (!open) setVerifyPassword("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("settings.encryption.validateDialog.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.encryption.validateDialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
id="settings-verify-e2e"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (verifyPassword.length > 0) void verify();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("settings.encryption.passwordPlaceholder")}
|
||||
value={verifyPassword}
|
||||
autoFocus
|
||||
onChange={(event) => setVerifyPassword(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isVerifying}
|
||||
onClick={() => {
|
||||
setIsVerifyOpen(false);
|
||||
setVerifyPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
form="settings-verify-e2e"
|
||||
isLoading={isVerifying}
|
||||
disabled={verifyPassword.length === 0}
|
||||
>
|
||||
{t("settings.encryption.validateDialog.submit")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section
|
||||
data-settings-section={id}
|
||||
tabIndex={-1}
|
||||
hidden={hidden}
|
||||
aria-labelledby={titleId}
|
||||
className="@container flex scroll-mt-3 flex-col gap-3 focus:outline-none"
|
||||
>
|
||||
<header className="flex min-h-8 items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-active={active}
|
||||
className={cn(
|
||||
"grid size-7 shrink-0 place-items-center rounded-lg transition-colors duration-200 [&_svg]:size-3.5",
|
||||
active
|
||||
? "bg-primary/12 text-primary-text"
|
||||
: "bg-foreground/6 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<h2 id={titleId} className="text-sm font-semibold tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{aside && (
|
||||
<div className="ml-auto flex items-center gap-2">{aside}</div>
|
||||
)}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A quiet card that holds rows separated by hairlines. */
|
||||
export function SettingsCard({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"divide-y divide-foreground/6 rounded-xl bg-foreground/3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Marks a setting whose draft differs from what is saved. */
|
||||
export function ChangedDot({ show }: { show: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const animate = useSettingsMotion();
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{show && (
|
||||
<motion.span
|
||||
role="img"
|
||||
aria-label={t("settings.changed")}
|
||||
title={t("settings.changed")}
|
||||
data-slot="settings-changed-dot"
|
||||
initial={animate ? { scale: 0, opacity: 0 } : false}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={animate ? { scale: 0, opacity: 0 } : { opacity: 0 }}
|
||||
transition={
|
||||
animate
|
||||
? { type: "spring", stiffness: 600, damping: 28 }
|
||||
: { duration: 0 }
|
||||
}
|
||||
className="inline-block size-1.5 shrink-0 rounded-full bg-primary"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
data-slot={slot}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-between gap-x-6 gap-y-2.5 px-4 py-3.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-[min(100%,15rem)] flex-1 items-start gap-3">
|
||||
{icon}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Label
|
||||
htmlFor={htmlFor}
|
||||
className="inline-flex items-center gap-2 text-sm leading-snug font-medium"
|
||||
>
|
||||
{label}
|
||||
<ChangedDot show={changed} />
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="text-xs leading-relaxed text-pretty text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex flex-col gap-3 px-4 py-3.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="inline-flex items-center gap-2 text-sm leading-snug font-medium">
|
||||
{label}
|
||||
<ChangedDot show={changed} />
|
||||
</span>
|
||||
{description && (
|
||||
<p className="text-xs leading-relaxed text-pretty text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg transition-colors duration-200 [&_svg]:size-4",
|
||||
{
|
||||
muted: "bg-foreground/6 text-muted-foreground",
|
||||
primary: "bg-primary/12 text-primary-text",
|
||||
success: "bg-success/12 text-success-text",
|
||||
warning: "bg-warning/14 text-warning-text",
|
||||
}[tone],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChoiceContextValue {
|
||||
value: string;
|
||||
select: (value: string) => void;
|
||||
indicatorId: string;
|
||||
animate: boolean;
|
||||
}
|
||||
|
||||
const ChoiceContext = createContext<ChoiceContextValue | null>(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<HTMLElement>) {
|
||||
const step = (
|
||||
{
|
||||
ArrowRight: 1,
|
||||
ArrowDown: 1,
|
||||
ArrowLeft: -1,
|
||||
ArrowUp: -1,
|
||||
} as Record<string, number>
|
||||
)[event.key];
|
||||
if (!step) return;
|
||||
const items = [
|
||||
...event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
||||
'[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 (
|
||||
<ChoiceContext.Provider
|
||||
value={{ value, select: onValueChange, indicatorId, animate }}
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
data-slot={slot}
|
||||
onKeyDown={moveChoice}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ChoiceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
aria-label={label}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
data-slot={slot}
|
||||
data-value={value}
|
||||
data-state={selected ? "checked" : "unchecked"}
|
||||
onClick={() => {
|
||||
if (!selected) select(value);
|
||||
}}
|
||||
className={cn(
|
||||
"group/choice relative isolate cursor-pointer rounded-xl text-left outline-none",
|
||||
"transition-[background-color,translate] duration-150 ease-[var(--ease-out)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
data-slot="settings-choice-indicator"
|
||||
layoutId={animate ? `choice-${indicatorId}` : undefined}
|
||||
initial={false}
|
||||
transition={animate ? MOTION_SPRING_POSITION : { duration: 0 }}
|
||||
className="pointer-events-none absolute -inset-px -z-10 rounded-[inherit] bg-primary/8 ring-2 ring-primary/70"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of short options with a pill that slides to the chosen one, the same
|
||||
* motion as the section nav.
|
||||
*/
|
||||
export function SegmentedControl<T extends string | number>({
|
||||
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 (
|
||||
<div
|
||||
id={id}
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
data-slot={slot}
|
||||
onKeyDown={moveChoice}
|
||||
className="inline-flex max-w-full items-center gap-0.5 overflow-x-auto scrollbar-none rounded-lg bg-foreground/5 p-0.5"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
data-value={option.value}
|
||||
onClick={() => {
|
||||
if (!selected) onValueChange(option.value);
|
||||
}}
|
||||
className={cn(
|
||||
"relative isolate h-7 shrink-0 cursor-pointer rounded-md px-2.5 text-xs font-medium whitespace-nowrap outline-none",
|
||||
"transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
layoutId={animate ? `segment-${indicatorId}` : undefined}
|
||||
initial={false}
|
||||
transition={animate ? MOTION_SPRING_POSITION : { duration: 0 }}
|
||||
className="absolute inset-0 -z-10 rounded-md bg-background shadow-sm ring-1 ring-foreground/8"
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A check that draws itself in when `done` turns true. */
|
||||
export function RuleCheck({ done, label }: { done: boolean; label: string }) {
|
||||
const animate = useSettingsMotion();
|
||||
return (
|
||||
<li
|
||||
data-done={done}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-xs transition-colors duration-200",
|
||||
done ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"grid size-4 place-items-center rounded-full transition-colors duration-200",
|
||||
done ? "bg-success/15 text-success-text" : "bg-foreground/6",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 12 12"
|
||||
className="size-2.5"
|
||||
fill="none"
|
||||
>
|
||||
<motion.path
|
||||
d="M2.5 6.2 5 8.5l4.5-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.8}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
initial={false}
|
||||
animate={{ pathLength: done ? 1 : 0, opacity: done ? 1 : 0 }}
|
||||
transition={
|
||||
animate
|
||||
? { duration: 0.24, ease: MOTION_EASE_OUT }
|
||||
: { duration: 0 }
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{label}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="settings-feedback"
|
||||
data-pending={hasChanges}
|
||||
className="mx-auto w-full max-w-4xl shrink-0 pt-3"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl py-2 pr-2 pl-3.5 text-xs transition-[background-color,box-shadow] duration-200",
|
||||
hasChanges
|
||||
? "bg-primary/8 ring-1 ring-primary/20"
|
||||
: "bg-foreground/3 ring-1 ring-transparent",
|
||||
)}
|
||||
>
|
||||
{saveError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="min-w-0 flex-1 break-words text-destructive-text"
|
||||
>
|
||||
{saveError}
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex min-h-5 items-center gap-2",
|
||||
hasChanges
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasChanges ? (
|
||||
<motion.span
|
||||
key={count}
|
||||
aria-hidden="true"
|
||||
initial={animate ? { scale: 2.2 } : false}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 18 }}
|
||||
className="size-1.5 rounded-full bg-primary"
|
||||
/>
|
||||
) : (
|
||||
saved && <ConfirmationMark />
|
||||
)}
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasChanges && !saveError && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
data-slot="settings-review"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
>
|
||||
{t("appFeedback.reviewChanges")}
|
||||
<LuChevronUp className="size-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-72 p-1.5"
|
||||
>
|
||||
<ul
|
||||
data-slot="settings-review-list"
|
||||
className="flex max-h-60 flex-col overflow-y-auto"
|
||||
>
|
||||
{pendingChanges.map((change) => (
|
||||
<li key={change.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onJump(change.section)}
|
||||
className="group flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none hover:bg-foreground/6 focus-visible:bg-foreground/6"
|
||||
>
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-primary" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{change.label}
|
||||
</span>
|
||||
<LuCornerDownRight className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{onCancel ? (
|
||||
<Button variant="subtle" size="sm" onClick={onCancel}>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
data-slot="settings-discard"
|
||||
onClick={onDiscard}
|
||||
disabled={!hasChanges || isSaving}
|
||||
>
|
||||
{t("appFeedback.discardChanges")}
|
||||
</Button>
|
||||
)}
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
className="h-8"
|
||||
data-slot="settings-save"
|
||||
isLoading={isSaving}
|
||||
onClick={onSave}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
{t("common.buttons.saveSettings")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<SoundSettings> {
|
||||
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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg transition-colors duration-200",
|
||||
on
|
||||
? "bg-primary/12 text-primary-text"
|
||||
: "bg-foreground/6 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="size-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4.7a.7.7 0 0 0-1.2-.5L6.4 7.6A1.4 1.4 0 0 1 5.4 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.4a1.4 1.4 0 0 1 1 .4l3.4 3.4a.7.7 0 0 0 1.2-.5z" />
|
||||
<motion.path
|
||||
d="M16 9a5 5 0 0 1 0 6"
|
||||
initial={false}
|
||||
animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}
|
||||
transition={transition(on ? 0 : 0.06)}
|
||||
/>
|
||||
<motion.path
|
||||
d="M19.4 5.6a10 10 0 0 1 0 12.8"
|
||||
initial={false}
|
||||
animate={{ pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}
|
||||
transition={transition(on ? 0.08 : 0)}
|
||||
/>
|
||||
<motion.path
|
||||
d="m16 9.5 5 5m0-5-5 5"
|
||||
initial={false}
|
||||
animate={{ pathLength: on ? 0 : 1, opacity: on ? 0 : 1 }}
|
||||
transition={transition(on ? 0 : 0.14)}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex h-4 items-end gap-[2px] transition-colors duration-200",
|
||||
active ? "text-primary-text" : "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
{[0.45, 0.85, 0.6, 0.35].map((rest, index) => (
|
||||
<motion.span
|
||||
key={`${index}-${beat}`}
|
||||
className="block h-full w-[3px] origin-bottom rounded-full bg-current"
|
||||
initial={{ scaleY: rest }}
|
||||
animate={
|
||||
animate && beat > 0
|
||||
? { scaleY: [rest, 1, rest * 0.6, rest] }
|
||||
: { scaleY: rest }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: index * 0.04,
|
||||
ease: MOTION_EASE_OUT,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function VolumeSlider({
|
||||
value,
|
||||
label,
|
||||
onChange,
|
||||
onCommit,
|
||||
}: {
|
||||
value: number;
|
||||
label: string;
|
||||
onChange: (value: number) => void;
|
||||
onCommit: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-56 items-center gap-3">
|
||||
<SliderPrimitive.Root
|
||||
data-slot="settings-sound-volume"
|
||||
aria-label={label}
|
||||
className="relative flex h-5 flex-1 cursor-pointer touch-none items-center select-none"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={[value]}
|
||||
onValueChange={([next]) => onChange(next)}
|
||||
onValueCommit={([next]) => onCommit(next)}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 grow overflow-hidden rounded-full bg-foreground/10">
|
||||
<SliderPrimitive.Range className="absolute h-full rounded-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
aria-label={label}
|
||||
className="block size-4 rounded-full bg-background shadow-md ring-1 ring-foreground/15 transition-transform duration-100 outline-none hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring active:scale-95"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
<span className="w-9 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{value}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Interface sounds: on or off, how loud, which set, and which kinds. */
|
||||
export function SoundSettings({
|
||||
draft,
|
||||
onChange,
|
||||
isChanged,
|
||||
}: {
|
||||
draft: Required<SoundSettings>;
|
||||
onChange: (patch: Partial<SoundSettings>) => void;
|
||||
isChanged: (key: keyof SoundSettings) => boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [beats, setBeats] = useState<Record<string, number>>({});
|
||||
const play = (sound: UiSound, next: Partial<SoundSettings>, beat: string) => {
|
||||
previewUiSound(sound, { ...draft, ...next });
|
||||
setBeats((prev) => ({ ...prev, [beat]: (prev[beat] ?? 0) + 1 }));
|
||||
};
|
||||
const sample = SOUND_CATEGORIES[0].sample;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
htmlFor="settings-ui-sounds"
|
||||
label={t("settings.sounds.enabled")}
|
||||
description={t("settings.sounds.enabledDescription")}
|
||||
changed={isChanged("ui_sounds")}
|
||||
icon={<SpeakerGlyph on={draft.ui_sounds} />}
|
||||
>
|
||||
<AnimatedSwitch
|
||||
id="settings-ui-sounds"
|
||||
data-slot="settings-ui-sounds"
|
||||
aria-label={t("settings.sounds.enabled")}
|
||||
checked={draft.ui_sounds}
|
||||
onCheckedChange={(v) => onChange({ ui_sounds: v === true })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<AnimatedDisclosureContent
|
||||
open={draft.ui_sounds}
|
||||
className="divide-y divide-foreground/6"
|
||||
>
|
||||
<SettingRow
|
||||
label={t("settings.sounds.volume")}
|
||||
changed={isChanged("ui_sound_volume")}
|
||||
>
|
||||
<VolumeSlider
|
||||
value={draft.ui_sound_volume}
|
||||
label={t("settings.sounds.volume")}
|
||||
onChange={(value) => onChange({ ui_sound_volume: value })}
|
||||
onCommit={(value) =>
|
||||
play(sample, { ui_sound_volume: value }, "volume")
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingBlock
|
||||
label={t("settings.sounds.pack")}
|
||||
changed={isChanged("ui_sound_pack")}
|
||||
>
|
||||
{/* A pack card plays its own preview, so the press stays silent. */}
|
||||
<div data-sound="off">
|
||||
<ChoiceGroup
|
||||
value={draft.ui_sound_pack}
|
||||
onValueChange={(id) => {
|
||||
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 }) => (
|
||||
<ChoiceCard
|
||||
key={id}
|
||||
value={id}
|
||||
slot="settings-sound-pack-option"
|
||||
className="flex items-start gap-3 px-3 py-2.5 hover:bg-foreground/4"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{t(`settings.sounds.packs.${id}.label`)}
|
||||
</span>
|
||||
<span className="text-xs leading-snug text-muted-foreground">
|
||||
{t(`settings.sounds.packs.${id}.description`)}
|
||||
</span>
|
||||
</span>
|
||||
<Bars
|
||||
beat={beats[id] ?? 0}
|
||||
active={draft.ui_sound_pack === id}
|
||||
/>
|
||||
</ChoiceCard>
|
||||
))}
|
||||
</ChoiceGroup>
|
||||
</div>
|
||||
</SettingBlock>
|
||||
{SOUND_CATEGORIES.map((category) => {
|
||||
const key = CATEGORY_KEYS[category.id];
|
||||
const id = `settings-sound-${category.id}`;
|
||||
return (
|
||||
<SettingRow
|
||||
key={category.id}
|
||||
htmlFor={id}
|
||||
label={t(`settings.sounds.categories.${category.id}.label`)}
|
||||
description={t(
|
||||
`settings.sounds.categories.${category.id}.description`,
|
||||
)}
|
||||
changed={isChanged(key)}
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label={t("settings.sounds.preview")}
|
||||
title={t("settings.sounds.preview")}
|
||||
data-sound="off"
|
||||
onClick={() => play(category.sample, {}, category.id)}
|
||||
>
|
||||
<LuPlay className="size-3.5" />
|
||||
</Button>
|
||||
<AnimatedSwitch
|
||||
id={id}
|
||||
aria-label={t(
|
||||
`settings.sounds.categories.${category.id}.label`,
|
||||
)}
|
||||
checked={draft[key]}
|
||||
onCheckedChange={(v) => onChange({ [key]: v === true })}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
})}
|
||||
</AnimatedDisclosureContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
scheme,
|
||||
"flex aspect-[16/10] w-full overflow-hidden rounded-lg bg-background text-foreground ring-1 ring-foreground/10",
|
||||
className,
|
||||
)}
|
||||
style={colors as CSSProperties | undefined}
|
||||
>
|
||||
<div className="flex w-[17%] flex-col items-center gap-[9%] bg-foreground/5 pt-[9%]">
|
||||
<span className="size-[22%] rounded-full bg-primary" />
|
||||
<span className="size-[18%] rounded-full bg-foreground/25" />
|
||||
<span className="size-[18%] rounded-full bg-foreground/25" />
|
||||
<span className="size-[18%] rounded-full bg-foreground/25" />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-[7%] p-[7%]">
|
||||
<div className="flex items-center gap-[6%]">
|
||||
<span className="h-1.5 w-[38%] rounded-full bg-foreground/80" />
|
||||
<span className="ml-auto h-2.5 w-[24%] rounded-[3px] bg-primary" />
|
||||
</div>
|
||||
{[0.55, 0.4, 0.48].map((width, index) => (
|
||||
<div
|
||||
key={width}
|
||||
className="flex items-center gap-[5%] rounded-[3px] bg-card px-[5%] py-[4%] ring-1 ring-border"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
index === 1 ? "bg-warning" : "bg-success",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className="h-1 rounded-full bg-foreground/45"
|
||||
style={{ width: `${width * 100}%` }}
|
||||
/>
|
||||
<span className="ml-auto h-1 w-[14%] rounded-full bg-muted-foreground/40" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemPreview() {
|
||||
return (
|
||||
<div className="relative">
|
||||
<ThemePreview scheme="light" />
|
||||
<ThemePreview
|
||||
scheme="dark"
|
||||
className="absolute inset-0 [clip-path:polygon(62%_0,100%_0,100%_100%,38%_100%)]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardCaption({
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5 px-1 pt-2 pb-0.5 text-xs font-medium text-muted-foreground transition-colors duration-150 group-hover/choice:text-foreground group-data-[state=checked]/choice:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">
|
||||
{icon}
|
||||
<span className="truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ChoiceGroup
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
label={t("settings.appearance.theme")}
|
||||
slot="settings-theme"
|
||||
className="grid grid-cols-2 gap-2 @lg:grid-cols-4"
|
||||
>
|
||||
<ChoiceCard
|
||||
value="light"
|
||||
slot="settings-theme-option"
|
||||
className={CARD_CLASS}
|
||||
>
|
||||
<ThemePreview scheme="light" />
|
||||
<CardCaption icon={<LuSun />}>
|
||||
{t("settings.appearance.light")}
|
||||
</CardCaption>
|
||||
</ChoiceCard>
|
||||
<ChoiceCard
|
||||
value="dark"
|
||||
slot="settings-theme-option"
|
||||
className={CARD_CLASS}
|
||||
>
|
||||
<ThemePreview scheme="dark" />
|
||||
<CardCaption icon={<LuMoon />}>
|
||||
{t("settings.appearance.dark")}
|
||||
</CardCaption>
|
||||
</ChoiceCard>
|
||||
<ChoiceCard
|
||||
value="system"
|
||||
slot="settings-theme-option"
|
||||
className={CARD_CLASS}
|
||||
>
|
||||
<SystemPreview />
|
||||
<CardCaption icon={<LuMonitor />}>
|
||||
{t("settings.appearance.system")}
|
||||
</CardCaption>
|
||||
</ChoiceCard>
|
||||
<ChoiceCard
|
||||
value="custom"
|
||||
slot="settings-theme-option"
|
||||
className={CARD_CLASS}
|
||||
>
|
||||
<ThemePreview colors={customColors} />
|
||||
<CardCaption icon={<LuPalette />}>
|
||||
{t("common.labels.custom")}
|
||||
</CardCaption>
|
||||
</ChoiceCard>
|
||||
</ChoiceGroup>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every preset drawn in its own colors, plus the user's own palette. */
|
||||
export function ThemePresetGallery({
|
||||
selectedId,
|
||||
colors,
|
||||
onSelect,
|
||||
}: {
|
||||
selectedId: string | null;
|
||||
colors: Record<string, string>;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ChoiceGroup
|
||||
value={selectedId ?? "custom"}
|
||||
onValueChange={(value) => 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) => (
|
||||
<ChoiceCard
|
||||
key={theme.id}
|
||||
value={theme.id}
|
||||
slot="settings-theme-preset"
|
||||
className={CARD_CLASS}
|
||||
>
|
||||
<ThemePreview colors={theme.colors} />
|
||||
<CardCaption>{theme.name}</CardCaption>
|
||||
</ChoiceCard>
|
||||
))}
|
||||
<ChoiceCard
|
||||
value="custom"
|
||||
slot="settings-theme-preset"
|
||||
className={CARD_CLASS}
|
||||
>
|
||||
<ThemePreview colors={colors} />
|
||||
<CardCaption icon={<LuPipette />}>
|
||||
{t("settings.appearance.yourOwn")}
|
||||
</CardCaption>
|
||||
</ChoiceCard>
|
||||
</ChoiceGroup>
|
||||
);
|
||||
}
|
||||
|
||||
/** Every theme color as a chip that opens a picker. */
|
||||
export function ThemeColorEditor({
|
||||
colors,
|
||||
onChange,
|
||||
}: {
|
||||
colors: Record<string, string>;
|
||||
onChange: (next: {
|
||||
colors: Record<string, string>;
|
||||
presetId: string | null;
|
||||
}) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))] gap-1.5">
|
||||
{THEME_VARIABLES.map(({ key, label }) => {
|
||||
const value = colors[key] ?? "#000000";
|
||||
return (
|
||||
<Popover key={key}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="flex min-w-0 cursor-pointer items-center gap-2 rounded-lg bg-foreground/4 p-1.5 pr-2 text-left transition-colors duration-150 outline-none hover:bg-foreground/7 focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-foreground/8"
|
||||
>
|
||||
<span
|
||||
className="size-6 shrink-0 rounded-md shadow-sm ring-1 ring-foreground/15"
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium">{label}</span>
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground uppercase">
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-3" sideOffset={6}>
|
||||
<ColorPicker
|
||||
className="rounded-md border bg-background p-3 shadow-sm"
|
||||
value={value}
|
||||
onColorChange={([r, g, b, a]) => {
|
||||
const next = {
|
||||
...colors,
|
||||
[key]: Color({ r, g, b }).alpha(a).hexa(),
|
||||
};
|
||||
onChange({
|
||||
colors: next,
|
||||
presetId: getThemeByColors(next)?.id ?? null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ColorPickerSelection className="h-36 rounded" />
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<ColorPickerEyeDropper />
|
||||
<div className="grid w-full gap-1">
|
||||
<ColorPickerHue />
|
||||
<ColorPickerAlpha />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<ColorPickerOutput />
|
||||
<ColorPickerFormat />
|
||||
</div>
|
||||
</ColorPicker>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<number>("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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>("app-update-ready", (event) => {
|
||||
console.log("App update ready:", event.payload);
|
||||
playUiSound("attention");
|
||||
setUpdateReady(true);
|
||||
setIsUpdating(false);
|
||||
setUpdateProgress(null);
|
||||
|
||||
+56
-18
@@ -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",
|
||||
|
||||
+56
-18
@@ -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",
|
||||
|
||||
+56
-18
@@ -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",
|
||||
|
||||
+56
-18
@@ -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",
|
||||
|
||||
+56
-18
@@ -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": "ドメイン",
|
||||
|
||||
+56
-18
@@ -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": "도메인",
|
||||
|
||||
+56
-18
@@ -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",
|
||||
|
||||
+56
-18
@@ -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": "доменов",
|
||||
|
||||
+56
-18
@@ -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ı",
|
||||
|
||||
+56
-18
@@ -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",
|
||||
|
||||
+56
-18
@@ -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": "个域名",
|
||||
|
||||
+15
-13
@@ -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,
|
||||
|
||||
+128
-16
@@ -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",
|
||||
|
||||
+650
-144
@@ -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<UiSound, SoundCategory> = {
|
||||
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<UiSound, SoundVoice[]> = {
|
||||
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<UiSound, Phrase> = {
|
||||
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<SoundPackId, Pack> = {
|
||||
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<SoundCategory, boolean>;
|
||||
}
|
||||
|
||||
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<UiSound, number> = {
|
||||
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<UiSound, number>();
|
||||
/**
|
||||
* 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<UiSound, number>();
|
||||
const lastBatch = new Map<UiSound, string | number>();
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user