feat: xray support

This commit is contained in:
zhom
2026-07-31 01:04:58 +04:00
parent 064bf297dd
commit 0a7d7803f2
112 changed files with 10291 additions and 772 deletions
+232 -62
View File
@@ -3,8 +3,9 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuArrowLeft, LuExternalLink, LuSearch } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -12,6 +13,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Input } from "@/components/ui/input";
import { StepTransition } from "@/components/ui/step-transition";
import licenses from "@/generated/licenses.json";
import xraySource from "@/generated/xray-source.json";
import { launchDonutClone } from "@/lib/donut-physics";
import { Logo } from "./icons/logo";
import { RippleButton } from "./ui/ripple";
@@ -28,6 +34,8 @@ interface SystemInfo {
portable: boolean;
}
type AboutView = "about" | "licenses";
// Flywheel: each click adds spin; past this speed the donut escapes the
// dialog and bounces around the window (shared physics with the rail egg).
const SPIN_PER_CLICK = 540; // deg/s
@@ -39,6 +47,8 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
const reducedMotion = useReducedMotion();
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [logoFlown, setLogoFlown] = useState(false);
const [view, setView] = useState<AboutView>("about");
const [licenseQuery, setLicenseQuery] = useState("");
const logoRef = useRef<HTMLButtonElement>(null);
const rotationRef = useRef(0);
@@ -46,6 +56,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
const rafRef = useRef(0);
const lastTimeRef = useRef(0);
const cancelLaunchRef = useRef<(() => void) | null>(null);
const restoreLicensesButtonFocusRef = useRef(false);
useEffect(() => {
if (!isOpen) return;
@@ -111,7 +122,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
}
}, [reducedMotion, logoFlown, spinFrame]);
const handleClose = useCallback(() => {
const resetLogo = useCallback(() => {
stopSpin();
cancelLaunchRef.current?.();
cancelLaunchRef.current = null;
@@ -121,8 +132,47 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
logoRef.current.style.visibility = "";
}
setLogoFlown(false);
}, [stopSpin]);
const handleClose = useCallback(() => {
resetLogo();
setView("about");
setLicenseQuery("");
restoreLicensesButtonFocusRef.current = false;
onClose();
}, [stopSpin, onClose]);
}, [onClose, resetLogo]);
const handleLicensesButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
if (node && restoreLicensesButtonFocusRef.current) {
restoreLicensesButtonFocusRef.current = false;
node.focus();
}
},
[],
);
const handleOpenLicenses = useCallback(() => {
resetLogo();
setLicenseQuery("");
setView("licenses");
}, [resetLogo]);
const handleBackToAbout = useCallback(() => {
restoreLicensesButtonFocusRef.current = true;
setLicenseQuery("");
setView("about");
}, []);
const filteredLicenses = useMemo(() => {
const query = licenseQuery.trim().toLocaleLowerCase();
if (!query) return licenses;
return licenses.filter(
({ name, license }) =>
name.toLocaleLowerCase().includes(query) ||
license.toLocaleLowerCase().includes(query),
);
}, [licenseQuery]);
useEffect(() => {
return () => {
@@ -132,72 +182,192 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
}, []);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("about.title")}</DialogTitle>
</DialogHeader>
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) handleClose();
}}
>
<DialogContent
className={
view === "licenses"
? "flex h-[min(80dvh,40rem)] max-w-lg flex-col overflow-hidden"
: "max-w-sm"
}
>
<StepTransition
transitionKey={view}
direction={view === "licenses" ? 1 : -1}
className={
view === "licenses"
? "flex min-h-0 flex-1 flex-col gap-4"
: "grid gap-4"
}
>
{view === "about" ? (
<>
<DialogHeader>
<DialogTitle>{t("about.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-4">
<button
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
onClick={handleLogoClick}
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
style={logoFlown ? { visibility: "hidden" } : undefined}
>
<Logo className="size-14" />
</button>
<div className="flex flex-col items-center gap-3 py-4">
<button
ref={logoRef}
type="button"
aria-label={t("header.donutLogo")}
onClick={handleLogoClick}
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
style={logoFlown ? { visibility: "hidden" } : undefined}
>
<Logo className="size-14" />
</button>
<div className="text-center">
<p className="text-lg font-semibold">Donut Browser</p>
{systemInfo && (
<>
<p className="text-sm text-muted-foreground">
{t("about.version", { version: systemInfo.app_version })}
{systemInfo.portable && (
<span className="ml-1.5 rounded border border-border bg-muted px-1 py-px text-[10px] align-middle">
{t("about.portableBadge")}
</span>
<div className="text-center">
<p className="text-lg font-semibold">Donut Browser</p>
{systemInfo && (
<>
<p className="text-sm text-muted-foreground">
{t("about.version", {
version: systemInfo.app_version,
})}
{systemInfo.portable && (
<span className="ml-1.5 rounded border border-border bg-muted px-1 py-px text-[10px] align-middle">
{t("about.portableBadge")}
</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{systemInfo.os} {systemInfo.arch}
</p>
</>
)}
</div>
<p className="text-center text-xs text-muted-foreground">
{t("about.licenseNotice")}
</p>
<p className="text-xs text-muted-foreground">
{systemInfo.os} {systemInfo.arch}
</p>
</>
)}
</div>
<p className="text-center text-xs text-muted-foreground">
{t("about.licenseNotice")}
</p>
<div className="flex flex-wrap justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void openUrl("https://donutbrowser.com")}
>
{t("about.website")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
void openUrl("https://github.com/zhom/donutbrowser")
}
>
{t("about.github")}
</Button>
<Button
ref={handleLicensesButtonRef}
variant="outline"
size="sm"
onClick={handleOpenLicenses}
>
{t("about.licenses")}
</Button>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void openUrl("https://donutbrowser.com")}
>
{t("about.website")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
void openUrl("https://github.com/zhom/donutbrowser")
}
>
GitHub
</Button>
</div>
</div>
<div className="flex justify-end">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.close")}
</RippleButton>
</div>
</>
) : (
<>
<DialogHeader className="flex-row items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-2 size-8"
aria-label={t("common.buttons.back")}
onClick={handleBackToAbout}
>
<LuArrowLeft aria-hidden="true" />
</Button>
<DialogTitle>{t("about.licenses")}</DialogTitle>
</DialogHeader>
<div className="flex justify-end">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.close")}
</RippleButton>
</div>
<div className="relative shrink-0">
<LuSearch
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
autoFocus
type="search"
value={licenseQuery}
onChange={(event) => {
setLicenseQuery(event.target.value);
}}
aria-label={t("about.searchLicenses")}
placeholder={t("about.searchLicenses")}
className="pl-9"
/>
</div>
<FadingScrollArea
role="region"
tabIndex={0}
aria-label={t("about.licenses")}
className="-mx-2 min-h-0 flex-1 rounded-md px-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{filteredLicenses.length > 0 ? (
<ul>
{filteredLicenses.map(({ name, license }) => (
<li
key={`${name}-${license}`}
className="grid grid-cols-[minmax(0,1fr)_minmax(7rem,45%)] items-baseline gap-3 border-b border-border/60 py-2.5 last:border-b-0"
>
{name === "Xray-core" ? (
<button
type="button"
onClick={() => void openUrl(xraySource.sourceUrl)}
aria-label={t("about.openSource", { name })}
title={t("about.openSource", { name })}
className="flex min-w-0 items-center gap-1.5 rounded-sm text-left text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 active:scale-[0.98] motion-reduce:transform-none"
>
<span className="truncate">{name}</span>
<LuExternalLink
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground"
/>
</button>
) : (
<span
className="truncate text-sm font-medium text-foreground"
title={name}
>
{name}
</span>
)}
<code className="break-words text-right text-xs text-muted-foreground">
{license}
</code>
</li>
))}
</ul>
) : (
<p
role="status"
className="py-12 text-center text-sm text-muted-foreground"
>
{t("about.noMatchingLicenses")}
</p>
)}
</FadingScrollArea>
</>
)}
</StepTransition>
</DialogContent>
</Dialog>
);
+3 -3
View File
@@ -222,7 +222,7 @@ export function AccountPage({
<AnimatedTabsContent value="account" className="mt-4">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-foreground">
<div className="grid size-12 shrink-0 place-items-center rounded-full bg-accent text-accent-foreground">
<LuUser className="size-6" />
</div>
<div className="min-w-0 flex-1">
@@ -305,7 +305,7 @@ export function AccountPage({
user &&
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === false && (
<p className="text-xs text-warning">
<p className="text-xs text-warning-text">
{t("account.automationPrimaryOnly")}
</p>
)}
@@ -314,7 +314,7 @@ export function AccountPage({
getEntitlements(user).browserAutomation &&
user.isPrimaryDevice === true &&
(user.deviceCount ?? 1) > 1 && (
<p className="text-xs text-success">
<p className="text-xs text-success-text">
{t("account.automationActiveHere")}
</p>
)}
+1 -1
View File
@@ -63,7 +63,7 @@ export function BandwidthMiniChart({
type="button"
onClick={onClick}
className={cn(
"relative flex w-full min-w-0 cursor-pointer items-center gap-1.5 rounded border-none bg-transparent px-2 transition-colors hover:bg-accent/50",
"relative flex w-full min-w-0 cursor-pointer items-center gap-1.5 rounded border-none bg-transparent px-2 transition-colors hover:bg-muted",
className,
)}
>
@@ -98,7 +98,7 @@ export function ConsistencyWarningDialog({
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuTriangleAlert className="size-5 text-warning" />
<LuTriangleAlert className="size-5 text-warning-text" />
{t("consistencyWarning.title")}
</DialogTitle>
</DialogHeader>
+3 -3
View File
@@ -407,7 +407,7 @@ export function CookieCopyDialog({
>
{p.name}
{runningProfiles.has(p.id) && (
<span className="text-xs text-destructive">
<span className="text-xs text-destructive-text">
{t("cookies.copy.running")}
</span>
)}
@@ -451,7 +451,7 @@ export function CookieCopyDialog({
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : error ? (
<div className="rounded-md bg-destructive/10 p-4 text-center text-destructive">
<div className="rounded-md bg-destructive/10 p-4 text-center text-destructive-text">
{error}
</div>
) : filteredDomains.length === 0 ? (
@@ -547,7 +547,7 @@ function DomainRow({
return (
<AnimatedDisclosureItem>
<div className="flex items-center gap-2 rounded p-2 hover:bg-accent/50">
<div className="flex items-center gap-2 rounded p-2 hover:bg-muted">
<Checkbox
checked={isAllSelected || isPartial}
onCheckedChange={() => {
+2 -2
View File
@@ -487,7 +487,7 @@ export function CookieManagementDialog({
{importResult && (
<div className="space-y-4">
<div className="rounded-lg bg-success/10 p-4">
<div className="font-medium text-success">
<div className="font-medium text-success-text">
{t("cookies.management.importedSuccess", {
imported: importResult.cookies_imported,
replaced: importResult.cookies_replaced,
@@ -634,7 +634,7 @@ function ExportDomainRow({
return (
<AnimatedDisclosureItem>
<div className="flex items-center gap-2 rounded p-1.5 hover:bg-accent/50">
<div className="flex items-center gap-2 rounded p-1.5 hover:bg-muted">
<Checkbox
checked={isAllSelected || isPartial}
onCheckedChange={() => {
+1 -1
View File
@@ -93,7 +93,7 @@ export function CreateGroupDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
+5 -5
View File
@@ -741,7 +741,7 @@ export function CreateProfileDialog({
autoComplete="new-password"
/>
{passwordError && (
<p className="text-sm text-destructive">
<p className="text-sm text-destructive-text">
{passwordError}
</p>
)}
@@ -764,7 +764,7 @@ export function CreateProfileDialog({
)}
{!isLoadingReleaseTypes && releaseTypesError && (
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="flex-1 text-sm text-destructive">
<p className="flex-1 text-sm text-destructive-text">
{releaseTypesError}
</p>
<RippleButton
@@ -783,7 +783,7 @@ export function CreateProfileDialog({
!releaseTypesError &&
!getBestAvailableVersion("wayfern") && (
<div className="flex items-center gap-3 rounded-md border border-warning/50 bg-warning/10 p-3">
<p className="text-sm text-warning">
<p className="text-sm text-warning-text">
{t("createProfile.platformUnavailable", {
browser: "Wayfern",
})}
@@ -910,7 +910,7 @@ export function CreateProfileDialog({
{!isLoadingReleaseTypes &&
releaseTypesError && (
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="flex-1 text-sm text-destructive">
<p className="flex-1 text-sm text-destructive-text">
{releaseTypesError}
</p>
<RippleButton
@@ -1272,7 +1272,7 @@ export function CreateProfileDialog({
)}
{!isLoadingReleaseTypes && releaseTypesError && (
<div className="flex items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="flex-1 text-sm text-destructive">
<p className="flex-1 text-sm text-destructive-text">
{releaseTypesError}
</p>
<RippleButton
+1 -1
View File
@@ -330,7 +330,7 @@ export function UnifiedToast(props: ToastProps) {
})}`}
</p>
{progress.failed_count > 0 && (
<p className="mt-0.5 text-xs text-destructive">
<p className="mt-0.5 text-xs text-destructive-text">
{t("toasts.progress.filesFailed", {
count: progress.failed_count,
})}
+3 -3
View File
@@ -98,7 +98,7 @@ function DataTableActionBarAction({
variant="secondary"
size={size}
className={cn(
"gap-1.5 border border-secondary bg-secondary/50 hover:bg-secondary/70 [&>svg]:size-3.5",
"gap-1.5 border border-secondary bg-secondary text-secondary-foreground hover:bg-secondary hover:text-secondary-foreground [&>svg]:size-3.5",
size === "icon" ? "size-7" : "h-7",
className,
)}
@@ -120,7 +120,7 @@ function DataTableActionBarAction({
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent
sideOffset={6}
className="border bg-accent font-semibold text-foreground dark:bg-card [&>span]:hidden"
className="border bg-accent font-semibold text-accent-foreground [&>span]:hidden"
>
<p>{tooltip}</p>
</TooltipContent>
@@ -161,7 +161,7 @@ function DataTableActionBarSelection<TData>({
</TooltipTrigger>
<TooltipContent
sideOffset={10}
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-card [&>span]:hidden"
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-accent-foreground [&>span]:hidden"
>
<p>{t("dataTableActionBar.clearSelection")}</p>
<kbd className="rounded border bg-background px-1.5 py-px font-mono text-[0.7rem] font-normal text-foreground shadow-xs select-none">
+2 -2
View File
@@ -165,7 +165,7 @@ export function DeleteGroupDialog({
<RadioGroupItem value="delete" id="delete" />
<Label
htmlFor="delete"
className="text-sm text-destructive"
className="text-sm text-destructive-text"
>
{t("groups.deleteAlongWithGroup")}
</Label>
@@ -184,7 +184,7 @@ export function DeleteGroupDialog({
)}
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
+1 -1
View File
@@ -103,7 +103,7 @@ export function EditGroupDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
@@ -160,7 +160,7 @@ export function ExtensionGroupAssignmentDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
@@ -1147,13 +1147,13 @@ export function ExtensionManagementDialog({
disabled={limitedMode}
>
<span>{t("extensions.extensionsTab")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-xs tabular-nums">
{extensions.length}
</span>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="groups" disabled={limitedMode}>
<span>{t("extensions.groupsTab")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-xs tabular-nums">
{extensionGroups.length}
</span>
</AnimatedTabsTrigger>
@@ -1696,7 +1696,7 @@ export function ExtensionManagementDialog({
href={editingExtension.homepage_url}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-0 items-center gap-1 text-primary hover:underline"
className="flex min-w-0 items-center gap-1 text-primary-text hover:underline"
>
<span className="truncate">
{editingExtension.homepage_url}
@@ -1849,7 +1849,7 @@ export function ExtensionManagementDialog({
tooltip={t("common.buttons.delete")}
variant="destructive"
size="icon"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
onClick={() => {
setBulkExtDeleteOpen(true);
}}
@@ -1875,7 +1875,7 @@ export function ExtensionManagementDialog({
tooltip={t("common.buttons.delete")}
variant="destructive"
size="icon"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
onClick={() => {
setBulkGroupDeleteOpen(true);
}}
+1 -1
View File
@@ -198,7 +198,7 @@ export function GroupAssignmentDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
+10 -4
View File
@@ -569,9 +569,15 @@ export function GroupManagementDialog({
<div className="@container flex min-h-0 w-full flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<div className="inline-flex h-7 items-center justify-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium whitespace-nowrap text-foreground">
<div
data-slot="group-summary-pill"
className="inline-flex h-7 items-center justify-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium whitespace-nowrap text-accent-foreground"
>
<span>{t("groups.pageTitle")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span
data-slot="group-summary-count"
className="text-xs tabular-nums"
>
{groups.length}
</span>
</div>
@@ -591,7 +597,7 @@ export function GroupManagementDialog({
</div>
{error && (
<div className="mt-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="mt-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
@@ -708,7 +714,7 @@ export function GroupManagementDialog({
onClick={() => setBulkDeleteOpen(true)}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
>
<LuTrash2 />
</DataTableActionBarAction>
+3 -3
View File
@@ -221,7 +221,7 @@ const HomeHeader = ({
behavior: "smooth",
});
}}
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LuChevronLeft className="size-3" />
</button>
@@ -295,7 +295,7 @@ const HomeHeader = ({
behavior: "smooth",
});
}}
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LuChevronRight className="size-3" />
</button>
@@ -323,7 +323,7 @@ const HomeHeader = ({
onClick={() => {
onSearchQueryChange("");
}}
className="absolute top-1/2 right-1.5 -translate-y-1/2 transform rounded-sm p-0.5 transition-colors hover:bg-accent"
className="absolute top-1/2 right-1.5 -translate-y-1/2 transform rounded-sm p-0.5 transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label={t("header.clearSearch")}
>
<LuX className="size-3.5 text-muted-foreground hover:text-foreground" />
+4 -3
View File
@@ -847,10 +847,11 @@ export function ImportProfileDialog({
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" && "text-success",
item.status === "imported" && "text-success-text",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" && "text-destructive",
item.status === "failed" &&
"text-destructive-text",
)}
>
{item.status === "imported" &&
@@ -864,7 +865,7 @@ export function ImportProfileDialog({
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive">
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
{translateBackendError(t, new Error(item.error))}
</span>
)}
+2 -2
View File
@@ -535,7 +535,7 @@ export function IntegrationsDialog({
<p className="text-xs text-muted-foreground">
{t("integrations.mcpEnableDescription")}
{!termsAccepted && (
<span className="ml-1 text-warning">
<span className="ml-1 text-warning-text">
{t("integrations.mcpAcceptTermsFirst")}
</span>
)}
@@ -622,7 +622,7 @@ export function IntegrationsDialog({
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-destructive"
className="size-8 text-muted-foreground hover:text-destructive-text"
disabled={busy}
onClick={() =>
void handleRemoveAgent(agent)
+1 -1
View File
@@ -205,7 +205,7 @@ export function PermissionDialog({
<div className="space-y-4">
{!isCurrentPermissionGranted && (
<div className="rounded-lg bg-warning/10 p-3">
<p className="text-sm text-warning">
<p className="text-sm text-warning-text">
{permissionType === "microphone"
? t("permissionDialog.notGrantedMicrophone")
: t("permissionDialog.notGrantedCamera")}
+10 -10
View File
@@ -370,7 +370,7 @@ function ExtCell({
<button
type="button"
disabled={isSaving}
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
>
<LuPuzzle className="size-3 shrink-0" />
<span className="flex-1 truncate" title={label}>
@@ -457,7 +457,7 @@ function DnsCell({
type="button"
data-onborda="dns-blocklist"
disabled={isSaving}
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
className="flex h-7 w-full items-center gap-1.5 rounded px-1.5 text-left text-xs text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
title={
level
? meta.t("profiles.table.dnsLevel", { level })
@@ -681,7 +681,7 @@ const TagsCell = React.memo<{
"flex h-6 w-full cursor-pointer items-center gap-1 overflow-hidden rounded border-none bg-transparent px-2 py-1",
isDisabled
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-accent/50",
: "cursor-pointer hover:bg-muted",
)}
onClick={() => {
if (!isDisabled) setOpenTagsEditorFor(profile.id);
@@ -894,7 +894,7 @@ const ProxyCellTrigger = React.memo<{
"flex max-w-full min-w-0 items-center gap-2 rounded px-2 py-1",
isDisabled
? "pointer-events-none cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-accent/50",
: "cursor-pointer hover:bg-muted",
)}
>
{vpnBadge && (
@@ -1043,7 +1043,7 @@ const NoteCell = React.memo<{
"flex min-h-6 w-full min-w-0 items-center rounded border-none bg-transparent px-2 py-1 text-left",
isDisabled
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-accent/50",
: "cursor-pointer hover:bg-muted",
)}
onClick={() => {
if (!isDisabled) {
@@ -2362,7 +2362,7 @@ export function ProfilesDataTable({
<Tooltip>
<TooltipTrigger asChild>
<span>
<LuTriangleAlert className="size-4 text-warning" />
<LuTriangleAlert className="size-4 text-warning-text" />
</span>
</TooltipTrigger>
<TooltipContent>
@@ -2390,7 +2390,7 @@ export function ProfilesDataTable({
canLaunch && "cursor-pointer",
isFollower && "border-accent",
isRunning &&
"bg-destructive/10 text-destructive hover:bg-destructive/20",
"bg-destructive/10 text-destructive-text hover:bg-destructive/20",
)}
onClick={() =>
isRunning
@@ -2611,7 +2611,7 @@ export function ProfilesDataTable({
"mr-auto h-6 max-w-full min-w-0 overflow-hidden rounded border-none bg-transparent px-2 py-1 text-left",
isCrossOsBlocked
? "cursor-not-allowed opacity-60"
: "cursor-pointer hover:bg-accent/50",
: "cursor-pointer hover:bg-muted",
)}
onClick={() => {
if (isCrossOsBlocked) return;
@@ -3325,7 +3325,7 @@ export function ProfilesDataTable({
title={crossOsTitle}
style={{ height: `${ROW_HEIGHT}px` }}
className={cn(
"overflow-visible border-0! hover:bg-accent/50",
"overflow-visible border-0! hover:bg-muted",
rowIsCrossOs && "opacity-60",
)}
>
@@ -3514,7 +3514,7 @@ export function ProfilesDataTable({
onClick={onBulkDelete}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
>
<LuTrash2 />
</DataTableActionBarAction>
+30 -24
View File
@@ -870,7 +870,7 @@ function ProfileInfoLayout({
type="button"
aria-label={t("common.buttons.close")}
onClick={onClose}
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors duration-100 hover:bg-accent/50 hover:text-foreground"
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<LuX className="size-3.5" />
</button>
@@ -893,7 +893,7 @@ function ProfileInfoLayout({
"flex h-7 items-center gap-2 rounded-md px-2 text-left text-xs transition-colors duration-100",
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<span className="shrink-0">{it.icon}</span>
@@ -913,7 +913,7 @@ function ProfileInfoLayout({
type="button"
onClick={deleteAction.onClick}
disabled={deleteAction.disabled}
className="flex h-7 items-center gap-2 rounded-md px-2 text-xs text-destructive transition-colors duration-100 hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
className="flex h-7 items-center gap-2 rounded-md px-2 text-xs text-destructive-text transition-colors duration-100 hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50"
>
<LuTrash2 className="size-3.5 shrink-0" />
<span className="flex-1 text-left">
@@ -1181,8 +1181,8 @@ function _SectionAction({
className={cn(
"flex h-9 items-center gap-2 rounded-md px-3 text-left text-xs transition-colors",
destructive
? "text-destructive hover:bg-destructive/10"
: "hover:bg-accent",
? "text-destructive-text hover:bg-destructive/10"
: "hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-50",
)}
>
@@ -1254,11 +1254,11 @@ function LaunchHookEditor({
className="font-mono text-xs"
/>
{showInvalidHint && (
<p className="text-xs text-warning">
<p className="text-xs text-warning-text">
{t("profileInfo.launchHook.invalidUrlHint")}
</p>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
<div className="flex items-center gap-2">
<Button
size="sm"
@@ -1360,11 +1360,13 @@ function SyncSectionInline({
{t(`profileInfo.syncStatusValue.${syncStatus.status}`)}
</p>
{syncStatus.error && (
<p className="mt-1 text-xs text-destructive">{syncStatus.error}</p>
<p className="mt-1 text-xs text-destructive-text">
{syncStatus.error}
</p>
)}
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
</div>
);
}
@@ -1500,7 +1502,7 @@ function NetworkSectionInline({
</Select>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
</div>
);
}
@@ -1604,7 +1606,7 @@ function ExtensionsSectionInline({
</SelectContent>
</Select>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
</div>
);
}
@@ -1812,7 +1814,7 @@ function CookiesSectionInline({
</ul>
</div>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
</>
)}
</div>
@@ -1928,8 +1930,10 @@ function FingerprintSectionInline({
profileBrowser={profile.browser}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
{success && !error && <p className="text-xs text-success">{success}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
{success && !error && (
<p className="text-xs text-success-text">{success}</p>
)}
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
<Button
@@ -2102,7 +2106,7 @@ function SecuritySectionInline({
}}
className={cn(
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
"border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
"border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{t("profilePassword.modes.validate")}
@@ -2117,7 +2121,7 @@ function SecuritySectionInline({
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
mode === "change"
? "border-transparent bg-accent text-accent-foreground"
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
: "border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{t("profilePassword.modes.change")}
@@ -2131,8 +2135,8 @@ function SecuritySectionInline({
className={cn(
"h-7 flex-1 rounded-md border px-2 text-xs transition-colors",
mode === "remove"
? "border-transparent bg-destructive/10 text-destructive"
: "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground",
? "border-transparent bg-destructive/10 text-destructive-text"
: "border-border text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{t("profilePassword.modes.remove")}
@@ -2182,8 +2186,10 @@ function SecuritySectionInline({
)}
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
{success && !error && <p className="text-xs text-success">{success}</p>}
{error && <p className="text-xs text-destructive-text">{error}</p>}
{success && !error && (
<p className="text-xs text-success-text">{success}</p>
)}
{isRunning && (
<p className="text-xs text-muted-foreground">
@@ -2395,7 +2401,7 @@ export function ProfileDnsBlocklistDialog({
href="https://github.com/hagezi/dns-blocklists"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
className="text-primary-text hover:underline"
>
{t("common.buttons.moreInfo")}
</a>
@@ -2408,8 +2414,8 @@ export function ProfileDnsBlocklistDialog({
onClick={() => setLevel(option.value)}
className={`w-full text-left px-3 py-2 rounded-md text-sm transition-colors ${
level === option.value
? "bg-primary/10 text-primary border border-primary/30"
: "hover:bg-accent border border-transparent"
? "border border-primary/30 bg-primary/10 text-primary-text"
: "border border-transparent hover:bg-accent hover:text-accent-foreground"
}`}
>
{option.label}
@@ -2531,7 +2537,7 @@ export function ProfileBypassRulesDialog({
onClick={() => {
handleRemoveRule(rule);
}}
className="shrink-0 text-muted-foreground transition-colors hover:text-destructive"
className="shrink-0 text-muted-foreground transition-colors hover:text-destructive-text"
>
<LuX className="size-3.5" />
</button>
+2 -2
View File
@@ -203,7 +203,7 @@ export function ProfilePasswordDialog({
<div className="flex flex-col gap-3">
{(mode === "set" || mode === "change") && (
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-sm">
<p className="font-medium text-warning">
<p className="font-medium text-warning-text">
{t("profilePassword.warnings.forgetWarningTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
@@ -212,7 +212,7 @@ export function ProfilePasswordDialog({
</div>
)}
{lockoutSecondsRemaining != null && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive-text">
{t("backendErrors.lockedOut", {
duration: formatLockoutDuration(t, lockoutSecondsRemaining),
})}
+1 -1
View File
@@ -267,7 +267,7 @@ export function ProfileSyncDialog({
{syncMode === "Encrypted" &&
!hasE2ePassword &&
userChangedMode && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{t("sync.mode.noPasswordWarning")}
</div>
)}
+1 -1
View File
@@ -299,7 +299,7 @@ export function ProxyAssignmentDialog({
</div>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive-text">
{error}
</div>
)}
+4 -4
View File
@@ -165,7 +165,7 @@ export function ProxyCheckButton({
<FiCheck className="absolute right-[-4px] bottom-[-6px]" />
</span>
) : result && !result.is_valid ? (
<span className="text-sm text-destructive"></span>
<span className="text-sm text-destructive-text"></span>
) : (
<FiCheck className="size-3" />
)}
@@ -185,10 +185,10 @@ export function ProxyCheckButton({
{[result.city, result.country].filter(Boolean).join(", ") ||
t("proxyCheck.unknownLocation")}
</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("proxyCheck.tooltipIp", { ip: result.ip })}
</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("proxyCheck.tooltipChecked", {
time: formatRelativeTime(result.timestamp),
})}
@@ -197,7 +197,7 @@ export function ProxyCheckButton({
) : result && !result.is_valid ? (
<div>
<p>{t("proxyCheck.tooltipFailedTitle")}</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("proxyCheck.tooltipFailed", {
time: formatRelativeTime(result.timestamp),
})}
+179 -82
View File
@@ -21,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { translateBackendError } from "@/lib/backend-errors";
import type { StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
@@ -32,6 +33,7 @@ interface ProxyFormData {
port: number;
username: string;
password: string;
vless_uri: string;
}
interface ProxyFormDialogProps {
@@ -47,8 +49,38 @@ const DEFAULT_FORM: ProxyFormData = {
port: 8080,
username: "",
password: "",
vless_uri: "",
};
interface VlessEndpoint {
host: string;
port: number;
}
function parseVlessEndpoint(uri: string): VlessEndpoint | null {
try {
const parsed = new URL(uri.trim());
const port = Number.parseInt(parsed.port, 10);
if (
parsed.protocol !== "vless:" ||
!parsed.hostname ||
!Number.isInteger(port) ||
port < 1 ||
port > 65535
) {
return null;
}
const host =
parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]")
? parsed.hostname.slice(1, -1)
: parsed.hostname;
return { host, port };
} catch {
return null;
}
}
export function ProxyFormDialog({
isOpen,
onClose,
@@ -79,6 +111,7 @@ export function ProxyFormDialog({
port: editingProxy.proxy_settings.port,
username: editingProxy.proxy_settings.username ?? "",
password: editingProxy.proxy_settings.password ?? "",
vless_uri: editingProxy.proxy_settings.vless_uri ?? "",
});
}, [editingProxy, isOpen, resetForm]);
@@ -88,7 +121,20 @@ export function ProxyFormDialog({
return;
}
if (!form.host.trim() || !form.port) {
const isVless = form.proxy_type === "vless";
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
if (isVless && !form.vless_uri.trim()) {
toast.error(t("proxies.form.vlessUriRequired"));
return;
}
if (isVless && !vlessEndpoint) {
toast.error(t("proxies.form.vlessUriInvalid"));
return;
}
if (!isVless && (!form.host.trim() || !form.port)) {
toast.error(t("proxies.form.hostPortRequired"));
return;
}
@@ -107,10 +153,11 @@ export function ProxyFormDialog({
name: form.name.trim(),
proxySettings: {
proxy_type: form.proxy_type,
host: form.host.trim(),
port: form.port,
username: form.username.trim() || undefined,
password: form.password.trim() || undefined,
host: vlessEndpoint?.host ?? form.host.trim(),
port: vlessEndpoint?.port ?? form.port,
username: isVless ? undefined : form.username.trim() || undefined,
password: isVless ? undefined : form.password.trim() || undefined,
vless_uri: isVless ? form.vless_uri.trim() : undefined,
},
};
@@ -144,13 +191,19 @@ export function ProxyFormDialog({
}
}, [isSubmitting, onClose]);
const isVless = form.proxy_type === "vless";
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
const hasInvalidVlessUri =
isVless && form.vless_uri.trim().length > 0 && !vlessEndpoint;
const isFormValid =
form.name.trim() &&
form.host.trim() &&
form.port > 0 &&
form.port <= 65535 &&
(form.proxy_type !== "ss" ||
(form.username.trim() && form.password.trim()));
(isVless
? vlessEndpoint !== null
: form.host.trim() &&
form.port > 0 &&
form.port <= 65535 &&
(form.proxy_type !== "ss" ||
(form.username.trim() && form.password.trim())));
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
@@ -176,7 +229,7 @@ export function ProxyFormDialog({
</div>
<div className="grid gap-2">
<Label>{t("proxies.form.type")}</Label>
<Label htmlFor="proxy-type">{t("proxies.form.type")}</Label>
<Select
value={form.proxy_type}
onValueChange={(value) => {
@@ -184,91 +237,135 @@ export function ProxyFormDialog({
}}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectTrigger id="proxy-type">
<SelectValue placeholder={t("proxies.form.selectType")} />
</SelectTrigger>
<SelectContent>
{["http", "https", "socks4", "socks5", "ss"].map((type) => (
<SelectItem key={type} value={type}>
{type === "ss" ? "Shadowsocks" : type.toUpperCase()}
</SelectItem>
))}
{["http", "https", "socks4", "socks5", "ss", "vless"].map(
(type) => (
<SelectItem key={type} value={type}>
{type === "ss"
? "Shadowsocks"
: type === "vless"
? t("proxies.form.vlessType")
: type.toUpperCase()}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
{isVless ? (
<div className="grid gap-2">
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input
id="proxy-host"
value={form.host}
onChange={(e) => {
setForm({ ...form, host: e.target.value });
}}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input
id="proxy-port"
type="number"
value={form.port}
onChange={(e) => {
setForm({
...form,
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{form.proxy_type === "ss"
? t("proxies.form.cipher")
: t("proxies.form.username")}
<Label htmlFor="proxy-vless-uri">
{t("proxies.form.vlessUri")}
</Label>
<Input
id="proxy-username"
value={form.username}
<Textarea
id="proxy-vless-uri"
value={form.vless_uri}
onChange={(e) => {
setForm({ ...form, username: e.target.value });
setForm({ ...form, vless_uri: e.target.value });
}}
placeholder={
form.proxy_type === "ss"
? t("proxies.form.cipherPlaceholder")
: t("proxies.form.usernamePlaceholder")
placeholder={t("proxies.form.vlessUriPlaceholder")}
disabled={isSubmitting}
aria-invalid={hasInvalidVlessUri}
aria-describedby="proxy-vless-uri-help"
autoCapitalize="none"
autoComplete="off"
spellCheck={false}
className="min-h-24 resize-y font-mono text-xs leading-relaxed"
/>
<p
id="proxy-vless-uri-help"
className={
hasInvalidVlessUri
? "text-xs text-destructive-text"
: "text-xs text-muted-foreground"
}
disabled={isSubmitting}
/>
role={hasInvalidVlessUri ? "alert" : undefined}
>
{hasInvalidVlessUri
? t("proxies.form.vlessUriInvalid")
: t("proxies.form.vlessUriHint")}
</p>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="proxy-host">{t("proxies.form.host")}</Label>
<Input
id="proxy-host"
value={form.host}
onChange={(e) => {
setForm({ ...form, host: e.target.value });
}}
placeholder={t("proxies.form.hostPlaceholder")}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{t("proxies.form.password")}
</Label>
<Input
id="proxy-password"
type="password"
value={form.password}
onChange={(e) => {
setForm({ ...form, password: e.target.value });
}}
placeholder={t("proxies.form.passwordPlaceholder")}
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-port">{t("proxies.form.port")}</Label>
<Input
id="proxy-port"
type="number"
value={form.port}
onChange={(e) => {
setForm({
...form,
port: Number.parseInt(e.target.value, 10) || 0,
});
}}
placeholder={t("proxies.form.portPlaceholder")}
min="1"
max="65535"
disabled={isSubmitting}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 @sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="proxy-username">
{form.proxy_type === "ss"
? t("proxies.form.cipher")
: t("proxies.form.username")}
</Label>
<Input
id="proxy-username"
value={form.username}
onChange={(e) => {
setForm({ ...form, username: e.target.value });
}}
placeholder={
form.proxy_type === "ss"
? t("proxies.form.cipherPlaceholder")
: t("proxies.form.usernamePlaceholder")
}
disabled={isSubmitting}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="proxy-password">
{t("proxies.form.password")}
</Label>
<Input
id="proxy-password"
type="password"
value={form.password}
onChange={(e) => {
setForm({ ...form, password: e.target.value });
}}
placeholder={t("proxies.form.passwordPlaceholder")}
disabled={isSubmitting}
/>
</div>
</div>
</>
)}
</div>
<DialogFooter>
+5 -5
View File
@@ -420,7 +420,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
key={`${proxy.original_line}-${i}`}
className="rounded bg-muted/30 p-2 font-mono text-xs break-all"
>
<span className="text-primary">
<span className="text-primary-text">
{proxy.proxy_type}://
</span>
{proxy.username && (
@@ -487,7 +487,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<span className="text-sm">
{t("proxies.importDialog.imported")}
</span>
<span className="text-sm font-medium text-success">
<span className="text-sm font-medium text-success-text">
{importResult.imported_count}
</span>
</div>
@@ -496,7 +496,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<span className="text-sm">
{t("proxies.importDialog.skippedDuplicates")}
</span>
<span className="text-sm font-medium text-warning">
<span className="text-sm font-medium text-warning-text">
{importResult.skipped_count}
</span>
</div>
@@ -506,7 +506,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
<span className="text-sm">
{t("proxies.importDialog.errors")}
</span>
<span className="text-sm font-medium text-destructive">
<span className="text-sm font-medium text-destructive-text">
{importResult.errors.length}
</span>
</div>
@@ -521,7 +521,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
{importResult.errors.map((error, i) => (
<div
key={`error-${i}`}
className="text-xs text-destructive"
className="text-xs text-destructive-text"
>
{error}
</div>
+4 -4
View File
@@ -1111,13 +1111,13 @@ export function ProxyManagementDialog({
<AnimatedTabsList>
<AnimatedTabsTrigger value="proxies">
<span>{t("proxies.management.tabProxies")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-xs tabular-nums">
{storedProxies.length}
</span>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="vpns">
<span>{t("proxies.management.tabVpns")}</span>
<span className="text-xs text-muted-foreground tabular-nums">
<span className="text-xs tabular-nums">
{vpnConfigs.length}
</span>
</AnimatedTabsTrigger>
@@ -1531,7 +1531,7 @@ export function ProxyManagementDialog({
}}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
>
<LuTrash2 />
</DataTableActionBarAction>
@@ -1554,7 +1554,7 @@ export function ProxyManagementDialog({
}}
size="icon"
variant="destructive"
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
className="border-destructive bg-destructive hover:bg-destructive"
>
<LuTrash2 />
</DataTableActionBarAction>
+8 -8
View File
@@ -319,8 +319,8 @@ export function RailNav({
className={cn(
"relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{active && <ActiveIndicator />}
@@ -347,8 +347,8 @@ export function RailNav({
className={cn(
"grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
moreOpen
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
<GoKebabHorizontal className="size-3.5" />
@@ -369,8 +369,8 @@ export function RailNav({
className={cn(
"relative grid size-7 shrink-0 cursor-pointer place-items-center rounded-md transition-colors duration-100",
currentPage === "settings"
? "bg-accent text-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-card-foreground",
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
>
{currentPage === "settings" && <ActiveIndicator />}
@@ -404,7 +404,7 @@ export function RailNav({
setMoreOpen(false);
onNavigate(page);
}}
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"
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 rounded bg-muted text-muted-foreground">
<Icon className="size-3" />
@@ -426,7 +426,7 @@ export function RailNav({
setMoreOpen(false);
onOpenAbout();
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent"
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 rounded bg-muted text-muted-foreground">
<LuInfo className="size-3" />
+22 -33
View File
@@ -51,8 +51,11 @@ import { useLanguage } from "@/hooks/use-language";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import {
applyThemeColors,
clearThemeColors,
getThemeByColors,
getThemeById,
normalizeThemeColors,
THEME_VARIABLES,
THEMES,
withThemeTransition,
@@ -238,19 +241,13 @@ export function SettingsDialog({
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
withThemeTransition(() => {
const root = document.documentElement;
Object.entries(vars).forEach(([k, v]) => {
root.style.setProperty(k, v, "important");
});
applyThemeColors(vars);
});
}, []);
const clearCustomTheme = useCallback(() => {
withThemeTransition(() => {
const root = document.documentElement;
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
clearThemeColors();
});
}, []);
@@ -267,7 +264,7 @@ export function SettingsDialog({
custom_theme:
appSettings.custom_theme &&
Object.keys(appSettings.custom_theme).length > 0
? appSettings.custom_theme
? normalizeThemeColors(appSettings.custom_theme)
: tokyoNightTheme.colors,
};
setSettings(merged);
@@ -490,24 +487,15 @@ export function SettingsDialog({
if (settings.theme === "custom") {
if (Object.keys(customThemeState.colors).length > 0) {
try {
const root = document.documentElement;
// Clear any previous custom vars first
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
Object.entries(customThemeState.colors).forEach(([k, v]) => {
root.style.setProperty(k, v, "important");
});
clearThemeColors();
applyThemeColors(customThemeState.colors);
} catch {
/* empty */
}
}
} else {
try {
const root = document.documentElement;
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
clearThemeColors();
} catch {
/* empty */
}
@@ -569,6 +557,7 @@ export function SettingsDialog({
applyCustomTheme(originalSettings.custom_theme);
} else {
clearCustomTheme();
setTheme(originalSettings.theme);
}
// Reset custom theme state to original
@@ -587,6 +576,7 @@ export function SettingsDialog({
applyCustomTheme,
clearCustomTheme,
onClose,
setTheme,
]);
// Only clear custom theme when switching away from custom, don't apply live
@@ -596,8 +586,9 @@ export function SettingsDialog({
useEffect(() => {
if (hasLoadedSettings && settings.theme !== "custom") {
clearCustomTheme();
setTheme(settings.theme);
}
}, [hasLoadedSettings, settings.theme, clearCustomTheme]);
}, [hasLoadedSettings, settings.theme, clearCustomTheme, setTheme]);
// Unmount-safe theme restore: rail navigation unmounts this dialog without
// calling handleClose, which used to leave the theme vars wiped for the
@@ -606,21 +597,17 @@ export function SettingsDialog({
return () => {
const s = originalSettingsRef.current;
if (!hasLoadedSettingsRef.current || !s) return;
const root = document.documentElement;
if (s.theme === "custom" && s.custom_theme) {
Object.entries(s.custom_theme).forEach(([k, v]) => {
root.style.setProperty(k, v, "important");
});
applyThemeColors(s.custom_theme);
} else {
// A non-custom persisted theme (light/dark/system) uses the
// stylesheet palette — strip any leftover inline custom vars so a
// just-saved switch away from custom isn't reverted on unmount.
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
clearThemeColors();
setTheme(s.theme);
}
};
}, []);
}, [setTheme]);
useEffect(() => {
if (isOpen) {
@@ -1177,7 +1164,9 @@ export function SettingsDialog({
}}
/>
{e2eError && (
<p className="text-sm text-destructive">{e2eError}</p>
<p className="text-sm text-destructive-text">
{e2eError}
</p>
)}
<LoadingButton
variant="default"
@@ -1249,7 +1238,7 @@ export function SettingsDialog({
// customer reads like a billing error, so swap in a
// subscription-active badge instead.
<div className="space-y-1">
<p className="text-sm font-medium text-success">
<p className="text-sm font-medium text-success-text">
{t("settings.commercial.subscriptionActive", {
plan: cloudUser.plan,
})}
@@ -1272,7 +1261,7 @@ export function SettingsDialog({
</div>
) : (
<div className="space-y-1">
<p className="text-sm font-medium text-warning">
<p className="text-sm font-medium text-warning-text">
{t("settings.commercial.trialExpired")}
</p>
<p className="text-xs text-muted-foreground">
+1 -1
View File
@@ -168,7 +168,7 @@ export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
}}
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card/50 p-3 transition-colors hover:bg-card"
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary-text">
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1 truncate text-sm font-medium">
+1 -1
View File
@@ -412,7 +412,7 @@ export function SyncConfigDialog({
onClick={() => {
setShowToken(!showToken);
}}
className="absolute top-1/2 right-3 -translate-y-1/2 transform rounded-sm p-1 transition-colors hover:bg-accent"
className="absolute top-1/2 right-3 -translate-y-1/2 transform rounded-sm p-1 transition-colors hover:bg-accent hover:text-accent-foreground"
aria-label={
showToken
? t("common.aria.hideToken")
+2 -2
View File
@@ -155,7 +155,7 @@ export function SyncFollowerDialog({
return (
<div
key={profile.id}
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-accent"
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-accent hover:text-accent-foreground"
onClick={() => {
handleToggle(
profile.id,
@@ -182,7 +182,7 @@ export function SyncFollowerDialog({
<TooltipTrigger asChild>
<Badge
variant="outline"
className="shrink-0 border-warning/50 px-1.5 py-0 text-[10px] text-warning"
className="shrink-0 border-warning/50 px-1.5 py-0 text-[10px] text-warning-text"
>
{t("profiles.synchronizer.flakyBadge")}
</Badge>
+3 -4
View File
@@ -59,9 +59,7 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const setTheme = useCallback((newTheme: string) => {
setThemeState(newTheme);
withThemeTransition(() => {
if (newTheme === "custom") {
applyClassToHtml("dark");
} else {
if (newTheme !== "custom") {
applyClassToHtml(newTheme);
}
});
@@ -77,7 +75,6 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
if (themeValue === "custom") {
setThemeState("custom");
applyClassToHtml("dark");
if (
settings.custom_theme &&
Object.keys(settings.custom_theme).length > 0
@@ -87,6 +84,8 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
} catch (error) {
console.warn("Failed to apply custom theme variables:", error);
}
} else {
applyClassToHtml("dark");
}
} else if (
themeValue === "light" ||
+1 -1
View File
@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
"bg-card text-destructive-text *:data-[slot=alert-description]:text-destructive-text [&>svg]:text-current",
},
},
defaultVariants: {
+3 -1
View File
@@ -122,7 +122,8 @@ function AnimatedTabsTrigger({
className={cn(
"relative inline-flex h-7 cursor-pointer items-center justify-center gap-1.5 rounded-md px-3 text-sm font-medium whitespace-nowrap transition-colors duration-150",
"text-muted-foreground hover:text-foreground",
isActive && "text-foreground",
isActive && !showIndicator && "text-foreground",
showIndicator && "text-accent-foreground hover:text-accent-foreground",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
"disabled:pointer-events-none disabled:opacity-50",
className,
@@ -132,6 +133,7 @@ function AnimatedTabsTrigger({
{showIndicator && (
<motion.span
layoutId={`animated-tabs-indicator-${indicatorId}`}
data-slot="animated-tabs-indicator"
className="pointer-events-none absolute inset-0 -z-10 rounded-md bg-accent"
transition={{ type: "spring", stiffness: 360, damping: 32 }}
/>
+3 -3
View File
@@ -10,11 +10,11 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
"border-transparent bg-primary text-primary-foreground [a&]:hover:shadow-sm",
secondary:
"border-transparent bg-secondary text-secondary-foreground dark:bg-secondary/60 [a&]:hover:bg-secondary/90",
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:shadow-sm",
destructive:
"border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
"border-transparent bg-destructive text-destructive-foreground focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 [a&]:hover:shadow-sm",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
+6 -8
View File
@@ -9,17 +9,15 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
default: "bg-primary text-primary-foreground shadow-xs hover:shadow-md",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
"bg-destructive text-destructive-foreground shadow-xs hover:shadow-md focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
"bg-secondary text-secondary-foreground shadow-xs hover:shadow-md",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary-text underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
+1 -1
View File
@@ -153,7 +153,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[selected=true]:[&_svg:not([class*='text-'])]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className,
)}
{...props}
+1 -1
View File
@@ -270,7 +270,7 @@ function DialogContent({
>
{children}
{!hideClose && dismissible && (
<DialogPrimitive.Close className="absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<DialogPrimitive.Close className="absolute top-4 right-4 cursor-pointer rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">{t("common.buttons.close")}</span>
</DialogPrimitive.Close>
+1 -1
View File
@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
"relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 data-[variant=destructive]:text-destructive-text data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive-text focus:[&_svg:not([class*='text-'])]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive-text!",
className,
)}
{...props}
+1 -1
View File
@@ -28,7 +28,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square size-4 cursor-pointer rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"aspect-square size-4 cursor-pointer rounded-full border border-primary-text text-primary-text ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+5 -7
View File
@@ -11,15 +11,13 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary text-primary-foreground hover:shadow-sm",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
"bg-destructive text-destructive-foreground hover:shadow-sm focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30",
secondary: "bg-secondary text-secondary-foreground hover:shadow-sm",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2 has-[>svg]:px-3",
+1 -1
View File
@@ -107,7 +107,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 focus:[&_svg:not([class*='text-'])]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
+4 -4
View File
@@ -109,9 +109,9 @@ export function VpnCheckButton({
{isCurrentlyChecking ? (
<span className="size-3 animate-spin rounded-full border border-current border-t-transparent" />
) : result?.is_valid ? (
<FiCheck className="size-3 text-success" />
<FiCheck className="size-3 text-success-text" />
) : result && !result.is_valid ? (
<span className="text-sm text-destructive"></span>
<span className="text-sm text-destructive-text"></span>
) : (
<FiCheck className="size-3" />
)}
@@ -125,7 +125,7 @@ export function VpnCheckButton({
) : result?.is_valid ? (
<div className="space-y-1">
<p>{t("vpnCheck.tooltipValid")}</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("vpnCheck.tooltipChecked", {
time: formatRelativeTime(result.timestamp),
})}
@@ -134,7 +134,7 @@ export function VpnCheckButton({
) : result && !result.is_valid ? (
<div>
<p>{t("vpnCheck.tooltipInvalid")}</p>
<p className="text-xs text-primary-foreground/70">
<p className="text-xs text-primary-foreground">
{t("vpnCheck.tooltipChecked", {
time: formatRelativeTime(result.timestamp),
})}
+5 -5
View File
@@ -282,7 +282,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
{step === "vpn-preview" && vpnPreview && (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg bg-muted/30 p-4">
<LuShield className="size-8 text-primary" />
<LuShield className="size-8 text-primary-text" />
<div>
<div className="font-medium">
{t("vpns.import.configurationLabel", {
@@ -332,9 +332,9 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
>
{vpnImportResult.success ? (
<div className="flex items-center gap-3">
<LuShield className="size-8 text-success" />
<LuShield className="size-8 text-success-text" />
<div>
<div className="font-medium text-success">
<div className="font-medium text-success-text">
{t("vpns.import.importedSuccess")}
</div>
<div className="text-sm text-muted-foreground">
@@ -344,10 +344,10 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
</div>
) : (
<div className="space-y-2">
<div className="font-medium text-destructive">
<div className="font-medium text-destructive-text">
{t("vpns.import.importFailed")}
</div>
<div className="text-sm text-destructive">
<div className="text-sm text-destructive-text">
{vpnImportResult.error}
</div>
</div>
+1 -1
View File
@@ -70,7 +70,7 @@ export function WayfernTermsDialog({
href="https://wayfern.com/tos"
target="_blank"
rel="noopener noreferrer"
className="block text-sm font-medium text-primary hover:underline"
className="block text-sm font-medium text-primary-text hover:underline"
>
https://wayfern.com/tos
</a>
+4 -4
View File
@@ -316,7 +316,7 @@ export function WelcomeDialog({
<dl className="flex flex-col gap-3">
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuHeart className="mt-0.5 size-4 shrink-0 text-success" />
<LuHeart className="mt-0.5 size-4 shrink-0 text-success-text" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.personalTitle")}
@@ -331,7 +331,7 @@ export function WelcomeDialog({
<div className="flex flex-col gap-0.5 text-left">
<dt className="flex flex-wrap items-center gap-2 text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.commercialTitle")}
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary-text">
{t("welcome.license.trialBadge")}
</span>
</dt>
@@ -389,7 +389,7 @@ export function WelcomeDialog({
initial={{ opacity: 0, scale: 0.85, rotate: -8 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={panelTransition}
className="flex size-12 items-center justify-center gap-1.5 rounded-full bg-primary/10 text-primary"
className="flex size-12 items-center justify-center gap-1.5 rounded-full bg-primary/10 text-primary-text"
aria-hidden="true"
>
<LuMic className="size-4 shrink-0" />
@@ -445,7 +445,7 @@ export function WelcomeDialog({
{setup.phase === "error" ? (
<>
<div className="flex flex-col items-center gap-2">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance text-destructive">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance text-destructive-text">
<LuTriangleAlert className="size-5 shrink-0" />
{t("welcome.ready.errorTitle")}
</h2>
+1 -1
View File
@@ -167,7 +167,7 @@ export function WindowDragArea() {
onClick={() => {
void handleClose();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive/90 hover:text-destructive-foreground"
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t("common.buttons.close")}
>
<svg
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
{
"sourceUrl": "https://github.com/XTLS/Xray-core/tree/v26.3.27"
}
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Host and port are required",
"ssCipherRequired": "Cipher and password are required for Shadowsocks",
"selectType": "Select proxy type",
"saveFailed": "Failed to save proxy: {{error}}"
"saveFailed": "Failed to save proxy: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "VLESS URI",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Requires XTLS Vision and REALITY.",
"vlessUriRequired": "Enter a VLESS URI.",
"vlessUriInvalid": "Enter a valid VLESS URI with a host and port."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "The MCP server configuration is unavailable. Restart the server and try again.",
"mcpAgentUnknown": "This MCP client is not supported.",
"mcpAgentInstallFailed": "Could not add Donut Browser to the MCP client: {{detail}}",
"mcpAgentRemoveFailed": "Could not remove Donut Browser from the MCP client: {{detail}}"
"mcpAgentRemoveFailed": "Could not remove Donut Browser from the MCP client: {{detail}}",
"vlessConfigInvalid": "The VLESS URI is invalid.",
"xrayUnavailable": "Xray-core is unavailable on this system.",
"xrayUnsupportedOs": "VLESS requires macOS 12 or newer.",
"xrayStartFailed": "Xray-core could not start."
},
"rail": {
"profiles": "Profiles",
@@ -2068,8 +2078,13 @@
"title": "About",
"version": "Version {{version}}",
"portableBadge": "portable",
"licenseNotice": "Open-source anti-detect browser, licensed under AGPL-3.0.",
"website": "Website"
"licenseNotice": "Open-source anti-detect browser.",
"website": "Website",
"github": "GitHub",
"licenses": "Licenses",
"searchLicenses": "Search licenses…",
"noMatchingLicenses": "No matching licenses.",
"openSource": "View {{name}} source code"
},
"clearOnClose": {
"label": "Clear data on close",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Host y puerto son obligatorios",
"ssCipherRequired": "Para Shadowsocks se requieren cifrado y contraseña",
"selectType": "Selecciona el tipo de proxy",
"saveFailed": "Error al guardar el proxy: {{error}}"
"saveFailed": "Error al guardar el proxy: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "URI de VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Requiere XTLS Vision y REALITY.",
"vlessUriRequired": "Introduce un URI de VLESS.",
"vlessUriInvalid": "Introduce un URI de VLESS válido con host y puerto."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "La configuración del servidor MCP no está disponible. Reinicia el servidor e inténtalo de nuevo.",
"mcpAgentUnknown": "Este cliente MCP no es compatible.",
"mcpAgentInstallFailed": "No se pudo añadir Donut Browser al cliente MCP: {{detail}}",
"mcpAgentRemoveFailed": "No se pudo eliminar Donut Browser del cliente MCP: {{detail}}"
"mcpAgentRemoveFailed": "No se pudo eliminar Donut Browser del cliente MCP: {{detail}}",
"vlessConfigInvalid": "El URI de VLESS no es válido.",
"xrayUnavailable": "Xray-core no está disponible en este sistema.",
"xrayUnsupportedOs": "VLESS requiere macOS 12 o posterior.",
"xrayStartFailed": "No se pudo iniciar Xray-core."
},
"rail": {
"profiles": "Perfiles",
@@ -2068,8 +2078,13 @@
"title": "Acerca de",
"version": "Versión {{version}}",
"portableBadge": "portable",
"licenseNotice": "Navegador anti-detección de código abierto, con licencia AGPL-3.0.",
"website": "Sitio web"
"licenseNotice": "Navegador anti-detección de código abierto.",
"website": "Sitio web",
"github": "GitHub",
"licenses": "Licencias",
"searchLicenses": "Buscar licencias…",
"noMatchingLicenses": "No hay licencias que coincidan.",
"openSource": "Ver el código fuente de {{name}}"
},
"clearOnClose": {
"label": "Borrar datos al cerrar",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Hôte et port sont requis",
"ssCipherRequired": "Le chiffrement et le mot de passe sont requis pour Shadowsocks",
"selectType": "Sélectionnez le type de proxy",
"saveFailed": "Échec de la sauvegarde du proxy : {{error}}"
"saveFailed": "Échec de la sauvegarde du proxy : {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "URI VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Nécessite XTLS Vision et REALITY.",
"vlessUriRequired": "Saisissez une URI VLESS.",
"vlessUriInvalid": "Saisissez une URI VLESS valide avec un hôte et un port."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "La configuration du serveur MCP est indisponible. Redémarrez le serveur et réessayez.",
"mcpAgentUnknown": "Ce client MCP nest pas pris en charge.",
"mcpAgentInstallFailed": "Impossible dajouter Donut Browser au client MCP : {{detail}}",
"mcpAgentRemoveFailed": "Impossible de retirer Donut Browser du client MCP : {{detail}}"
"mcpAgentRemoveFailed": "Impossible de retirer Donut Browser du client MCP : {{detail}}",
"vlessConfigInvalid": "LURI VLESS nest pas valide.",
"xrayUnavailable": "Xray-core nest pas disponible sur ce système.",
"xrayUnsupportedOs": "VLESS nécessite macOS 12 ou une version ultérieure.",
"xrayStartFailed": "Impossible de démarrer Xray-core."
},
"rail": {
"profiles": "Profils",
@@ -2068,8 +2078,13 @@
"title": "À propos",
"version": "Version {{version}}",
"portableBadge": "portable",
"licenseNotice": "Navigateur anti-détection open source, sous licence AGPL-3.0.",
"website": "Site web"
"licenseNotice": "Navigateur anti-détection open source.",
"website": "Site web",
"github": "GitHub",
"licenses": "Licences",
"searchLicenses": "Rechercher des licences…",
"noMatchingLicenses": "Aucune licence correspondante.",
"openSource": "Voir le code source de {{name}}"
},
"clearOnClose": {
"label": "Effacer les données à la fermeture",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "ホストとポートが必要です",
"ssCipherRequired": "Shadowsocks には暗号とパスワードが必要です",
"selectType": "プロキシの種類を選択",
"saveFailed": "プロキシの保存に失敗しました: {{error}}"
"saveFailed": "プロキシの保存に失敗しました: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "VLESS URI",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "XTLS Vision と REALITY が必要です。",
"vlessUriRequired": "VLESS URI を入力してください。",
"vlessUriInvalid": "ホストとポートを含む有効な VLESS URI を入力してください。"
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "MCPサーバーの設定を取得できません。サーバーを再起動して、もう一度お試しください。",
"mcpAgentUnknown": "このMCPクライアントはサポートされていません。",
"mcpAgentInstallFailed": "MCPクライアントにDonut Browserを追加できませんでした:{{detail}}",
"mcpAgentRemoveFailed": "MCPクライアントからDonut Browserを削除できませんでした:{{detail}}"
"mcpAgentRemoveFailed": "MCPクライアントからDonut Browserを削除できませんでした:{{detail}}",
"vlessConfigInvalid": "VLESS URI が無効です。",
"xrayUnavailable": "このシステムでは Xray-core を利用できません。",
"xrayUnsupportedOs": "VLESS には macOS 12 以降が必要です。",
"xrayStartFailed": "Xray-core を起動できませんでした。"
},
"rail": {
"profiles": "プロファイル",
@@ -2068,8 +2078,13 @@
"title": "このアプリについて",
"version": "バージョン {{version}}",
"portableBadge": "ポータブル",
"licenseNotice": "AGPL-3.0 ライセンスのオープンソースアンチディテクトブラウザです。",
"website": "ウェブサイト"
"licenseNotice": "オープンソースアンチディテクトブラウザです。",
"website": "ウェブサイト",
"github": "GitHub",
"licenses": "ライセンス",
"searchLicenses": "ライセンスを検索…",
"noMatchingLicenses": "一致するライセンスはありません。",
"openSource": "{{name}} のソースコードを表示"
},
"clearOnClose": {
"label": "終了時にデータを消去",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "호스트와 포트는 필수입니다",
"ssCipherRequired": "Shadowsocks에는 암호화와 비밀번호가 필요합니다",
"selectType": "프록시 유형 선택",
"saveFailed": "프록시 저장 실패: {{error}}"
"saveFailed": "프록시 저장 실패: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "VLESS URI",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "XTLS Vision 및 REALITY가 필요합니다.",
"vlessUriRequired": "VLESS URI를 입력하세요.",
"vlessUriInvalid": "호스트와 포트가 포함된 올바른 VLESS URI를 입력하세요."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "MCP 서버 구성을 사용할 수 없습니다. 서버를 다시 시작한 후 다시 시도하세요.",
"mcpAgentUnknown": "이 MCP 클라이언트는 지원되지 않습니다.",
"mcpAgentInstallFailed": "MCP 클라이언트에 Donut Browser를 추가하지 못했습니다: {{detail}}",
"mcpAgentRemoveFailed": "MCP 클라이언트에서 Donut Browser를 제거하지 못했습니다: {{detail}}"
"mcpAgentRemoveFailed": "MCP 클라이언트에서 Donut Browser를 제거하지 못했습니다: {{detail}}",
"vlessConfigInvalid": "VLESS URI가 올바르지 않습니다.",
"xrayUnavailable": "이 시스템에서는 Xray-core를 사용할 수 없습니다.",
"xrayUnsupportedOs": "VLESS를 사용하려면 macOS 12 이상이 필요합니다.",
"xrayStartFailed": "Xray-core를 시작할 수 없습니다."
},
"rail": {
"profiles": "프로필",
@@ -2068,8 +2078,13 @@
"title": "정보",
"version": "버전 {{version}}",
"portableBadge": "포터블",
"licenseNotice": "AGPL-3.0 라이선스의 오픈 소스 안티 디텍트 브라우저입니다.",
"website": "웹사이트"
"licenseNotice": "오픈 소스 안티 디텍트 브라우저입니다.",
"website": "웹사이트",
"github": "GitHub",
"licenses": "라이선스",
"searchLicenses": "라이선스 검색…",
"noMatchingLicenses": "일치하는 라이선스가 없습니다.",
"openSource": "{{name}} 소스 코드 보기"
},
"clearOnClose": {
"label": "닫을 때 데이터 지우기",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Host e porta são obrigatórios",
"ssCipherRequired": "Cifra e senha são obrigatórias para Shadowsocks",
"selectType": "Selecione o tipo de proxy",
"saveFailed": "Falha ao salvar o proxy: {{error}}"
"saveFailed": "Falha ao salvar o proxy: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "URI VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Requer XTLS Vision e REALITY.",
"vlessUriRequired": "Insira um URI VLESS.",
"vlessUriInvalid": "Insira um URI VLESS válido com host e porta."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "A configuração do servidor MCP não está disponível. Reinicie o servidor e tente novamente.",
"mcpAgentUnknown": "Este cliente MCP não é compatível.",
"mcpAgentInstallFailed": "Não foi possível adicionar o Donut Browser ao cliente MCP: {{detail}}",
"mcpAgentRemoveFailed": "Não foi possível remover o Donut Browser do cliente MCP: {{detail}}"
"mcpAgentRemoveFailed": "Não foi possível remover o Donut Browser do cliente MCP: {{detail}}",
"vlessConfigInvalid": "O URI VLESS é inválido.",
"xrayUnavailable": "O Xray-core não está disponível neste sistema.",
"xrayUnsupportedOs": "O VLESS requer macOS 12 ou posterior.",
"xrayStartFailed": "Não foi possível iniciar o Xray-core."
},
"rail": {
"profiles": "Perfis",
@@ -2068,8 +2078,13 @@
"title": "Sobre",
"version": "Versão {{version}}",
"portableBadge": "portátil",
"licenseNotice": "Navegador anti-detecção de código aberto, licenciado sob AGPL-3.0.",
"website": "Site"
"licenseNotice": "Navegador anti-detecção de código aberto.",
"website": "Site",
"github": "GitHub",
"licenses": "Licenças",
"searchLicenses": "Pesquisar licenças…",
"noMatchingLicenses": "Nenhuma licença encontrada.",
"openSource": "Ver o código-fonte de {{name}}"
},
"clearOnClose": {
"label": "Limpar dados ao fechar",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Требуются хост и порт",
"ssCipherRequired": "Для Shadowsocks требуется шифр и пароль",
"selectType": "Выберите тип прокси",
"saveFailed": "Не удалось сохранить прокси: {{error}}"
"saveFailed": "Не удалось сохранить прокси: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "URI VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Требуются XTLS Vision и REALITY.",
"vlessUriRequired": "Введите URI VLESS.",
"vlessUriInvalid": "Введите корректный URI VLESS с адресом и портом."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "Конфигурация сервера MCP недоступна. Перезапустите сервер и повторите попытку.",
"mcpAgentUnknown": "Этот клиент MCP не поддерживается.",
"mcpAgentInstallFailed": "Не удалось добавить Donut Browser в клиент MCP: {{detail}}",
"mcpAgentRemoveFailed": "Не удалось удалить Donut Browser из клиента MCP: {{detail}}"
"mcpAgentRemoveFailed": "Не удалось удалить Donut Browser из клиента MCP: {{detail}}",
"vlessConfigInvalid": "URI VLESS недействителен.",
"xrayUnavailable": "Xray-core недоступен в этой системе.",
"xrayUnsupportedOs": "Для VLESS требуется macOS 12 или новее.",
"xrayStartFailed": "Не удалось запустить Xray-core."
},
"rail": {
"profiles": "Профили",
@@ -2068,8 +2078,13 @@
"title": "О приложении",
"version": "Версия {{version}}",
"portableBadge": "портативная",
"licenseNotice": "Антидетект-браузер с открытым исходным кодом, распространяется по лицензии AGPL-3.0.",
"website": "Веб-сайт"
"licenseNotice": "Антидетект-браузер с открытым исходным кодом.",
"website": "Веб-сайт",
"github": "GitHub",
"licenses": "Лицензии",
"searchLicenses": "Поиск лицензий…",
"noMatchingLicenses": "Совпадающих лицензий нет.",
"openSource": "Открыть исходный код {{name}}"
},
"clearOnClose": {
"label": "Очищать данные при закрытии",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Sunucu ve bağlantı noktası zorunludur",
"ssCipherRequired": "Shadowsocks için şifreleme algoritması ve parola zorunludur",
"selectType": "Proxy türünü seçin",
"saveFailed": "Proxy kaydedilemedi: {{error}}"
"saveFailed": "Proxy kaydedilemedi: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "VLESS URI'si",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "XTLS Vision ve REALITY gerektirir.",
"vlessUriRequired": "Bir VLESS URI'si girin.",
"vlessUriInvalid": "Sunucu ve bağlantı noktası içeren geçerli bir VLESS URI'si girin."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "MCP sunucusu yapılandırması kullanılamıyor. Sunucuyu yeniden başlatıp tekrar deneyin.",
"mcpAgentUnknown": "Bu MCP istemcisi desteklenmiyor.",
"mcpAgentInstallFailed": "Donut Browser MCP istemcisine eklenemedi: {{detail}}",
"mcpAgentRemoveFailed": "Donut Browser MCP istemcisinden kaldırılamadı: {{detail}}"
"mcpAgentRemoveFailed": "Donut Browser MCP istemcisinden kaldırılamadı: {{detail}}",
"vlessConfigInvalid": "VLESS URI'si geçersiz.",
"xrayUnavailable": "Xray-core bu sistemde kullanılamıyor.",
"xrayUnsupportedOs": "VLESS için macOS 12 veya sonraki bir sürüm gerekir.",
"xrayStartFailed": "Xray-core başlatılamadı."
},
"rail": {
"profiles": "Profiller",
@@ -2068,8 +2078,13 @@
"title": "Hakkında",
"version": "Sürüm {{version}}",
"portableBadge": "taşınabilir",
"licenseNotice": "AGPL-3.0 lisanslı, açık kaynak anti-detect tarayıcı.",
"website": "Web sitesi"
"licenseNotice": "Açık kaynaklı anti-detect tarayıcı.",
"website": "Web sitesi",
"github": "GitHub",
"licenses": "Lisanslar",
"searchLicenses": "Lisanslarda ara…",
"noMatchingLicenses": "Eşleşen lisans yok.",
"openSource": "{{name}} kaynak kodunu görüntüle"
},
"clearOnClose": {
"label": "Kapatırken verileri temizle",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "Host và cổng là bắt buộc",
"ssCipherRequired": "Cipher và mật khẩu là bắt buộc cho Shadowsocks",
"selectType": "Chọn loại proxy",
"saveFailed": "Lưu proxy thất bại: {{error}}"
"saveFailed": "Lưu proxy thất bại: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "URI VLESS",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "Yêu cầu XTLS Vision và REALITY.",
"vlessUriRequired": "Nhập URI VLESS.",
"vlessUriInvalid": "Nhập URI VLESS hợp lệ có máy chủ và cổng."
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "Cấu hình máy chủ MCP không khả dụng. Hãy khởi động lại máy chủ rồi thử lại.",
"mcpAgentUnknown": "Ứng dụng MCP này không được hỗ trợ.",
"mcpAgentInstallFailed": "Không thể thêm Donut Browser vào ứng dụng MCP: {{detail}}",
"mcpAgentRemoveFailed": "Không thể xóa Donut Browser khỏi ứng dụng MCP: {{detail}}"
"mcpAgentRemoveFailed": "Không thể xóa Donut Browser khỏi ứng dụng MCP: {{detail}}",
"vlessConfigInvalid": "URI VLESS không hợp lệ.",
"xrayUnavailable": "Xray-core không khả dụng trên hệ thống này.",
"xrayUnsupportedOs": "VLESS yêu cầu macOS 12 trở lên.",
"xrayStartFailed": "Không thể khởi động Xray-core."
},
"rail": {
"profiles": "Profile",
@@ -2068,8 +2078,13 @@
"title": "Giới thiệu",
"version": "Phiên bản {{version}}",
"portableBadge": "bản portable",
"licenseNotice": "Trình duyệt chống phát hiện mã nguồn mở, được cấp phép theo AGPL-3.0.",
"website": "Trang web"
"licenseNotice": "Trình duyệt chống phát hiện mã nguồn mở.",
"website": "Trang web",
"github": "GitHub",
"licenses": "Giấy phép",
"searchLicenses": "Tìm kiếm giấy phép…",
"noMatchingLicenses": "Không có giấy phép phù hợp.",
"openSource": "Xem mã nguồn của {{name}}"
},
"clearOnClose": {
"label": "Xóa dữ liệu khi đóng",
+19 -4
View File
@@ -459,7 +459,13 @@
"hostPortRequired": "主机和端口为必填项",
"ssCipherRequired": "Shadowsocks 需要密码学和密码",
"selectType": "选择代理类型",
"saveFailed": "保存代理失败: {{error}}"
"saveFailed": "保存代理失败: {{error}}",
"vlessType": "VLESS · Vision · REALITY",
"vlessUri": "VLESS URI",
"vlessUriPlaceholder": "vless://…",
"vlessUriHint": "需要 XTLS Vision 和 REALITY。",
"vlessUriRequired": "请输入 VLESS URI。",
"vlessUriInvalid": "请输入包含主机和端口的有效 VLESS URI。"
},
"types": {
"http": "HTTP",
@@ -1841,7 +1847,11 @@
"mcpConfigurationUnavailable": "MCP 服务器配置不可用。请重启服务器后重试。",
"mcpAgentUnknown": "不支持此 MCP 客户端。",
"mcpAgentInstallFailed": "无法将 Donut Browser 添加到 MCP 客户端:{{detail}}",
"mcpAgentRemoveFailed": "无法从 MCP 客户端移除 Donut Browser{{detail}}"
"mcpAgentRemoveFailed": "无法从 MCP 客户端移除 Donut Browser{{detail}}",
"vlessConfigInvalid": "VLESS URI 无效。",
"xrayUnavailable": "此系统无法使用 Xray-core。",
"xrayUnsupportedOs": "VLESS 需要 macOS 12 或更高版本。",
"xrayStartFailed": "无法启动 Xray-core。"
},
"rail": {
"profiles": "配置文件",
@@ -2068,8 +2078,13 @@
"title": "关于",
"version": "版本 {{version}}",
"portableBadge": "便携版",
"licenseNotice": "开源反检测浏览器,基于 AGPL-3.0 许可证发布。",
"website": "官网"
"licenseNotice": "开源反检测浏览器。",
"website": "官网",
"github": "GitHub",
"licenses": "许可证",
"searchLicenses": "搜索许可证…",
"noMatchingLicenses": "没有匹配的许可证。",
"openSource": "查看 {{name}} 源代码"
},
"clearOnClose": {
"label": "关闭时清除数据",
+12
View File
@@ -63,6 +63,10 @@ export type BackendErrorCode =
| "MCP_AGENT_UNKNOWN"
| "MCP_AGENT_INSTALL_FAILED"
| "MCP_AGENT_REMOVE_FAILED"
| "VLESS_CONFIG_INVALID"
| "XRAY_UNAVAILABLE"
| "XRAY_UNSUPPORTED_OS"
| "XRAY_START_FAILED"
| "INTERNAL_ERROR";
export interface BackendError {
@@ -242,6 +246,14 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.mcpAgentRemoveFailed", {
detail: parsed.params?.detail ?? "",
});
case "VLESS_CONFIG_INVALID":
return t("backendErrors.vlessConfigInvalid");
case "XRAY_UNAVAILABLE":
return t("backendErrors.xrayUnavailable");
case "XRAY_UNSUPPORTED_OS":
return t("backendErrors.xrayUnsupportedOs");
case "XRAY_START_FAILED":
return t("backendErrors.xrayStartFailed");
case "CLEAR_ON_CLOSE_UNAVAILABLE":
return t("backendErrors.clearOnCloseUnavailable");
case "INTERNAL_ERROR":
+163
View File
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import test from "node:test";
import Color from "color";
import {
getDerivedThemeColors,
getThemeByColors,
getThemeMode,
normalizeThemeColors,
THEMES,
} from "./themes.ts";
const AA_TEXT = 4.5;
const UI_BOUNDARY = 3;
const SEMANTIC_PAIRS = [
["background", "foreground"],
["card", "card-foreground"],
["popover", "popover-foreground"],
["primary", "primary-foreground"],
["secondary", "secondary-foreground"],
["muted", "muted-foreground"],
["accent", "accent-foreground"],
["destructive", "destructive-foreground"],
["success", "success-foreground"],
["warning", "warning-foreground"],
];
const CONTENT_SURFACES = ["background", "card", "popover", "muted"];
const CONTEXTUAL_ROLES = ["primary", "destructive", "success", "warning"];
const LIGHT_THEMES = new Set([
"ayu-light",
"catppuccin-latte",
"github-light",
"gruvbox-light",
"rose-pine-dawn",
"solarized-light",
]);
function contrast(foreground, background) {
return Color(foreground).contrast(Color(background));
}
function tintedSurface(surface, fill, opacity = 0.1) {
return Color(surface).mix(Color(fill), opacity);
}
function assertNoContrastFailures(failures) {
assert.deepEqual(failures, [], failures.join("\n"));
}
test("every theme declares the intended appearance mode", () => {
assert.equal(THEMES.length, 20);
assert.equal(new Set(THEMES.map((theme) => theme.id)).size, THEMES.length);
assert.deepEqual(
THEMES.filter((theme) => theme.mode === "light")
.map((theme) => theme.id)
.sort(),
[...LIGHT_THEMES].sort(),
);
for (const theme of THEMES) {
assert.equal(theme.mode, LIGHT_THEMES.has(theme.id) ? "light" : "dark");
assert.equal(getThemeMode(theme.colors), theme.mode);
}
});
test("every semantic fill and foreground pair meets WCAG AA", () => {
const failures = [];
for (const theme of THEMES) {
for (const [surface, content] of SEMANTIC_PAIRS) {
const ratio = contrast(
theme.colors[`--${content}`],
theme.colors[`--${surface}`],
);
if (ratio < AA_TEXT) {
failures.push(
`${theme.id}: ${surface}/${content} ${ratio.toFixed(2)}:1`,
);
}
}
}
assertNoContrastFailures(failures);
});
test("foreground and muted text remain readable on every content surface", () => {
const failures = [];
for (const theme of THEMES) {
for (const content of ["foreground", "muted-foreground"]) {
for (const surface of CONTENT_SURFACES) {
const ratio = contrast(
theme.colors[`--${content}`],
theme.colors[`--${surface}`],
);
if (ratio < AA_TEXT) {
failures.push(
`${theme.id}: ${content}/${surface} ${ratio.toFixed(2)}:1`,
);
}
}
}
}
assertNoContrastFailures(failures);
});
test("derived contextual text and control boundaries remain accessible", () => {
const failures = [];
for (const theme of THEMES) {
const derived = getDerivedThemeColors(theme.colors);
for (const role of CONTEXTUAL_ROLES) {
for (const surface of ["background", "card", "popover", "muted"]) {
const surfaceColor = theme.colors[`--${surface}`];
const contexts = [[surface, surfaceColor]];
if (surface !== "muted") {
contexts.push(
...[0.1, 0.2].map((opacity) => [
`${surface} with ${role} ${opacity * 100}% tint`,
tintedSurface(surfaceColor, theme.colors[`--${role}`], opacity),
]),
);
}
for (const [context, background] of contexts) {
const ratio = contrast(derived[`--${role}-text`], background);
if (ratio < AA_TEXT) {
failures.push(
`${theme.id}: ${role}-text/${context} ${ratio.toFixed(2)}:1`,
);
}
}
}
}
for (const boundary of ["--input", "--ring"]) {
for (const surface of ["background", "card", "popover", "muted"]) {
const ratio = contrast(derived[boundary], theme.colors[`--${surface}`]);
if (ratio < UI_BOUNDARY) {
failures.push(
`${theme.id}: ${boundary}/${surface} ${ratio.toFixed(2)}:1`,
);
}
}
}
}
assertNoContrastFailures(failures);
});
test("unmatched custom palettes derive their mode from the background", () => {
const darkCustom = { ...THEMES[0].colors, "--background": "#20242b" };
const lightCustom = { ...THEMES[0].colors, "--background": "#f4f1e8" };
assert.equal(getThemeMode(darkCustom), "dark");
assert.equal(getThemeMode(lightCustom), "light");
});
test("saved copies of the previous presets migrate without claiming edits", () => {
const houston = THEMES.find((theme) => theme.id === "houston");
const legacyHouston = {
...houston.colors,
"--secondary-foreground": "#f7f7f8",
"--accent-foreground": "#f7f7f8",
"--destructive-foreground": "#f7f7f8",
};
assert.equal(getThemeByColors(legacyHouston)?.id, "houston");
assert.deepEqual(normalizeThemeColors(legacyHouston), houston.colors);
const editedHouston = { ...legacyHouston, "--accent": "#123456" };
assert.equal(getThemeByColors(editedHouston), undefined);
assert.equal(normalizeThemeColors(editedHouston), editedHouston);
});
+486 -71
View File
@@ -30,6 +30,7 @@ export interface ThemeColors extends Record<string, string> {
export interface Theme {
id: string;
name: string;
mode: "light" | "dark";
colors: ThemeColors;
}
@@ -37,6 +38,7 @@ export const THEMES: Theme[] = [
{
id: "donut-mono",
name: "Donut Mono",
mode: "dark",
colors: {
"--background": "#070707",
"--foreground": "#ffffff",
@@ -69,6 +71,7 @@ export const THEMES: Theme[] = [
{
id: "tokyo-night",
name: "Tokyo Night",
mode: "dark",
colors: {
"--background": "#1a1b26",
"--foreground": "#c0caf5",
@@ -101,23 +104,24 @@ export const THEMES: Theme[] = [
{
id: "dracula",
name: "Dracula",
mode: "dark",
colors: {
"--background": "#282a36",
"--foreground": "#f8f8f2",
"--foreground": "#fcfcfa",
"--card": "#44475a",
"--card-foreground": "#f8f8f2",
"--card-foreground": "#fcfcfa",
"--popover": "#44475a",
"--popover-foreground": "#f8f8f2",
"--popover-foreground": "#fcfcfa",
"--primary": "#bd93f9",
"--primary-foreground": "#282a36",
"--secondary": "#8be9fd",
"--secondary-foreground": "#282a36",
"--muted": "#6272a4",
"--muted-foreground": "#f8f8f2",
"--muted-foreground": "#fcfcfa",
"--accent": "#ff79c6",
"--accent-foreground": "#282a36",
"--destructive": "#ff5555",
"--destructive-foreground": "#f8f8f2",
"--destructive-foreground": "#282a36",
"--success": "#50fa7b",
"--success-foreground": "#282a36",
"--warning": "#ffb86c",
@@ -133,6 +137,7 @@ export const THEMES: Theme[] = [
{
id: "matchalk",
name: "Matchalk",
mode: "dark",
colors: {
"--background": "#273136",
"--foreground": "#D1DED3",
@@ -145,7 +150,7 @@ export const THEMES: Theme[] = [
"--secondary": "#d2b48c",
"--secondary-foreground": "#273136",
"--muted": "#323e45",
"--muted-foreground": "#7ea4b0",
"--muted-foreground": "#8badb8",
"--accent": "#d2b48c",
"--accent-foreground": "#273136",
"--destructive": "#ff819f",
@@ -165,6 +170,7 @@ export const THEMES: Theme[] = [
{
id: "houston",
name: "Houston",
mode: "dark",
colors: {
"--background": "#17191e",
"--foreground": "#f7f7f8",
@@ -175,13 +181,13 @@ export const THEMES: Theme[] = [
"--primary": "#5755d9",
"--primary-foreground": "#f7f7f8",
"--secondary": "#f25f4c",
"--secondary-foreground": "#f7f7f8",
"--secondary-foreground": "#17191e",
"--muted": "#2a2e39",
"--muted-foreground": "#9ca3af",
"--accent": "#0ea5e9",
"--accent-foreground": "#f7f7f8",
"--accent-foreground": "#17191e",
"--destructive": "#ef4444",
"--destructive-foreground": "#f7f7f8",
"--destructive-foreground": "#17191e",
"--success": "#22c55e",
"--success-foreground": "#17191e",
"--warning": "#f59e0b",
@@ -197,6 +203,7 @@ export const THEMES: Theme[] = [
{
id: "ayu-dark",
name: "Ayu Dark",
mode: "dark",
colors: {
"--background": "#0a0e14",
"--foreground": "#b3b1ad",
@@ -209,11 +216,11 @@ export const THEMES: Theme[] = [
"--secondary": "#ffb454",
"--secondary-foreground": "#0a0e14",
"--muted": "#1f2430",
"--muted-foreground": "#5c6773",
"--muted-foreground": "#858d96",
"--accent": "#d2a6ff",
"--accent-foreground": "#0a0e14",
"--destructive": "#f07178",
"--destructive-foreground": "#b3b1ad",
"--destructive-foreground": "#0a0e14",
"--success": "#c2d94c",
"--success-foreground": "#0a0e14",
"--warning": "#ffb454",
@@ -229,6 +236,7 @@ export const THEMES: Theme[] = [
{
id: "ayu-light",
name: "Ayu Light",
mode: "light",
// Source: ayu-theme/ayu-colors light.yaml. Primary uses the iconic
// Ayu orange instead of blue — that's the colour the theme is known for.
colors: {
@@ -239,19 +247,19 @@ export const THEMES: Theme[] = [
"--popover": "#ffffff",
"--popover-foreground": "#5c6166",
"--primary": "#f29718",
"--primary-foreground": "#ffffff",
"--primary-foreground": "#0a0e14",
"--secondary": "#399ee6",
"--secondary-foreground": "#ffffff",
"--secondary-foreground": "#0a0e14",
"--muted": "#ebeef0",
"--muted-foreground": "#828e9f",
"--muted-foreground": "#626b78",
"--accent": "#a37acc",
"--accent-foreground": "#ffffff",
"--accent-foreground": "#0a0e14",
"--destructive": "#e65050",
"--destructive-foreground": "#ffffff",
"--destructive-foreground": "#0a0e14",
"--success": "#86b300",
"--success-foreground": "#ffffff",
"--success-foreground": "#0a0e14",
"--warning": "#fa8532",
"--warning-foreground": "#ffffff",
"--warning-foreground": "#0a0e14",
"--border": "#c8d0d6",
"--chart-1": "#f29718",
"--chart-2": "#86b300",
@@ -263,6 +271,7 @@ export const THEMES: Theme[] = [
{
id: "catppuccin-latte",
name: "Catppuccin Latte",
mode: "light",
// Source: github.com/catppuccin/palette/blob/main/palette.json
// Primary uses mauve (purple) — the colour Catppuccin is most known
// for — instead of blue, to differentiate from the many blue themes.
@@ -270,25 +279,25 @@ export const THEMES: Theme[] = [
// mid-points between Latte and Mocha and added little variety.
colors: {
"--background": "#eff1f5",
"--foreground": "#4c4f69",
"--foreground": "#484b64",
"--card": "#ccd0da",
"--card-foreground": "#4c4f69",
"--card-foreground": "#484b64",
"--popover": "#ccd0da",
"--popover-foreground": "#4c4f69",
"--popover-foreground": "#484b64",
"--primary": "#8839ef",
"--primary-foreground": "#eff1f5",
"--secondary": "#1e66f5",
"--secondary-foreground": "#eff1f5",
"--secondary-foreground": "#ffffff",
"--muted": "#bcc0cc",
"--muted-foreground": "#6c6f85",
"--muted-foreground": "#4b4d5c",
"--accent": "#ea76cb",
"--accent-foreground": "#eff1f5",
"--accent-foreground": "#353637",
"--destructive": "#d20f39",
"--destructive-foreground": "#eff1f5",
"--success": "#40a02b",
"--success-foreground": "#eff1f5",
"--success-foreground": "#242525",
"--warning": "#df8e1d",
"--warning-foreground": "#eff1f5",
"--warning-foreground": "#363637",
"--border": "#9ca0b0",
"--chart-1": "#8839ef",
"--chart-2": "#40a02b",
@@ -300,6 +309,7 @@ export const THEMES: Theme[] = [
{
id: "catppuccin-mocha",
name: "Catppuccin Mocha",
mode: "dark",
// Source: github.com/catppuccin/palette/blob/main/palette.json
// Primary uses mauve (purple) — Catppuccin's signature colour.
colors: {
@@ -314,7 +324,7 @@ export const THEMES: Theme[] = [
"--secondary": "#89b4fa",
"--secondary-foreground": "#1e1e2e",
"--muted": "#45475a",
"--muted-foreground": "#a6adc8",
"--muted-foreground": "#bac2de",
"--accent": "#f5c2e7",
"--accent-foreground": "#1e1e2e",
"--destructive": "#f38ba8",
@@ -334,6 +344,7 @@ export const THEMES: Theme[] = [
{
id: "nord",
name: "Nord",
mode: "dark",
// Source: nordtheme.com/docs/colors-and-palettes (Polar Night / Snow Storm / Frost / Aurora)
colors: {
"--background": "#2e3440",
@@ -349,9 +360,9 @@ export const THEMES: Theme[] = [
"--muted": "#434c5e",
"--muted-foreground": "#d8dee9",
"--accent": "#b48ead",
"--accent-foreground": "#2e3440",
"--accent-foreground": "#2b313c",
"--destructive": "#bf616a",
"--destructive-foreground": "#eceff4",
"--destructive-foreground": "#111112",
"--success": "#a3be8c",
"--success-foreground": "#2e3440",
"--warning": "#ebcb8b",
@@ -367,6 +378,7 @@ export const THEMES: Theme[] = [
{
id: "gruvbox-dark",
name: "Gruvbox Dark",
mode: "dark",
// Source: github.com/morhetz/gruvbox medium-contrast dark palette.
// Primary uses the iconic Gruvbox orange instead of blue.
colors: {
@@ -381,11 +393,11 @@ export const THEMES: Theme[] = [
"--secondary": "#83a598",
"--secondary-foreground": "#282828",
"--muted": "#504945",
"--muted-foreground": "#a89984",
"--muted-foreground": "#d5c4a1",
"--accent": "#d3869b",
"--accent-foreground": "#282828",
"--destructive": "#fb4934",
"--destructive-foreground": "#282828",
"--destructive-foreground": "#222222",
"--success": "#b8bb26",
"--success-foreground": "#282828",
"--warning": "#fabd2f",
@@ -401,6 +413,7 @@ export const THEMES: Theme[] = [
{
id: "gruvbox-light",
name: "Gruvbox Light",
mode: "light",
// Source: github.com/morhetz/gruvbox medium-contrast light palette.
// Primary uses the iconic Gruvbox orange instead of blue.
colors: {
@@ -415,15 +428,15 @@ export const THEMES: Theme[] = [
"--secondary": "#076678",
"--secondary-foreground": "#fbf1c7",
"--muted": "#d5c4a1",
"--muted-foreground": "#7c6f64",
"--muted-foreground": "#595048",
"--accent": "#8f3f71",
"--accent-foreground": "#fbf1c7",
"--destructive": "#9d0006",
"--destructive-foreground": "#fbf1c7",
"--success": "#79740e",
"--success-foreground": "#fbf1c7",
"--success-foreground": "#fdf9e6",
"--warning": "#b57614",
"--warning-foreground": "#fbf1c7",
"--warning-foreground": "#1b1a16",
"--border": "#a89984",
"--chart-1": "#af3a03",
"--chart-2": "#79740e",
@@ -435,24 +448,25 @@ export const THEMES: Theme[] = [
{
id: "solarized-dark",
name: "Solarized Dark",
mode: "dark",
// Source: ethanschoonover.com/solarized — base03 / base02 / base01 / base00 / base0 / base1
colors: {
"--background": "#002b36",
"--foreground": "#839496",
"--foreground": "#8d9d9f",
"--card": "#073642",
"--card-foreground": "#839496",
"--card-foreground": "#8d9d9f",
"--popover": "#073642",
"--popover-foreground": "#839496",
"--popover-foreground": "#8d9d9f",
"--primary": "#268bd2",
"--primary-foreground": "#002b36",
"--primary-foreground": "#002028",
"--secondary": "#2aa198",
"--secondary-foreground": "#002b36",
"--muted": "#073642",
"--muted-foreground": "#93a1a1",
"--accent": "#6c71c4",
"--accent-foreground": "#fdf6e3",
"--accent-foreground": "#070706",
"--destructive": "#dc322f",
"--destructive-foreground": "#fdf6e3",
"--destructive-foreground": "#fffefd",
"--success": "#859900",
"--success-foreground": "#002b36",
"--warning": "#b58900",
@@ -468,28 +482,29 @@ export const THEMES: Theme[] = [
{
id: "solarized-light",
name: "Solarized Light",
mode: "light",
// Source: ethanschoonover.com/solarized — same accents, inverted base scale
colors: {
"--background": "#fdf6e3",
"--foreground": "#657b83",
"--foreground": "#576a71",
"--card": "#eee8d5",
"--card-foreground": "#657b83",
"--card-foreground": "#576a71",
"--popover": "#eee8d5",
"--popover-foreground": "#657b83",
"--popover-foreground": "#576a71",
"--primary": "#268bd2",
"--primary-foreground": "#fdf6e3",
"--primary-foreground": "#1d1d1a",
"--secondary": "#2aa198",
"--secondary-foreground": "#fdf6e3",
"--secondary-foreground": "#2a2926",
"--muted": "#eee8d5",
"--muted-foreground": "#93a1a1",
"--muted-foreground": "#606969",
"--accent": "#6c71c4",
"--accent-foreground": "#fdf6e3",
"--accent-foreground": "#070706",
"--destructive": "#dc322f",
"--destructive-foreground": "#fdf6e3",
"--destructive-foreground": "#fffefd",
"--success": "#859900",
"--success-foreground": "#fdf6e3",
"--success-foreground": "#292825",
"--warning": "#b58900",
"--warning-foreground": "#fdf6e3",
"--warning-foreground": "#292825",
"--border": "#cdc7b3",
"--chart-1": "#268bd2",
"--chart-2": "#859900",
@@ -501,6 +516,7 @@ export const THEMES: Theme[] = [
{
id: "one-dark",
name: "One Dark",
mode: "dark",
// Source: github.com/atom/atom one-dark-syntax/styles/colors.less (mono-1, hue-1..6)
colors: {
"--background": "#282c34",
@@ -514,11 +530,11 @@ export const THEMES: Theme[] = [
"--secondary": "#56b6c2",
"--secondary-foreground": "#282c34",
"--muted": "#3e4451",
"--muted-foreground": "#7d8590",
"--muted-foreground": "#abb2bf",
"--accent": "#c678dd",
"--accent-foreground": "#282c34",
"--destructive": "#e06c75",
"--destructive-foreground": "#282c34",
"--destructive-foreground": "#252830",
"--success": "#98c379",
"--success-foreground": "#282c34",
"--warning": "#e5c07b",
@@ -534,6 +550,7 @@ export const THEMES: Theme[] = [
{
id: "monokai-pro",
name: "Monokai Pro",
mode: "dark",
// Source: classic Monokai filter (monokai-pro.nvim palette/classic.lua).
// Primary uses Monokai's signature green instead of cyan.
colors: {
@@ -548,11 +565,11 @@ export const THEMES: Theme[] = [
"--secondary": "#66d9ef",
"--secondary-foreground": "#272822",
"--muted": "#3b3c35",
"--muted-foreground": "#919288",
"--muted-foreground": "#a6a79f",
"--accent": "#ae81ff",
"--accent-foreground": "#272822",
"--destructive": "#f92672",
"--destructive-foreground": "#fdfff1",
"--destructive-foreground": "#1a1a19",
"--success": "#a6e22e",
"--success-foreground": "#272822",
"--warning": "#e6db74",
@@ -568,6 +585,7 @@ export const THEMES: Theme[] = [
{
id: "rose-pine",
name: "Rosé Pine",
mode: "dark",
// Source: github.com/rose-pine/palette/blob/main/palette.json.
// Primary uses iris (purple) — the iconic Rosé Pine accent — and
// success uses pine. Destructive stays love (pink), which is correct
@@ -590,7 +608,7 @@ export const THEMES: Theme[] = [
"--destructive": "#eb6f92",
"--destructive-foreground": "#191724",
"--success": "#31748f",
"--success-foreground": "#e0def4",
"--success-foreground": "#f1f0fa",
"--warning": "#f6c177",
"--warning-foreground": "#191724",
"--border": "#403d52",
@@ -604,6 +622,7 @@ export const THEMES: Theme[] = [
{
id: "rose-pine-dawn",
name: "Rosé Pine Dawn",
mode: "light",
// Source: github.com/rose-pine/palette/blob/main/palette.json (dawn variant).
// Primary uses iris (purple) for parity with the dark variant.
colors: {
@@ -614,19 +633,19 @@ export const THEMES: Theme[] = [
"--popover": "#fffaf3",
"--popover-foreground": "#575279",
"--primary": "#907aa9",
"--primary-foreground": "#faf4ed",
"--primary-foreground": "#1a1a19",
"--secondary": "#56949f",
"--secondary-foreground": "#faf4ed",
"--secondary-foreground": "#242322",
"--muted": "#f2e9e1",
"--muted-foreground": "#797593",
"--muted-foreground": "#696680",
"--accent": "#d7827e",
"--accent-foreground": "#faf4ed",
"--accent-foreground": "#32302f",
"--destructive": "#b4637a",
"--destructive-foreground": "#faf4ed",
"--destructive-foreground": "#0e0e0e",
"--success": "#286983",
"--success-foreground": "#faf4ed",
"--warning": "#ea9d34",
"--warning-foreground": "#faf4ed",
"--warning-foreground": "#42403e",
"--border": "#cecacd",
"--chart-1": "#907aa9",
"--chart-2": "#56949f",
@@ -638,6 +657,7 @@ export const THEMES: Theme[] = [
{
id: "github-dark",
name: "GitHub Dark",
mode: "dark",
// Source: github.com/primer/primitives base color tokens (dark default)
colors: {
"--background": "#0d1117",
@@ -647,17 +667,17 @@ export const THEMES: Theme[] = [
"--popover": "#151b23",
"--popover-foreground": "#f0f6fc",
"--primary": "#1f6feb",
"--primary-foreground": "#f0f6fc",
"--primary-foreground": "#fefeff",
"--secondary": "#58a6ff",
"--secondary-foreground": "#0d1117",
"--muted": "#212830",
"--muted-foreground": "#9198a1",
"--accent": "#8957e5",
"--accent-foreground": "#f0f6fc",
"--accent-foreground": "#ffffff",
"--destructive": "#da3633",
"--destructive-foreground": "#f0f6fc",
"--destructive-foreground": "#ffffff",
"--success": "#238636",
"--success-foreground": "#f0f6fc",
"--success-foreground": "#fefeff",
"--warning": "#d29922",
"--warning-foreground": "#0d1117",
"--border": "#3d444d",
@@ -671,6 +691,7 @@ export const THEMES: Theme[] = [
{
id: "github-light",
name: "GitHub Light",
mode: "light",
// Source: github.com/primer/primitives base color tokens (light default)
colors: {
"--background": "#ffffff",
@@ -682,7 +703,7 @@ export const THEMES: Theme[] = [
"--primary": "#0969da",
"--primary-foreground": "#ffffff",
"--secondary": "#54aeff",
"--secondary-foreground": "#ffffff",
"--secondary-foreground": "#25292e",
"--muted": "#eff2f5",
"--muted-foreground": "#59636e",
"--accent": "#8250df",
@@ -692,7 +713,7 @@ export const THEMES: Theme[] = [
"--success": "#1a7f37",
"--success-foreground": "#ffffff",
"--warning": "#bf8700",
"--warning-foreground": "#ffffff",
"--warning-foreground": "#25292e",
"--border": "#d1d9e0",
"--chart-1": "#0969da",
"--chart-2": "#1a7f37",
@@ -703,6 +724,112 @@ export const THEMES: Theme[] = [
},
];
const LEGACY_PRESET_OVERRIDES: Record<string, Partial<ThemeColors>> = {
dracula: {
"--foreground": "#f8f8f2",
"--card-foreground": "#f8f8f2",
"--popover-foreground": "#f8f8f2",
"--muted-foreground": "#f8f8f2",
"--destructive-foreground": "#f8f8f2",
},
matchalk: {
"--muted-foreground": "#7ea4b0",
},
houston: {
"--secondary-foreground": "#f7f7f8",
"--accent-foreground": "#f7f7f8",
"--destructive-foreground": "#f7f7f8",
},
"ayu-dark": {
"--muted-foreground": "#5c6773",
"--destructive-foreground": "#b3b1ad",
},
"ayu-light": {
"--primary-foreground": "#ffffff",
"--secondary-foreground": "#ffffff",
"--muted-foreground": "#828e9f",
"--accent-foreground": "#ffffff",
"--destructive-foreground": "#ffffff",
"--success-foreground": "#ffffff",
"--warning-foreground": "#ffffff",
},
"catppuccin-latte": {
"--foreground": "#4c4f69",
"--card-foreground": "#4c4f69",
"--popover-foreground": "#4c4f69",
"--secondary-foreground": "#eff1f5",
"--muted-foreground": "#6c6f85",
"--accent-foreground": "#eff1f5",
"--success-foreground": "#eff1f5",
"--warning-foreground": "#eff1f5",
},
"catppuccin-mocha": {
"--muted-foreground": "#a6adc8",
},
nord: {
"--accent-foreground": "#2e3440",
"--destructive-foreground": "#eceff4",
},
"gruvbox-dark": {
"--muted-foreground": "#a89984",
"--destructive-foreground": "#282828",
},
"gruvbox-light": {
"--muted-foreground": "#7c6f64",
"--success-foreground": "#fbf1c7",
"--warning-foreground": "#fbf1c7",
},
"solarized-dark": {
"--foreground": "#839496",
"--card-foreground": "#839496",
"--popover-foreground": "#839496",
"--primary-foreground": "#002b36",
"--accent-foreground": "#fdf6e3",
"--destructive-foreground": "#fdf6e3",
},
"solarized-light": {
"--foreground": "#657b83",
"--card-foreground": "#657b83",
"--popover-foreground": "#657b83",
"--primary-foreground": "#fdf6e3",
"--secondary-foreground": "#fdf6e3",
"--muted-foreground": "#93a1a1",
"--accent-foreground": "#fdf6e3",
"--destructive-foreground": "#fdf6e3",
"--success-foreground": "#fdf6e3",
"--warning-foreground": "#fdf6e3",
},
"one-dark": {
"--muted-foreground": "#7d8590",
"--destructive-foreground": "#282c34",
},
"monokai-pro": {
"--muted-foreground": "#919288",
"--destructive-foreground": "#fdfff1",
},
"rose-pine": {
"--success-foreground": "#e0def4",
},
"rose-pine-dawn": {
"--primary-foreground": "#faf4ed",
"--secondary-foreground": "#faf4ed",
"--muted-foreground": "#797593",
"--accent-foreground": "#faf4ed",
"--destructive-foreground": "#faf4ed",
"--warning-foreground": "#faf4ed",
},
"github-dark": {
"--primary-foreground": "#f0f6fc",
"--accent-foreground": "#f0f6fc",
"--destructive-foreground": "#f0f6fc",
"--success-foreground": "#f0f6fc",
},
"github-light": {
"--secondary-foreground": "#ffffff",
"--warning-foreground": "#ffffff",
},
};
export const THEME_VARIABLES: Array<{ key: keyof ThemeColors; label: string }> =
[
{ key: "--background", label: "Background" },
@@ -740,18 +867,303 @@ export function getThemeById(id: string): Theme | undefined {
export function getThemeByColors(
colors: Record<string, string>,
): Theme | undefined {
const matches = (expected: Record<string, string>) =>
THEME_VARIABLES.every(({ key }) => expected[key] === colors[key]);
const current = THEMES.find((theme) => matches(theme.colors));
if (current) return current;
return THEMES.find((theme) => {
return THEME_VARIABLES.every(({ key }) => {
return theme.colors[key] === colors[key];
});
const overrides = LEGACY_PRESET_OVERRIDES[theme.id];
if (!overrides) return false;
const legacyColors: Record<string, string> = { ...theme.colors };
for (const [key, value] of Object.entries(overrides)) {
if (value !== undefined) legacyColors[key] = value;
}
return matches(legacyColors);
});
}
export function normalizeThemeColors(
colors: Record<string, string>,
): Record<string, string> {
return getThemeByColors(colors)?.colors ?? colors;
}
interface RgbaColor {
r: number;
g: number;
b: number;
a: number;
}
export const DERIVED_THEME_VARIABLES = [
"--primary-text",
"--destructive-text",
"--success-text",
"--warning-text",
"--input",
"--ring",
"--sidebar",
"--sidebar-foreground",
"--sidebar-primary",
"--sidebar-primary-foreground",
"--sidebar-accent",
"--sidebar-accent-foreground",
"--sidebar-border",
"--sidebar-ring",
] as const;
function parseCssColor(value: string | undefined): RgbaColor | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
const hex = normalized.match(/^#([\da-f]{3,8})$/)?.[1];
if (hex) {
if (hex.length === 3 || hex.length === 4) {
return {
r: Number.parseInt(`${hex[0]}${hex[0]}`, 16),
g: Number.parseInt(`${hex[1]}${hex[1]}`, 16),
b: Number.parseInt(`${hex[2]}${hex[2]}`, 16),
a:
hex.length === 4
? Number.parseInt(`${hex[3]}${hex[3]}`, 16) / 255
: 1,
};
}
if (hex.length === 6 || hex.length === 8) {
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
a: hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1,
};
}
}
const rgb = normalized.match(
/^rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/,
);
if (!rgb) return null;
const alpha = rgb[4]?.endsWith("%")
? Number.parseFloat(rgb[4]) / 100
: Number.parseFloat(rgb[4] ?? "1");
const channels = rgb.slice(1, 4).map(Number);
if (
channels.some((channel) => !Number.isFinite(channel)) ||
!Number.isFinite(alpha)
) {
return null;
}
return {
r: Math.min(255, Math.max(0, channels[0])),
g: Math.min(255, Math.max(0, channels[1])),
b: Math.min(255, Math.max(0, channels[2])),
a: Math.min(1, Math.max(0, alpha)),
};
}
function mixColor(from: RgbaColor, to: RgbaColor, amount: number): RgbaColor {
return {
r: from.r + (to.r - from.r) * amount,
g: from.g + (to.g - from.g) * amount,
b: from.b + (to.b - from.b) * amount,
a: from.a + (to.a - from.a) * amount,
};
}
function compositeColor(foreground: RgbaColor, background: RgbaColor) {
const alpha = foreground.a + background.a * (1 - foreground.a);
if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 };
return {
r:
(foreground.r * foreground.a +
background.r * background.a * (1 - foreground.a)) /
alpha,
g:
(foreground.g * foreground.a +
background.g * background.a * (1 - foreground.a)) /
alpha,
b:
(foreground.b * foreground.a +
background.b * background.a * (1 - foreground.a)) /
alpha,
a: alpha,
};
}
function relativeLuminance(color: RgbaColor) {
const linear = [color.r, color.g, color.b].map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
function contrastRatio(foreground: RgbaColor, background: RgbaColor) {
const opaqueForeground = compositeColor(foreground, background);
const foregroundLuminance = relativeLuminance(opaqueForeground);
const backgroundLuminance = relativeLuminance(background);
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
function colorToCss(color: RgbaColor) {
const channels = [color.r, color.g, color.b].map((channel) =>
Math.round(channel).toString(16).padStart(2, "0"),
);
if (color.a >= 0.999) return `#${channels.join("")}`;
return `rgba(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(
color.b,
)}, ${color.a.toFixed(3)})`;
}
function findReadableColor(
sourceValue: string | undefined,
surfaceValues: Array<string | undefined>,
minimumContrast: number,
fallback: string,
includeTintedSurfaces = false,
tintSurfaceValues = surfaceValues,
) {
const source = parseCssColor(sourceValue);
const surfaces = surfaceValues
.map(parseCssColor)
.filter((surface): surface is RgbaColor => surface !== null);
const tintSurfaces = tintSurfaceValues
.map(parseCssColor)
.filter((surface): surface is RgbaColor => surface !== null);
if (!source || surfaces.length === 0) return fallback;
const testedSurfaces = includeTintedSurfaces
? [
...surfaces,
...tintSurfaces.flatMap((surface) =>
[0.1, 0.2].map((opacity) =>
compositeColor({ ...source, a: source.a * opacity }, surface),
),
),
]
: surfaces;
const targetContrast = minimumContrast + 0.05;
const isReadable = (candidate: RgbaColor) =>
testedSurfaces.every(
(surface) => contrastRatio(candidate, surface) >= targetContrast,
);
if (isReadable(source)) return sourceValue ?? fallback;
const targets: RgbaColor[] = [
{ r: 0, g: 0, b: 0, a: 1 },
{ r: 255, g: 255, b: 255, a: 1 },
];
for (let step = 1; step <= 1000; step += 1) {
const amount = step / 1000;
for (const target of targets) {
const candidate = mixColor(source, target, amount);
if (isReadable(candidate)) return colorToCss(candidate);
}
}
return fallback;
}
export function getThemeMode(colors: Record<string, string>): "light" | "dark" {
const preset = getThemeByColors(colors);
if (preset) return preset.mode;
const background = parseCssColor(colors["--background"]);
if (!background) return "dark";
const opaqueBackground = compositeColor(background, {
r: 255,
g: 255,
b: 255,
a: 1,
});
const blackContrast = contrastRatio(
{ r: 0, g: 0, b: 0, a: 1 },
opaqueBackground,
);
const whiteContrast = contrastRatio(
{ r: 255, g: 255, b: 255, a: 1 },
opaqueBackground,
);
return blackContrast >= whiteContrast ? "light" : "dark";
}
export function getDerivedThemeColors(
colors: Record<string, string>,
): Record<(typeof DERIVED_THEME_VARIABLES)[number], string> {
const surfaces = [
colors["--background"],
colors["--card"],
colors["--popover"],
colors["--muted"],
];
const tintSurfaces = surfaces.slice(0, 3);
const foreground = colors["--foreground"] ?? "#ffffff";
const primaryText = findReadableColor(
colors["--primary"],
surfaces,
4.5,
foreground,
true,
tintSurfaces,
);
const destructiveText = findReadableColor(
colors["--destructive"],
surfaces,
4.5,
foreground,
true,
tintSurfaces,
);
const successText = findReadableColor(
colors["--success"],
surfaces,
4.5,
foreground,
true,
tintSurfaces,
);
const warningText = findReadableColor(
colors["--warning"],
surfaces,
4.5,
foreground,
true,
tintSurfaces,
);
const input = findReadableColor(colors["--border"], surfaces, 3, foreground);
return {
"--primary-text": primaryText,
"--destructive-text": destructiveText,
"--success-text": successText,
"--warning-text": warningText,
"--input": input,
"--ring": primaryText,
"--sidebar": colors["--background"],
"--sidebar-foreground": colors["--foreground"],
"--sidebar-primary": colors["--primary"],
"--sidebar-primary-foreground": colors["--primary-foreground"],
"--sidebar-accent": colors["--accent"],
"--sidebar-accent-foreground": colors["--accent-foreground"],
"--sidebar-border": colors["--border"],
"--sidebar-ring": primaryText,
};
}
export function applyThemeColors(colors: Record<string, string>): void {
const normalizedColors = normalizeThemeColors(colors);
const root = document.documentElement;
Object.entries(colors).forEach(([key, value]) => {
root.classList.remove("light", "dark");
root.classList.add(getThemeMode(normalizedColors));
Object.entries(normalizedColors).forEach(([key, value]) => {
root.style.setProperty(key, value, "important");
});
Object.entries(getDerivedThemeColors(normalizedColors)).forEach(
([key, value]) => {
root.style.setProperty(key, value, "important");
},
);
}
export function clearThemeColors(): void {
@@ -759,6 +1171,9 @@ export function clearThemeColors(): void {
THEME_VARIABLES.forEach(({ key }) => {
root.style.removeProperty(key as string);
});
DERIVED_THEME_VARIABLES.forEach((key) => {
root.style.removeProperty(key);
});
}
/**
+12
View File
@@ -32,6 +32,7 @@
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-primary-text: var(--primary-text);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
@@ -40,10 +41,13 @@
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-destructive-text: var(--destructive-text);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-success-text: var(--success-text);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-text: var(--warning-text);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@@ -73,6 +77,7 @@
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--primary-text: oklch(0.21 0.006 285.885);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
@@ -81,10 +86,13 @@
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--destructive-text: #b91c1c;
--success: oklch(0.6 0.2 145);
--success-foreground: oklch(0.985 0 0);
--success-text: #15753a;
--warning: oklch(0.75 0.15 75);
--warning-foreground: oklch(0.141 0.005 285.823);
--warning-text: #854d0e;
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
@@ -118,6 +126,7 @@
--popover-foreground: #e4e4e4;
--primary: #ffffff;
--primary-foreground: #070707;
--primary-text: #ffffff;
--secondary: #161616;
--secondary-foreground: #e4e4e4;
--muted: #161616;
@@ -126,10 +135,13 @@
--accent-foreground: #ffffff;
--destructive: #ec6a5e;
--destructive-foreground: #070707;
--destructive-text: #ec6a5e;
--success: #61c554;
--success-foreground: #070707;
--success-text: #61c554;
--warning: #f4be4f;
--warning-foreground: #070707;
--warning-text: #f4be4f;
--border: rgba(255, 255, 255, 0.06);
--input: rgba(255, 255, 255, 0.1);
--ring: #6b6b6b;
+3 -1
View File
@@ -1,9 +1,10 @@
export interface ProxySettings {
proxy_type: string; // "http", "https", "socks4", "socks5", or "ss" (Shadowsocks)
proxy_type: string;
host: string;
port: number;
username?: string;
password?: string;
vless_uri?: string;
}
export interface TableSortingSettings {
@@ -545,6 +546,7 @@ export interface ParsedProxyLine {
port: number;
username?: string;
password?: string;
vless_uri?: string;
original_line: string;
}