mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-06 07:03:52 +02:00
refactor: cleanup
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user