mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-27 23:00:50 +02:00
refactor: ui refresh
This commit is contained in:
+84
-5
@@ -3,14 +3,21 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { motion } from "motion/react";
|
||||
import { useOnborda } from "onborda";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AboutDialog } from "@/components/about-dialog";
|
||||
import { AccountPage } from "@/components/account-page";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import {
|
||||
type ConsistencyResult,
|
||||
ConsistencyWarningDialog,
|
||||
isConsistencyWarningSuppressed,
|
||||
} from "@/components/consistency-warning-dialog";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
@@ -60,6 +67,7 @@ import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import {
|
||||
ONBOARDING_TOUR_FINISHED_EVENT,
|
||||
setOnboardingActive,
|
||||
@@ -324,6 +332,11 @@ export default function Home() {
|
||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
|
||||
const [consistencyWarning, setConsistencyWarning] = useState<{
|
||||
profile: BrowserProfile;
|
||||
result: ConsistencyResult;
|
||||
} | null>(null);
|
||||
// Owned by page.tsx so the command palette can request opening the profile
|
||||
// info dialog. ProfilesDataTable consumes it through controlled props.
|
||||
const [profileInfoDialog, setProfileInfoDialog] =
|
||||
@@ -917,6 +930,24 @@ export default function Home() {
|
||||
profile,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
|
||||
// Non-blocking: after a successful launch, check that the proxy exit
|
||||
// node's timezone/country agrees with the fingerprint. A mismatch is a
|
||||
// strong anti-bot tell even though the real device never leaks.
|
||||
if (profile.proxy_id && !isConsistencyWarningSuppressed(profile.id)) {
|
||||
void invoke<ConsistencyResult>(
|
||||
"check_profile_fingerprint_consistency",
|
||||
{ profileId: profile.id },
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.checked && !res.consistent) {
|
||||
setConsistencyWarning({ profile, result: res });
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn("Consistency check failed:", e);
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -1580,12 +1611,24 @@ export default function Home() {
|
||||
pageTitle={subPageTitle}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<RailNav currentPage={currentPage} onNavigate={handleRailNavigate} />
|
||||
<RailNav
|
||||
currentPage={currentPage}
|
||||
onNavigate={handleRailNavigate}
|
||||
onOpenAbout={() => {
|
||||
setAboutDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{currentPage === "profiles" && (
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2.5">
|
||||
{isLoading && groupsData.length === 0 ? null : null}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: MOTION_EASE_OUT }}
|
||||
className="flex min-h-0 flex-1 flex-col px-3 pt-2.5"
|
||||
>
|
||||
<ProfilesDataTable
|
||||
isLoading={isLoading && profiles.length === 0}
|
||||
showOnboardingEmptyState={profiles.length === 0}
|
||||
profiles={filteredProfiles}
|
||||
infoDialogProfile={profileInfoDialog}
|
||||
onInfoDialogProfileChange={setProfileInfoDialog}
|
||||
@@ -1626,12 +1669,25 @@ export default function Home() {
|
||||
onLaunchWithSync={(profile) => {
|
||||
setSyncLeaderProfile(profile);
|
||||
}}
|
||||
onCreateProfile={() => {
|
||||
setCreateProfileDialogOpen(true);
|
||||
}}
|
||||
onImportProfiles={() => {
|
||||
handleRailNavigate("import");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{currentPage === "shortcuts" && (
|
||||
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: MOTION_EASE_OUT }}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<ShortcutsPage groupTargets={orderedGroupTargets} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{settingsDialogOpen && (
|
||||
@@ -1760,6 +1816,29 @@ export default function Home() {
|
||||
handleRailNavigate("profiles");
|
||||
setProfileInfoDialog(profile);
|
||||
}}
|
||||
onCreateProfile={() => {
|
||||
setCreateProfileDialogOpen(true);
|
||||
}}
|
||||
onOpenAbout={() => {
|
||||
setAboutDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AboutDialog
|
||||
isOpen={aboutDialogOpen}
|
||||
onClose={() => {
|
||||
setAboutDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConsistencyWarningDialog
|
||||
isOpen={consistencyWarning !== null}
|
||||
onClose={() => {
|
||||
setConsistencyWarning(null);
|
||||
}}
|
||||
profileName={consistencyWarning?.profile.name ?? ""}
|
||||
profileId={consistencyWarning?.profile.id ?? ""}
|
||||
result={consistencyWarning?.result ?? null}
|
||||
/>
|
||||
|
||||
{pendingUrls.map((pendingUrl) => (
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { launchDonutClone } from "@/lib/donut-physics";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AboutDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
app_version: string;
|
||||
os: string;
|
||||
arch: string;
|
||||
portable: boolean;
|
||||
}
|
||||
|
||||
// Flywheel: each click adds spin; past this speed the donut escapes the
|
||||
// dialog and bounces around the window (shared physics with the rail egg).
|
||||
const SPIN_PER_CLICK = 540; // deg/s
|
||||
const ESCAPE_VELOCITY = 2200; // deg/s
|
||||
const SPIN_FRICTION = 1.1; // fraction of velocity lost per second
|
||||
|
||||
export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
|
||||
const [logoFlown, setLogoFlown] = useState(false);
|
||||
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
const rotationRef = useRef(0);
|
||||
const velocityRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
const lastTimeRef = useRef(0);
|
||||
const cancelLaunchRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
invoke<SystemInfo>("get_system_info")
|
||||
.then(setSystemInfo)
|
||||
.catch(() => {
|
||||
setSystemInfo(null);
|
||||
});
|
||||
}, [isOpen]);
|
||||
|
||||
const stopSpin = useCallback(() => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = 0;
|
||||
velocityRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const spinFrame = useCallback(
|
||||
(time: number) => {
|
||||
const el = logoRef.current;
|
||||
if (!el) {
|
||||
rafRef.current = 0;
|
||||
return;
|
||||
}
|
||||
const dt = Math.min((time - lastTimeRef.current) / 1000, 0.05);
|
||||
lastTimeRef.current = time;
|
||||
|
||||
rotationRef.current += velocityRef.current * dt;
|
||||
velocityRef.current *= Math.max(0, 1 - SPIN_FRICTION * dt);
|
||||
el.style.transform = `rotate(${rotationRef.current}deg)`;
|
||||
|
||||
if (velocityRef.current >= ESCAPE_VELOCITY) {
|
||||
// The flywheel wins: the donut tears loose and joins the bounce sim,
|
||||
// keeping its spin.
|
||||
stopSpin();
|
||||
setLogoFlown(true);
|
||||
cancelLaunchRef.current = launchDonutClone(el, {
|
||||
initialVX: Math.random() > 0.5 ? 420 : -420,
|
||||
initialVY: -750,
|
||||
spinSpeed: ESCAPE_VELOCITY,
|
||||
onExit: () => {
|
||||
cancelLaunchRef.current = null;
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (velocityRef.current > 5) {
|
||||
rafRef.current = requestAnimationFrame(spinFrame);
|
||||
} else {
|
||||
rafRef.current = 0;
|
||||
velocityRef.current = 0;
|
||||
}
|
||||
},
|
||||
[stopSpin],
|
||||
);
|
||||
|
||||
const handleLogoClick = useCallback(() => {
|
||||
if (reducedMotion || logoFlown) return;
|
||||
velocityRef.current += SPIN_PER_CLICK;
|
||||
if (!rafRef.current) {
|
||||
lastTimeRef.current = performance.now();
|
||||
rafRef.current = requestAnimationFrame(spinFrame);
|
||||
}
|
||||
}, [reducedMotion, logoFlown, spinFrame]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
stopSpin();
|
||||
cancelLaunchRef.current?.();
|
||||
cancelLaunchRef.current = null;
|
||||
rotationRef.current = 0;
|
||||
if (logoRef.current) {
|
||||
logoRef.current.style.transform = "";
|
||||
logoRef.current.style.visibility = "";
|
||||
}
|
||||
setLogoFlown(false);
|
||||
onClose();
|
||||
}, [stopSpin, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
cancelLaunchRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("about.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<button
|
||||
ref={logoRef}
|
||||
type="button"
|
||||
aria-label={t("header.donutLogo")}
|
||||
onClick={handleLogoClick}
|
||||
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
|
||||
style={logoFlown ? { visibility: "hidden" } : undefined}
|
||||
>
|
||||
<Logo className="size-14" />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold">Donut Browser</p>
|
||||
{systemInfo && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("about.version", { version: systemInfo.app_version })}
|
||||
{systemInfo.portable && (
|
||||
<span className="ml-1.5 rounded border border-border bg-muted px-1 py-px text-[10px] align-middle">
|
||||
{t("about.portableBadge")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{systemInfo.os} {systemInfo.arch}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("about.licenseNotice")}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void openUrl("https://donutbrowser.com")}
|
||||
>
|
||||
{t("about.website")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void openUrl("https://github.com/zhom/donutbrowser")
|
||||
}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+272
-270
@@ -199,314 +199,316 @@ export function AccountPage({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="account">
|
||||
{t("account.tabs.account")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
title={
|
||||
selfHostedDisabled
|
||||
? t("account.selfHosted.disabledWhileLoggedIn")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("account.tabs.selfHosted")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="account">
|
||||
{t("account.tabs.account")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger
|
||||
value="self-hosted"
|
||||
disabled={selfHostedDisabled}
|
||||
title={
|
||||
selfHostedDisabled
|
||||
? t("account.selfHosted.disabledWhileLoggedIn")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("account.tabs.selfHosted")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
|
||||
<LuUser className="size-6" />
|
||||
<AnimatedTabsContent value="account" className="mt-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
|
||||
<LuUser className="size-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
<h2 className="truncate text-base font-semibold">
|
||||
{user.email}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.plan", {
|
||||
plan: user.plan,
|
||||
period: user.planPeriod ?? "—",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-base font-semibold">
|
||||
{t("account.signedOut")}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.signedOutDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.subscriptionStatus ?? "—"}</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
{isLoggedIn && user && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
{t("account.fields.plan")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
<p className="mt-0.5 font-medium uppercase">
|
||||
{user.plan}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
{t("account.fields.status")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
})}
|
||||
{user.subscriptionStatus ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
{user.teamRole && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.teamRole")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.teamRole}</p>
|
||||
</div>
|
||||
)}
|
||||
{user.planPeriod && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.period")}
|
||||
</p>
|
||||
<p className="mt-0.5">{user.planPeriod}</p>
|
||||
</div>
|
||||
)}
|
||||
{typeof user.deviceOrdinal === "number" && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("account.fields.device")}
|
||||
</p>
|
||||
<p className="mt-0.5">
|
||||
{t("account.deviceOrdinal", {
|
||||
ordinal: user.deviceOrdinal,
|
||||
count: user.deviceCount ?? user.deviceOrdinal,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
</p>
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
{t("account.automationActiveHere")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === false && (
|
||||
<p className="text-xs text-warning">
|
||||
{t("account.automationPrimaryOnly")}
|
||||
</p>
|
||||
)}
|
||||
{isLoggedIn &&
|
||||
user &&
|
||||
getEntitlements(user).browserAutomation &&
|
||||
user.isPrimaryDevice === true &&
|
||||
(user.deviceCount ?? 1) > 1 && (
|
||||
<p className="text-xs text-success">
|
||||
{t("account.automationActiveHere")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void handleRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuRefreshCw className="size-3" />
|
||||
{t("account.refresh")}
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
isLoading={isLoggingOut}
|
||||
disabled={isRefreshing}
|
||||
onClick={() => {
|
||||
void handleLogout();
|
||||
}}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuLogOut className="size-3" />
|
||||
{t("account.logout")}
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onOpenSignIn}
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
>
|
||||
<LuCloud className="size-3" />
|
||||
{t("account.signIn")}
|
||||
</Button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent value="self-hosted" className="mt-4">
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
// but if state flips mid-render (e.g. a cloud login finishes
|
||||
// while the tab is open), show the explanation instead of
|
||||
// a silent empty card.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedTabsContent value="self-hosted" className="mt-4">
|
||||
{selfHostedDisabled ? (
|
||||
// Defensive: the tab trigger is disabled while the user is
|
||||
// logged in, so this branch shouldn't be reachable via UI —
|
||||
// but if state flips mid-render (e.g. a cloud login finishes
|
||||
// while the tab is open), show the explanation instead of
|
||||
// a silent empty card.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("account.selfHosted.disabledWhileLoggedIn")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("account.selfHosted.title")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t("account.selfHosted.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-server-url" className="text-xs">
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="self-hosted-server-url"
|
||||
className="text-xs"
|
||||
>
|
||||
{t("sync.serverUrl")}
|
||||
</Label>
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
id="self-hosted-server-url"
|
||||
type="url"
|
||||
placeholder={t("sync.serverUrlPlaceholder")}
|
||||
value={serverUrl}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setServerUrl(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="self-hosted-token" className="text-xs">
|
||||
{t("sync.token")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="self-hosted-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder={t("sync.tokenPlaceholder")}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setConnectionStatus("unknown");
|
||||
}}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="pr-9"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowToken((v) => !v);
|
||||
}}
|
||||
aria-label={
|
||||
showToken
|
||||
? t("common.aria.hideToken")
|
||||
: t("common.aria.showToken")
|
||||
}
|
||||
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("account.selfHosted.connectionStatus")}
|
||||
</span>
|
||||
{connectionStatus === "connected" && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-success text-success-foreground"
|
||||
>
|
||||
{t("sync.status.connected")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<Badge variant="destructive">
|
||||
{t("sync.status.error")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "testing" && (
|
||||
<Badge variant="secondary">
|
||||
{t("sync.status.syncing")}
|
||||
</Badge>
|
||||
)}
|
||||
{connectionStatus === "unknown" && (
|
||||
<Badge variant="secondary">
|
||||
{t("account.selfHosted.statusUnknown")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
variant="outline"
|
||||
isLoading={isTestingConnection}
|
||||
disabled={!serverUrl || isSavingSelfHosted}
|
||||
onClick={() => void handleTestConnection()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
{t("account.selfHosted.testConnection")}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSavingSelfHosted}
|
||||
disabled={!serverUrl || !token || isTestingConnection}
|
||||
onClick={() => void handleSaveSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
{hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isSavingSelfHosted || isTestingConnection}
|
||||
onClick={() => void handleDisconnectSelfHosted()}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t("account.selfHosted.disconnect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MotionConfig } from "motion/react";
|
||||
import { useEffect } from "react";
|
||||
import { I18nProvider } from "@/components/i18n-provider";
|
||||
import { OnboardingProvider } from "@/components/onboarding-provider";
|
||||
@@ -17,11 +18,17 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>
|
||||
<OnboardingProvider>{children}</OnboardingProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
{/* reducedMotion="user" makes every motion/react animation honor the
|
||||
OS prefers-reduced-motion setting: transforms are skipped, opacity
|
||||
cross-fades are kept. The CSS-side media query in globals.css only
|
||||
covers CSS transitions — this covers the JS-driven ones. */}
|
||||
<MotionConfig reducedMotion="user">
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>
|
||||
<OnboardingProvider>{children}</OnboardingProvider>
|
||||
</TooltipProvider>
|
||||
<Toaster />
|
||||
</MotionConfig>
|
||||
</CustomThemeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,14 @@ import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear } from "react-icons/go";
|
||||
import {
|
||||
LuBadgeInfo,
|
||||
LuCircleStop,
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPlus,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
@@ -53,6 +55,8 @@ interface CommandPaletteProps {
|
||||
onLaunchProfile: (profile: BrowserProfile) => void;
|
||||
onKillProfile: (profile: BrowserProfile) => void;
|
||||
onShowProfileInfo: (profile: BrowserProfile) => void;
|
||||
onCreateProfile: () => void;
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
@@ -122,6 +126,8 @@ export function CommandPalette({
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onShowProfileInfo,
|
||||
onCreateProfile,
|
||||
onOpenAbout,
|
||||
}: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -251,6 +257,14 @@ export function CommandPalette({
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.actions")}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
dispatch(onCreateProfile);
|
||||
}}
|
||||
>
|
||||
<LuPlus />
|
||||
<span>{t("commandPalette.actions.createProfile")}</span>
|
||||
</CommandItem>
|
||||
{byGroup("actions").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
@@ -268,6 +282,14 @@ export function CommandPalette({
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
dispatch(onOpenAbout);
|
||||
}}
|
||||
>
|
||||
<LuBadgeInfo />
|
||||
<span>{t("commandPalette.actions.about")}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export interface ConsistencyResult {
|
||||
consistent: boolean;
|
||||
checked: boolean;
|
||||
exit_ip: string | null;
|
||||
exit_country_code: string | null;
|
||||
exit_timezone: string | null;
|
||||
fingerprint_timezone: string | null;
|
||||
fingerprint_language: string | null;
|
||||
mismatches: string[];
|
||||
}
|
||||
|
||||
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
|
||||
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
|
||||
|
||||
export function isConsistencyWarningEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(GLOBAL_DISABLE_KEY) !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function isConsistencyWarningSuppressed(profileId: string): boolean {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" ||
|
||||
localStorage.getItem(perProfileKey(profileId)) === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ConsistencyWarningDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileName: string;
|
||||
profileId: string;
|
||||
result: ConsistencyResult | null;
|
||||
}
|
||||
|
||||
export function ConsistencyWarningDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileName,
|
||||
profileId,
|
||||
result,
|
||||
}: ConsistencyWarningDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dontWarnAgain, setDontWarnAgain] = useState(false);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontWarnAgain) {
|
||||
try {
|
||||
localStorage.setItem(perProfileKey(profileId), "1");
|
||||
} catch {
|
||||
// localStorage unavailable — nothing to persist
|
||||
}
|
||||
}
|
||||
setDontWarnAgain(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const mismatches = result?.mismatches ?? [];
|
||||
const exitIp = result?.exit_ip ?? null;
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!exitIp) {
|
||||
return;
|
||||
}
|
||||
setIsMatching(true);
|
||||
try {
|
||||
await invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId,
|
||||
exitIp,
|
||||
});
|
||||
showSuccessToast(t("consistencyWarning.matchSuccess"));
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning" />
|
||||
{t("consistencyWarning.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{t("consistencyWarning.intro", { name: profileName })}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/10 p-3">
|
||||
{mismatches.includes("timezone") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.timezoneTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.timezoneDetail", {
|
||||
exit: result?.exit_timezone ?? "?",
|
||||
fingerprint: result?.fingerprint_timezone ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mismatches.includes("language") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.languageTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.languageDetail", {
|
||||
country: result?.exit_country_code ?? "?",
|
||||
fingerprint: result?.fingerprint_language ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.explainer")}
|
||||
</p>
|
||||
|
||||
<label
|
||||
htmlFor="consistency-dont-warn"
|
||||
className="flex cursor-pointer items-center gap-2 text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
id="consistency-dont-warn"
|
||||
checked={dontWarnAgain}
|
||||
onCheckedChange={(v) => setDontWarnAgain(v === true)}
|
||||
/>
|
||||
{t("consistencyWarning.dontWarnAgain")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isMatching}
|
||||
>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
{exitIp && (
|
||||
<RippleButton onClick={handleMatch} disabled={isMatching}>
|
||||
{isMatching
|
||||
? t("consistencyWarning.matching")
|
||||
: t("consistencyWarning.matchToProxy")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserReleaseTypes, WayfernConfig, WayfernOS } from "@/types";
|
||||
|
||||
@@ -1183,21 +1184,14 @@ export function CreateProfileDialog({
|
||||
<SelectItem value="none">
|
||||
{t("dnsBlocklist.none")}
|
||||
</SelectItem>
|
||||
<SelectItem value="light">
|
||||
{t("dnsBlocklist.light")}
|
||||
</SelectItem>
|
||||
<SelectItem value="normal">
|
||||
{t("dnsBlocklist.normal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pro">
|
||||
{t("dnsBlocklist.pro")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pro_plus">
|
||||
{t("dnsBlocklist.proPlus")}
|
||||
</SelectItem>
|
||||
<SelectItem value="ultimate">
|
||||
{t("dnsBlocklist.ultimate")}
|
||||
</SelectItem>
|
||||
{DNS_BLOCKLIST_LEVELS.map((level) => (
|
||||
<SelectItem
|
||||
key={level.value}
|
||||
value={level.value}
|
||||
>
|
||||
{t(level.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
open as openDialog,
|
||||
save as saveDialog,
|
||||
} from "@tauri-apps/plugin-dialog";
|
||||
import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -12,6 +25,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { dnsBlocklistLabelKey } from "@/lib/dns-blocklist-levels";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
|
||||
interface BlocklistCacheStatus {
|
||||
level: string;
|
||||
@@ -23,11 +41,25 @@ interface BlocklistCacheStatus {
|
||||
is_cached: boolean;
|
||||
}
|
||||
|
||||
interface CustomDnsConfig {
|
||||
sources: string[];
|
||||
block_domains: string[];
|
||||
allow_domains: string[];
|
||||
allowlist_mode: boolean;
|
||||
updated_at: number | null;
|
||||
}
|
||||
|
||||
interface DnsBlocklistDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const linesToArray = (v: string) =>
|
||||
v
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export function DnsBlocklistDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -36,6 +68,12 @@ export function DnsBlocklistDialog({
|
||||
const [statuses, setStatuses] = useState<BlocklistCacheStatus[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const [sources, setSources] = useState("");
|
||||
const [blockDomains, setBlockDomains] = useState("");
|
||||
const [allowDomains, setAllowDomains] = useState("");
|
||||
const [allowlistMode, setAllowlistMode] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const loadStatuses = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<BlocklistCacheStatus[]>(
|
||||
@@ -47,11 +85,24 @@ export function DnsBlocklistDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadCustomConfig = useCallback(async () => {
|
||||
try {
|
||||
const config = await invoke<CustomDnsConfig>("get_custom_dns_config");
|
||||
setSources(config.sources.join("\n"));
|
||||
setBlockDomains(config.block_domains.join("\n"));
|
||||
setAllowDomains(config.allow_domains.join("\n"));
|
||||
setAllowlistMode(config.allowlist_mode);
|
||||
} catch (e) {
|
||||
console.error("Failed to load custom DNS config:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadStatuses();
|
||||
void loadCustomConfig();
|
||||
}
|
||||
}, [isOpen, loadStatuses]);
|
||||
}, [isOpen, loadStatuses, loadCustomConfig]);
|
||||
|
||||
const handleRefreshAll = async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -65,6 +116,67 @@ export function DnsBlocklistDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCustom = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const config = await invoke<CustomDnsConfig>("set_custom_dns_config", {
|
||||
sources: linesToArray(sources),
|
||||
blockDomains: linesToArray(blockDomains),
|
||||
allowDomains: linesToArray(allowDomains),
|
||||
allowlistMode,
|
||||
});
|
||||
setSources(config.sources.join("\n"));
|
||||
setBlockDomains(config.block_domains.join("\n"));
|
||||
setAllowDomains(config.allow_domains.join("\n"));
|
||||
setAllowlistMode(config.allowlist_mode);
|
||||
toast.success(t("dnsBlocklist.custom.saved"));
|
||||
} catch (e) {
|
||||
toast.error(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: "Rules", extensions: ["json", "txt"] }],
|
||||
});
|
||||
if (!selected || typeof selected !== "string") return;
|
||||
const content = await readTextFile(selected);
|
||||
const format = selected.toLowerCase().endsWith(".json") ? "json" : "txt";
|
||||
const config = await invoke<CustomDnsConfig>("import_custom_dns_rules", {
|
||||
content,
|
||||
format,
|
||||
});
|
||||
setSources(config.sources.join("\n"));
|
||||
setBlockDomains(config.block_domains.join("\n"));
|
||||
setAllowDomains(config.allow_domains.join("\n"));
|
||||
setAllowlistMode(config.allowlist_mode);
|
||||
toast.success(t("dnsBlocklist.custom.imported"));
|
||||
} catch (e) {
|
||||
toast.error(translateBackendError(t, e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (format: "json" | "txt") => {
|
||||
try {
|
||||
const content = await invoke<string>("export_custom_dns_rules", {
|
||||
format,
|
||||
});
|
||||
const path = await saveDialog({
|
||||
defaultPath: `donut-dns-rules.${format}`,
|
||||
filters: [{ name: format.toUpperCase(), extensions: [format] }],
|
||||
});
|
||||
if (!path) return;
|
||||
await writeTextFile(path, content);
|
||||
toast.success(t("dnsBlocklist.custom.exported"));
|
||||
} catch (e) {
|
||||
toast.error(translateBackendError(t, e));
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -78,69 +190,185 @@ export function DnsBlocklistDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-lg flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("dnsBlocklist.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
<AnimatedTabs
|
||||
defaultValue="blocklists"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
<AnimatedTabsTrigger value="blocklists">
|
||||
{t("dnsBlocklist.tabBlocklists")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="custom">
|
||||
{t("dnsBlocklist.tabCustom")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<div className="max-h-[40vh] min-h-0 space-y-3 overflow-y-auto">
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
className="flex items-center justify-between rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{status.display_name}
|
||||
</span>
|
||||
{status.is_cached ? (
|
||||
status.is_fresh ? (
|
||||
<Badge variant="default" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.fresh")}
|
||||
</Badge>
|
||||
<AnimatedTabsContent
|
||||
value="blocklists"
|
||||
className="min-h-0 flex-1 space-y-3 overflow-y-auto"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
className="flex items-center justify-between rounded-md border border-border p-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t(dnsBlocklistLabelKey(status.level))}
|
||||
</span>
|
||||
{status.is_cached ? (
|
||||
status.is_fresh ? (
|
||||
<Badge variant="default" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.fresh")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1.5 text-[10px]"
|
||||
>
|
||||
{t("dnsBlocklist.stale")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="secondary" className="px-1.5 text-[10px]">
|
||||
{t("dnsBlocklist.stale")}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{t("dnsBlocklist.notCached")}
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-1.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{t("dnsBlocklist.notCached")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{status.is_cached && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status.entry_count.toLocaleString()}{" "}
|
||||
{t("dnsBlocklist.domains")} ·{" "}
|
||||
{formatSize(status.file_size_bytes)} ·{" "}
|
||||
{formatDate(status.last_updated)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{status.is_cached && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{status.entry_count.toLocaleString()}{" "}
|
||||
{t("dnsBlocklist.domains")} ·{" "}
|
||||
{formatSize(status.file_size_bytes)} ·{" "}
|
||||
{formatDate(status.last_updated)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<LuRefreshCw
|
||||
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("dnsBlocklist.refreshAll")}
|
||||
</Button>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<Button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={isRefreshing}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<LuRefreshCw
|
||||
className={`mr-2 size-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("dnsBlocklist.refreshAll")}
|
||||
</Button>
|
||||
<AnimatedTabsContent
|
||||
value="custom"
|
||||
className="min-h-0 flex-1 space-y-4 overflow-y-auto"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("dnsBlocklist.custom.description")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("dnsBlocklist.custom.allowlistModeLabel")}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{allowlistMode
|
||||
? t("dnsBlocklist.custom.allowlistModeOn")
|
||||
: t("dnsBlocklist.custom.allowlistModeOff")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={allowlistMode}
|
||||
onCheckedChange={(v) => setAllowlistMode(v === true)}
|
||||
aria-label={t("dnsBlocklist.custom.allowlistModeLabel")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!allowlistMode && (
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
{t("dnsBlocklist.custom.sourcesLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={sources}
|
||||
onChange={(e) => setSources(e.target.value)}
|
||||
placeholder={t("dnsBlocklist.custom.sourcesPlaceholder")}
|
||||
rows={3}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!allowlistMode && (
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
{t("dnsBlocklist.custom.blockLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={blockDomains}
|
||||
onChange={(e) => setBlockDomains(e.target.value)}
|
||||
placeholder={t("dnsBlocklist.custom.blockPlaceholder")}
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5">
|
||||
{allowlistMode
|
||||
? t("dnsBlocklist.custom.allowedOnlyLabel")
|
||||
: t("dnsBlocklist.custom.allowLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
value={allowDomains}
|
||||
onChange={(e) => setAllowDomains(e.target.value)}
|
||||
placeholder={t("dnsBlocklist.custom.allowPlaceholder")}
|
||||
rows={allowlistMode ? 6 : 3}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{allowlistMode
|
||||
? t("dnsBlocklist.custom.allowedOnlyHint")
|
||||
: t("dnsBlocklist.custom.allowHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LoadingButton isLoading={isSaving} onClick={handleSaveCustom}>
|
||||
{t("common.buttons.save")}
|
||||
</LoadingButton>
|
||||
<Button variant="outline" onClick={handleImport}>
|
||||
{t("common.buttons.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleExport("txt")}
|
||||
>
|
||||
{t("dnsBlocklist.custom.exportTxt")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleExport("json")}
|
||||
>
|
||||
{t("dnsBlocklist.custom.exportJson")}
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaFileArchive, FaFolder } from "react-icons/fa";
|
||||
import { LuChevronRight } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AnimatedDisclosureChevron,
|
||||
AnimatedDisclosureContent,
|
||||
} from "@/components/ui/animated-disclosure";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
@@ -36,8 +42,10 @@ import {
|
||||
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||
import { useGroupEvents } from "@/hooks/use-group-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { fireSprinkleConfetti } from "@/lib/confetti";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ArchiveScanResult,
|
||||
@@ -89,7 +97,13 @@ export function ImportProfileDialog({
|
||||
useState<DuplicateStrategy>("rename");
|
||||
// "none" | "round-robin" | a stored proxy id
|
||||
const [proxyAssignment, setProxyAssignment] = useState<string>("none");
|
||||
// "none" | a VPN config id (applied to every imported profile)
|
||||
const [vpnAssignment, setVpnAssignment] = useState<string>("none");
|
||||
const [wayfernConfig, setWayfernConfig] = useState<WayfernConfig>({});
|
||||
// Fingerprint + advanced options collapse behind disclosures — the default
|
||||
// path is just names + proxy/VPN.
|
||||
const [showFingerprint, setShowFingerprint] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [progress, setProgress] = useState<ProfileImportProgress | null>(null);
|
||||
@@ -97,6 +111,8 @@ export function ImportProfileDialog({
|
||||
|
||||
const { storedProxies } = useProxyEvents();
|
||||
const { groups } = useGroupEvents();
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const activeProfiles =
|
||||
importMode === "auto-detect" ? detectedProfiles : scannedProfiles;
|
||||
@@ -284,6 +300,7 @@ export function ImportProfileDialog({
|
||||
browser_type: p.browser,
|
||||
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
|
||||
proxy_id: proxyIdForIndex(index),
|
||||
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
|
||||
}));
|
||||
|
||||
setCurrentStep("importing");
|
||||
@@ -308,6 +325,9 @@ export function ImportProfileDialog({
|
||||
failed: batchResult.failed_count,
|
||||
}),
|
||||
);
|
||||
if (batchResult.imported_count > 0 && !reducedMotion) {
|
||||
fireSprinkleConfetti();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to import profiles:", error);
|
||||
toast.error(translateBackendError(t, error));
|
||||
@@ -319,9 +339,11 @@ export function ImportProfileDialog({
|
||||
selectedProfiles,
|
||||
profileNames,
|
||||
proxyIdForIndex,
|
||||
vpnAssignment,
|
||||
selectedGroupId,
|
||||
duplicateStrategy,
|
||||
wayfernConfig,
|
||||
reducedMotion,
|
||||
t,
|
||||
]);
|
||||
|
||||
@@ -339,7 +361,10 @@ export function ImportProfileDialog({
|
||||
setNewGroupName("");
|
||||
setDuplicateStrategy("rename");
|
||||
setProxyAssignment("none");
|
||||
setVpnAssignment("none");
|
||||
setWayfernConfig({});
|
||||
setShowFingerprint(false);
|
||||
setShowAdvanced(false);
|
||||
setProgress(null);
|
||||
setResult(null);
|
||||
onClose();
|
||||
@@ -430,365 +455,434 @@ export function ImportProfileDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 space-y-6 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-2xl",
|
||||
)}
|
||||
>
|
||||
{currentStep === "select" && (
|
||||
<AnimatedTabs
|
||||
value={importMode}
|
||||
onValueChange={(v) => {
|
||||
setImportMode(v as ImportMode);
|
||||
setSelectedPaths(new Set());
|
||||
}}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
|
||||
{t("importProfile.autoDetect")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
|
||||
{t("importProfile.manualImport")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent value="auto-detect">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.detectedProfilesTitle")}
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{t("importProfile.scanning")}
|
||||
</p>
|
||||
</div>
|
||||
) : detectedProfiles.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{t("importProfile.noneFound")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("importProfile.noneFoundHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderProfileList(detectedProfiles)
|
||||
)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent value="manual">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.manualTitle")}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-path" className="mb-2">
|
||||
{t("importProfile.profileFolderPath")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="manual-profile-path"
|
||||
value={manualPath}
|
||||
onChange={(e) => {
|
||||
setManualPath(e.target.value);
|
||||
}}
|
||||
placeholder={t(
|
||||
"importProfile.profileFolderPlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseFolder()}
|
||||
title={t("importProfile.browseFolderTitle")}
|
||||
>
|
||||
<FaFolder className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseArchive()}
|
||||
title={t("importProfile.selectArchiveTitle")}
|
||||
>
|
||||
<FaFileArchive className="size-4" />
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
isLoading={isScanning}
|
||||
disabled={!manualPath.trim()}
|
||||
onClick={() => void scanPath(manualPath.trim())}
|
||||
>
|
||||
{t("importProfile.scanButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("importProfile.manualHint")}
|
||||
</p>
|
||||
<p className="mt-2 text-xs break-all text-muted-foreground">
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application Support/Google/Chrome/Default
|
||||
<br />
|
||||
Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default
|
||||
<br />
|
||||
Linux: ~/.config/google-chrome/Default
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scannedProfiles.length > 0 &&
|
||||
renderProfileList(scannedProfiles)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
)}
|
||||
|
||||
{currentStep === "configure" && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("importProfile.importedAs", {
|
||||
browser: getBrowserDisplayName("wayfern"),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.profilesToImport")}
|
||||
</Label>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{selectedProfiles.map((profile) => (
|
||||
<div key={profile.path} className="flex items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||
title={profile.path}
|
||||
>
|
||||
{profile.name}
|
||||
</span>
|
||||
<Input
|
||||
className="flex-1"
|
||||
aria-label={t("importProfile.newProfileName")}
|
||||
value={profileNames[profile.path] ?? profile.name}
|
||||
onChange={(e) => {
|
||||
setProfileNames((prev) => ({
|
||||
...prev,
|
||||
[profile.path]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.groupOptional")}
|
||||
</Label>
|
||||
{isCreatingGroup ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder={t("importProfile.newGroupNamePlaceholder")}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!newGroupName.trim()}
|
||||
onClick={() => void handleCreateGroup()}
|
||||
>
|
||||
{t("common.buttons.create")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId}
|
||||
onValueChange={(value) => {
|
||||
if (value === "create-new") {
|
||||
setIsCreatingGroup(true);
|
||||
} else {
|
||||
setSelectedGroupId(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("importProfile.noGroup")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noGroup")}
|
||||
</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="create-new">
|
||||
{t("importProfile.createNewGroup")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.duplicateStrategyLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={duplicateStrategy}
|
||||
onValueChange={(value) => {
|
||||
setDuplicateStrategy(value as DuplicateStrategy);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rename">
|
||||
{t("importProfile.duplicateRename")}
|
||||
</SelectItem>
|
||||
<SelectItem value="skip">
|
||||
{t("importProfile.duplicateSkip")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.proxyOptional")}
|
||||
</Label>
|
||||
<Select
|
||||
value={proxyAssignment}
|
||||
onValueChange={setProxyAssignment}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("importProfile.noProxy")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noProxy")}
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectItem value="round-robin">
|
||||
{t("importProfile.proxyRoundRobin")}
|
||||
</SelectItem>
|
||||
)}
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
className={cn("space-y-6", subPage && "mx-auto w-full max-w-3xl")}
|
||||
>
|
||||
{currentStep === "select" && (
|
||||
<AnimatedTabs
|
||||
value={importMode}
|
||||
onValueChange={(v) => {
|
||||
setImportMode(v as ImportMode);
|
||||
setSelectedPaths(new Set());
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="auto-detect" disabled={isLoading}>
|
||||
{t("importProfile.autoDetect")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="manual" disabled={isLoading}>
|
||||
{t("importProfile.manualImport")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
{currentStep === "importing" && (
|
||||
<div className="space-y-4">
|
||||
{isImporting && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.importingTitle")}
|
||||
</h3>
|
||||
<Progress value={progressPercent} />
|
||||
{progress && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("importProfile.importProgress", {
|
||||
completed: progress.completed,
|
||||
total: progress.total,
|
||||
})}
|
||||
{progress.status === "importing" && (
|
||||
<> — {progress.name}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<AnimatedTabsContent value="auto-detect">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.detectedProfilesTitle")}
|
||||
</h3>
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.resultsSummary", {
|
||||
imported: result.imported_count,
|
||||
skipped: result.skipped_count,
|
||||
failed: result.failed_count,
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{t("importProfile.scanning")}
|
||||
</p>
|
||||
</div>
|
||||
) : detectedProfiles.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{t("importProfile.noneFound")}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{t("importProfile.noneFoundHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
renderProfileList(detectedProfiles)
|
||||
)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent value="manual">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.manualTitle")}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="manual-profile-path" className="mb-2">
|
||||
{t("importProfile.profileFolderPath")}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="manual-profile-path"
|
||||
value={manualPath}
|
||||
onChange={(e) => {
|
||||
setManualPath(e.target.value);
|
||||
}}
|
||||
placeholder={t(
|
||||
"importProfile.profileFolderPlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseFolder()}
|
||||
title={t("importProfile.browseFolderTitle")}
|
||||
>
|
||||
<FaFolder className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => void handleBrowseArchive()}
|
||||
title={t("importProfile.selectArchiveTitle")}
|
||||
>
|
||||
<FaFileArchive className="size-4" />
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="outline"
|
||||
isLoading={isScanning}
|
||||
disabled={!manualPath.trim()}
|
||||
onClick={() => void scanPath(manualPath.trim())}
|
||||
>
|
||||
{t("importProfile.scanButton")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("importProfile.manualHint")}
|
||||
</p>
|
||||
<p className="mt-2 text-xs break-all text-muted-foreground">
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application
|
||||
Support/Google/Chrome/Default
|
||||
<br />
|
||||
Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default
|
||||
<br />
|
||||
Linux: ~/.config/google-chrome/Default
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{scannedProfiles.length > 0 &&
|
||||
renderProfileList(scannedProfiles)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
)}
|
||||
|
||||
{currentStep === "configure" && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("importProfile.importedAs", {
|
||||
browser: getBrowserDisplayName("wayfern"),
|
||||
})}
|
||||
</h3>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{result.results.map((item) => (
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.profilesToImport")}
|
||||
</Label>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{selectedProfiles.map((profile) => (
|
||||
<div
|
||||
key={item.source_path}
|
||||
className="flex items-center gap-2 p-1 text-sm"
|
||||
key={profile.path}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium",
|
||||
item.status === "imported" && "text-success",
|
||||
item.status === "skipped" &&
|
||||
"text-muted-foreground",
|
||||
item.status === "failed" && "text-destructive",
|
||||
)}
|
||||
className="min-w-0 flex-1 truncate text-xs text-muted-foreground"
|
||||
title={profile.path}
|
||||
>
|
||||
{item.status === "imported" &&
|
||||
t("importProfile.statusImported")}
|
||||
{item.status === "skipped" &&
|
||||
t("importProfile.statusSkipped")}
|
||||
{item.status === "failed" &&
|
||||
t("importProfile.statusFailed")}
|
||||
{profile.name}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.name || item.source_path}
|
||||
</span>
|
||||
{item.error && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-destructive">
|
||||
{translateBackendError(t, new Error(item.error))}
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
className="flex-1"
|
||||
aria-label={t("importProfile.newProfileName")}
|
||||
value={profileNames[profile.path] ?? profile.name}
|
||||
onChange={(e) => {
|
||||
setProfileNames((prev) => ({
|
||||
...prev,
|
||||
[profile.path]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
placeholder={t(
|
||||
"importProfile.newProfileNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.proxyOptional")}
|
||||
</Label>
|
||||
<Select
|
||||
value={proxyAssignment}
|
||||
onValueChange={setProxyAssignment}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("importProfile.noProxy")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noProxy")}
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectItem value="round-robin">
|
||||
{t("importProfile.proxyRoundRobin")}
|
||||
</SelectItem>
|
||||
)}
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{vpnConfigs.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.vpnOptional")}
|
||||
</Label>
|
||||
<Select
|
||||
value={vpnAssignment}
|
||||
onValueChange={setVpnAssignment}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("importProfile.noVpn")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noVpn")}
|
||||
</SelectItem>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<SelectItem key={vpn.id} value={vpn.id}>
|
||||
{vpn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
aria-expanded={showAdvanced}
|
||||
>
|
||||
<AnimatedDisclosureChevron open={showAdvanced}>
|
||||
<LuChevronRight className="size-3.5" />
|
||||
</AnimatedDisclosureChevron>
|
||||
{t("importProfile.advancedOptions")}
|
||||
</button>
|
||||
<AnimatedDisclosureContent
|
||||
open={showAdvanced}
|
||||
className="mt-3 space-y-4"
|
||||
>
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.groupOptional")}
|
||||
</Label>
|
||||
{isCreatingGroup ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder={t(
|
||||
"importProfile.newGroupNamePlaceholder",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!newGroupName.trim()}
|
||||
onClick={() => void handleCreateGroup()}
|
||||
>
|
||||
{t("common.buttons.create")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId}
|
||||
onValueChange={(value) => {
|
||||
if (value === "create-new") {
|
||||
setIsCreatingGroup(true);
|
||||
} else {
|
||||
setSelectedGroupId(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("importProfile.noGroup")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("importProfile.noGroup")}
|
||||
</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="create-new">
|
||||
{t("importProfile.createNewGroup")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2">
|
||||
{t("importProfile.duplicateStrategyLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={duplicateStrategy}
|
||||
onValueChange={(value) => {
|
||||
setDuplicateStrategy(value as DuplicateStrategy);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rename">
|
||||
{t("importProfile.duplicateRename")}
|
||||
</SelectItem>
|
||||
<SelectItem value="skip">
|
||||
{t("importProfile.duplicateSkip")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</AnimatedDisclosureContent>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowFingerprint((v) => !v)}
|
||||
aria-expanded={showFingerprint}
|
||||
>
|
||||
<AnimatedDisclosureChevron open={showFingerprint}>
|
||||
<LuChevronRight className="size-3.5" />
|
||||
</AnimatedDisclosureChevron>
|
||||
{t("importProfile.configureFingerprint")}
|
||||
</button>
|
||||
<AnimatedDisclosureContent
|
||||
open={showFingerprint}
|
||||
className="mt-3"
|
||||
>
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={(key, value) => {
|
||||
setWayfernConfig((prev) => ({ ...prev, [key]: value }));
|
||||
}}
|
||||
isCreating={true}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
</AnimatedDisclosureContent>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === "importing" && (
|
||||
<div className="space-y-4">
|
||||
{isImporting && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.importingTitle")}
|
||||
</h3>
|
||||
<Progress value={progressPercent} />
|
||||
{progress && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("importProfile.importProgress", {
|
||||
completed: progress.completed,
|
||||
total: progress.total,
|
||||
})}
|
||||
{progress.status === "importing" && (
|
||||
<> — {progress.name}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t("importProfile.resultsSummary", {
|
||||
imported: result.imported_count,
|
||||
skipped: result.skipped_count,
|
||||
failed: result.failed_count,
|
||||
})}
|
||||
</h3>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{result.results.map((item) => (
|
||||
<div
|
||||
key={item.source_path}
|
||||
className="flex items-center gap-2 p-1 text-sm"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium",
|
||||
item.status === "imported" && "text-success",
|
||||
item.status === "skipped" &&
|
||||
"text-muted-foreground",
|
||||
item.status === "failed" && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{item.status === "imported" &&
|
||||
t("importProfile.statusImported")}
|
||||
{item.status === "skipped" &&
|
||||
t("importProfile.statusSkipped")}
|
||||
{item.status === "failed" &&
|
||||
t("importProfile.statusFailed")}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.name || item.source_path}
|
||||
</span>
|
||||
{item.error && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-destructive">
|
||||
{translateBackendError(t, new Error(item.error))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-end gap-2",
|
||||
subPage
|
||||
? "mx-auto w-full max-w-2xl border-t border-border pt-2"
|
||||
? "mx-auto w-full max-w-3xl border-t border-border pt-2"
|
||||
: undefined,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -315,167 +315,261 @@ export function IntegrationsDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
subPage && "mx-auto w-full max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="mcp">
|
||||
{t("integrations.tabMcp")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="mcp">
|
||||
{t("integrations.tabMcp")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="@container mt-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuPlug className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.apiEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.apiEnableDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={(checked) =>
|
||||
void handleApiToggle(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={apiServerPort !== null}
|
||||
disabled={isApiStarting}
|
||||
onCheckedChange={(checked) => void handleApiToggle(checked)}
|
||||
/>
|
||||
|
||||
{apiServerPort && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("integrations.apiRunningOn")}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
http://127.0.0.1:{apiServerPort}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apiServerPort && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span className="text-muted-foreground">
|
||||
{t("integrations.apiRunningOn")}
|
||||
</span>
|
||||
<code className="rounded bg-muted px-2 py-1 font-mono text-[11px]">
|
||||
http://127.0.0.1:{apiServerPort}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 @2xl:grid-cols-2">
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiPortLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={apiPortDraft}
|
||||
onChange={(e) => {
|
||||
setApiPortDraft(e.target.value);
|
||||
const val = Number.parseInt(e.target.value, 10);
|
||||
if (
|
||||
!Number.isNaN(val) &&
|
||||
val >= 1 &&
|
||||
val <= 65535
|
||||
) {
|
||||
setSettings({ ...settings, api_port: val });
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="w-24 font-mono"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isApiStarting ||
|
||||
apiServerPort === settings.api_port
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const val = Number.parseInt(apiPortDraft, 10);
|
||||
if (Number.isNaN(val) || val < 1 || val > 65535) {
|
||||
setApiPortDraft(String(settings.api_port));
|
||||
}
|
||||
}}
|
||||
className="w-24 font-mono"
|
||||
min={1}
|
||||
max={65535}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
isApiStarting || apiServerPort === settings.api_port
|
||||
}
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(t("integrations.apiInvalidPort"), {
|
||||
description: t(
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings },
|
||||
);
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>(
|
||||
"start_api_server",
|
||||
{ port },
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
onClick={async () => {
|
||||
const port = settings.api_port;
|
||||
if (port < 1 || port > 65535) {
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
t("integrations.apiInvalidPort"),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
"integrations.apiInvalidPortDescription",
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(t("integrations.apiStartFailed"), {
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("integrations.apiUnknownError"),
|
||||
});
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
setIsApiStarting(true);
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings },
|
||||
);
|
||||
setSettings(next);
|
||||
const actualPort = await invoke<number>(
|
||||
"start_api_server",
|
||||
{ port },
|
||||
);
|
||||
setApiServerPort(actualPort);
|
||||
if (actualPort !== port) {
|
||||
showErrorToast(
|
||||
t("integrations.apiPortInUse", { port }),
|
||||
{
|
||||
description: t(
|
||||
"integrations.apiFallbackPort",
|
||||
{ port: actualPort },
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(
|
||||
t("integrations.apiRunning", {
|
||||
port: actualPort,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorToast(
|
||||
t("integrations.apiStartFailed"),
|
||||
{
|
||||
description:
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t("integrations.apiUnknownError"),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsApiStarting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
readOnly
|
||||
className="pr-10 font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
}}
|
||||
>
|
||||
{showApiToken ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiTokenLabel")}
|
||||
{t("integrations.apiExampleRequest")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="mcp"
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpEnableDescription")}
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-warning">
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={settings.mcp_enabled && mcpConfig !== null}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={(checked) =>
|
||||
void handleMcpToggle(checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpConfig && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.url")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showApiToken ? "text" : "password"}
|
||||
value={settings.api_token ?? ""}
|
||||
type={showMcpUrl ? "text" : "password"}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
className="pr-10 font-mono"
|
||||
className="pr-10 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -483,10 +577,10 @@ export function IntegrationsDialog({
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowApiToken(!showApiToken);
|
||||
setShowMcpUrl(!showMcpUrl);
|
||||
}}
|
||||
>
|
||||
{showApiToken ? (
|
||||
{showMcpUrl ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
@@ -494,164 +588,80 @@ export function IntegrationsDialog({
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={settings.api_token ?? ""}
|
||||
successMessage={t("integrations.tokenCopied")}
|
||||
text={mcpUrl}
|
||||
successMessage={t("integrations.mcp.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.apiExampleRequest")}
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<CopyToClipboard
|
||||
text={`curl -H "Authorization: Bearer ${settings.api_token ?? "${TOKEN}"}" \\\n http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
successMessage={t("common.buttons.copied")}
|
||||
/>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-background p-3 font-mono text-[11px] whitespace-pre">
|
||||
{`curl -H "Authorization: Bearer \${TOKEN}" \\
|
||||
http://127.0.0.1:${apiServerPort ?? settings.api_port}/v1/profiles`}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="mcp"
|
||||
className="mt-4 flex flex-col gap-5"
|
||||
>
|
||||
<div className="flex flex-col gap-4 rounded-md border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<LuZap className="mt-0.5 size-5 text-muted-foreground" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("integrations.mcpEnableLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpEnableDescription")}
|
||||
{!termsAccepted && (
|
||||
<span className="ml-1 text-warning">
|
||||
{t("integrations.mcpAcceptTermsFirst")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={settings.mcp_enabled && mcpConfig !== null}
|
||||
disabled={!termsAccepted || isMcpStarting}
|
||||
onCheckedChange={(checked) => void handleMcpToggle(checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpConfig && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 rounded-md border bg-card p-4">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.url")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showMcpUrl ? "text" : "password"}
|
||||
value={mcpUrl}
|
||||
readOnly
|
||||
className="pr-10 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-0 right-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
setShowMcpUrl(!showMcpUrl);
|
||||
}}
|
||||
>
|
||||
{showMcpUrl ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyToClipboard
|
||||
text={mcpUrl}
|
||||
successMessage={t("integrations.mcp.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="@container flex flex-col gap-3">
|
||||
<Label className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
|
||||
<AgentIcon category={agent.category} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{agent.display_name}
|
||||
</p>
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{categoryLabel(t, agent.category)}
|
||||
</p>
|
||||
</div>
|
||||
{agent.connected ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
|
||||
<LuCheck className="size-3" />
|
||||
{t("integrations.mcp.connected")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void handleRemoveAgent(agent)}
|
||||
aria-label={t(
|
||||
"integrations.mcp.removeAriaLabel",
|
||||
{
|
||||
name: agent.display_name,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</Button>
|
||||
<div className="grid grid-cols-1 gap-3 @2xl:grid-cols-2">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-3 rounded-md border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="grid size-8 shrink-0 place-items-center rounded-md bg-muted">
|
||||
<AgentIcon category={agent.category} />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void handleAddAgent(agent)}
|
||||
>
|
||||
{t("integrations.mcp.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{agent.display_name}
|
||||
</p>
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
{categoryLabel(t, agent.category)}
|
||||
</p>
|
||||
</div>
|
||||
{agent.connected ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted px-2 py-1 text-[10px] font-medium tracking-wide text-foreground uppercase">
|
||||
<LuCheck className="size-3" />
|
||||
{t("integrations.mcp.connected")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void handleRemoveAgent(agent)
|
||||
}
|
||||
aria-label={t(
|
||||
"integrations.mcp.removeAriaLabel",
|
||||
{
|
||||
name: agent.display_name,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => void handleAddAgent(agent)}
|
||||
>
|
||||
{t("integrations.mcp.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</>
|
||||
)}
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
LuSquare,
|
||||
LuTrash2,
|
||||
LuTriangleAlert,
|
||||
LuUserSearch,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
@@ -89,6 +91,7 @@ import {
|
||||
getProfileIcon,
|
||||
isCrossOsProfile,
|
||||
} from "@/lib/browser-utils";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -107,11 +110,13 @@ import {
|
||||
DataTableActionBarAction,
|
||||
DataTableActionBarSelection,
|
||||
} from "./data-table-action-bar";
|
||||
import { Logo } from "./icons/logo";
|
||||
import MultipleSelector, { type Option } from "./multiple-selector";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { TrafficDetailsDialog } from "./traffic-details-dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import { Skeleton } from "./ui/skeleton";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
@@ -424,15 +429,7 @@ function DnsCell({
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const level = profile.dns_blocklist ?? null;
|
||||
// Backend levels are: light, normal, pro, pro_plus, ultimate (+ null).
|
||||
// Keep the list ordered from least to most restrictive.
|
||||
const LEVELS: { value: string; labelKey: string }[] = [
|
||||
{ value: "light", labelKey: "dnsBlocklist.light" },
|
||||
{ value: "normal", labelKey: "dnsBlocklist.normal" },
|
||||
{ value: "pro", labelKey: "dnsBlocklist.pro" },
|
||||
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
|
||||
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
|
||||
];
|
||||
const LEVELS = DNS_BLOCKLIST_LEVELS;
|
||||
const currentLabel =
|
||||
level === null
|
||||
? null
|
||||
@@ -1168,6 +1165,12 @@ interface ProfilesDataTableProps {
|
||||
*/
|
||||
infoDialogProfile?: BrowserProfile | null;
|
||||
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
|
||||
/** Initial data load in flight — renders skeleton rows instead of "empty". */
|
||||
isLoading?: boolean;
|
||||
/** True when the app has zero profiles overall (not just a filtered view). */
|
||||
showOnboardingEmptyState?: boolean;
|
||||
onCreateProfile?: () => void;
|
||||
onImportProfiles?: () => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -1205,6 +1208,10 @@ export function ProfilesDataTable({
|
||||
onRemovePassword,
|
||||
infoDialogProfile,
|
||||
onInfoDialogProfileChange,
|
||||
isLoading = false,
|
||||
showOnboardingEmptyState = false,
|
||||
onCreateProfile,
|
||||
onImportProfiles,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -2391,13 +2398,42 @@ export function ProfilesDataTable({
|
||||
: void handleProfileLaunch(profile)
|
||||
}
|
||||
>
|
||||
{isLaunching || isStopping ? (
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
) : isRunning ? (
|
||||
<LuSquare className="size-3.5 fill-current" />
|
||||
) : (
|
||||
<LuPlay className="size-3.5 fill-current" />
|
||||
)}
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{isLaunching || isStopping ? (
|
||||
<motion.span
|
||||
key="spinner"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<div className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
|
||||
</motion.span>
|
||||
) : isRunning ? (
|
||||
<motion.span
|
||||
key="stop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<LuSquare className="size-3.5 fill-current" />
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="play"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<LuPlay className="size-3.5 fill-current" />
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</RippleButton>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -3172,14 +3208,97 @@ export function ProfilesDataTable({
|
||||
</TableHeader>
|
||||
<TableBody className="overflow-visible">
|
||||
{sortedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("profiles.table.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
isLoading ? (
|
||||
Array.from({ length: 8 }, (_, i) => (
|
||||
<TableRow
|
||||
key={`skeleton-${i}`}
|
||||
className="border-0!"
|
||||
style={{ height: `${ROW_HEIGHT}px` }}
|
||||
>
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="py-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-7 shrink-0 rounded-md" />
|
||||
<Skeleton
|
||||
className="h-3"
|
||||
style={{ width: `${30 + ((i * 17) % 40)}%` }}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-10" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : showOnboardingEmptyState ? (
|
||||
<TableRow className="border-0! hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="py-16"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<Logo className="size-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("profiles.table.emptyTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("profiles.table.emptyHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-2">
|
||||
{onCreateProfile && (
|
||||
<RippleButton size="sm" onClick={onCreateProfile}>
|
||||
{t("profiles.table.emptyCreate")}
|
||||
</RippleButton>
|
||||
)}
|
||||
{onImportProfiles && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onImportProfiles}
|
||||
>
|
||||
{t("profiles.table.emptyImport")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow className="border-0! hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="py-16"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="grid size-12 place-items-center rounded-full bg-muted/60">
|
||||
<LuUserSearch className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{t("profiles.table.emptyFilteredTitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("profiles.table.emptyFilteredHint")}
|
||||
</p>
|
||||
</div>
|
||||
{onCreateProfile && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1"
|
||||
onClick={onCreateProfile}
|
||||
>
|
||||
{t("profiles.table.emptyCreate")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LuCookie,
|
||||
LuCopy,
|
||||
LuDownload,
|
||||
LuEraser,
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { SharedFingerprintConfigForm } from "@/components/shared-fingerprint-config-form";
|
||||
import { AnimatedSwitch } from "@/components/ui/animated-switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ColorPicker,
|
||||
@@ -73,6 +75,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getProfileIcon } from "@/lib/browser-utils";
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -126,6 +129,56 @@ function _OSIcon({ os }: { os: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function ClearOnCloseToggle({
|
||||
profile,
|
||||
isDisabled,
|
||||
}: {
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [enabled, setEnabled] = React.useState(profile.clear_on_close === true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setEnabled(profile.clear_on_close === true);
|
||||
}, [profile.clear_on_close]);
|
||||
|
||||
const toggle = async (next: boolean) => {
|
||||
setEnabled(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
await invoke("update_profile_clear_on_close", {
|
||||
profileId: profile.id,
|
||||
clearOnClose: next,
|
||||
});
|
||||
} catch (error) {
|
||||
setEnabled(!next);
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<LuEraser className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{t("clearOnClose.label")}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("clearOnClose.description")}
|
||||
</p>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
checked={enabled}
|
||||
disabled={saving || isDisabled}
|
||||
onCheckedChange={(v) => void toggle(v === true)}
|
||||
aria-label={t("clearOnClose.label")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/50 px-3 py-2.5">
|
||||
@@ -976,6 +1029,10 @@ function ProfileInfoLayout({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!profile.ephemeral && !profile.password_protected && (
|
||||
<ClearOnCloseToggle profile={profile} isDisabled={isDisabled} />
|
||||
)}
|
||||
|
||||
{profile.created_by_email && (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
|
||||
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
|
||||
@@ -2320,11 +2377,10 @@ export function ProfileDnsBlocklistDialog({
|
||||
|
||||
const options = [
|
||||
{ value: "", label: t("dnsBlocklist.none") },
|
||||
{ value: "light", label: t("dnsBlocklist.light") },
|
||||
{ value: "normal", label: t("dnsBlocklist.normal") },
|
||||
{ value: "pro", label: t("dnsBlocklist.pro") },
|
||||
{ value: "pro_plus", label: t("dnsBlocklist.proPlus") },
|
||||
{ value: "ultimate", label: t("dnsBlocklist.ultimate") },
|
||||
...DNS_BLOCKLIST_LEVELS.map((l) => ({
|
||||
value: l.value as string,
|
||||
label: t(l.labelKey),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
+51
-87
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
@@ -7,12 +8,15 @@ import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
||||
import {
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { launchDonutClone } from "@/lib/donut-physics";
|
||||
import { MOTION_SPRING_POSITION } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -31,11 +35,6 @@ export type AppPage =
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
const CLICK_WINDOW_MS = 2000;
|
||||
const GRAVITY = 2200;
|
||||
const BOUNCE_DAMPING = 0.6;
|
||||
const INITIAL_HORIZONTAL_SPEED = 350;
|
||||
const SPIN_SPEED = 720;
|
||||
const MIN_BOUNCE_VELOCITY = 60;
|
||||
const LOGO_HIDDEN_KEY = "donut-logo-hidden";
|
||||
|
||||
function useLogoEasterEgg({
|
||||
@@ -64,74 +63,15 @@ function useLogoEasterEgg({
|
||||
}
|
||||
});
|
||||
const logoRef = useRef<HTMLButtonElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const cancelFallRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const triggerFall = useCallback(() => {
|
||||
const el = logoRef.current;
|
||||
if (!el || isFalling) return;
|
||||
setIsFalling(true);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.left = `${startX}px`;
|
||||
clone.style.top = `${startY}px`;
|
||||
clone.style.zIndex = "9999";
|
||||
clone.style.pointerEvents = "none";
|
||||
clone.style.margin = "0";
|
||||
document.body.appendChild(clone);
|
||||
el.style.visibility = "hidden";
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vy = -500;
|
||||
// Roll right first, bounce off the right wall, then escape the left.
|
||||
let vx = INITIAL_HORIZONTAL_SPEED;
|
||||
let rotation = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const animate = (time: number) => {
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
|
||||
// Read live so a mid-animation window resize moves the floor/wall.
|
||||
const floorY = window.innerHeight;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
rotation += SPIN_SPEED * dt * (vx > 0 ? 1 : -1);
|
||||
|
||||
const currentBottom = startY + y + rect.height;
|
||||
if (currentBottom >= floorY && vy > 0) {
|
||||
y = floorY - startY - rect.height;
|
||||
vy =
|
||||
Math.abs(vy) > MIN_BOUNCE_VELOCITY
|
||||
? -Math.abs(vy) * BOUNCE_DAMPING
|
||||
: -MIN_BOUNCE_VELOCITY * 3;
|
||||
}
|
||||
|
||||
// Right-wall bounce: hit, reverse horizontal velocity (with a tiny
|
||||
// damping), and keep rolling. Left wall has no bounce — the donut
|
||||
// exits the window off the left edge.
|
||||
const currentRight = startX + x + rect.width;
|
||||
if (currentRight >= rightWall && vx > 0) {
|
||||
x = rightWall - startX - rect.width;
|
||||
vx = -Math.abs(vx) * 0.9;
|
||||
}
|
||||
|
||||
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
|
||||
|
||||
const offScreenLeft = startX + x + rect.width < -200;
|
||||
const offScreenBottom = startY + y > floorY + 100;
|
||||
const offScreenTop = startY + y + rect.height < -200;
|
||||
|
||||
if (offScreenLeft || offScreenBottom || offScreenTop) {
|
||||
clone.remove();
|
||||
cancelFallRef.current = launchDonutClone(el, {
|
||||
onExit: () => {
|
||||
try {
|
||||
sessionStorage.setItem(LOGO_HIDDEN_KEY, "1");
|
||||
} catch {
|
||||
@@ -139,16 +79,13 @@ function useLogoEasterEgg({
|
||||
}
|
||||
setIsHidden(true);
|
||||
setIsFalling(false);
|
||||
return;
|
||||
}
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(animate);
|
||||
},
|
||||
});
|
||||
}, [isFalling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
cancelFallRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -236,6 +173,19 @@ function useLogoEasterEgg({
|
||||
interface RailNavProps {
|
||||
currentPage: AppPage;
|
||||
onNavigate: (page: AppPage) => void;
|
||||
onOpenAbout: () => void;
|
||||
}
|
||||
|
||||
/** Shared-element indicator that slides between the active rail items. */
|
||||
function ActiveIndicator() {
|
||||
return (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
layoutId="rail-indicator"
|
||||
transition={MOTION_SPRING_POSITION}
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RailItem {
|
||||
@@ -275,7 +225,11 @@ const MORE_ITEMS: MoreMenuItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
export function RailNav({
|
||||
currentPage,
|
||||
onNavigate,
|
||||
onOpenAbout,
|
||||
}: RailNavProps) {
|
||||
const { t } = useTranslation();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const {
|
||||
@@ -358,12 +312,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
{active && <ActiveIndicator />}
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -413,12 +362,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
|
||||
)}
|
||||
>
|
||||
{currentPage === "settings" && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-1.5 left-[-7px] w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
{currentPage === "settings" && <ActiveIndicator />}
|
||||
<GoGear className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -435,7 +379,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border bg-card p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
|
||||
<div className="surface-material-card absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
|
||||
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
|
||||
<button
|
||||
key={page}
|
||||
@@ -459,6 +403,26 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
onOpenAbout();
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent"
|
||||
>
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded bg-muted text-muted-foreground">
|
||||
<LuInfo className="size-3" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{t("rail.more.about")}
|
||||
</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{t("rail.more.aboutHint")}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
+753
-611
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,11 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { applyThemeColors, clearThemeColors } from "@/lib/themes";
|
||||
import {
|
||||
applyThemeColors,
|
||||
clearThemeColors,
|
||||
withThemeTransition,
|
||||
} from "@/lib/themes";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
@@ -54,11 +58,13 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
|
||||
const setTheme = useCallback((newTheme: string) => {
|
||||
setThemeState(newTheme);
|
||||
if (newTheme === "custom") {
|
||||
applyClassToHtml("dark");
|
||||
} else {
|
||||
applyClassToHtml(newTheme);
|
||||
}
|
||||
withThemeTransition(() => {
|
||||
if (newTheme === "custom") {
|
||||
applyClassToHtml("dark");
|
||||
} else {
|
||||
applyClassToHtml(newTheme);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load initial theme from Tauri settings
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuSearch, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
@@ -17,6 +18,13 @@ import type {
|
||||
ValueType,
|
||||
} from "recharts/types/component/DefaultTooltipContent";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AnimatedTabs,
|
||||
AnimatedTabsContent,
|
||||
AnimatedTabsList,
|
||||
AnimatedTabsTrigger,
|
||||
} from "@/components/ui/animated-tabs";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +32,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
@@ -37,7 +46,10 @@ import {
|
||||
TooltipTrigger,
|
||||
Tooltip as UITooltip,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import type { FilteredTrafficStats } from "@/types";
|
||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
type TimePeriod =
|
||||
| "1m"
|
||||
@@ -157,24 +169,28 @@ export function TrafficDetailsDialog({
|
||||
const { t } = useTranslation();
|
||||
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
|
||||
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
|
||||
const [showClearConfirm, setShowClearConfirm] = React.useState(false);
|
||||
const [isClearing, setIsClearing] = React.useState(false);
|
||||
const [domainSearch, setDomainSearch] = React.useState("");
|
||||
|
||||
const fetchStats = React.useCallback(async () => {
|
||||
if (!profileId) return;
|
||||
try {
|
||||
const seconds = getSecondsForPeriod(timePeriod);
|
||||
const filteredStats = await invoke<FilteredTrafficStats | null>(
|
||||
"get_traffic_stats_for_period",
|
||||
{ profileId, seconds },
|
||||
);
|
||||
setStats(filteredStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch traffic stats:", error);
|
||||
}
|
||||
}, [profileId, timePeriod]);
|
||||
|
||||
// Fetch stats periodically - now uses filtered API
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !profileId) return;
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const seconds = getSecondsForPeriod(timePeriod);
|
||||
const filteredStats = await invoke<FilteredTrafficStats | null>(
|
||||
"get_traffic_stats_for_period",
|
||||
{ profileId, seconds },
|
||||
);
|
||||
setStats(filteredStats);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch traffic stats:", error);
|
||||
}
|
||||
};
|
||||
|
||||
void fetchStats();
|
||||
const interval = setInterval(() => {
|
||||
void fetchStats();
|
||||
@@ -185,7 +201,22 @@ export function TrafficDetailsDialog({
|
||||
// Clear stats from memory when dialog closes to free up memory
|
||||
setStats(null);
|
||||
};
|
||||
}, [isOpen, profileId, timePeriod]);
|
||||
}, [isOpen, profileId, fetchStats]);
|
||||
|
||||
const handleClearHistory = React.useCallback(async () => {
|
||||
if (!profileId) return;
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await invoke("clear_profile_traffic_stats", { profileId });
|
||||
setStats(null);
|
||||
setShowClearConfirm(false);
|
||||
await fetchStats();
|
||||
} catch (error) {
|
||||
toast.error(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
}, [profileId, fetchStats, t]);
|
||||
|
||||
// Transform data for chart (already filtered by backend)
|
||||
const chartData = React.useMemo(() => {
|
||||
@@ -231,24 +262,31 @@ export function TrafficDetailsDialog({
|
||||
[t],
|
||||
);
|
||||
|
||||
// Top domains sorted by total traffic
|
||||
const topDomainsByTraffic = React.useMemo(() => {
|
||||
// Domains matching the search query (empty query = all).
|
||||
const filteredDomains = React.useMemo(() => {
|
||||
if (!stats?.domains) return [];
|
||||
return Object.values(stats.domains)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
|
||||
)
|
||||
.slice(0, 10);
|
||||
}, [stats]);
|
||||
const q = domainSearch.trim().toLowerCase();
|
||||
const all = Object.values(stats.domains);
|
||||
return q ? all.filter((d) => d.domain.toLowerCase().includes(q)) : all;
|
||||
}, [stats, domainSearch]);
|
||||
|
||||
// Top domains sorted by total traffic. When searching, show every match
|
||||
// (not just the top 10) so the queried domain is never hidden below the cut.
|
||||
const topDomainsByTraffic = React.useMemo(() => {
|
||||
const sorted = [...filteredDomains].sort(
|
||||
(a, b) =>
|
||||
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
|
||||
);
|
||||
return domainSearch.trim() ? sorted : sorted.slice(0, 10);
|
||||
}, [filteredDomains, domainSearch]);
|
||||
|
||||
// Top domains sorted by request count
|
||||
const topDomainsByRequests = React.useMemo(() => {
|
||||
if (!stats?.domains) return [];
|
||||
return Object.values(stats.domains)
|
||||
.sort((a, b) => b.request_count - a.request_count)
|
||||
.slice(0, 10);
|
||||
}, [stats]);
|
||||
const sorted = [...filteredDomains].sort(
|
||||
(a, b) => b.request_count - a.request_count,
|
||||
);
|
||||
return domainSearch.trim() ? sorted : sorted.slice(0, 10);
|
||||
}, [filteredDomains, domainSearch]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -257,364 +295,461 @@ export function TrafficDetailsDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-[min(56rem,calc(100%-4rem))]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
— {profileName}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[min(56rem,calc(100%-4rem))] flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<DialogTitle>
|
||||
{t("traffic.title")}
|
||||
{profileName && (
|
||||
<span className="ml-2 font-normal text-muted-foreground">
|
||||
— {profileName}
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!stats}
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
<LuTrash2 className="mr-1.5 size-3.5" />
|
||||
{t("traffic.clearHistory")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[60vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => {
|
||||
setTimePeriod(v as TimePeriod);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1m">{t("traffic.last1m")}</SelectItem>
|
||||
<SelectItem value="5m">{t("traffic.last5m")}</SelectItem>
|
||||
<SelectItem value="30m">{t("traffic.last30m")}</SelectItem>
|
||||
<SelectItem value="1h">{t("traffic.last1h")}</SelectItem>
|
||||
<SelectItem value="2h">{t("traffic.last2h")}</SelectItem>
|
||||
<SelectItem value="4h">{t("traffic.last4h")}</SelectItem>
|
||||
<SelectItem value="1d">{t("traffic.last1d")}</SelectItem>
|
||||
<SelectItem value="7d">{t("traffic.last7d")}</SelectItem>
|
||||
<SelectItem value="30d">{t("traffic.last30d")}</SelectItem>
|
||||
<SelectItem value="all">{t("traffic.allTime")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<AnimatedTabs
|
||||
defaultValue="overview"
|
||||
className="flex min-h-0 flex-1 flex-col gap-4"
|
||||
>
|
||||
<AnimatedTabsList className="shrink-0">
|
||||
<AnimatedTabsTrigger value="overview">
|
||||
{t("traffic.tabOverview")}
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="domains">
|
||||
{t("traffic.tabTopDomains")}
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
|
||||
<div className="h-[clamp(200px,28vh,360px)] w-full">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sentGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
<AnimatedTabsContent
|
||||
value="overview"
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="h-[56vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Chart with Period Selector */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("traffic.bandwidthOverTime")}
|
||||
</h3>
|
||||
<Select
|
||||
value={timePeriod}
|
||||
onValueChange={(v) => {
|
||||
setTimePeriod(v as TimePeriod);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue
|
||||
placeholder={t("traffic.timePeriodPlaceholder")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1m">
|
||||
{t("traffic.last1m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="5m">
|
||||
{t("traffic.last5m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30m">
|
||||
{t("traffic.last30m")}
|
||||
</SelectItem>
|
||||
<SelectItem value="1h">
|
||||
{t("traffic.last1h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="2h">
|
||||
{t("traffic.last2h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="4h">
|
||||
{t("traffic.last4h")}
|
||||
</SelectItem>
|
||||
<SelectItem value="1d">
|
||||
{t("traffic.last1d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="7d">
|
||||
{t("traffic.last7d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="30d">
|
||||
{t("traffic.last30d")}
|
||||
</SelectItem>
|
||||
<SelectItem value="all">
|
||||
{t("traffic.allTime")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="h-[clamp(200px,28vh,360px)] w-full">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
minWidth={1}
|
||||
minHeight={1}
|
||||
>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.5}
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="sentGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="receivedGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.5}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.1}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={(t) =>
|
||||
new Date(t * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="receivedGradient"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.5}
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytesPerSecond(v)}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
width={60}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-2)"
|
||||
stopOpacity={0.1}
|
||||
<Tooltip content={renderTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sent"
|
||||
stackId="1"
|
||||
stroke="var(--chart-1)"
|
||||
fill="url(#sentGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={(t) =>
|
||||
new Date(t * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytesPerSecond(v)}
|
||||
className="text-xs"
|
||||
tick={{ fill: "var(--muted-foreground)" }}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip content={renderTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="sent"
|
||||
stackId="1"
|
||||
stroke="var(--chart-1)"
|
||||
fill="url(#sentGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="received"
|
||||
stackId="1"
|
||||
stroke="var(--chart-2)"
|
||||
fill="url(#receivedGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="received"
|
||||
stackId="1"
|
||||
stroke="var(--chart-2)"
|
||||
fill="url(#receivedGradient)"
|
||||
strokeWidth={1.5}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLegend")}
|
||||
</span>
|
||||
<div className="mt-2 flex items-center justify-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-1)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLegend")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLegend")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 rounded"
|
||||
style={{ backgroundColor: "var(--chart-2)" }}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLegend")}
|
||||
</span>
|
||||
|
||||
{/* Period Stats - now uses backend-computed values */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-1">
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-2">
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period Stats - now uses backend-computed values */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.sentLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-1">
|
||||
{formatBytes(stats?.period_bytes_sent ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.receivedLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-chart-2">
|
||||
{formatBytes(stats?.period_bytes_received ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("traffic.requestsLabel", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.totalSuffix")
|
||||
: timePeriod,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-lg font-semibold">
|
||||
{(stats?.period_requests ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeTraffic")}
|
||||
</span>{" "}
|
||||
{formatBytes(
|
||||
(stats?.total_bytes_sent ?? 0) +
|
||||
(stats?.total_bytes_received ?? 0),
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeRequests")}
|
||||
</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Stats (smaller, under period stats) */}
|
||||
<div className="flex items-center gap-6 border-t pt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeTraffic")}
|
||||
</span>{" "}
|
||||
{formatBytes(
|
||||
(stats?.total_bytes_sent ?? 0) +
|
||||
(stats?.total_bytes_received ?? 0),
|
||||
{/* Disclaimer about proxy/VPN traffic calculation */}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t("traffic.proxyDisclaimer")}
|
||||
</p>
|
||||
|
||||
{/* No data state (overview) */}
|
||||
{!stats && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("traffic.allTimeRequests")}
|
||||
</span>{" "}
|
||||
{stats?.total_requests?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
{/* Disclaimer about proxy/VPN traffic calculation */}
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
{t("traffic.proxyDisclaimer")}
|
||||
</p>
|
||||
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnSent")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnReceived")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right text-chart-1">
|
||||
{formatBytes(domain.bytes_sent)}
|
||||
</span>
|
||||
<span className="text-right text-chart-2">
|
||||
{formatBytes(domain.bytes_received)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<AnimatedTabsContent
|
||||
value="domains"
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="h-[56vh]">
|
||||
<div className="space-y-6 pr-4">
|
||||
<div className="relative">
|
||||
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={domainSearch}
|
||||
onChange={(e) => setDomainSearch(e.target.value)}
|
||||
placeholder={t("traffic.searchDomains")}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Domains by Requests */}
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnTotal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_100px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
{domainSearch.trim() && topDomainsByTraffic.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("traffic.noDomainMatch")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Top Domains by Traffic */}
|
||||
{topDomainsByTraffic.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByTraffic", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatBytes(
|
||||
domain.bytes_sent + domain.bytes_received,
|
||||
)}
|
||||
{t("traffic.columnSent")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnReceived")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right text-chart-1">
|
||||
{formatBytes(domain.bytes_sent)}
|
||||
</span>
|
||||
<span className="text-right text-chart-2">
|
||||
{formatBytes(domain.bytes_received)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Unique IPs */}
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
||||
</h3>
|
||||
<FadingScrollArea className="max-h-[clamp(120px,15vh,240px)] p-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.unique_ips.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
{/* Top Domains by Requests */}
|
||||
{topDomainsByRequests.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.topByRequests", {
|
||||
period:
|
||||
timePeriod === "all"
|
||||
? t("traffic.allTimeShort")
|
||||
: timePeriod,
|
||||
})}
|
||||
</h3>
|
||||
<div className="rounded-md border">
|
||||
<div className="grid grid-cols-[1fr_80px_100px] gap-2 border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t("traffic.columnDomain")}</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnRequests")}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{t("traffic.columnTotal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="grid grid-cols-[1fr_80px_100px] gap-2 border-b px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<TruncatedDomain domain={domain.domain} />
|
||||
</div>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{domain.request_count.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{formatBytes(
|
||||
domain.bytes_sent + domain.bytes_received,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadingScrollArea>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* No data state */}
|
||||
{!stats && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
{/* Unique IPs */}
|
||||
{stats?.unique_ips && stats.unique_ips.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
{t("traffic.uniqueIps", {
|
||||
count: stats.unique_ips.length,
|
||||
})}
|
||||
</h3>
|
||||
<FadingScrollArea className="max-h-[clamp(120px,15vh,240px)] p-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.unique_ips.map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="rounded bg-muted px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</FadingScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No data state (domains) */}
|
||||
{!stats && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<p>{t("traffic.noData")}</p>
|
||||
<p className="mt-1 text-sm">{t("traffic.noDataHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showClearConfirm}
|
||||
onClose={() => setShowClearConfirm(false)}
|
||||
onConfirm={handleClearHistory}
|
||||
title={t("traffic.clearHistoryTitle")}
|
||||
description={t("traffic.clearHistoryDescription", {
|
||||
name: profileName ?? "",
|
||||
})}
|
||||
confirmButtonText={t("traffic.clearHistory")}
|
||||
isLoading={isClearing}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -162,8 +162,12 @@ function SubPageContent({
|
||||
<motion.div
|
||||
data-slot="sub-page"
|
||||
data-sub-page="true"
|
||||
initial={false}
|
||||
animate={{ opacity: 1 }}
|
||||
// Sub-pages enter with a short rise+fade so rail navigation reads as a
|
||||
// transition instead of a hard cut. Same axis for every page (spatial
|
||||
// consistency); the outgoing page unmounts under the incoming fade.
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
@@ -177,7 +181,11 @@ function SubPageContent({
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
gap: 12,
|
||||
overflow: "auto",
|
||||
// The sub-page wrapper never scrolls itself — exactly one inner
|
||||
// element per page owns scrolling (full-width, so the wheel works
|
||||
// over side gutters too). "auto" here created a competing,
|
||||
// never-engaged scroll container.
|
||||
overflow: "hidden",
|
||||
background: "var(--background)",
|
||||
containerType: "inline-size",
|
||||
}}
|
||||
@@ -258,7 +266,7 @@ function DialogContent({
|
||||
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
|
||||
// gutter even when callers override max-w-*: tailwind-merge drops
|
||||
// a base max-w in favor of the caller's, but leaves width alone.
|
||||
"fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6 shadow-lg",
|
||||
"surface-material fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md surface-material-popover border p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -232,7 +232,7 @@ function DropdownMenuSubContent({
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -32,7 +32,7 @@ function PopoverContent({
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -61,7 +61,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"relative z-50000 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md surface-material-popover border text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "Disable App Auto Updates",
|
||||
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.",
|
||||
"keepDecryptedProfilesInRam": "Keep Decrypted Profiles In RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless."
|
||||
"keepDecryptedProfilesInRamDescription": "Preserve the decrypted in-RAM copy of password-protected profiles between launches for faster startup. The on-disk copy stays encrypted regardless.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Fingerprint consistency warning",
|
||||
"consistencyWarningDescription": "Warn on launch when a profile's timezone or language doesn't match its proxy exit node.",
|
||||
"clearTraffic": "Clear all traffic history",
|
||||
"clearTrafficDescription": "Securely erase recorded traffic statistics for every profile.",
|
||||
"clearTrafficSuccess": "Traffic history cleared"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Search profiles...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Can't modify running profile",
|
||||
"cantModifyLaunching": "Can't modify profile while launching",
|
||||
"cantModifyStopping": "Can't modify profile while stopping",
|
||||
"cantModifyUpdating": "Can't modify profile while browser is updating"
|
||||
"cantModifyUpdating": "Can't modify profile while browser is updating",
|
||||
"emptyTitle": "No profiles yet",
|
||||
"emptyHint": "Create your first profile or import existing ones from another browser.",
|
||||
"emptyCreate": "Create profile",
|
||||
"emptyImport": "Import profiles",
|
||||
"emptyFilteredTitle": "No profiles found",
|
||||
"emptyFilteredHint": "No profiles match this group or search. Try another filter or create a new one."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Launch",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "domains",
|
||||
"fresh": "Fresh",
|
||||
"stale": "Stale",
|
||||
"notCached": "Not cached"
|
||||
"notCached": "Not cached",
|
||||
"tabBlocklists": "Blocklists",
|
||||
"tabCustom": "Custom lists",
|
||||
"custom.description": "Build your own blocklist from source URLs and manual rules. Allowed domains always override blocked ones.",
|
||||
"custom.sourcesLabel": "Blocklist source URLs",
|
||||
"custom.sourcesPlaceholder": "One URL per line",
|
||||
"custom.blockLabel": "Blocked domains",
|
||||
"custom.blockPlaceholder": "One domain per line",
|
||||
"custom.allowLabel": "Allowed domains",
|
||||
"custom.allowPlaceholder": "One domain per line",
|
||||
"custom.allowHint": "Allowed domains are removed from the compiled blocklist, overriding any block rule.",
|
||||
"custom.saved": "Custom DNS rules saved",
|
||||
"custom.imported": "Custom DNS rules imported",
|
||||
"custom.exported": "Custom DNS rules exported",
|
||||
"custom.exportTxt": "Export TXT",
|
||||
"custom.exportJson": "Export JSON",
|
||||
"customLevel": "Custom",
|
||||
"custom.allowlistModeLabel": "Allowlist mode",
|
||||
"custom.allowlistModeOn": "Only the domains below are reachable; everything else is blocked.",
|
||||
"custom.allowlistModeOff": "Block listed domains; allow everything else.",
|
||||
"custom.allowedOnlyLabel": "Allowed domains (only these)",
|
||||
"custom.allowedOnlyHint": "Subdomains of a listed domain are allowed too. An empty list disables filtering."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1415,7 +1449,11 @@
|
||||
"statusImported": "Imported",
|
||||
"statusSkipped": "Skipped",
|
||||
"statusFailed": "Failed",
|
||||
"importButtonCount": "Import ({{count}})"
|
||||
"importButtonCount": "Import ({{count}})",
|
||||
"vpnOptional": "VPN (Optional)",
|
||||
"noVpn": "No VPN",
|
||||
"advancedOptions": "Advanced options",
|
||||
"configureFingerprint": "Configure fingerprint (optional)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Syncing...",
|
||||
@@ -1618,7 +1656,14 @@
|
||||
"sentLegend": "Sent",
|
||||
"receivedLegend": "Received",
|
||||
"tooltipSent": "↑ Sent: ",
|
||||
"tooltipReceived": "↓ Received: "
|
||||
"tooltipReceived": "↓ Received: ",
|
||||
"clearHistory": "Clear history",
|
||||
"clearHistoryTitle": "Clear traffic history",
|
||||
"clearHistoryDescription": "Permanently and securely erase all recorded traffic history for \"{{name}}\". This can't be undone.",
|
||||
"tabOverview": "Overview",
|
||||
"tabTopDomains": "Top domains",
|
||||
"searchDomains": "Search domains…",
|
||||
"noDomainMatch": "No domains match your search."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Unknown",
|
||||
@@ -1818,7 +1863,14 @@
|
||||
"importNoItems": "Nothing selected to import",
|
||||
"browserNotDownloaded": "No downloaded version of {{browser}} is available. Download it first, then retry the import.",
|
||||
"archiveExtractionFailed": "Failed to extract the archive: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Unsupported archive format. Only ZIP archives are supported."
|
||||
"unsupportedArchiveFormat": "Unsupported archive format. Only ZIP archives are supported.",
|
||||
"clearOnCloseUnavailable": "Clear-on-close isn't available for ephemeral or password-protected profiles.",
|
||||
"proxyAndVpnMutuallyExclusive": "A profile can use either a proxy or a VPN, not both.",
|
||||
"invalidDnsRulesJson": "The selected file isn't valid DNS rules JSON.",
|
||||
"unsupportedDnsRulesFormat": "Unsupported rules format: {{format}}",
|
||||
"dnsRulesSaveFailed": "Failed to save the DNS rules.",
|
||||
"dnsRulesExportFailed": "Failed to export the DNS rules.",
|
||||
"fingerprintMatchFailed": "Couldn't match the fingerprint to the proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiles",
|
||||
@@ -1831,7 +1883,9 @@
|
||||
"importProfile": "Import profile",
|
||||
"importProfileHint": "Bring profiles from another tool",
|
||||
"keyboardShortcuts": "Keyboard shortcuts",
|
||||
"keyboardShortcutsHint": "View all shortcuts"
|
||||
"keyboardShortcutsHint": "View all shortcuts",
|
||||
"about": "About Donut Browser",
|
||||
"aboutHint": "Version and app information"
|
||||
},
|
||||
"network": "Network",
|
||||
"integrations": "Integrations",
|
||||
@@ -1920,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Launch {{name}}",
|
||||
"stopProfile": "Stop {{name}}",
|
||||
"profileInfo": "Info — {{name}}"
|
||||
"profileInfo": "Info — {{name}}",
|
||||
"createProfile": "Create profile",
|
||||
"about": "About Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2036,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "No other Wayfern profiles selected"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"version": "Version {{version}}",
|
||||
"portableBadge": "portable",
|
||||
"licenseNotice": "Open-source anti-detect browser, licensed under AGPL-3.0.",
|
||||
"website": "Website"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Clear data on close",
|
||||
"description": "Wipe cookies, history and cache when the browser closes. Extensions and bookmarks are kept."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Fingerprint mismatch",
|
||||
"intro": "Your proxy exit for \"{{name}}\" doesn't match this profile's fingerprint:",
|
||||
"timezoneTitle": "Timezone mismatch",
|
||||
"timezoneDetail": "Exit node is in {{exit}} but the fingerprint reports {{fingerprint}}.",
|
||||
"languageTitle": "Language mismatch",
|
||||
"languageDetail": "Exit country is {{country}} but the fingerprint language is {{fingerprint}}.",
|
||||
"explainer": "A timezone or language that disagrees with your exit IP is a strong anti-bot signal, even though your real device never leaks. Align the fingerprint with the proxy location to reduce hostile treatment.",
|
||||
"dontWarnAgain": "Don't warn again for this profile",
|
||||
"matchToProxy": "Match fingerprint to proxy",
|
||||
"matching": "Matching…",
|
||||
"matchSuccess": "Fingerprint updated to match the proxy. Relaunch the profile to apply."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,14 @@
|
||||
"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.",
|
||||
"keepDecryptedProfilesInRam": "Mantener Perfiles Descifrados en RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso."
|
||||
"keepDecryptedProfilesInRamDescription": "Conservar la copia descifrada en RAM de los perfiles protegidos por contraseña entre lanzamientos para un inicio más rápido. La copia en disco permanece cifrada en cualquier caso.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Advertencia de consistencia de huella digital",
|
||||
"consistencyWarningDescription": "Advertir al iniciar cuando la zona horaria o el idioma de un perfil no coincidan con su nodo de salida del proxy.",
|
||||
"clearTraffic": "Borrar todo el historial de tráfico",
|
||||
"clearTrafficDescription": "Elimina de forma segura las estadísticas de tráfico registradas de todos los perfiles.",
|
||||
"clearTrafficSuccess": "Historial de tráfico borrado"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Buscar perfiles...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "No se puede modificar un perfil en ejecución",
|
||||
"cantModifyLaunching": "No se puede modificar el perfil mientras se inicia",
|
||||
"cantModifyStopping": "No se puede modificar el perfil mientras se detiene",
|
||||
"cantModifyUpdating": "No se puede modificar el perfil mientras se actualiza el navegador"
|
||||
"cantModifyUpdating": "No se puede modificar el perfil mientras se actualiza el navegador",
|
||||
"emptyTitle": "Aún no hay perfiles",
|
||||
"emptyHint": "Crea tu primer perfil o importa perfiles existentes desde otro navegador.",
|
||||
"emptyCreate": "Crear perfil",
|
||||
"emptyImport": "Importar perfiles",
|
||||
"emptyFilteredTitle": "No se encontraron perfiles",
|
||||
"emptyFilteredHint": "Ningún perfil coincide con este grupo o búsqueda. Prueba otro filtro o crea uno nuevo."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "dominios",
|
||||
"fresh": "Actualizado",
|
||||
"stale": "Desactualizado",
|
||||
"notCached": "Sin caché"
|
||||
"notCached": "Sin caché",
|
||||
"tabBlocklists": "Listas de bloqueo",
|
||||
"tabCustom": "Listas personalizadas",
|
||||
"custom.description": "Crea tu propia lista de bloqueo a partir de URLs de origen y reglas manuales. Los dominios permitidos siempre prevalecen sobre los bloqueados.",
|
||||
"custom.sourcesLabel": "URLs de origen de listas de bloqueo",
|
||||
"custom.sourcesPlaceholder": "Una URL por línea",
|
||||
"custom.blockLabel": "Dominios bloqueados",
|
||||
"custom.blockPlaceholder": "Un dominio por línea",
|
||||
"custom.allowLabel": "Dominios permitidos",
|
||||
"custom.allowPlaceholder": "Un dominio por línea",
|
||||
"custom.allowHint": "Los dominios permitidos se eliminan de la lista de bloqueo compilada, anulando cualquier regla de bloqueo.",
|
||||
"custom.saved": "Reglas DNS personalizadas guardadas",
|
||||
"custom.imported": "Reglas DNS personalizadas importadas",
|
||||
"custom.exported": "Reglas DNS personalizadas exportadas",
|
||||
"custom.exportTxt": "Exportar TXT",
|
||||
"custom.exportJson": "Exportar JSON",
|
||||
"customLevel": "Personalizada",
|
||||
"custom.allowlistModeLabel": "Modo lista de permitidos",
|
||||
"custom.allowlistModeOn": "Solo se puede acceder a los dominios de abajo; todo lo demás se bloquea.",
|
||||
"custom.allowlistModeOff": "Bloquear los dominios de la lista; permitir todo lo demás.",
|
||||
"custom.allowedOnlyLabel": "Dominios permitidos (solo estos)",
|
||||
"custom.allowedOnlyHint": "También se permiten los subdominios de un dominio de la lista. Una lista vacía desactiva el filtrado."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1415,7 +1449,11 @@
|
||||
"statusImported": "Importado",
|
||||
"statusSkipped": "Omitido",
|
||||
"statusFailed": "Fallido",
|
||||
"importButtonCount": "Importar ({{count}})"
|
||||
"importButtonCount": "Importar ({{count}})",
|
||||
"vpnOptional": "VPN (opcional)",
|
||||
"noVpn": "Sin VPN",
|
||||
"advancedOptions": "Opciones avanzadas",
|
||||
"configureFingerprint": "Configurar huella digital (opcional)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Sincronizando...",
|
||||
@@ -1618,7 +1656,14 @@
|
||||
"sentLegend": "Enviado",
|
||||
"receivedLegend": "Recibido",
|
||||
"tooltipSent": "↑ Enviado: ",
|
||||
"tooltipReceived": "↓ Recibido: "
|
||||
"tooltipReceived": "↓ Recibido: ",
|
||||
"clearHistory": "Borrar historial",
|
||||
"clearHistoryTitle": "Borrar historial de tráfico",
|
||||
"clearHistoryDescription": "Elimina de forma permanente y segura todo el historial de tráfico registrado de \"{{name}}\". Esta acción no se puede deshacer.",
|
||||
"tabOverview": "Resumen",
|
||||
"tabTopDomains": "Dominios principales",
|
||||
"searchDomains": "Buscar dominios…",
|
||||
"noDomainMatch": "Ningún dominio coincide con tu búsqueda."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Desconocido",
|
||||
@@ -1818,7 +1863,14 @@
|
||||
"importNoItems": "No hay nada seleccionado para importar",
|
||||
"browserNotDownloaded": "No hay ninguna versión descargada de {{browser}}. Descárgala primero y vuelve a intentar la importación.",
|
||||
"archiveExtractionFailed": "No se pudo extraer el archivo: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Formato de archivo no compatible. Solo se admiten archivos ZIP."
|
||||
"unsupportedArchiveFormat": "Formato de archivo no compatible. Solo se admiten archivos ZIP.",
|
||||
"clearOnCloseUnavailable": "El borrado al cerrar no está disponible para perfiles efímeros o protegidos con contraseña.",
|
||||
"proxyAndVpnMutuallyExclusive": "Un perfil puede usar un proxy o una VPN, pero no ambos.",
|
||||
"invalidDnsRulesJson": "El archivo seleccionado no es un JSON de reglas DNS válido.",
|
||||
"unsupportedDnsRulesFormat": "Formato de reglas no compatible: {{format}}",
|
||||
"dnsRulesSaveFailed": "No se pudieron guardar las reglas DNS.",
|
||||
"dnsRulesExportFailed": "No se pudieron exportar las reglas DNS.",
|
||||
"fingerprintMatchFailed": "No se pudo ajustar la huella al proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfiles",
|
||||
@@ -1831,7 +1883,9 @@
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trae perfiles de otra herramienta",
|
||||
"keyboardShortcuts": "Atajos de teclado",
|
||||
"keyboardShortcutsHint": "Ver todos los atajos"
|
||||
"keyboardShortcutsHint": "Ver todos los atajos",
|
||||
"about": "Acerca de Donut Browser",
|
||||
"aboutHint": "Versión e información de la aplicación"
|
||||
},
|
||||
"network": "Red",
|
||||
"integrations": "Integraciones",
|
||||
@@ -1920,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Iniciar {{name}}",
|
||||
"stopProfile": "Detener {{name}}",
|
||||
"profileInfo": "Información — {{name}}"
|
||||
"profileInfo": "Información — {{name}}",
|
||||
"createProfile": "Crear perfil",
|
||||
"about": "Acerca de Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2036,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "No hay otros perfiles Wayfern seleccionados"
|
||||
},
|
||||
"about": {
|
||||
"title": "Acerca de",
|
||||
"version": "Versión {{version}}",
|
||||
"portableBadge": "portable",
|
||||
"licenseNotice": "Navegador anti-detección de código abierto, con licencia AGPL-3.0.",
|
||||
"website": "Sitio web"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Borrar datos al cerrar",
|
||||
"description": "Elimina cookies, historial y caché al cerrar el navegador. Las extensiones y los marcadores se conservan."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Discrepancia de huella digital",
|
||||
"intro": "La salida del proxy de \"{{name}}\" no coincide con la huella digital de este perfil:",
|
||||
"timezoneTitle": "Discrepancia de zona horaria",
|
||||
"timezoneDetail": "El nodo de salida está en {{exit}}, pero la huella digital indica {{fingerprint}}.",
|
||||
"languageTitle": "Discrepancia de idioma",
|
||||
"languageDetail": "El país de salida es {{country}}, pero el idioma de la huella digital es {{fingerprint}}.",
|
||||
"explainer": "Una zona horaria o un idioma que no coincide con tu IP de salida es una fuerte señal anti-bot, aunque tu dispositivo real nunca se filtre. Alinea la huella digital con la ubicación del proxy para reducir el trato hostil.",
|
||||
"dontWarnAgain": "No volver a advertir para este perfil",
|
||||
"matchToProxy": "Ajustar huella al proxy",
|
||||
"matching": "Ajustando…",
|
||||
"matchSuccess": "Huella actualizada para coincidir con el proxy. Reinicia el perfil para aplicar."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,14 @@
|
||||
"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.",
|
||||
"keepDecryptedProfilesInRam": "Conserver les profils déchiffrés en RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas."
|
||||
"keepDecryptedProfilesInRamDescription": "Conserver en RAM la copie déchiffrée des profils protégés par mot de passe entre les lancements pour un démarrage plus rapide. La copie sur disque reste chiffrée dans tous les cas.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Avertissement de cohérence d'empreinte",
|
||||
"consistencyWarningDescription": "Avertir au lancement lorsque le fuseau horaire ou la langue d'un profil ne correspond pas à son nœud de sortie proxy.",
|
||||
"clearTraffic": "Effacer tout l'historique de trafic",
|
||||
"clearTrafficDescription": "Efface en toute sécurité les statistiques de trafic enregistrées pour chaque profil.",
|
||||
"clearTrafficSuccess": "Historique de trafic effacé"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Rechercher des profils...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Impossible de modifier un profil en cours d'exécution",
|
||||
"cantModifyLaunching": "Impossible de modifier le profil pendant le lancement",
|
||||
"cantModifyStopping": "Impossible de modifier le profil pendant l'arrêt",
|
||||
"cantModifyUpdating": "Impossible de modifier le profil pendant la mise à jour du navigateur"
|
||||
"cantModifyUpdating": "Impossible de modifier le profil pendant la mise à jour du navigateur",
|
||||
"emptyTitle": "Aucun profil pour l'instant",
|
||||
"emptyHint": "Créez votre premier profil ou importez des profils existants depuis un autre navigateur.",
|
||||
"emptyCreate": "Créer un profil",
|
||||
"emptyImport": "Importer des profils",
|
||||
"emptyFilteredTitle": "Aucun profil trouvé",
|
||||
"emptyFilteredHint": "Aucun profil ne correspond à ce groupe ou à cette recherche. Essayez un autre filtre ou créez-en un."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Lancer",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "domaines",
|
||||
"fresh": "À jour",
|
||||
"stale": "Obsolète",
|
||||
"notCached": "Non mis en cache"
|
||||
"notCached": "Non mis en cache",
|
||||
"tabBlocklists": "Listes de blocage",
|
||||
"tabCustom": "Listes personnalisées",
|
||||
"custom.description": "Créez votre propre liste de blocage à partir d'URL sources et de règles manuelles. Les domaines autorisés priment toujours sur les domaines bloqués.",
|
||||
"custom.sourcesLabel": "URL sources de listes de blocage",
|
||||
"custom.sourcesPlaceholder": "Une URL par ligne",
|
||||
"custom.blockLabel": "Domaines bloqués",
|
||||
"custom.blockPlaceholder": "Un domaine par ligne",
|
||||
"custom.allowLabel": "Domaines autorisés",
|
||||
"custom.allowPlaceholder": "Un domaine par ligne",
|
||||
"custom.allowHint": "Les domaines autorisés sont retirés de la liste de blocage compilée et priment sur toute règle de blocage.",
|
||||
"custom.saved": "Règles DNS personnalisées enregistrées",
|
||||
"custom.imported": "Règles DNS personnalisées importées",
|
||||
"custom.exported": "Règles DNS personnalisées exportées",
|
||||
"custom.exportTxt": "Exporter en TXT",
|
||||
"custom.exportJson": "Exporter en JSON",
|
||||
"customLevel": "Personnalisée",
|
||||
"custom.allowlistModeLabel": "Mode liste d'autorisation",
|
||||
"custom.allowlistModeOn": "Seuls les domaines ci-dessous sont accessibles ; tout le reste est bloqué.",
|
||||
"custom.allowlistModeOff": "Bloquer les domaines listés ; autoriser tout le reste.",
|
||||
"custom.allowedOnlyLabel": "Domaines autorisés (uniquement ceux-ci)",
|
||||
"custom.allowedOnlyHint": "Les sous-domaines d'un domaine listé sont aussi autorisés. Une liste vide désactive le filtrage."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1415,7 +1449,11 @@
|
||||
"statusImported": "Importé",
|
||||
"statusSkipped": "Ignoré",
|
||||
"statusFailed": "Échec",
|
||||
"importButtonCount": "Importer ({{count}})"
|
||||
"importButtonCount": "Importer ({{count}})",
|
||||
"vpnOptional": "VPN (facultatif)",
|
||||
"noVpn": "Sans VPN",
|
||||
"advancedOptions": "Options avancées",
|
||||
"configureFingerprint": "Configurer l'empreinte (facultatif)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Synchronisation...",
|
||||
@@ -1618,7 +1656,14 @@
|
||||
"sentLegend": "Envoyé",
|
||||
"receivedLegend": "Reçu",
|
||||
"tooltipSent": "↑ Envoyé : ",
|
||||
"tooltipReceived": "↓ Reçu : "
|
||||
"tooltipReceived": "↓ Reçu : ",
|
||||
"clearHistory": "Effacer l'historique",
|
||||
"clearHistoryTitle": "Effacer l'historique de trafic",
|
||||
"clearHistoryDescription": "Efface définitivement et en toute sécurité tout l'historique de trafic enregistré pour « {{name}} ». Cette action est irréversible.",
|
||||
"tabOverview": "Vue d'ensemble",
|
||||
"tabTopDomains": "Principaux domaines",
|
||||
"searchDomains": "Rechercher des domaines…",
|
||||
"noDomainMatch": "Aucun domaine ne correspond à votre recherche."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Inconnu",
|
||||
@@ -1818,7 +1863,14 @@
|
||||
"importNoItems": "Rien n'est sélectionné pour l'importation",
|
||||
"browserNotDownloaded": "Aucune version téléchargée de {{browser}} n'est disponible. Téléchargez-la d'abord, puis réessayez l'importation.",
|
||||
"archiveExtractionFailed": "Échec de l'extraction de l'archive : {{detail}}",
|
||||
"unsupportedArchiveFormat": "Format d'archive non pris en charge. Seules les archives ZIP sont prises en charge."
|
||||
"unsupportedArchiveFormat": "Format d'archive non pris en charge. Seules les archives ZIP sont prises en charge.",
|
||||
"clearOnCloseUnavailable": "L'effacement à la fermeture n'est pas disponible pour les profils éphémères ou protégés par mot de passe.",
|
||||
"proxyAndVpnMutuallyExclusive": "Un profil peut utiliser un proxy ou un VPN, mais pas les deux.",
|
||||
"invalidDnsRulesJson": "Le fichier sélectionné n'est pas un JSON de règles DNS valide.",
|
||||
"unsupportedDnsRulesFormat": "Format de règles non pris en charge : {{format}}",
|
||||
"dnsRulesSaveFailed": "Échec de l'enregistrement des règles DNS.",
|
||||
"dnsRulesExportFailed": "Échec de l'exportation des règles DNS.",
|
||||
"fingerprintMatchFailed": "Impossible d'aligner l'empreinte sur le proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profils",
|
||||
@@ -1831,7 +1883,9 @@
|
||||
"importProfile": "Importer un profil",
|
||||
"importProfileHint": "Importer depuis un autre outil",
|
||||
"keyboardShortcuts": "Raccourcis clavier",
|
||||
"keyboardShortcutsHint": "Voir tous les raccourcis"
|
||||
"keyboardShortcutsHint": "Voir tous les raccourcis",
|
||||
"about": "À propos de Donut Browser",
|
||||
"aboutHint": "Version et informations sur l'application"
|
||||
},
|
||||
"network": "Réseau",
|
||||
"integrations": "Intégrations",
|
||||
@@ -1920,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Lancer {{name}}",
|
||||
"stopProfile": "Arrêter {{name}}",
|
||||
"profileInfo": "Informations — {{name}}"
|
||||
"profileInfo": "Informations — {{name}}",
|
||||
"createProfile": "Créer un profil",
|
||||
"about": "À propos de Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2036,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "Aucun autre profil Wayfern sélectionné"
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos",
|
||||
"version": "Version {{version}}",
|
||||
"portableBadge": "portable",
|
||||
"licenseNotice": "Navigateur anti-détection open source, sous licence AGPL-3.0.",
|
||||
"website": "Site web"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Effacer les données à la fermeture",
|
||||
"description": "Supprime les cookies, l'historique et le cache à la fermeture du navigateur. Les extensions et les favoris sont conservés."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Incohérence d'empreinte",
|
||||
"intro": "La sortie du proxy de « {{name}} » ne correspond pas à l'empreinte de ce profil :",
|
||||
"timezoneTitle": "Incohérence de fuseau horaire",
|
||||
"timezoneDetail": "Le nœud de sortie est dans {{exit}}, mais l'empreinte indique {{fingerprint}}.",
|
||||
"languageTitle": "Incohérence de langue",
|
||||
"languageDetail": "Le pays de sortie est {{country}}, mais la langue de l'empreinte est {{fingerprint}}.",
|
||||
"explainer": "Un fuseau horaire ou une langue en désaccord avec votre IP de sortie est un signal anti-bot fort, même si votre appareil réel ne fuite jamais. Alignez l'empreinte sur l'emplacement du proxy pour réduire les traitements hostiles.",
|
||||
"dontWarnAgain": "Ne plus avertir pour ce profil",
|
||||
"matchToProxy": "Aligner l'empreinte sur le proxy",
|
||||
"matching": "Alignement…",
|
||||
"matchSuccess": "Empreinte mise à jour pour correspondre au proxy. Relancez le profil pour l'appliquer."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "アプリの自動更新を無効にする",
|
||||
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
|
||||
"keepDecryptedProfilesInRam": "復号済みプロファイルをRAMに保持",
|
||||
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。"
|
||||
"keepDecryptedProfilesInRamDescription": "起動を高速化するため、パスワード保護されたプロファイルの復号済みコピーをRAMに保持します。ディスク上のコピーは常に暗号化されたままです。",
|
||||
"privacy": {
|
||||
"consistencyWarning": "フィンガープリント整合性の警告",
|
||||
"consistencyWarningDescription": "プロファイルのタイムゾーンや言語がプロキシ出口ノードと一致しない場合、起動時に警告します。",
|
||||
"clearTraffic": "すべてのトラフィック履歴を消去",
|
||||
"clearTrafficDescription": "すべてのプロファイルの記録されたトラフィック統計を安全に消去します。",
|
||||
"clearTrafficSuccess": "トラフィック履歴を消去しました"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "プロファイルを検索...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "実行中のプロファイルは変更できません",
|
||||
"cantModifyLaunching": "起動中はプロファイルを変更できません",
|
||||
"cantModifyStopping": "停止中はプロファイルを変更できません",
|
||||
"cantModifyUpdating": "ブラウザの更新中はプロファイルを変更できません"
|
||||
"cantModifyUpdating": "ブラウザの更新中はプロファイルを変更できません",
|
||||
"emptyTitle": "プロファイルはまだありません",
|
||||
"emptyHint": "最初のプロファイルを作成するか、他のブラウザから既存のプロファイルをインポートしてください。",
|
||||
"emptyCreate": "プロファイルを作成",
|
||||
"emptyImport": "プロファイルをインポート",
|
||||
"emptyFilteredTitle": "プロファイルが見つかりません",
|
||||
"emptyFilteredHint": "このグループまたは検索に一致するプロファイルはありません。別のフィルターを試すか、新規作成してください。"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "起動",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "ドメイン",
|
||||
"fresh": "最新",
|
||||
"stale": "期限切れ",
|
||||
"notCached": "キャッシュなし"
|
||||
"notCached": "キャッシュなし",
|
||||
"tabBlocklists": "ブロックリスト",
|
||||
"tabCustom": "カスタムリスト",
|
||||
"custom.description": "ソース URL と手動ルールから独自のブロックリストを作成します。許可ドメインは常にブロックより優先されます。",
|
||||
"custom.sourcesLabel": "ブロックリストのソース URL",
|
||||
"custom.sourcesPlaceholder": "1 行につき 1 つの URL",
|
||||
"custom.blockLabel": "ブロックするドメイン",
|
||||
"custom.blockPlaceholder": "1 行につき 1 つのドメイン",
|
||||
"custom.allowLabel": "許可するドメイン",
|
||||
"custom.allowPlaceholder": "1 行につき 1 つのドメイン",
|
||||
"custom.allowHint": "許可ドメインはコンパイル済みブロックリストから除外され、あらゆるブロックルールより優先されます。",
|
||||
"custom.saved": "カスタム DNS ルールを保存しました",
|
||||
"custom.imported": "カスタム DNS ルールをインポートしました",
|
||||
"custom.exported": "カスタム DNS ルールをエクスポートしました",
|
||||
"custom.exportTxt": "TXT をエクスポート",
|
||||
"custom.exportJson": "JSON をエクスポート",
|
||||
"customLevel": "カスタム",
|
||||
"custom.allowlistModeLabel": "許可リストモード",
|
||||
"custom.allowlistModeOn": "下記のドメインのみアクセス可能で、それ以外はすべてブロックされます。",
|
||||
"custom.allowlistModeOff": "リストのドメインをブロックし、それ以外はすべて許可します。",
|
||||
"custom.allowedOnlyLabel": "許可するドメイン(これらのみ)",
|
||||
"custom.allowedOnlyHint": "リスト内ドメインのサブドメインも許可されます。リストが空の場合はフィルタリングが無効になります。"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1415,7 +1449,11 @@
|
||||
"statusImported": "インポート済み",
|
||||
"statusSkipped": "スキップ",
|
||||
"statusFailed": "失敗",
|
||||
"importButtonCount": "インポート ({{count}})"
|
||||
"importButtonCount": "インポート ({{count}})",
|
||||
"vpnOptional": "VPN(任意)",
|
||||
"noVpn": "VPNなし",
|
||||
"advancedOptions": "詳細オプション",
|
||||
"configureFingerprint": "フィンガープリントを設定(任意)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "同期中...",
|
||||
@@ -1618,7 +1656,14 @@
|
||||
"sentLegend": "送信",
|
||||
"receivedLegend": "受信",
|
||||
"tooltipSent": "↑ 送信: ",
|
||||
"tooltipReceived": "↓ 受信: "
|
||||
"tooltipReceived": "↓ 受信: ",
|
||||
"clearHistory": "履歴を消去",
|
||||
"clearHistoryTitle": "トラフィック履歴を消去",
|
||||
"clearHistoryDescription": "「{{name}}」の記録されたトラフィック履歴をすべて完全かつ安全に消去します。この操作は元に戻せません。",
|
||||
"tabOverview": "概要",
|
||||
"tabTopDomains": "上位ドメイン",
|
||||
"searchDomains": "ドメインを検索…",
|
||||
"noDomainMatch": "検索に一致するドメインはありません。"
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "不明",
|
||||
@@ -1818,7 +1863,14 @@
|
||||
"importNoItems": "インポートする項目が選択されていません",
|
||||
"browserNotDownloaded": "{{browser}}のダウンロード済みバージョンがありません。先にダウンロードしてから、再度インポートしてください。",
|
||||
"archiveExtractionFailed": "アーカイブの展開に失敗しました:{{detail}}",
|
||||
"unsupportedArchiveFormat": "サポートされていないアーカイブ形式です。ZIPアーカイブのみサポートされています。"
|
||||
"unsupportedArchiveFormat": "サポートされていないアーカイブ形式です。ZIPアーカイブのみサポートされています。",
|
||||
"clearOnCloseUnavailable": "終了時消去は、一時プロファイルやパスワード保護されたプロファイルでは利用できません。",
|
||||
"proxyAndVpnMutuallyExclusive": "プロファイルにはプロキシまたは VPN のいずれかを設定できます(両方は設定できません)。",
|
||||
"invalidDnsRulesJson": "選択したファイルは有効な DNS ルール JSON ではありません。",
|
||||
"unsupportedDnsRulesFormat": "サポートされていないルール形式: {{format}}",
|
||||
"dnsRulesSaveFailed": "DNS ルールを保存できませんでした。",
|
||||
"dnsRulesExportFailed": "DNS ルールをエクスポートできませんでした。",
|
||||
"fingerprintMatchFailed": "フィンガープリントをプロキシに合わせられませんでした。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "プロファイル",
|
||||
@@ -1831,7 +1883,9 @@
|
||||
"importProfile": "プロファイルをインポート",
|
||||
"importProfileHint": "別のツールから取り込む",
|
||||
"keyboardShortcuts": "キーボードショートカット",
|
||||
"keyboardShortcutsHint": "すべてのショートカットを表示"
|
||||
"keyboardShortcutsHint": "すべてのショートカットを表示",
|
||||
"about": "Donut Browser について",
|
||||
"aboutHint": "バージョンとアプリ情報"
|
||||
},
|
||||
"network": "ネットワーク",
|
||||
"integrations": "連携",
|
||||
@@ -1920,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "{{name}} を起動",
|
||||
"stopProfile": "{{name}} を停止",
|
||||
"profileInfo": "情報 — {{name}}"
|
||||
"profileInfo": "情報 — {{name}}",
|
||||
"createProfile": "プロファイルを作成",
|
||||
"about": "Donut Browser について"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2036,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "他に Wayfern プロファイルが選択されていません"
|
||||
},
|
||||
"about": {
|
||||
"title": "このアプリについて",
|
||||
"version": "バージョン {{version}}",
|
||||
"portableBadge": "ポータブル",
|
||||
"licenseNotice": "AGPL-3.0 ライセンスのオープンソース・アンチディテクトブラウザです。",
|
||||
"website": "ウェブサイト"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "終了時にデータを消去",
|
||||
"description": "ブラウザを閉じるときに Cookie、履歴、キャッシュを消去します。拡張機能とブックマークは保持されます。"
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "フィンガープリントの不一致",
|
||||
"intro": "「{{name}}」のプロキシ出口がこのプロファイルのフィンガープリントと一致していません:",
|
||||
"timezoneTitle": "タイムゾーンの不一致",
|
||||
"timezoneDetail": "出口ノードは {{exit}} にありますが、フィンガープリントは {{fingerprint}} を示しています。",
|
||||
"languageTitle": "言語の不一致",
|
||||
"languageDetail": "出口の国は {{country}} ですが、フィンガープリントの言語は {{fingerprint}} です。",
|
||||
"explainer": "出口 IP と食い違うタイムゾーンや言語は、実際のデバイス情報が漏れていなくても強力なアンチボットシグナルになります。フィンガープリントをプロキシの場所に合わせて、警戒される扱いを減らしましょう。",
|
||||
"dontWarnAgain": "このプロファイルでは今後警告しない",
|
||||
"matchToProxy": "フィンガープリントをプロキシに合わせる",
|
||||
"matching": "調整中…",
|
||||
"matchSuccess": "フィンガープリントをプロキシに合わせて更新しました。反映するにはプロファイルを再起動してください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "앱 자동 업데이트 사용 안 함",
|
||||
"disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
|
||||
"keepDecryptedProfilesInRam": "복호화된 프로필을 RAM에 유지",
|
||||
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다."
|
||||
"keepDecryptedProfilesInRamDescription": "비밀번호로 보호된 프로필의 복호화된 RAM 사본을 실행 사이에 유지하여 시작 속도를 높입니다. 디스크의 사본은 그대로 암호화된 상태로 유지됩니다.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "핑거프린트 일관성 경고",
|
||||
"consistencyWarningDescription": "프로필의 시간대나 언어가 프록시 출구 노드와 일치하지 않으면 실행 시 경고합니다.",
|
||||
"clearTraffic": "모든 트래픽 기록 지우기",
|
||||
"clearTrafficDescription": "모든 프로필의 기록된 트래픽 통계를 안전하게 지웁니다.",
|
||||
"clearTrafficSuccess": "트래픽 기록이 지워졌습니다"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "프로필 검색...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "실행 중인 프로필은 수정할 수 없습니다",
|
||||
"cantModifyLaunching": "실행하는 동안 프로필을 수정할 수 없습니다",
|
||||
"cantModifyStopping": "중지하는 동안 프로필을 수정할 수 없습니다",
|
||||
"cantModifyUpdating": "브라우저 업데이트 중에는 프로필을 수정할 수 없습니다"
|
||||
"cantModifyUpdating": "브라우저 업데이트 중에는 프로필을 수정할 수 없습니다",
|
||||
"emptyTitle": "아직 프로필이 없습니다",
|
||||
"emptyHint": "첫 프로필을 만들거나 다른 브라우저에서 기존 프로필을 가져오세요.",
|
||||
"emptyCreate": "프로필 생성",
|
||||
"emptyImport": "프로필 가져오기",
|
||||
"emptyFilteredTitle": "프로필을 찾을 수 없습니다",
|
||||
"emptyFilteredHint": "이 그룹 또는 검색과 일치하는 프로필이 없습니다. 다른 필터를 사용하거나 새로 만드세요."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "실행",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "도메인",
|
||||
"fresh": "최신",
|
||||
"stale": "오래됨",
|
||||
"notCached": "캐시되지 않음"
|
||||
"notCached": "캐시되지 않음",
|
||||
"tabBlocklists": "차단 목록",
|
||||
"tabCustom": "사용자 지정 목록",
|
||||
"custom.description": "소스 URL과 수동 규칙으로 나만의 차단 목록을 만드세요. 허용 도메인은 항상 차단 도메인보다 우선합니다.",
|
||||
"custom.sourcesLabel": "차단 목록 소스 URL",
|
||||
"custom.sourcesPlaceholder": "한 줄에 URL 하나",
|
||||
"custom.blockLabel": "차단할 도메인",
|
||||
"custom.blockPlaceholder": "한 줄에 도메인 하나",
|
||||
"custom.allowLabel": "허용할 도메인",
|
||||
"custom.allowPlaceholder": "한 줄에 도메인 하나",
|
||||
"custom.allowHint": "허용 도메인은 컴파일된 차단 목록에서 제거되어 모든 차단 규칙보다 우선합니다.",
|
||||
"custom.saved": "사용자 지정 DNS 규칙이 저장되었습니다",
|
||||
"custom.imported": "사용자 지정 DNS 규칙을 가져왔습니다",
|
||||
"custom.exported": "사용자 지정 DNS 규칙을 내보냈습니다",
|
||||
"custom.exportTxt": "TXT 내보내기",
|
||||
"custom.exportJson": "JSON 내보내기",
|
||||
"customLevel": "사용자 지정",
|
||||
"custom.allowlistModeLabel": "허용 목록 모드",
|
||||
"custom.allowlistModeOn": "아래 도메인만 접근할 수 있으며, 그 외에는 모두 차단됩니다.",
|
||||
"custom.allowlistModeOff": "목록의 도메인을 차단하고, 그 외에는 모두 허용합니다.",
|
||||
"custom.allowedOnlyLabel": "허용 도메인 (이것만)",
|
||||
"custom.allowedOnlyHint": "목록에 있는 도메인의 하위 도메인도 허용됩니다. 목록이 비어 있으면 필터링이 비활성화됩니다."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1415,7 +1449,11 @@
|
||||
"statusImported": "가져옴",
|
||||
"statusSkipped": "건너뜀",
|
||||
"statusFailed": "실패",
|
||||
"importButtonCount": "가져오기 ({{count}})"
|
||||
"importButtonCount": "가져오기 ({{count}})",
|
||||
"vpnOptional": "VPN (선택 사항)",
|
||||
"noVpn": "VPN 없음",
|
||||
"advancedOptions": "고급 옵션",
|
||||
"configureFingerprint": "핑거프린트 구성 (선택 사항)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "동기화 중...",
|
||||
@@ -1618,7 +1656,14 @@
|
||||
"sentLegend": "보냄",
|
||||
"receivedLegend": "받음",
|
||||
"tooltipSent": "↑ 보냄: ",
|
||||
"tooltipReceived": "↓ 받음: "
|
||||
"tooltipReceived": "↓ 받음: ",
|
||||
"clearHistory": "기록 지우기",
|
||||
"clearHistoryTitle": "트래픽 기록 지우기",
|
||||
"clearHistoryDescription": "\"{{name}}\"의 기록된 모든 트래픽 기록을 영구적이고 안전하게 지웁니다. 이 작업은 되돌릴 수 없습니다.",
|
||||
"tabOverview": "개요",
|
||||
"tabTopDomains": "상위 도메인",
|
||||
"searchDomains": "도메인 검색…",
|
||||
"noDomainMatch": "검색과 일치하는 도메인이 없습니다."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "알 수 없음",
|
||||
@@ -1818,7 +1863,14 @@
|
||||
"importNoItems": "가져올 항목이 선택되지 않았습니다",
|
||||
"browserNotDownloaded": "{{browser}}의 다운로드된 버전이 없습니다. 먼저 다운로드한 후 가져오기를 다시 시도하세요.",
|
||||
"archiveExtractionFailed": "아카이브 추출에 실패했습니다: {{detail}}",
|
||||
"unsupportedArchiveFormat": "지원되지 않는 아카이브 형식입니다. ZIP 아카이브만 지원됩니다."
|
||||
"unsupportedArchiveFormat": "지원되지 않는 아카이브 형식입니다. ZIP 아카이브만 지원됩니다.",
|
||||
"clearOnCloseUnavailable": "닫을 때 지우기는 임시 프로필이나 비밀번호로 보호된 프로필에서는 사용할 수 없습니다.",
|
||||
"proxyAndVpnMutuallyExclusive": "프로필에는 프록시 또는 VPN 중 하나만 사용할 수 있습니다.",
|
||||
"invalidDnsRulesJson": "선택한 파일이 올바른 DNS 규칙 JSON이 아닙니다.",
|
||||
"unsupportedDnsRulesFormat": "지원되지 않는 규칙 형식: {{format}}",
|
||||
"dnsRulesSaveFailed": "DNS 규칙을 저장하지 못했습니다.",
|
||||
"dnsRulesExportFailed": "DNS 규칙을 내보내지 못했습니다.",
|
||||
"fingerprintMatchFailed": "지문을 프록시에 맞추지 못했습니다."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "프로필",
|
||||
@@ -1831,7 +1883,9 @@
|
||||
"importProfile": "프로필 가져오기",
|
||||
"importProfileHint": "다른 도구에서 프로필 가져오기",
|
||||
"keyboardShortcuts": "키보드 단축키",
|
||||
"keyboardShortcutsHint": "모든 단축키 보기"
|
||||
"keyboardShortcutsHint": "모든 단축키 보기",
|
||||
"about": "Donut Browser 정보",
|
||||
"aboutHint": "버전 및 앱 정보"
|
||||
},
|
||||
"network": "네트워크",
|
||||
"integrations": "통합",
|
||||
@@ -1920,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "{{name}} 실행",
|
||||
"stopProfile": "{{name}} 중지",
|
||||
"profileInfo": "정보 — {{name}}"
|
||||
"profileInfo": "정보 — {{name}}",
|
||||
"createProfile": "프로필 생성",
|
||||
"about": "Donut Browser 정보"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2036,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "선택된 다른 Wayfern 프로필이 없습니다"
|
||||
},
|
||||
"about": {
|
||||
"title": "정보",
|
||||
"version": "버전 {{version}}",
|
||||
"portableBadge": "포터블",
|
||||
"licenseNotice": "AGPL-3.0 라이선스의 오픈 소스 안티 디텍트 브라우저입니다.",
|
||||
"website": "웹사이트"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "닫을 때 데이터 지우기",
|
||||
"description": "브라우저를 닫을 때 쿠키, 방문 기록, 캐시를 지웁니다. 확장 프로그램과 북마크는 유지됩니다."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "핑거프린트 불일치",
|
||||
"intro": "\"{{name}}\"의 프록시 출구가 이 프로필의 핑거프린트와 일치하지 않습니다:",
|
||||
"timezoneTitle": "시간대 불일치",
|
||||
"timezoneDetail": "출구 노드는 {{exit}}에 있지만 핑거프린트는 {{fingerprint}}로 보고합니다.",
|
||||
"languageTitle": "언어 불일치",
|
||||
"languageDetail": "출구 국가는 {{country}}이지만 핑거프린트 언어는 {{fingerprint}}입니다.",
|
||||
"explainer": "출구 IP와 어긋나는 시간대나 언어는 실제 기기 정보가 유출되지 않더라도 강력한 안티봇 신호가 됩니다. 핑거프린트를 프록시 위치에 맞춰 의심받는 상황을 줄이세요.",
|
||||
"dontWarnAgain": "이 프로필에 대해 다시 경고하지 않음",
|
||||
"matchToProxy": "지문을 프록시에 맞추기",
|
||||
"matching": "맞추는 중…",
|
||||
"matchSuccess": "지문이 프록시에 맞게 업데이트되었습니다. 적용하려면 프로필을 다시 실행하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,14 @@
|
||||
"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.",
|
||||
"keepDecryptedProfilesInRam": "Manter Perfis Descriptografados na RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso."
|
||||
"keepDecryptedProfilesInRamDescription": "Preserva a cópia descriptografada na RAM dos perfis protegidos por senha entre execuções para um início mais rápido. A cópia em disco permanece criptografada em qualquer caso.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Aviso de consistência de impressão digital",
|
||||
"consistencyWarningDescription": "Avisar ao iniciar quando o fuso horário ou o idioma de um perfil não corresponder ao seu nó de saída do proxy.",
|
||||
"clearTraffic": "Limpar todo o histórico de tráfego",
|
||||
"clearTrafficDescription": "Apaga com segurança as estatísticas de tráfego registradas de todos os perfis.",
|
||||
"clearTrafficSuccess": "Histórico de tráfego limpo"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Pesquisar perfis...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Não é possível modificar um perfil em execução",
|
||||
"cantModifyLaunching": "Não é possível modificar o perfil durante a inicialização",
|
||||
"cantModifyStopping": "Não é possível modificar o perfil durante a parada",
|
||||
"cantModifyUpdating": "Não é possível modificar o perfil enquanto o navegador é atualizado"
|
||||
"cantModifyUpdating": "Não é possível modificar o perfil enquanto o navegador é atualizado",
|
||||
"emptyTitle": "Nenhum perfil ainda",
|
||||
"emptyHint": "Crie seu primeiro perfil ou importe perfis existentes de outro navegador.",
|
||||
"emptyCreate": "Criar perfil",
|
||||
"emptyImport": "Importar perfis",
|
||||
"emptyFilteredTitle": "Nenhum perfil encontrado",
|
||||
"emptyFilteredHint": "Nenhum perfil corresponde a este grupo ou pesquisa. Tente outro filtro ou crie um novo."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Iniciar",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "domínios",
|
||||
"fresh": "Atualizado",
|
||||
"stale": "Desatualizado",
|
||||
"notCached": "Sem cache"
|
||||
"notCached": "Sem cache",
|
||||
"tabBlocklists": "Listas de bloqueio",
|
||||
"tabCustom": "Listas personalizadas",
|
||||
"custom.description": "Monte sua própria lista de bloqueio a partir de URLs de origem e regras manuais. Domínios permitidos sempre têm prioridade sobre os bloqueados.",
|
||||
"custom.sourcesLabel": "URLs de origem de listas de bloqueio",
|
||||
"custom.sourcesPlaceholder": "Uma URL por linha",
|
||||
"custom.blockLabel": "Domínios bloqueados",
|
||||
"custom.blockPlaceholder": "Um domínio por linha",
|
||||
"custom.allowLabel": "Domínios permitidos",
|
||||
"custom.allowPlaceholder": "Um domínio por linha",
|
||||
"custom.allowHint": "Os domínios permitidos são removidos da lista de bloqueio compilada, anulando qualquer regra de bloqueio.",
|
||||
"custom.saved": "Regras DNS personalizadas salvas",
|
||||
"custom.imported": "Regras DNS personalizadas importadas",
|
||||
"custom.exported": "Regras DNS personalizadas exportadas",
|
||||
"custom.exportTxt": "Exportar TXT",
|
||||
"custom.exportJson": "Exportar JSON",
|
||||
"customLevel": "Personalizada",
|
||||
"custom.allowlistModeLabel": "Modo lista de permitidos",
|
||||
"custom.allowlistModeOn": "Apenas os domínios abaixo são acessíveis; todo o resto é bloqueado.",
|
||||
"custom.allowlistModeOff": "Bloquear os domínios listados; permitir todo o resto.",
|
||||
"custom.allowedOnlyLabel": "Domínios permitidos (apenas estes)",
|
||||
"custom.allowedOnlyHint": "Subdomínios de um domínio listado também são permitidos. Uma lista vazia desativa a filtragem."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1415,7 +1449,11 @@
|
||||
"statusImported": "Importado",
|
||||
"statusSkipped": "Ignorado",
|
||||
"statusFailed": "Falhou",
|
||||
"importButtonCount": "Importar ({{count}})"
|
||||
"importButtonCount": "Importar ({{count}})",
|
||||
"vpnOptional": "VPN (opcional)",
|
||||
"noVpn": "Sem VPN",
|
||||
"advancedOptions": "Opções avançadas",
|
||||
"configureFingerprint": "Configurar impressão digital (opcional)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Sincronizando...",
|
||||
@@ -1618,7 +1656,14 @@
|
||||
"sentLegend": "Enviado",
|
||||
"receivedLegend": "Recebido",
|
||||
"tooltipSent": "↑ Enviado: ",
|
||||
"tooltipReceived": "↓ Recebido: "
|
||||
"tooltipReceived": "↓ Recebido: ",
|
||||
"clearHistory": "Limpar histórico",
|
||||
"clearHistoryTitle": "Limpar histórico de tráfego",
|
||||
"clearHistoryDescription": "Apaga de forma permanente e segura todo o histórico de tráfego registrado de \"{{name}}\". Isso não pode ser desfeito.",
|
||||
"tabOverview": "Visão geral",
|
||||
"tabTopDomains": "Principais domínios",
|
||||
"searchDomains": "Pesquisar domínios…",
|
||||
"noDomainMatch": "Nenhum domínio corresponde à sua pesquisa."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Desconhecido",
|
||||
@@ -1818,7 +1863,14 @@
|
||||
"importNoItems": "Nada selecionado para importar",
|
||||
"browserNotDownloaded": "Nenhuma versão baixada de {{browser}} está disponível. Baixe-a primeiro e tente importar novamente.",
|
||||
"archiveExtractionFailed": "Falha ao extrair o arquivo: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Formato de arquivo não suportado. Apenas arquivos ZIP são suportados."
|
||||
"unsupportedArchiveFormat": "Formato de arquivo não suportado. Apenas arquivos ZIP são suportados.",
|
||||
"clearOnCloseUnavailable": "A limpeza ao fechar não está disponível para perfis efêmeros ou protegidos por senha.",
|
||||
"proxyAndVpnMutuallyExclusive": "Um perfil pode usar um proxy ou uma VPN, mas não ambos.",
|
||||
"invalidDnsRulesJson": "O arquivo selecionado não é um JSON de regras DNS válido.",
|
||||
"unsupportedDnsRulesFormat": "Formato de regras não suportado: {{format}}",
|
||||
"dnsRulesSaveFailed": "Falha ao salvar as regras DNS.",
|
||||
"dnsRulesExportFailed": "Falha ao exportar as regras DNS.",
|
||||
"fingerprintMatchFailed": "Não foi possível ajustar a impressão digital ao proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Perfis",
|
||||
@@ -1831,7 +1883,9 @@
|
||||
"importProfile": "Importar perfil",
|
||||
"importProfileHint": "Trazer perfis de outra ferramenta",
|
||||
"keyboardShortcuts": "Atalhos de teclado",
|
||||
"keyboardShortcutsHint": "Ver todos os atalhos"
|
||||
"keyboardShortcutsHint": "Ver todos os atalhos",
|
||||
"about": "Sobre o Donut Browser",
|
||||
"aboutHint": "Versão e informações do aplicativo"
|
||||
},
|
||||
"network": "Rede",
|
||||
"integrations": "Integrações",
|
||||
@@ -1920,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Iniciar {{name}}",
|
||||
"stopProfile": "Parar {{name}}",
|
||||
"profileInfo": "Informações — {{name}}"
|
||||
"profileInfo": "Informações — {{name}}",
|
||||
"createProfile": "Criar perfil",
|
||||
"about": "Sobre o Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2036,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "Nenhum outro perfil Wayfern selecionado"
|
||||
},
|
||||
"about": {
|
||||
"title": "Sobre",
|
||||
"version": "Versão {{version}}",
|
||||
"portableBadge": "portátil",
|
||||
"licenseNotice": "Navegador anti-detecção de código aberto, licenciado sob AGPL-3.0.",
|
||||
"website": "Site"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Limpar dados ao fechar",
|
||||
"description": "Apaga cookies, histórico e cache quando o navegador é fechado. Extensões e favoritos são mantidos."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Divergência de impressão digital",
|
||||
"intro": "A saída do proxy de \"{{name}}\" não corresponde à impressão digital deste perfil:",
|
||||
"timezoneTitle": "Divergência de fuso horário",
|
||||
"timezoneDetail": "O nó de saída está em {{exit}}, mas a impressão digital indica {{fingerprint}}.",
|
||||
"languageTitle": "Divergência de idioma",
|
||||
"languageDetail": "O país de saída é {{country}}, mas o idioma da impressão digital é {{fingerprint}}.",
|
||||
"explainer": "Um fuso horário ou idioma que não combina com seu IP de saída é um forte sinal anti-bot, mesmo que seu dispositivo real nunca vaze. Alinhe a impressão digital com a localização do proxy para reduzir tratamentos hostis.",
|
||||
"dontWarnAgain": "Não avisar novamente para este perfil",
|
||||
"matchToProxy": "Ajustar impressão ao proxy",
|
||||
"matching": "Ajustando…",
|
||||
"matchSuccess": "Impressão digital atualizada para corresponder ao proxy. Reinicie o perfil para aplicar."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "Отключить автообновление приложения",
|
||||
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
|
||||
"keepDecryptedProfilesInRam": "Хранить расшифрованные профили в ОЗУ",
|
||||
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной."
|
||||
"keepDecryptedProfilesInRamDescription": "Сохранять расшифрованную копию защищённых паролем профилей в ОЗУ между запусками для ускорения старта. Копия на диске в любом случае остаётся зашифрованной.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Предупреждение о согласованности отпечатка",
|
||||
"consistencyWarningDescription": "Предупреждать при запуске, если часовой пояс или язык профиля не совпадает с выходным узлом прокси.",
|
||||
"clearTraffic": "Очистить всю историю трафика",
|
||||
"clearTrafficDescription": "Безопасно удаляет записанную статистику трафика для всех профилей.",
|
||||
"clearTrafficSuccess": "История трафика очищена"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Поиск профилей...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Нельзя изменить запущенный профиль",
|
||||
"cantModifyLaunching": "Нельзя изменить профиль во время запуска",
|
||||
"cantModifyStopping": "Нельзя изменить профиль во время остановки",
|
||||
"cantModifyUpdating": "Нельзя изменить профиль во время обновления браузера"
|
||||
"cantModifyUpdating": "Нельзя изменить профиль во время обновления браузера",
|
||||
"emptyTitle": "Профилей пока нет",
|
||||
"emptyHint": "Создайте свой первый профиль или импортируйте существующие из другого браузера.",
|
||||
"emptyCreate": "Создать профиль",
|
||||
"emptyImport": "Импортировать профили",
|
||||
"emptyFilteredTitle": "Профили не найдены",
|
||||
"emptyFilteredHint": "Нет профилей для этой группы или запроса. Попробуйте другой фильтр или создайте профиль."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Запустить",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "доменов",
|
||||
"fresh": "Актуальный",
|
||||
"stale": "Устаревший",
|
||||
"notCached": "Не кэшировано"
|
||||
"notCached": "Не кэшировано",
|
||||
"tabBlocklists": "Списки блокировки",
|
||||
"tabCustom": "Пользовательские списки",
|
||||
"custom.description": "Составьте собственный список блокировки из URL-источников и ручных правил. Разрешённые домены всегда имеют приоритет над заблокированными.",
|
||||
"custom.sourcesLabel": "URL-источники списков блокировки",
|
||||
"custom.sourcesPlaceholder": "Один URL на строку",
|
||||
"custom.blockLabel": "Заблокированные домены",
|
||||
"custom.blockPlaceholder": "Один домен на строку",
|
||||
"custom.allowLabel": "Разрешённые домены",
|
||||
"custom.allowPlaceholder": "Один домен на строку",
|
||||
"custom.allowHint": "Разрешённые домены исключаются из итогового списка блокировки и имеют приоритет над любым правилом блокировки.",
|
||||
"custom.saved": "Пользовательские правила DNS сохранены",
|
||||
"custom.imported": "Пользовательские правила DNS импортированы",
|
||||
"custom.exported": "Пользовательские правила DNS экспортированы",
|
||||
"custom.exportTxt": "Экспорт TXT",
|
||||
"custom.exportJson": "Экспорт JSON",
|
||||
"customLevel": "Пользовательский",
|
||||
"custom.allowlistModeLabel": "Режим разрешённого списка",
|
||||
"custom.allowlistModeOn": "Доступны только домены ниже; всё остальное блокируется.",
|
||||
"custom.allowlistModeOff": "Блокировать домены из списка; всё остальное разрешено.",
|
||||
"custom.allowedOnlyLabel": "Разрешённые домены (только эти)",
|
||||
"custom.allowedOnlyHint": "Поддомены указанных доменов также разрешены. Пустой список отключает фильтрацию."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1415,7 +1449,11 @@
|
||||
"statusImported": "Импортирован",
|
||||
"statusSkipped": "Пропущен",
|
||||
"statusFailed": "Ошибка",
|
||||
"importButtonCount": "Импортировать ({{count}})"
|
||||
"importButtonCount": "Импортировать ({{count}})",
|
||||
"vpnOptional": "VPN (необязательно)",
|
||||
"noVpn": "Без VPN",
|
||||
"advancedOptions": "Дополнительные параметры",
|
||||
"configureFingerprint": "Настроить отпечаток (необязательно)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Синхронизация...",
|
||||
@@ -1618,7 +1656,14 @@
|
||||
"sentLegend": "Отправлено",
|
||||
"receivedLegend": "Получено",
|
||||
"tooltipSent": "↑ Отправлено: ",
|
||||
"tooltipReceived": "↓ Получено: "
|
||||
"tooltipReceived": "↓ Получено: ",
|
||||
"clearHistory": "Очистить историю",
|
||||
"clearHistoryTitle": "Очистить историю трафика",
|
||||
"clearHistoryDescription": "Навсегда и безопасно удаляет всю записанную историю трафика для «{{name}}». Это действие нельзя отменить.",
|
||||
"tabOverview": "Обзор",
|
||||
"tabTopDomains": "Топ доменов",
|
||||
"searchDomains": "Поиск доменов…",
|
||||
"noDomainMatch": "Нет доменов, соответствующих запросу."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Неизвестно",
|
||||
@@ -1818,7 +1863,14 @@
|
||||
"importNoItems": "Ничего не выбрано для импорта",
|
||||
"browserNotDownloaded": "Нет загруженной версии {{browser}}. Сначала загрузите её, затем повторите импорт.",
|
||||
"archiveExtractionFailed": "Не удалось распаковать архив: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Неподдерживаемый формат архива. Поддерживаются только ZIP-архивы."
|
||||
"unsupportedArchiveFormat": "Неподдерживаемый формат архива. Поддерживаются только ZIP-архивы.",
|
||||
"clearOnCloseUnavailable": "Очистка при закрытии недоступна для эфемерных и защищённых паролем профилей.",
|
||||
"proxyAndVpnMutuallyExclusive": "Профиль может использовать либо прокси, либо VPN, но не оба сразу.",
|
||||
"invalidDnsRulesJson": "Выбранный файл не является корректным JSON с правилами DNS.",
|
||||
"unsupportedDnsRulesFormat": "Неподдерживаемый формат правил: {{format}}",
|
||||
"dnsRulesSaveFailed": "Не удалось сохранить правила DNS.",
|
||||
"dnsRulesExportFailed": "Не удалось экспортировать правила DNS.",
|
||||
"fingerprintMatchFailed": "Не удалось подогнать отпечаток под прокси."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Профили",
|
||||
@@ -1831,7 +1883,9 @@
|
||||
"importProfile": "Импорт профиля",
|
||||
"importProfileHint": "Перенести профили из другого инструмента",
|
||||
"keyboardShortcuts": "Сочетания клавиш",
|
||||
"keyboardShortcutsHint": "Показать все сочетания"
|
||||
"keyboardShortcutsHint": "Показать все сочетания",
|
||||
"about": "О Donut Browser",
|
||||
"aboutHint": "Версия и сведения о приложении"
|
||||
},
|
||||
"network": "Сеть",
|
||||
"integrations": "Интеграции",
|
||||
@@ -1920,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Запустить {{name}}",
|
||||
"stopProfile": "Остановить {{name}}",
|
||||
"profileInfo": "Информация — {{name}}"
|
||||
"profileInfo": "Информация — {{name}}",
|
||||
"createProfile": "Создать профиль",
|
||||
"about": "О Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2036,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "Других выбранных Wayfern профилей нет"
|
||||
},
|
||||
"about": {
|
||||
"title": "О приложении",
|
||||
"version": "Версия {{version}}",
|
||||
"portableBadge": "портативная",
|
||||
"licenseNotice": "Антидетект-браузер с открытым исходным кодом, распространяется по лицензии AGPL-3.0.",
|
||||
"website": "Веб-сайт"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Очищать данные при закрытии",
|
||||
"description": "Удаляет cookie, историю и кэш при закрытии браузера. Расширения и закладки сохраняются."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Несовпадение отпечатка",
|
||||
"intro": "Выходной узел прокси для «{{name}}» не соответствует отпечатку этого профиля:",
|
||||
"timezoneTitle": "Несовпадение часового пояса",
|
||||
"timezoneDetail": "Выходной узел находится в {{exit}}, но отпечаток сообщает {{fingerprint}}.",
|
||||
"languageTitle": "Несовпадение языка",
|
||||
"languageDetail": "Страна выхода — {{country}}, но язык отпечатка — {{fingerprint}}.",
|
||||
"explainer": "Часовой пояс или язык, не совпадающий с выходным IP, — сильный антибот-сигнал, даже если данные вашего реального устройства никогда не утекают. Приведите отпечаток в соответствие с расположением прокси, чтобы снизить враждебное отношение.",
|
||||
"dontWarnAgain": "Больше не предупреждать для этого профиля",
|
||||
"matchToProxy": "Подогнать отпечаток под прокси",
|
||||
"matching": "Подгонка…",
|
||||
"matchSuccess": "Отпечаток обновлён под прокси. Перезапустите профиль, чтобы применить."
|
||||
}
|
||||
}
|
||||
|
||||
+118
-22
@@ -197,7 +197,14 @@
|
||||
"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.",
|
||||
"keepDecryptedProfilesInRam": "Şifresi Çözülmüş Profilleri RAM'de Tut",
|
||||
"keepDecryptedProfilesInRamDescription": "Daha hızlı başlatma için parola korumalı profillerin şifresi çözülmüş RAM kopyasını başlatmalar arasında koruyun. Diskteki kopya her durumda şifreli kalır."
|
||||
"keepDecryptedProfilesInRamDescription": "Daha hızlı başlatma için parola korumalı profillerin şifresi çözülmüş RAM kopyasını başlatmalar arasında koruyun. Diskteki kopya her durumda şifreli kalır.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Parmak izi tutarlılık uyarısı",
|
||||
"consistencyWarningDescription": "Bir profilin saat dilimi veya dili proxy çıkış düğümüyle eşleşmediğinde başlatma sırasında uyar.",
|
||||
"clearTraffic": "Tüm trafik geçmişini temizle",
|
||||
"clearTrafficDescription": "Tüm profillerin kayıtlı trafik istatistiklerini güvenli bir şekilde siler.",
|
||||
"clearTrafficSuccess": "Trafik geçmişi temizlendi"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Profillerde ara...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Çalışan profil değiştirilemez",
|
||||
"cantModifyLaunching": "Başlatılırken profil değiştirilemez",
|
||||
"cantModifyStopping": "Durdurulurken profil değiştirilemez",
|
||||
"cantModifyUpdating": "Tarayıcı güncellenirken profil değiştirilemez"
|
||||
"cantModifyUpdating": "Tarayıcı güncellenirken profil değiştirilemez",
|
||||
"emptyTitle": "Henüz profil yok",
|
||||
"emptyHint": "İlk profilinizi oluşturun veya başka bir tarayıcıdan mevcut profilleri içe aktarın.",
|
||||
"emptyCreate": "Profil oluştur",
|
||||
"emptyImport": "Profilleri içe aktar",
|
||||
"emptyFilteredTitle": "Profil bulunamadı",
|
||||
"emptyFilteredHint": "Bu grup veya aramayla eşleşen profil yok. Başka bir filtre deneyin veya yeni bir profil oluşturun."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Başlat",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "alan adı",
|
||||
"fresh": "Güncel",
|
||||
"stale": "Eski",
|
||||
"notCached": "Önbelleğe alınmadı"
|
||||
"notCached": "Önbelleğe alınmadı",
|
||||
"tabBlocklists": "Engel listeleri",
|
||||
"tabCustom": "Özel listeler",
|
||||
"custom.description": "Kaynak URL'lerden ve manuel kurallardan kendi engel listenizi oluşturun. İzin verilen alan adları her zaman engellenenlerin önüne geçer.",
|
||||
"custom.sourcesLabel": "Engel listesi kaynak URL'leri",
|
||||
"custom.sourcesPlaceholder": "Her satıra bir URL",
|
||||
"custom.blockLabel": "Engellenen alan adları",
|
||||
"custom.blockPlaceholder": "Her satıra bir alan adı",
|
||||
"custom.allowLabel": "İzin verilen alan adları",
|
||||
"custom.allowPlaceholder": "Her satıra bir alan adı",
|
||||
"custom.allowHint": "İzin verilen alan adları derlenen engel listesinden çıkarılır ve tüm engelleme kurallarını geçersiz kılar.",
|
||||
"custom.saved": "Özel DNS kuralları kaydedildi",
|
||||
"custom.imported": "Özel DNS kuralları içe aktarıldı",
|
||||
"custom.exported": "Özel DNS kuralları dışa aktarıldı",
|
||||
"custom.exportTxt": "TXT olarak dışa aktar",
|
||||
"custom.exportJson": "JSON olarak dışa aktar",
|
||||
"customLevel": "Özel",
|
||||
"custom.allowlistModeLabel": "İzin listesi modu",
|
||||
"custom.allowlistModeOn": "Yalnızca aşağıdaki alan adlarına erişilebilir; diğer her şey engellenir.",
|
||||
"custom.allowlistModeOff": "Listelenen alan adlarını engelle; diğer her şeye izin ver.",
|
||||
"custom.allowedOnlyLabel": "İzin verilen alan adları (yalnızca bunlar)",
|
||||
"custom.allowedOnlyHint": "Listelenen bir alan adının alt alan adlarına da izin verilir. Boş liste filtrelemeyi devre dışı bırakır."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1378,16 +1412,9 @@
|
||||
"scanning": "Tarayıcı profilleri taranıyor...",
|
||||
"noneFound": "Sisteminizde tarayıcı profili bulunamadı.",
|
||||
"noneFoundHint": "Profilleriniz özel konumlardaysa elle içe aktarma seçeneğini deneyin.",
|
||||
"selectProfile": "Profil Seçin:",
|
||||
"selectProfilePlaceholder": "Algılanan bir profil seçin",
|
||||
"pathLabel": "Yol:",
|
||||
"browserLabel": "Tarayıcı:",
|
||||
"newProfileName": "Yeni Profil Adı:",
|
||||
"newProfileNamePlaceholder": "İçe aktarılan profil için bir ad girin",
|
||||
"manualTitle": "Elle Profil İçe Aktarma",
|
||||
"browserType": "Tarayıcı Türü:",
|
||||
"loadingBrowsers": "Tarayıcılar yükleniyor...",
|
||||
"selectBrowserType": "Tarayıcı türü seçin",
|
||||
"profileFolderPath": "Profil Klasörü Yolu:",
|
||||
"profileFolderPlaceholder": "Profil klasörünün tam yolunu girin",
|
||||
"browseFolderTitle": "Klasöre göz at",
|
||||
@@ -1395,17 +1422,38 @@
|
||||
"selectFolderTitle": "Tarayıcı Profil Klasörünü Seçin",
|
||||
"folderDialogFailed": "Klasör iletişim kutusu açılamadı",
|
||||
"detectFailed": "Mevcut tarayıcı profilleri algılanamadı",
|
||||
"fillFields": "Lütfen tüm alanları doldurun",
|
||||
"selectAndName": "Lütfen bir profil seçin ve bir ad girin",
|
||||
"profileNotFound": "Seçilen profil bulunamadı",
|
||||
"importedSuccess": "\"{{name}}\" profili başarıyla içe aktarıldı",
|
||||
"notInstalled": "{{browser}} yüklü değil. Lütfen önce ana pencereden {{browser}} indirin, ardından içe aktarmayı yeniden deneyin.",
|
||||
"importFailed": "Profil içe aktarılamadı: {{error}}",
|
||||
"proxyOptional": "Proxy (İsteğe Bağlı)",
|
||||
"noProxy": "Proxy yok",
|
||||
"nextButton": "İleri",
|
||||
"importButton": "İçe Aktar",
|
||||
"importedAs": "Bu profil bir {{browser}} profili olarak içe aktarılacak."
|
||||
"importedAs": "Bu profil bir {{browser}} profili olarak içe aktarılacak.",
|
||||
"selectAll": "Tümünü seç",
|
||||
"selectedCount": "{{count}} seçildi",
|
||||
"scanButton": "Tara",
|
||||
"manualHint": "Bir profil klasörü, tarayıcı kullanıcı verisi klasörü, dışa aktarılmış profiller içeren bir klasör veya bir ZIP arşivi seçin.",
|
||||
"selectArchiveTitle": "ZIP arşivi seç",
|
||||
"noProfilesInLocation": "Bu konumda profil bulunamadı.",
|
||||
"selectAtLeastOne": "İçe aktarmak için en az bir profil seçin",
|
||||
"emptyNames": "Seçilen her profilin bir adı olmalı",
|
||||
"profilesToImport": "İçe aktarılacak profiller",
|
||||
"groupOptional": "Grup (İsteğe bağlı)",
|
||||
"noGroup": "Grup yok",
|
||||
"createNewGroup": "Yeni grup oluştur…",
|
||||
"newGroupNamePlaceholder": "Yeni grup adı",
|
||||
"duplicateStrategyLabel": "Profil adı zaten varsa",
|
||||
"duplicateRename": "Otomatik olarak yeniden adlandır",
|
||||
"duplicateSkip": "Atla",
|
||||
"proxyRoundRobin": "Kayıtlı proxy'leri dağıt (sırayla)",
|
||||
"importingTitle": "Profiller içe aktarılıyor…",
|
||||
"importProgress": "{{total}} öğeden {{completed}} tanesi işlendi",
|
||||
"resultsSummary": "{{imported}} içe aktarıldı, {{skipped}} atlandı, {{failed}} başarısız",
|
||||
"statusImported": "İçe aktarıldı",
|
||||
"statusSkipped": "Atlandı",
|
||||
"statusFailed": "Başarısız",
|
||||
"importButtonCount": "İçe aktar ({{count}})",
|
||||
"vpnOptional": "VPN (isteğe bağlı)",
|
||||
"noVpn": "VPN yok",
|
||||
"advancedOptions": "Gelişmiş seçenekler",
|
||||
"configureFingerprint": "Parmak izini yapılandır (isteğe bağlı)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Eşitleniyor...",
|
||||
@@ -1608,7 +1656,14 @@
|
||||
"sentLegend": "Gönderilen",
|
||||
"receivedLegend": "Alınan",
|
||||
"tooltipSent": "↑ Gönderilen: ",
|
||||
"tooltipReceived": "↓ Alınan: "
|
||||
"tooltipReceived": "↓ Alınan: ",
|
||||
"clearHistory": "Geçmişi temizle",
|
||||
"clearHistoryTitle": "Trafik geçmişini temizle",
|
||||
"clearHistoryDescription": "\"{{name}}\" için kayıtlı tüm trafik geçmişini kalıcı ve güvenli bir şekilde siler. Bu işlem geri alınamaz.",
|
||||
"tabOverview": "Genel bakış",
|
||||
"tabTopDomains": "En çok kullanılan alan adları",
|
||||
"searchDomains": "Alan adı ara…",
|
||||
"noDomainMatch": "Aramanızla eşleşen alan adı yok."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Bilinmiyor",
|
||||
@@ -1802,7 +1857,20 @@
|
||||
"updateChecksumsUnavailable": "{{version}} güncellemesi doğrulanamadı çünkü sağlama toplamı dosyası alınamadı. Güncelleme yüklenmedi; daha sonra yeniden denenecek.",
|
||||
"updateChecksumMismatch": "İndirilen güncelleme dosyası {{file}} sağlama toplamı doğrulamasını geçemedi ve silindi. Lütfen yeniden deneyin.",
|
||||
"nameCannotBeEmpty": "Ad boş olamaz",
|
||||
"wayfernVersionNotAvailable": "Wayfern {{requested}} sürümü indirilemiyor. Güncel sürüm: {{current}}."
|
||||
"wayfernVersionNotAvailable": "Wayfern {{requested}} sürümü indirilemiyor. Güncel sürüm: {{current}}.",
|
||||
"profileNameExists": "\"{{name}}\" adlı bir profil zaten var",
|
||||
"importSourceNotFound": "Kaynak yol mevcut değil",
|
||||
"importNoItems": "İçe aktarmak için hiçbir şey seçilmedi",
|
||||
"browserNotDownloaded": "{{browser}} tarayıcısının indirilmiş bir sürümü yok. Önce indirin, sonra içe aktarmayı yeniden deneyin.",
|
||||
"archiveExtractionFailed": "Arşiv çıkarılamadı: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Desteklenmeyen arşiv biçimi. Yalnızca ZIP arşivleri desteklenir.",
|
||||
"clearOnCloseUnavailable": "Kapatırken temizleme, geçici veya parola korumalı profillerde kullanılamaz.",
|
||||
"proxyAndVpnMutuallyExclusive": "Bir profil ya proxy ya da VPN kullanabilir, ikisini birden kullanamaz.",
|
||||
"invalidDnsRulesJson": "Seçilen dosya geçerli bir DNS kuralı JSON'u değil.",
|
||||
"unsupportedDnsRulesFormat": "Desteklenmeyen kural biçimi: {{format}}",
|
||||
"dnsRulesSaveFailed": "DNS kuralları kaydedilemedi.",
|
||||
"dnsRulesExportFailed": "DNS kuralları dışa aktarılamadı.",
|
||||
"fingerprintMatchFailed": "Parmak izi proxy'ye eşlenemedi."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profiller",
|
||||
@@ -1815,7 +1883,9 @@
|
||||
"importProfile": "Profil içe aktar",
|
||||
"importProfileHint": "Başka bir araçtan profil getirin",
|
||||
"keyboardShortcuts": "Klavye kısayolları",
|
||||
"keyboardShortcutsHint": "Tüm kısayolları görüntüle"
|
||||
"keyboardShortcutsHint": "Tüm kısayolları görüntüle",
|
||||
"about": "Donut Browser Hakkında",
|
||||
"aboutHint": "Sürüm ve uygulama bilgileri"
|
||||
},
|
||||
"network": "Ağ",
|
||||
"integrations": "Entegrasyonlar",
|
||||
@@ -1904,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "{{name}} profilini başlat",
|
||||
"stopProfile": "{{name}} profilini durdur",
|
||||
"profileInfo": "Bilgi — {{name}}"
|
||||
"profileInfo": "Bilgi — {{name}}",
|
||||
"createProfile": "Profil oluştur",
|
||||
"about": "Donut Browser Hakkında"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2020,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "Başka Wayfern profili seçilmedi"
|
||||
},
|
||||
"about": {
|
||||
"title": "Hakkında",
|
||||
"version": "Sürüm {{version}}",
|
||||
"portableBadge": "taşınabilir",
|
||||
"licenseNotice": "AGPL-3.0 lisanslı, açık kaynak anti-detect tarayıcı.",
|
||||
"website": "Web sitesi"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Kapatırken verileri temizle",
|
||||
"description": "Tarayıcı kapanırken çerezleri, geçmişi ve önbelleği siler. Uzantılar ve yer imleri korunur."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Parmak izi uyuşmazlığı",
|
||||
"intro": "\"{{name}}\" için proxy çıkışı bu profilin parmak iziyle eşleşmiyor:",
|
||||
"timezoneTitle": "Saat dilimi uyuşmazlığı",
|
||||
"timezoneDetail": "Çıkış düğümü {{exit}} konumunda, ancak parmak izi {{fingerprint}} bildiriyor.",
|
||||
"languageTitle": "Dil uyuşmazlığı",
|
||||
"languageDetail": "Çıkış ülkesi {{country}}, ancak parmak izi dili {{fingerprint}}.",
|
||||
"explainer": "Çıkış IP'nizle uyuşmayan bir saat dilimi veya dil, gerçek cihazınız hiç sızdırmasa bile güçlü bir anti-bot sinyalidir. Şüpheli muameleyi azaltmak için parmak izini proxy konumuyla hizalayın.",
|
||||
"dontWarnAgain": "Bu profil için bir daha uyarma",
|
||||
"matchToProxy": "Parmak izini proxy'ye eşle",
|
||||
"matching": "Eşleniyor…",
|
||||
"matchSuccess": "Parmak izi proxy'ye uyacak şekilde güncellendi. Uygulamak için profili yeniden başlatın."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,14 @@
|
||||
"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.",
|
||||
"keepDecryptedProfilesInRam": "Giữ hồ sơ đã giải mã trong RAM",
|
||||
"keepDecryptedProfilesInRamDescription": "Giữ bản sao đã giải mã trong RAM của hồ sơ được bảo vệ bằng mật khẩu giữa các lần khởi chạy để khởi động nhanh hơn. Bản sao trên ổ đĩa vẫn được mã hóa."
|
||||
"keepDecryptedProfilesInRamDescription": "Giữ bản sao đã giải mã trong RAM của hồ sơ được bảo vệ bằng mật khẩu giữa các lần khởi chạy để khởi động nhanh hơn. Bản sao trên ổ đĩa vẫn được mã hóa.",
|
||||
"privacy": {
|
||||
"consistencyWarning": "Cảnh báo nhất quán vân tay",
|
||||
"consistencyWarningDescription": "Cảnh báo khi khởi chạy nếu múi giờ hoặc ngôn ngữ của hồ sơ không khớp với nút thoát proxy.",
|
||||
"clearTraffic": "Xóa toàn bộ lịch sử lưu lượng",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Tìm kiếm hồ sơ...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "Không thể chỉnh sửa profile đang chạy",
|
||||
"cantModifyLaunching": "Không thể chỉnh sửa profile khi đang khởi chạy",
|
||||
"cantModifyStopping": "Không thể chỉnh sửa profile khi đang dừng",
|
||||
"cantModifyUpdating": "Không thể chỉnh sửa profile khi trình duyệt đang cập nhật"
|
||||
"cantModifyUpdating": "Không thể chỉnh sửa profile khi trình duyệt đang cập nhật",
|
||||
"emptyTitle": "Chưa có hồ sơ nào",
|
||||
"emptyHint": "Tạo hồ sơ đầu tiên của bạn hoặc nhập hồ sơ hiện có từ trình duyệt khác.",
|
||||
"emptyCreate": "Tạo hồ sơ",
|
||||
"emptyImport": "Nhập hồ sơ",
|
||||
"emptyFilteredTitle": "Không tìm thấy hồ sơ",
|
||||
"emptyFilteredHint": "Không có hồ sơ nào khớp với nhóm hoặc tìm kiếm này. Hãy thử bộ lọc khác hoặc tạo mới."
|
||||
},
|
||||
"actions": {
|
||||
"launch": "Khởi chạy",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "tên miền",
|
||||
"fresh": "Mới",
|
||||
"stale": "Cũ",
|
||||
"notCached": "Chưa lưu bộ nhớ đệm"
|
||||
"notCached": "Chưa lưu bộ nhớ đệm",
|
||||
"tabBlocklists": "Danh sách chặn",
|
||||
"tabCustom": "Danh sách tùy chỉnh",
|
||||
"custom.description": "Tự tạo danh sách chặn của riêng bạn từ các URL nguồn và quy tắc thủ công. Tên miền được phép luôn được ưu tiên hơn tên miền bị chặn.",
|
||||
"custom.sourcesLabel": "URL nguồn danh sách chặn",
|
||||
"custom.sourcesPlaceholder": "Mỗi dòng một URL",
|
||||
"custom.blockLabel": "Tên miền bị chặn",
|
||||
"custom.blockPlaceholder": "Mỗi dòng một tên miền",
|
||||
"custom.allowLabel": "Tên miền được phép",
|
||||
"custom.allowPlaceholder": "Mỗi dòng một tên miền",
|
||||
"custom.allowHint": "Tên miền được phép sẽ bị loại khỏi danh sách chặn đã biên dịch, ghi đè mọi quy tắc chặn.",
|
||||
"custom.saved": "Đã lưu quy tắc DNS tùy chỉnh",
|
||||
"custom.imported": "Đã nhập quy tắc DNS tùy chỉnh",
|
||||
"custom.exported": "Đã xuất quy tắc DNS tùy chỉnh",
|
||||
"custom.exportTxt": "Xuất TXT",
|
||||
"custom.exportJson": "Xuất JSON",
|
||||
"customLevel": "Tùy chỉnh",
|
||||
"custom.allowlistModeLabel": "Chế độ danh sách cho phép",
|
||||
"custom.allowlistModeOn": "Chỉ các tên miền bên dưới có thể truy cập; mọi thứ khác đều bị chặn.",
|
||||
"custom.allowlistModeOff": "Chặn các tên miền trong danh sách; cho phép mọi thứ khác.",
|
||||
"custom.allowedOnlyLabel": "Tên miền được phép (chỉ những tên này)",
|
||||
"custom.allowedOnlyHint": "Tên miền phụ của tên miền trong danh sách cũng được phép. Danh sách trống sẽ tắt bộ lọc."
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1415,7 +1449,11 @@
|
||||
"statusImported": "Đã nhập",
|
||||
"statusSkipped": "Bỏ qua",
|
||||
"statusFailed": "Thất bại",
|
||||
"importButtonCount": "Nhập ({{count}})"
|
||||
"importButtonCount": "Nhập ({{count}})",
|
||||
"vpnOptional": "VPN (tùy chọn)",
|
||||
"noVpn": "Không dùng VPN",
|
||||
"advancedOptions": "Tùy chọn nâng cao",
|
||||
"configureFingerprint": "Cấu hình vân tay (tùy chọn)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "Đang đồng bộ...",
|
||||
@@ -1618,7 +1656,14 @@
|
||||
"sentLegend": "Đã gửi",
|
||||
"receivedLegend": "Đã nhận",
|
||||
"tooltipSent": "↑ Đã gửi: ",
|
||||
"tooltipReceived": "↓ Đã nhận: "
|
||||
"tooltipReceived": "↓ Đã nhận: ",
|
||||
"clearHistory": "Xóa lịch sử",
|
||||
"clearHistoryTitle": "Xóa lịch sử lưu lượng",
|
||||
"clearHistoryDescription": "Xóa vĩnh viễn và an toàn toàn bộ lịch sử lưu lượng đã ghi của \"{{name}}\". Hành động này không thể hoàn tác.",
|
||||
"tabOverview": "Tổng quan",
|
||||
"tabTopDomains": "Tên miền hàng đầu",
|
||||
"searchDomains": "Tìm kiếm tên miền…",
|
||||
"noDomainMatch": "Không có tên miền nào khớp với tìm kiếm."
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "Không xác định",
|
||||
@@ -1818,7 +1863,14 @@
|
||||
"importNoItems": "Chưa chọn mục nào để nhập",
|
||||
"browserNotDownloaded": "Không có phiên bản {{browser}} nào đã tải xuống. Hãy tải xuống trước, sau đó thử nhập lại.",
|
||||
"archiveExtractionFailed": "Không thể giải nén tệp: {{detail}}",
|
||||
"unsupportedArchiveFormat": "Định dạng tệp nén không được hỗ trợ. Chỉ hỗ trợ tệp ZIP."
|
||||
"unsupportedArchiveFormat": "Định dạng tệp nén không được hỗ trợ. Chỉ hỗ trợ tệp ZIP.",
|
||||
"clearOnCloseUnavailable": "Tính năng xóa khi đóng không khả dụng với hồ sơ tạm thời hoặc hồ sơ được bảo vệ bằng mật khẩu.",
|
||||
"proxyAndVpnMutuallyExclusive": "Một hồ sơ chỉ có thể dùng proxy hoặc VPN, không dùng cả hai.",
|
||||
"invalidDnsRulesJson": "Tệp đã chọn không phải là JSON quy tắc DNS hợp lệ.",
|
||||
"unsupportedDnsRulesFormat": "Định dạng quy tắc không được hỗ trợ: {{format}}",
|
||||
"dnsRulesSaveFailed": "Không thể lưu quy tắc DNS.",
|
||||
"dnsRulesExportFailed": "Không thể xuất quy tắc DNS.",
|
||||
"fingerprintMatchFailed": "Không thể khớp vân tay với proxy."
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "Profile",
|
||||
@@ -1831,7 +1883,9 @@
|
||||
"importProfile": "Nhập profile",
|
||||
"importProfileHint": "Đưa profile từ công cụ khác",
|
||||
"keyboardShortcuts": "Phím tắt",
|
||||
"keyboardShortcutsHint": "Xem tất cả phím tắt"
|
||||
"keyboardShortcutsHint": "Xem tất cả phím tắt",
|
||||
"about": "Giới thiệu về Donut Browser",
|
||||
"aboutHint": "Phiên bản và thông tin ứng dụng"
|
||||
},
|
||||
"network": "Mạng",
|
||||
"integrations": "Tích hợp",
|
||||
@@ -1920,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "Khởi chạy {{name}}",
|
||||
"stopProfile": "Dừng {{name}}",
|
||||
"profileInfo": "Thông tin — {{name}}"
|
||||
"profileInfo": "Thông tin — {{name}}",
|
||||
"createProfile": "Tạo hồ sơ",
|
||||
"about": "Giới thiệu về Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2036,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "Chưa chọn profile Wayfern nào khác"
|
||||
},
|
||||
"about": {
|
||||
"title": "Giới thiệu",
|
||||
"version": "Phiên bản {{version}}",
|
||||
"portableBadge": "bản portable",
|
||||
"licenseNotice": "Trình duyệt chống phát hiện mã nguồn mở, được cấp phép theo AGPL-3.0.",
|
||||
"website": "Trang web"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "Xóa dữ liệu khi đóng",
|
||||
"description": "Xóa cookie, lịch sử và bộ nhớ đệm khi trình duyệt đóng. Tiện ích mở rộng và dấu trang được giữ lại."
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "Vân tay không khớp",
|
||||
"intro": "Điểm thoát proxy của \"{{name}}\" không khớp với vân tay của hồ sơ này:",
|
||||
"timezoneTitle": "Múi giờ không khớp",
|
||||
"timezoneDetail": "Nút thoát nằm ở {{exit}} nhưng vân tay báo là {{fingerprint}}.",
|
||||
"languageTitle": "Ngôn ngữ không khớp",
|
||||
"languageDetail": "Quốc gia thoát là {{country}} nhưng ngôn ngữ của vân tay là {{fingerprint}}.",
|
||||
"explainer": "Múi giờ hoặc ngôn ngữ không khớp với IP thoát là một tín hiệu chống bot rất mạnh, dù thiết bị thật của bạn không bao giờ bị lộ. Hãy căn chỉnh vân tay theo vị trí proxy để giảm bị đối xử khắt khe.",
|
||||
"dontWarnAgain": "Không cảnh báo lại cho hồ sơ này",
|
||||
"matchToProxy": "Khớp vân tay với proxy",
|
||||
"matching": "Đang khớp…",
|
||||
"matchSuccess": "Đã cập nhật vân tay để khớp với proxy. Khởi động lại hồ sơ để áp dụng."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,14 @@
|
||||
"disableAutoUpdates": "禁用应用自动更新",
|
||||
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
|
||||
"keepDecryptedProfilesInRam": "在内存中保留已解密的配置文件",
|
||||
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。"
|
||||
"keepDecryptedProfilesInRamDescription": "在启动之间保留密码保护配置文件的已解密内存副本,以便更快地启动。无论如何磁盘上的副本始终保持加密。",
|
||||
"privacy": {
|
||||
"consistencyWarning": "指纹一致性警告",
|
||||
"consistencyWarningDescription": "当配置文件的时区或语言与其代理出口节点不匹配时,在启动时发出警告。",
|
||||
"clearTraffic": "清除所有流量历史",
|
||||
"clearTrafficDescription": "安全清除所有配置文件的已记录流量统计数据。",
|
||||
"clearTrafficSuccess": "流量历史已清除"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "搜索配置文件...",
|
||||
@@ -245,7 +252,13 @@
|
||||
"cantModifyRunning": "无法修改正在运行的配置文件",
|
||||
"cantModifyLaunching": "启动期间无法修改配置文件",
|
||||
"cantModifyStopping": "停止期间无法修改配置文件",
|
||||
"cantModifyUpdating": "浏览器更新期间无法修改配置文件"
|
||||
"cantModifyUpdating": "浏览器更新期间无法修改配置文件",
|
||||
"emptyTitle": "暂无配置文件",
|
||||
"emptyHint": "创建您的第一个配置文件,或从其他浏览器导入现有配置文件。",
|
||||
"emptyCreate": "创建配置文件",
|
||||
"emptyImport": "导入配置文件",
|
||||
"emptyFilteredTitle": "未找到配置文件",
|
||||
"emptyFilteredHint": "没有符合此分组或搜索的配置文件。请尝试其他筛选条件或新建一个。"
|
||||
},
|
||||
"actions": {
|
||||
"launch": "启动",
|
||||
@@ -1294,7 +1307,28 @@
|
||||
"domains": "个域名",
|
||||
"fresh": "最新",
|
||||
"stale": "过期",
|
||||
"notCached": "未缓存"
|
||||
"notCached": "未缓存",
|
||||
"tabBlocklists": "拦截列表",
|
||||
"tabCustom": "自定义列表",
|
||||
"custom.description": "通过源 URL 和手动规则构建您自己的拦截列表。允许的域名始终优先于被拦截的域名。",
|
||||
"custom.sourcesLabel": "拦截列表源 URL",
|
||||
"custom.sourcesPlaceholder": "每行一个 URL",
|
||||
"custom.blockLabel": "拦截的域名",
|
||||
"custom.blockPlaceholder": "每行一个域名",
|
||||
"custom.allowLabel": "允许的域名",
|
||||
"custom.allowPlaceholder": "每行一个域名",
|
||||
"custom.allowHint": "允许的域名会从编译后的拦截列表中移除,并覆盖任何拦截规则。",
|
||||
"custom.saved": "自定义 DNS 规则已保存",
|
||||
"custom.imported": "自定义 DNS 规则已导入",
|
||||
"custom.exported": "自定义 DNS 规则已导出",
|
||||
"custom.exportTxt": "导出 TXT",
|
||||
"custom.exportJson": "导出 JSON",
|
||||
"customLevel": "自定义",
|
||||
"custom.allowlistModeLabel": "允许列表模式",
|
||||
"custom.allowlistModeOn": "仅可访问下方域名,其余全部拦截。",
|
||||
"custom.allowlistModeOff": "拦截列表中的域名,允许其余所有域名。",
|
||||
"custom.allowedOnlyLabel": "允许的域名(仅限这些)",
|
||||
"custom.allowedOnlyHint": "列表中域名的子域名也会被允许。列表为空时将禁用过滤。"
|
||||
},
|
||||
"vpns": {
|
||||
"form": {
|
||||
@@ -1415,7 +1449,11 @@
|
||||
"statusImported": "已导入",
|
||||
"statusSkipped": "已跳过",
|
||||
"statusFailed": "失败",
|
||||
"importButtonCount": "导入 ({{count}})"
|
||||
"importButtonCount": "导入 ({{count}})",
|
||||
"vpnOptional": "VPN(可选)",
|
||||
"noVpn": "不使用 VPN",
|
||||
"advancedOptions": "高级选项",
|
||||
"configureFingerprint": "配置指纹(可选)"
|
||||
},
|
||||
"syncTooltips": {
|
||||
"syncing": "同步中...",
|
||||
@@ -1618,7 +1656,14 @@
|
||||
"sentLegend": "已发送",
|
||||
"receivedLegend": "已接收",
|
||||
"tooltipSent": "↑ 已发送: ",
|
||||
"tooltipReceived": "↓ 已接收: "
|
||||
"tooltipReceived": "↓ 已接收: ",
|
||||
"clearHistory": "清除历史",
|
||||
"clearHistoryTitle": "清除流量历史",
|
||||
"clearHistoryDescription": "永久且安全地清除「{{name}}」的所有已记录流量历史。此操作无法撤销。",
|
||||
"tabOverview": "概览",
|
||||
"tabTopDomains": "热门域名",
|
||||
"searchDomains": "搜索域名…",
|
||||
"noDomainMatch": "没有与搜索匹配的域名。"
|
||||
},
|
||||
"proxyCheck": {
|
||||
"unknownLocation": "未知",
|
||||
@@ -1818,7 +1863,14 @@
|
||||
"importNoItems": "未选择要导入的内容",
|
||||
"browserNotDownloaded": "没有已下载的 {{browser}} 版本。请先下载,然后重试导入。",
|
||||
"archiveExtractionFailed": "解压压缩包失败:{{detail}}",
|
||||
"unsupportedArchiveFormat": "不支持的压缩包格式。仅支持 ZIP 压缩包。"
|
||||
"unsupportedArchiveFormat": "不支持的压缩包格式。仅支持 ZIP 压缩包。",
|
||||
"clearOnCloseUnavailable": "关闭时清除功能不适用于临时配置文件或受密码保护的配置文件。",
|
||||
"proxyAndVpnMutuallyExclusive": "配置文件只能使用代理或 VPN,不能同时使用两者。",
|
||||
"invalidDnsRulesJson": "所选文件不是有效的 DNS 规则 JSON。",
|
||||
"unsupportedDnsRulesFormat": "不支持的规则格式:{{format}}",
|
||||
"dnsRulesSaveFailed": "保存 DNS 规则失败。",
|
||||
"dnsRulesExportFailed": "导出 DNS 规则失败。",
|
||||
"fingerprintMatchFailed": "无法将指纹匹配到代理。"
|
||||
},
|
||||
"rail": {
|
||||
"profiles": "配置文件",
|
||||
@@ -1831,7 +1883,9 @@
|
||||
"importProfile": "导入配置文件",
|
||||
"importProfileHint": "从其他工具导入",
|
||||
"keyboardShortcuts": "键盘快捷键",
|
||||
"keyboardShortcutsHint": "查看所有快捷键"
|
||||
"keyboardShortcutsHint": "查看所有快捷键",
|
||||
"about": "关于 Donut Browser",
|
||||
"aboutHint": "版本和应用信息"
|
||||
},
|
||||
"network": "网络",
|
||||
"integrations": "集成",
|
||||
@@ -1920,7 +1974,9 @@
|
||||
"actions": {
|
||||
"launchProfile": "启动 {{name}}",
|
||||
"stopProfile": "停止 {{name}}",
|
||||
"profileInfo": "信息 — {{name}}"
|
||||
"profileInfo": "信息 — {{name}}",
|
||||
"createProfile": "创建配置文件",
|
||||
"about": "关于 Donut Browser"
|
||||
}
|
||||
},
|
||||
"shortcuts": {
|
||||
@@ -2036,5 +2092,29 @@
|
||||
},
|
||||
"cookieCopy": {
|
||||
"noOtherTargets": "未选择其他 Wayfern 配置文件"
|
||||
},
|
||||
"about": {
|
||||
"title": "关于",
|
||||
"version": "版本 {{version}}",
|
||||
"portableBadge": "便携版",
|
||||
"licenseNotice": "开源反检测浏览器,基于 AGPL-3.0 许可证发布。",
|
||||
"website": "官网"
|
||||
},
|
||||
"clearOnClose": {
|
||||
"label": "关闭时清除数据",
|
||||
"description": "浏览器关闭时清除 Cookie、历史记录和缓存。扩展和书签将被保留。"
|
||||
},
|
||||
"consistencyWarning": {
|
||||
"title": "指纹不匹配",
|
||||
"intro": "「{{name}}」的代理出口与此配置文件的指纹不匹配:",
|
||||
"timezoneTitle": "时区不匹配",
|
||||
"timezoneDetail": "出口节点位于 {{exit}},但指纹报告为 {{fingerprint}}。",
|
||||
"languageTitle": "语言不匹配",
|
||||
"languageDetail": "出口国家/地区为 {{country}},但指纹语言为 {{fingerprint}}。",
|
||||
"explainer": "时区或语言与出口 IP 不一致是强烈的反机器人信号,即使您的真实设备信息从未泄露。请让指纹与代理位置保持一致,以减少被针对的风险。",
|
||||
"dontWarnAgain": "不再为此配置文件发出警告",
|
||||
"matchToProxy": "将指纹匹配到代理",
|
||||
"matching": "匹配中…",
|
||||
"matchSuccess": "指纹已更新以匹配代理。重新启动配置文件以生效。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ export type BackendErrorCode =
|
||||
| "BROWSER_NOT_DOWNLOADED"
|
||||
| "ARCHIVE_EXTRACTION_FAILED"
|
||||
| "UNSUPPORTED_ARCHIVE_FORMAT"
|
||||
| "CLEAR_ON_CLOSE_UNAVAILABLE"
|
||||
| "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE"
|
||||
| "FINGERPRINT_MATCH_FAILED"
|
||||
| "INVALID_DNS_RULES_JSON"
|
||||
| "UNSUPPORTED_DNS_RULES_FORMAT"
|
||||
| "DNS_RULES_SAVE_FAILED"
|
||||
| "DNS_RULES_EXPORT_FAILED"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -181,6 +188,22 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
});
|
||||
case "UNSUPPORTED_ARCHIVE_FORMAT":
|
||||
return t("backendErrors.unsupportedArchiveFormat");
|
||||
case "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE":
|
||||
return t("backendErrors.proxyAndVpnMutuallyExclusive");
|
||||
case "FINGERPRINT_MATCH_FAILED":
|
||||
return t("backendErrors.fingerprintMatchFailed");
|
||||
case "INVALID_DNS_RULES_JSON":
|
||||
return t("backendErrors.invalidDnsRulesJson");
|
||||
case "UNSUPPORTED_DNS_RULES_FORMAT":
|
||||
return t("backendErrors.unsupportedDnsRulesFormat", {
|
||||
format: parsed.params?.format ?? "",
|
||||
});
|
||||
case "DNS_RULES_SAVE_FAILED":
|
||||
return t("backendErrors.dnsRulesSaveFailed");
|
||||
case "DNS_RULES_EXPORT_FAILED":
|
||||
return t("backendErrors.dnsRulesExportFailed");
|
||||
case "CLEAR_ON_CLOSE_UNAVAILABLE":
|
||||
return t("backendErrors.clearOnCloseUnavailable");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
/**
|
||||
* Donut-sprinkle confetti: small rounded bars tinted with the active theme's
|
||||
* chart colors. Used for celebration moments (e.g. a successful profile
|
||||
* import). Callers must skip it under prefers-reduced-motion.
|
||||
*/
|
||||
|
||||
// A 12×6 capsule — reads as a donut sprinkle at small scale.
|
||||
const SPRINKLE_PATH = "M3 0 h6 a3 3 0 0 1 0 6 h-6 a3 3 0 0 1 0 -6 z";
|
||||
|
||||
function themeChartColors(): string[] {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const colors = [1, 2, 3, 4, 5]
|
||||
.map((i) => styles.getPropertyValue(`--chart-${i}`).trim())
|
||||
.filter(Boolean);
|
||||
return colors.length > 0 ? colors : ["#888888"];
|
||||
}
|
||||
|
||||
export function fireSprinkleConfetti(): void {
|
||||
const sprinkle = confetti.shapeFromPath({ path: SPRINKLE_PATH });
|
||||
const colors = themeChartColors();
|
||||
|
||||
const fire = (particleCount: number, opts: confetti.Options = {}) => {
|
||||
void confetti({
|
||||
particleCount,
|
||||
spread: 75,
|
||||
startVelocity: 42,
|
||||
scalar: 0.9,
|
||||
ticks: 130,
|
||||
shapes: [sprinkle],
|
||||
colors,
|
||||
origin: { y: 0.65 },
|
||||
...opts,
|
||||
});
|
||||
};
|
||||
|
||||
fire(70);
|
||||
window.setTimeout(() => {
|
||||
fire(45, { angle: 60, origin: { x: 0.2, y: 0.7 } });
|
||||
}, 180);
|
||||
window.setTimeout(() => {
|
||||
fire(45, { angle: 120, origin: { x: 0.8, y: 0.7 } });
|
||||
}, 360);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The DNS blocklist levels the backend accepts, mirroring
|
||||
* `BlocklistLevel::as_str` in `src-tauri/src/dns_blocklist.rs`. Ordered from
|
||||
* least to most restrictive, with `custom` (the user's own sources/rules) last.
|
||||
*
|
||||
* Every level picker reads from this list. Keeping it in one place is what
|
||||
* stops a new level from reaching some surfaces and not others — `custom` was
|
||||
* previously missing from two pickers, so a profile already set to it rendered
|
||||
* with nothing selected and could not be restored.
|
||||
*
|
||||
* Labels are translation keys, never the backend's `display_name`: that field
|
||||
* is hardcoded English and renders untranslated to every locale.
|
||||
*/
|
||||
export const DNS_BLOCKLIST_LEVELS = [
|
||||
{ value: "light", labelKey: "dnsBlocklist.light" },
|
||||
{ value: "normal", labelKey: "dnsBlocklist.normal" },
|
||||
{ value: "pro", labelKey: "dnsBlocklist.pro" },
|
||||
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
|
||||
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
|
||||
{ value: "custom", labelKey: "dnsBlocklist.customLevel" },
|
||||
] as const;
|
||||
|
||||
export type DnsBlocklistLevel = (typeof DNS_BLOCKLIST_LEVELS)[number]["value"];
|
||||
|
||||
/**
|
||||
* Translation key for a level slug. A null/empty level means no filtering, and
|
||||
* an unrecognised one (a level added backend-first) falls back to the same,
|
||||
* which is the honest reading of "we don't know this level".
|
||||
*/
|
||||
export function dnsBlocklistLabelKey(level: string | null | undefined): string {
|
||||
if (!level) {
|
||||
return "dnsBlocklist.none";
|
||||
}
|
||||
return (
|
||||
DNS_BLOCKLIST_LEVELS.find((l) => l.value === level)?.labelKey ??
|
||||
"dnsBlocklist.none"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Shared physics for the Donut logo easter eggs: clones an element into a
|
||||
* fixed-position layer, drops it with gravity, bounces it off the floor and
|
||||
* right wall, and lets the user grab it (1:1, respecting the grab offset) and
|
||||
* throw it — the sim continues at the pointer's release velocity. Used by the
|
||||
* rail logo (5-click trigger) and the About dialog flywheel escape.
|
||||
*/
|
||||
|
||||
const GRAVITY = 2200;
|
||||
const BOUNCE_DAMPING = 0.6;
|
||||
const DEFAULT_HORIZONTAL_SPEED = 350;
|
||||
const DEFAULT_SPIN_SPEED = 720;
|
||||
const MIN_BOUNCE_VELOCITY = 60;
|
||||
|
||||
export interface DonutLaunchOptions {
|
||||
/** Initial horizontal velocity in px/s. Defaults to a rightward roll. */
|
||||
initialVX?: number;
|
||||
/** Initial vertical velocity in px/s (negative = upward). */
|
||||
initialVY?: number;
|
||||
/** Spin speed in deg/s. */
|
||||
spinSpeed?: number;
|
||||
/** Called once the clone has left the screen and been removed. */
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a physics clone of `el`. The source element is hidden (visibility)
|
||||
* and stays hidden — the caller decides when/if to restore it. Returns a
|
||||
* cancel function that removes the clone and stops the sim.
|
||||
*/
|
||||
export function launchDonutClone(
|
||||
el: HTMLElement,
|
||||
options: DonutLaunchOptions = {},
|
||||
): () => void {
|
||||
// getBoundingClientRect measures the *transformed* box. A caller that rotates
|
||||
// the element before launching (the About dialog spins it up to escape
|
||||
// velocity) would otherwise hand us the rotated bounding box: ~37% too large
|
||||
// at 45°, with an offset origin — so the clone jumps at launch and bounces off
|
||||
// the floor and walls early. Suppress the transform for the measurement to get
|
||||
// the layout box, then put it back.
|
||||
const previousTransform = el.style.transform;
|
||||
if (previousTransform) {
|
||||
el.style.transform = "none";
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (previousTransform) {
|
||||
el.style.transform = previousTransform;
|
||||
}
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.left = `${startX}px`;
|
||||
clone.style.top = `${startY}px`;
|
||||
clone.style.zIndex = "9999";
|
||||
clone.style.margin = "0";
|
||||
// The fallen donut is a toy: it can be grabbed 1:1 and thrown, inheriting
|
||||
// the pointer's release velocity.
|
||||
clone.style.pointerEvents = "auto";
|
||||
clone.style.cursor = "grab";
|
||||
clone.style.touchAction = "none";
|
||||
document.body.appendChild(clone);
|
||||
el.style.visibility = "hidden";
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vy = options.initialVY ?? -500;
|
||||
// Roll right first, bounce off the right wall, then escape the left.
|
||||
let vx = options.initialVX ?? DEFAULT_HORIZONTAL_SPEED;
|
||||
const spinSpeed = options.spinSpeed ?? DEFAULT_SPIN_SPEED;
|
||||
let rotation = 0;
|
||||
let lastTime = performance.now();
|
||||
let grabbed = false;
|
||||
let grabDX = 0;
|
||||
let grabDY = 0;
|
||||
let animFrame = 0;
|
||||
let cancelled = false;
|
||||
// Recent pointer positions (≤100ms) for release-velocity estimation.
|
||||
let history: { t: number; x: number; y: number }[] = [];
|
||||
|
||||
// Progressive resistance past a window edge — follows less the further out.
|
||||
const rubberband = (overshoot: number, dimension: number, c = 0.55) =>
|
||||
(overshoot * dimension * c) / (dimension + c * Math.abs(overshoot));
|
||||
|
||||
const applyTransform = () => {
|
||||
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (grabbed) return;
|
||||
grabbed = true;
|
||||
clone.setPointerCapture(e.pointerId);
|
||||
clone.style.cursor = "grabbing";
|
||||
// Respect where the donut was grabbed — no snap to center.
|
||||
grabDX = e.clientX - (startX + x);
|
||||
grabDY = e.clientY - (startY + y);
|
||||
vx = 0;
|
||||
vy = 0;
|
||||
history = [{ t: performance.now(), x, y }];
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!grabbed) return;
|
||||
let nx = e.clientX - grabDX - startX;
|
||||
let ny = e.clientY - grabDY - startY;
|
||||
const minX = -startX;
|
||||
const maxX = window.innerWidth - rect.width - startX;
|
||||
const minY = -startY;
|
||||
const maxY = window.innerHeight - rect.height - startY;
|
||||
if (nx > maxX) nx = maxX + rubberband(nx - maxX, rect.width);
|
||||
if (nx < minX) nx = minX + rubberband(nx - minX, rect.width);
|
||||
if (ny > maxY) ny = maxY + rubberband(ny - maxY, rect.height);
|
||||
if (ny < minY) ny = minY + rubberband(ny - minY, rect.height);
|
||||
x = nx;
|
||||
y = ny;
|
||||
const now = performance.now();
|
||||
history.push({ t: now, x, y });
|
||||
while (history.length > 1 && now - history[0].t > 100) history.shift();
|
||||
applyTransform();
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (!grabbed) return;
|
||||
grabbed = false;
|
||||
clone.style.cursor = "grab";
|
||||
try {
|
||||
clone.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// capture already gone
|
||||
}
|
||||
// Velocity handoff: the sim continues at the finger's speed so a flick
|
||||
// throws the donut instead of dropping it.
|
||||
const now = performance.now();
|
||||
const oldest = history[0];
|
||||
const dt = oldest ? (now - oldest.t) / 1000 : 0;
|
||||
if (oldest && dt > 0.016) {
|
||||
vx = (x - oldest.x) / dt;
|
||||
vy = (y - oldest.y) / dt;
|
||||
}
|
||||
history = [];
|
||||
lastTime = now;
|
||||
};
|
||||
|
||||
clone.addEventListener("pointerdown", onPointerDown);
|
||||
clone.addEventListener("pointermove", onPointerMove);
|
||||
clone.addEventListener("pointerup", onPointerUp);
|
||||
clone.addEventListener("pointercancel", onPointerUp);
|
||||
|
||||
const animate = (time: number) => {
|
||||
if (cancelled) return;
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
|
||||
// Read live so a mid-animation window resize moves the floor/wall.
|
||||
const floorY = window.innerHeight;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
if (!grabbed) {
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
rotation += spinSpeed * dt * (vx > 0 ? 1 : -1);
|
||||
|
||||
const currentBottom = startY + y + rect.height;
|
||||
if (currentBottom >= floorY && vy > 0) {
|
||||
y = floorY - startY - rect.height;
|
||||
vy =
|
||||
Math.abs(vy) > MIN_BOUNCE_VELOCITY
|
||||
? -Math.abs(vy) * BOUNCE_DAMPING
|
||||
: -MIN_BOUNCE_VELOCITY * 3;
|
||||
// A donut dropped with no sideways speed would hop in place forever —
|
||||
// nudge it toward its left-edge exit.
|
||||
if (Math.abs(vx) < 40) {
|
||||
vx = -DEFAULT_HORIZONTAL_SPEED * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Right-wall bounce: hit, reverse horizontal velocity (with a tiny
|
||||
// damping), and keep rolling. Left wall has no bounce — the donut
|
||||
// exits the window off the left edge.
|
||||
const currentRight = startX + x + rect.width;
|
||||
if (currentRight >= rightWall && vx > 0) {
|
||||
x = rightWall - startX - rect.width;
|
||||
vx = -Math.abs(vx) * 0.9;
|
||||
}
|
||||
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
const offScreenLeft = startX + x + rect.width < -200;
|
||||
const offScreenBottom = startY + y > floorY + 100;
|
||||
const offScreenTop = startY + y + rect.height < -200;
|
||||
|
||||
if (!grabbed && (offScreenLeft || offScreenBottom || offScreenTop)) {
|
||||
clone.remove();
|
||||
options.onExit?.();
|
||||
return;
|
||||
}
|
||||
animFrame = requestAnimationFrame(animate);
|
||||
};
|
||||
animFrame = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(animFrame);
|
||||
clone.remove();
|
||||
};
|
||||
}
|
||||
@@ -98,11 +98,14 @@ export const SHORTCUTS: ShortcutDef[] = [
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
// Mod+Shift+A (not Mod+A): plain Mod+A must stay select-all in any
|
||||
// focused text field or table context.
|
||||
id: "goAccount",
|
||||
labelKey: "shortcuts.goAccount",
|
||||
group: "navigation",
|
||||
key: "a",
|
||||
mod: true,
|
||||
shift: true,
|
||||
},
|
||||
{
|
||||
id: "goSettings",
|
||||
|
||||
@@ -760,3 +760,27 @@ export function clearThemeColors(): void {
|
||||
root.style.removeProperty(key as string);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a theme mutation inside a View Transition so the whole UI cross-fades
|
||||
* (~200ms, tuned in globals.css) instead of hard-cutting between palettes.
|
||||
* Falls back to an instant switch when the API is unavailable or the user
|
||||
* prefers reduced motion.
|
||||
*/
|
||||
export function withThemeTransition(mutate: () => void): void {
|
||||
if (typeof document === "undefined") {
|
||||
mutate();
|
||||
return;
|
||||
}
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const doc = document as Document & {
|
||||
startViewTransition?: (callback: () => void) => unknown;
|
||||
};
|
||||
if (reduced || typeof doc.startViewTransition !== "function") {
|
||||
mutate();
|
||||
return;
|
||||
}
|
||||
doc.startViewTransition(mutate);
|
||||
}
|
||||
|
||||
@@ -261,3 +261,51 @@
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme switches run inside a View Transition (see themes.ts
|
||||
withThemeTransition) — a short whole-page cross-fade instead of an abrupt
|
||||
palette jump. */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 200ms;
|
||||
}
|
||||
|
||||
/* Translucent material for floating chrome (modals, popovers, menus).
|
||||
Uses theme variables so it adapts to every theme; falls back to the solid
|
||||
surface when the user prefers reduced transparency. Never stack two
|
||||
material surfaces on top of each other. */
|
||||
.surface-material {
|
||||
background-color: color-mix(in oklab, var(--background) 85%, transparent);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
}
|
||||
|
||||
.surface-material-popover {
|
||||
background-color: color-mix(in oklab, var(--popover) 85%, transparent);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
}
|
||||
|
||||
.surface-material-card {
|
||||
background-color: color-mix(in oklab, var(--card) 85%, transparent);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.4);
|
||||
backdrop-filter: blur(20px) saturate(1.4);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.surface-material {
|
||||
background-color: var(--background);
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.surface-material-popover {
|
||||
background-color: var(--popover);
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.surface-material-card {
|
||||
background-color: var(--card);
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface BrowserProfile {
|
||||
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
|
||||
host_os?: string; // OS where profile was created ("macos", "windows", "linux")
|
||||
ephemeral?: boolean;
|
||||
clear_on_close?: boolean;
|
||||
extension_group_id?: string;
|
||||
proxy_bypass_rules?: string[];
|
||||
created_by_id?: string;
|
||||
@@ -201,7 +202,9 @@ export interface ImportProfileItem {
|
||||
source_path: string;
|
||||
browser_type?: string;
|
||||
new_profile_name: string;
|
||||
/** Mutually exclusive with `vpn_id`; the importer rejects setting both. */
|
||||
proxy_id?: string | null;
|
||||
vpn_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ProfileImportItemResult {
|
||||
|
||||
Reference in New Issue
Block a user