chore: linting

This commit is contained in:
zhom
2026-01-03 14:26:44 +04:00
parent fba0e1ca71
commit 7544c11197
80 changed files with 15102 additions and 169 deletions
+53 -1
View File
@@ -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>
);
}
+191 -36
View File
@@ -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>
+11 -1
View File
@@ -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);
+120
View File
@@ -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);
+207
View File
@@ -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 &quot;{profile.name}&quot;
</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>
);
}
+213 -68
View File
@@ -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>
+214
View File
@@ -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>
);
}
+20
View File
@@ -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 {