feat: add tips

This commit is contained in:
zhom
2026-09-09 06:16:14 -07:00
parent 3a0e5a41c0
commit a01901ef07
83 changed files with 8032 additions and 2732 deletions
+95 -9
View File
@@ -28,6 +28,7 @@ import HomeHeader from "@/components/home-header";
import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { IntegrationsDialog } from "@/components/integrations-dialog";
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
import { PaidWelcomeDialog } from "@/components/paid-welcome-dialog";
import { PermissionDialog } from "@/components/permission-dialog";
import {
type GateDecision,
@@ -53,6 +54,7 @@ import { SyncConfigDialog } from "@/components/sync-config-dialog";
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
import { SynchronizerPanel } from "@/components/synchronizer-panel";
import { ThankYouDialog } from "@/components/thank-you-dialog";
import { TipsDialog } from "@/components/tips-dialog";
import { TrashPage } from "@/components/trash-page";
import { WayfernConfigDialog } from "@/components/wayfern-config-dialog";
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
@@ -69,6 +71,7 @@ import { usePermissions } from "@/hooks/use-permissions";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useSyncSessions } from "@/hooks/use-sync-session";
import { useTips } from "@/hooks/use-tips";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { useVpnEvents } from "@/hooks/use-vpn-events";
@@ -96,6 +99,7 @@ import {
SHORTCUTS,
type ShortcutId,
} from "@/lib/shortcuts";
import type { TipAction } from "@/lib/tips";
import {
dismissToast,
showErrorToast,
@@ -340,8 +344,31 @@ export default function Home() {
} = useCommercialTrial();
// Cloud auth for cross-OS unlock
const { user: cloudUser } = useCloudAuth();
const { user: cloudUser, loggedInAt: cloudLoggedInAt } = useCloudAuth();
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
// Shown once when the commercial trial runs out; modal, so it goes first.
const commercialTrialModalOpen =
!termsLoading &&
termsAccepted === true &&
trialStatus?.type === "Expired" &&
!trialAcknowledged &&
!crossOsUnlocked;
// Feature tips and the paid-plan welcome wait for a settled app: not the
// first-run session, terms accepted, nothing modal in the way.
const tipsFlow = useTips({
cloudUser,
loggedInAt: cloudLoggedInAt,
ready:
firstRunOnboarding === false &&
!profilesLoading &&
!welcomeOpen &&
!thankYouOpen &&
!isOnbordaVisible &&
!termsLoading &&
termsAccepted === true &&
!commercialTrialModalOpen,
});
const { openTips, closeTips } = tipsFlow;
// Bulk run/stop is a paid (browser automation) feature, matching the
// /v1/profiles/batch/run API gate. Free/solo users see the bulk Run/Stop
// actions disabled with a Pro badge.
@@ -396,6 +423,10 @@ export default function Home() {
const [agentInitialTab, setAgentInitialTab] = useState<AgentTab>("run");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
// A settings section to land on, set by a tip's action for one opening.
const [settingsInitialSection, setSettingsInitialSection] = useState<
string | null
>(null);
const [trashPageOpen, setTrashPageOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
@@ -531,6 +562,7 @@ export default function Home() {
setCookieBotDialogOpen(false);
setAgentDialogOpen(false);
setTrashPageOpen(false);
setSettingsInitialSection(null);
setCurrentPage(page);
switch (page) {
@@ -579,12 +611,36 @@ export default function Home() {
}
}, []);
const runTipAction = useCallback(
(action: TipAction) => {
closeTips();
switch (action.kind) {
case "page":
handleRailNavigate(action.page);
break;
case "settings":
// The navigation clears the section; setting it afterwards in the
// same batch is what makes it win.
handleRailNavigate("settings");
setSettingsInitialSection(action.section);
break;
case "palette":
setCommandPaletteOpen(true);
break;
}
},
[closeTips, handleRailNavigate],
);
const runShortcut = useCallback(
(id: ShortcutId) => {
switch (id) {
case "openPalette":
setCommandPaletteOpen(true);
break;
case "openTips":
openTips();
break;
case "openShortcuts":
handleRailNavigate("shortcuts");
break;
@@ -675,7 +731,13 @@ export default function Home() {
break;
}
},
[handleRailNavigate, currentPage, proxyManagementInitialTab, cloudUser],
[
handleRailNavigate,
currentPage,
proxyManagementInitialTab,
cloudUser,
openTips,
],
);
// Ordered list the digit shortcuts and palette consume. "__all__" is index 1
@@ -2216,6 +2278,7 @@ export default function Home() {
onOpenAbout={() => {
setAboutDialogOpen(true);
}}
onOpenTips={() => openTips()}
cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -2303,6 +2366,7 @@ export default function Home() {
setCurrentPage("integrations");
}}
subPage={currentPage === "settings"}
initialSection={settingsInitialSection}
/>
)}
@@ -2516,6 +2580,34 @@ export default function Home() {
isOpen={thankYouOpen}
onClose={() => setThankYouOpen(false)}
/>
<TipsDialog
key={tipsFlow.dialog.session}
open={tipsFlow.dialog.open}
mode={tipsFlow.dialog.mode}
tips={tipsFlow.tips}
seen={tipsFlow.seen}
initialTipId={tipsFlow.dialog.initialTipId}
auto={tipsFlow.dialog.auto}
autoShow={tipsFlow.autoShow}
onOpenChange={(open) => {
if (!open) closeTips();
}}
onTipShown={tipsFlow.markSeen}
onAutoShowChange={(enabled) => void tipsFlow.setAutoShow(enabled)}
onAction={runTipAction}
/>
<PaidWelcomeDialog
open={tipsFlow.paidWelcome.open}
plan={tipsFlow.paidWelcome.plan}
tips={tipsFlow.planTips}
onOpenChange={(open) => {
if (!open) tipsFlow.dismissPaidWelcome();
}}
onOpenTip={(id) => {
tipsFlow.dismissPaidWelcome();
openTips(id);
}}
/>
<CloneProfileDialog
isOpen={!!cloneProfile}
@@ -2768,13 +2860,7 @@ export default function Home() {
{/* Commercial Trial Modal - shown once when trial expires (skip for paid users) */}
<CommercialTrialModal
isOpen={
!termsLoading &&
termsAccepted === true &&
trialStatus?.type === "Expired" &&
!trialAcknowledged &&
!crossOsUnlocked
}
isOpen={commercialTrialModalOpen}
onClose={checkTrialStatus}
/>
+2
View File
@@ -12,6 +12,7 @@ import {
LuCookie,
LuInfo,
LuKeyboard,
LuLightbulb,
LuPlay,
LuPlug,
LuPlus,
@@ -65,6 +66,7 @@ interface CommandPaletteProps {
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
openPalette: LuKeyboard,
openShortcuts: LuKeyboard,
openTips: LuLightbulb,
importProfile: FaDownload,
goProfiles: LuUser,
goProxies: FiWifi,
+8 -2
View File
@@ -192,6 +192,10 @@ export function ImportProfileDialog({
const [isImporting, setIsImporting] = useState(false);
const [progress, setProgress] = useState<ProfileImportProgress | null>(null);
const activeImportItems = useRef<ImportProfileItem[]>([]);
// Each run's summary toast gets its own id. Re-using one id after
// dismissing it merges the new toast into the one still sliding out, and
// a fast retry's summary was never seen.
const resultsToastId = useRef<string | null>(null);
const [sourceProgress, setSourceProgress] = useState<
Record<string, ProfileImportProgress["status"]>
>({});
@@ -404,7 +408,7 @@ export function ImportProfileDialog({
setCurrentStep("importing");
setIsImporting(true);
setProgress(null);
toast.dismiss("profile-import-results");
if (resultsToastId.current) toast.dismiss(resultsToastId.current);
// A retry covers only the failed subset, so the earlier results are still
// the truth for everything else and must not be thrown away.
const previous = retryPaths ? result : null;
@@ -435,13 +439,14 @@ export function ImportProfileDialog({
? toast.warning
: toast.error
: toast.success;
resultsToastId.current = `profile-import-results-${Date.now()}`;
notify(
t("importProfile.resultsSummary", {
imported: combined.imported_count,
skipped: combined.skipped_count,
failed: combined.failed_count,
}),
{ id: "profile-import-results" },
{ id: resultsToastId.current },
);
if (
batchResult.imported_count > 0 &&
@@ -977,6 +982,7 @@ export function ImportProfileDialog({
<OperationFlow
label={t("importProfile.importingTitle")}
active={isImporting ? 1 : 2}
busy={isImporting}
failed={!isImporting && !!result?.failed_count}
steps={[
{
+18 -2
View File
@@ -72,10 +72,26 @@ export function IntegrationDiagnostics({
</div>
<OperationFlow
label={t("appFeedback.connectionTest")}
active={busy ? 1 : result?.authorized ? 2 : result?.reachable ? 1 : 0}
// The station the probe stopped at: nothing configured stops at
// "configured", unreachable at "reachable", a refused token at
// "authorized".
active={
busy
? 1
: !result
? 0
: !result.configured
? 0
: result.reachable === false
? 1
: 2
}
busy={busy}
failed={
!!result &&
(result.reachable === false || result.authorized === false)
(!result.configured ||
result.reachable === false ||
result.authorized === false)
}
steps={[
{
+157
View File
@@ -0,0 +1,157 @@
"use client";
import confetti from "canvas-confetti";
import { motion, useReducedMotion } from "motion/react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { LuChevronRight } from "react-icons/lu";
import { Logo } from "@/components/icons/logo";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useInputModality } from "@/hooks/use-input-modality";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { isMacOS } from "@/lib/platform";
import { type TipDefinition, type TipId, tipTextKeys } from "@/lib/tips";
const spring = { type: "spring", stiffness: 240, damping: 22 } as const;
/** "pro" reads as "Pro" in the title; an unknown plan keeps its own spelling. */
function displayPlan(plan: string): string {
return plan ? plan.charAt(0).toLocaleUpperCase() + plan.slice(1) : plan;
}
/**
* The first thing a freshly paid account sees: what the plan unlocked, each
* item opening the tip that shows it working. Shown once per account.
*/
export function PaidWelcomeDialog({
open,
plan,
tips,
onOpenChange,
onOpenTip,
}: {
open: boolean;
plan: string;
/** The plan tips this account is entitled to, in catalog order. */
tips: TipDefinition[];
onOpenChange: (open: boolean) => void;
onOpenTip: (id: TipId) => void;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const modality = useInputModality();
const animate = !reduceMotion && modality === "pointer";
const mod = isMacOS() ? "⌘" : "Ctrl";
useEffect(() => {
if (!open || reduceMotion || document.hidden) return;
const fire = (options: confetti.Options) => {
if (document.hidden) return;
void confetti({
origin: { y: 0.65 },
disableForReducedMotion: true,
...options,
});
};
fire({ particleCount: 80, spread: 66, startVelocity: 42 });
const second = window.setTimeout(
() => fire({ particleCount: 40, spread: 100, decay: 0.92 }),
220,
);
return () => window.clearTimeout(second);
}, [open, reduceMotion]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-slot="paid-welcome"
className="p-5 sm:max-w-md sm:p-6"
>
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-col items-center gap-3 text-center">
<motion.div
initial={animate ? { scale: 0.92, rotate: -6 } : false}
animate={{ scale: 1, rotate: 0 }}
transition={animate ? spring : { duration: 0 }}
className="text-foreground"
>
<Logo className="size-12" />
</motion.div>
<DialogTitle className="text-2xl font-semibold tracking-tight text-balance">
{t("paidWelcome.title", { plan: displayPlan(plan) })}
</DialogTitle>
<p className="max-w-[40ch] text-sm/6 text-pretty text-muted-foreground">
{t("paidWelcome.body")}
</p>
</div>
{tips.length > 0 && (
<ul
data-slot="paid-welcome-list"
className="flex min-w-0 flex-col gap-0.5"
>
{tips.map((tip, index) => {
const keys = tipTextKeys(tip.id);
return (
<li key={tip.id} className="min-w-0">
<motion.button
type="button"
data-slot="paid-welcome-item"
data-tip-id={tip.id}
onClick={() => onOpenTip(tip.id)}
initial={animate ? { y: 8 } : false}
animate={{ y: 0 }}
transition={{
delay: animate ? 0.05 * index : 0,
duration: animate ? 0.3 : 0,
ease: MOTION_EASE_OUT,
}}
className="flex w-full min-w-0 cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">
{t(keys.title, { mod })}
</span>
<span className="truncate text-xs text-muted-foreground">
{t(keys.body, { mod })}
</span>
</span>
<LuChevronRight
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
</motion.button>
</li>
);
})}
</ul>
)}
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onOpenChange(false)}
>
{t("paidWelcome.later")}
</Button>
<Button
type="button"
size="sm"
data-slot="paid-welcome-cta"
disabled={tips.length === 0}
onClick={() => {
if (tips[0]) onOpenTip(tips[0].id);
}}
>
{t("paidWelcome.cta")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -45,6 +45,7 @@ export function ProfileLaunchActivity({
<OperationFlow
label={t("appFeedback.launchActivity")}
active={active}
busy={!terminal}
failed={current.stage === "failed"}
steps={[
{
+82 -1
View File
@@ -23,6 +23,7 @@ import {
import { translateBackendError } from "@/lib/backend-errors";
import { formatRelativeTime } from "@/lib/flag-utils";
import { runProxyCheck, useProxyCheck } from "@/lib/proxy-check-store";
import { cn } from "@/lib/utils";
import type { ProxyCheckHistoryEntry, StoredProxy, UdpSupport } from "@/types";
const COPIED_MARK_MS = 1600;
@@ -74,6 +75,83 @@ export function ProxyUdpBadge({ proxy }: { proxy: StoredProxy }) {
);
}
/** How many remembered checks the strip draws; the list below keeps them all. */
const TREND_BARS = 40;
/**
* The remembered checks as one thin bar per check, oldest on the left, so a
* proxy that is slowing down or starting to fail shows as a shape before
* anyone reads a line. A failed check has no latency and carries a cross at
* the baseline instead of a bar; the newest check is the dark one.
*/
function LatencyStrip({ history }: { history: ProxyCheckHistoryEntry[] }) {
const { t } = useTranslation();
const entries = history.slice(0, TREND_BARS).reverse();
const peak = entries.reduce(
(max, entry) =>
entry.ok && typeof entry.latency_ms === "number"
? Math.max(max, entry.latency_ms)
: max,
0,
);
if (entries.length < 2 || peak === 0) return null;
return (
<div data-slot="proxy-check-trend" className="space-y-1">
<div
role="img"
aria-label={t("proxyCheck.trendLabel", { count: entries.length })}
className="flex h-10 items-end gap-0.5"
>
{entries.map((entry, index) => {
const latest = index === entries.length - 1;
const ms =
entry.ok && typeof entry.latency_ms === "number"
? entry.latency_ms
: null;
return (
<Tooltip key={`${entry.timestamp}-${index}`}>
<TooltipTrigger asChild>
<span
data-slot="proxy-check-bar"
data-ok={entry.ok}
className="flex h-full w-1.5 flex-col justify-end"
>
{ms === null ? (
<FiX
aria-hidden="true"
className="size-1.5 shrink-0 text-destructive-text"
/>
) : (
<span
className={cn(
"w-full rounded-t-[3px]",
latest ? "bg-foreground" : "bg-muted-foreground/45",
)}
style={{ height: `${Math.max(8, (ms / peak) * 100)}%` }}
/>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{ms === null
? t("proxyCheck.historyFailed")
: t("proxyCheck.latencyValue", { ms })}
{" · "}
{formatRelativeTime(entry.timestamp)}
</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
<p className="text-[11px] text-muted-foreground tabular-nums">
{t("proxyCheck.trendPeak", { ms: peak })}
</p>
</div>
);
}
/** One remembered check, as a single line. */
function HistoryRow({ entry }: { entry: ProxyCheckHistoryEntry }) {
const { t } = useTranslation();
@@ -302,7 +380,9 @@ export function ProxyCheckButton({
<h3 className="break-words text-sm font-medium">{proxy.name}</h3>
<OperationFlow
label={t("appFeedback.routeDetails")}
active={checking ? 1 : result?.is_valid ? 2 : 0}
// A failed check stops at the proxy: the device end is fine.
active={checking ? 1 : result?.is_valid ? 2 : result ? 1 : 0}
busy={checking}
failed={!checking && !!result && !result.is_valid}
steps={[
{
@@ -419,6 +499,7 @@ export function ProxyCheckButton({
<h4 className="font-medium text-foreground">
{t("proxyCheck.historyTitle")}
</h4>
{history && <LatencyStrip history={history} />}
{history && history.length > 0 ? (
<ul className="max-h-48 divide-y divide-border overflow-y-auto">
{history.map((entry, index) => (
+26
View File
@@ -12,6 +12,7 @@ import {
LuCookie,
LuInfo,
LuKeyboard,
LuLightbulb,
LuPlug,
LuPuzzle,
LuTrash2,
@@ -218,6 +219,8 @@ interface RailNavProps {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
onOpenAbout: () => void;
/** Opens the feature tips catalog. */
onOpenTips: () => void;
/**
* A remote session is running right now. The Cookie Bot item carries a dot so
* the state is legible from every other page — an overnight job you cannot
@@ -291,6 +294,7 @@ export function RailNav({
currentPage,
onNavigate,
onOpenAbout,
onOpenTips,
cookieBotRunning = false,
}: RailNavProps) {
const { t } = useTranslation();
@@ -496,6 +500,28 @@ export function RailNav({
</span>
</button>
))}
<button
type="button"
role="menuitem"
data-slot="rail-open-tips"
onClick={() => {
setMoreOpen(false);
onOpenTips();
}}
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 hover:text-accent-foreground"
>
<span className="grid size-5 shrink-0 place-items-center text-muted-foreground">
<LuLightbulb className="size-3" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium text-foreground">
{t("rail.more.tips")}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{t("rail.more.tipsHint")}
</span>
</span>
</button>
<button
type="button"
role="menuitem"
+104 -15
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
import { openUrl } from "@tauri-apps/plugin-opener";
import Color from "color";
import { motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCamera, BsMic } from "react-icons/bs";
@@ -49,11 +50,13 @@ import {
} from "@/components/ui/select";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
import { useInputModality } from "@/hooks/use-input-modality";
import { useLanguage } from "@/hooks/use-language";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { translateBackendError } from "@/lib/backend-errors";
import { effectivePlanOf } from "@/lib/entitlements";
import { MOTION_SPRING_POSITION } from "@/lib/motion";
import {
applyThemeColors,
clearThemeColors,
@@ -111,6 +114,8 @@ interface SettingsDialogProps {
onClose: () => void;
onIntegrationsOpen?: () => void;
subPage?: boolean;
/** A section to scroll to and focus as soon as the page opens. */
initialSection?: string | null;
}
export function SettingsDialog({
@@ -118,6 +123,7 @@ export function SettingsDialog({
onClose,
onIntegrationsOpen,
subPage,
initialSection = null,
}: SettingsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
set_as_default_browser: false,
@@ -145,8 +151,19 @@ export function SettingsDialog({
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [search, setSearch] = useState("");
const [jumpTo, setJumpTo] = useState<string | null>(null);
const [jumpTo, setJumpTo] = useState<string | null>(initialSection);
const sectionsRef = useRef<HTMLFieldSetElement>(null);
const scrollerRef = useRef<HTMLDivElement>(null);
// The section the reader is in, so the nav can point at it. A click pins
// its target until the reader scrolls away, because the last sections can
// never reach the top of the scroller on their own.
const [activeSection, setActiveSection] = useState<string | null>(null);
const pinnedSectionRef = useRef<{ id: string; scrollTop: number } | null>(
null,
);
const reduceMotion = useReducedMotion();
const inputModality = useInputModality();
const animateNav = !reduceMotion && inputModality === "pointer";
const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const [isClearingTraffic, setIsClearingTraffic] = useState(false);
@@ -884,6 +901,44 @@ export function SettingsDialog({
});
const sectionVisible = (id: string) =>
matchingSections.some(([key]) => key === id);
const visibleSectionIds = matchingSections.map(([id]) => id).join(",");
const syncActiveSection = useCallback(() => {
const scroller = scrollerRef.current;
const root = sectionsRef.current;
if (!scroller || !root) return;
const nodes = [
...root.querySelectorAll<HTMLElement>("[data-settings-section]"),
].filter((node) => !node.hidden);
if (nodes.length === 0) {
setActiveSection(null);
return;
}
const pinned = pinnedSectionRef.current;
if (pinned && Math.abs(scroller.scrollTop - pinned.scrollTop) < 4) {
setActiveSection(pinned.id);
return;
}
pinnedSectionRef.current = null;
const atBottom =
scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
if (atBottom) {
setActiveSection(nodes[nodes.length - 1].dataset.settingsSection ?? null);
return;
}
const threshold = scroller.getBoundingClientRect().top + 12;
let current = nodes[0];
for (const node of nodes) {
if (node.getBoundingClientRect().top <= threshold) current = node;
else break;
}
setActiveSection(current.dataset.settingsSection ?? null);
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: the section list changes with the search filter and the settings load, and the sync must run again then
useEffect(() => {
syncActiveSection();
}, [syncActiveSection, visibleSectionIds, isLoading]);
useEffect(() => {
if (!jumpTo) return;
@@ -892,6 +947,13 @@ export function SettingsDialog({
);
section?.scrollIntoView({ block: "start" });
section?.focus({ preventScroll: true });
if (scrollerRef.current) {
pinnedSectionRef.current = {
id: jumpTo,
scrollTop: scrollerRef.current.scrollTop,
};
}
setActiveSection(jumpTo);
setJumpTo(null);
}, [jumpTo]);
@@ -924,21 +986,46 @@ export function SettingsDialog({
/>
<nav
aria-label={t("settings.title")}
className="flex flex-wrap gap-x-3 gap-y-1"
className="flex flex-wrap gap-x-1 gap-y-1"
>
{sections.map(([id, label]) => (
<button
type="button"
key={id}
onClick={() => {
setSearch("");
setJumpTo(id);
}}
className="rounded-sm py-1 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
{t(label)}
</button>
))}
{sections.map(([id, label]) => {
const active = activeSection === id;
return (
<button
type="button"
key={id}
data-slot="settings-nav-item"
data-section={id}
aria-current={active ? "location" : undefined}
onClick={() => {
setSearch("");
setJumpTo(id);
}}
className={cn(
"relative isolate rounded-md px-2 py-1 text-xs transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
active
? "text-accent-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
aria-hidden="true"
data-slot="settings-nav-indicator"
layoutId={
animateNav ? "settings-nav-indicator" : undefined
}
initial={false}
transition={
animateNav ? MOTION_SPRING_POSITION : { duration: 0 }
}
className="absolute inset-0 -z-10 rounded-md bg-accent"
/>
)}
{t(label)}
</button>
);
})}
</nav>
</div>
@@ -946,6 +1033,8 @@ export function SettingsDialog({
side gutters); the width cap lives on the inner column. Fusing
them was the dead-wheel-zone bug. */}
<div
ref={scrollerRef}
onScroll={syncActiveSection}
className={cn(
"min-h-0 flex-1 overflow-y-auto",
subPage ? "py-2" : "py-4",
+322
View File
@@ -0,0 +1,322 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
import { TipScene } from "@/components/tips/scene-for";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useInputModality } from "@/hooks/use-input-modality";
import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion";
import { isMacOS } from "@/lib/platform";
import {
isPlanTip,
type TipAction,
type TipDefinition,
type TipId,
tipTextKeys,
} from "@/lib/tips";
import { cn } from "@/lib/utils";
export type TipsDialogMode = "browse" | "single";
interface TipsDialogProps {
open: boolean;
/** `browse` shows the whole catalog beside the tip; `single` is one card. */
mode: TipsDialogMode;
tips: TipDefinition[];
seen: string[];
initialTipId: TipId | null;
/** True when the automatic flow opened the dialog, not the user. */
auto: boolean;
autoShow: boolean;
onOpenChange: (open: boolean) => void;
onTipShown: (id: TipId, auto: boolean) => void;
onAutoShowChange: (enabled: boolean) => void;
onAction: (action: TipAction) => void;
}
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
);
}
/**
* Feature tips: a drawing of the feature in motion, a few lines on what it
* does for the user, and a button into the place it lives. The catalog on
* the left is only there in browse mode; the automatic flow is one card.
*/
export function TipsDialog({
open,
mode,
tips,
seen,
initialTipId,
auto,
autoShow,
onOpenChange,
onTipShown,
onAutoShowChange,
onAction,
}: TipsDialogProps) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const modality = useInputModality();
const animate = !reduceMotion && modality === "pointer";
const listId = useId();
const autoShowId = useId();
const total = tips.length;
const initialIndex = Math.max(
0,
initialTipId ? tips.findIndex((tip) => tip.id === initialTipId) : 0,
);
const [index, setIndex] = useState(initialIndex);
const [direction, setDirection] = useState<1 | -1>(1);
const tip = tips[Math.min(index, Math.max(0, total - 1))];
const mod = isMacOS() ? "⌘" : "Ctrl";
// Each tip is reported once as it comes on screen, so the automatic flow
// never repeats one and the catalog can tell seen from new.
const reportedRef = useRef<string | null>(null);
useEffect(() => {
if (!open || !tip || reportedRef.current === tip.id) return;
reportedRef.current = tip.id;
onTipShown(tip.id, auto && tip.id === initialTipId);
}, [open, tip, auto, initialTipId, onTipShown]);
if (!tip) return null;
const keys = tipTextKeys(tip.id);
const isLast = index >= total - 1;
const go = (next: number) => {
const clamped = Math.max(0, Math.min(total - 1, next));
if (clamped === index) return;
setDirection(clamped > index ? 1 : -1);
setIndex(clamped);
};
const essentials = tips
.map((item, itemIndex) => ({ item, itemIndex }))
.filter(({ item }) => !isPlanTip(item));
const planTips = tips
.map((item, itemIndex) => ({ item, itemIndex }))
.filter(({ item }) => isPlanTip(item));
const sections = [
{ key: "essentials", label: t("tips.essentials"), entries: essentials },
...(planTips.length > 0
? [{ key: "plan", label: t("tips.planSection"), entries: planTips }]
: []),
];
const detail = (
<section
data-slot="tip-detail"
data-tip-id={tip.id}
className="flex min-h-0 min-w-0 flex-col gap-4 overflow-y-auto p-5"
>
<DialogTitle className="pr-6 text-lg font-semibold tracking-tight text-balance">
{t(keys.title, { mod })}
</DialogTitle>
<div
data-slot="tip-scene-panel"
className="relative h-40 overflow-hidden rounded-lg bg-muted/35"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={tip.id}
className="absolute inset-0 p-3"
initial={animate ? { x: direction * 24 } : false}
animate={{ x: 0 }}
exit={animate ? { x: -direction * 24, opacity: 0 } : { opacity: 0 }}
transition={{ duration: 0.22, ease: MOTION_EASE_OUT }}
>
<TipScene id={tip.id} />
</motion.div>
</AnimatePresence>
</div>
<div className="flex flex-col gap-1.5">
{isPlanTip(tip) && (
<p className="text-xs text-muted-foreground">
{t("tips.planSection")}
</p>
)}
<p className="text-sm/6 text-pretty text-muted-foreground">
{t(keys.body, { mod })}
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="size-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("tips.previous")}
data-slot="tip-previous"
disabled={index === 0}
onClick={() => go(index - 1)}
>
<LuChevronLeft className="size-4" />
</Button>
<span
className="min-w-[5ch] text-center text-xs text-muted-foreground tabular-nums"
aria-live="polite"
>
{t("tips.count", { current: index + 1, total })}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="size-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("tips.next")}
data-slot="tip-next"
disabled={isLast}
onClick={() => go(index + 1)}
>
<LuChevronRight className="size-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
data-slot="tip-action"
onClick={() => onAction(tip.action)}
>
{t(keys.action)}
</Button>
<Button
type="button"
size="sm"
data-slot="tip-advance"
onClick={() => {
if (isLast) onOpenChange(false);
else go(index + 1);
}}
>
{t(isLast ? "tips.done" : "tips.next")}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<AnimatedSwitch
id={autoShowId}
data-slot="tips-auto-show"
checked={autoShow}
onCheckedChange={onAutoShowChange}
/>
<Label
htmlFor={autoShowId}
className="cursor-pointer text-xs font-normal text-muted-foreground"
>
{t("tips.autoShow")}
</Label>
</div>
</section>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-slot="tips-dialog"
data-mode={mode}
className={cn(
"max-h-[calc(100vh-3rem)] gap-0 overflow-hidden p-0",
mode === "browse" ? "sm:max-w-2xl" : "sm:max-w-md",
)}
onKeyDown={(event) => {
if (isTypingTarget(event.target)) return;
if (event.key === "ArrowRight") {
event.preventDefault();
go(index + 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
go(index - 1);
}
}}
>
{mode === "browse" ? (
<div className="grid max-h-[calc(100vh-3rem)] min-h-0 sm:grid-cols-[11rem_minmax(0,1fr)]">
<aside className="hidden min-h-0 overflow-hidden border-r sm:flex sm:flex-col">
<h2 className="px-4 pt-5 pb-1 text-sm font-semibold">
{t("tips.title")}
</h2>
<nav
aria-label={t("tips.title")}
className="min-h-0 flex-1 overflow-y-auto px-2 pb-3"
>
{sections.map((section) => (
<div key={section.key}>
<p className="px-2 pt-3 pb-1 text-[11px] font-medium text-muted-foreground">
{section.label}
</p>
<ul data-slot="tips-list">
{section.entries.map(({ item, itemIndex }) => {
const active = itemIndex === index;
return (
<li key={item.id}>
<button
type="button"
data-slot="tips-list-item"
data-tip-id={item.id}
data-seen={seen.includes(item.id)}
aria-current={active ? "true" : undefined}
onClick={() => go(itemIndex)}
className={cn(
"relative isolate flex w-full cursor-pointer items-center rounded-md px-2 py-1.5 text-left text-sm transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
active
? "text-accent-foreground"
: seen.includes(item.id)
? "text-muted-foreground hover:text-foreground"
: "text-foreground",
)}
>
{active && (
<motion.span
aria-hidden="true"
data-slot="tips-list-indicator"
layoutId={
animate ? `${listId}-indicator` : undefined
}
initial={false}
transition={
animate
? MOTION_SPRING_POSITION
: { duration: 0 }
}
className="absolute inset-0 -z-10 rounded-md bg-accent"
/>
)}
<span className="truncate">
{t(tipTextKeys(item.id).label)}
</span>
</button>
</li>
);
})}
</ul>
</div>
))}
</nav>
</aside>
{detail}
</div>
) : (
<div className="grid max-h-[calc(100vh-3rem)] min-h-0">{detail}</div>
)}
</DialogContent>
</Dialog>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { isMacOS } from "@/lib/platform";
import type { TipId } from "@/lib/tips";
import {
ApiScene,
ConsistencyScene,
DnsScene,
ExtensionsScene,
GroupsScene,
ImportScene,
LinkRouteScene,
LockScene,
PaletteScene,
ProxyRouteScene,
SweepScene,
SyncScene,
TrashScene,
} from "./scenes-essentials";
import {
AgentScene,
CloudSyncScene,
CookieBotScene,
CrossOsScene,
RemoteScene,
TeamScene,
} from "./scenes-plan";
/** The drawing for one tip. Every tip id has one; the switch is exhaustive. */
export function TipScene({ id }: { id: TipId }) {
switch (id) {
case "dnsBlocklist":
return <DnsScene />;
case "proxyCheck":
return <ProxyRouteScene />;
case "groups":
return <GroupsScene />;
case "commandPalette":
return <PaletteScene modLabel={isMacOS() ? "⌘" : "Ctrl"} />;
case "fingerprintGate":
return <ConsistencyScene />;
case "profilePassword":
return <LockScene />;
case "clearOnClose":
return <SweepScene />;
case "defaultBrowser":
return <LinkRouteScene />;
case "extensionGroups":
return <ExtensionsScene />;
case "selfHostedSync":
return <SyncScene />;
case "trash":
return <TrashScene />;
case "localApi":
return <ApiScene variant="api" />;
case "importProfiles":
return <ImportScene />;
case "cloudBackup":
return <CloudSyncScene />;
case "cookieBot":
return <CookieBotScene />;
case "crossOs":
return <CrossOsScene />;
case "automation":
return <ApiScene variant="run" />;
case "agent":
return <AgentScene />;
case "team":
return <TeamScene />;
case "remoteControl":
return <RemoteScene />;
}
}
+368
View File
@@ -0,0 +1,368 @@
"use client";
import {
motion,
type TargetAndTransition,
type Transition,
useReducedMotion,
} from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export const VIEW_W = 320;
export const VIEW_H = 160;
/**
* Shared timing for one looping scene. Every element in a scene keys off the
* same duration and its own `times`, so the parts stay in step without any
* orchestration. With reduced motion a scene shows its resting frame: the
* last keyframe of every value, no loop.
*/
export function useScene(duration: number) {
const reduce = useReducedMotion() ?? false;
const kf = <T,>(values: T[]): T | T[] =>
reduce ? (values[values.length - 1] as T) : values;
const tr = (times: number[], extra?: Transition): Transition =>
reduce
? { duration: 0 }
: {
duration,
times,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
...extra,
};
return { reduce, kf, tr };
}
export interface Point {
x: number;
y: number;
}
/** Points along a cubic bezier, so a dot can travel a drawn wire. */
export function bezier(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
steps: number,
): Point[] {
const out: Point[] = [];
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const mt = 1 - t;
out.push({
x:
mt ** 3 * p0.x +
3 * mt ** 2 * t * p1.x +
3 * mt * t ** 2 * p2.x +
t ** 3 * p3.x,
y:
mt ** 3 * p0.y +
3 * mt ** 2 * t * p1.y +
3 * mt * t ** 2 * p2.y +
t ** 3 * p3.y,
});
}
return out;
}
/**
* Keyframes that hold at the first point, travel through every point between
* `from` and `to` (as fractions of the loop), then hold at the last point.
*/
export function travel(points: Point[], from: number, to: number) {
const last = points.length - 1;
const times = [
0,
...points.map((_, index) => from + ((to - from) * index) / last),
1,
];
return {
cx: [points[0].x, ...points.map((p) => p.x), points[last].x],
cy: [points[0].y, ...points.map((p) => p.y), points[last].y],
times,
};
}
export function Scene({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<svg
data-slot="tip-scene"
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className={cn("h-full w-full text-muted-foreground", className)}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
);
}
/** A browser window: a frame, a title bar with one tab, and some text lines. */
export function Window({
x,
y,
width,
height,
lines = 3,
className,
children,
}: {
x: number;
y: number;
width: number;
height: number;
lines?: number;
className?: string;
children?: ReactNode;
}) {
const barY = y + 14;
return (
<g className={className}>
<rect x={x} y={y} width={width} height={height} rx={6} />
<path d={`M${x} ${barY} H${x + width}`} />
<rect
x={x + 6}
y={y + 4}
width={Math.min(24, width / 3)}
height={6}
rx={2}
/>
{Array.from({ length: lines }, (_, index) => {
const lineY = barY + 14 + index * 12;
const lineWidth = (width - 24) * (index % 2 === 0 ? 0.8 : 0.55);
return lineY < y + height - 8 ? (
<path
key={index}
d={`M${x + 12} ${lineY} h${lineWidth}`}
strokeWidth={2}
/>
) : null;
})}
{children}
</g>
);
}
export function Laptop({ x, y }: { x: number; y: number }) {
return (
<g>
<rect x={x} y={y} width={56} height={36} rx={3} />
<path d={`M${x - 6} ${y + 41} H${x + 62}`} strokeWidth={2} />
</g>
);
}
export function Monitor({ x, y }: { x: number; y: number }) {
return (
<g>
<rect x={x} y={y} width={56} height={40} rx={3} />
<path d={`M${x + 28} ${y + 40} v8 M${x + 18} ${y + 48} h20`} />
</g>
);
}
export function Server({ x, y }: { x: number; y: number }) {
return (
<g>
<rect x={x} y={y} width={40} height={56} rx={3} />
<path d={`M${x} ${y + 19} h40 M${x} ${y + 37} h40`} />
{[9, 28, 47].map((offset) => (
<circle
key={offset}
cx={x + 32}
cy={y + offset}
r={1.5}
fill="currentColor"
stroke="none"
/>
))}
</g>
);
}
export function Person({ cx, cy }: { cx: number; cy: number }) {
return (
<g>
<circle cx={cx} cy={cy - 9} r={6} />
<path d={`M${cx - 12} ${cy + 10} a12 12 0 0 1 24 0`} />
</g>
);
}
export function Cloud({ cx, cy }: { cx: number; cy: number }) {
return (
<path
d={`M${cx - 22} ${cy + 8} a9 9 0 0 1 2 -17 a14 14 0 0 1 27 -5 a10 10 0 0 1 10 22 z`}
/>
);
}
export function Globe({ cx, cy, r }: { cx: number; cy: number; r: number }) {
return (
<g>
<circle cx={cx} cy={cy} r={r} />
<ellipse cx={cx} cy={cy} rx={r * 0.42} ry={r} />
<path d={`M${cx - r} ${cy} h${r * 2}`} />
<path
d={`M${cx - r * 0.86} ${cy - r * 0.5} q${r * 0.86} ${r * 0.28} ${r * 1.72} 0`}
/>
</g>
);
}
export function Bin({ x, y }: { x: number; y: number }) {
return (
<path
d={`M${x} ${y} h22 M${x + 3} ${y} v17 a2 2 0 0 0 2 2 h12 a2 2 0 0 0 2 -2 v-17 M${x + 8} ${y} v-3 h6 v3 M${x + 8} ${y + 5} v9 M${x + 14} ${y + 5} v9`}
/>
);
}
/** A padlock; the shackle is its own element so a scene can lift it. */
export function Padlock({
x,
y,
shackle,
className,
}: {
x: number;
y: number;
/** Motion props for the shackle group. */
shackle?: { animate: TargetAndTransition; transition: Transition };
className?: string;
}) {
return (
<g className={className}>
<rect x={x} y={y} width={16} height={12} rx={2} />
<circle cx={x + 8} cy={y + 6} r={1.5} fill="currentColor" stroke="none" />
<motion.path
d={`M${x + 4} ${y} v-4 a4 4 0 0 1 8 0 v4`}
initial={false}
{...shackle}
/>
</g>
);
}
/** A puzzle piece with a bump on top and one on the right, for extensions. */
export function puzzlePath(x: number, y: number, size: number): string {
const r = size * 0.16;
const side = size / 2 - r;
return `M${x} ${y} h${side} a${r} ${r} 0 1 1 ${r * 2} 0 h${side} v${side} a${r} ${r} 0 1 1 0 ${r * 2} v${side} h-${size} z`;
}
export function Puzzle({
x,
y,
size,
className,
}: {
x: number;
y: number;
size: number;
className?: string;
}) {
return <path d={puzzlePath(x, y, size)} className={className} />;
}
export function Keycap({
x,
y,
width,
label,
className,
animate,
transition,
}: {
x: number;
y: number;
width: number;
label: string;
className?: string;
animate?: TargetAndTransition;
transition?: Transition;
}) {
return (
<motion.g
className={className}
initial={false}
animate={animate}
transition={transition}
>
<rect x={x} y={y} width={width} height={18} rx={4} />
<text
x={x + width / 2}
y={y + 9}
textAnchor="middle"
dominantBaseline="central"
fontSize={9}
fontFamily="inherit"
fill="currentColor"
stroke="none"
>
{label}
</text>
</motion.g>
);
}
export function Cursor({
animate,
transition,
className,
}: {
animate: TargetAndTransition;
transition: Transition;
className?: string;
}) {
return (
<motion.path
d="M0 0 L0 11 L3 8.5 L5 13 L7 12 L5 7.5 L9 7.5 Z"
fill="currentColor"
className={className}
initial={false}
animate={animate}
transition={transition}
/>
);
}
export function Check({
x,
y,
size = 12,
className,
animate,
transition,
}: {
x: number;
y: number;
size?: number;
className?: string;
animate: TargetAndTransition;
transition: Transition;
}) {
return (
<motion.path
d={`M${x} ${y} l${size / 3} ${size / 3} l${(size * 2) / 3} -${(size * 2) / 3}`}
className={className}
strokeWidth={2}
initial={false}
animate={animate}
transition={transition}
/>
);
}
+745
View File
@@ -0,0 +1,745 @@
"use client";
import { motion } from "motion/react";
import {
Bin,
bezier,
Check,
Cursor,
Globe,
Keycap,
Laptop,
Padlock,
Puzzle,
puzzlePath,
Scene,
Server,
travel,
useScene,
Window,
} from "./scene-primitives";
/**
* Every scene here is decorative: the dialog text carries the meaning and the
* drawing shows it happening. Scenes loop on their own clock, render their
* resting frame under reduced motion, and never hide anything a reader needs.
*/
/** Requests leave a profile; the ones bound for ad and tracker hosts stop at the shield. */
export function DnsScene() {
const { kf, tr } = useScene(3.6);
return (
<Scene>
<Window x={16} y={32} width={92} height={96} />
<path
d="M160 42 l22 8 v22 c0 18 -10 30 -22 38 c-12 -8 -22 -20 -22 -38 v-22 z"
className="text-foreground"
/>
{[50, 84, 118].map((y) => (
<g key={y}>
<path d={`M108 ${y} H236`} strokeDasharray="2 5" />
<rect x={236} y={y - 12} width={68} height={24} rx={5} />
<path d={`M248 ${y} h24`} strokeWidth={2} />
</g>
))}
<motion.circle
r={3.5}
cy={50}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf([108, 108, 236, 236]) }}
transition={tr([0, 0.08, 0.55, 1])}
/>
{[84, 118].map((y, index) => {
const hit = 0.34 + index * 0.1;
return (
<g key={y} className="text-destructive-text">
<motion.circle
cy={y}
fill="currentColor"
stroke="none"
initial={false}
animate={{
cx: kf([108, 108, 134, 134, 134]),
r: kf([3.5, 3.5, 3.5, 0, 0]),
}}
transition={tr([0, 0.08, hit, hit + 0.05, 1])}
/>
<motion.path
d={`M130 ${y - 4} l8 8 M138 ${y - 4} l-8 8`}
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, hit, hit + 0.12, 1])}
/>
</g>
);
})}
</Scene>
);
}
const ROUTE = travel(
[
...bezier(
{ x: 82, y: 104 },
{ x: 118, y: 104 },
{ x: 128, y: 44 },
{ x: 160, y: 44 },
8,
),
...bezier(
{ x: 160, y: 44 },
{ x: 192, y: 44 },
{ x: 204, y: 104 },
{ x: 256, y: 104 },
8,
).slice(1),
],
0.08,
0.66,
);
/** A check travels this device, the proxy, the exit; the exit is confirmed. */
export function ProxyRouteScene() {
const { kf, tr } = useScene(3.4);
return (
<Scene>
<Laptop x={24} y={86} />
<path d="M82 104 C118 104 128 44 160 44 C192 44 204 104 256 104" />
<motion.circle
cx={160}
cy={44}
r={10}
className="text-foreground"
initial={false}
animate={{ r: kf([10, 10, 13, 10, 10]) }}
transition={tr([0, 0.3, 0.37, 0.44, 1])}
/>
<circle
cx={160}
cy={44}
r={2}
fill="currentColor"
stroke="none"
className="text-foreground"
/>
<Globe cx={272} cy={104} r={16} />
<motion.circle
r={3.5}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf(ROUTE.cx), cy: kf(ROUTE.cy) }}
transition={tr(ROUTE.times, { ease: "linear" })}
/>
<Check
x={280}
y={78}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.7, 0.82, 1])}
/>
</Scene>
);
}
const COLUMNS = [20, 118, 216];
/** Profiles settle into groups, then the group keys walk the columns. */
export function GroupsScene() {
const { kf, tr } = useScene(4.4);
return (
<Scene>
{COLUMNS.map((x, index) => {
const press = 0.5 + index * 0.14;
return (
<g key={x}>
<rect x={x} y={26} width={84} height={104} rx={6} />
<path d={`M${x + 10} 40 h${28 + index * 10}`} strokeWidth={2} />
<Keycap
x={x + 33}
y={138}
width={18}
label={String(index + 1)}
animate={{ y: kf([0, 0, 2, 0, 0]) }}
transition={tr([0, press, press + 0.05, press + 0.1, 1])}
/>
</g>
);
})}
<motion.rect
x={20}
y={26}
width={84}
height={104}
rx={6}
className="text-foreground"
initial={false}
animate={{ x: kf([0, 0, 98, 196, 196]) }}
transition={tr([0, 0.5, 0.64, 0.78, 1])}
/>
{Array.from({ length: 6 }, (_, index) => {
const column = Math.floor(index / 2);
const fromY = 52 + index * 12;
const toY = 52 + (index % 2) * 12;
const start = 0.1 + index * 0.05;
return (
<motion.rect
key={index}
x={32}
y={toY}
width={60}
height={8}
rx={3}
strokeWidth={2}
initial={false}
animate={{
x: kf([0, 0, COLUMNS[column] - 20, COLUMNS[column] - 20]),
y: kf([fromY - toY, fromY - toY, 0, 0]),
}}
transition={tr([0, start, start + 0.2, 1])}
/>
);
})}
</Scene>
);
}
/** The chord opens the palette; two typed letters narrow it to one entry. */
export function PaletteScene({ modLabel }: { modLabel: string }) {
const { kf, tr } = useScene(4);
const press = (at: number) => ({
animate: { y: kf([0, 0, 2, 0, 0]) },
transition: tr([0, at, at + 0.04, at + 0.1, 1]),
});
return (
<Scene>
<Keycap x={22} y={118} width={30} label={modLabel} {...press(0.06)} />
<Keycap x={58} y={118} width={22} label="K" {...press(0.08)} />
<rect x={110} y={22} width={190} height={116} rx={8} />
<path d="M110 48 H300" />
<motion.path
d="M124 35 h26"
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.25, 0.4, 1])}
/>
<motion.path
d="M154 30 v10"
className="text-foreground"
initial={false}
animate={{ x: kf([-26, -26, 0, 0]) }}
transition={tr([0, 0.25, 0.4, 1])}
/>
{[62, 80, 98, 116].map((y, index) => (
<g key={y}>
<circle cx={128} cy={y} r={3} />
<path d={`M140 ${y} h${40 + ((index * 23) % 50)}`} strokeWidth={2} />
</g>
))}
<motion.rect
x={116}
y={54}
width={178}
height={16}
rx={4}
className="text-foreground"
initial={false}
animate={{ y: kf([0, 0, 36, 36]) }}
transition={tr([0, 0.45, 0.6, 1])}
/>
</Scene>
);
}
const CLOCK = { cx: 212, cy: 82, r: 16 };
function hand(deg: number, length: number) {
return {
x: CLOCK.cx + Math.sin((deg * Math.PI) / 180) * length,
y: CLOCK.cy - Math.cos((deg * Math.PI) / 180) * length,
};
}
/** The profile clock turns to the exit's timezone; the mismatch mark gives way. */
export function ConsistencyScene() {
const { kf, tr } = useScene(4);
const wrong = hand(-110, 9);
const right = hand(60, 9);
return (
<Scene>
<Globe cx={76} cy={82} r={40} />
<g className="text-foreground">
<circle cx={98} cy={62} r={3.5} fill="currentColor" stroke="none" />
<path d="M98 62 v-14" />
</g>
<path d="M118 62 C140 46 156 46 176 58" strokeDasharray="2 5" />
<Window x={176} y={34} width={122} height={94} lines={0}>
<circle cx={CLOCK.cx} cy={CLOCK.cy} r={CLOCK.r} />
<path
d={`M${CLOCK.cx} ${CLOCK.cy} L${CLOCK.cx + 5} ${CLOCK.cy - 11}`}
/>
<motion.line
x1={CLOCK.cx}
y1={CLOCK.cy}
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{
x2: kf([wrong.x, wrong.x, right.x, right.x]),
y2: kf([wrong.y, wrong.y, right.y, right.y]),
}}
transition={tr([0, 0.3, 0.55, 1])}
/>
<path d="M244 118 h40" strokeWidth={2} />
</Window>
<motion.g
className="text-warning-text"
initial={false}
animate={{ opacity: kf([1, 1, 0, 0]) }}
transition={tr([0, 0.4, 0.55, 1])}
>
<path d="M268 68 l9 16 h-18 z" />
<path d="M268 74 v5 M268 82 v0.01" strokeWidth={2} />
</motion.g>
<Check
x={262}
y={78}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.6, 0.75, 1])}
/>
</Scene>
);
}
const CIPHER_LINES = [64, 78, 92, 106];
/** The padlock drops and the profile's text turns to cipher. */
export function LockScene() {
const { kf, tr } = useScene(4.2);
const swap = tr([0, 0.3, 0.42, 1], { repeatDelay: 0.8 });
return (
<Scene>
<Window x={96} y={30} width={128} height={100} lines={0}>
<motion.g
initial={false}
animate={{ opacity: kf([1, 1, 0.15, 0.15]) }}
transition={swap}
>
{CIPHER_LINES.map((y, index) => (
<path
key={y}
d={`M108 ${y} h${70 - (index % 2) * 24}`}
strokeWidth={2}
/>
))}
</motion.g>
<motion.g
initial={false}
animate={{ opacity: kf([0.15, 0.15, 1, 1]) }}
transition={swap}
>
{CIPHER_LINES.map((y, index) => (
<path
key={y}
d={`M108 ${y} h${70 - (index % 2) * 24}`}
strokeWidth={2}
strokeDasharray="3 3"
/>
))}
</motion.g>
</Window>
<Padlock
x={200}
y={112}
className="text-foreground"
shackle={{
animate: { y: kf([-5, -5, 0, 0]) },
transition: tr([0, 0.3, 0.4, 1], { repeatDelay: 0.8 }),
}}
/>
</Scene>
);
}
const CRUMBS = [
{ x: 100, y: 72, at: 0.4 },
{ x: 132, y: 66, at: 0.46 },
{ x: 166, y: 76, at: 0.52 },
];
/** The window closes and its cookies and storage fall away. */
export function SweepScene() {
const { kf, tr } = useScene(4);
const fall = (at: number) => tr([0, at, at + 0.22, 1]);
return (
<Scene>
<Window x={70} y={26} width={150} height={108} lines={0}>
<motion.path
d="M203 40 l8 8 M211 40 l-8 8"
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.28, 0.36, 1])}
/>
</Window>
{CRUMBS.map((crumb) => (
<motion.g
key={crumb.x}
initial={false}
animate={{ y: kf([0, 0, 90, 90]), opacity: kf([1, 1, 0, 0]) }}
transition={fall(crumb.at)}
>
<circle cx={crumb.x} cy={crumb.y} r={6} />
<circle
cx={crumb.x - 2}
cy={crumb.y - 2}
r={0.8}
fill="currentColor"
stroke="none"
/>
<circle
cx={crumb.x + 2.5}
cy={crumb.y + 1.5}
r={0.8}
fill="currentColor"
stroke="none"
/>
</motion.g>
))}
{[
{ x: 96, at: 0.5 },
{ x: 150, at: 0.56 },
].map((store) => (
<motion.rect
key={store.x}
x={store.x}
y={98}
width={30}
height={12}
rx={3}
initial={false}
animate={{ y: kf([0, 0, 70, 70]), opacity: kf([1, 1, 0, 0]) }}
transition={fall(store.at)}
/>
))}
</Scene>
);
}
/** A link from another app passes the chooser and opens in the chosen profile. */
export function LinkRouteScene() {
const { kf, tr } = useScene(4.2);
const pop = tr([0, 0.55, 0.7, 1]);
return (
<Scene>
<rect x={16} y={56} width={64} height={48} rx={5} />
<path d="M26 70 h44 M26 82 h30" strokeWidth={2} />
<path d="M26 94 h24" strokeWidth={2} className="text-foreground" />
<path d="M80 94 H112" strokeDasharray="2 5" />
<motion.circle
r={3}
cy={94}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf([80, 80, 112, 112]) }}
transition={tr([0, 0.1, 0.3, 1])}
/>
<Window x={112} y={30} width={104} height={100} lines={0}>
{[52, 76, 100].map((y, index) => (
<g key={y}>
<circle cx={128} cy={y + 8} r={5} />
<path d={`M140 ${y + 8} h${28 + index * 12}`} strokeWidth={2} />
</g>
))}
</Window>
<motion.rect
x={118}
y={52}
width={92}
height={16}
rx={4}
className="text-foreground"
initial={false}
animate={{ y: kf([0, 0, 24, 24]) }}
transition={tr([0, 0.35, 0.5, 1])}
/>
<path d="M216 84 H240" strokeDasharray="2 5" />
<motion.g
initial={false}
animate={{ x: kf([12, 12, 0, 0]), y: kf([10, 10, 0, 0]) }}
transition={pop}
>
<motion.rect
x={240}
y={44}
rx={6}
initial={false}
animate={{
width: kf([40, 40, 64, 64]),
height: kf([48, 48, 80, 80]),
}}
transition={pop}
/>
<motion.path
d="M240 58 H304"
initial={false}
animate={{ pathLength: kf([0.6, 0.6, 1, 1]) }}
transition={pop}
/>
<path d="M250 74 h40 M250 86 h28" strokeWidth={2} />
</motion.g>
</Scene>
);
}
/** One extension group; each profile that uses it receives the pieces. */
export function ExtensionsScene() {
const { kf, tr } = useScene(4);
return (
<Scene>
<rect x={20} y={40} width={88} height={80} rx={8} />
<Puzzle x={50} y={66} size={28} className="text-foreground" />
{[28, 66, 104].map((y, index) => {
const at = 0.25 + index * 0.18;
return (
<g key={y}>
<path
d={`M108 80 C130 80 130 ${y + 15} 148 ${y + 15}`}
strokeDasharray="2 5"
/>
<rect x={148} y={y} width={152} height={30} rx={6} />
<path
d={`M162 ${y + 15} h${50 + (index % 2) * 20}`}
strokeWidth={2}
/>
<motion.path
d={puzzlePath(268, y + 8, 14)}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, at, at + 0.15, 1])}
/>
</g>
);
})}
</Scene>
);
}
/** A packet is sealed on its way to the server; the server confirms it. */
export function SyncScene() {
const { kf, tr } = useScene(4.2);
return (
<Scene>
<Laptop x={24} y={62} />
<path d="M86 80 H244" />
<Server x={244} y={52} />
<motion.rect
x={90}
y={74}
width={12}
height={9}
rx={2}
className="text-foreground"
initial={false}
animate={{ x: kf([0, 0, 140, 140]) }}
transition={tr([0, 0.1, 0.6, 1], { ease: "linear" })}
/>
<Padlock
x={158}
y={96}
className="text-foreground"
shackle={{
animate: { y: kf([-5, -5, 0, 0]) },
transition: tr([0, 0.3, 0.38, 1]),
}}
/>
<motion.circle
cx={276}
cy={61}
r={1.5}
className="text-success-text"
fill="currentColor"
stroke="none"
initial={false}
animate={{ r: kf([1.5, 1.5, 3, 3]) }}
transition={tr([0, 0.62, 0.7, 1])}
/>
</Scene>
);
}
/** A profile goes to the bin, the retention ring drains, and it comes back. */
export function TrashScene() {
const { kf, tr } = useScene(5);
const move = tr([0, 0.12, 0.34, 0.72, 0.94, 1]);
return (
<Scene>
<Bin x={240} y={70} />
<motion.circle
cx={251}
cy={78}
r={22}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([1, 1, 1, 0.12, 1, 1]) }}
transition={tr([0, 0.34, 0.4, 0.68, 0.8, 1])}
/>
<motion.g
initial={false}
animate={{
x: kf([0, 0, 150, 150, 0, 0]),
opacity: kf([1, 1, 0.3, 0.3, 1, 1]),
}}
transition={move}
>
<rect x={28} y={66} width={104} height={24} rx={6} />
<circle cx={44} cy={78} r={6} />
<path d="M58 74 h48 M58 82 h30" strokeWidth={2} />
</motion.g>
</Scene>
);
}
/** A command in a terminal opens a real profile; `run` also drives it. */
export function ApiScene({ variant }: { variant: "api" | "run" }) {
const { kf, tr } = useScene(4.4);
const pop = tr([0, 0.5, 0.64, 1]);
return (
<Scene>
<Window x={16} y={30} width={140} height={100} lines={0}>
<path d="M30 60 l6 5 l-6 5" className="text-foreground" />
<motion.path
d="M44 65 h72"
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.1, 0.36, 1])}
/>
<motion.g
initial={false}
animate={{ opacity: kf([0.2, 0.2, 1, 1]) }}
transition={tr([0, 0.42, 0.5, 1])}
>
<path d="M30 84 h50 M30 96 h34" strokeWidth={2} />
</motion.g>
</Window>
<path d="M156 80 H196" strokeDasharray="2 5" />
<motion.circle
r={3}
cy={80}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf([156, 156, 196, 196]) }}
transition={tr([0, 0.38, 0.5, 1])}
/>
{variant === "run" && (
<rect
x={212}
y={36}
width={92}
height={70}
rx={6}
strokeDasharray="3 3"
/>
)}
<motion.g
initial={false}
animate={{ x: kf([8, 8, 0, 0]), y: kf([8, 8, 0, 0]) }}
transition={pop}
>
<motion.rect
x={196}
y={44}
rx={6}
initial={false}
animate={{
width: kf([60, 60, 108, 108]),
height: kf([44, 44, 76, 76]),
}}
transition={pop}
/>
<motion.path
d="M196 58 H304"
initial={false}
animate={{ pathLength: kf([0.55, 0.55, 1, 1]) }}
transition={pop}
/>
<path d="M208 74 h48 M208 88 h30" strokeWidth={2} />
{variant === "run" && (
<Cursor
className="text-foreground"
animate={{
x: kf([232, 232, 262, 262]),
y: kf([92, 92, 76, 76]),
}}
transition={tr([0, 0.7, 0.9, 1])}
/>
)}
</motion.g>
<Check
x={274}
y={100}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.66, 0.78, 1])}
/>
</Scene>
);
}
const IMPORT_ARC = bezier(
{ x: 126, y: 84 },
{ x: 160, y: 26 },
{ x: 200, y: 26 },
{ x: 263, y: 84 },
12,
);
/** Cookies, logins and extensions cross from another browser into a profile. */
export function ImportScene() {
const { kf, tr } = useScene(4.6);
return (
<Scene>
<Window x={16} y={38} width={110} height={92} lines={4} />
<path d="M126 84 C160 26 200 26 263 84" strokeDasharray="2 5" />
<rect x={222} y={38} width={82} height={92} rx={6} />
<circle cx={263} cy={84} r={18} className="text-foreground" />
<circle cx={263} cy={84} r={7} className="text-foreground" />
{[0.1, 0.26, 0.42].map((at) => {
const trip = travel(IMPORT_ARC, at, at + 0.3);
return (
<motion.circle
key={at}
r={4}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf(trip.cx), cy: kf(trip.cy) }}
transition={tr(trip.times, { ease: "linear" })}
/>
);
})}
<Check
x={286}
y={50}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.76, 0.86, 1])}
/>
</Scene>
);
}
+332
View File
@@ -0,0 +1,332 @@
"use client";
import { motion } from "motion/react";
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
import {
bezier,
Check,
Cloud,
Cursor,
Laptop,
Monitor,
Padlock,
Person,
Scene,
travel,
useScene,
Window,
} from "./scene-primitives";
const UP = travel(
bezier(
{ x: 82, y: 96 },
{ x: 100, y: 70 },
{ x: 120, y: 56 },
{ x: 140, y: 56 },
8,
),
0.08,
0.34,
);
const DOWN = travel(
bezier(
{ x: 180, y: 56 },
{ x: 200, y: 56 },
{ x: 220, y: 70 },
{ x: 238, y: 96 },
8,
),
0.44,
0.7,
);
/** A profile goes up to the cloud from one machine and down to another. */
export function CloudSyncScene() {
const { kf, tr } = useScene(4.4);
return (
<Scene>
<Laptop x={24} y={78} />
<Cloud cx={160} cy={52} />
<Monitor x={238} y={74} />
<path d="M82 96 C100 70 120 56 140 56" strokeDasharray="2 5" />
<path d="M180 56 C200 56 220 70 238 96" strokeDasharray="2 5" />
<motion.circle
r={3.5}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf(UP.cx), cy: kf(UP.cy) }}
transition={tr(UP.times, { ease: "linear" })}
/>
<motion.circle
r={3.5}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf(DOWN.cx), cy: kf(DOWN.cy) }}
transition={tr(DOWN.times, { ease: "linear" })}
/>
<Check
x={260}
y={92}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.72, 0.84, 1])}
/>
</Scene>
);
}
const SKY = travel(
bezier(
{ x: 40, y: 60 },
{ x: 100, y: -4 },
{ x: 220, y: -4 },
{ x: 280, y: 60 },
12,
),
0.05,
0.75,
);
/** The moon crosses the sky while the profile collects cookies and history. */
export function CookieBotScene() {
const { kf, tr } = useScene(5);
return (
<Scene>
<path d="M24 60 H296" strokeDasharray="2 5" />
<motion.g
className="text-foreground"
initial={false}
animate={{
x: kf(SKY.cx.map((x) => x - 40)),
y: kf(SKY.cy.map((y) => y - 60)),
}}
transition={tr(SKY.times, { ease: "linear" })}
>
<path d="M40 52 a8 8 0 1 0 6 13 a6 6 0 0 1 -6 -13 z" />
</motion.g>
<Window x={96} y={74} width={128} height={62} lines={0}>
{[0, 1, 2, 3, 4].map((index) => {
const at = 0.12 + index * 0.13;
return (
<motion.circle
key={index}
cx={116 + index * 22}
cy={104}
initial={false}
animate={{ r: kf([0, 0, 5, 5]) }}
transition={tr([0, at, at + 0.08, 1])}
/>
);
})}
<motion.path
d="M108 122 h104"
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.08, 0.76, 1], { ease: "linear" })}
/>
</Window>
</Scene>
);
}
const OS_MARKS = [FaApple, FaWindows, FaLinux];
const OS_TIMES = [0, 0.28, 0.34, 0.61, 0.67, 0.94, 1];
const OS_VISIBLE = [
[1, 1, 0, 0, 0, 0, 1],
[0, 0, 1, 1, 0, 0, 0],
[0, 0, 0, 0, 1, 1, 0],
];
/** One profile presents as each operating system in turn. */
export function CrossOsScene() {
const { kf, tr } = useScene(5.4);
return (
<Scene>
<Window x={40} y={30} width={240} height={100} lines={3} />
<rect x={214} y={54} width={52} height={52} rx={8} />
{OS_MARKS.map((Mark, index) => (
<motion.g
key={Mark.name}
className="text-foreground"
initial={false}
animate={{ opacity: kf(OS_VISIBLE[index]) }}
transition={tr(OS_TIMES)}
>
<Mark x={226} y={66} width={28} height={28} stroke="none" />
</motion.g>
))}
<motion.path
d="M222 114 h12"
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{ x: kf([0, 0, 12, 12, 24, 24, 0]) }}
transition={tr(OS_TIMES)}
/>
</Scene>
);
}
const AGENT_BUTTONS = [
{ x: 36, y: 56 },
{ x: 36, y: 82 },
{ x: 108, y: 108 },
];
const AGENT_CLICKS = [0.18, 0.42, 0.66];
/** The agent clicks through a page and writes each step into a recipe. */
export function AgentScene() {
const { kf, tr } = useScene(5.2);
return (
<Scene>
<Window x={16} y={26} width={168} height={108} lines={0}>
{AGENT_BUTTONS.map((button, index) => {
const at = AGENT_CLICKS[index];
return (
<g key={button.y}>
<rect x={button.x} y={button.y} width={52} height={14} rx={4} />
<motion.rect
x={button.x}
y={button.y}
width={52}
height={14}
rx={4}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ opacity: kf([0, 0, 0.9, 0.9]) }}
transition={tr([0, at, at + 0.05, 1])}
/>
<Check
x={button.x + 58}
y={button.y + 6}
size={9}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, at + 0.04, at + 0.12, 1])}
/>
</g>
);
})}
</Window>
<Cursor
className="text-foreground"
animate={{
x: kf([150, 150, 58, 58, 58, 58, 130, 130]),
y: kf([120, 120, 60, 60, 86, 86, 112, 112]),
}}
transition={tr([
0,
0.06,
AGENT_CLICKS[0],
AGENT_CLICKS[0] + 0.1,
AGENT_CLICKS[1],
AGENT_CLICKS[1] + 0.1,
AGENT_CLICKS[2],
1,
])}
/>
<Window x={204} y={26} width={100} height={108} lines={0}>
{AGENT_CLICKS.map((at, index) => (
<g key={at}>
<circle
cx={214}
cy={58 + index * 22}
r={2}
fill="currentColor"
stroke="none"
/>
<motion.path
d={`M222 ${58 + index * 22} h${60 - index * 10}`}
strokeWidth={2}
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, at + 0.08, at + 0.2, 1])}
/>
</g>
))}
</Window>
</Scene>
);
}
/** One teammate holds the profile lock, releases it, and the other takes it. */
export function TeamScene() {
const { kf, tr } = useScene(5);
return (
<Scene>
<Person cx={44} cy={84} />
<Person cx={276} cy={84} />
<Window x={112} y={48} width={96} height={64} lines={2} />
<path d="M58 80 H112 M208 80 H262" />
<motion.path
d="M58 80 H112"
className="text-foreground"
strokeWidth={2.5}
initial={false}
animate={{ pathLength: kf([1, 1, 0, 0, 0]) }}
transition={tr([0, 0.4, 0.5, 0.52, 1])}
/>
<motion.path
d="M262 80 H208"
className="text-foreground"
strokeWidth={2.5}
initial={false}
animate={{ pathLength: kf([0, 0, 0, 1, 1]) }}
transition={tr([0, 0.55, 0.6, 0.72, 1])}
/>
<Padlock
x={152}
y={116}
className="text-foreground"
shackle={{
animate: { y: kf([0, 0, -5, -5, 0, 0]) },
transition: tr([0, 0.42, 0.5, 0.62, 0.7, 1]),
}}
/>
</Scene>
);
}
/** A request from the website crosses the bridge, drives the desktop, and reports back. */
export function RemoteScene() {
const { kf, tr } = useScene(4.8);
return (
<Scene>
<Cloud cx={64} cy={64} />
<Monitor x={232} y={62} />
<path d="M92 82 H232" strokeDasharray="2 5" />
<motion.rect
x={96}
y={78}
width={12}
height={8}
rx={2}
className="text-foreground"
initial={false}
animate={{ x: kf([0, 0, 124, 124, 0, 0]) }}
transition={tr([0, 0.08, 0.34, 0.56, 0.82, 1], { ease: "linear" })}
/>
<Cursor
className="text-foreground"
animate={{ x: kf([246, 246, 268, 268]), y: kf([88, 88, 74, 74]) }}
transition={tr([0, 0.36, 0.5, 1])}
/>
<motion.circle
cx={274}
cy={80}
className="text-foreground"
initial={false}
animate={{ r: kf([0, 0, 4, 7, 0, 0]) }}
transition={tr([0, 0.5, 0.53, 0.58, 0.62, 1])}
/>
</Scene>
);
}
+59 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { motion, useReducedMotion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuLock, LuRotateCcw, LuTrash2 } from "react-icons/lu";
@@ -27,10 +28,12 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useInputModality } from "@/hooks/use-input-modality";
import { useTrashEvents } from "@/hooks/use-trash-events";
import { translateBackendError } from "@/lib/backend-errors";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import { formatBytes } from "@/lib/format-bytes";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, TrashedProfileSummary } from "@/types";
@@ -48,6 +51,51 @@ function daysUntil(expiresAt: number, nowSeconds: number): number {
return Math.max(0, Math.ceil((expiresAt - nowSeconds) / SECONDS_PER_DAY));
}
/**
* The retention period as a ring that drains from the top: what is left of
* it reads at a glance beside the day count, and an entry about to go is the
* one whose ring is nearly gone.
*/
function RetentionRing({
deletedAt,
expiresAt,
nowSeconds,
warning,
}: {
deletedAt: number;
expiresAt: number;
nowSeconds: number;
warning: boolean;
}) {
const reduceMotion = useReducedMotion();
const modality = useInputModality();
const animate = !reduceMotion && modality === "pointer";
const total = Math.max(1, expiresAt - deletedAt);
const left = Math.max(0, Math.min(1, (expiresAt - nowSeconds) / total));
return (
<svg
data-slot="trash-retention-ring"
viewBox="0 0 16 16"
className="size-3.5 shrink-0 -rotate-90"
fill="none"
strokeWidth={2}
strokeLinecap="round"
aria-hidden="true"
>
<circle cx={8} cy={8} r={6} className="stroke-border" />
<motion.circle
cx={8}
cy={8}
r={6}
className={warning ? "stroke-warning-text" : "stroke-foreground"}
initial={animate ? { pathLength: 0 } : false}
animate={{ pathLength: left }}
transition={{ duration: animate ? 0.6 : 0, ease: MOTION_EASE_OUT }}
/>
</svg>
);
}
export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
const { t, i18n } = useTranslation();
const { entries, isLoading, error } = useTrashEvents();
@@ -265,9 +313,17 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
: "text-muted-foreground",
)}
>
{daysLeft === 0
? t("trash.expiresToday")
: t("trash.expiresIn", { count: daysLeft })}
<span className="inline-flex items-center gap-1.5">
<RetentionRing
deletedAt={entry.deleted_at}
expiresAt={entry.expires_at}
nowSeconds={nowSeconds}
warning={daysLeft <= 1}
/>
{daysLeft === 0
? t("trash.expiresToday")
: t("trash.expiresIn", { count: daysLeft })}
</span>
</TableCell>
<TableCell className="hidden @xl:table-cell text-right text-sm tabular-nums text-muted-foreground">
{formatBytes(entry.size_bytes)}
+1 -1
View File
@@ -264,7 +264,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(100dvh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6",
"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",
className,
)}
{...props}
+171 -36
View File
@@ -12,26 +12,56 @@ export interface OperationStep {
detail: ReactNode;
}
/** A measured relationship or operation, with labels independent of its marker. */
type NodeState = "done" | "active" | "failed" | "pending";
/**
* A measured relationship or operation as a row of stations.
*
* Every station before the current one is settled and wears a check; the
* current one is a ring, or a cross when the operation failed there; the
* ones after it wait as small dots. The wire between stations fills as they
* settle, and while the operation is busy a pulse travels the wire into the
* station being worked on. Reaching the last station with nothing failed
* settles the whole row.
*/
export function OperationFlow({
steps,
active,
failed = false,
busy = false,
label,
}: {
steps: OperationStep[];
/** The station the operation is at. */
active: number;
/** The operation stopped at `active`. */
failed?: boolean;
/** The operation is still working towards `active`. */
busy?: boolean;
label: string;
}) {
const reduced = useReducedMotion();
const modality = useInputModality();
const current = Math.max(0, Math.min(steps.length - 1, active));
const animate = !reduced && modality !== "keyboard";
const last = steps.length - 1;
const current = Math.max(0, Math.min(last, active));
if (steps.length < 2) return null;
const complete = !busy && !failed && current === last;
const stateOf = (index: number): NodeState => {
if (complete || index < current) return "done";
if (index === current) return failed ? "failed" : "active";
return "pending";
};
const at = (index: number) => `${(index / last) * 100}%`;
const settle = { duration: animate ? 0.35 : 0, ease: MOTION_EASE_OUT };
return (
<div
data-slot="operation-flow"
data-state={
failed ? "failed" : busy ? "busy" : complete ? "done" : "active"
}
role="group"
aria-label={label}
className="min-w-0 py-2"
@@ -39,30 +69,121 @@ export function OperationFlow({
<div
data-slot="operation-track"
aria-hidden="true"
className="relative mb-3 h-3"
className="relative mb-3 h-4"
style={{ marginInline: `${50 / steps.length}%` }}
>
<span className="absolute inset-x-0 top-[5px] h-0.5 rounded-full bg-border" />
{steps.map((step, index) => (
<span
key={step.id}
className="absolute top-1 size-1 -translate-x-1/2 rounded-full bg-muted-foreground"
style={{ left: `${(index / (steps.length - 1)) * 100}%` }}
/>
))}
<motion.span
data-slot="operation-marker"
initial={false}
animate={{ left: `${(current / (steps.length - 1)) * 100}%` }}
transition={{
duration: reduced || modality === "keyboard" ? 0 : 0.22,
ease: MOTION_EASE_OUT,
}}
className={cn(
"absolute top-0 size-3 -translate-x-1/2 rounded-full bg-foreground",
failed && "bg-destructive",
)}
/>
{steps.slice(0, -1).map((step, index) => {
// The wire into a station fills once that station is settled.
const filled =
complete ||
index + 1 < current ||
(index + 1 === current && !busy && !failed);
const pulsing = busy && index === current - 1;
return (
<span
key={step.id}
data-slot="operation-wire"
data-filled={filled}
className="absolute top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-border"
style={{ left: at(index), width: `${100 / last}%` }}
>
<motion.span
className="absolute inset-y-0 left-0 rounded-full bg-foreground"
initial={false}
animate={{ width: filled ? "100%" : "0%" }}
transition={settle}
/>
{pulsing && (
<motion.span
data-slot="operation-pulse"
className="absolute top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground"
initial={false}
animate={{ left: animate ? ["0%", "100%"] : "100%" }}
transition={
animate
? {
duration: 1.1,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}
: { duration: 0 }
}
/>
)}
</span>
);
})}
{steps.map((step, index) => {
const state = stateOf(index);
return (
<span
key={step.id}
data-slot={
index === current ? "operation-marker" : "operation-node"
}
data-node-state={state}
className={cn(
"absolute top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full",
state === "pending"
? "size-1.5 bg-muted-foreground"
: "size-3.5",
state === "done" && "bg-foreground text-background",
state === "active" &&
"border-2 border-foreground bg-background",
state === "failed" &&
"border-2 border-destructive bg-background text-destructive-text",
)}
style={{ left: at(index) }}
>
{state === "done" && (
<svg
aria-hidden="true"
viewBox="0 0 10 10"
className="size-2"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.path
d="M2 5.2 L4.2 7.4 L8 3"
initial={animate ? { pathLength: 0 } : false}
animate={{ pathLength: 1 }}
transition={{
duration: animate ? 0.25 : 0,
ease: "easeOut",
}}
/>
</svg>
)}
{state === "failed" && (
<svg
aria-hidden="true"
viewBox="0 0 10 10"
className="size-2"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
>
<motion.path
d="M2.5 2.5 L7.5 7.5 M7.5 2.5 L2.5 7.5"
initial={animate ? { pathLength: 0 } : false}
animate={{ pathLength: 1 }}
transition={{
duration: animate ? 0.25 : 0,
ease: "easeOut",
}}
/>
</svg>
)}
{state === "active" && (
<span className="size-1 rounded-full bg-foreground" />
)}
</span>
);
})}
</div>
<ol
className="grid"
@@ -70,18 +191,32 @@ export function OperationFlow({
gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))`,
}}
>
{steps.map((step, index) => (
<li
key={step.id}
aria-current={index === current ? "step" : undefined}
className="min-w-0 px-1.5 text-center"
>
<p className="break-words text-xs font-medium">{step.label}</p>
<div className="mt-1 break-words text-xs leading-relaxed text-muted-foreground">
{step.detail}
</div>
</li>
))}
{steps.map((step, index) => {
const state = stateOf(index);
return (
<li
key={step.id}
aria-current={index === current ? "step" : undefined}
data-node-state={state}
className="min-w-0 px-1.5 text-center"
>
<p
className={cn(
"break-words text-xs font-medium transition-colors duration-200",
state === "pending"
? "text-muted-foreground"
: "text-foreground",
state === "failed" && "text-destructive-text",
)}
>
{step.label}
</p>
<div className="mt-1 break-words text-xs leading-relaxed text-muted-foreground">
{step.detail}
</div>
</li>
);
})}
</ol>
</div>
);
+3
View File
@@ -5,6 +5,8 @@ import type { CloudAuthState, CloudUser } from "@/types";
interface UseCloudAuthReturn {
user: CloudUser | null;
/** When this desktop signed in, as the backend recorded it. */
loggedInAt: string | null;
isLoggedIn: boolean;
isLoading: boolean;
exchangeDeviceCode: (code: string) => Promise<CloudAuthState>;
@@ -77,6 +79,7 @@ export function useCloudAuth(): UseCloudAuthReturn {
return {
user: authState?.user ?? null,
loggedInAt: authState?.logged_in_at ?? null,
isLoggedIn: authState !== null,
isLoading,
exchangeDeviceCode,
+194
View File
@@ -0,0 +1,194 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TipsDialogMode } from "@/components/tips-dialog";
import { effectivePlanOf, getEntitlements } from "@/lib/entitlements";
import {
FRESH_LOGIN_WINDOW_MS,
isPlanTip,
pickAutoTip,
TIP_AUTO_DELAY_MS,
type TipDefinition,
type TipId,
tipsFor,
} from "@/lib/tips";
import type { CloudUser } from "@/types";
/** Mirror of `settings_manager::TipsState`. */
export interface TipsState {
auto_show: boolean;
seen: string[];
last_auto_shown_at: number | null;
auto_due: boolean;
}
export interface TipsDialogState {
open: boolean;
mode: TipsDialogMode;
initialTipId: TipId | null;
auto: boolean;
/** Bumped on every open so the dialog remounts with fresh navigation state. */
session: number;
}
interface PaidWelcomeState {
plan: string;
status: "pending" | "open" | "done";
}
interface UseTipsOptions {
cloudUser: CloudUser | null;
loggedInAt: string | null;
/**
* The app is settled enough to put a dialog in front of the user: not the
* first-run session, terms accepted, nothing else blocking.
*/
ready: boolean;
}
/**
* The tips flow: which tips this install may see, what has been seen, the
* one tip a day that opens by itself, and the welcome for an account that
* just turned paid. State lives in the app settings; this hook only decides.
*/
export function useTips({ cloudUser, loggedInAt, ready }: UseTipsOptions) {
const [state, setState] = useState<TipsState | null>(null);
const [dialog, setDialog] = useState<TipsDialogState>({
open: false,
mode: "browse",
initialTipId: null,
auto: false,
session: 0,
});
const [paidWelcome, setPaidWelcome] = useState<PaidWelcomeState | null>(null);
const entitlements = useMemo(() => getEntitlements(cloudUser), [cloudUser]);
const tips = useMemo(() => tipsFor(entitlements), [entitlements]);
const planTips = useMemo(() => tips.filter(isPlanTip), [tips]);
useEffect(() => {
let cancelled = false;
invoke<TipsState>("get_tips_state")
.then((loaded) => {
if (!cancelled) setState(loaded);
})
.catch((error: unknown) => {
console.error("Failed to load the tips state:", error);
});
return () => {
cancelled = true;
};
}, []);
const openTips = useCallback(
(
initialTipId: TipId | null = null,
options: { mode?: TipsDialogMode; auto?: boolean } = {},
) => {
setDialog((previous) => ({
open: true,
mode: options.mode ?? "browse",
initialTipId,
auto: options.auto ?? false,
session: previous.session + 1,
}));
},
[],
);
const closeTips = useCallback(() => {
setDialog((previous) =>
previous.open ? { ...previous, open: false } : previous,
);
}, []);
// One observation per account and plan status. The backend remembers the
// status and answers whether this is the moment to greet a new paid plan.
const observedRef = useRef<string | null>(null);
useEffect(() => {
if (!cloudUser) return;
const paid = entitlements.active;
const key = `${cloudUser.id}:${paid ? "paid" : "free"}`;
if (observedRef.current === key) return;
observedRef.current = key;
const freshLogin =
loggedInAt !== null &&
Date.now() - Date.parse(loggedInAt) < FRESH_LOGIN_WINDOW_MS;
invoke<boolean>("observe_cloud_plan", {
userId: cloudUser.id,
paid,
freshLogin,
})
.then((due) => {
if (!due) return;
setPaidWelcome({ plan: effectivePlanOf(cloudUser), status: "pending" });
})
.catch((error: unknown) => {
console.error("Failed to record the cloud plan:", error);
});
}, [cloudUser, entitlements.active, loggedInAt]);
// The welcome waits for a quiet moment: the app settled and no tip open.
useEffect(() => {
if (!ready || dialog.open || paidWelcome?.status !== "pending") return;
setPaidWelcome({ plan: paidWelcome.plan, status: "open" });
}, [ready, dialog.open, paidWelcome]);
// The automatic tip: one unseen tip, a moment after the app settles, and
// never on top of the paid welcome. Marked handled only once it opens, so a
// welcome arriving during the delay simply takes its place.
const autoHandledRef = useRef(false);
useEffect(() => {
if (!ready || !state || autoHandledRef.current) return;
if (!state.auto_due || dialog.open) return;
if (paidWelcome && paidWelcome.status !== "done") return;
const tip = pickAutoTip(tips, state.seen);
if (!tip) return;
const timer = window.setTimeout(() => {
autoHandledRef.current = true;
openTips(tip.id, { mode: "single", auto: true });
}, TIP_AUTO_DELAY_MS);
return () => window.clearTimeout(timer);
}, [ready, state, dialog.open, paidWelcome, tips, openTips]);
const markSeen = useCallback(async (id: TipId, auto: boolean) => {
try {
setState(await invoke<TipsState>("mark_tip_seen", { tipId: id, auto }));
} catch (error) {
console.error("Failed to remember the tip as seen:", error);
}
}, []);
const setAutoShow = useCallback(async (enabled: boolean) => {
try {
setState(await invoke<TipsState>("set_tips_auto_show", { enabled }));
} catch (error) {
console.error("Failed to save the tips preference:", error);
}
}, []);
const dismissPaidWelcome = useCallback(() => {
setPaidWelcome((previous) =>
previous ? { ...previous, status: "done" } : previous,
);
}, []);
return {
tips,
planTips,
seen: state?.seen ?? [],
autoShow: state?.auto_show ?? true,
dialog,
openTips,
closeTips,
markSeen,
setAutoShow,
paidWelcome: {
open: paidWelcome?.status === "open",
plan: paidWelcome?.plan ?? "",
},
dismissPaidWelcome,
};
}
export type TipsFlow = ReturnType<typeof useTips>;
export type { TipDefinition };
+148 -4
View File
@@ -1982,7 +1982,9 @@
"historyTitle": "Recent checks",
"historyEmpty": "No checks recorded yet.",
"historyOk": "Passed",
"historyFailed": "Failed"
"historyFailed": "Failed",
"trendLabel": "Latency of the last {{count}} checks, newest on the right",
"trendPeak": "Slowest: {{ms}} ms"
},
"vpnCheck": {
"valid": "VPN \"{{name}}\" configuration is valid",
@@ -2341,7 +2343,8 @@
"syncSessionUnavailable": "The sync session cannot be reached right now.",
"syncDisplayUnavailable": "The display size could not be read, so the windows cannot be arranged.",
"syncDisplayTooSmall": "The display is too small for {{windows}} windows in that layout.",
"syncArrangeFailed": "No window could be moved."
"syncArrangeFailed": "No window could be moved.",
"extensionPathInvalid": "That path is not allowed: it contains '..'."
},
"rail": {
"profiles": "Profiles",
@@ -2358,7 +2361,9 @@
"about": "About Donut Browser",
"aboutHint": "Version and app information",
"trash": "Trash",
"trashHint": "Restore deleted profiles"
"trashHint": "Restore deleted profiles",
"tips": "Tips",
"tipsHint": "Feature walkthroughs"
},
"network": "Network",
"integrations": "Integrations",
@@ -2475,7 +2480,8 @@
"goSettings": "Go to Settings",
"goCookieBot": "Cookie Bot",
"goTrash": "Go to Trash",
"goAgent": "Go to Agent"
"goAgent": "Go to Agent",
"openTips": "Open tips"
},
"closeConfirm": {
"title": "Close Donut Browser?",
@@ -3335,5 +3341,143 @@
"urlPlaceholder": "https://example.com",
"folderPlaceholder": "Optional",
"saved": "Group bookmarks saved"
},
"tips": {
"title": "Tips",
"essentials": "Essentials",
"planSection": "Included in your plan",
"count": "{{current}} of {{total}}",
"previous": "Previous tip",
"next": "Next tip",
"done": "Done",
"autoShow": "Show a tip when Donut starts",
"items": {
"dnsBlocklist": {
"label": "DNS blocking",
"title": "Block ads and trackers before they load",
"body": "Every profile can carry its own DNS blocklist. Pick a level in the profile's DNS column; the higher levels also stop tracking and malware domains at the network level.",
"action": "Open DNS settings"
},
"proxyCheck": {
"label": "Proxy check",
"title": "Check a proxy before you launch",
"body": "The connection check reports the exit IP, country, latency and whether UDP passes. Run it from the Network page or a profile row, and read the trail of past checks to spot a proxy that is going bad.",
"action": "Open Network"
},
"groups": {
"label": "Groups",
"title": "Switch groups from the keyboard",
"body": "Groups keep related profiles together, and each group gets a number: {{mod}}+1 to {{mod}}+9 switches the list instantly.",
"action": "Open Groups"
},
"commandPalette": {
"label": "Command palette",
"title": "Every page is one chord away",
"body": "{{mod}}+K opens the command palette. Type a few letters of a page or an action and press Enter.",
"action": "Open the palette"
},
"fingerprintGate": {
"label": "Fingerprint gate",
"title": "Keep the fingerprint true to the exit",
"body": "Before a launch, Donut compares the proxy exit's timezone and language with the profile's fingerprint and stops a mismatch. Fix the fingerprint, or turn the gate off under Advanced if you know what you are doing.",
"action": "Open Advanced settings"
},
"profilePassword": {
"label": "Profile password",
"title": "Lock a profile with a password",
"body": "A password-protected profile is encrypted on disk and decrypted only while it runs. Set the password from the profile's menu.",
"action": "Open Profiles"
},
"clearOnClose": {
"label": "Clear on close",
"title": "Start clean every time",
"body": "With Clear on close, a profile drops its cookies, storage and history when its window closes. Good for one-off sessions and shared machines.",
"action": "Open Profiles"
},
"defaultBrowser": {
"label": "Default browser",
"title": "Open every link in the right profile",
"body": "Make Donut your default browser and each link from another app asks which profile should open it.",
"action": "Open default browser settings"
},
"extensionGroups": {
"label": "Extension groups",
"title": "Share one extension set across profiles",
"body": "Put extensions in an extension group and assign the group to profiles. Change the group once and every profile follows at its next launch.",
"action": "Open Extensions"
},
"selfHostedSync": {
"label": "Self-hosted sync",
"title": "Back up to your own server",
"body": "Point Donut at a self-hosted donut-sync server and profiles, proxies and groups mirror to it. Add an end-to-end password and the server only ever sees ciphertext.",
"action": "Open Account"
},
"trash": {
"label": "Trash",
"title": "Deleted profiles wait in the trash",
"body": "A deleted profile stays in the trash for 30 days by default and comes back with everything in it. The retention period is under Advanced settings.",
"action": "Open Trash"
},
"localApi": {
"label": "API and MCP",
"title": "Automate Donut from scripts and agents",
"body": "The local REST API and MCP server let scripts and AI agents list, create and configure profiles. Turn them on under Integrations and copy the token.",
"action": "Open Integrations"
},
"importProfiles": {
"label": "Import",
"title": "Bring profiles over from Chrome, Edge or Brave",
"body": "Import copies cookies, logins and extensions from a Chromium profile or an archive into a new Donut profile, ready to launch.",
"action": "Open Import"
},
"cloudBackup": {
"label": "Cloud sync",
"title": "Your profiles on every device",
"body": "Cloud sync backs up profiles, proxies and groups and restores them on another machine. Turn it on per profile from the sync column.",
"action": "Open Account"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "Warm profiles overnight",
"body": "Cookie Bot browses real sites on a schedule from a remote host, so a fresh profile builds a natural history before you use it.",
"action": "Open Cookie Bot"
},
"crossOs": {
"label": "Cross-OS fingerprint",
"title": "Present as any operating system",
"body": "A cross-OS fingerprint lets a profile report macOS, Windows or Linux whatever machine it runs on. Pick the platform when you create or edit the fingerprint.",
"action": "Open Profiles"
},
"automation": {
"label": "Automation",
"title": "Launch and drive profiles from code",
"body": "The run, open-url and kill endpoints start real profiles from your scripts, and every launched profile exposes a CDP endpoint for Playwright or Puppeteer.",
"action": "Open Integrations"
},
"agent": {
"label": "Agent",
"title": "Hand the clicking to an agent",
"body": "Describe a task and the agent drives a profile step by step, records what it did and can replay it as a recipe.",
"action": "Open Agent"
},
"team": {
"label": "Team locks",
"title": "Share profiles without collisions",
"body": "In a team, a running profile is locked for everyone else and released when it closes. The Account page shows who holds what.",
"action": "Open Account"
},
"remoteControl": {
"label": "Remote control",
"title": "Drive this desktop from donutbrowser.com",
"body": "With remote control on, agents on the website reach this machine's profiles over an outbound bridge. Turn it on under Integrations.",
"action": "Open Integrations"
}
}
},
"paidWelcome": {
"title": "Welcome to {{plan}}",
"body": "Your plan just unlocked these. Each tip shows one of them working.",
"cta": "Show me",
"later": "Later"
}
}
+148 -4
View File
@@ -1992,7 +1992,9 @@
"historyTitle": "Comprobaciones recientes",
"historyEmpty": "Todavía no hay comprobaciones registradas.",
"historyOk": "Correcta",
"historyFailed": "Fallida"
"historyFailed": "Fallida",
"trendLabel": "Latencia de las últimas {{count}} comprobaciones, la más reciente a la derecha",
"trendPeak": "Más lenta: {{ms}} ms"
},
"vpnCheck": {
"valid": "La configuración de VPN \"{{name}}\" es válida",
@@ -2351,7 +2353,8 @@
"syncSessionUnavailable": "No se puede acceder ahora a la sesión de sincronización.",
"syncDisplayUnavailable": "No se pudo leer el tamaño de la pantalla, así que las ventanas no se pueden ordenar.",
"syncDisplayTooSmall": "La pantalla es demasiado pequeña para {{windows}} ventanas en esa disposición.",
"syncArrangeFailed": "No se pudo mover ninguna ventana."
"syncArrangeFailed": "No se pudo mover ninguna ventana.",
"extensionPathInvalid": "Esa ruta no está permitida: contiene '..'."
},
"rail": {
"profiles": "Perfiles",
@@ -2368,7 +2371,9 @@
"about": "Acerca de Donut Browser",
"aboutHint": "Versión e información de la aplicación",
"trash": "Papelera",
"trashHint": "Restaurar perfiles eliminados"
"trashHint": "Restaurar perfiles eliminados",
"tips": "Consejos",
"tipsHint": "Recorridos por las funciones"
},
"network": "Red",
"integrations": "Integraciones",
@@ -2485,7 +2490,8 @@
"goSettings": "Ir a Configuración",
"goCookieBot": "Cookie Bot",
"goTrash": "Ir a la Papelera",
"goAgent": "Ir a Agente"
"goAgent": "Ir a Agente",
"openTips": "Abrir consejos"
},
"closeConfirm": {
"title": "¿Cerrar Donut Browser?",
@@ -3368,5 +3374,143 @@
"urlPlaceholder": "https://ejemplo.com",
"folderPlaceholder": "Opcional",
"saved": "Marcadores del grupo guardados"
},
"tips": {
"title": "Consejos",
"essentials": "Básicos",
"planSection": "Incluido en tu plan",
"count": "{{current}} de {{total}}",
"previous": "Consejo anterior",
"next": "Siguiente consejo",
"done": "Listo",
"autoShow": "Mostrar un consejo al iniciar Donut",
"items": {
"dnsBlocklist": {
"label": "Bloqueo DNS",
"title": "Bloquea anuncios y rastreadores antes de que carguen",
"body": "Cada perfil puede llevar su propia lista de bloqueo DNS. Elige un nivel en la columna DNS del perfil; los niveles altos también detienen dominios de rastreo y malware a nivel de red.",
"action": "Abrir ajustes de DNS"
},
"proxyCheck": {
"label": "Comprobar proxy",
"title": "Comprueba un proxy antes de lanzar",
"body": "La comprobación de conexión informa la IP de salida, el país, la latencia y si pasa UDP. Ejecútala desde la página Red o desde una fila de perfil, y revisa el historial de comprobaciones para detectar un proxy que empieza a fallar.",
"action": "Abrir Red"
},
"groups": {
"label": "Grupos",
"title": "Cambia de grupo con el teclado",
"body": "Los grupos mantienen juntos los perfiles relacionados, y cada grupo recibe un número: {{mod}}+1 a {{mod}}+9 cambia la lista al instante.",
"action": "Abrir Grupos"
},
"commandPalette": {
"label": "Paleta de comandos",
"title": "Cada página está a un atajo de distancia",
"body": "{{mod}}+K abre la paleta de comandos. Escribe unas letras de una página o una acción y pulsa Intro.",
"action": "Abrir la paleta"
},
"fingerprintGate": {
"label": "Control de huella",
"title": "Mantén la huella coherente con la salida",
"body": "Antes de lanzar, Donut compara la zona horaria y el idioma de la salida del proxy con la huella del perfil y detiene cualquier discrepancia. Corrige la huella o desactiva el bloqueo en Avanzado si sabes lo que haces.",
"action": "Abrir ajustes avanzados"
},
"profilePassword": {
"label": "Contraseña de perfil",
"title": "Protege un perfil con contraseña",
"body": "Un perfil protegido con contraseña se cifra en disco y solo se descifra mientras se ejecuta. Define la contraseña desde el menú del perfil.",
"action": "Abrir Perfiles"
},
"clearOnClose": {
"label": "Limpiar al cerrar",
"title": "Empieza limpio cada vez",
"body": "Con Limpiar al cerrar, un perfil descarta sus cookies, almacenamiento e historial al cerrar su ventana. Ideal para sesiones puntuales y equipos compartidos.",
"action": "Abrir Perfiles"
},
"defaultBrowser": {
"label": "Navegador predeterminado",
"title": "Abre cada enlace en el perfil correcto",
"body": "Haz de Donut tu navegador predeterminado y cada enlace de otra aplicación preguntará qué perfil debe abrirlo.",
"action": "Abrir ajustes del navegador predeterminado"
},
"extensionGroups": {
"label": "Grupos de extensiones",
"title": "Comparte un mismo conjunto de extensiones entre perfiles",
"body": "Pon las extensiones en un grupo de extensiones y asigna el grupo a los perfiles. Cambia el grupo una vez y cada perfil lo sigue en su próximo lanzamiento.",
"action": "Abrir Extensiones"
},
"selfHostedSync": {
"label": "Sincronización propia",
"title": "Haz copias en tu propio servidor",
"body": "Apunta Donut a un servidor donut-sync autoalojado y los perfiles, proxies y grupos se replicarán en él. Añade una contraseña de extremo a extremo y el servidor solo verá texto cifrado.",
"action": "Abrir Cuenta"
},
"trash": {
"label": "Papelera",
"title": "Los perfiles eliminados esperan en la papelera",
"body": "Un perfil eliminado permanece en la papelera 30 días de forma predeterminada y vuelve con todo su contenido. El periodo de retención está en los ajustes avanzados.",
"action": "Abrir Papelera"
},
"localApi": {
"label": "API y MCP",
"title": "Automatiza Donut desde scripts y agentes",
"body": "La API REST local y el servidor MCP permiten que scripts y agentes de IA listen, creen y configuren perfiles. Actívalos en Integraciones y copia el token.",
"action": "Abrir Integraciones"
},
"importProfiles": {
"label": "Importar",
"title": "Trae perfiles desde Chrome, Edge o Brave",
"body": "La importación copia cookies, inicios de sesión y extensiones de un perfil Chromium o de un archivo a un nuevo perfil de Donut, listo para lanzar.",
"action": "Abrir Importar"
},
"cloudBackup": {
"label": "Sincronización en la nube",
"title": "Tus perfiles en todos tus dispositivos",
"body": "La sincronización en la nube respalda perfiles, proxies y grupos y los restaura en otra máquina. Actívala por perfil desde la columna de sincronización.",
"action": "Abrir Cuenta"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "Calienta perfiles durante la noche",
"body": "Cookie Bot navega por sitios reales según un horario desde un host remoto, para que un perfil nuevo construya un historial natural antes de que lo uses.",
"action": "Abrir Cookie Bot"
},
"crossOs": {
"label": "Huella multi-SO",
"title": "Preséntate como cualquier sistema operativo",
"body": "Una huella multi-SO permite que un perfil informe macOS, Windows o Linux sea cual sea la máquina donde se ejecuta. Elige la plataforma al crear o editar la huella.",
"action": "Abrir Perfiles"
},
"automation": {
"label": "Automatización",
"title": "Lanza y controla perfiles desde código",
"body": "Los endpoints run, open-url y kill inician perfiles reales desde tus scripts, y cada perfil lanzado expone un endpoint CDP para Playwright o Puppeteer.",
"action": "Abrir Integraciones"
},
"agent": {
"label": "Agente",
"title": "Deja los clics a un agente",
"body": "Describe una tarea y el agente controla un perfil paso a paso, registra lo que hizo y puede repetirlo como una receta.",
"action": "Abrir Agente"
},
"team": {
"label": "Bloqueos de equipo",
"title": "Comparte perfiles sin choques",
"body": "En un equipo, un perfil en ejecución queda bloqueado para los demás y se libera al cerrarse. La página Cuenta muestra quién tiene cada uno.",
"action": "Abrir Cuenta"
},
"remoteControl": {
"label": "Control remoto",
"title": "Controla este equipo desde donutbrowser.com",
"body": "Con el control remoto activado, los agentes del sitio web llegan a los perfiles de esta máquina a través de un puente saliente. Actívalo en Integraciones.",
"action": "Abrir Integraciones"
}
}
},
"paidWelcome": {
"title": "Te damos la bienvenida a {{plan}}",
"body": "Tu plan acaba de desbloquear esto. Cada consejo muestra una función en acción.",
"cta": "Enséñame",
"later": "Más tarde"
}
}
+148 -4
View File
@@ -1992,7 +1992,9 @@
"historyTitle": "Vérifications récentes",
"historyEmpty": "Aucune vérification enregistrée pour l'instant.",
"historyOk": "Réussie",
"historyFailed": "Échouée"
"historyFailed": "Échouée",
"trendLabel": "Latence des {{count}} derniers tests, le plus récent à droite",
"trendPeak": "Le plus lent : {{ms}} ms"
},
"vpnCheck": {
"valid": "La configuration VPN « {{name}} » est valide",
@@ -2351,7 +2353,8 @@
"syncSessionUnavailable": "La session de synchronisation est inaccessible pour le moment.",
"syncDisplayUnavailable": "La taille de l'écran n'a pas pu être lue, les fenêtres ne peuvent donc pas être rangées.",
"syncDisplayTooSmall": "L'écran est trop petit pour {{windows}} fenêtres dans cette disposition.",
"syncArrangeFailed": "Aucune fenêtre n'a pu être déplacée."
"syncArrangeFailed": "Aucune fenêtre n'a pu être déplacée.",
"extensionPathInvalid": "Ce chemin n'est pas autorisé : il contient « .. »."
},
"rail": {
"profiles": "Profils",
@@ -2368,7 +2371,9 @@
"about": "À propos de Donut Browser",
"aboutHint": "Version et informations sur l'application",
"trash": "Corbeille",
"trashHint": "Restaurer des profils supprimés"
"trashHint": "Restaurer des profils supprimés",
"tips": "Astuces",
"tipsHint": "Découverte des fonctions"
},
"network": "Réseau",
"integrations": "Intégrations",
@@ -2485,7 +2490,8 @@
"goSettings": "Aller à Paramètres",
"goCookieBot": "Cookie Bot",
"goTrash": "Aller à la Corbeille",
"goAgent": "Aller à Agent"
"goAgent": "Aller à Agent",
"openTips": "Ouvrir les astuces"
},
"closeConfirm": {
"title": "Fermer Donut Browser ?",
@@ -3368,5 +3374,143 @@
"urlPlaceholder": "https://exemple.com",
"folderPlaceholder": "Facultatif",
"saved": "Favoris du groupe enregistrés"
},
"tips": {
"title": "Astuces",
"essentials": "Essentiels",
"planSection": "Inclus dans votre offre",
"count": "{{current}} sur {{total}}",
"previous": "Astuce précédente",
"next": "Astuce suivante",
"done": "Terminé",
"autoShow": "Afficher une astuce au démarrage de Donut",
"items": {
"dnsBlocklist": {
"label": "Blocage DNS",
"title": "Bloquez les publicités et les traqueurs avant leur chargement",
"body": "Chaque profil peut avoir sa propre liste de blocage DNS. Choisissez un niveau dans la colonne DNS du profil ; les niveaux élevés bloquent aussi les domaines de pistage et de logiciels malveillants au niveau du réseau.",
"action": "Ouvrir les réglages DNS"
},
"proxyCheck": {
"label": "Test de proxy",
"title": "Vérifiez un proxy avant de lancer",
"body": "Le test de connexion indique l'IP de sortie, le pays, la latence et si l'UDP passe. Lancez-le depuis la page Réseau ou une ligne de profil, et consultez l'historique des tests pour repérer un proxy qui se dégrade.",
"action": "Ouvrir Réseau"
},
"groups": {
"label": "Groupes",
"title": "Changez de groupe au clavier",
"body": "Les groupes rassemblent les profils liés, et chaque groupe reçoit un numéro : {{mod}}+1 à {{mod}}+9 change la liste instantanément.",
"action": "Ouvrir Groupes"
},
"commandPalette": {
"label": "Palette de commandes",
"title": "Chaque page est à un raccourci",
"body": "{{mod}}+K ouvre la palette de commandes. Tapez quelques lettres d'une page ou d'une action, puis appuyez sur Entrée.",
"action": "Ouvrir la palette"
},
"fingerprintGate": {
"label": "Contrôle d'empreinte",
"title": "Gardez l'empreinte cohérente avec la sortie",
"body": "Avant un lancement, Donut compare le fuseau horaire et la langue de la sortie du proxy avec l'empreinte du profil et bloque toute incohérence. Corrigez l'empreinte, ou désactivez le blocage dans Avancé si vous savez ce que vous faites.",
"action": "Ouvrir les réglages avancés"
},
"profilePassword": {
"label": "Mot de passe de profil",
"title": "Verrouillez un profil avec un mot de passe",
"body": "Un profil protégé par mot de passe est chiffré sur le disque et déchiffré uniquement pendant son exécution. Définissez le mot de passe depuis le menu du profil.",
"action": "Ouvrir Profils"
},
"clearOnClose": {
"label": "Effacer à la fermeture",
"title": "Repartez de zéro à chaque fois",
"body": "Avec Effacer à la fermeture, un profil abandonne ses cookies, son stockage et son historique quand sa fenêtre se ferme. Idéal pour les sessions ponctuelles et les machines partagées.",
"action": "Ouvrir Profils"
},
"defaultBrowser": {
"label": "Navigateur par défaut",
"title": "Ouvrez chaque lien dans le bon profil",
"body": "Faites de Donut votre navigateur par défaut et chaque lien venant d'une autre application demandera quel profil doit l'ouvrir.",
"action": "Ouvrir les réglages du navigateur par défaut"
},
"extensionGroups": {
"label": "Groupes d'extensions",
"title": "Partagez un même jeu d'extensions entre profils",
"body": "Placez les extensions dans un groupe d'extensions et assignez le groupe aux profils. Modifiez le groupe une fois et chaque profil suit à son prochain lancement.",
"action": "Ouvrir Extensions"
},
"selfHostedSync": {
"label": "Synchro auto-hébergée",
"title": "Sauvegardez sur votre propre serveur",
"body": "Pointez Donut vers un serveur donut-sync auto-hébergé et les profils, proxys et groupes s'y répliquent. Ajoutez un mot de passe de bout en bout et le serveur ne verra jamais que du texte chiffré.",
"action": "Ouvrir Compte"
},
"trash": {
"label": "Corbeille",
"title": "Les profils supprimés attendent dans la corbeille",
"body": "Un profil supprimé reste 30 jours dans la corbeille par défaut et revient avec tout son contenu. La durée de conservation se règle dans les réglages avancés.",
"action": "Ouvrir Corbeille"
},
"localApi": {
"label": "API et MCP",
"title": "Automatisez Donut depuis des scripts et des agents",
"body": "L'API REST locale et le serveur MCP permettent aux scripts et aux agents IA de lister, créer et configurer des profils. Activez-les dans Intégrations et copiez le jeton.",
"action": "Ouvrir Intégrations"
},
"importProfiles": {
"label": "Importer",
"title": "Importez des profils depuis Chrome, Edge ou Brave",
"body": "L'import copie les cookies, les identifiants et les extensions d'un profil Chromium ou d'une archive vers un nouveau profil Donut, prêt à lancer.",
"action": "Ouvrir Importer"
},
"cloudBackup": {
"label": "Synchro cloud",
"title": "Vos profils sur tous vos appareils",
"body": "La synchronisation cloud sauvegarde les profils, proxys et groupes et les restaure sur une autre machine. Activez-la par profil depuis la colonne de synchronisation.",
"action": "Ouvrir Compte"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "Chauffez vos profils pendant la nuit",
"body": "Cookie Bot navigue sur de vrais sites selon un planning depuis un hôte distant, pour qu'un profil neuf se construise un historique naturel avant que vous l'utilisiez.",
"action": "Ouvrir Cookie Bot"
},
"crossOs": {
"label": "Empreinte multi-OS",
"title": "Présentez-vous sous n'importe quel système",
"body": "Une empreinte multi-OS permet à un profil d'annoncer macOS, Windows ou Linux quelle que soit la machine. Choisissez la plateforme à la création ou à la modification de l'empreinte.",
"action": "Ouvrir Profils"
},
"automation": {
"label": "Automatisation",
"title": "Lancez et pilotez des profils depuis du code",
"body": "Les points de terminaison run, open-url et kill démarrent de vrais profils depuis vos scripts, et chaque profil lancé expose un point de terminaison CDP pour Playwright ou Puppeteer.",
"action": "Ouvrir Intégrations"
},
"agent": {
"label": "Agent",
"title": "Confiez les clics à un agent",
"body": "Décrivez une tâche et l'agent pilote un profil étape par étape, enregistre ce qu'il a fait et peut le rejouer comme une recette.",
"action": "Ouvrir Agent"
},
"team": {
"label": "Verrous d'équipe",
"title": "Partagez des profils sans collision",
"body": "Dans une équipe, un profil en cours d'exécution est verrouillé pour les autres et libéré à sa fermeture. La page Compte montre qui détient quoi.",
"action": "Ouvrir Compte"
},
"remoteControl": {
"label": "Contrôle à distance",
"title": "Pilotez ce poste depuis donutbrowser.com",
"body": "Avec le contrôle à distance activé, les agents du site web atteignent les profils de cette machine par un pont sortant. Activez-le dans Intégrations.",
"action": "Ouvrir Intégrations"
}
}
},
"paidWelcome": {
"title": "Bienvenue dans {{plan}}",
"body": "Votre offre vient de débloquer ceci. Chaque astuce montre une fonction en action.",
"cta": "Montrez-moi",
"later": "Plus tard"
}
}
+148 -4
View File
@@ -1982,7 +1982,9 @@
"historyTitle": "最近のチェック",
"historyEmpty": "まだチェックの記録がありません。",
"historyOk": "成功",
"historyFailed": "失敗"
"historyFailed": "失敗",
"trendLabel": "直近 {{count}} 回のチェックのレイテンシ(右端が最新)",
"trendPeak": "最も遅い: {{ms}} ms"
},
"vpnCheck": {
"valid": "VPN「{{name}}」の構成は有効です",
@@ -2341,7 +2343,8 @@
"syncSessionUnavailable": "いま同期セッションにアクセスできません。",
"syncDisplayUnavailable": "画面の大きさを読み取れなかったため、ウィンドウを並べられません。",
"syncDisplayTooSmall": "その配置で {{windows}} 個のウィンドウを並べるには画面が小さすぎます。",
"syncArrangeFailed": "動かせたウィンドウはありませんでした。"
"syncArrangeFailed": "動かせたウィンドウはありませんでした。",
"extensionPathInvalid": "そのパスは使用できません。'..' が含まれています。"
},
"rail": {
"profiles": "プロファイル",
@@ -2358,7 +2361,9 @@
"about": "Donut Browser について",
"aboutHint": "バージョンとアプリ情報",
"trash": "ゴミ箱",
"trashHint": "削除したプロファイルを復元"
"trashHint": "削除したプロファイルを復元",
"tips": "ヒント",
"tipsHint": "機能の使い方"
},
"network": "ネットワーク",
"integrations": "連携",
@@ -2475,7 +2480,8 @@
"goSettings": "設定へ移動",
"goCookieBot": "Cookie Bot",
"goTrash": "ゴミ箱へ移動",
"goAgent": "エージェントへ移動"
"goAgent": "エージェントへ移動",
"openTips": "ヒントを開く"
},
"closeConfirm": {
"title": "Donut Browser を閉じますか?",
@@ -3335,5 +3341,143 @@
"urlPlaceholder": "https://example.com",
"folderPlaceholder": "任意",
"saved": "グループのブックマークを保存しました"
},
"tips": {
"title": "ヒント",
"essentials": "基本",
"planSection": "プランに含まれる機能",
"count": "{{current}} / {{total}}",
"previous": "前のヒント",
"next": "次のヒント",
"done": "完了",
"autoShow": "Donut の起動時にヒントを表示",
"items": {
"dnsBlocklist": {
"label": "DNS ブロック",
"title": "広告とトラッカーを読み込み前にブロック",
"body": "各プロファイルは独自の DNS ブロックリストを持てます。プロファイルの DNS 列でレベルを選んでください。高いレベルではトラッキングやマルウェアのドメインもネットワーク層で止めます。",
"action": "DNS 設定を開く"
},
"proxyCheck": {
"label": "プロキシ確認",
"title": "起動前にプロキシを確認",
"body": "接続チェックは出口 IP、国、レイテンシ、UDP が通るかを報告します。ネットワークページやプロファイル行から実行し、過去のチェック履歴で劣化しつつあるプロキシを見つけてください。",
"action": "ネットワークを開く"
},
"groups": {
"label": "グループ",
"title": "キーボードでグループを切り替え",
"body": "グループは関連するプロファイルをまとめ、各グループに番号が付きます。{{mod}}+1 から {{mod}}+9 で一覧を即座に切り替えられます。",
"action": "グループを開く"
},
"commandPalette": {
"label": "コマンドパレット",
"title": "どのページもショートカット一つで",
"body": "{{mod}}+K でコマンドパレットが開きます。ページや操作の名前を数文字入力して Enter を押してください。",
"action": "パレットを開く"
},
"fingerprintGate": {
"label": "フィンガープリント検査",
"title": "フィンガープリントを出口と一致させる",
"body": "起動前に Donut はプロキシ出口のタイムゾーンと言語をプロファイルのフィンガープリントと比較し、不一致があれば起動を止めます。フィンガープリントを修正するか、理解した上で「詳細設定」でゲートを無効にしてください。",
"action": "詳細設定を開く"
},
"profilePassword": {
"label": "プロファイルのパスワード",
"title": "パスワードでプロファイルを保護",
"body": "パスワード保護されたプロファイルはディスク上で暗号化され、実行中だけ復号されます。パスワードはプロファイルのメニューから設定します。",
"action": "プロファイルを開く"
},
"clearOnClose": {
"label": "閉じるときに消去",
"title": "毎回クリーンな状態で開始",
"body": "「閉じるときに消去」を有効にすると、ウィンドウを閉じた時点でプロファイルの Cookie、ストレージ、履歴が破棄されます。使い捨てのセッションや共有マシンに最適です。",
"action": "プロファイルを開く"
},
"defaultBrowser": {
"label": "既定のブラウザ",
"title": "すべてのリンクを適切なプロファイルで開く",
"body": "Donut を既定のブラウザにすると、他のアプリからのリンクごとにどのプロファイルで開くかを確認できます。",
"action": "既定のブラウザ設定を開く"
},
"extensionGroups": {
"label": "拡張機能グループ",
"title": "拡張機能のセットをプロファイル間で共有",
"body": "拡張機能を拡張機能グループに入れ、そのグループをプロファイルに割り当てます。グループを一度変更すれば、各プロファイルは次回起動時に追従します。",
"action": "拡張機能を開く"
},
"selfHostedSync": {
"label": "セルフホスト同期",
"title": "自分のサーバーにバックアップ",
"body": "セルフホストの donut-sync サーバーを指定すると、プロファイル、プロキシ、グループがそこにミラーされます。エンドツーエンドのパスワードを加えれば、サーバーには暗号文しか届きません。",
"action": "アカウントを開く"
},
"trash": {
"label": "ゴミ箱",
"title": "削除したプロファイルはゴミ箱で待機",
"body": "削除したプロファイルは既定で 30 日間ゴミ箱に残り、中身ごと復元できます。保持期間は詳細設定にあります。",
"action": "ゴミ箱を開く"
},
"localApi": {
"label": "API と MCP",
"title": "スクリプトやエージェントから Donut を自動化",
"body": "ローカルの REST API と MCP サーバーで、スクリプトや AI エージェントがプロファイルの一覧取得、作成、設定を行えます。「連携」で有効にしてトークンをコピーしてください。",
"action": "連携を開く"
},
"importProfiles": {
"label": "インポート",
"title": "Chrome、Edge、Brave からプロファイルを移行",
"body": "インポートは Chromium プロファイルやアーカイブから Cookie、ログイン情報、拡張機能を新しい Donut プロファイルにコピーし、すぐ起動できる状態にします。",
"action": "インポートを開く"
},
"cloudBackup": {
"label": "クラウド同期",
"title": "あらゆるデバイスにプロファイルを",
"body": "クラウド同期はプロファイル、プロキシ、グループをバックアップし、別のマシンで復元します。同期列からプロファイルごとに有効にしてください。",
"action": "アカウントを開く"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "夜間にプロファイルを温める",
"body": "Cookie Bot はリモートホストからスケジュールに従って実際のサイトを閲覧し、新しいプロファイルに自然な履歴を作ってから使えるようにします。",
"action": "Cookie Bot を開く"
},
"crossOs": {
"label": "クロス OS 指紋",
"title": "任意の OS として振る舞う",
"body": "クロス OS フィンガープリントを使うと、どのマシンで実行しても macOS、Windows、Linux として報告できます。フィンガープリントの作成時または編集時にプラットフォームを選んでください。",
"action": "プロファイルを開く"
},
"automation": {
"label": "自動化",
"title": "コードからプロファイルを起動・操作",
"body": "run、open-url、kill エンドポイントはスクリプトから実際のプロファイルを起動し、起動した各プロファイルは Playwright や Puppeteer 向けの CDP エンドポイントを公開します。",
"action": "連携を開く"
},
"agent": {
"label": "エージェント",
"title": "クリック作業をエージェントに任せる",
"body": "タスクを説明すると、エージェントがプロファイルを一歩ずつ操作し、実行内容を記録して、レシピとして再実行できます。",
"action": "エージェントを開く"
},
"team": {
"label": "チームロック",
"title": "衝突なしでプロファイルを共有",
"body": "チームでは、実行中のプロファイルは他のメンバーに対してロックされ、閉じると解放されます。アカウントページで誰が何を使用中か確認できます。",
"action": "アカウントを開く"
},
"remoteControl": {
"label": "リモート操作",
"title": "donutbrowser.com からこのデスクトップを操作",
"body": "リモートコントロールを有効にすると、ウェブサイト上のエージェントが送信方向のブリッジ経由でこのマシンのプロファイルにアクセスできます。「連携」で有効にしてください。",
"action": "連携を開く"
}
}
},
"paidWelcome": {
"title": "{{plan}} へようこそ",
"body": "プランで次の機能が使えるようになりました。各ヒントで実際の動きを確認できます。",
"cta": "見てみる",
"later": "あとで"
}
}
+148 -4
View File
@@ -1982,7 +1982,9 @@
"historyTitle": "최근 검사",
"historyEmpty": "아직 기록된 검사가 없습니다.",
"historyOk": "성공",
"historyFailed": "실패"
"historyFailed": "실패",
"trendLabel": "최근 {{count}}회 확인의 지연 시간, 오른쪽이 최신",
"trendPeak": "가장 느림: {{ms}} ms"
},
"vpnCheck": {
"valid": "VPN \"{{name}}\" 구성이 유효합니다",
@@ -2341,7 +2343,8 @@
"syncSessionUnavailable": "지금은 동기화 세션에 접근할 수 없습니다.",
"syncDisplayUnavailable": "화면 크기를 읽지 못해 창을 정렬할 수 없습니다.",
"syncDisplayTooSmall": "그 배치로 창 {{windows}}개를 놓기에는 화면이 너무 작습니다.",
"syncArrangeFailed": "옮겨진 창이 없습니다."
"syncArrangeFailed": "옮겨진 창이 없습니다.",
"extensionPathInvalid": "그 경로는 사용할 수 없습니다. '..'이 포함되어 있습니다."
},
"rail": {
"profiles": "프로필",
@@ -2358,7 +2361,9 @@
"about": "Donut Browser 정보",
"aboutHint": "버전 및 앱 정보",
"trash": "휴지통",
"trashHint": "삭제한 프로필 복원"
"trashHint": "삭제한 프로필 복원",
"tips": "팁",
"tipsHint": "기능 안내"
},
"network": "네트워크",
"integrations": "통합",
@@ -2475,7 +2480,8 @@
"goSettings": "설정으로 이동",
"goCookieBot": "Cookie Bot",
"goTrash": "휴지통으로 이동",
"goAgent": "에이전트로 이동"
"goAgent": "에이전트로 이동",
"openTips": "팁 열기"
},
"closeConfirm": {
"title": "Donut Browser를 닫으시겠습니까?",
@@ -3335,5 +3341,143 @@
"urlPlaceholder": "https://example.com",
"folderPlaceholder": "선택 사항",
"saved": "그룹 북마크를 저장했습니다"
},
"tips": {
"title": "팁",
"essentials": "기본",
"planSection": "내 플랜에 포함",
"count": "{{current}} / {{total}}",
"previous": "이전 팁",
"next": "다음 팁",
"done": "완료",
"autoShow": "Donut 시작 시 팁 표시",
"items": {
"dnsBlocklist": {
"label": "DNS 차단",
"title": "광고와 추적기를 로드 전에 차단",
"body": "프로필마다 자체 DNS 차단 목록을 가질 수 있습니다. 프로필의 DNS 열에서 수준을 선택하세요. 높은 수준은 추적 및 악성코드 도메인도 네트워크 단계에서 막습니다.",
"action": "DNS 설정 열기"
},
"proxyCheck": {
"label": "프록시 확인",
"title": "실행 전에 프록시 확인",
"body": "연결 확인은 출구 IP, 국가, 지연 시간, UDP 통과 여부를 알려줍니다. 네트워크 페이지나 프로필 행에서 실행하고, 지난 확인 기록으로 상태가 나빠지는 프록시를 찾아내세요.",
"action": "네트워크 열기"
},
"groups": {
"label": "그룹",
"title": "키보드로 그룹 전환",
"body": "그룹은 관련 프로필을 한데 모으고, 각 그룹에는 번호가 붙습니다. {{mod}}+1부터 {{mod}}+9까지로 목록을 즉시 전환합니다.",
"action": "그룹 열기"
},
"commandPalette": {
"label": "명령 팔레트",
"title": "모든 페이지가 단축키 하나 거리",
"body": "{{mod}}+K로 명령 팔레트를 엽니다. 페이지나 작업 이름을 몇 글자 입력하고 Enter를 누르세요.",
"action": "팔레트 열기"
},
"fingerprintGate": {
"label": "핑거프린트 검사",
"title": "핑거프린트를 출구와 일치시키기",
"body": "실행 전에 Donut은 프록시 출구의 시간대와 언어를 프로필 핑거프린트와 비교하고 불일치가 있으면 실행을 막습니다. 핑거프린트를 고치거나, 잘 알고 있다면 고급 설정에서 게이트를 끄세요.",
"action": "고급 설정 열기"
},
"profilePassword": {
"label": "프로필 비밀번호",
"title": "비밀번호로 프로필 잠그기",
"body": "비밀번호로 보호된 프로필은 디스크에서 암호화되고 실행 중에만 복호화됩니다. 프로필 메뉴에서 비밀번호를 설정하세요.",
"action": "프로필 열기"
},
"clearOnClose": {
"label": "닫을 때 지우기",
"title": "매번 깨끗하게 시작",
"body": "닫을 때 지우기를 켜면 창이 닫힐 때 프로필의 쿠키, 저장소, 기록이 삭제됩니다. 일회성 세션과 공용 컴퓨터에 좋습니다.",
"action": "프로필 열기"
},
"defaultBrowser": {
"label": "기본 브라우저",
"title": "모든 링크를 알맞은 프로필에서 열기",
"body": "Donut을 기본 브라우저로 설정하면 다른 앱에서 온 링크마다 어떤 프로필로 열지 물어봅니다.",
"action": "기본 브라우저 설정 열기"
},
"extensionGroups": {
"label": "확장 그룹",
"title": "하나의 확장 프로그램 세트를 여러 프로필과 공유",
"body": "확장 프로그램을 확장 그룹에 넣고 그 그룹을 프로필에 할당하세요. 그룹을 한 번 바꾸면 각 프로필이 다음 실행 때 따라갑니다.",
"action": "확장 프로그램 열기"
},
"selfHostedSync": {
"label": "자체 호스팅 동기화",
"title": "내 서버에 백업",
"body": "자체 호스팅한 donut-sync 서버를 지정하면 프로필, 프록시, 그룹이 그곳에 미러링됩니다. 종단 간 비밀번호를 추가하면 서버는 암호문만 봅니다.",
"action": "계정 열기"
},
"trash": {
"label": "휴지통",
"title": "삭제된 프로필은 휴지통에서 대기",
"body": "삭제된 프로필은 기본적으로 30일 동안 휴지통에 남고 내용 그대로 복원됩니다. 보관 기간은 고급 설정에 있습니다.",
"action": "휴지통 열기"
},
"localApi": {
"label": "API와 MCP",
"title": "스크립트와 에이전트로 Donut 자동화",
"body": "로컬 REST API와 MCP 서버로 스크립트와 AI 에이전트가 프로필을 나열, 생성, 설정할 수 있습니다. 통합에서 켜고 토큰을 복사하세요.",
"action": "통합 열기"
},
"importProfiles": {
"label": "가져오기",
"title": "Chrome, Edge, Brave에서 프로필 가져오기",
"body": "가져오기는 Chromium 프로필이나 아카이브의 쿠키, 로그인 정보, 확장 프로그램을 새 Donut 프로필로 복사해 바로 실행할 수 있게 합니다.",
"action": "가져오기 열기"
},
"cloudBackup": {
"label": "클라우드 동기화",
"title": "모든 기기에 내 프로필",
"body": "클라우드 동기화는 프로필, 프록시, 그룹을 백업하고 다른 컴퓨터에서 복원합니다. 동기화 열에서 프로필별로 켜세요.",
"action": "계정 열기"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "밤사이 프로필 워밍",
"body": "Cookie Bot은 원격 호스트에서 일정에 따라 실제 사이트를 탐색해, 새 프로필이 사용 전에 자연스러운 기록을 쌓게 합니다.",
"action": "Cookie Bot 열기"
},
"crossOs": {
"label": "크로스 OS 핑거프린트",
"title": "어떤 운영체제로든 보이기",
"body": "크로스 OS 핑거프린트를 쓰면 어떤 컴퓨터에서 실행하든 프로필이 macOS, Windows, Linux로 보고할 수 있습니다. 핑거프린트를 만들거나 편집할 때 플랫폼을 고르세요.",
"action": "프로필 열기"
},
"automation": {
"label": "자동화",
"title": "코드에서 프로필 실행과 제어",
"body": "run, open-url, kill 엔드포인트는 스크립트에서 실제 프로필을 시작하고, 실행된 각 프로필은 Playwright나 Puppeteer용 CDP 엔드포인트를 제공합니다.",
"action": "통합 열기"
},
"agent": {
"label": "에이전트",
"title": "클릭은 에이전트에게",
"body": "작업을 설명하면 에이전트가 프로필을 단계별로 조작하고, 수행한 내용을 기록하며, 레시피로 다시 실행할 수 있습니다.",
"action": "에이전트 열기"
},
"team": {
"label": "팀 잠금",
"title": "충돌 없이 프로필 공유",
"body": "팀에서는 실행 중인 프로필이 다른 사람에게 잠기고 닫히면 해제됩니다. 계정 페이지에서 누가 무엇을 쓰는지 볼 수 있습니다.",
"action": "계정 열기"
},
"remoteControl": {
"label": "원격 제어",
"title": "donutbrowser.com에서 이 데스크톱 제어",
"body": "원격 제어를 켜면 웹사이트의 에이전트가 아웃바운드 브리지를 통해 이 컴퓨터의 프로필에 접근합니다. 통합에서 켜세요.",
"action": "통합 열기"
}
}
},
"paidWelcome": {
"title": "{{plan}}에 오신 것을 환영합니다",
"body": "플랜으로 다음 기능이 열렸습니다. 각 팁에서 실제 동작을 볼 수 있습니다.",
"cta": "보여주기",
"later": "나중에"
}
}
+148 -4
View File
@@ -1992,7 +1992,9 @@
"historyTitle": "Verificações recentes",
"historyEmpty": "Nenhuma verificação registrada ainda.",
"historyOk": "Aprovada",
"historyFailed": "Falhou"
"historyFailed": "Falhou",
"trendLabel": "Latência das últimas {{count}} verificações, a mais recente à direita",
"trendPeak": "Mais lenta: {{ms}} ms"
},
"vpnCheck": {
"valid": "Configuração de VPN \"{{name}}\" é válida",
@@ -2351,7 +2353,8 @@
"syncSessionUnavailable": "Não é possível aceder agora à sessão de sincronização.",
"syncDisplayUnavailable": "Não foi possível ler o tamanho do ecrã, por isso as janelas não podem ser organizadas.",
"syncDisplayTooSmall": "O ecrã é demasiado pequeno para {{windows}} janelas nessa disposição.",
"syncArrangeFailed": "Não foi possível mover nenhuma janela."
"syncArrangeFailed": "Não foi possível mover nenhuma janela.",
"extensionPathInvalid": "Esse caminho não é permitido: contém '..'."
},
"rail": {
"profiles": "Perfis",
@@ -2368,7 +2371,9 @@
"about": "Sobre o Donut Browser",
"aboutHint": "Versão e informações do aplicativo",
"trash": "Lixeira",
"trashHint": "Restaurar perfis excluídos"
"trashHint": "Restaurar perfis excluídos",
"tips": "Dicas",
"tipsHint": "Passo a passo dos recursos"
},
"network": "Rede",
"integrations": "Integrações",
@@ -2485,7 +2490,8 @@
"goSettings": "Ir para Configurações",
"goCookieBot": "Cookie Bot",
"goTrash": "Ir para a Lixeira",
"goAgent": "Ir para Agente"
"goAgent": "Ir para Agente",
"openTips": "Abrir dicas"
},
"closeConfirm": {
"title": "Fechar Donut Browser?",
@@ -3368,5 +3374,143 @@
"urlPlaceholder": "https://exemplo.com",
"folderPlaceholder": "Opcional",
"saved": "Marcadores do grupo guardados"
},
"tips": {
"title": "Dicas",
"essentials": "Essenciais",
"planSection": "Incluído no seu plano",
"count": "{{current}} de {{total}}",
"previous": "Dica anterior",
"next": "Próxima dica",
"done": "Concluído",
"autoShow": "Mostrar uma dica ao iniciar o Donut",
"items": {
"dnsBlocklist": {
"label": "Bloqueio DNS",
"title": "Bloqueie anúncios e rastreadores antes de carregarem",
"body": "Cada perfil pode ter a sua própria lista de bloqueio DNS. Escolha um nível na coluna DNS do perfil; os níveis mais altos também travam domínios de rastreamento e malware na camada de rede.",
"action": "Abrir configurações de DNS"
},
"proxyCheck": {
"label": "Verificação de proxy",
"title": "Verifique um proxy antes de iniciar",
"body": "A verificação de conexão informa o IP de saída, o país, a latência e se o UDP passa. Execute-a na página Rede ou numa linha de perfil, e consulte o histórico de verificações para detectar um proxy que está piorando.",
"action": "Abrir Rede"
},
"groups": {
"label": "Grupos",
"title": "Troque de grupo pelo teclado",
"body": "Os grupos mantêm perfis relacionados juntos, e cada grupo recebe um número: {{mod}}+1 a {{mod}}+9 troca a lista na hora.",
"action": "Abrir Grupos"
},
"commandPalette": {
"label": "Paleta de comandos",
"title": "Toda página a um atalho de distância",
"body": "{{mod}}+K abre a paleta de comandos. Digite algumas letras de uma página ou ação e pressione Enter.",
"action": "Abrir a paleta"
},
"fingerprintGate": {
"label": "Controle de impressão digital",
"title": "Mantenha a impressão digital fiel à saída",
"body": "Antes de iniciar, o Donut compara o fuso horário e o idioma da saída do proxy com a impressão digital do perfil e bloqueia qualquer divergência. Corrija a impressão digital, ou desative o bloqueio em Avançado se souber o que está fazendo.",
"action": "Abrir configurações avançadas"
},
"profilePassword": {
"label": "Senha do perfil",
"title": "Proteja um perfil com senha",
"body": "Um perfil protegido por senha é criptografado no disco e descriptografado apenas enquanto está em execução. Defina a senha no menu do perfil.",
"action": "Abrir Perfis"
},
"clearOnClose": {
"label": "Limpar ao fechar",
"title": "Comece limpo toda vez",
"body": "Com Limpar ao fechar, o perfil descarta cookies, armazenamento e histórico quando a janela fecha. Ótimo para sessões avulsas e máquinas compartilhadas.",
"action": "Abrir Perfis"
},
"defaultBrowser": {
"label": "Navegador padrão",
"title": "Abra cada link no perfil certo",
"body": "Torne o Donut o seu navegador padrão e cada link vindo de outro aplicativo perguntará qual perfil deve abri-lo.",
"action": "Abrir configurações do navegador padrão"
},
"extensionGroups": {
"label": "Grupos de extensões",
"title": "Compartilhe um conjunto de extensões entre perfis",
"body": "Coloque as extensões em um grupo de extensões e atribua o grupo aos perfis. Altere o grupo uma vez e cada perfil acompanha na próxima inicialização.",
"action": "Abrir Extensões"
},
"selfHostedSync": {
"label": "Sincronização própria",
"title": "Faça backup no seu próprio servidor",
"body": "Aponte o Donut para um servidor donut-sync auto-hospedado e perfis, proxies e grupos serão espelhados nele. Adicione uma senha de ponta a ponta e o servidor só verá texto cifrado.",
"action": "Abrir Conta"
},
"trash": {
"label": "Lixeira",
"title": "Perfis excluídos esperam na lixeira",
"body": "Um perfil excluído fica na lixeira por 30 dias por padrão e volta com tudo o que tinha. O período de retenção está nas configurações avançadas.",
"action": "Abrir Lixeira"
},
"localApi": {
"label": "API e MCP",
"title": "Automatize o Donut com scripts e agentes",
"body": "A API REST local e o servidor MCP permitem que scripts e agentes de IA listem, criem e configurem perfis. Ative-os em Integrações e copie o token.",
"action": "Abrir Integrações"
},
"importProfiles": {
"label": "Importar",
"title": "Traga perfis do Chrome, Edge ou Brave",
"body": "A importação copia cookies, logins e extensões de um perfil Chromium ou de um arquivo para um novo perfil do Donut, pronto para iniciar.",
"action": "Abrir Importar"
},
"cloudBackup": {
"label": "Sincronização na nuvem",
"title": "Seus perfis em todos os dispositivos",
"body": "A sincronização na nuvem faz backup de perfis, proxies e grupos e os restaura em outra máquina. Ative-a por perfil na coluna de sincronização.",
"action": "Abrir Conta"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "Aqueça perfis durante a noite",
"body": "O Cookie Bot navega em sites reais conforme um cronograma a partir de um host remoto, para que um perfil novo construa um histórico natural antes de você usá-lo.",
"action": "Abrir Cookie Bot"
},
"crossOs": {
"label": "Impressão digital multi-SO",
"title": "Apresente-se como qualquer sistema operacional",
"body": "Uma impressão digital multi-SO permite que um perfil informe macOS, Windows ou Linux em qualquer máquina. Escolha a plataforma ao criar ou editar a impressão digital.",
"action": "Abrir Perfis"
},
"automation": {
"label": "Automação",
"title": "Inicie e controle perfis por código",
"body": "Os endpoints run, open-url e kill iniciam perfis reais a partir dos seus scripts, e cada perfil iniciado expõe um endpoint CDP para Playwright ou Puppeteer.",
"action": "Abrir Integrações"
},
"agent": {
"label": "Agente",
"title": "Deixe os cliques com um agente",
"body": "Descreva uma tarefa e o agente controla um perfil passo a passo, registra o que fez e pode repetir tudo como uma receita.",
"action": "Abrir Agente"
},
"team": {
"label": "Bloqueios de equipe",
"title": "Compartilhe perfis sem colisões",
"body": "Em uma equipe, um perfil em execução fica bloqueado para os demais e é liberado ao fechar. A página Conta mostra quem está com o quê.",
"action": "Abrir Conta"
},
"remoteControl": {
"label": "Controle remoto",
"title": "Controle este computador pelo donutbrowser.com",
"body": "Com o controle remoto ativado, os agentes do site alcançam os perfis desta máquina por uma ponte de saída. Ative-o em Integrações.",
"action": "Abrir Integrações"
}
}
},
"paidWelcome": {
"title": "Bem-vindo ao {{plan}}",
"body": "Seu plano acabou de desbloquear isto. Cada dica mostra um recurso em ação.",
"cta": "Mostre-me",
"later": "Mais tarde"
}
}
+148 -4
View File
@@ -2002,7 +2002,9 @@
"historyTitle": "Последние проверки",
"historyEmpty": "Проверок пока нет.",
"historyOk": "Успешно",
"historyFailed": "Неудачно"
"historyFailed": "Неудачно",
"trendLabel": "Задержка последних {{count}} проверок, новейшая справа",
"trendPeak": "Самая медленная: {{ms}} мс"
},
"vpnCheck": {
"valid": "Конфигурация VPN «{{name}}» действительна",
@@ -2361,7 +2363,8 @@
"syncSessionUnavailable": "Сейчас к сеансу синхронизации нет доступа.",
"syncDisplayUnavailable": "Не удалось узнать размер экрана, поэтому окна не расставить.",
"syncDisplayTooSmall": "Экран слишком мал для {{windows}} окон в таком расположении.",
"syncArrangeFailed": "Ни одно окно не удалось переместить."
"syncArrangeFailed": "Ни одно окно не удалось переместить.",
"extensionPathInvalid": "Этот путь недопустим: он содержит «..»."
},
"rail": {
"profiles": "Профили",
@@ -2378,7 +2381,9 @@
"about": "О Donut Browser",
"aboutHint": "Версия и сведения о приложении",
"trash": "Корзина",
"trashHint": "Восстановить удалённые профили"
"trashHint": "Восстановить удалённые профили",
"tips": "Подсказки",
"tipsHint": "Обзор возможностей"
},
"network": "Сеть",
"integrations": "Интеграции",
@@ -2495,7 +2500,8 @@
"goSettings": "Перейти к Настройкам",
"goCookieBot": "Cookie Bot",
"goTrash": "Перейти в Корзину",
"goAgent": "Перейти к агенту"
"goAgent": "Перейти к агенту",
"openTips": "Открыть подсказки"
},
"closeConfirm": {
"title": "Закрыть Donut Browser?",
@@ -3401,5 +3407,143 @@
"urlPlaceholder": "https://example.com",
"folderPlaceholder": "Необязательно",
"saved": "Закладки группы сохранены"
},
"tips": {
"title": "Подсказки",
"essentials": "Основы",
"planSection": "Входит в ваш тариф",
"count": "{{current}} из {{total}}",
"previous": "Предыдущая подсказка",
"next": "Следующая подсказка",
"done": "Готово",
"autoShow": "Показывать подсказку при запуске Donut",
"items": {
"dnsBlocklist": {
"label": "DNS-блокировка",
"title": "Блокируйте рекламу и трекеры до загрузки",
"body": "У каждого профиля может быть свой DNS-список блокировки. Выберите уровень в столбце DNS профиля; высокие уровни также останавливают домены трекинга и вредоносного ПО на уровне сети.",
"action": "Открыть настройки DNS"
},
"proxyCheck": {
"label": "Проверка прокси",
"title": "Проверьте прокси перед запуском",
"body": "Проверка соединения показывает IP выхода, страну, задержку и проходит ли UDP. Запускайте её со страницы «Сеть» или из строки профиля и смотрите историю проверок, чтобы заметить прокси, который начинает сбоить.",
"action": "Открыть Сеть"
},
"groups": {
"label": "Группы",
"title": "Переключайте группы с клавиатуры",
"body": "Группы держат связанные профили вместе, и у каждой группы есть номер: {{mod}}+1 … {{mod}}+9 мгновенно переключают список.",
"action": "Открыть Группы"
},
"commandPalette": {
"label": "Палитра команд",
"title": "Любая страница в одном сочетании",
"body": "{{mod}}+K открывает палитру команд. Введите несколько букв названия страницы или действия и нажмите Enter.",
"action": "Открыть палитру"
},
"fingerprintGate": {
"label": "Проверка отпечатка",
"title": "Держите отпечаток согласованным с выходом",
"body": "Перед запуском Donut сравнивает часовой пояс и язык выхода прокси с отпечатком профиля и останавливает несовпадение. Исправьте отпечаток или отключите проверку в разделе «Дополнительно», если понимаете, что делаете.",
"action": "Открыть дополнительные настройки"
},
"profilePassword": {
"label": "Пароль профиля",
"title": "Защитите профиль паролем",
"body": "Профиль с паролем зашифрован на диске и расшифровывается только на время работы. Пароль задаётся в меню профиля.",
"action": "Открыть Профили"
},
"clearOnClose": {
"label": "Очистка при закрытии",
"title": "Начинайте с чистого листа",
"body": "С опцией «Очищать при закрытии» профиль сбрасывает cookie, хранилище и историю, когда закрывается его окно. Удобно для разовых сессий и общих компьютеров.",
"action": "Открыть Профили"
},
"defaultBrowser": {
"label": "Браузер по умолчанию",
"title": "Открывайте каждую ссылку в нужном профиле",
"body": "Сделайте Donut браузером по умолчанию, и каждая ссылка из другого приложения будет спрашивать, в каком профиле её открыть.",
"action": "Открыть настройки браузера по умолчанию"
},
"extensionGroups": {
"label": "Группы расширений",
"title": "Один набор расширений для многих профилей",
"body": "Соберите расширения в группу расширений и назначьте её профилям. Измените группу один раз, и каждый профиль подхватит изменения при следующем запуске.",
"action": "Открыть Расширения"
},
"selfHostedSync": {
"label": "Своя синхронизация",
"title": "Резервные копии на своём сервере",
"body": "Укажите Donut собственный сервер donut-sync, и профили, прокси и группы будут зеркалироваться на него. Добавьте сквозной пароль, и сервер увидит только шифртекст.",
"action": "Открыть Аккаунт"
},
"trash": {
"label": "Корзина",
"title": "Удалённые профили ждут в корзине",
"body": "Удалённый профиль по умолчанию хранится в корзине 30 дней и восстанавливается со всем содержимым. Срок хранения задаётся в дополнительных настройках.",
"action": "Открыть Корзину"
},
"localApi": {
"label": "API и MCP",
"title": "Автоматизируйте Donut скриптами и агентами",
"body": "Локальный REST API и сервер MCP позволяют скриптам и ИИ-агентам просматривать, создавать и настраивать профили. Включите их в разделе «Интеграции» и скопируйте токен.",
"action": "Открыть Интеграции"
},
"importProfiles": {
"label": "Импорт",
"title": "Перенесите профили из Chrome, Edge или Brave",
"body": "Импорт копирует cookie, логины и расширения из профиля Chromium или архива в новый профиль Donut, готовый к запуску.",
"action": "Открыть Импорт"
},
"cloudBackup": {
"label": "Облачная синхронизация",
"title": "Ваши профили на каждом устройстве",
"body": "Облачная синхронизация сохраняет профили, прокси и группы и восстанавливает их на другом компьютере. Включайте её для каждого профиля в столбце синхронизации.",
"action": "Открыть Аккаунт"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "Прогревайте профили по ночам",
"body": "Cookie Bot по расписанию просматривает настоящие сайты с удалённого хоста, чтобы новый профиль накопил естественную историю до того, как вы им воспользуетесь.",
"action": "Открыть Cookie Bot"
},
"crossOs": {
"label": "Кросс-ОС отпечаток",
"title": "Выглядите как любая операционная система",
"body": "Кросс-ОС отпечаток позволяет профилю сообщать macOS, Windows или Linux независимо от машины, на которой он запущен. Выберите платформу при создании или редактировании отпечатка.",
"action": "Открыть Профили"
},
"automation": {
"label": "Автоматизация",
"title": "Запускайте и управляйте профилями из кода",
"body": "Эндпоинты run, open-url и kill запускают настоящие профили из ваших скриптов, а каждый запущенный профиль открывает CDP-эндпоинт для Playwright или Puppeteer.",
"action": "Открыть Интеграции"
},
"agent": {
"label": "Агент",
"title": "Доверьте клики агенту",
"body": "Опишите задачу, и агент проведёт профиль по шагам, запишет сделанное и сможет повторить это как рецепт.",
"action": "Открыть Агента"
},
"team": {
"label": "Блокировки команды",
"title": "Делитесь профилями без конфликтов",
"body": "В команде запущенный профиль блокируется для остальных и освобождается при закрытии. На странице «Аккаунт» видно, кто что держит.",
"action": "Открыть Аккаунт"
},
"remoteControl": {
"label": "Удалённое управление",
"title": "Управляйте этим компьютером с donutbrowser.com",
"body": "При включённом удалённом управлении агенты на сайте получают доступ к профилям этой машины через исходящий мост. Включите его в разделе «Интеграции».",
"action": "Открыть Интеграции"
}
}
},
"paidWelcome": {
"title": "Добро пожаловать в {{plan}}",
"body": "Ваш тариф только что открыл эти возможности. Каждая подсказка показывает одну из них в действии.",
"cta": "Покажите",
"later": "Позже"
}
}
+148 -4
View File
@@ -1982,7 +1982,9 @@
"historyTitle": "Son denetimler",
"historyEmpty": "Henüz kayıtlı denetim yok.",
"historyOk": "Başarılı",
"historyFailed": "Başarısız"
"historyFailed": "Başarısız",
"trendLabel": "Son {{count}} kontrolün gecikmesi, en yenisi sağda",
"trendPeak": "En yavaş: {{ms}} ms"
},
"vpnCheck": {
"valid": "\"{{name}}\" VPN yapılandırması geçerli",
@@ -2341,7 +2343,8 @@
"syncSessionUnavailable": "Eşitleme oturumuna şu anda erişilemiyor.",
"syncDisplayUnavailable": "Ekran boyutu okunamadı, bu yüzden pencereler yerleştirilemiyor.",
"syncDisplayTooSmall": "Ekran, o düzende {{windows}} pencere için fazla küçük.",
"syncArrangeFailed": "Hiçbir pencere taşınamadı."
"syncArrangeFailed": "Hiçbir pencere taşınamadı.",
"extensionPathInvalid": "Bu yol kullanılamaz: '..' içeriyor."
},
"rail": {
"profiles": "Profiller",
@@ -2358,7 +2361,9 @@
"about": "Donut Browser Hakkında",
"aboutHint": "Sürüm ve uygulama bilgileri",
"trash": "Çöp Kutusu",
"trashHint": "Silinen profilleri geri yükle"
"trashHint": "Silinen profilleri geri yükle",
"tips": "İpuçları",
"tipsHint": "Özellik turları"
},
"network": "Ağ",
"integrations": "Entegrasyonlar",
@@ -2475,7 +2480,8 @@
"goSettings": "Ayarlar'a git",
"goCookieBot": "Cookie Bot",
"goTrash": "Çöp Kutusuna git",
"goAgent": "Ajan'a git"
"goAgent": "Ajan'a git",
"openTips": "İpuçlarını aç"
},
"closeConfirm": {
"title": "Donut Browser kapatılsın mı?",
@@ -3335,5 +3341,143 @@
"urlPlaceholder": "https://ornek.com",
"folderPlaceholder": "İsteğe bağlı",
"saved": "Grup yer imleri kaydedildi"
},
"tips": {
"title": "İpuçları",
"essentials": "Temeller",
"planSection": "Planınıza dahil",
"count": "{{current}} / {{total}}",
"previous": "Önceki ipucu",
"next": "Sonraki ipucu",
"done": "Bitti",
"autoShow": "Donut açılırken bir ipucu göster",
"items": {
"dnsBlocklist": {
"label": "DNS engelleme",
"title": "Reklamları ve izleyicileri yüklenmeden engelleyin",
"body": "Her profilin kendi DNS engel listesi olabilir. Profilin DNS sütunundan bir düzey seçin; yüksek düzeyler izleme ve kötü amaçlı yazılım alan adlarını da ağ katmanında durdurur.",
"action": "DNS ayarlarını aç"
},
"proxyCheck": {
"label": "Proxy kontrolü",
"title": "Başlatmadan önce proxy'yi kontrol edin",
"body": "Bağlantı testi çıkış IP'sini, ülkeyi, gecikmeyi ve UDP'nin geçip geçmediğini bildirir. Ağ sayfasından veya bir profil satırından çalıştırın ve bozulmaya başlayan bir proxy'yi geçmiş kontrollerden fark edin.",
"action": "Ağ'ı aç"
},
"groups": {
"label": "Gruplar",
"title": "Gruplar arasında klavyeyle geçin",
"body": "Gruplar ilişkili profilleri bir arada tutar ve her grubun bir numarası vardır: {{mod}}+1 ile {{mod}}+9 listeyi anında değiştirir.",
"action": "Grupları aç"
},
"commandPalette": {
"label": "Komut paleti",
"title": "Her sayfa tek bir kısayol uzağınızda",
"body": "{{mod}}+K komut paletini açar. Bir sayfanın veya eylemin birkaç harfini yazıp Enter'a basın.",
"action": "Paleti aç"
},
"fingerprintGate": {
"label": "Parmak izi denetimi",
"title": "Parmak izini çıkışla tutarlı tutun",
"body": "Başlatmadan önce Donut, proxy çıkışının saat dilimi ve dilini profilin parmak iziyle karşılaştırır ve uyuşmazlığı durdurur. Parmak izini düzeltin ya da ne yaptığınızı biliyorsanız Gelişmiş'ten bu denetimi kapatın.",
"action": "Gelişmiş ayarları aç"
},
"profilePassword": {
"label": "Profil parolası",
"title": "Bir profili parolayla kilitleyin",
"body": "Parola korumalı bir profil diskte şifrelenir ve yalnızca çalışırken çözülür. Parolayı profil menüsünden belirleyin.",
"action": "Profilleri aç"
},
"clearOnClose": {
"label": "Kapatınca temizle",
"title": "Her seferinde temiz başlayın",
"body": "Kapatınca temizle açıkken profil, penceresi kapandığında çerezlerini, depolamasını ve geçmişini siler. Tek seferlik oturumlar ve ortak bilgisayarlar için idealdir.",
"action": "Profilleri aç"
},
"defaultBrowser": {
"label": "Varsayılan tarayıcı",
"title": "Her bağlantıyı doğru profilde açın",
"body": "Donut'u varsayılan tarayıcınız yapın; başka bir uygulamadan gelen her bağlantı hangi profilde açılacağını sorar.",
"action": "Varsayılan tarayıcı ayarlarını aç"
},
"extensionGroups": {
"label": "Uzantı grupları",
"title": "Tek bir uzantı setini profiller arasında paylaşın",
"body": "Uzantıları bir uzantı grubuna koyun ve grubu profillere atayın. Grubu bir kez değiştirin; her profil bir sonraki başlatmada bunu izler.",
"action": "Uzantıları aç"
},
"selfHostedSync": {
"label": "Kendi eşitlemeniz",
"title": "Kendi sunucunuza yedekleyin",
"body": "Donut'u kendi barındırdığınız bir donut-sync sunucusuna yönlendirin; profiller, proxy'ler ve gruplar oraya yansıtılır. Uçtan uca bir parola ekleyin, sunucu yalnızca şifreli metin görsün.",
"action": "Hesabı aç"
},
"trash": {
"label": "Çöp kutusu",
"title": "Silinen profiller çöp kutusunda bekler",
"body": "Silinen bir profil varsayılan olarak 30 gün çöp kutusunda kalır ve içindeki her şeyle geri gelir. Saklama süresi Gelişmiş ayarlardadır.",
"action": "Çöp kutusunu aç"
},
"localApi": {
"label": "API ve MCP",
"title": "Donut'u betikler ve ajanlarla otomatikleştirin",
"body": "Yerel REST API ve MCP sunucusu, betiklerin ve yapay zekâ ajanlarının profilleri listelemesine, oluşturmasına ve yapılandırmasına izin verir. Entegrasyonlar'dan açın ve belirteci kopyalayın.",
"action": "Entegrasyonları aç"
},
"importProfiles": {
"label": "İçe aktarma",
"title": "Chrome, Edge veya Brave'den profil getirin",
"body": "İçe aktarma, bir Chromium profilinden veya arşivden çerezleri, oturum bilgilerini ve uzantıları başlatmaya hazır yeni bir Donut profiline kopyalar.",
"action": "İçe aktarmayı aç"
},
"cloudBackup": {
"label": "Bulut eşitleme",
"title": "Profilleriniz her cihazda",
"body": "Bulut eşitleme profilleri, proxy'leri ve grupları yedekler ve başka bir makinede geri yükler. Eşitleme sütunundan profil başına açın.",
"action": "Hesabı aç"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "Profilleri gece boyunca ısıtın",
"body": "Cookie Bot uzak bir ana bilgisayardan bir programa göre gerçek siteleri gezer; böylece yeni bir profil siz kullanmadan önce doğal bir geçmiş oluşturur.",
"action": "Cookie Bot'u aç"
},
"crossOs": {
"label": "Çapraz OS parmak izi",
"title": "Herhangi bir işletim sistemi gibi görünün",
"body": "Çapraz işletim sistemi parmak izi, profilin hangi makinede çalışırsa çalışsın macOS, Windows veya Linux bildirmesini sağlar. Parmak izini oluştururken veya düzenlerken platformu seçin.",
"action": "Profilleri aç"
},
"automation": {
"label": "Otomasyon",
"title": "Profilleri koddan başlatın ve yönetin",
"body": "run, open-url ve kill uç noktaları betiklerinizden gerçek profiller başlatır; başlatılan her profil Playwright veya Puppeteer için bir CDP uç noktası sunar.",
"action": "Entegrasyonları aç"
},
"agent": {
"label": "Ajan",
"title": "Tıklamaları bir ajana bırakın",
"body": "Bir görevi tarif edin; ajan profili adım adım yönetir, yaptıklarını kaydeder ve bunu bir tarif olarak yeniden oynatabilir.",
"action": "Ajanı aç"
},
"team": {
"label": "Ekip kilitleri",
"title": "Profilleri çakışmadan paylaşın",
"body": "Bir ekipte çalışan bir profil diğerleri için kilitlenir ve kapandığında serbest bırakılır. Hesap sayfası kimin neyi tuttuğunu gösterir.",
"action": "Hesabı aç"
},
"remoteControl": {
"label": "Uzaktan denetim",
"title": "Bu masaüstünü donutbrowser.com'dan yönetin",
"body": "Uzaktan denetim açıkken web sitesindeki ajanlar bu makinenin profillerine giden yönlü bir köprü üzerinden ulaşır. Entegrasyonlar'dan açın.",
"action": "Entegrasyonları aç"
}
}
},
"paidWelcome": {
"title": "{{plan}} planına hoş geldiniz",
"body": "Planınız bunların kilidini açtı. Her ipucu birini çalışırken gösterir.",
"cta": "Göster",
"later": "Sonra"
}
}
+148 -4
View File
@@ -1982,7 +1982,9 @@
"historyTitle": "Các lần kiểm tra gần đây",
"historyEmpty": "Chưa có lần kiểm tra nào được ghi lại.",
"historyOk": "Đạt",
"historyFailed": "Thất bại"
"historyFailed": "Thất bại",
"trendLabel": "Độ trễ của {{count}} lần kiểm tra gần nhất, mới nhất ở bên phải",
"trendPeak": "Chậm nhất: {{ms}} ms"
},
"vpnCheck": {
"valid": "Cấu hình VPN \"{{name}}\" hợp lệ",
@@ -2341,7 +2343,8 @@
"syncSessionUnavailable": "Hiện không truy cập được phiên đồng bộ.",
"syncDisplayUnavailable": "Không đọc được kích thước màn hình nên không xếp được cửa sổ.",
"syncDisplayTooSmall": "Màn hình quá nhỏ cho {{windows}} cửa sổ theo cách xếp đó.",
"syncArrangeFailed": "Không cửa sổ nào được di chuyển."
"syncArrangeFailed": "Không cửa sổ nào được di chuyển.",
"extensionPathInvalid": "Đường dẫn đó không được phép: nó chứa '..'."
},
"rail": {
"profiles": "Profile",
@@ -2358,7 +2361,9 @@
"about": "Giới thiệu về Donut Browser",
"aboutHint": "Phiên bản và thông tin ứng dụng",
"trash": "Thùng rác",
"trashHint": "Khôi phục hồ sơ đã xóa"
"trashHint": "Khôi phục hồ sơ đã xóa",
"tips": "Mẹo",
"tipsHint": "Hướng dẫn tính năng"
},
"network": "Mạng",
"integrations": "Tích hợp",
@@ -2475,7 +2480,8 @@
"goSettings": "Đi đến Cài đặt",
"goCookieBot": "Cookie Bot",
"goTrash": "Đi tới Thùng rác",
"goAgent": "Đến Tác nhân"
"goAgent": "Đến Tác nhân",
"openTips": "Mở mẹo"
},
"closeConfirm": {
"title": "Đóng Donut Browser?",
@@ -3335,5 +3341,143 @@
"urlPlaceholder": "https://vidu.com",
"folderPlaceholder": "Tùy chọn",
"saved": "Đã lưu dấu trang của nhóm"
},
"tips": {
"title": "Mẹo",
"essentials": "Cơ bản",
"planSection": "Có trong gói của bạn",
"count": "{{current}} / {{total}}",
"previous": "Mẹo trước",
"next": "Mẹo tiếp theo",
"done": "Xong",
"autoShow": "Hiện một mẹo khi Donut khởi động",
"items": {
"dnsBlocklist": {
"label": "Chặn DNS",
"title": "Chặn quảng cáo và trình theo dõi trước khi tải",
"body": "Mỗi hồ sơ có thể mang danh sách chặn DNS riêng. Chọn một mức trong cột DNS của hồ sơ; các mức cao hơn còn chặn tên miền theo dõi và mã độc ngay ở tầng mạng.",
"action": "Mở cài đặt DNS"
},
"proxyCheck": {
"label": "Kiểm tra proxy",
"title": "Kiểm tra proxy trước khi khởi chạy",
"body": "Kiểm tra kết nối cho biết IP đầu ra, quốc gia, độ trễ và UDP có đi qua hay không. Chạy từ trang Mạng hoặc từ một hàng hồ sơ, và xem lịch sử kiểm tra để phát hiện proxy đang xuống cấp.",
"action": "Mở Mạng"
},
"groups": {
"label": "Nhóm",
"title": "Chuyển nhóm bằng bàn phím",
"body": "Nhóm giữ các hồ sơ liên quan ở cùng nhau, và mỗi nhóm có một số: {{mod}}+1 đến {{mod}}+9 chuyển danh sách ngay lập tức.",
"action": "Mở Nhóm"
},
"commandPalette": {
"label": "Bảng lệnh",
"title": "Mọi trang chỉ cách một tổ hợp phím",
"body": "{{mod}}+K mở bảng lệnh. Gõ vài chữ cái của một trang hoặc thao tác rồi nhấn Enter.",
"action": "Mở bảng lệnh"
},
"fingerprintGate": {
"label": "Kiểm tra dấu vân tay",
"title": "Giữ dấu vân tay khớp với điểm ra",
"body": "Trước khi khởi chạy, Donut so sánh múi giờ và ngôn ngữ của điểm ra proxy với dấu vân tay của hồ sơ và chặn khi không khớp. Hãy sửa dấu vân tay, hoặc tắt kiểm tra trong Nâng cao nếu bạn biết mình đang làm gì.",
"action": "Mở cài đặt nâng cao"
},
"profilePassword": {
"label": "Mật khẩu hồ sơ",
"title": "Khóa hồ sơ bằng mật khẩu",
"body": "Hồ sơ được bảo vệ bằng mật khẩu được mã hóa trên đĩa và chỉ được giải mã khi đang chạy. Đặt mật khẩu từ menu của hồ sơ.",
"action": "Mở Hồ sơ"
},
"clearOnClose": {
"label": "Xóa khi đóng",
"title": "Bắt đầu sạch sẽ mỗi lần",
"body": "Với Xóa khi đóng, hồ sơ bỏ cookie, bộ nhớ và lịch sử khi cửa sổ của nó đóng lại. Phù hợp cho phiên dùng một lần và máy dùng chung.",
"action": "Mở Hồ sơ"
},
"defaultBrowser": {
"label": "Trình duyệt mặc định",
"title": "Mở mọi liên kết trong đúng hồ sơ",
"body": "Đặt Donut làm trình duyệt mặc định và mỗi liên kết từ ứng dụng khác sẽ hỏi nên mở bằng hồ sơ nào.",
"action": "Mở cài đặt trình duyệt mặc định"
},
"extensionGroups": {
"label": "Nhóm tiện ích",
"title": "Dùng chung một bộ tiện ích cho nhiều hồ sơ",
"body": "Đưa tiện ích vào một nhóm tiện ích và gán nhóm đó cho các hồ sơ. Đổi nhóm một lần và mọi hồ sơ sẽ theo ở lần khởi chạy tiếp theo.",
"action": "Mở Tiện ích"
},
"selfHostedSync": {
"label": "Đồng bộ tự lưu trữ",
"title": "Sao lưu lên máy chủ của riêng bạn",
"body": "Trỏ Donut đến máy chủ donut-sync tự lưu trữ và hồ sơ, proxy, nhóm sẽ được phản chiếu lên đó. Thêm mật khẩu đầu cuối và máy chủ chỉ thấy văn bản đã mã hóa.",
"action": "Mở Tài khoản"
},
"trash": {
"label": "Thùng rác",
"title": "Hồ sơ đã xóa chờ trong thùng rác",
"body": "Hồ sơ đã xóa nằm trong thùng rác 30 ngày theo mặc định và quay lại với đầy đủ nội dung. Thời gian lưu giữ nằm trong cài đặt nâng cao.",
"action": "Mở Thùng rác"
},
"localApi": {
"label": "API và MCP",
"title": "Tự động hóa Donut từ script và tác nhân",
"body": "REST API cục bộ và máy chủ MCP cho phép script và tác nhân AI liệt kê, tạo và cấu hình hồ sơ. Bật chúng trong Tích hợp và sao chép mã thông báo.",
"action": "Mở Tích hợp"
},
"importProfiles": {
"label": "Nhập",
"title": "Mang hồ sơ từ Chrome, Edge hoặc Brave sang",
"body": "Nhập sao chép cookie, thông tin đăng nhập và tiện ích từ hồ sơ Chromium hoặc tệp nén vào một hồ sơ Donut mới, sẵn sàng khởi chạy.",
"action": "Mở Nhập"
},
"cloudBackup": {
"label": "Đồng bộ đám mây",
"title": "Hồ sơ của bạn trên mọi thiết bị",
"body": "Đồng bộ đám mây sao lưu hồ sơ, proxy và nhóm rồi khôi phục trên máy khác. Bật cho từng hồ sơ từ cột đồng bộ.",
"action": "Mở Tài khoản"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "Làm nóng hồ sơ qua đêm",
"body": "Cookie Bot duyệt các trang thật theo lịch từ một máy chủ từ xa, để hồ sơ mới tích lũy lịch sử tự nhiên trước khi bạn dùng.",
"action": "Mở Cookie Bot"
},
"crossOs": {
"label": "Dấu vân tay đa hệ điều hành",
"title": "Xuất hiện như bất kỳ hệ điều hành nào",
"body": "Dấu vân tay đa hệ điều hành cho phép hồ sơ báo là macOS, Windows hoặc Linux dù chạy trên máy nào. Chọn nền tảng khi tạo hoặc chỉnh sửa dấu vân tay.",
"action": "Mở Hồ sơ"
},
"automation": {
"label": "Tự động hóa",
"title": "Khởi chạy và điều khiển hồ sơ từ mã",
"body": "Các điểm cuối run, open-url và kill khởi động hồ sơ thật từ script của bạn, và mỗi hồ sơ đã khởi chạy cung cấp một điểm cuối CDP cho Playwright hoặc Puppeteer.",
"action": "Mở Tích hợp"
},
"agent": {
"label": "Tác nhân",
"title": "Giao việc nhấp chuột cho tác nhân",
"body": "Mô tả một nhiệm vụ và tác nhân sẽ điều khiển hồ sơ từng bước, ghi lại những gì đã làm và có thể phát lại như một công thức.",
"action": "Mở Tác nhân"
},
"team": {
"label": "Khóa nhóm",
"title": "Chia sẻ hồ sơ mà không xung đột",
"body": "Trong nhóm, hồ sơ đang chạy bị khóa với mọi người khác và được giải phóng khi đóng. Trang Tài khoản cho biết ai đang giữ gì.",
"action": "Mở Tài khoản"
},
"remoteControl": {
"label": "Điều khiển từ xa",
"title": "Điều khiển máy này từ donutbrowser.com",
"body": "Khi bật điều khiển từ xa, các tác nhân trên trang web tiếp cận hồ sơ của máy này qua một cầu nối đi ra. Bật trong Tích hợp.",
"action": "Mở Tích hợp"
}
}
},
"paidWelcome": {
"title": "Chào mừng đến với {{plan}}",
"body": "Gói của bạn vừa mở khóa những tính năng này. Mỗi mẹo cho thấy một tính năng đang hoạt động.",
"cta": "Cho tôi xem",
"later": "Để sau"
}
}
+148 -4
View File
@@ -1982,7 +1982,9 @@
"historyTitle": "最近的检测",
"historyEmpty": "尚无检测记录。",
"historyOk": "通过",
"historyFailed": "失败"
"historyFailed": "失败",
"trendLabel": "最近 {{count}} 次检查的延迟,最新的在右侧",
"trendPeak": "最慢:{{ms}} ms"
},
"vpnCheck": {
"valid": "VPN「{{name}}」配置有效",
@@ -2341,7 +2343,8 @@
"syncSessionUnavailable": "目前无法访问该同步会话。",
"syncDisplayUnavailable": "无法读取显示器尺寸,因此不能排列窗口。",
"syncDisplayTooSmall": "显示器太小,无法以该布局排列 {{windows}} 个窗口。",
"syncArrangeFailed": "没有窗口被移动。"
"syncArrangeFailed": "没有窗口被移动。",
"extensionPathInvalid": "该路径不允许使用:它包含“..”。"
},
"rail": {
"profiles": "配置文件",
@@ -2358,7 +2361,9 @@
"about": "关于 Donut Browser",
"aboutHint": "版本和应用信息",
"trash": "回收站",
"trashHint": "恢复已删除的配置文件"
"trashHint": "恢复已删除的配置文件",
"tips": "小贴士",
"tipsHint": "功能演示"
},
"network": "网络",
"integrations": "集成",
@@ -2475,7 +2480,8 @@
"goSettings": "转到设置",
"goCookieBot": "Cookie Bot",
"goTrash": "前往回收站",
"goAgent": "前往智能体"
"goAgent": "前往智能体",
"openTips": "打开小贴士"
},
"closeConfirm": {
"title": "关闭 Donut Browser",
@@ -3335,5 +3341,143 @@
"urlPlaceholder": "https://example.com",
"folderPlaceholder": "可选",
"saved": "分组书签已保存"
},
"tips": {
"title": "小贴士",
"essentials": "基础",
"planSection": "您的套餐已包含",
"count": "{{current}} / {{total}}",
"previous": "上一条",
"next": "下一条",
"done": "完成",
"autoShow": "Donut 启动时显示一条小贴士",
"items": {
"dnsBlocklist": {
"label": "DNS 拦截",
"title": "在加载前拦截广告和跟踪器",
"body": "每个配置文件都可以有自己的 DNS 拦截列表。在配置文件的 DNS 列中选择级别;更高的级别还会在网络层拦截跟踪和恶意软件域名。",
"action": "打开 DNS 设置"
},
"proxyCheck": {
"label": "代理检查",
"title": "启动前先检查代理",
"body": "连接检查会报告出口 IP、国家、延迟以及 UDP 是否可用。可从“网络”页面或配置文件行运行,并通过历史检查记录发现正在变差的代理。",
"action": "打开网络"
},
"groups": {
"label": "分组",
"title": "用键盘切换分组",
"body": "分组将相关的配置文件放在一起,每个分组都有编号:{{mod}}+1 到 {{mod}}+9 可立即切换列表。",
"action": "打开分组"
},
"commandPalette": {
"label": "命令面板",
"title": "任何页面只需一个快捷键",
"body": "{{mod}}+K 打开命令面板。输入页面或操作的几个字母,然后按 Enter。",
"action": "打开命令面板"
},
"fingerprintGate": {
"label": "指纹检查",
"title": "让指纹与出口保持一致",
"body": "启动前,Donut 会将代理出口的时区和语言与配置文件的指纹进行比较,并阻止不一致的启动。请修正指纹,或者在确认无误的情况下于“高级”中关闭此检查。",
"action": "打开高级设置"
},
"profilePassword": {
"label": "配置文件密码",
"title": "用密码锁定配置文件",
"body": "受密码保护的配置文件在磁盘上加密,仅在运行时解密。可在配置文件菜单中设置密码。",
"action": "打开配置文件"
},
"clearOnClose": {
"label": "关闭时清除",
"title": "每次都从干净状态开始",
"body": "开启“关闭时清除”后,窗口关闭时配置文件会丢弃 Cookie、存储和历史记录。适合一次性会话和共用电脑。",
"action": "打开配置文件"
},
"defaultBrowser": {
"label": "默认浏览器",
"title": "让每个链接在正确的配置文件中打开",
"body": "将 Donut 设为默认浏览器后,来自其他应用的每个链接都会询问应由哪个配置文件打开。",
"action": "打开默认浏览器设置"
},
"extensionGroups": {
"label": "扩展组",
"title": "在多个配置文件间共用一套扩展",
"body": "把扩展放进扩展组,再将该组分配给配置文件。修改一次分组,每个配置文件都会在下次启动时跟随。",
"action": "打开扩展"
},
"selfHostedSync": {
"label": "自托管同步",
"title": "备份到自己的服务器",
"body": "将 Donut 指向自托管的 donut-sync 服务器,配置文件、代理和分组就会镜像到那里。再加上端到端密码,服务器只会看到密文。",
"action": "打开账户"
},
"trash": {
"label": "回收站",
"title": "已删除的配置文件在回收站等候",
"body": "已删除的配置文件默认在回收站保留 30 天,并可连同全部内容一起恢复。保留期限在高级设置中。",
"action": "打开回收站"
},
"localApi": {
"label": "API 与 MCP",
"title": "用脚本和智能体自动化 Donut",
"body": "本地 REST API 和 MCP 服务器可让脚本和 AI 智能体列出、创建和配置配置文件。在“集成”中开启并复制令牌。",
"action": "打开集成"
},
"importProfiles": {
"label": "导入",
"title": "从 Chrome、Edge 或 Brave 迁移配置文件",
"body": "导入会把 Chromium 配置文件或压缩包中的 Cookie、登录信息和扩展复制到新的 Donut 配置文件,随时可以启动。",
"action": "打开导入"
},
"cloudBackup": {
"label": "云同步",
"title": "让配置文件出现在每台设备上",
"body": "云同步会备份配置文件、代理和分组,并在另一台电脑上恢复。可在同步列中按配置文件开启。",
"action": "打开账户"
},
"cookieBot": {
"label": "Cookie Bot",
"title": "夜间养号",
"body": "Cookie Bot 会从远程主机按计划浏览真实网站,让新配置文件在您使用之前积累自然的历史记录。",
"action": "打开 Cookie Bot"
},
"crossOs": {
"label": "跨系统指纹",
"title": "伪装成任意操作系统",
"body": "跨系统指纹可让配置文件在任何电脑上都报告为 macOS、Windows 或 Linux。在创建或编辑指纹时选择平台。",
"action": "打开配置文件"
},
"automation": {
"label": "自动化",
"title": "用代码启动和驱动配置文件",
"body": "run、open-url 和 kill 端点可从脚本启动真实的配置文件,每个已启动的配置文件都会提供供 Playwright 或 Puppeteer 使用的 CDP 端点。",
"action": "打开集成"
},
"agent": {
"label": "智能体",
"title": "把点击交给智能体",
"body": "描述一个任务,智能体就会一步步驱动配置文件,记录它做了什么,并能作为配方重放。",
"action": "打开智能体"
},
"team": {
"label": "团队锁定",
"title": "共享配置文件而不冲突",
"body": "在团队中,正在运行的配置文件会对其他人锁定,关闭后释放。账户页面会显示谁在使用什么。",
"action": "打开账户"
},
"remoteControl": {
"label": "远程控制",
"title": "从 donutbrowser.com 控制这台电脑",
"body": "开启远程控制后,网站上的智能体可通过出站桥接访问这台电脑的配置文件。在“集成”中开启。",
"action": "打开集成"
}
}
},
"paidWelcome": {
"title": "欢迎使用 {{plan}}",
"body": "您的套餐刚刚解锁了这些功能。每条小贴士都会演示其中一项。",
"cta": "带我看看",
"later": "稍后"
}
}
+3
View File
@@ -39,6 +39,7 @@ export type BackendErrorCode =
| "EXTENSION_UNSUPPORTED_FILE_TYPE"
| "EXTENSION_DIR_NOT_FOUND"
| "EXTENSION_NOT_A_DIRECTORY"
| "EXTENSION_PATH_INVALID"
| "EXTENSION_MANIFEST_MISSING"
| "EXTENSION_MANIFEST_INVALID"
| "EXTENSION_DIR_TOO_LARGE"
@@ -395,6 +396,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.extensionDirNotFound");
case "EXTENSION_NOT_A_DIRECTORY":
return t("backendErrors.extensionNotADirectory");
case "EXTENSION_PATH_INVALID":
return t("backendErrors.extensionPathInvalid");
case "EXTENSION_MANIFEST_MISSING":
return t("backendErrors.extensionManifestMissing");
case "EXTENSION_MANIFEST_INVALID":
+10
View File
@@ -31,6 +31,7 @@ export interface ShortcutDef {
export type ShortcutId =
| "openPalette"
| "openShortcuts"
| "openTips"
| "importProfile"
| "goProfiles"
| "goProxies"
@@ -59,6 +60,15 @@ export const SHORTCUTS: ShortcutDef[] = [
key: "/",
mod: true,
},
{
// Mod+Shift+H, "hints". Plain Mod+H hides the window on macOS.
id: "openTips",
labelKey: "shortcuts.openTips",
group: "actions",
key: "h",
mod: true,
shift: true,
},
{
id: "importProfile",
labelKey: "shortcuts.importProfile",
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { isPlanTip, pickAutoTip, TIPS, tipsFor } from "./tips.ts";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const LOCALES = path.join(HERE, "..", "i18n", "locales");
const NONE = {
active: false,
cloudBackup: false,
cookieBot: false,
crossOsFingerprints: false,
browserAutomation: false,
agentAutomation: false,
teamCollaboration: false,
remoteControl: false,
};
test("tip ids are unique and every tip has copy in every locale", () => {
const ids = TIPS.map((tip) => tip.id);
assert.equal(new Set(ids).size, ids.length);
const locales = readdirSync(LOCALES).filter((name) => name.endsWith(".json"));
assert.ok(locales.length >= 2, "expected several locale files");
for (const name of locales) {
const bundle = JSON.parse(readFileSync(path.join(LOCALES, name), "utf8"));
for (const id of ids) {
const item = bundle.tips?.items?.[id];
assert.ok(item, `${name} is missing tips.items.${id}`);
for (const field of ["label", "title", "body", "action"]) {
assert.equal(
typeof item[field],
"string",
`${name}: tips.items.${id}.${field} must be a string`,
);
assert.ok(
item[field].trim().length > 0,
`${name}: tips.items.${id}.${field} is empty`,
);
}
}
}
});
test("a free install is offered every essential and no plan tip", () => {
const offered = tipsFor(NONE);
assert.ok(offered.length > 0);
assert.ok(offered.every((tip) => !isPlanTip(tip)));
assert.equal(offered.length, TIPS.filter((tip) => !isPlanTip(tip)).length);
});
test("a plan tip needs an active plan with that capability", () => {
const solo = { ...NONE, active: true, cloudBackup: true, cookieBot: true };
const offered = tipsFor(solo).map((tip) => tip.id);
assert.ok(offered.includes("cloudBackup"));
assert.ok(offered.includes("cookieBot"));
assert.ok(!offered.includes("team"), "solo has no team collaboration");
assert.ok(!offered.includes("remoteControl"));
const lapsed = { ...solo, active: false };
assert.ok(
tipsFor(lapsed).every((tip) => !isPlanTip(tip)),
"a lapsed plan is offered only the essentials",
);
});
test("the automatic flow picks the first unseen tip in catalog order", () => {
const offered = tipsFor(NONE);
assert.equal(pickAutoTip(offered, []), offered[0]);
assert.equal(pickAutoTip(offered, [offered[0].id]), offered[1]);
assert.equal(
pickAutoTip(
offered,
offered.map((tip) => tip.id),
),
null,
);
});
+161
View File
@@ -0,0 +1,161 @@
import type { AppPage } from "@/components/rail-nav";
import type { Entitlements } from "@/types";
/**
* The plan capabilities a tip can be gated on. A tip that names one of these
* is offered only when the signed-in plan grants it, so nobody is walked
* through a feature they cannot open.
*/
export type TipRequirement = Extract<
keyof Entitlements,
| "cloudBackup"
| "cookieBot"
| "crossOsFingerprints"
| "browserAutomation"
| "agentAutomation"
| "teamCollaboration"
| "remoteControl"
>;
/** Where a tip's action button takes the user. */
export type TipAction =
| { kind: "page"; page: AppPage }
| { kind: "settings"; section: string }
| { kind: "palette" };
export type TipId =
| "dnsBlocklist"
| "proxyCheck"
| "groups"
| "commandPalette"
| "fingerprintGate"
| "profilePassword"
| "clearOnClose"
| "defaultBrowser"
| "extensionGroups"
| "selfHostedSync"
| "trash"
| "localApi"
| "importProfiles"
| "cloudBackup"
| "cookieBot"
| "crossOs"
| "automation"
| "agent"
| "team"
| "remoteControl";
export interface TipDefinition {
id: TipId;
action: TipAction;
requires?: TipRequirement;
}
/**
* Every tip, in the order the automatic flow offers them. The essentials come
* first because they apply to every install; the plan tips follow, and each
* one is skipped for a plan that lacks the capability.
*
* The copy lives under `tips.items.<id>` in every locale: `label`, `title`,
* `body` and `action`. `src/lib/tips.test.mjs` checks that no tip is missing its text.
*/
export const TIPS: readonly TipDefinition[] = [
{ id: "dnsBlocklist", action: { kind: "settings", section: "dns" } },
{ id: "proxyCheck", action: { kind: "page", page: "proxies" } },
{ id: "groups", action: { kind: "page", page: "groups" } },
{ id: "commandPalette", action: { kind: "palette" } },
{
id: "fingerprintGate",
action: { kind: "settings", section: "advanced" },
},
{ id: "profilePassword", action: { kind: "page", page: "profiles" } },
{ id: "clearOnClose", action: { kind: "page", page: "profiles" } },
{ id: "defaultBrowser", action: { kind: "settings", section: "default" } },
{ id: "extensionGroups", action: { kind: "page", page: "extensions" } },
{ id: "selfHostedSync", action: { kind: "page", page: "account" } },
{ id: "trash", action: { kind: "page", page: "trash" } },
{ id: "localApi", action: { kind: "page", page: "integrations" } },
{ id: "importProfiles", action: { kind: "page", page: "import" } },
{
id: "cloudBackup",
action: { kind: "page", page: "account" },
requires: "cloudBackup",
},
{
id: "cookieBot",
action: { kind: "page", page: "cookieBot" },
requires: "cookieBot",
},
{
id: "crossOs",
action: { kind: "page", page: "profiles" },
requires: "crossOsFingerprints",
},
{
id: "automation",
action: { kind: "page", page: "integrations" },
requires: "browserAutomation",
},
{
id: "agent",
action: { kind: "page", page: "agent" },
requires: "agentAutomation",
},
{
id: "team",
action: { kind: "page", page: "account" },
requires: "teamCollaboration",
},
{
id: "remoteControl",
action: { kind: "page", page: "integrations" },
requires: "remoteControl",
},
];
/** How long after the app settles the automatic tip waits before opening. */
export const TIP_AUTO_DELAY_MS = 2500;
/**
* A sign-in younger than this counts as "just came back from the website",
* which is when a paid account seen for the first time gets its welcome.
*/
export const FRESH_LOGIN_WINDOW_MS = 15 * 60 * 1000;
export function isPlanTip(tip: TipDefinition): boolean {
return tip.requires !== undefined;
}
/** The tips this plan may see: every essential, plus the plan tips it unlocks. */
export function tipsFor(
entitlements: Pick<Entitlements, "active" | TipRequirement>,
): TipDefinition[] {
return TIPS.filter(
(tip) =>
tip.requires === undefined ||
(entitlements.active && entitlements[tip.requires]),
);
}
/** The first tip the user has not seen yet, or null when they have seen them all. */
export function pickAutoTip(
tips: readonly TipDefinition[],
seen: readonly string[],
): TipDefinition | null {
return tips.find((tip) => !seen.includes(tip.id)) ?? null;
}
export function tipTextKeys(id: TipId): {
/** The short name the catalog lists the tip under. */
label: string;
title: string;
body: string;
action: string;
} {
return {
label: `tips.items.${id}.label`,
title: `tips.items.${id}.title`,
body: `tips.items.${id}.body`,
action: `tips.items.${id}.action`,
};
}