Files
donutbrowser/src/components/sync-all-dialog.tsx
T
2026-07-16 23:56:59 +04:00

205 lines
6.2 KiB
TypeScript

"use client";
import { invoke } from "@tauri-apps/api/core";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FiWifi } from "react-icons/fi";
import { LuLayers, LuPuzzle, LuShield, LuUsers } from "react-icons/lu";
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 { MOTION_EASE_OUT } from "@/lib/motion";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
interface UnsyncedEntityCounts {
proxies: number;
groups: number;
vpns: number;
extensions: number;
extension_groups: number;
}
interface SyncAllDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const [counts, setCounts] = useState<UnsyncedEntityCounts | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isEnabling, setIsEnabling] = useState(false);
const loadCounts = useCallback(async () => {
setIsLoading(true);
try {
const result = await invoke<UnsyncedEntityCounts>(
"get_unsynced_entity_counts",
);
setCounts(result);
} catch (error) {
console.error("Failed to get unsynced entity counts:", error);
setCounts(null);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (isOpen) {
void loadCounts();
}
}, [isOpen, loadCounts]);
const handleEnableAll = useCallback(async () => {
setIsEnabling(true);
try {
await invoke("enable_sync_for_all_entities");
showSuccessToast(t("syncAll.success"));
onClose();
} catch (error) {
console.error("Failed to enable sync for all entities:", error);
showErrorToast(String(error));
} finally {
setIsEnabling(false);
}
}, [onClose, t]);
const items = useMemo(() => {
if (!counts) return [];
return [
{
key: "proxies",
count: counts.proxies,
label: t("syncAll.labels.proxies"),
Icon: FiWifi,
},
{
key: "vpns",
count: counts.vpns,
label: t("syncAll.labels.vpns"),
Icon: LuShield,
},
{
key: "groups",
count: counts.groups,
label: t("syncAll.labels.groups"),
Icon: LuUsers,
},
{
key: "extensions",
count: counts.extensions,
label: t("syncAll.labels.extensions"),
Icon: LuPuzzle,
},
{
key: "extensionGroups",
count: counts.extension_groups,
label: t("syncAll.labels.extensionGroups"),
Icon: LuLayers,
},
].filter((item) => item.count > 0);
}, [counts, t]);
const totalCount = items.reduce((sum, item) => sum + item.count, 0);
// Don't render anything when there's nothing to sync — the parent
// mounts this dialog eagerly after login, so silent-close is correct.
if (!isLoading && totalCount === 0) {
return null;
}
return (
<Dialog
open={isOpen && (isLoading || totalCount > 0)}
onOpenChange={onClose}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("syncAll.title")}</DialogTitle>
<DialogDescription>{t("syncAll.description")}</DialogDescription>
</DialogHeader>
<AnimatePresence initial={false} mode="wait">
{isLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: reduceMotion ? 0.15 : 0.12,
ease: MOTION_EASE_OUT,
}}
className="flex justify-center py-8"
>
<div className="size-6 animate-spin rounded-full border-2 border-current border-t-transparent" />
</motion.div>
) : (
<motion.div
key="results"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
}}
className="grid grid-cols-2 gap-2 py-2"
>
{items.map(({ key, count, label, Icon }, index) => (
<motion.div
key={key}
initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduceMotion ? 0.15 : 0.16,
delay: reduceMotion ? 0 : Math.min(index * 0.03, 0.12),
ease: MOTION_EASE_OUT,
}}
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">
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1 truncate text-sm font-medium">
{label}
</div>
<Badge
variant="secondary"
className="shrink-0 px-2 tabular-nums"
>
{count}
</Badge>
</motion.div>
))}
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={onClose} disabled={isEnabling}>
{t("syncAll.skip")}
</Button>
<LoadingButton
onClick={handleEnableAll}
isLoading={isEnabling}
disabled={isLoading}
>
{t("syncAll.enableAll")}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}