mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-02 17:28:43 +02:00
feat: xray support
This commit is contained in:
+232
-62
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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),
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"sourceUrl": "https://github.com/XTLS/Xray-core/tree/v26.3.27"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 n’est pas pris en charge.",
|
||||
"mcpAgentInstallFailed": "Impossible d’ajouter 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": "L’URI VLESS n’est pas valide.",
|
||||
"xrayUnavailable": "Xray-core n’est 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",
|
||||
|
||||
@@ -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": "終了時にデータを消去",
|
||||
|
||||
@@ -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": "닫을 때 데이터 지우기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Очищать данные при закрытии",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "关闭时清除数据",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user