mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 23:01:26 +02:00
feat: privacy settings
This commit is contained in:
1 parent
759263b63f
commit
d6657eac98
30 files changed
+1229
-418
No files matched your search
+95
-173
@@ -1,17 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import {
|
||||
LuChevronLeft,
|
||||
LuChevronRight,
|
||||
LuCircleHelp,
|
||||
LuSearch,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { LuCircleHelp, LuSearch, LuX } from "react-icons/lu";
|
||||
import { useWindowDecorations } from "@/hooks/use-window-decorations";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import {
|
||||
@@ -25,6 +18,7 @@ import { UNGROUPED_DROP_ID, useProfileGroupDrag } from "./profile-group-drag";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollStrip } from "./ui/scroll-strip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const HOLD_MS = 150;
|
||||
@@ -165,7 +159,6 @@ const HomeHeader = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const profileDrag = useProfileGroupDrag();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [platform, setPlatform] = useState<string>("macos");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -265,36 +258,6 @@ const HomeHeader = ({
|
||||
[clearHold],
|
||||
);
|
||||
|
||||
// Horizontal scroll fades for the group filter strip — when the user
|
||||
// has more groups than fit, the right edge fades to hint at overflow.
|
||||
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [groupsFadeLeft, setGroupsFadeLeft] = useState(false);
|
||||
const [groupsFadeRight, setGroupsFadeRight] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!showProfileToolbar) return;
|
||||
const el = groupsScrollRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
setGroupsFadeLeft(el.scrollLeft > 1);
|
||||
setGroupsFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
|
||||
};
|
||||
update();
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
const mutations = new MutationObserver(update);
|
||||
mutations.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
mutations.disconnect();
|
||||
};
|
||||
}, [showProfileToolbar]);
|
||||
|
||||
const isWindows = platform === "windows";
|
||||
|
||||
return (
|
||||
@@ -351,145 +314,104 @@ const HomeHeader = ({
|
||||
) : null}
|
||||
|
||||
{showProfileToolbar && (
|
||||
<div className="relative flex min-w-0 flex-1 items-center">
|
||||
{groupsFadeLeft && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("header.scrollGroupsLeft")}
|
||||
onClick={() => {
|
||||
const el = groupsScrollRef.current;
|
||||
if (el)
|
||||
el.scrollBy({
|
||||
left: -el.clientWidth * 0.6,
|
||||
behavior:
|
||||
reducedMotion || profileDrag?.active
|
||||
? "instant"
|
||||
: "smooth",
|
||||
});
|
||||
}}
|
||||
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<LuChevronLeft className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={groupsScrollRef}
|
||||
data-slot="profile-group-strip"
|
||||
className="ml-2 flex scrollbar-none items-center gap-3 overflow-x-auto [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
style={{
|
||||
paddingLeft: groupsFadeLeft ? 22 : 0,
|
||||
paddingRight: groupsFadeRight ? 22 : 0,
|
||||
}}
|
||||
>
|
||||
{/* "All" filter — shows every profile regardless of group. */}
|
||||
{(() => {
|
||||
const active = selectedGroupId === ALL_FILTER_ID;
|
||||
return (
|
||||
<button
|
||||
key="__all__"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onGroupSelect(ALL_FILTER_ID);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{t("groups.all")}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{totalProfiles}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{profileDrag && profileDrag.movingIds.size > 0 && (
|
||||
<span
|
||||
data-profile-group-drop={UNGROUPED_DROP_ID}
|
||||
data-drop-state={
|
||||
profileDrag.targetId === UNGROUPED_DROP_ID
|
||||
? "target"
|
||||
: profileDrag.canDrop(UNGROUPED_DROP_ID)
|
||||
? "available"
|
||||
: "unavailable"
|
||||
}
|
||||
title={t("profileMotion.dragTarget", {
|
||||
group: t("groups.noGroup"),
|
||||
})}
|
||||
<ScrollStrip
|
||||
data-slot="profile-group-strip"
|
||||
className="ml-2 flex-1"
|
||||
stripClassName="gap-3"
|
||||
activeKey={selectedGroupId}
|
||||
instant={Boolean(profileDrag?.active)}
|
||||
scrollLeftLabel={t("header.scrollGroupsLeft")}
|
||||
scrollRightLabel={t("header.scrollGroupsRight")}
|
||||
>
|
||||
{/* "All" filter — shows every profile regardless of group. */}
|
||||
{(() => {
|
||||
const active = selectedGroupId === ALL_FILTER_ID;
|
||||
return (
|
||||
<button
|
||||
key="__all__"
|
||||
type="button"
|
||||
aria-current={active ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onGroupSelect(ALL_FILTER_ID);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center rounded-sm px-2 text-xs text-muted-foreground transition-colors duration-100",
|
||||
profileDrag.targetId === UNGROUPED_DROP_ID &&
|
||||
"bg-accent font-medium text-accent-foreground",
|
||||
!profileDrag.canDrop(UNGROUPED_DROP_ID) && "opacity-40",
|
||||
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("groups.noGroup")}
|
||||
</span>
|
||||
)}
|
||||
{groups.map((group) => {
|
||||
const active = selectedGroupId === group.id;
|
||||
const dropTarget = profileDrag?.targetId === group.id;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
title={group.name}
|
||||
data-profile-group-drop={group.id}
|
||||
data-drop-state={
|
||||
profileDrag?.active
|
||||
? dropTarget
|
||||
? "target"
|
||||
: profileDrag.canDrop(group.id)
|
||||
? "available"
|
||||
: "unavailable"
|
||||
: undefined
|
||||
}
|
||||
aria-current={active ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center gap-1.5 rounded-sm px-1 text-xs transition-colors duration-100",
|
||||
dropTarget && "bg-accent text-accent-foreground",
|
||||
profileDrag?.active &&
|
||||
!profileDrag.canDrop(group.id) &&
|
||||
"opacity-40",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-40 truncate">{group.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{group.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{groupsFadeRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("header.scrollGroupsRight")}
|
||||
onClick={() => {
|
||||
const el = groupsScrollRef.current;
|
||||
if (el)
|
||||
el.scrollBy({
|
||||
left: el.clientWidth * 0.6,
|
||||
behavior:
|
||||
reducedMotion || profileDrag?.active
|
||||
? "instant"
|
||||
: "smooth",
|
||||
});
|
||||
}}
|
||||
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
<span>{t("groups.all")}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{totalProfiles}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{profileDrag && profileDrag.movingIds.size > 0 && (
|
||||
<span
|
||||
data-profile-group-drop={UNGROUPED_DROP_ID}
|
||||
data-drop-state={
|
||||
profileDrag.targetId === UNGROUPED_DROP_ID
|
||||
? "target"
|
||||
: profileDrag.canDrop(UNGROUPED_DROP_ID)
|
||||
? "available"
|
||||
: "unavailable"
|
||||
}
|
||||
title={t("profileMotion.dragTarget", {
|
||||
group: t("groups.noGroup"),
|
||||
})}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center rounded-sm px-2 text-xs text-muted-foreground transition-colors duration-100",
|
||||
profileDrag.targetId === UNGROUPED_DROP_ID &&
|
||||
"bg-accent font-medium text-accent-foreground",
|
||||
!profileDrag.canDrop(UNGROUPED_DROP_ID) && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<LuChevronRight className="size-3" />
|
||||
</button>
|
||||
{t("groups.noGroup")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{groups.map((group) => {
|
||||
const active = selectedGroupId === group.id;
|
||||
const dropTarget = profileDrag?.targetId === group.id;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
title={group.name}
|
||||
data-profile-group-drop={group.id}
|
||||
data-drop-state={
|
||||
profileDrag?.active
|
||||
? dropTarget
|
||||
? "target"
|
||||
: profileDrag.canDrop(group.id)
|
||||
? "available"
|
||||
: "unavailable"
|
||||
: undefined
|
||||
}
|
||||
aria-current={active ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center gap-1.5 rounded-sm px-1 text-xs transition-colors duration-100",
|
||||
dropTarget && "bg-accent text-accent-foreground",
|
||||
profileDrag?.active &&
|
||||
!profileDrag.canDrop(group.id) &&
|
||||
"opacity-40",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-40 truncate">{group.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{group.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ScrollStrip>
|
||||
)}
|
||||
|
||||
{!showProfileToolbar && <div className="flex-1" />}
|
||||
|
||||
+226
-102
@@ -40,6 +40,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollStrip } from "@/components/ui/scroll-strip";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -82,8 +83,15 @@ interface AppSettings {
|
||||
keep_decrypted_profiles_in_ram?: boolean;
|
||||
fingerprint_gate_disabled?: boolean;
|
||||
vpn_extension_warning_disabled?: boolean;
|
||||
require_route_for_launch?: boolean;
|
||||
trash_retention_days?: number;
|
||||
record_traffic_domains?: boolean;
|
||||
}
|
||||
|
||||
/** The retention choices offered, in days. 0 turns the trash off. */
|
||||
const TRASH_RETENTION_DAYS = [0, 1, 7, 14, 30, 90] as const;
|
||||
const DEFAULT_TRASH_RETENTION_DAYS = 30;
|
||||
|
||||
interface CustomThemeState {
|
||||
selectedThemeId: string | null;
|
||||
colors: Record<string, string>;
|
||||
@@ -603,10 +611,12 @@ export function SettingsDialog({
|
||||
| "es"
|
||||
| "pt"
|
||||
| "fr"
|
||||
| "de"
|
||||
| "zh"
|
||||
| "ja"
|
||||
| "ko"
|
||||
| "ru"
|
||||
| "tr"
|
||||
| "vi"),
|
||||
);
|
||||
setOriginalLanguage(selectedLanguage);
|
||||
@@ -826,12 +836,21 @@ export function SettingsDialog({
|
||||
"vpn_extension_warning_disabled",
|
||||
"settings.privacy.vpnExtensionWarning",
|
||||
],
|
||||
["require_route_for_launch", "settings.privacy.requireRoute"],
|
||||
] as const
|
||||
)
|
||||
.filter(
|
||||
([key]) => Boolean(settings[key]) !== Boolean(originalSettings[key]),
|
||||
)
|
||||
.map(([, key]) => t(key)),
|
||||
...((settings.record_traffic_domains ?? true) !==
|
||||
(originalSettings.record_traffic_domains ?? true)
|
||||
? [t("settings.privacy.recordSites")]
|
||||
: []),
|
||||
...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !==
|
||||
(originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS)
|
||||
? [t("settings.trashRetention")]
|
||||
: []),
|
||||
];
|
||||
const hasChanges = pendingChanges.length > 0;
|
||||
const sections = [
|
||||
@@ -859,6 +878,15 @@ export function SettingsDialog({
|
||||
["dns", "dnsBlocklist.title", ["dnsBlocklist"]],
|
||||
["encryption", "settings.encryption.title", ["settings.encryption"]],
|
||||
["commercial", "settings.commercial.title", ["settings.commercial"]],
|
||||
[
|
||||
"privacy",
|
||||
"settings.privacy.title",
|
||||
[
|
||||
"settings.privacy",
|
||||
"settings.trashRetention",
|
||||
"settings.trashRetentionDescription",
|
||||
],
|
||||
],
|
||||
[
|
||||
"advanced",
|
||||
"settings.advanced.title",
|
||||
@@ -868,7 +896,6 @@ export function SettingsDialog({
|
||||
"settings.disableAutoUpdatesDescription",
|
||||
"settings.keepDecryptedProfilesInRam",
|
||||
"settings.keepDecryptedProfilesInRamDescription",
|
||||
"settings.privacy",
|
||||
],
|
||||
],
|
||||
] as Array<[string, string, string[]]>;
|
||||
@@ -965,48 +992,55 @@ export function SettingsDialog({
|
||||
aria-label={t("appFeedback.searchSettings")}
|
||||
className="h-8"
|
||||
/>
|
||||
<nav
|
||||
aria-label={t("settings.title")}
|
||||
className="flex flex-wrap gap-x-1 gap-y-1"
|
||||
>
|
||||
{sections.map(([id, label]) => {
|
||||
const active = activeSection === id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
data-slot="settings-nav-item"
|
||||
data-section={id}
|
||||
aria-current={active ? "location" : undefined}
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setJumpTo(id);
|
||||
}}
|
||||
className={cn(
|
||||
"relative isolate rounded-md px-2 py-1 text-xs transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
|
||||
active
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
data-slot="settings-nav-indicator"
|
||||
layoutId={
|
||||
animateNav ? "settings-nav-indicator" : undefined
|
||||
}
|
||||
initial={false}
|
||||
transition={
|
||||
animateNav ? MOTION_SPRING_POSITION : { duration: 0 }
|
||||
}
|
||||
className="absolute inset-0 -z-10 rounded-md bg-accent"
|
||||
/>
|
||||
)}
|
||||
{t(label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<nav aria-label={t("settings.title")}>
|
||||
<ScrollStrip
|
||||
data-slot="settings-nav"
|
||||
activeKey={activeSection}
|
||||
scrollLeftLabel={t("settings.scrollSectionsLeft")}
|
||||
scrollRightLabel={t("settings.scrollSectionsRight")}
|
||||
stripClassName="gap-1"
|
||||
>
|
||||
{sections.map(([id, label]) => {
|
||||
const active = activeSection === id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
data-slot="settings-nav-item"
|
||||
data-section={id}
|
||||
aria-current={active ? "location" : undefined}
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setJumpTo(id);
|
||||
}}
|
||||
className={cn(
|
||||
"relative isolate shrink-0 rounded-md px-2 py-1 text-xs whitespace-nowrap transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
|
||||
active
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
data-slot="settings-nav-indicator"
|
||||
layoutId={
|
||||
animateNav ? "settings-nav-indicator" : undefined
|
||||
}
|
||||
initial={false}
|
||||
transition={
|
||||
animateNav
|
||||
? MOTION_SPRING_POSITION
|
||||
: { duration: 0 }
|
||||
}
|
||||
className="absolute inset-0 -z-10 rounded-md bg-accent"
|
||||
/>
|
||||
)}
|
||||
{t(label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ScrollStrip>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1015,6 +1049,7 @@ export function SettingsDialog({
|
||||
them was the dead-wheel-zone bug. */}
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
data-slot="settings-scroller"
|
||||
onScroll={syncActiveSection}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
@@ -1637,6 +1672,154 @@ export function SettingsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy Section */}
|
||||
<div
|
||||
data-settings-section="privacy"
|
||||
tabIndex={-1}
|
||||
hidden={!sectionVisible("privacy")}
|
||||
className="scroll-mt-2 space-y-4 focus:outline-none"
|
||||
>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.privacy.title")}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.requireRoute")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.requireRouteDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.requireRoute")}
|
||||
checked={settings.require_route_for_launch ?? false}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting("require_route_for_launch", v === true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.consistencyWarning")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.consistencyWarningDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.consistencyWarning")}
|
||||
checked={!(settings.fingerprint_gate_disabled ?? false)}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting("fingerprint_gate_disabled", v !== true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.vpnExtensionWarning")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.vpnExtensionWarningDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.vpnExtensionWarning")}
|
||||
checked={
|
||||
!(settings.vpn_extension_warning_disabled ?? false)
|
||||
}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting(
|
||||
"vpn_extension_warning_disabled",
|
||||
v !== true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.recordSites")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.recordSitesDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.recordSites")}
|
||||
checked={settings.record_traffic_domains ?? true}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting("record_traffic_domains", v === true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label
|
||||
htmlFor="trash-retention"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("settings.trashRetention")}
|
||||
</Label>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.trashRetentionDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={String(
|
||||
settings.trash_retention_days ??
|
||||
DEFAULT_TRASH_RETENTION_DAYS,
|
||||
)}
|
||||
onValueChange={(value) => {
|
||||
updateSetting("trash_retention_days", Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="trash-retention"
|
||||
className="w-40 shrink-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRASH_RETENTION_DAYS.map((days) => (
|
||||
<SelectItem key={days} value={String(days)}>
|
||||
{days === 0
|
||||
? t("settings.trashRetentionOff")
|
||||
: t("settings.trashRetentionDays", {
|
||||
count: days,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoadingButton
|
||||
isLoading={isClearingTraffic}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleClearTraffic().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("settings.privacy.clearTraffic")}
|
||||
</LoadingButton>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("settings.privacy.clearTrafficDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div
|
||||
data-settings-section="advanced"
|
||||
@@ -1750,65 +1933,6 @@ export function SettingsDialog({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.copyLogsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t pt-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.consistencyWarning")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.consistencyWarningDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.consistencyWarning")}
|
||||
checked={!(settings.fingerprint_gate_disabled ?? false)}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting("fingerprint_gate_disabled", v !== true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-x-3 rounded-lg border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.vpnExtensionWarning")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.vpnExtensionWarningDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.vpnExtensionWarning")}
|
||||
checked={
|
||||
!(settings.vpn_extension_warning_disabled ?? false)
|
||||
}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting(
|
||||
"vpn_extension_warning_disabled",
|
||||
v !== true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<LoadingButton
|
||||
isLoading={isClearingTraffic}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleClearTraffic().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("settings.privacy.clearTraffic")}
|
||||
</LoadingButton>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("settings.privacy.clearTrafficDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{matchingSections.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** How far a pressed pointer travels before a press becomes a drag. */
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
/** Room kept between the active item and a faded edge. */
|
||||
const EDGE_ROOM_PX = 28;
|
||||
/** Clears a 2px focus outline: a row that scrolls sideways also clips
|
||||
* vertically, so a badge's focus ring would be cut at every edge. */
|
||||
const RING_ROOM_PX = 2;
|
||||
|
||||
interface ScrollStripProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
/** Changes whenever the item marked `aria-current` changes. */
|
||||
activeKey?: string | null;
|
||||
scrollLeftLabel: string;
|
||||
scrollRightLabel: string;
|
||||
/** Classes for the scrolling row itself; `className` styles the frame. */
|
||||
stripClassName?: string;
|
||||
/** Scroll without animation, e.g. while something is dragged over it. */
|
||||
instant?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of badges that never wraps. When the badges do not fit, the row
|
||||
* fades out at the side that has more, shows a chevron there, and scrolls
|
||||
* with the wheel, a drag or the chevrons. The badge marked `aria-current` is
|
||||
* kept in view whenever `activeKey` changes.
|
||||
*/
|
||||
export function ScrollStrip({
|
||||
children,
|
||||
activeKey,
|
||||
scrollLeftLabel,
|
||||
scrollRightLabel,
|
||||
className,
|
||||
stripClassName,
|
||||
instant = false,
|
||||
onPointerDown,
|
||||
onClickCapture,
|
||||
...rest
|
||||
}: ScrollStripProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [fadeLeft, setFadeLeft] = useState(false);
|
||||
const [fadeRight, setFadeRight] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startLeft: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const behavior: ScrollBehavior =
|
||||
instant || reducedMotion ? "instant" : "smooth";
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
setFadeLeft(el.scrollLeft > 1);
|
||||
setFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
|
||||
};
|
||||
update();
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const resize = new ResizeObserver(update);
|
||||
resize.observe(el);
|
||||
const mutations = new MutationObserver(update);
|
||||
mutations.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
// A vertical wheel has nowhere to go in a single row, so turn it
|
||||
// sideways. At either end the event is left alone.
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey) return;
|
||||
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
||||
const room = el.scrollWidth - el.clientWidth;
|
||||
if (room <= 1) return;
|
||||
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
||||
const next = Math.min(room, Math.max(0, el.scrollLeft + delta));
|
||||
if (next === el.scrollLeft) return;
|
||||
event.preventDefault();
|
||||
el.scrollLeft = next;
|
||||
};
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
el.removeEventListener("wheel", onWheel);
|
||||
resize.disconnect();
|
||||
mutations.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeKey is the trigger; the active badge is read from the DOM
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || dragRef.current?.moved) return;
|
||||
const item = el.querySelector<HTMLElement>(
|
||||
'[aria-current]:not([aria-current="false"])',
|
||||
);
|
||||
if (!item) return;
|
||||
const start = item.offsetLeft;
|
||||
const end = start + item.offsetWidth;
|
||||
let target: number | null = null;
|
||||
if (start < el.scrollLeft + EDGE_ROOM_PX) {
|
||||
target = start - EDGE_ROOM_PX;
|
||||
} else if (end > el.scrollLeft + el.clientWidth - EDGE_ROOM_PX) {
|
||||
target = end - el.clientWidth + EDGE_ROOM_PX;
|
||||
}
|
||||
if (target !== null) {
|
||||
el.scrollTo({ left: Math.max(0, target), behavior });
|
||||
}
|
||||
}, [activeKey]);
|
||||
|
||||
const page = useCallback(
|
||||
(direction: -1 | 1) => {
|
||||
const el = ref.current;
|
||||
if (el) {
|
||||
el.scrollBy({ left: direction * el.clientWidth * 0.6, behavior });
|
||||
}
|
||||
},
|
||||
[behavior],
|
||||
);
|
||||
|
||||
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
if (drag.moved) {
|
||||
// The click that ends a drag arrives in this same task. If none comes,
|
||||
// the next real click must not be swallowed.
|
||||
suppressClickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
}, 0);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}
|
||||
dragRef.current = null;
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
const overflowing = fadeLeft || fadeRight;
|
||||
const chevron =
|
||||
"absolute top-1/2 z-10 grid size-6 -translate-y-1/2 place-items-center rounded-sm text-muted-foreground transition-colors duration-100 hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring";
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex min-w-0 items-center", className)}>
|
||||
{fadeLeft && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={scrollLeftLabel}
|
||||
onClick={() => page(-1)}
|
||||
className={cn(chevron, "left-0")}
|
||||
>
|
||||
<LuChevronLeft className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={ref}
|
||||
data-fade-left={fadeLeft ? "true" : "false"}
|
||||
data-fade-right={fadeRight ? "true" : "false"}
|
||||
{...rest}
|
||||
onPointerDown={(event) => {
|
||||
onPointerDown?.(event);
|
||||
const el = ref.current;
|
||||
if (
|
||||
!el ||
|
||||
event.pointerType !== "mouse" ||
|
||||
event.button !== 0 ||
|
||||
el.scrollWidth - el.clientWidth <= 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The strip owns this press. Without this the title bar under it
|
||||
// would start moving the window on a long press.
|
||||
event.stopPropagation();
|
||||
suppressClickRef.current = false;
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startLeft: el.scrollLeft,
|
||||
moved: false,
|
||||
};
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = dragRef.current;
|
||||
const el = ref.current;
|
||||
if (!drag || !el || drag.pointerId !== event.pointerId) return;
|
||||
const dx = event.clientX - drag.startX;
|
||||
if (!drag.moved) {
|
||||
if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
|
||||
drag.moved = true;
|
||||
// Keep receiving moves when the pointer leaves the strip. A
|
||||
// pointer the browser no longer tracks cannot be captured, and
|
||||
// the drag still works without it while the pointer stays over.
|
||||
try {
|
||||
el.setPointerCapture(event.pointerId);
|
||||
} catch {}
|
||||
setDragging(true);
|
||||
}
|
||||
el.scrollLeft = drag.startLeft - dx;
|
||||
}}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onClickCapture={(event) => {
|
||||
// The press that ended a drag is not a click on the badge under it.
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onClickCapture?.(event);
|
||||
}}
|
||||
className={cn(
|
||||
"scroll-fade-x relative flex scrollbar-none items-center overflow-x-auto py-0.5 [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
|
||||
overflowing && "cursor-grab",
|
||||
dragging && "cursor-grabbing select-none",
|
||||
stripClassName,
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: fadeLeft ? 22 : RING_ROOM_PX,
|
||||
paddingRight: fadeRight ? 22 : RING_ROOM_PX,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{fadeRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={scrollRightLabel}
|
||||
onClick={() => page(1)}
|
||||
className={cn(chevron, "right-0")}
|
||||
>
|
||||
<LuChevronRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "Securely erase recorded traffic statistics for every profile.",
|
||||
"clearTrafficSuccess": "Traffic history cleared",
|
||||
"vpnExtensionWarning": "VPN extension warning",
|
||||
"vpnExtensionWarningDescription": "Warn before launching when a profile contains an extension that can reroute the browser's traffic."
|
||||
"vpnExtensionWarningDescription": "Warn before launching when a profile contains an extension that can reroute the browser's traffic.",
|
||||
"title": "Privacy",
|
||||
"requireRoute": "Block launches without a proxy or VPN",
|
||||
"requireRouteDescription": "A profile never starts from your own IP address: not when it has no proxy or VPN, and not when its proxy or VPN is gone or does not answer.",
|
||||
"recordSites": "Record visited sites",
|
||||
"recordSitesDescription": "Traffic stats list the sites each profile reached. When off, only data totals are kept. Password-protected and ephemeral profiles never record sites."
|
||||
},
|
||||
"trashRetention": "Keep deleted profiles for",
|
||||
"trashRetentionDescription": "Deleted profiles wait in the trash and can be restored until then. After that they are removed for good.",
|
||||
@@ -240,7 +245,10 @@
|
||||
"cleaning": "Removing the old directory",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "Delete at once",
|
||||
"scrollSectionsLeft": "Scroll sections left",
|
||||
"scrollSectionsRight": "Scroll sections right"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Search profiles...",
|
||||
@@ -2351,7 +2359,9 @@
|
||||
"syncDisplayTooSmall": "The display is too small for {{windows}} windows in that layout.",
|
||||
"syncArrangeFailed": "No window could be moved.",
|
||||
"extensionPathInvalid": "That path is not allowed: it contains '..'.",
|
||||
"systemProxyUnreachable": "Donut could not connect through the system proxy {{proxy}}. Fix or turn off the proxy in your system network settings, then try again."
|
||||
"systemProxyUnreachable": "Donut could not connect through the system proxy {{proxy}}. Fix or turn off the proxy in your system network settings, then try again.",
|
||||
"profileRouteRequired": "This profile has no proxy or VPN. Launches without one are blocked in Settings, under Privacy.",
|
||||
"profileRouteUnavailable": "The proxy or VPN of this profile is not available, so the launch was stopped to keep your IP address hidden. Assign a working proxy or VPN to the profile."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -3452,7 +3462,7 @@
|
||||
"trash": {
|
||||
"label": "Trash",
|
||||
"title": "Deleted profiles wait in the trash",
|
||||
"body": "A deleted profile stays in the trash for 30 days by default and comes back with everything in it. The retention period is under Advanced settings.",
|
||||
"body": "A deleted profile stays in the trash for 30 days by default and comes back with everything in it. The retention period is under Privacy settings.",
|
||||
"action": "Open Trash"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "Elimina de forma segura las estadísticas de tráfico registradas de todos los perfiles.",
|
||||
"clearTrafficSuccess": "Historial de tráfico borrado",
|
||||
"vpnExtensionWarning": "Aviso de extensión VPN",
|
||||
"vpnExtensionWarningDescription": "Avisar antes de iniciar cuando un perfil contenga una extensión capaz de redirigir el tráfico del navegador."
|
||||
"vpnExtensionWarningDescription": "Avisar antes de iniciar cuando un perfil contenga una extensión capaz de redirigir el tráfico del navegador.",
|
||||
"title": "Privacidad",
|
||||
"requireRoute": "Bloquear inicios sin proxy ni VPN",
|
||||
"requireRouteDescription": "Un perfil nunca se inicia desde tu propia dirección IP: ni cuando no tiene proxy o VPN, ni cuando su proxy o VPN ya no existe o no responde.",
|
||||
"recordSites": "Registrar sitios visitados",
|
||||
"recordSitesDescription": "Las estadísticas de tráfico muestran los sitios a los que llegó cada perfil. Si se desactiva, solo se guardan los totales de datos. Los perfiles con contraseña y los efímeros nunca registran sitios."
|
||||
},
|
||||
"trashRetention": "Conservar perfiles eliminados durante",
|
||||
"trashRetentionDescription": "Los perfiles eliminados esperan en la papelera y se pueden restaurar hasta entonces. Después se eliminan definitivamente.",
|
||||
@@ -241,7 +246,10 @@
|
||||
"cleaning": "Eliminando el directorio anterior",
|
||||
"done": "Listo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "Eliminar al instante",
|
||||
"scrollSectionsLeft": "Desplazar secciones a la izquierda",
|
||||
"scrollSectionsRight": "Desplazar secciones a la derecha"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Buscar perfiles...",
|
||||
@@ -2361,7 +2369,9 @@
|
||||
"syncDisplayTooSmall": "La pantalla es demasiado pequeña para {{windows}} ventanas en esa disposición.",
|
||||
"syncArrangeFailed": "No se pudo mover ninguna ventana.",
|
||||
"extensionPathInvalid": "Esa ruta no está permitida: contiene '..'.",
|
||||
"systemProxyUnreachable": "Donut no pudo conectarse a través del proxy del sistema {{proxy}}. Corrige o desactiva el proxy en la configuración de red del sistema e inténtalo de nuevo."
|
||||
"systemProxyUnreachable": "Donut no pudo conectarse a través del proxy del sistema {{proxy}}. Corrige o desactiva el proxy en la configuración de red del sistema e inténtalo de nuevo.",
|
||||
"profileRouteRequired": "Este perfil no tiene proxy ni VPN. Los inicios sin ellos están bloqueados en Ajustes, en Privacidad.",
|
||||
"profileRouteUnavailable": "El proxy o la VPN de este perfil no está disponible, así que el inicio se detuvo para mantener oculta tu dirección IP. Asigna al perfil un proxy o una VPN que funcione."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -3487,7 +3497,7 @@
|
||||
"trash": {
|
||||
"label": "Papelera",
|
||||
"title": "Los perfiles eliminados esperan en la papelera",
|
||||
"body": "Un perfil eliminado permanece en la papelera 30 días de forma predeterminada y vuelve con todo su contenido. El periodo de retención está en los ajustes avanzados.",
|
||||
"body": "Un perfil eliminado permanece en la papelera 30 días de forma predeterminada y vuelve con todo su contenido. El periodo de retención está en los ajustes de privacidad.",
|
||||
"action": "Abrir Papelera"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "Efface en toute sécurité les statistiques de trafic enregistrées pour chaque profil.",
|
||||
"clearTrafficSuccess": "Historique de trafic effacé",
|
||||
"vpnExtensionWarning": "Avertissement d'extension VPN",
|
||||
"vpnExtensionWarningDescription": "Avertir avant le lancement lorsqu'un profil contient une extension capable de rerouter le trafic du navigateur."
|
||||
"vpnExtensionWarningDescription": "Avertir avant le lancement lorsqu'un profil contient une extension capable de rerouter le trafic du navigateur.",
|
||||
"title": "Confidentialité",
|
||||
"requireRoute": "Bloquer les lancements sans proxy ni VPN",
|
||||
"requireRouteDescription": "Un profil ne démarre jamais depuis votre propre adresse IP : ni sans proxy ou VPN, ni lorsque son proxy ou VPN a disparu ou ne répond pas.",
|
||||
"recordSites": "Enregistrer les sites visités",
|
||||
"recordSitesDescription": "Les statistiques de trafic listent les sites atteints par chaque profil. Désactivé, seuls les volumes de données sont conservés. Les profils protégés par mot de passe et éphémères n'enregistrent jamais de sites."
|
||||
},
|
||||
"trashRetention": "Conserver les profils supprimés pendant",
|
||||
"trashRetentionDescription": "Les profils supprimés attendent dans la corbeille et peuvent être restaurés jusque-là. Ensuite, ils sont supprimés définitivement.",
|
||||
@@ -241,7 +246,10 @@
|
||||
"cleaning": "Suppression de l'ancien répertoire",
|
||||
"done": "Terminé"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "Supprimer aussitôt",
|
||||
"scrollSectionsLeft": "Faire défiler les sections vers la gauche",
|
||||
"scrollSectionsRight": "Faire défiler les sections vers la droite"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Rechercher des profils...",
|
||||
@@ -2361,7 +2369,9 @@
|
||||
"syncDisplayTooSmall": "L'écran est trop petit pour {{windows}} fenêtres dans cette disposition.",
|
||||
"syncArrangeFailed": "Aucune fenêtre n'a pu être déplacée.",
|
||||
"extensionPathInvalid": "Ce chemin n'est pas autorisé : il contient « .. ».",
|
||||
"systemProxyUnreachable": "Donut n'a pas pu se connecter via le proxy système {{proxy}}. Corrigez ou désactivez le proxy dans les paramètres réseau de votre système, puis réessayez."
|
||||
"systemProxyUnreachable": "Donut n'a pas pu se connecter via le proxy système {{proxy}}. Corrigez ou désactivez le proxy dans les paramètres réseau de votre système, puis réessayez.",
|
||||
"profileRouteRequired": "Ce profil n'a ni proxy ni VPN. Les lancements sans eux sont bloqués dans Réglages, sous Confidentialité.",
|
||||
"profileRouteUnavailable": "Le proxy ou le VPN de ce profil n'est pas disponible : le lancement a été arrêté pour garder votre adresse IP cachée. Attribuez au profil un proxy ou un VPN qui fonctionne."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -3487,7 +3497,7 @@
|
||||
"trash": {
|
||||
"label": "Corbeille",
|
||||
"title": "Les profils supprimés attendent dans la corbeille",
|
||||
"body": "Un profil supprimé reste 30 jours dans la corbeille par défaut et revient avec tout son contenu. La durée de conservation se règle dans les réglages avancés.",
|
||||
"body": "Un profil supprimé reste 30 jours dans la corbeille par défaut et revient avec tout son contenu. La durée de conservation se règle dans les réglages de confidentialité.",
|
||||
"action": "Ouvrir Corbeille"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "すべてのプロファイルの記録されたトラフィック統計を安全に消去します。",
|
||||
"clearTrafficSuccess": "トラフィック履歴を消去しました",
|
||||
"vpnExtensionWarning": "VPN拡張機能の警告",
|
||||
"vpnExtensionWarningDescription": "ブラウザーの通信を経路変更できる拡張機能がプロファイルに含まれる場合、起動前に警告します。"
|
||||
"vpnExtensionWarningDescription": "ブラウザーの通信を経路変更できる拡張機能がプロファイルに含まれる場合、起動前に警告します。",
|
||||
"title": "プライバシー",
|
||||
"requireRoute": "プロキシまたは VPN なしの起動をブロック",
|
||||
"requireRouteDescription": "プロファイルが自分の IP アドレスから起動することはありません。プロキシや VPN がない場合も、プロキシや VPN が削除されたり応答しない場合も同様です。",
|
||||
"recordSites": "訪問したサイトを記録",
|
||||
"recordSitesDescription": "トラフィック統計には各プロファイルがアクセスしたサイトが記録されます。オフにするとデータ量の合計だけが残ります。パスワード保護されたプロファイルと一時プロファイルはサイトを記録しません。"
|
||||
},
|
||||
"trashRetention": "削除したプロファイルの保持期間",
|
||||
"trashRetentionDescription": "削除したプロファイルはゴミ箱に保管され、期限まで復元できます。期限を過ぎると完全に削除されます。",
|
||||
@@ -240,7 +245,10 @@
|
||||
"cleaning": "以前のディレクトリを削除しています",
|
||||
"done": "完了"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "すぐに削除",
|
||||
"scrollSectionsLeft": "セクションを左へスクロール",
|
||||
"scrollSectionsRight": "セクションを右へスクロール"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "プロファイルを検索...",
|
||||
@@ -2351,7 +2359,9 @@
|
||||
"syncDisplayTooSmall": "その配置で {{windows}} 個のウィンドウを並べるには画面が小さすぎます。",
|
||||
"syncArrangeFailed": "動かせたウィンドウはありませんでした。",
|
||||
"extensionPathInvalid": "そのパスは使用できません。'..' が含まれています。",
|
||||
"systemProxyUnreachable": "システムプロキシ {{proxy}} 経由で接続できませんでした。システムのネットワーク設定でプロキシを修正するか無効にしてから、もう一度お試しください。"
|
||||
"systemProxyUnreachable": "システムプロキシ {{proxy}} 経由で接続できませんでした。システムのネットワーク設定でプロキシを修正するか無効にしてから、もう一度お試しください。",
|
||||
"profileRouteRequired": "このプロファイルにはプロキシも VPN もありません。設定の「プライバシー」で、これらなしの起動がブロックされています。",
|
||||
"profileRouteUnavailable": "このプロファイルのプロキシまたは VPN が利用できないため、IP アドレスを隠すために起動を停止しました。動作するプロキシまたは VPN をプロファイルに割り当ててください。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -3452,7 +3462,7 @@
|
||||
"trash": {
|
||||
"label": "ゴミ箱",
|
||||
"title": "削除したプロファイルはゴミ箱で待機",
|
||||
"body": "削除したプロファイルは既定で 30 日間ゴミ箱に残り、中身ごと復元できます。保持期間は詳細設定にあります。",
|
||||
"body": "削除したプロファイルは既定で 30 日間ゴミ箱に残り、中身ごと復元できます。保持期間はプライバシー設定にあります。",
|
||||
"action": "ゴミ箱を開く"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "모든 프로필의 기록된 트래픽 통계를 안전하게 지웁니다.",
|
||||
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다",
|
||||
"vpnExtensionWarning": "VPN 확장 프로그램 경고",
|
||||
"vpnExtensionWarningDescription": "브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램이 프로필에 있으면 실행 전에 경고합니다."
|
||||
"vpnExtensionWarningDescription": "브라우저 트래픽의 경로를 바꿀 수 있는 확장 프로그램이 프로필에 있으면 실행 전에 경고합니다.",
|
||||
"title": "개인정보 보호",
|
||||
"requireRoute": "프록시나 VPN 없는 실행 차단",
|
||||
"requireRouteDescription": "프로필이 내 IP 주소로 시작되지 않습니다. 프록시나 VPN이 없을 때도, 프록시나 VPN이 사라졌거나 응답하지 않을 때도 마찬가지입니다.",
|
||||
"recordSites": "방문한 사이트 기록",
|
||||
"recordSitesDescription": "트래픽 통계에는 각 프로필이 접속한 사이트가 표시됩니다. 끄면 데이터 합계만 보관됩니다. 비밀번호로 보호된 프로필과 임시 프로필은 사이트를 기록하지 않습니다."
|
||||
},
|
||||
"trashRetention": "삭제한 프로필 보관 기간",
|
||||
"trashRetentionDescription": "삭제한 프로필은 휴지통에 보관되며 그때까지 복원할 수 있습니다. 이후에는 영구 삭제됩니다.",
|
||||
@@ -240,7 +245,10 @@
|
||||
"cleaning": "이전 디렉터리를 지우는 중",
|
||||
"done": "완료"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "즉시 삭제",
|
||||
"scrollSectionsLeft": "섹션 왼쪽으로 스크롤",
|
||||
"scrollSectionsRight": "섹션 오른쪽으로 스크롤"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "프로필 검색...",
|
||||
@@ -2351,7 +2359,9 @@
|
||||
"syncDisplayTooSmall": "그 배치로 창 {{windows}}개를 놓기에는 화면이 너무 작습니다.",
|
||||
"syncArrangeFailed": "옮겨진 창이 없습니다.",
|
||||
"extensionPathInvalid": "그 경로는 사용할 수 없습니다. '..'이 포함되어 있습니다.",
|
||||
"systemProxyUnreachable": "시스템 프록시 {{proxy}}를 통해 연결하지 못했습니다. 시스템 네트워크 설정에서 프록시를 수정하거나 끈 다음 다시 시도하세요."
|
||||
"systemProxyUnreachable": "시스템 프록시 {{proxy}}를 통해 연결하지 못했습니다. 시스템 네트워크 설정에서 프록시를 수정하거나 끈 다음 다시 시도하세요.",
|
||||
"profileRouteRequired": "이 프로필에는 프록시나 VPN이 없습니다. 설정의 개인정보 보호에서 이 경우의 실행이 차단되어 있습니다.",
|
||||
"profileRouteUnavailable": "이 프로필의 프록시나 VPN을 사용할 수 없어 IP 주소를 숨기기 위해 실행을 중단했습니다. 작동하는 프록시나 VPN을 프로필에 지정하세요."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -3452,7 +3462,7 @@
|
||||
"trash": {
|
||||
"label": "휴지통",
|
||||
"title": "삭제된 프로필은 휴지통에서 대기",
|
||||
"body": "삭제된 프로필은 기본적으로 30일 동안 휴지통에 남고 내용 그대로 복원됩니다. 보관 기간은 고급 설정에 있습니다.",
|
||||
"body": "삭제된 프로필은 기본적으로 30일 동안 휴지통에 남고 내용 그대로 복원됩니다. 보관 기간은 개인정보 보호 설정에 있습니다.",
|
||||
"action": "휴지통 열기"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "Apaga com segurança as estatísticas de tráfego registradas de todos os perfis.",
|
||||
"clearTrafficSuccess": "Histórico de tráfego limpo",
|
||||
"vpnExtensionWarning": "Aviso de extensão VPN",
|
||||
"vpnExtensionWarningDescription": "Avisar antes de iniciar quando um perfil contiver uma extensão capaz de redirecionar o tráfego do navegador."
|
||||
"vpnExtensionWarningDescription": "Avisar antes de iniciar quando um perfil contiver uma extensão capaz de redirecionar o tráfego do navegador.",
|
||||
"title": "Privacidade",
|
||||
"requireRoute": "Bloquear inicializações sem proxy ou VPN",
|
||||
"requireRouteDescription": "Um perfil nunca inicia a partir do seu próprio endereço IP: nem quando não tem proxy ou VPN, nem quando o proxy ou a VPN sumiu ou não responde.",
|
||||
"recordSites": "Registrar sites visitados",
|
||||
"recordSitesDescription": "As estatísticas de tráfego listam os sites que cada perfil acessou. Desativado, só os totais de dados são mantidos. Perfis protegidos por senha e efêmeros nunca registram sites."
|
||||
},
|
||||
"trashRetention": "Manter perfis excluídos por",
|
||||
"trashRetentionDescription": "Os perfis excluídos ficam na lixeira e podem ser restaurados até lá. Depois disso, são removidos permanentemente.",
|
||||
@@ -241,7 +246,10 @@
|
||||
"cleaning": "A remover o diretório anterior",
|
||||
"done": "Concluído"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "Excluir na hora",
|
||||
"scrollSectionsLeft": "Rolar seções para a esquerda",
|
||||
"scrollSectionsRight": "Rolar seções para a direita"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Pesquisar perfis...",
|
||||
@@ -2361,7 +2369,9 @@
|
||||
"syncDisplayTooSmall": "O ecrã é demasiado pequeno para {{windows}} janelas nessa disposição.",
|
||||
"syncArrangeFailed": "Não foi possível mover nenhuma janela.",
|
||||
"extensionPathInvalid": "Esse caminho não é permitido: contém '..'.",
|
||||
"systemProxyUnreachable": "O Donut não conseguiu se conectar pelo proxy do sistema {{proxy}}. Corrija ou desative o proxy nas configurações de rede do sistema e tente novamente."
|
||||
"systemProxyUnreachable": "O Donut não conseguiu se conectar pelo proxy do sistema {{proxy}}. Corrija ou desative o proxy nas configurações de rede do sistema e tente novamente.",
|
||||
"profileRouteRequired": "Este perfil não tem proxy nem VPN. Inicializações sem eles estão bloqueadas em Configurações, em Privacidade.",
|
||||
"profileRouteUnavailable": "O proxy ou a VPN deste perfil não está disponível, então a inicialização foi interrompida para manter seu endereço IP oculto. Atribua ao perfil um proxy ou uma VPN que funcione."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -3487,7 +3497,7 @@
|
||||
"trash": {
|
||||
"label": "Lixeira",
|
||||
"title": "Perfis excluídos esperam na lixeira",
|
||||
"body": "Um perfil excluído fica na lixeira por 30 dias por padrão e volta com tudo o que tinha. O período de retenção está nas configurações avançadas.",
|
||||
"body": "Um perfil excluído fica na lixeira por 30 dias por padrão e volta com tudo o que tinha. O período de retenção está nas configurações de privacidade.",
|
||||
"action": "Abrir Lixeira"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "Безопасно удаляет записанную статистику трафика для всех профилей.",
|
||||
"clearTrafficSuccess": "История трафика очищена",
|
||||
"vpnExtensionWarning": "Предупреждение о VPN-расширении",
|
||||
"vpnExtensionWarningDescription": "Предупреждать перед запуском, если в профиле есть расширение, способное перенаправить трафик браузера."
|
||||
"vpnExtensionWarningDescription": "Предупреждать перед запуском, если в профиле есть расширение, способное перенаправить трафик браузера.",
|
||||
"title": "Конфиденциальность",
|
||||
"requireRoute": "Блокировать запуск без прокси или VPN",
|
||||
"requireRouteDescription": "Профиль никогда не запускается с вашего собственного IP-адреса: ни без прокси или VPN, ни когда его прокси или VPN удалён или не отвечает.",
|
||||
"recordSites": "Записывать посещённые сайты",
|
||||
"recordSitesDescription": "Статистика трафика показывает сайты, которые открывал каждый профиль. Если выключено, хранятся только объёмы данных. Профили с паролем и временные профили никогда не записывают сайты."
|
||||
},
|
||||
"trashRetention": "Хранить удалённые профили",
|
||||
"trashRetentionDescription": "Удалённые профили хранятся в корзине, и до этого срока их можно восстановить. После этого они удаляются навсегда.",
|
||||
@@ -242,7 +247,10 @@
|
||||
"cleaning": "Удаляем прежний каталог",
|
||||
"done": "Готово"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "Удалять сразу",
|
||||
"scrollSectionsLeft": "Прокрутить разделы влево",
|
||||
"scrollSectionsRight": "Прокрутить разделы вправо"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Поиск профилей...",
|
||||
@@ -2371,7 +2379,9 @@
|
||||
"syncDisplayTooSmall": "Экран слишком мал для {{windows}} окон в таком расположении.",
|
||||
"syncArrangeFailed": "Ни одно окно не удалось переместить.",
|
||||
"extensionPathInvalid": "Этот путь недопустим: он содержит «..».",
|
||||
"systemProxyUnreachable": "Donut не удалось подключиться через системный прокси {{proxy}}. Исправьте или отключите прокси в сетевых настройках системы и повторите попытку."
|
||||
"systemProxyUnreachable": "Donut не удалось подключиться через системный прокси {{proxy}}. Исправьте или отключите прокси в сетевых настройках системы и повторите попытку.",
|
||||
"profileRouteRequired": "У этого профиля нет прокси или VPN. Запуск без них заблокирован в настройках, в разделе «Конфиденциальность».",
|
||||
"profileRouteUnavailable": "Прокси или VPN этого профиля недоступен, поэтому запуск остановлен, чтобы скрыть ваш IP-адрес. Назначьте профилю рабочий прокси или VPN."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -3522,7 +3532,7 @@
|
||||
"trash": {
|
||||
"label": "Корзина",
|
||||
"title": "Удалённые профили ждут в корзине",
|
||||
"body": "Удалённый профиль по умолчанию хранится в корзине 30 дней и восстанавливается со всем содержимым. Срок хранения задаётся в дополнительных настройках.",
|
||||
"body": "Удалённый профиль по умолчанию хранится в корзине 30 дней и восстанавливается со всем содержимым. Срок хранения задаётся в настройках конфиденциальности.",
|
||||
"action": "Открыть Корзину"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "Tüm profillerin kayıtlı trafik istatistiklerini güvenli bir şekilde siler.",
|
||||
"clearTrafficSuccess": "Trafik geçmişi temizlendi",
|
||||
"vpnExtensionWarning": "VPN uzantısı uyarısı",
|
||||
"vpnExtensionWarningDescription": "Bir profil, tarayıcı trafiğini yeniden yönlendirebilecek bir uzantı içerdiğinde başlatmadan önce uyarır."
|
||||
"vpnExtensionWarningDescription": "Bir profil, tarayıcı trafiğini yeniden yönlendirebilecek bir uzantı içerdiğinde başlatmadan önce uyarır.",
|
||||
"title": "Gizlilik",
|
||||
"requireRoute": "Proxy veya VPN olmadan başlatmayı engelle",
|
||||
"requireRouteDescription": "Bir profil asla kendi IP adresinizden başlamaz: proxy veya VPN yoksa da, proxy veya VPN kaldırıldıysa ya da yanıt vermiyorsa da.",
|
||||
"recordSites": "Ziyaret edilen siteleri kaydet",
|
||||
"recordSitesDescription": "Trafik istatistikleri her profilin ulaştığı siteleri listeler. Kapalıyken yalnızca veri toplamları tutulur. Parola korumalı ve geçici profiller hiçbir zaman site kaydetmez."
|
||||
},
|
||||
"trashRetention": "Silinen profilleri şu süre boyunca sakla",
|
||||
"trashRetentionDescription": "Silinen profiller çöp kutusunda bekler ve o zamana kadar geri yüklenebilir. Sonrasında kalıcı olarak kaldırılır.",
|
||||
@@ -240,7 +245,10 @@
|
||||
"cleaning": "Eski dizin siliniyor",
|
||||
"done": "Bitti"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "Hemen sil",
|
||||
"scrollSectionsLeft": "Bölümleri sola kaydır",
|
||||
"scrollSectionsRight": "Bölümleri sağa kaydır"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Profillerde ara...",
|
||||
@@ -2351,7 +2359,9 @@
|
||||
"syncDisplayTooSmall": "Ekran, o düzende {{windows}} pencere için fazla küçük.",
|
||||
"syncArrangeFailed": "Hiçbir pencere taşınamadı.",
|
||||
"extensionPathInvalid": "Bu yol kullanılamaz: '..' içeriyor.",
|
||||
"systemProxyUnreachable": "Donut, {{proxy}} sistem proxy'si üzerinden bağlanamadı. Sistem ağ ayarlarınızda proxy'yi düzeltin veya kapatın, ardından yeniden deneyin."
|
||||
"systemProxyUnreachable": "Donut, {{proxy}} sistem proxy'si üzerinden bağlanamadı. Sistem ağ ayarlarınızda proxy'yi düzeltin veya kapatın, ardından yeniden deneyin.",
|
||||
"profileRouteRequired": "Bu profilin proxy'si veya VPN'i yok. Bunlar olmadan başlatma, Ayarlar'daki Gizlilik bölümünde engellendi.",
|
||||
"profileRouteUnavailable": "Bu profilin proxy'si veya VPN'i kullanılamıyor, bu yüzden IP adresinizi gizli tutmak için başlatma durduruldu. Profile çalışan bir proxy veya VPN atayın."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
@@ -3452,7 +3462,7 @@
|
||||
"trash": {
|
||||
"label": "Çöp kutusu",
|
||||
"title": "Silinen profiller çöp kutusunda bekler",
|
||||
"body": "Silinen bir profil varsayılan olarak 30 gün çöp kutusunda kalır ve içindeki her şeyle geri gelir. Saklama süresi Gelişmiş ayarlardadır.",
|
||||
"body": "Silinen bir profil varsayılan olarak 30 gün çöp kutusunda kalır ve içindeki her şeyle geri gelir. Saklama süresi Gizlilik ayarlarındadır.",
|
||||
"action": "Çöp kutusunu aç"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "Xóa an toàn số liệu thống kê lưu lượng đã ghi của mọi hồ sơ.",
|
||||
"clearTrafficSuccess": "Đã xóa lịch sử lưu lượng",
|
||||
"vpnExtensionWarning": "Cảnh báo tiện ích VPN",
|
||||
"vpnExtensionWarningDescription": "Cảnh báo trước khi khởi chạy khi hồ sơ có tiện ích có thể định tuyến lại lưu lượng của trình duyệt."
|
||||
"vpnExtensionWarningDescription": "Cảnh báo trước khi khởi chạy khi hồ sơ có tiện ích có thể định tuyến lại lưu lượng của trình duyệt.",
|
||||
"title": "Quyền riêng tư",
|
||||
"requireRoute": "Chặn khởi chạy khi không có proxy hoặc VPN",
|
||||
"requireRouteDescription": "Hồ sơ không bao giờ khởi chạy từ địa chỉ IP của bạn: kể cả khi không có proxy hoặc VPN, hoặc khi proxy hay VPN của nó đã mất hoặc không phản hồi.",
|
||||
"recordSites": "Ghi lại trang đã truy cập",
|
||||
"recordSitesDescription": "Thống kê lưu lượng liệt kê các trang mà mỗi hồ sơ đã truy cập. Khi tắt, chỉ lưu tổng dung lượng dữ liệu. Hồ sơ có mật khẩu và hồ sơ tạm thời không bao giờ ghi lại trang."
|
||||
},
|
||||
"trashRetention": "Giữ hồ sơ đã xóa trong",
|
||||
"trashRetentionDescription": "Hồ sơ đã xóa được giữ trong thùng rác và có thể khôi phục cho đến lúc đó. Sau đó chúng sẽ bị xóa vĩnh viễn.",
|
||||
@@ -240,7 +245,10 @@
|
||||
"cleaning": "Đang xoá thư mục cũ",
|
||||
"done": "Xong"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "Xóa ngay",
|
||||
"scrollSectionsLeft": "Cuộn các mục sang trái",
|
||||
"scrollSectionsRight": "Cuộn các mục sang phải"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Tìm kiếm hồ sơ...",
|
||||
@@ -2351,7 +2359,9 @@
|
||||
"syncDisplayTooSmall": "Màn hình quá nhỏ cho {{windows}} cửa sổ theo cách xếp đó.",
|
||||
"syncArrangeFailed": "Không cửa sổ nào được di chuyển.",
|
||||
"extensionPathInvalid": "Đường dẫn đó không được phép: nó chứa '..'.",
|
||||
"systemProxyUnreachable": "Donut không thể kết nối qua proxy hệ thống {{proxy}}. Hãy sửa hoặc tắt proxy trong cài đặt mạng của hệ thống rồi thử lại."
|
||||
"systemProxyUnreachable": "Donut không thể kết nối qua proxy hệ thống {{proxy}}. Hãy sửa hoặc tắt proxy trong cài đặt mạng của hệ thống rồi thử lại.",
|
||||
"profileRouteRequired": "Hồ sơ này không có proxy hoặc VPN. Việc khởi chạy khi không có chúng đang bị chặn trong Cài đặt, mục Quyền riêng tư.",
|
||||
"profileRouteUnavailable": "Proxy hoặc VPN của hồ sơ này không khả dụng, nên việc khởi chạy đã dừng để giữ địa chỉ IP của bạn được ẩn. Hãy gán cho hồ sơ một proxy hoặc VPN hoạt động được."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -3452,7 +3462,7 @@
|
||||
"trash": {
|
||||
"label": "Thùng rác",
|
||||
"title": "Hồ sơ đã xóa chờ trong thùng rác",
|
||||
"body": "Hồ sơ đã xóa nằm trong thùng rác 30 ngày theo mặc định và quay lại với đầy đủ nội dung. Thời gian lưu giữ nằm trong cài đặt nâng cao.",
|
||||
"body": "Hồ sơ đã xóa nằm trong thùng rác 30 ngày theo mặc định và quay lại với đầy đủ nội dung. Thời gian lưu giữ nằm trong cài đặt quyền riêng tư.",
|
||||
"action": "Mở Thùng rác"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -210,7 +210,12 @@
|
||||
"clearTrafficDescription": "安全清除所有配置文件的已记录流量统计数据。",
|
||||
"clearTrafficSuccess": "流量历史已清除",
|
||||
"vpnExtensionWarning": "VPN 扩展警告",
|
||||
"vpnExtensionWarningDescription": "当配置文件中存在可改变浏览器流量路径的扩展时,在启动前发出警告。"
|
||||
"vpnExtensionWarningDescription": "当配置文件中存在可改变浏览器流量路径的扩展时,在启动前发出警告。",
|
||||
"title": "隐私",
|
||||
"requireRoute": "阻止未使用代理或 VPN 的启动",
|
||||
"requireRouteDescription": "配置文件绝不会使用您自己的 IP 地址启动:无论是没有代理或 VPN,还是其代理或 VPN 已被删除或无响应。",
|
||||
"recordSites": "记录访问过的网站",
|
||||
"recordSitesDescription": "流量统计会列出每个配置文件访问过的网站。关闭后仅保留数据总量。受密码保护的配置文件和临时配置文件从不记录网站。"
|
||||
},
|
||||
"trashRetention": "已删除配置文件的保留时间",
|
||||
"trashRetentionDescription": "已删除的配置文件会保留在回收站中,在此期间可以恢复。之后将被永久删除。",
|
||||
@@ -240,7 +245,10 @@
|
||||
"cleaning": "正在删除旧目录",
|
||||
"done": "完成"
|
||||
}
|
||||
}
|
||||
},
|
||||
"trashRetentionOff": "立即删除",
|
||||
"scrollSectionsLeft": "向左滚动分区",
|
||||
"scrollSectionsRight": "向右滚动分区"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "搜索配置文件...",
|
||||
@@ -2351,7 +2359,9 @@
|
||||
"syncDisplayTooSmall": "显示器太小,无法以该布局排列 {{windows}} 个窗口。",
|
||||
"syncArrangeFailed": "没有窗口被移动。",
|
||||
"extensionPathInvalid": "该路径不允许使用:它包含“..”。",
|
||||
"systemProxyUnreachable": "Donut 无法通过系统代理 {{proxy}} 连接。请在系统网络设置中修正或关闭该代理,然后重试。"
|
||||
"systemProxyUnreachable": "Donut 无法通过系统代理 {{proxy}} 连接。请在系统网络设置中修正或关闭该代理,然后重试。",
|
||||
"profileRouteRequired": "此配置文件没有代理或 VPN。设置的“隐私”中已阻止在没有它们的情况下启动。",
|
||||
"profileRouteUnavailable": "此配置文件的代理或 VPN 不可用,为隐藏您的 IP 地址已停止启动。请为该配置文件分配可用的代理或 VPN。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -3452,7 +3462,7 @@
|
||||
"trash": {
|
||||
"label": "回收站",
|
||||
"title": "已删除的配置文件在回收站等候",
|
||||
"body": "已删除的配置文件默认在回收站保留 30 天,并可连同全部内容一起恢复。保留期限在高级设置中。",
|
||||
"body": "已删除的配置文件默认在回收站保留 30 天,并可连同全部内容一起恢复。保留期限在隐私设置中。",
|
||||
"action": "打开回收站"
|
||||
},
|
||||
"localApi": {
|
||||
|
||||
@@ -65,6 +65,8 @@ export type BackendErrorCode =
|
||||
| "BROWSER_CHECKSUM_UNAVAILABLE"
|
||||
| "BROWSER_CHECKSUM_MISMATCH"
|
||||
| "SYSTEM_PROXY_UNREACHABLE"
|
||||
| "PROFILE_ROUTE_REQUIRED"
|
||||
| "PROFILE_ROUTE_UNAVAILABLE"
|
||||
| "UPDATE_PROFILES_RUNNING"
|
||||
| "UPDATE_PREPARATION_FAILED"
|
||||
| "PROFILE_NAME_EXISTS"
|
||||
@@ -442,6 +444,10 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
browser: parsed.params?.browser ?? "",
|
||||
version: parsed.params?.version ?? "",
|
||||
});
|
||||
case "PROFILE_ROUTE_REQUIRED":
|
||||
return t("backendErrors.profileRouteRequired");
|
||||
case "PROFILE_ROUTE_UNAVAILABLE":
|
||||
return t("backendErrors.profileRouteUnavailable");
|
||||
case "SYSTEM_PROXY_UNREACHABLE":
|
||||
return t("backendErrors.systemProxyUnreachable", {
|
||||
proxy: parsed.params?.proxy ?? "",
|
||||
|
||||
@@ -239,6 +239,34 @@
|
||||
--bottom-mask: transparent;
|
||||
}
|
||||
|
||||
/* The same for a single row that scrolls sideways (`ScrollStrip`). The band
|
||||
is wider than the chevron that sits over it, so a badge passing under the
|
||||
chevron is already gone. */
|
||||
.scroll-fade-x {
|
||||
--left-mask: black;
|
||||
--right-mask: black;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
var(--left-mask) 0,
|
||||
black 36px,
|
||||
black calc(100% - 36px),
|
||||
var(--right-mask)
|
||||
);
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
var(--left-mask) 0,
|
||||
black 36px,
|
||||
black calc(100% - 36px),
|
||||
var(--right-mask)
|
||||
);
|
||||
}
|
||||
.scroll-fade-x[data-fade-left="true"] {
|
||||
--left-mask: transparent;
|
||||
}
|
||||
.scroll-fade-x[data-fade-right="true"] {
|
||||
--right-mask: transparent;
|
||||
}
|
||||
|
||||
/* Ensure Sonner toasts appear above all dialogs and remain interactive */
|
||||
.toaster,
|
||||
[data-sonner-toaster] {
|
||||
|
||||
Reference in new issue
Block a user