mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-08 07:53:57 +02:00
310 lines
9.9 KiB
TypeScript
310 lines
9.9 KiB
TypeScript
"use client";
|
|
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { LoadingButton } from "@/components/loading-button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
|
import { getEntitlements } from "@/lib/entitlements";
|
|
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
|
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
|
import { isSyncEnabled } from "@/types";
|
|
|
|
interface ProfileSyncDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
profile: BrowserProfile | null;
|
|
onSyncConfigOpen: () => void;
|
|
}
|
|
|
|
export function ProfileSyncDialog({
|
|
isOpen,
|
|
onClose,
|
|
profile,
|
|
onSyncConfigOpen,
|
|
}: ProfileSyncDialogProps) {
|
|
const { t } = useTranslation();
|
|
const { user: cloudUser } = useCloudAuth();
|
|
const isCloudSyncEligible = getEntitlements(cloudUser).cloudBackup;
|
|
// Encryption available to everyone except team members who aren't owners
|
|
const canUseEncryption =
|
|
cloudUser == null ||
|
|
cloudUser.plan !== "team" ||
|
|
cloudUser.teamRole === "owner";
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [syncMode, setSyncMode] = useState<SyncMode>(
|
|
profile?.sync_mode ?? "Disabled",
|
|
);
|
|
const [hasSelfHostedConfig, setHasSelfHostedConfig] = useState(false);
|
|
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
|
const [isCheckingConfig, setIsCheckingConfig] = useState(false);
|
|
const [userChangedMode, setUserChangedMode] = useState(false);
|
|
|
|
const hasConfig = isCloudSyncEligible || hasSelfHostedConfig;
|
|
|
|
const checkSyncConfig = useCallback(async () => {
|
|
setIsCheckingConfig(true);
|
|
try {
|
|
const settings = await invoke<SyncSettings>("get_sync_settings");
|
|
setHasSelfHostedConfig(
|
|
Boolean(settings.sync_server_url && settings.sync_token),
|
|
);
|
|
const hasPassword = await invoke<boolean>("check_has_e2e_password");
|
|
setHasE2ePassword(hasPassword);
|
|
} catch {
|
|
setHasSelfHostedConfig(false);
|
|
} finally {
|
|
setIsCheckingConfig(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && profile) {
|
|
setSyncMode(profile.sync_mode ?? "Disabled");
|
|
setUserChangedMode(false);
|
|
void checkSyncConfig();
|
|
}
|
|
}, [isOpen, profile, checkSyncConfig]);
|
|
|
|
const handleOpenChange = useCallback(
|
|
(open: boolean) => {
|
|
if (!open) {
|
|
onClose();
|
|
}
|
|
},
|
|
[onClose],
|
|
);
|
|
|
|
const handleModeChange = useCallback(
|
|
async (newMode: string) => {
|
|
if (!profile) return;
|
|
|
|
if (!hasConfig) {
|
|
showErrorToast(t("sync.mode.noPasswordWarning"));
|
|
onSyncConfigOpen();
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
if (newMode === "Encrypted" && !canUseEncryption) {
|
|
showErrorToast(t("settings.encryption.requiresProOrOwner"));
|
|
return;
|
|
}
|
|
|
|
if (newMode === "Encrypted" && !hasE2ePassword) {
|
|
showErrorToast(t("sync.mode.passwordRequired"));
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
await invoke("set_profile_sync_mode", {
|
|
profileId: profile.id,
|
|
syncMode: newMode,
|
|
});
|
|
setSyncMode(newMode as SyncMode);
|
|
setUserChangedMode(true);
|
|
showSuccessToast(
|
|
newMode !== "Disabled"
|
|
? t("sync.mode.enabledToast")
|
|
: t("sync.mode.disabledToast"),
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to set sync mode:", error);
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
},
|
|
[
|
|
profile,
|
|
hasConfig,
|
|
hasE2ePassword,
|
|
canUseEncryption,
|
|
onSyncConfigOpen,
|
|
onClose,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const handleSyncNow = useCallback(async () => {
|
|
if (!profile) return;
|
|
|
|
if (!hasConfig) {
|
|
showErrorToast(t("sync.mode.noPasswordWarning"));
|
|
onSyncConfigOpen();
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
setIsSyncing(true);
|
|
try {
|
|
await invoke("request_profile_sync", { profileId: profile.id });
|
|
showSuccessToast(t("sync.mode.syncQueued"));
|
|
} catch (error) {
|
|
console.error("Failed to queue sync:", error);
|
|
showErrorToast(String(error));
|
|
} finally {
|
|
setIsSyncing(false);
|
|
}
|
|
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
|
|
|
|
const formatLastSync = (timestamp?: number) => {
|
|
if (!timestamp) return t("common.labels.never");
|
|
const date = new Date(timestamp * 1000);
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
if (!profile) return null;
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("sync.mode.description", {
|
|
name: profile.name,
|
|
defaultValue: `Manage sync settings for "${profile.name}"`,
|
|
})}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{isCheckingConfig ? (
|
|
<div className="flex justify-center py-8">
|
|
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 py-4">
|
|
{!hasConfig && (
|
|
<div className="p-3 text-sm rounded-md bg-muted">
|
|
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
onSyncConfigOpen();
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("sync.mode.configureService")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{hasConfig && (
|
|
<>
|
|
<RadioGroup
|
|
value={syncMode}
|
|
onValueChange={handleModeChange}
|
|
disabled={isSaving}
|
|
className="grid gap-3"
|
|
>
|
|
<div className="flex items-start gap-x-3">
|
|
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
|
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
|
<span className="font-medium">
|
|
{t("sync.mode.disabled")}
|
|
</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("sync.mode.disabledDescription")}
|
|
</p>
|
|
</Label>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-x-3">
|
|
<RadioGroupItem value="Regular" id="sync-regular" />
|
|
<Label htmlFor="sync-regular" className="cursor-pointer">
|
|
<span className="font-medium">
|
|
{t("sync.mode.regular")}
|
|
</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("sync.mode.regularDescription")}
|
|
</p>
|
|
</Label>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-x-3">
|
|
<RadioGroupItem
|
|
value="Encrypted"
|
|
id="sync-encrypted"
|
|
disabled={!canUseEncryption}
|
|
/>
|
|
<Label
|
|
htmlFor="sync-encrypted"
|
|
className={
|
|
canUseEncryption
|
|
? "cursor-pointer"
|
|
: "cursor-not-allowed opacity-50"
|
|
}
|
|
>
|
|
<span className="font-medium">
|
|
{t("sync.mode.encrypted")}
|
|
</span>
|
|
<p className="text-sm text-muted-foreground">
|
|
{canUseEncryption
|
|
? t("sync.mode.encryptedDescription")
|
|
: t("settings.encryption.requiresProOrOwner")}
|
|
</p>
|
|
</Label>
|
|
</div>
|
|
</RadioGroup>
|
|
|
|
{syncMode === "Encrypted" &&
|
|
!hasE2ePassword &&
|
|
userChangedMode && (
|
|
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
|
{t("sync.mode.noPasswordWarning")}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t("sync.mode.lastSynced")}</Label>
|
|
<div className="flex gap-2 items-center">
|
|
<Badge variant="outline">
|
|
{formatLastSync(profile.last_sync)}
|
|
</Badge>
|
|
{isSyncEnabled(profile) && (
|
|
<Badge
|
|
variant={profile.last_sync ? "default" : "secondary"}
|
|
>
|
|
{profile.last_sync
|
|
? t("common.status.synced")
|
|
: t("common.status.pending")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose}>
|
|
{t("common.buttons.close")}
|
|
</Button>
|
|
{hasConfig && isSyncEnabled(profile) && (
|
|
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
|
|
{t("sync.mode.syncNow")}
|
|
</LoadingButton>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|