mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-31 04:19:29 +02:00
chore: linting
This commit is contained in:
+53
-1
@@ -15,9 +15,11 @@ import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||
import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
||||
import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
|
||||
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { useGroupEvents } from "@/hooks/use-group-events";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
@@ -26,7 +28,7 @@ import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { showErrorToast, showToast } from "@/lib/toast-utils";
|
||||
import { showErrorToast, showSuccessToast, showToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, CamoufoxConfig } from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
@@ -99,6 +101,10 @@ export default function Home() {
|
||||
const [showBulkDeleteConfirmation, setShowBulkDeleteConfirmation] =
|
||||
useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
|
||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
@@ -594,6 +600,34 @@ export default function Home() {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
}, []);
|
||||
|
||||
const handleOpenProfileSyncDialog = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForSync(profile);
|
||||
setProfileSyncDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleToggleProfileSync = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
try {
|
||||
await invoke("set_profile_sync_enabled", {
|
||||
profileId: profile.id,
|
||||
enabled: !profile.sync_enabled,
|
||||
});
|
||||
showSuccessToast(
|
||||
profile.sync_enabled ? "Sync disabled" : "Sync enabled",
|
||||
{
|
||||
description: profile.sync_enabled
|
||||
? "Profile sync has been disabled"
|
||||
: "Profile sync has been enabled",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast("Failed to update sync settings");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for startup default browser prompt
|
||||
void checkStartupPrompt();
|
||||
@@ -726,6 +760,7 @@ export default function Home() {
|
||||
onImportProfileDialogOpen={setImportProfileDialogOpen}
|
||||
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
|
||||
onSettingsDialogOpen={setSettingsDialogOpen}
|
||||
onSyncConfigDialogOpen={setSyncConfigDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
/>
|
||||
@@ -754,6 +789,8 @@ export default function Home() {
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onBulkProxyAssignment={handleBulkProxyAssignment}
|
||||
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
|
||||
onToggleProfileSync={handleToggleProfileSync}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -878,6 +915,21 @@ export default function Home() {
|
||||
profileIds={selectedProfiles}
|
||||
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
|
||||
/>
|
||||
|
||||
<SyncConfigDialog
|
||||
isOpen={syncConfigDialogOpen}
|
||||
onClose={() => setSyncConfigDialogOpen(false)}
|
||||
/>
|
||||
|
||||
<ProfileSyncDialog
|
||||
isOpen={profileSyncDialogOpen}
|
||||
onClose={() => {
|
||||
setProfileSyncDialogOpen(false);
|
||||
setCurrentProfileForSync(null);
|
||||
}}
|
||||
profile={currentProfileForSync}
|
||||
onSyncConfigOpen={() => setSyncConfigDialogOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
@@ -9,6 +10,7 @@ import { DeleteGroupDialog } from "@/components/delete-group-dialog";
|
||||
import { EditGroupDialog } from "@/components/edit-group-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,9 +34,42 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { GroupWithCount, ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
|
||||
function getSyncStatusDot(
|
||||
group: GroupWithCount,
|
||||
liveStatus: SyncStatus | undefined,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-green-500",
|
||||
tooltip: group.last_sync
|
||||
? `Synced ${new Date(group.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-yellow-500",
|
||||
tooltip: "Waiting to sync",
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
|
||||
default:
|
||||
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
|
||||
}
|
||||
}
|
||||
|
||||
interface GroupManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -57,6 +92,36 @@ export function GroupManagementDialog({
|
||||
const [selectedGroup, setSelectedGroup] = useState<GroupWithCount | null>(
|
||||
null,
|
||||
);
|
||||
const [groupSyncStatus, setGroupSyncStatus] = useState<
|
||||
Record<string, SyncStatus>
|
||||
>({});
|
||||
const [groupInUse, setGroupInUse] = useState<Record<string, boolean>>({});
|
||||
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Listen for group sync status events
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
"group-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
setGroupSyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
void setupListener();
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -66,6 +131,21 @@ export function GroupManagementDialog({
|
||||
"get_groups_with_profile_counts",
|
||||
);
|
||||
setGroups(groupList);
|
||||
|
||||
// Check which groups are in use by synced profiles
|
||||
const inUse: Record<string, boolean> = {};
|
||||
for (const group of groupList) {
|
||||
try {
|
||||
const inUseBySynced = await invoke<boolean>(
|
||||
"is_group_in_use_by_synced_profile",
|
||||
{ groupId: group.id },
|
||||
);
|
||||
inUse[group.id] = inUseBySynced;
|
||||
} catch (_error) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
setGroupInUse(inUse);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
@@ -105,6 +185,28 @@ export function GroupManagementDialog({
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleToggleSync = useCallback(
|
||||
async (group: GroupWithCount) => {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [group.id]: true }));
|
||||
try {
|
||||
await invoke("set_group_sync_enabled", {
|
||||
groupId: group.id,
|
||||
enabled: !group.sync_enabled,
|
||||
});
|
||||
showSuccessToast(group.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await loadGroups();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [group.id]: false }));
|
||||
}
|
||||
},
|
||||
[loadGroups],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadGroups();
|
||||
@@ -161,52 +263,105 @@ export function GroupManagementDialog({
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
{group.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{groups.map((group) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate ? "animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{group.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={group.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(group)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[group.id] ||
|
||||
groupInUse[group.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
{groupInUse[group.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { LuSearch, LuUsers, LuX } from "react-icons/lu";
|
||||
import { LuCloud, LuSearch, LuUsers, LuX } from "react-icons/lu";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Button } from "./ui/button";
|
||||
import { CardTitle } from "./ui/card";
|
||||
@@ -20,6 +20,7 @@ type Props = {
|
||||
onGroupManagementDialogOpen: (open: boolean) => void;
|
||||
onImportProfileDialogOpen: (open: boolean) => void;
|
||||
onCreateProfileDialogOpen: (open: boolean) => void;
|
||||
onSyncConfigDialogOpen: (open: boolean) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
};
|
||||
@@ -30,6 +31,7 @@ const HomeHeader = ({
|
||||
onGroupManagementDialogOpen,
|
||||
onImportProfileDialogOpen,
|
||||
onCreateProfileDialogOpen,
|
||||
onSyncConfigDialogOpen,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
}: Props) => {
|
||||
@@ -118,6 +120,14 @@ const HomeHeader = ({
|
||||
<LuUsers className="mr-2 w-4 h-4" />
|
||||
Groups
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onSyncConfigDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuCloud className="mr-2 w-4 h-4" />
|
||||
Sync Service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onImportProfileDialogOpen(true);
|
||||
|
||||
@@ -161,6 +161,11 @@ type TableMeta = {
|
||||
// Traffic snapshots (lightweight real-time data)
|
||||
trafficSnapshots: Record<string, TrafficSnapshot>;
|
||||
onOpenTrafficDialog?: (profileId: string) => void;
|
||||
|
||||
// Sync
|
||||
syncStatuses: Record<string, string>;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
};
|
||||
|
||||
const TagsCell = React.memo<{
|
||||
@@ -677,6 +682,8 @@ interface ProfilesDataTableProps {
|
||||
onBulkDelete?: () => void;
|
||||
onBulkGroupAssignment?: () => void;
|
||||
onBulkProxyAssignment?: () => void;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -694,6 +701,8 @@ export function ProfilesDataTable({
|
||||
onBulkDelete,
|
||||
onBulkGroupAssignment,
|
||||
onBulkProxyAssignment,
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
@@ -799,6 +808,9 @@ export function ProfilesDataTable({
|
||||
id: string;
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
const [syncStatuses, setSyncStatuses] = React.useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
// Load cached check results for proxies
|
||||
React.useEffect(() => {
|
||||
@@ -867,6 +879,28 @@ export function ProfilesDataTable({
|
||||
stoppingProfiles,
|
||||
);
|
||||
|
||||
// Listen for sync status events
|
||||
React.useEffect(() => {
|
||||
if (!browserState.isClient) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen<{ profile_id: string; status: string }>(
|
||||
"profile-sync-status",
|
||||
(event) => {
|
||||
const { profile_id, status } = event.payload;
|
||||
setSyncStatuses((prev) => ({ ...prev, [profile_id]: status }));
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync status events:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [browserState.isClient]);
|
||||
|
||||
// Fetch traffic snapshots for running profiles (lightweight, real-time data)
|
||||
// Convert Set to sorted array to avoid Set reference comparison issues in dependencies
|
||||
const runningProfileIds = React.useMemo(
|
||||
@@ -1275,6 +1309,11 @@ export function ProfilesDataTable({
|
||||
const profile = profiles.find((p) => p.id === profileId);
|
||||
setTrafficDialogProfile({ id: profileId, name: profile?.name });
|
||||
},
|
||||
|
||||
// Sync
|
||||
syncStatuses,
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
}),
|
||||
[
|
||||
selectedProfiles,
|
||||
@@ -1311,6 +1350,9 @@ export function ProfilesDataTable({
|
||||
onLaunchProfile,
|
||||
onAssignProfilesToGroup,
|
||||
onConfigureCamoufox,
|
||||
syncStatuses,
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1855,6 +1897,65 @@ export function ProfilesDataTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
header: "",
|
||||
size: 24,
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
|
||||
if (!profile.sync_enabled) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-3 h-3">
|
||||
<span className="w-2 h-2 rounded-full bg-muted-foreground/30" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Sync disabled</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const syncStatus = meta.syncStatuses[profile.id];
|
||||
const isSyncing = syncStatus === "syncing";
|
||||
const isWaiting = syncStatus === "waiting";
|
||||
const isSynced =
|
||||
syncStatus === "synced" || (!syncStatus && profile.last_sync);
|
||||
const isError = syncStatus === "error";
|
||||
|
||||
let dotClass = "bg-yellow-500";
|
||||
let tooltipText = "Sync pending";
|
||||
|
||||
if (isSyncing) {
|
||||
dotClass = "bg-yellow-500 animate-pulse";
|
||||
tooltipText = "Syncing...";
|
||||
} else if (isWaiting) {
|
||||
dotClass = "bg-yellow-500";
|
||||
tooltipText = "Waiting for profile to stop";
|
||||
} else if (isError) {
|
||||
dotClass = "bg-red-500";
|
||||
tooltipText = "Sync error";
|
||||
} else if (isSynced) {
|
||||
dotClass = "bg-green-500";
|
||||
tooltipText = profile.last_sync
|
||||
? `Last synced: ${new Date(profile.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced";
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-3 h-3">
|
||||
<span className={`w-2 h-2 rounded-full ${dotClass}`} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
cell: ({ row, table }) => {
|
||||
@@ -1908,6 +2009,25 @@ export function ProfilesDataTable({
|
||||
Change Fingerprint
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.onOpenProfileSyncDialog && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onOpenProfileSyncDialog?.(profile);
|
||||
}}
|
||||
>
|
||||
Sync Settings
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.onToggleProfileSync && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onToggleProfileSync?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, SyncSettings } from "@/types";
|
||||
|
||||
interface ProfileSyncDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
onSyncConfigOpen: () => void;
|
||||
}
|
||||
|
||||
export function ProfileSyncDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
onSyncConfigOpen,
|
||||
}: ProfileSyncDialogProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncEnabled, setSyncEnabled] = useState(
|
||||
profile?.sync_enabled ?? false,
|
||||
);
|
||||
const [hasConfig, setHasConfig] = useState(false);
|
||||
const [isCheckingConfig, setIsCheckingConfig] = useState(false);
|
||||
|
||||
const checkSyncConfig = useCallback(async () => {
|
||||
setIsCheckingConfig(true);
|
||||
try {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
setHasConfig(Boolean(settings.sync_server_url && settings.sync_token));
|
||||
} catch {
|
||||
setHasConfig(false);
|
||||
} finally {
|
||||
setIsCheckingConfig(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open && profile) {
|
||||
setSyncEnabled(profile.sync_enabled ?? false);
|
||||
void checkSyncConfig();
|
||||
}
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[profile, onClose, checkSyncConfig],
|
||||
);
|
||||
|
||||
const handleToggleSync = useCallback(async () => {
|
||||
if (!profile) return;
|
||||
|
||||
if (!hasConfig) {
|
||||
showErrorToast("Please configure sync service first");
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("set_profile_sync_enabled", {
|
||||
profileId: profile.id,
|
||||
enabled: !syncEnabled,
|
||||
});
|
||||
setSyncEnabled(!syncEnabled);
|
||||
showSuccessToast(
|
||||
!syncEnabled ? "Sync enabled - syncing now..." : "Sync disabled",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast("Failed to update sync settings");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [profile, syncEnabled, hasConfig, onSyncConfigOpen, onClose]);
|
||||
|
||||
const handleSyncNow = useCallback(async () => {
|
||||
if (!profile) return;
|
||||
|
||||
if (!hasConfig) {
|
||||
showErrorToast("Please configure sync service first");
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
await invoke("request_profile_sync", { profileId: profile.id });
|
||||
showSuccessToast("Sync queued");
|
||||
} catch (error) {
|
||||
console.error("Failed to queue sync:", error);
|
||||
showErrorToast("Failed to queue sync");
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, [profile, hasConfig, onSyncConfigOpen, onClose]);
|
||||
|
||||
const formatLastSync = (timestamp?: number) => {
|
||||
if (!timestamp) return "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>Profile Sync</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage sync settings for "{profile.name}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isCheckingConfig ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-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">Sync service not configured.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Configure Sync Service
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasConfig && (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sync-enabled">Sync Enabled</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sync this profile across devices
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="sync-enabled"
|
||||
checked={syncEnabled}
|
||||
onCheckedChange={handleToggleSync}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Last Synced</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant="outline">
|
||||
{formatLastSync(profile.last_sync)}
|
||||
</Badge>
|
||||
{syncEnabled && (
|
||||
<Badge
|
||||
variant={profile.last_sync ? "default" : "secondary"}
|
||||
>
|
||||
{profile.last_sync ? "Synced" : "Pending"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{hasConfig && syncEnabled && (
|
||||
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
|
||||
Sync Now
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import * as React from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
@@ -11,6 +10,7 @@ import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialo
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -35,10 +35,43 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { ProxyCheckResult, StoredProxy } from "@/types";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
|
||||
function getSyncStatusDot(
|
||||
proxy: StoredProxy,
|
||||
liveStatus: SyncStatus | undefined,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (proxy.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-green-500",
|
||||
tooltip: proxy.last_sync
|
||||
? `Synced ${new Date(proxy.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
animate: false,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-yellow-500",
|
||||
tooltip: "Waiting to sync",
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
|
||||
default:
|
||||
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
|
||||
}
|
||||
}
|
||||
|
||||
interface ProxyManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -56,13 +89,44 @@ export function ProxyManagementDialog({
|
||||
const [proxyCheckResults, setProxyCheckResults] = useState<
|
||||
Record<string, ProxyCheckResult>
|
||||
>({});
|
||||
const [proxySyncStatus, setProxySyncStatus] = useState<
|
||||
Record<string, SyncStatus>
|
||||
>({});
|
||||
const [proxyInUse, setProxyInUse] = useState<Record<string, boolean>>({});
|
||||
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const { storedProxies, proxyUsage, isLoading } = useProxyEvents();
|
||||
|
||||
// Listen for proxy sync status events
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
"proxy-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
setProxySyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
void setupListener();
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load cached check results on mount and when proxies change
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const loadCachedResults = async () => {
|
||||
const results: Record<string, ProxyCheckResult> = {};
|
||||
const inUse: Record<string, boolean> = {};
|
||||
for (const proxy of storedProxies) {
|
||||
try {
|
||||
const cached = await invoke<ProxyCheckResult | null>(
|
||||
@@ -72,11 +136,18 @@ export function ProxyManagementDialog({
|
||||
if (cached) {
|
||||
results[proxy.id] = cached;
|
||||
}
|
||||
|
||||
const inUseBySynced = await invoke<boolean>(
|
||||
"is_proxy_in_use_by_synced_profile",
|
||||
{ proxyId: proxy.id },
|
||||
);
|
||||
inUse[proxy.id] = inUseBySynced;
|
||||
} catch (_error) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
setProxyCheckResults(results);
|
||||
setProxyInUse(inUse);
|
||||
};
|
||||
if (storedProxies.length > 0) {
|
||||
void loadCachedResults();
|
||||
@@ -119,6 +190,25 @@ export function ProxyManagementDialog({
|
||||
setEditingProxy(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleSync = useCallback(async (proxy: StoredProxy) => {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: true }));
|
||||
try {
|
||||
await invoke("set_proxy_sync_enabled", {
|
||||
proxyId: proxy.id,
|
||||
enabled: !proxy.sync_enabled,
|
||||
});
|
||||
showSuccessToast(proxy.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingSync((prev) => ({ ...prev, [proxy.id]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -162,84 +252,139 @@ export function ProxyManagementDialog({
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
{proxy.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ProxyCheckButton
|
||||
proxy={proxy}
|
||||
profileId={proxy.id}
|
||||
checkingProfileId={checkingProxyId}
|
||||
cachedResult={proxyCheckResults[proxy.id]}
|
||||
setCheckingProfileId={setCheckingProxyId}
|
||||
onCheckComplete={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
onCheckFailed={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{storedProxies.map((proxy) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate ? "animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{proxy.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditProxy(proxy)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
proxyInUse[proxy.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteProxy(proxy)}
|
||||
disabled={(proxyUsage[proxy.id] ?? 0) > 0}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1 ? "s" : ""}
|
||||
Sync cannot be disabled while this proxy
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ProxyCheckButton
|
||||
proxy={proxy}
|
||||
profileId={proxy.id}
|
||||
checkingProfileId={checkingProxyId}
|
||||
cachedResult={proxyCheckResults[proxy.id]}
|
||||
setCheckingProfileId={setCheckingProxyId}
|
||||
onCheckComplete={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
onCheckFailed={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditProxy(proxy)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteProxy(proxy)}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1 ? "s" : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
|
||||
interface SyncConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
setServerUrl(settings.sync_server_url || "");
|
||||
setToken(settings.sync_token || "");
|
||||
} catch (error) {
|
||||
console.error("Failed to load sync settings:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSettings();
|
||||
}
|
||||
}, [isOpen, loadSettings]);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!serverUrl) {
|
||||
showErrorToast("Please enter a server URL");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
|
||||
const response = await fetch(healthUrl);
|
||||
if (response.ok) {
|
||||
showSuccessToast("Connection successful!");
|
||||
} else {
|
||||
showErrorToast("Server responded with an error");
|
||||
}
|
||||
} catch {
|
||||
showErrorToast("Failed to connect to server");
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}, [serverUrl]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke<SyncSettings>("save_sync_settings", {
|
||||
syncServerUrl: serverUrl || null,
|
||||
syncToken: token || null,
|
||||
});
|
||||
showSuccessToast("Sync settings saved");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save sync settings:", error);
|
||||
showErrorToast("Failed to save settings");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [serverUrl, token, onClose]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke<SyncSettings>("save_sync_settings", {
|
||||
syncServerUrl: null,
|
||||
syncToken: null,
|
||||
});
|
||||
setServerUrl("");
|
||||
setToken("");
|
||||
showSuccessToast("Sync disconnected");
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
showErrorToast("Failed to disconnect");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isConnected = Boolean(serverUrl && token);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sync Service</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure connection to a sync server to synchronize your profiles
|
||||
across devices.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sync-server-url">Server URL</Label>
|
||||
<Input
|
||||
id="sync-server-url"
|
||||
placeholder="https://sync.example.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sync-token">Access Token</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="sync-token"
|
||||
type={showToken ? "text" : "password"}
|
||||
placeholder="Enter your sync token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-3 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
aria-label={showToken ? "Hide token" : "Show token"}
|
||||
>
|
||||
{showToken ? (
|
||||
<LuEyeOff className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
) : (
|
||||
<LuEye className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showToken ? "Hide token" : "Show token"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
Connected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
{isConnected && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting || !serverUrl}
|
||||
>
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleSave}
|
||||
isLoading={isSaving}
|
||||
disabled={!serverUrl || !token}
|
||||
>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,20 @@ export interface BrowserProfile {
|
||||
group_id?: string; // Reference to profile group
|
||||
tags?: string[];
|
||||
note?: string; // User note
|
||||
sync_enabled?: boolean; // Whether sync is enabled for this profile
|
||||
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
|
||||
}
|
||||
|
||||
export type SyncStatus = "Disabled" | "Syncing" | "Synced" | "Error";
|
||||
|
||||
export interface SyncSettings {
|
||||
sync_server_url?: string;
|
||||
sync_token?: string;
|
||||
}
|
||||
|
||||
export interface ProfileSyncStatusEvent {
|
||||
profile_id: string;
|
||||
status: "disabled" | "syncing" | "synced" | "error" | "pending";
|
||||
}
|
||||
|
||||
export interface ProxyCheckResult {
|
||||
@@ -39,17 +53,23 @@ export interface StoredProxy {
|
||||
id: string;
|
||||
name: string;
|
||||
proxy_settings: ProxySettings;
|
||||
sync_enabled?: boolean;
|
||||
last_sync?: number;
|
||||
}
|
||||
|
||||
export interface ProfileGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
sync_enabled?: boolean;
|
||||
last_sync?: number;
|
||||
}
|
||||
|
||||
export interface GroupWithCount {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
sync_enabled?: boolean;
|
||||
last_sync?: number;
|
||||
}
|
||||
|
||||
export interface DetectedProfile {
|
||||
|
||||
Reference in New Issue
Block a user