refactor: cleanup

This commit is contained in:
zhom
2026-05-05 22:33:43 +04:00
parent 904dda2bad
commit 34450ad06b
29 changed files with 1312 additions and 369 deletions
+48 -14
View File
@@ -12,6 +12,7 @@ import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { DeviceCodeVerifyDialog } from "@/components/device-code-verify-dialog";
import { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
@@ -197,6 +198,7 @@ export default function Home() {
useState(false);
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
const [deviceCodeDialogOpen, setDeviceCodeDialogOpen] = useState(false);
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
const [currentProfileForSync, setCurrentProfileForSync] =
@@ -394,21 +396,32 @@ export default function Home() {
}
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
const checkNextPermission = useCallback(() => {
try {
if (!isMicrophoneAccessGranted) {
setCurrentPermissionType("microphone");
setPermissionDialogOpen(true);
} else if (!isCameraAccessGranted) {
setCurrentPermissionType("camera");
setPermissionDialogOpen(true);
} else {
setPermissionDialogOpen(false);
const checkNextPermission = useCallback(
(justGranted?: PermissionType) => {
try {
// Treat the just-granted permission as already granted even if our
// own usePermissions instance hasn't observed it yet — it polls on a
// 5 s cadence and would otherwise leave the dialog stuck on the
// permission the user just successfully granted.
const micGranted =
isMicrophoneAccessGranted || justGranted === "microphone";
const camGranted = isCameraAccessGranted || justGranted === "camera";
if (!micGranted) {
setCurrentPermissionType("microphone");
setPermissionDialogOpen(true);
} else if (!camGranted) {
setCurrentPermissionType("camera");
setPermissionDialogOpen(true);
} else {
setPermissionDialogOpen(false);
}
} catch (error) {
console.error("Failed to check next permission:", error);
}
} catch (error) {
console.error("Failed to check next permission:", error);
}
}, [isMicrophoneAccessGranted, isCameraAccessGranted]);
},
[isMicrophoneAccessGranted, isCameraAccessGranted],
);
const listenForUrlEvents = useCallback(async () => {
try {
@@ -1316,8 +1329,29 @@ export default function Home() {
setSyncAllDialogOpen(true);
}
}}
onLoginStarted={() => {
// Hand the verify step off to its own dialog. We close this one
// first so the verify dialog isn't stacked on top of it (and
// can't end up stacked on top of the profile selector either).
setSyncConfigDialogOpen(false);
setDeviceCodeDialogOpen(true);
}}
/>
{/* Only render while no profile-selector flow is in progress, so the
verify dialog never lands on top of a deep-link-triggered selector. */}
{pendingUrls.length === 0 && (
<DeviceCodeVerifyDialog
isOpen={deviceCodeDialogOpen}
onClose={(loginOccurred) => {
setDeviceCodeDialogOpen(false);
if (loginOccurred) {
setSyncAllDialogOpen(true);
}
}}
/>
)}
<SyncAllDialog
isOpen={syncAllDialogOpen}
onClose={() => {
+1 -1
View File
@@ -537,7 +537,7 @@ export function CreateProfileDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-full max-h-[90vh] flex flex-col">
<DialogContent className="max-w-md max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>
{currentStep === "browser-selection"
@@ -0,0 +1,119 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface DeviceCodeVerifyDialogProps {
isOpen: boolean;
onClose: (loginOccurred?: boolean) => void;
}
/**
* Dedicated dialog for pasting and verifying the cloud device-link code.
* Opens after the user clicks "Login" in the sync config dialog so the
* verify step is a focused step on its own — and so it doesn't visually
* stack with other dialogs (e.g. the profile selector triggered by a
* deep link) sharing the same view.
*/
export function DeviceCodeVerifyDialog({
isOpen,
onClose,
}: DeviceCodeVerifyDialogProps) {
const { t } = useTranslation();
const { exchangeDeviceCode } = useCloudAuth();
const [linkCode, setLinkCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
// Reset the field when the dialog reopens so a stale code from a
// previous attempt doesn't auto-populate.
useEffect(() => {
if (isOpen) {
setLinkCode("");
}
}, [isOpen]);
const handleVerify = async () => {
const trimmed = linkCode.trim();
if (!trimmed) return;
setIsVerifying(true);
try {
await exchangeDeviceCode(trimmed);
showSuccessToast(t("sync.cloud.loginSuccess"));
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
onClose(true);
} catch (error) {
console.error("Device-code exchange failed:", error);
showErrorToast(String(error));
} finally {
setIsVerifying(false);
}
};
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose(false);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("sync.cloud.verifyAndLogin")}</DialogTitle>
<DialogDescription>
{t("sync.cloud.deviceLinkInstructions")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<Label htmlFor="device-link-code">
{t("sync.cloud.linkCodeLabel")}
</Label>
<Input
id="device-link-code"
placeholder={t("sync.cloud.linkCodePlaceholder")}
value={linkCode}
onChange={(e) => {
setLinkCode(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && linkCode.trim()) {
void handleVerify();
}
}}
autoComplete="off"
spellCheck={false}
autoFocus
/>
<LoadingButton
onClick={() => void handleVerify()}
isLoading={isVerifying}
disabled={!linkCode.trim()}
className="w-full"
>
{isVerifying
? t("sync.cloud.loggingIn")
: t("sync.cloud.verifyAndLogin")}
</LoadingButton>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -272,7 +272,7 @@ const HomeHeader = ({
<Button
size="sm"
variant="outline"
className="flex gap-2 items-center h-[36px]"
className="flex gap-2 items-center h-[36px] border-foreground/20 hover:text-foreground"
>
<GoKebabHorizontal className="w-4 h-4" />
</Button>
+96 -26
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
@@ -21,7 +21,14 @@ interface PermissionDialogProps {
isOpen: boolean;
onClose: () => void;
permissionType: PermissionType;
onPermissionGranted?: () => void;
/**
* Fired when the displayed permission becomes granted. The just-granted
* type is passed through so the parent can act optimistically — its own
* usePermissions instance polls on a 5 s cadence and would otherwise be
* stale right after the macOS system prompt is accepted, leaving the
* dialog open in a confusing state.
*/
onPermissionGranted?: (justGranted: PermissionType) => void;
}
export function PermissionDialog({
@@ -32,6 +39,7 @@ export function PermissionDialog({
}: PermissionDialogProps) {
const { t } = useTranslation();
const [isRequesting, setIsRequesting] = useState(false);
const [isWaitingForGrant, setIsWaitingForGrant] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
const {
requestPermission,
@@ -57,12 +65,68 @@ export function PermissionDialog({
? isMicrophoneAccessGranted
: isCameraAccessGranted;
// Auto-close dialog when permission is granted
// Mirror the latest permission state into a ref so the deferred timeout
// callback can read it without being recreated on every state change.
const isCurrentPermissionGrantedRef = useRef(isCurrentPermissionGranted);
useEffect(() => {
if (isCurrentPermissionGranted && isOpen) {
onPermissionGranted?.();
isCurrentPermissionGrantedRef.current = isCurrentPermissionGranted;
}, [isCurrentPermissionGranted]);
// When the permission becomes granted, fire a success toast and let the
// parent decide what to do next (progress to the other permission, or close).
// We deliberately do NOT keep the dialog around to show a "Done" state —
// the toast is the confirmation, and the dialog closes immediately.
// Use a ref to ensure we only fire the toast once per grant transition.
const grantedToastFiredForRef = useRef<PermissionType | null>(null);
useEffect(() => {
if (!isOpen) {
grantedToastFiredForRef.current = null;
return;
}
}, [isCurrentPermissionGranted, isOpen, onPermissionGranted]);
if (
isCurrentPermissionGranted &&
grantedToastFiredForRef.current !== permissionType
) {
grantedToastFiredForRef.current = permissionType;
showSuccessToast(
permissionType === "microphone"
? t("permissionDialog.grantedToastMicrophone")
: t("permissionDialog.grantedToastCamera"),
);
onPermissionGranted?.(permissionType);
}
}, [
isCurrentPermissionGranted,
isOpen,
onPermissionGranted,
permissionType,
t,
]);
// Pending-grant timeout: triggered after the user clicks "Grant Access"
// to give the macOS permission state a few seconds to propagate to our poll.
const waitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// If permission becomes granted during the wait window, end the wait early.
useEffect(() => {
if (isWaitingForGrant && isCurrentPermissionGranted) {
if (waitTimeoutRef.current) {
clearTimeout(waitTimeoutRef.current);
waitTimeoutRef.current = null;
}
setIsWaitingForGrant(false);
}
}, [isWaitingForGrant, isCurrentPermissionGranted]);
// Clear any pending timeout on unmount.
useEffect(() => {
return () => {
if (waitTimeoutRef.current) {
clearTimeout(waitTimeoutRef.current);
waitTimeoutRef.current = null;
}
};
}, []);
const getPermissionIcon = (type: PermissionType) => {
switch (type) {
@@ -95,11 +159,25 @@ export function PermissionDialog({
setIsRequesting(true);
try {
await requestPermission(permissionType);
showSuccessToast(
permissionType === "microphone"
? t("permissionDialog.requestSuccessMicrophone")
: t("permissionDialog.requestSuccessCamera"),
);
// The macOS permission poll runs every 5 s, so the new state can take
// a moment to surface. Keep the grant button in its busy state for
// that window so the user has clear feedback, and notify them if the
// grant still hasn't landed by the end.
setIsWaitingForGrant(true);
if (waitTimeoutRef.current) {
clearTimeout(waitTimeoutRef.current);
}
waitTimeoutRef.current = setTimeout(() => {
waitTimeoutRef.current = null;
setIsWaitingForGrant(false);
if (!isCurrentPermissionGrantedRef.current) {
showErrorToast(
permissionType === "microphone"
? t("permissionDialog.stillNotGrantedMicrophone")
: t("permissionDialog.stillNotGrantedCamera"),
);
}
}, 5000);
} catch (error) {
console.error("Failed to request permission:", error);
showErrorToast(t("permissionDialog.requestFailed"));
@@ -129,16 +207,6 @@ export function PermissionDialog({
</DialogHeader>
<div className="space-y-4">
{isCurrentPermissionGranted && (
<div className="p-3 bg-success/10 rounded-lg">
<p className="text-sm text-success">
{permissionType === "microphone"
? t("permissionDialog.grantedMicrophone")
: t("permissionDialog.grantedCamera")}
</p>
</div>
)}
{!isCurrentPermissionGranted && (
<div className="p-3 bg-warning/10 rounded-lg">
<p className="text-sm text-warning">
@@ -151,15 +219,17 @@ export function PermissionDialog({
</div>
<DialogFooter className="gap-2">
<RippleButton variant="outline" onClick={onClose}>
{isCurrentPermissionGranted
? t("permissionDialog.doneButton")
: t("permissionDialog.cancelButton")}
<RippleButton
variant="outline"
onClick={onClose}
className="min-w-24"
>
{t("permissionDialog.cancelButton")}
</RippleButton>
{!isCurrentPermissionGranted && (
<LoadingButton
isLoading={isRequesting}
isLoading={isRequesting || isWaitingForGrant}
onClick={() => {
handleRequestPermission().catch((err: unknown) => {
console.error(err);
+7
View File
@@ -907,6 +907,13 @@ export function ProfilesDataTable({
}
setRowSelection(newSelection);
prevSelectedProfilesRef.current = selectedProfiles;
// When the parent clears the selection (e.g. after a bulk action like
// delete / move-to-group), collapse the checkbox column back to icons.
// Otherwise the row checkboxes stay visible and only revert after the
// user clicks one — which the per-checkbox handler resets.
if (selectedProfiles.length === 0) {
setShowCheckboxes(false);
}
}
}, [selectedProfiles]);
+7 -2
View File
@@ -408,7 +408,12 @@ export function SettingsDialog({
// Update settings with any generated tokens
setSettings(savedSettings);
settingsToSave = savedSettings;
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
// Pass the actual theme value through. Calling setTheme("dark") here
// when the user is on "custom" pushes the provider state to "dark",
// which triggers its clear-custom-vars effect and wipes the CSS
// variables we set just below — that's the bug where saving a custom
// theme made it disappear until the app was restarted.
setTheme(settings.theme);
// Apply or clear custom variables only on Save
if (settings.theme === "custom") {
@@ -539,7 +544,7 @@ export function SettingsDialog({
checkDefaultBrowserStatus().catch((err: unknown) => {
console.error(err);
});
}, 500); // Check every 500ms
}, 2000);
// Cleanup interval on component unmount or dialog close
return () => {
+18 -58
View File
@@ -32,6 +32,14 @@ const DEVICE_LINK_URL = "https://donutbrowser.com/auth/link";
interface SyncConfigDialogProps {
isOpen: boolean;
onClose: (loginOccurred?: boolean) => void;
/**
* Called after the user clicks "Login" so the parent can open the
* device-code verify dialog as a separate step. Implementations should
* close this dialog and open the verify one — that keeps the verify
* step visually independent and avoids stacking on top of other
* dialogs (e.g. the profile selector triggered by deep links).
*/
onLoginStarted?: () => void;
}
interface ProxyUsage {
@@ -42,7 +50,11 @@ interface ProxyUsage {
extra_limit_mb: number;
}
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
export function SyncConfigDialog({
isOpen,
onClose,
onLoginStarted,
}: SyncConfigDialogProps) {
const { t } = useTranslation();
// Self-hosted state
@@ -58,11 +70,8 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
user,
isLoggedIn,
isLoading: isCloudLoading,
exchangeDeviceCode,
logout,
} = useCloudAuth();
const [linkCode, setLinkCode] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [activeTab, setActiveTab] = useState<string>("cloud");
const [, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
@@ -103,7 +112,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
if (isOpen) {
setConnectionStatus("unknown");
void loadSettings();
setLinkCode("");
void invoke<ProxyUsage | null>("cloud_get_proxy_usage")
.then(setLiveProxyUsage)
.catch(() => {
@@ -199,32 +207,15 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
const handleOpenLogin = useCallback(async () => {
try {
await invoke("handle_url_open", { url: DEVICE_LINK_URL });
// Hand off the verify step to its own dialog so the user has a
// focused place to paste the code, and so it doesn't visually
// stack with this dialog or any other modal currently on screen.
onLoginStarted?.();
} catch (error) {
console.error("Failed to open login link:", error);
showErrorToast(String(error));
}
}, []);
const handleVerifyCode = useCallback(async () => {
const trimmed = linkCode.trim();
if (!trimmed) return;
setIsVerifying(true);
try {
await exchangeDeviceCode(trimmed);
showSuccessToast(t("sync.cloud.loginSuccess"));
try {
await invoke("restart_sync_service");
} catch (e) {
console.error("Failed to restart sync service:", e);
}
onClose(true);
} catch (error) {
console.error("Device-code exchange failed:", error);
showErrorToast(String(error));
} finally {
setIsVerifying(false);
}
}, [linkCode, exchangeDeviceCode, t, onClose]);
}, [onLoginStarted]);
const handleCloudLogout = useCallback(async () => {
try {
@@ -375,37 +366,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
>
{t("sync.cloud.openLogin")}
</Button>
<div className="space-y-2">
<Label htmlFor="cloud-link-code">
{t("sync.cloud.linkCodeLabel")}
</Label>
<Input
id="cloud-link-code"
placeholder={t("sync.cloud.linkCodePlaceholder")}
value={linkCode}
onChange={(e) => {
setLinkCode(e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && linkCode.trim()) {
void handleVerifyCode();
}
}}
autoComplete="off"
spellCheck={false}
/>
<LoadingButton
onClick={() => void handleVerifyCode()}
isLoading={isVerifying}
disabled={!linkCode.trim()}
className="w-full"
>
{isVerifying
? t("sync.cloud.loggingIn")
: t("sync.cloud.verifyAndLogin")}
</LoadingButton>
</div>
</div>
)}
</TabsContent>
+1 -1
View File
@@ -159,7 +159,7 @@ export function usePermissions(): UsePermissionsReturn {
intervalRef.current = setInterval(() => {
void checkPermissions();
}, 500);
}, 5000);
return () => {
if (intervalRef.current) {
+5 -1
View File
@@ -1442,7 +1442,11 @@
"grantAccessButton": "Grant Access",
"requestSuccessMicrophone": "Microphone Access permission requested",
"requestSuccessCamera": "Camera Access permission requested",
"requestFailed": "Failed to request permission"
"requestFailed": "Failed to request permission",
"stillNotGrantedMicrophone": "Microphone access still hasn't been granted. You may need to enable it manually in System Settings → Privacy & Security → Microphone.",
"stillNotGrantedCamera": "Camera access still hasn't been granted. You may need to enable it manually in System Settings → Privacy & Security → Camera.",
"grantedToastMicrophone": "Microphone access granted",
"grantedToastCamera": "Camera access granted"
},
"traffic": {
"title": "Traffic Details",
+5 -1
View File
@@ -1442,7 +1442,11 @@
"grantAccessButton": "Conceder acceso",
"requestSuccessMicrophone": "Acceso al micrófono solicitado",
"requestSuccessCamera": "Acceso a la cámara solicitado",
"requestFailed": "Error al solicitar el permiso"
"requestFailed": "Error al solicitar el permiso",
"stillNotGrantedMicrophone": "El acceso al micrófono aún no se ha concedido. Puede que tengas que habilitarlo manualmente en Configuración del Sistema → Privacidad y Seguridad → Micrófono.",
"stillNotGrantedCamera": "El acceso a la cámara aún no se ha concedido. Puede que tengas que habilitarlo manualmente en Configuración del Sistema → Privacidad y Seguridad → Cámara.",
"grantedToastMicrophone": "Acceso al micrófono concedido",
"grantedToastCamera": "Acceso a la cámara concedido"
},
"traffic": {
"title": "Detalles de tráfico",
+5 -1
View File
@@ -1442,7 +1442,11 @@
"grantAccessButton": "Accorder l'accès",
"requestSuccessMicrophone": "Accès au microphone demandé",
"requestSuccessCamera": "Accès à la caméra demandé",
"requestFailed": "Échec de la demande de permission"
"requestFailed": "Échec de la demande de permission",
"stillNotGrantedMicrophone": "L'accès au microphone n'a toujours pas été accordé. Vous devrez peut-être l'activer manuellement dans Réglages Système → Confidentialité et sécurité → Microphone.",
"stillNotGrantedCamera": "L'accès à la caméra n'a toujours pas été accordé. Vous devrez peut-être l'activer manuellement dans Réglages Système → Confidentialité et sécurité → Caméra.",
"grantedToastMicrophone": "Accès au microphone accordé",
"grantedToastCamera": "Accès à la caméra accordé"
},
"traffic": {
"title": "Détails du trafic",
+5 -1
View File
@@ -1442,7 +1442,11 @@
"grantAccessButton": "アクセスを許可",
"requestSuccessMicrophone": "マイクアクセスをリクエストしました",
"requestSuccessCamera": "カメラアクセスをリクエストしました",
"requestFailed": "許可のリクエストに失敗しました"
"requestFailed": "許可のリクエストに失敗しました",
"stillNotGrantedMicrophone": "マイクへのアクセスはまだ許可されていません。システム設定 → プライバシーとセキュリティ → マイク で手動で有効にする必要があるかもしれません。",
"stillNotGrantedCamera": "カメラへのアクセスはまだ許可されていません。システム設定 → プライバシーとセキュリティ → カメラ で手動で有効にする必要があるかもしれません。",
"grantedToastMicrophone": "マイクへのアクセスが許可されました",
"grantedToastCamera": "カメラへのアクセスが許可されました"
},
"traffic": {
"title": "トラフィックの詳細",
+5 -1
View File
@@ -1442,7 +1442,11 @@
"grantAccessButton": "Conceder acesso",
"requestSuccessMicrophone": "Acesso ao microfone solicitado",
"requestSuccessCamera": "Acesso à câmera solicitado",
"requestFailed": "Falha ao solicitar permissão"
"requestFailed": "Falha ao solicitar permissão",
"stillNotGrantedMicrophone": "O acesso ao microfone ainda não foi concedido. Pode ser necessário ativá-lo manualmente em Ajustes do Sistema → Privacidade e Segurança → Microfone.",
"stillNotGrantedCamera": "O acesso à câmera ainda não foi concedido. Pode ser necessário ativá-lo manualmente em Ajustes do Sistema → Privacidade e Segurança → Câmera.",
"grantedToastMicrophone": "Acesso ao microfone concedido",
"grantedToastCamera": "Acesso à câmera concedido"
},
"traffic": {
"title": "Detalhes do tráfego",
+5 -1
View File
@@ -1442,7 +1442,11 @@
"grantAccessButton": "Предоставить доступ",
"requestSuccessMicrophone": "Запрошен доступ к микрофону",
"requestSuccessCamera": "Запрошен доступ к камере",
"requestFailed": "Не удалось запросить разрешение"
"requestFailed": "Не удалось запросить разрешение",
"stillNotGrantedMicrophone": "Доступ к микрофону всё ещё не предоставлен. Возможно, потребуется включить его вручную в Системных настройках → Конфиденциальность и безопасность → Микрофон.",
"stillNotGrantedCamera": "Доступ к камере всё ещё не предоставлен. Возможно, потребуется включить его вручную в Системных настройках → Конфиденциальность и безопасность → Камера.",
"grantedToastMicrophone": "Доступ к микрофону предоставлен",
"grantedToastCamera": "Доступ к камере предоставлен"
},
"traffic": {
"title": "Подробности трафика",
+5 -1
View File
@@ -1442,7 +1442,11 @@
"grantAccessButton": "授予访问",
"requestSuccessMicrophone": "已请求麦克风访问",
"requestSuccessCamera": "已请求摄像头访问",
"requestFailed": "请求权限失败"
"requestFailed": "请求权限失败",
"stillNotGrantedMicrophone": "麦克风访问权限仍未授予。您可能需要在系统设置 → 隐私与安全 → 麦克风中手动启用。",
"stillNotGrantedCamera": "摄像头访问权限仍未授予。您可能需要在系统设置 → 隐私与安全 → 摄像头中手动启用。",
"grantedToastMicrophone": "已授予麦克风访问权限",
"grantedToastCamera": "已授予摄像头访问权限"
},
"traffic": {
"title": "流量详情",
+414 -99
View File
@@ -197,38 +197,45 @@ export const THEMES: Theme[] = [
{
id: "ayu-light",
name: "Ayu 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: {
"--background": "#fafafa",
"--foreground": "#5c6773",
"--card": "#ffffff",
"--card-foreground": "#5c6773",
"--background": "#f8f9fa",
"--foreground": "#5c6166",
"--card": "#fcfcfc",
"--card-foreground": "#5c6166",
"--popover": "#ffffff",
"--popover-foreground": "#5c6773",
"--primary": "#399ee6",
"--primary-foreground": "#fafafa",
"--secondary": "#fa8d3e",
"--secondary-foreground": "#fafafa",
"--muted": "#f0f0f0",
"--muted-foreground": "#828c99",
"--popover-foreground": "#5c6166",
"--primary": "#f29718",
"--primary-foreground": "#ffffff",
"--secondary": "#399ee6",
"--secondary-foreground": "#ffffff",
"--muted": "#ebeef0",
"--muted-foreground": "#828e9f",
"--accent": "#a37acc",
"--accent-foreground": "#fafafa",
"--destructive": "#f07178",
"--destructive-foreground": "#fafafa",
"--accent-foreground": "#ffffff",
"--destructive": "#e65050",
"--destructive-foreground": "#ffffff",
"--success": "#86b300",
"--success-foreground": "#fafafa",
"--warning": "#fa8d3e",
"--warning-foreground": "#fafafa",
"--border": "#e7eaed",
"--chart-1": "#399ee6",
"--success-foreground": "#ffffff",
"--warning": "#fa8532",
"--warning-foreground": "#ffffff",
"--border": "#c8d0d6",
"--chart-1": "#f29718",
"--chart-2": "#86b300",
"--chart-3": "#a37acc",
"--chart-4": "#fa8d3e",
"--chart-5": "#f07178",
"--chart-4": "#399ee6",
"--chart-5": "#4cbf99",
},
},
{
id: "catppuccin-latte",
name: "Catppuccin Latte",
// 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.
// Frappé and Macchiato variants intentionally omitted; they're tonal
// mid-points between Latte and Mocha and added little variety.
colors: {
"--background": "#eff1f5",
"--foreground": "#4c4f69",
@@ -236,13 +243,13 @@ export const THEMES: Theme[] = [
"--card-foreground": "#4c4f69",
"--popover": "#ccd0da",
"--popover-foreground": "#4c4f69",
"--primary": "#1e66f5",
"--primary": "#8839ef",
"--primary-foreground": "#eff1f5",
"--secondary": "#04a5e5",
"--secondary": "#1e66f5",
"--secondary-foreground": "#eff1f5",
"--muted": "#bcc0cc",
"--muted-foreground": "#5c5f77",
"--accent": "#8839ef",
"--muted-foreground": "#6c6f85",
"--accent": "#ea76cb",
"--accent-foreground": "#eff1f5",
"--destructive": "#d20f39",
"--destructive-foreground": "#eff1f5",
@@ -251,80 +258,18 @@ export const THEMES: Theme[] = [
"--warning": "#df8e1d",
"--warning-foreground": "#eff1f5",
"--border": "#9ca0b0",
"--chart-1": "#1e66f5",
"--chart-1": "#8839ef",
"--chart-2": "#40a02b",
"--chart-3": "#8839ef",
"--chart-3": "#ea76cb",
"--chart-4": "#04a5e5",
"--chart-5": "#df8e1d",
},
},
{
id: "catppuccin-frappe",
name: "Catppuccin Frappe",
colors: {
"--background": "#303446",
"--foreground": "#c6d0f5",
"--card": "#414559",
"--card-foreground": "#c6d0f5",
"--popover": "#414559",
"--popover-foreground": "#c6d0f5",
"--primary": "#8caaee",
"--primary-foreground": "#303446",
"--secondary": "#99d1db",
"--secondary-foreground": "#303446",
"--muted": "#51576d",
"--muted-foreground": "#b5bfe2",
"--accent": "#ca9ee6",
"--accent-foreground": "#303446",
"--destructive": "#e78284",
"--destructive-foreground": "#303446",
"--success": "#a6d189",
"--success-foreground": "#303446",
"--warning": "#e5c890",
"--warning-foreground": "#303446",
"--border": "#737994",
"--chart-1": "#8caaee",
"--chart-2": "#a6d189",
"--chart-3": "#ca9ee6",
"--chart-4": "#99d1db",
"--chart-5": "#e5c890",
},
},
{
id: "catppuccin-macchiato",
name: "Catppuccin Macchiato",
colors: {
"--background": "#24273a",
"--foreground": "#cad3f5",
"--card": "#363a4f",
"--card-foreground": "#cad3f5",
"--popover": "#363a4f",
"--popover-foreground": "#cad3f5",
"--primary": "#8aadf4",
"--primary-foreground": "#24273a",
"--secondary": "#91d7e3",
"--secondary-foreground": "#24273a",
"--muted": "#494d64",
"--muted-foreground": "#b8c0e0",
"--accent": "#c6a0f6",
"--accent-foreground": "#24273a",
"--destructive": "#ed8796",
"--destructive-foreground": "#24273a",
"--success": "#a6da95",
"--success-foreground": "#24273a",
"--warning": "#eed49f",
"--warning-foreground": "#24273a",
"--border": "#6e738d",
"--chart-1": "#8aadf4",
"--chart-2": "#a6da95",
"--chart-3": "#c6a0f6",
"--chart-4": "#91d7e3",
"--chart-5": "#eed49f",
"--chart-5": "#fe640b",
},
},
{
id: "catppuccin-mocha",
name: "Catppuccin Mocha",
// Source: github.com/catppuccin/palette/blob/main/palette.json
// Primary uses mauve (purple) — Catppuccin's signature colour.
colors: {
"--background": "#1e1e2e",
"--foreground": "#cdd6f4",
@@ -332,13 +277,13 @@ export const THEMES: Theme[] = [
"--card-foreground": "#cdd6f4",
"--popover": "#313244",
"--popover-foreground": "#cdd6f4",
"--primary": "#89b4fa",
"--primary": "#cba6f7",
"--primary-foreground": "#1e1e2e",
"--secondary": "#89dceb",
"--secondary": "#89b4fa",
"--secondary-foreground": "#1e1e2e",
"--muted": "#45475a",
"--muted-foreground": "#bac2de",
"--accent": "#cba6f7",
"--muted-foreground": "#a6adc8",
"--accent": "#f5c2e7",
"--accent-foreground": "#1e1e2e",
"--destructive": "#f38ba8",
"--destructive-foreground": "#1e1e2e",
@@ -347,11 +292,381 @@ export const THEMES: Theme[] = [
"--warning": "#f9e2af",
"--warning-foreground": "#1e1e2e",
"--border": "#585b70",
"--chart-1": "#89b4fa",
"--chart-1": "#cba6f7",
"--chart-2": "#a6e3a1",
"--chart-3": "#cba6f7",
"--chart-3": "#f5c2e7",
"--chart-4": "#89dceb",
"--chart-5": "#f9e2af",
"--chart-5": "#fab387",
},
},
{
id: "nord",
name: "Nord",
// Source: nordtheme.com/docs/colors-and-palettes (Polar Night / Snow Storm / Frost / Aurora)
colors: {
"--background": "#2e3440",
"--foreground": "#d8dee9",
"--card": "#3b4252",
"--card-foreground": "#d8dee9",
"--popover": "#3b4252",
"--popover-foreground": "#d8dee9",
"--primary": "#81a1c1",
"--primary-foreground": "#2e3440",
"--secondary": "#88c0d0",
"--secondary-foreground": "#2e3440",
"--muted": "#434c5e",
"--muted-foreground": "#d8dee9",
"--accent": "#b48ead",
"--accent-foreground": "#2e3440",
"--destructive": "#bf616a",
"--destructive-foreground": "#eceff4",
"--success": "#a3be8c",
"--success-foreground": "#2e3440",
"--warning": "#ebcb8b",
"--warning-foreground": "#2e3440",
"--border": "#4c566a",
"--chart-1": "#81a1c1",
"--chart-2": "#a3be8c",
"--chart-3": "#b48ead",
"--chart-4": "#88c0d0",
"--chart-5": "#d08770",
},
},
{
id: "gruvbox-dark",
name: "Gruvbox Dark",
// Source: github.com/morhetz/gruvbox medium-contrast dark palette.
// Primary uses the iconic Gruvbox orange instead of blue.
colors: {
"--background": "#282828",
"--foreground": "#ebdbb2",
"--card": "#3c3836",
"--card-foreground": "#ebdbb2",
"--popover": "#3c3836",
"--popover-foreground": "#ebdbb2",
"--primary": "#fe8019",
"--primary-foreground": "#282828",
"--secondary": "#83a598",
"--secondary-foreground": "#282828",
"--muted": "#504945",
"--muted-foreground": "#a89984",
"--accent": "#d3869b",
"--accent-foreground": "#282828",
"--destructive": "#fb4934",
"--destructive-foreground": "#282828",
"--success": "#b8bb26",
"--success-foreground": "#282828",
"--warning": "#fabd2f",
"--warning-foreground": "#282828",
"--border": "#665c54",
"--chart-1": "#fe8019",
"--chart-2": "#b8bb26",
"--chart-3": "#d3869b",
"--chart-4": "#83a598",
"--chart-5": "#8ec07c",
},
},
{
id: "gruvbox-light",
name: "Gruvbox Light",
// Source: github.com/morhetz/gruvbox medium-contrast light palette.
// Primary uses the iconic Gruvbox orange instead of blue.
colors: {
"--background": "#fbf1c7",
"--foreground": "#3c3836",
"--card": "#ebdbb2",
"--card-foreground": "#3c3836",
"--popover": "#ebdbb2",
"--popover-foreground": "#3c3836",
"--primary": "#af3a03",
"--primary-foreground": "#fbf1c7",
"--secondary": "#076678",
"--secondary-foreground": "#fbf1c7",
"--muted": "#d5c4a1",
"--muted-foreground": "#7c6f64",
"--accent": "#8f3f71",
"--accent-foreground": "#fbf1c7",
"--destructive": "#9d0006",
"--destructive-foreground": "#fbf1c7",
"--success": "#79740e",
"--success-foreground": "#fbf1c7",
"--warning": "#b57614",
"--warning-foreground": "#fbf1c7",
"--border": "#a89984",
"--chart-1": "#af3a03",
"--chart-2": "#79740e",
"--chart-3": "#8f3f71",
"--chart-4": "#076678",
"--chart-5": "#427b58",
},
},
{
id: "solarized-dark",
name: "Solarized Dark",
// Source: ethanschoonover.com/solarized — base03 / base02 / base01 / base00 / base0 / base1
colors: {
"--background": "#002b36",
"--foreground": "#839496",
"--card": "#073642",
"--card-foreground": "#839496",
"--popover": "#073642",
"--popover-foreground": "#839496",
"--primary": "#268bd2",
"--primary-foreground": "#002b36",
"--secondary": "#2aa198",
"--secondary-foreground": "#002b36",
"--muted": "#073642",
"--muted-foreground": "#93a1a1",
"--accent": "#6c71c4",
"--accent-foreground": "#fdf6e3",
"--destructive": "#dc322f",
"--destructive-foreground": "#fdf6e3",
"--success": "#859900",
"--success-foreground": "#002b36",
"--warning": "#b58900",
"--warning-foreground": "#002b36",
"--border": "#586e75",
"--chart-1": "#268bd2",
"--chart-2": "#859900",
"--chart-3": "#6c71c4",
"--chart-4": "#2aa198",
"--chart-5": "#cb4b16",
},
},
{
id: "solarized-light",
name: "Solarized Light",
// Source: ethanschoonover.com/solarized — same accents, inverted base scale
colors: {
"--background": "#fdf6e3",
"--foreground": "#657b83",
"--card": "#eee8d5",
"--card-foreground": "#657b83",
"--popover": "#eee8d5",
"--popover-foreground": "#657b83",
"--primary": "#268bd2",
"--primary-foreground": "#fdf6e3",
"--secondary": "#2aa198",
"--secondary-foreground": "#fdf6e3",
"--muted": "#eee8d5",
"--muted-foreground": "#93a1a1",
"--accent": "#6c71c4",
"--accent-foreground": "#fdf6e3",
"--destructive": "#dc322f",
"--destructive-foreground": "#fdf6e3",
"--success": "#859900",
"--success-foreground": "#fdf6e3",
"--warning": "#b58900",
"--warning-foreground": "#fdf6e3",
"--border": "#cdc7b3",
"--chart-1": "#268bd2",
"--chart-2": "#859900",
"--chart-3": "#6c71c4",
"--chart-4": "#2aa198",
"--chart-5": "#cb4b16",
},
},
{
id: "one-dark",
name: "One Dark",
// Source: github.com/atom/atom one-dark-syntax/styles/colors.less (mono-1, hue-1..6)
colors: {
"--background": "#282c34",
"--foreground": "#abb2bf",
"--card": "#21252b",
"--card-foreground": "#abb2bf",
"--popover": "#21252b",
"--popover-foreground": "#abb2bf",
"--primary": "#61afef",
"--primary-foreground": "#282c34",
"--secondary": "#56b6c2",
"--secondary-foreground": "#282c34",
"--muted": "#3e4451",
"--muted-foreground": "#7d8590",
"--accent": "#c678dd",
"--accent-foreground": "#282c34",
"--destructive": "#e06c75",
"--destructive-foreground": "#282c34",
"--success": "#98c379",
"--success-foreground": "#282c34",
"--warning": "#e5c07b",
"--warning-foreground": "#282c34",
"--border": "#3e4451",
"--chart-1": "#61afef",
"--chart-2": "#98c379",
"--chart-3": "#c678dd",
"--chart-4": "#56b6c2",
"--chart-5": "#d19a66",
},
},
{
id: "monokai-pro",
name: "Monokai Pro",
// Source: classic Monokai filter (monokai-pro.nvim palette/classic.lua).
// Primary uses Monokai's signature green instead of cyan.
colors: {
"--background": "#272822",
"--foreground": "#fdfff1",
"--card": "#1d1e19",
"--card-foreground": "#fdfff1",
"--popover": "#1d1e19",
"--popover-foreground": "#fdfff1",
"--primary": "#a6e22e",
"--primary-foreground": "#272822",
"--secondary": "#66d9ef",
"--secondary-foreground": "#272822",
"--muted": "#3b3c35",
"--muted-foreground": "#919288",
"--accent": "#ae81ff",
"--accent-foreground": "#272822",
"--destructive": "#f92672",
"--destructive-foreground": "#fdfff1",
"--success": "#a6e22e",
"--success-foreground": "#272822",
"--warning": "#e6db74",
"--warning-foreground": "#272822",
"--border": "#57584f",
"--chart-1": "#a6e22e",
"--chart-2": "#66d9ef",
"--chart-3": "#ae81ff",
"--chart-4": "#e6db74",
"--chart-5": "#fd971f",
},
},
{
id: "rose-pine",
name: "Rosé Pine",
// 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
// for the palette's red role.
colors: {
"--background": "#191724",
"--foreground": "#e0def4",
"--card": "#1f1d2e",
"--card-foreground": "#e0def4",
"--popover": "#1f1d2e",
"--popover-foreground": "#e0def4",
"--primary": "#c4a7e7",
"--primary-foreground": "#191724",
"--secondary": "#9ccfd8",
"--secondary-foreground": "#191724",
"--muted": "#26233a",
"--muted-foreground": "#908caa",
"--accent": "#ebbcba",
"--accent-foreground": "#191724",
"--destructive": "#eb6f92",
"--destructive-foreground": "#191724",
"--success": "#31748f",
"--success-foreground": "#e0def4",
"--warning": "#f6c177",
"--warning-foreground": "#191724",
"--border": "#403d52",
"--chart-1": "#c4a7e7",
"--chart-2": "#9ccfd8",
"--chart-3": "#ebbcba",
"--chart-4": "#eb6f92",
"--chart-5": "#f6c177",
},
},
{
id: "rose-pine-dawn",
name: "Rosé Pine Dawn",
// Source: github.com/rose-pine/palette/blob/main/palette.json (dawn variant).
// Primary uses iris (purple) for parity with the dark variant.
colors: {
"--background": "#faf4ed",
"--foreground": "#575279",
"--card": "#fffaf3",
"--card-foreground": "#575279",
"--popover": "#fffaf3",
"--popover-foreground": "#575279",
"--primary": "#907aa9",
"--primary-foreground": "#faf4ed",
"--secondary": "#56949f",
"--secondary-foreground": "#faf4ed",
"--muted": "#f2e9e1",
"--muted-foreground": "#797593",
"--accent": "#d7827e",
"--accent-foreground": "#faf4ed",
"--destructive": "#b4637a",
"--destructive-foreground": "#faf4ed",
"--success": "#286983",
"--success-foreground": "#faf4ed",
"--warning": "#ea9d34",
"--warning-foreground": "#faf4ed",
"--border": "#cecacd",
"--chart-1": "#907aa9",
"--chart-2": "#56949f",
"--chart-3": "#d7827e",
"--chart-4": "#b4637a",
"--chart-5": "#ea9d34",
},
},
{
id: "github-dark",
name: "GitHub Dark",
// Source: github.com/primer/primitives base color tokens (dark default)
colors: {
"--background": "#0d1117",
"--foreground": "#f0f6fc",
"--card": "#151b23",
"--card-foreground": "#f0f6fc",
"--popover": "#151b23",
"--popover-foreground": "#f0f6fc",
"--primary": "#1f6feb",
"--primary-foreground": "#f0f6fc",
"--secondary": "#58a6ff",
"--secondary-foreground": "#0d1117",
"--muted": "#212830",
"--muted-foreground": "#9198a1",
"--accent": "#8957e5",
"--accent-foreground": "#f0f6fc",
"--destructive": "#da3633",
"--destructive-foreground": "#f0f6fc",
"--success": "#238636",
"--success-foreground": "#f0f6fc",
"--warning": "#d29922",
"--warning-foreground": "#0d1117",
"--border": "#3d444d",
"--chart-1": "#1f6feb",
"--chart-2": "#238636",
"--chart-3": "#8957e5",
"--chart-4": "#58a6ff",
"--chart-5": "#db6d28",
},
},
{
id: "github-light",
name: "GitHub Light",
// Source: github.com/primer/primitives base color tokens (light default)
colors: {
"--background": "#ffffff",
"--foreground": "#25292e",
"--card": "#f6f8fa",
"--card-foreground": "#25292e",
"--popover": "#f6f8fa",
"--popover-foreground": "#25292e",
"--primary": "#0969da",
"--primary-foreground": "#ffffff",
"--secondary": "#54aeff",
"--secondary-foreground": "#ffffff",
"--muted": "#eff2f5",
"--muted-foreground": "#59636e",
"--accent": "#8250df",
"--accent-foreground": "#ffffff",
"--destructive": "#cf222e",
"--destructive-foreground": "#ffffff",
"--success": "#1a7f37",
"--success-foreground": "#ffffff",
"--warning": "#bf8700",
"--warning-foreground": "#ffffff",
"--border": "#d1d9e0",
"--chart-1": "#0969da",
"--chart-2": "#1a7f37",
"--chart-3": "#8250df",
"--chart-4": "#54aeff",
"--chart-5": "#bc4c00",
},
},
];