mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-25 05:16:18 +02:00
2733 lines
92 KiB
TypeScript
2733 lines
92 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
type ColumnDef,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getSortedRowModel,
|
|
type RowSelectionState,
|
|
type SortingState,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { emit, listen } from "@tauri-apps/api/event";
|
|
import type { Dispatch, SetStateAction } from "react";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
|
import { FiWifi } from "react-icons/fi";
|
|
import {
|
|
LuCheck,
|
|
LuChevronDown,
|
|
LuChevronUp,
|
|
LuCookie,
|
|
LuInfo,
|
|
LuLock,
|
|
LuPuzzle,
|
|
LuTrash2,
|
|
LuTriangleAlert,
|
|
LuUsers,
|
|
} from "react-icons/lu";
|
|
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
|
import {
|
|
ProfileBypassRulesDialog,
|
|
ProfileInfoDialog,
|
|
} from "@/components/profile-info-dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import { useBrowserState } from "@/hooks/use-browser-state";
|
|
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
|
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
|
import { useTableSorting } from "@/hooks/use-table-sorting";
|
|
import { useTeamLocks } from "@/hooks/use-team-locks";
|
|
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
|
import {
|
|
getBrowserDisplayName,
|
|
getCurrentOS,
|
|
getOSDisplayName,
|
|
getProfileIcon,
|
|
isCrossOsProfile,
|
|
} from "@/lib/browser-utils";
|
|
import { formatRelativeTime } from "@/lib/flag-utils";
|
|
import { trimName } from "@/lib/name-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import type {
|
|
BrowserProfile,
|
|
LocationItem,
|
|
ProxyCheckResult,
|
|
StoredProxy,
|
|
SyncSessionInfo,
|
|
TrafficSnapshot,
|
|
VpnConfig,
|
|
} from "@/types";
|
|
import { BandwidthMiniChart } from "./bandwidth-mini-chart";
|
|
import {
|
|
DataTableActionBar,
|
|
DataTableActionBarAction,
|
|
DataTableActionBarSelection,
|
|
} from "./data-table-action-bar";
|
|
import MultipleSelector, { type Option } from "./multiple-selector";
|
|
import { ProxyCheckButton } from "./proxy-check-button";
|
|
import { TrafficDetailsDialog } from "./traffic-details-dialog";
|
|
import { Input } from "./ui/input";
|
|
import { RippleButton } from "./ui/ripple";
|
|
|
|
// Stable table meta type to pass volatile state/handlers into TanStack Table without
|
|
// causing column definitions to be recreated on every render.
|
|
type TableMeta = {
|
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
selectedProfiles: string[];
|
|
selectableCount: number;
|
|
showCheckboxes: boolean;
|
|
isClient: boolean;
|
|
runningProfiles: Set<string>;
|
|
launchingProfiles: Set<string>;
|
|
stoppingProfiles: Set<string>;
|
|
isUpdating: (browser: string) => boolean;
|
|
browserState: ReturnType<typeof useBrowserState>;
|
|
|
|
// Tags editor state
|
|
tagsOverrides: Record<string, string[]>;
|
|
allTags: string[];
|
|
openTagsEditorFor: string | null;
|
|
setAllTags: React.Dispatch<React.SetStateAction<string[]>>;
|
|
setOpenTagsEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
|
|
setTagsOverrides: React.Dispatch<
|
|
React.SetStateAction<Record<string, string[]>>
|
|
>;
|
|
|
|
// Note editor state
|
|
noteOverrides: Record<string, string | null>;
|
|
openNoteEditorFor: string | null;
|
|
setOpenNoteEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
|
|
setNoteOverrides: React.Dispatch<
|
|
React.SetStateAction<Record<string, string | null>>
|
|
>;
|
|
|
|
// Proxy selector state
|
|
openProxySelectorFor: string | null;
|
|
setOpenProxySelectorFor: React.Dispatch<React.SetStateAction<string | null>>;
|
|
proxyOverrides: Record<string, string | null>;
|
|
storedProxies: StoredProxy[];
|
|
handleProxySelection: (
|
|
profileId: string,
|
|
proxyId: string | null,
|
|
) => void | Promise<void>;
|
|
checkingProfileId: string | null;
|
|
proxyCheckResults: Record<string, ProxyCheckResult>;
|
|
|
|
// VPN selector state
|
|
vpnConfigs: VpnConfig[];
|
|
vpnOverrides: Record<string, string | null>;
|
|
handleVpnSelection: (
|
|
profileId: string,
|
|
vpnId: string | null,
|
|
) => void | Promise<void>;
|
|
|
|
// Selection helpers
|
|
isProfileSelected: (id: string) => boolean;
|
|
handleToggleAll: (checked: boolean) => void;
|
|
handleCheckboxChange: (id: string, checked: boolean) => void;
|
|
handleIconClick: (id: string) => void;
|
|
|
|
// Rename helpers
|
|
handleRename: () => void | Promise<void>;
|
|
setProfileToRename: React.Dispatch<
|
|
React.SetStateAction<BrowserProfile | null>
|
|
>;
|
|
setNewProfileName: React.Dispatch<React.SetStateAction<string>>;
|
|
setRenameError: React.Dispatch<React.SetStateAction<string | null>>;
|
|
profileToRename: BrowserProfile | null;
|
|
newProfileName: string;
|
|
isRenamingSaving: boolean;
|
|
renameError: string | null;
|
|
|
|
// Launch/stop helpers
|
|
setLaunchingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
setStoppingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
|
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
|
|
|
// Overflow actions
|
|
onAssignProfilesToGroup?: (profileIds: string[]) => void;
|
|
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
|
onCloneProfile?: (profile: BrowserProfile) => void;
|
|
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
|
onOpenCookieManagement?: (profile: BrowserProfile) => void;
|
|
|
|
// Traffic snapshots (lightweight real-time data)
|
|
trafficSnapshots: Record<string, TrafficSnapshot>;
|
|
onOpenTrafficDialog?: (profileId: string) => void;
|
|
|
|
// Sync
|
|
syncStatuses: Record<string, { status: string; error?: string }>;
|
|
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
|
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
|
crossOsUnlocked?: boolean;
|
|
syncUnlocked?: boolean;
|
|
|
|
// Country proxy creation (inline in proxy dropdown)
|
|
countries: LocationItem[];
|
|
canCreateLocationProxy: boolean;
|
|
loadCountries: () => Promise<void>;
|
|
handleCreateCountryProxy: (
|
|
profileId: string,
|
|
country: LocationItem,
|
|
) => Promise<void>;
|
|
|
|
// Team locks
|
|
isProfileLockedByAnother: (profileId: string) => boolean;
|
|
getProfileLockEmail: (profileId: string) => string | undefined;
|
|
|
|
// Synchronizer
|
|
getProfileSyncInfo: (profileId: string) =>
|
|
| {
|
|
session: SyncSessionInfo;
|
|
isLeader: boolean;
|
|
failedAtUrl: string | null;
|
|
}
|
|
| undefined;
|
|
onLaunchWithSync: (profile: BrowserProfile) => void;
|
|
};
|
|
|
|
type SyncStatusDot = {
|
|
color: string;
|
|
tooltip: string;
|
|
animate: boolean;
|
|
encrypted: boolean;
|
|
};
|
|
|
|
function getProfileSyncStatusDot(
|
|
profile: BrowserProfile,
|
|
liveStatus:
|
|
| "syncing"
|
|
| "waiting"
|
|
| "synced"
|
|
| "error"
|
|
| "disabled"
|
|
| undefined,
|
|
errorMessage?: string,
|
|
): SyncStatusDot | null {
|
|
const encrypted = profile.sync_mode === "Encrypted";
|
|
const status =
|
|
liveStatus ??
|
|
(profile.sync_mode && profile.sync_mode !== "Disabled"
|
|
? "synced"
|
|
: "disabled");
|
|
|
|
switch (status) {
|
|
case "syncing":
|
|
return {
|
|
color: "bg-warning",
|
|
tooltip: "Syncing...",
|
|
animate: true,
|
|
encrypted,
|
|
};
|
|
case "waiting":
|
|
return {
|
|
color: "bg-warning",
|
|
tooltip: "Close the profile to sync",
|
|
animate: false,
|
|
encrypted,
|
|
};
|
|
case "synced":
|
|
return {
|
|
color: "bg-success",
|
|
tooltip: profile.last_sync
|
|
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
|
|
: "Synced",
|
|
animate: false,
|
|
encrypted,
|
|
};
|
|
case "error":
|
|
return {
|
|
color: "bg-destructive",
|
|
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
|
animate: false,
|
|
encrypted,
|
|
};
|
|
case "disabled":
|
|
if (profile.last_sync) {
|
|
return {
|
|
color: "bg-muted-foreground",
|
|
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
|
|
animate: false,
|
|
encrypted: false,
|
|
};
|
|
}
|
|
return null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const TagsCell = React.memo<{
|
|
profile: BrowserProfile;
|
|
isDisabled: boolean;
|
|
tagsOverrides: Record<string, string[]>;
|
|
allTags: string[];
|
|
setAllTags: React.Dispatch<React.SetStateAction<string[]>>;
|
|
openTagsEditorFor: string | null;
|
|
setOpenTagsEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
|
|
setTagsOverrides: React.Dispatch<
|
|
React.SetStateAction<Record<string, string[]>>
|
|
>;
|
|
}>(
|
|
({
|
|
profile,
|
|
isDisabled,
|
|
tagsOverrides,
|
|
allTags,
|
|
setAllTags,
|
|
openTagsEditorFor,
|
|
setOpenTagsEditorFor,
|
|
setTagsOverrides,
|
|
}) => {
|
|
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.id)
|
|
? tagsOverrides[profile.id]
|
|
: (profile.tags ?? []);
|
|
|
|
const valueOptions: Option[] = React.useMemo(
|
|
() => effectiveTags.map((t) => ({ value: t, label: t })),
|
|
[effectiveTags],
|
|
);
|
|
const allOptions: Option[] = React.useMemo(
|
|
() => allTags.map((t) => ({ value: t, label: t })),
|
|
[allTags],
|
|
);
|
|
|
|
const onTagsChange = React.useCallback(
|
|
async (newTagsRaw: string[]) => {
|
|
// Dedupe tags
|
|
const seen = new Set<string>();
|
|
const newTags: string[] = [];
|
|
for (const t of newTagsRaw) {
|
|
if (!seen.has(t)) {
|
|
seen.add(t);
|
|
newTags.push(t);
|
|
}
|
|
}
|
|
setTagsOverrides((prev) => ({ ...prev, [profile.id]: newTags }));
|
|
try {
|
|
await invoke<BrowserProfile>("update_profile_tags", {
|
|
profileId: profile.id,
|
|
tags: newTags,
|
|
});
|
|
setAllTags((prev) => {
|
|
const next = new Set(prev);
|
|
for (const t of newTags) next.add(t);
|
|
return Array.from(next).sort();
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to update tags:", error);
|
|
}
|
|
},
|
|
[profile.id, setTagsOverrides, setAllTags],
|
|
);
|
|
|
|
const handleChange = React.useCallback(
|
|
async (opts: Option[]) => {
|
|
const newTagsRaw = opts.map((o) => o.value);
|
|
await onTagsChange(newTagsRaw);
|
|
},
|
|
[onTagsChange],
|
|
);
|
|
|
|
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
|
const editorRef = React.useRef<HTMLDivElement | null>(null);
|
|
const [visibleCount, setVisibleCount] = React.useState<number>(
|
|
effectiveTags.length,
|
|
);
|
|
const [isFocused, setIsFocused] = React.useState(false);
|
|
|
|
React.useLayoutEffect(() => {
|
|
// Only measure when not editing this profile's tags
|
|
if (openTagsEditorFor === profile.id) return;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
let timeoutId: number | undefined;
|
|
const compute = () => {
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
timeoutId = window.setTimeout(() => {
|
|
const available = container.clientWidth;
|
|
if (available <= 0) return;
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
const style = window.getComputedStyle(container);
|
|
const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
ctx.font = font;
|
|
const padding = 16;
|
|
const gap = 4;
|
|
let used = 0;
|
|
let count = 0;
|
|
for (let i = 0; i < effectiveTags.length; i++) {
|
|
const text = effectiveTags[i];
|
|
const width = Math.ceil(ctx.measureText(text).width) + padding;
|
|
const remaining = effectiveTags.length - (i + 1);
|
|
let extra = 0;
|
|
if (remaining > 0) {
|
|
const plusText = `+${remaining}`;
|
|
extra = Math.ceil(ctx.measureText(plusText).width) + padding;
|
|
}
|
|
const nextUsed =
|
|
used +
|
|
(used > 0 ? gap : 0) +
|
|
width +
|
|
(remaining > 0 ? gap + extra : 0);
|
|
if (nextUsed <= available) {
|
|
used += (used > 0 ? gap : 0) + width;
|
|
count = i + 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
setVisibleCount(count);
|
|
}, 16); // Debounce with RAF timing
|
|
};
|
|
compute();
|
|
const ro = new ResizeObserver(compute);
|
|
ro.observe(container);
|
|
return () => {
|
|
ro.disconnect();
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
};
|
|
}, [effectiveTags, openTagsEditorFor, profile.id]);
|
|
|
|
React.useEffect(() => {
|
|
if (openTagsEditorFor !== profile.id) return;
|
|
const handleClick = (e: MouseEvent) => {
|
|
const target = e.target as Node | null;
|
|
if (
|
|
editorRef.current &&
|
|
target &&
|
|
!editorRef.current.contains(target)
|
|
) {
|
|
setOpenTagsEditorFor(null);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [openTagsEditorFor, profile.id, setOpenTagsEditorFor]);
|
|
|
|
React.useEffect(() => {
|
|
if (openTagsEditorFor === profile.id && editorRef.current) {
|
|
// Focus the inner input of MultipleSelector on open
|
|
const inputEl = editorRef.current.querySelector("input");
|
|
if (inputEl) {
|
|
(inputEl as HTMLInputElement).focus();
|
|
}
|
|
}
|
|
}, [openTagsEditorFor, profile.id]);
|
|
|
|
if (openTagsEditorFor !== profile.id) {
|
|
const hiddenCount = Math.max(0, effectiveTags.length - visibleCount);
|
|
const ButtonContent = (
|
|
<button
|
|
type="button"
|
|
ref={containerRef as unknown as React.RefObject<HTMLButtonElement>}
|
|
className={cn(
|
|
"flex overflow-hidden gap-1 items-center px-2 py-1 h-6 w-full bg-transparent rounded border-none cursor-pointer",
|
|
isDisabled
|
|
? "opacity-60 cursor-not-allowed"
|
|
: "cursor-pointer hover:bg-accent/50",
|
|
)}
|
|
onClick={() => {
|
|
if (!isDisabled) setOpenTagsEditorFor(profile.id);
|
|
}}
|
|
>
|
|
{effectiveTags.slice(0, visibleCount).map((t) => (
|
|
<Badge key={t} variant="secondary" className="px-2 py-0 text-xs">
|
|
{t}
|
|
</Badge>
|
|
))}
|
|
{effectiveTags.length === 0 && (
|
|
<span className="text-muted-foreground">No tags</span>
|
|
)}
|
|
{hiddenCount > 0 && (
|
|
<Badge variant="outline" className="px-2 py-0 text-xs">
|
|
+{hiddenCount}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div className="w-40 h-6 cursor-pointer">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
|
|
{hiddenCount > 0 && (
|
|
<TooltipContent className="max-w-[320px]">
|
|
<div className="flex flex-wrap gap-1">
|
|
{effectiveTags.map((t) => (
|
|
<Badge
|
|
key={t}
|
|
variant="secondary"
|
|
className="px-2 py-0 text-xs"
|
|
>
|
|
{t}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"w-40 h-6 relative",
|
|
isDisabled && "opacity-60 pointer-events-none",
|
|
)}
|
|
>
|
|
<div
|
|
ref={editorRef}
|
|
className="absolute top-0 left-0 z-50 w-40 min-h-6 bg-popover rounded-md shadow-md"
|
|
>
|
|
<MultipleSelector
|
|
value={valueOptions}
|
|
options={allOptions}
|
|
onChange={(opts) => void handleChange(opts)}
|
|
creatable
|
|
selectFirstItem={false}
|
|
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
|
|
className={cn(
|
|
"bg-transparent border-0! focus-within:ring-0!",
|
|
"[&_div:first-child]:border-0! [&_div:first-child]:ring-0! [&_div:first-child]:focus-within:ring-0!",
|
|
"[&_div:first-child]:min-h-6! [&_div:first-child]:px-2! [&_div:first-child]:py-1!",
|
|
"[&_div:first-child>div]:items-center [&_div:first-child>div]:h-6!",
|
|
"[&_input]:ml-0! [&_input]:mt-0! [&_input]:px-0!",
|
|
!isFocused && "[&_div:first-child>div]:justify-center",
|
|
)}
|
|
badgeClassName="shrink-0"
|
|
inputProps={{
|
|
className: "!py-0 text-sm caret-current !ml-0 !mt-0 !px-0",
|
|
onKeyDown: (e) => {
|
|
if (e.key === "Escape") setOpenTagsEditorFor(null);
|
|
},
|
|
onFocus: () => setIsFocused(true),
|
|
onBlur: () => setIsFocused(false),
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
TagsCell.displayName = "TagsCell";
|
|
|
|
const NonHoverableTooltip = React.memo<{
|
|
children: React.ReactNode;
|
|
content: React.ReactNode;
|
|
sideOffset?: number;
|
|
alignOffset?: number;
|
|
horizontalOffset?: number;
|
|
}>(
|
|
({
|
|
children,
|
|
content,
|
|
sideOffset = 4,
|
|
alignOffset = 0,
|
|
horizontalOffset = 0,
|
|
}) => {
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
|
|
return (
|
|
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
|
|
<TooltipTrigger
|
|
asChild
|
|
onMouseEnter={() => setIsOpen(true)}
|
|
onMouseLeave={() => setIsOpen(false)}
|
|
>
|
|
{children}
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
sideOffset={sideOffset}
|
|
alignOffset={alignOffset}
|
|
arrowOffset={horizontalOffset}
|
|
onPointerEnter={(e) => e.preventDefault()}
|
|
onPointerLeave={() => setIsOpen(false)}
|
|
className="pointer-events-none"
|
|
style={
|
|
horizontalOffset !== 0
|
|
? { transform: `translateX(${horizontalOffset}px)` }
|
|
: undefined
|
|
}
|
|
>
|
|
{content}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
);
|
|
|
|
NonHoverableTooltip.displayName = "NonHoverableTooltip";
|
|
|
|
const NoteCell = React.memo<{
|
|
profile: BrowserProfile;
|
|
isDisabled: boolean;
|
|
noteOverrides: Record<string, string | null>;
|
|
openNoteEditorFor: string | null;
|
|
setOpenNoteEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
|
|
setNoteOverrides: React.Dispatch<
|
|
React.SetStateAction<Record<string, string | null>>
|
|
>;
|
|
}>(
|
|
({
|
|
profile,
|
|
isDisabled,
|
|
noteOverrides,
|
|
openNoteEditorFor,
|
|
setOpenNoteEditorFor,
|
|
setNoteOverrides,
|
|
}) => {
|
|
const effectiveNote: string | null = Object.hasOwn(
|
|
noteOverrides,
|
|
profile.id,
|
|
)
|
|
? noteOverrides[profile.id]
|
|
: (profile.note ?? null);
|
|
|
|
const onNoteChange = React.useCallback(
|
|
async (newNote: string | null) => {
|
|
const trimmedNote = newNote?.trim() || null;
|
|
setNoteOverrides((prev) => ({ ...prev, [profile.id]: trimmedNote }));
|
|
try {
|
|
await invoke<BrowserProfile>("update_profile_note", {
|
|
profileId: profile.id,
|
|
note: trimmedNote,
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to update note:", error);
|
|
}
|
|
},
|
|
[profile.id, setNoteOverrides],
|
|
);
|
|
|
|
const editorRef = React.useRef<HTMLDivElement | null>(null);
|
|
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
|
|
const [noteValue, setNoteValue] = React.useState(effectiveNote || "");
|
|
|
|
// Update local state when effective note changes (from outside)
|
|
React.useEffect(() => {
|
|
if (openNoteEditorFor !== profile.id) {
|
|
setNoteValue(effectiveNote || "");
|
|
}
|
|
}, [effectiveNote, openNoteEditorFor, profile.id]);
|
|
|
|
// Auto-resize textarea on open
|
|
React.useEffect(() => {
|
|
if (openNoteEditorFor === profile.id && textareaRef.current) {
|
|
const textarea = textareaRef.current;
|
|
textarea.style.height = "auto";
|
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
|
}
|
|
}, [openNoteEditorFor, profile.id]);
|
|
|
|
const handleTextareaChange = React.useCallback(
|
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const newValue = e.target.value;
|
|
setNoteValue(newValue);
|
|
// Auto-resize
|
|
const textarea = e.target;
|
|
textarea.style.height = "auto";
|
|
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
|
},
|
|
[],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (openNoteEditorFor !== profile.id) return;
|
|
const handleClick = (e: MouseEvent) => {
|
|
const target = e.target as Node | null;
|
|
if (
|
|
editorRef.current &&
|
|
target &&
|
|
!editorRef.current.contains(target)
|
|
) {
|
|
const currentValue = textareaRef.current?.value || "";
|
|
void onNoteChange(currentValue);
|
|
setOpenNoteEditorFor(null);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClick);
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
|
}, [openNoteEditorFor, profile.id, setOpenNoteEditorFor, onNoteChange]);
|
|
|
|
React.useEffect(() => {
|
|
if (openNoteEditorFor === profile.id && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
// Move cursor to end
|
|
const len = textareaRef.current.value.length;
|
|
textareaRef.current.setSelectionRange(len, len);
|
|
}
|
|
}, [openNoteEditorFor, profile.id]);
|
|
|
|
const displayNote = effectiveNote || "";
|
|
const trimmedNote =
|
|
displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote;
|
|
const showTooltip = displayNote.length > 12 || displayNote.length > 0;
|
|
|
|
if (openNoteEditorFor !== profile.id) {
|
|
return (
|
|
<div className="w-24 min-h-6">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex items-start px-2 py-1 min-h-6 w-full bg-transparent rounded border-none text-left",
|
|
isDisabled
|
|
? "opacity-60 cursor-not-allowed"
|
|
: "cursor-pointer hover:bg-accent/50",
|
|
)}
|
|
onClick={() => {
|
|
if (!isDisabled) {
|
|
setNoteValue(effectiveNote || "");
|
|
setOpenNoteEditorFor(profile.id);
|
|
}
|
|
}}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"text-sm wrap-break-word",
|
|
!effectiveNote && "text-muted-foreground",
|
|
)}
|
|
>
|
|
{effectiveNote ? trimmedNote : "No Note"}
|
|
</span>
|
|
</button>
|
|
</TooltipTrigger>
|
|
{showTooltip && (
|
|
<TooltipContent className="max-w-[320px]">
|
|
<p className="whitespace-pre-wrap wrap-break-word">
|
|
{effectiveNote || "No Note"}
|
|
</p>
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"w-24 relative",
|
|
isDisabled && "opacity-60 pointer-events-none",
|
|
)}
|
|
>
|
|
<div
|
|
ref={editorRef}
|
|
className="absolute -top-[15px] -left-px z-50 w-60 min-h-6 bg-popover rounded-md shadow-md border"
|
|
>
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={noteValue}
|
|
onChange={handleTextareaChange}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") {
|
|
setNoteValue(effectiveNote || "");
|
|
setOpenNoteEditorFor(null);
|
|
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
void onNoteChange(noteValue);
|
|
setOpenNoteEditorFor(null);
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
void onNoteChange(noteValue);
|
|
setOpenNoteEditorFor(null);
|
|
}}
|
|
placeholder="Add a note..."
|
|
className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0"
|
|
style={{
|
|
overflow: "auto",
|
|
}}
|
|
rows={1}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
NoteCell.displayName = "NoteCell";
|
|
|
|
interface ProfilesDataTableProps {
|
|
profiles: BrowserProfile[];
|
|
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
|
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
|
onCloneProfile: (profile: BrowserProfile) => void | Promise<void>;
|
|
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
|
|
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
|
|
onConfigureCamoufox: (profile: BrowserProfile) => void;
|
|
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
|
onOpenCookieManagement?: (profile: BrowserProfile) => void;
|
|
runningProfiles: Set<string>;
|
|
isUpdating: (browser: string) => boolean;
|
|
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
|
onAssignProfilesToGroup: (profileIds: string[]) => void;
|
|
selectedGroupId: string | null;
|
|
selectedProfiles: string[];
|
|
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
|
|
onBulkDelete?: () => void;
|
|
onBulkGroupAssignment?: () => void;
|
|
onBulkProxyAssignment?: () => void;
|
|
onBulkCopyCookies?: () => void;
|
|
onBulkExtensionGroupAssignment?: () => void;
|
|
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
|
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
|
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
|
crossOsUnlocked?: boolean;
|
|
syncUnlocked?: boolean;
|
|
getProfileSyncInfo?: (profileId: string) =>
|
|
| {
|
|
session: SyncSessionInfo;
|
|
isLeader: boolean;
|
|
failedAtUrl: string | null;
|
|
}
|
|
| undefined;
|
|
onLaunchWithSync?: (profile: BrowserProfile) => void;
|
|
}
|
|
|
|
export function ProfilesDataTable({
|
|
profiles,
|
|
onLaunchProfile,
|
|
onKillProfile,
|
|
onCloneProfile,
|
|
onDeleteProfile,
|
|
onRenameProfile,
|
|
onConfigureCamoufox,
|
|
onCopyCookiesToProfile,
|
|
onOpenCookieManagement,
|
|
runningProfiles,
|
|
isUpdating,
|
|
onAssignProfilesToGroup,
|
|
selectedProfiles,
|
|
onSelectedProfilesChange,
|
|
onBulkDelete,
|
|
onBulkGroupAssignment,
|
|
onBulkProxyAssignment,
|
|
onBulkCopyCookies,
|
|
onBulkExtensionGroupAssignment,
|
|
onAssignExtensionGroup,
|
|
onOpenProfileSyncDialog,
|
|
onToggleProfileSync,
|
|
crossOsUnlocked = false,
|
|
syncUnlocked = false,
|
|
getProfileSyncInfo,
|
|
onLaunchWithSync,
|
|
}: ProfilesDataTableProps) {
|
|
const { t } = useTranslation();
|
|
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
|
|
|
// Sync external selectedProfiles with table's row selection state
|
|
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
|
|
const prevSelectedProfilesRef = React.useRef<string[]>(selectedProfiles);
|
|
|
|
// Update row selection when external selectedProfiles changes
|
|
React.useEffect(() => {
|
|
// Only update if selectedProfiles actually changed
|
|
if (
|
|
prevSelectedProfilesRef.current.length !== selectedProfiles.length ||
|
|
!prevSelectedProfilesRef.current.every((id) =>
|
|
selectedProfiles.includes(id),
|
|
)
|
|
) {
|
|
const newSelection: RowSelectionState = {};
|
|
for (const profileId of selectedProfiles) {
|
|
newSelection[profileId] = true;
|
|
}
|
|
setRowSelection(newSelection);
|
|
prevSelectedProfilesRef.current = selectedProfiles;
|
|
}
|
|
}, [selectedProfiles]);
|
|
|
|
// Update external selectedProfiles when table selection changes
|
|
const handleRowSelectionChange = React.useCallback(
|
|
(updater: React.SetStateAction<RowSelectionState>) => {
|
|
setRowSelection((prevSelection) => {
|
|
const newSelection =
|
|
typeof updater === "function" ? updater(prevSelection) : updater;
|
|
|
|
const selectedIds = Object.keys(newSelection).filter(
|
|
(id) => newSelection[id],
|
|
);
|
|
|
|
// Only update external state if selection actually changed
|
|
const prevIds = Object.keys(prevSelection).filter(
|
|
(id) => prevSelection[id],
|
|
);
|
|
|
|
if (
|
|
selectedIds.length !== prevIds.length ||
|
|
!selectedIds.every((id) => prevIds.includes(id))
|
|
) {
|
|
onSelectedProfilesChange(selectedIds);
|
|
}
|
|
|
|
return newSelection;
|
|
});
|
|
},
|
|
[onSelectedProfilesChange],
|
|
);
|
|
const [profileToRename, setProfileToRename] =
|
|
React.useState<BrowserProfile | null>(null);
|
|
const [newProfileName, setNewProfileName] = React.useState("");
|
|
const [renameError, setRenameError] = React.useState<string | null>(null);
|
|
const [isRenamingSaving, setIsRenamingSaving] = React.useState(false);
|
|
const renameContainerRef = React.useRef<HTMLDivElement | null>(null);
|
|
const [profileToDelete, setProfileToDelete] =
|
|
React.useState<BrowserProfile | null>(null);
|
|
const [isDeleting, setIsDeleting] = React.useState(false);
|
|
const [profileForInfoDialog, setProfileForInfoDialog] =
|
|
React.useState<BrowserProfile | null>(null);
|
|
const [bypassRulesProfile, setBypassRulesProfile] =
|
|
React.useState<BrowserProfile | null>(null);
|
|
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const [stoppingProfiles, setStoppingProfiles] = React.useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
|
|
const { storedProxies } = useProxyEvents();
|
|
const { vpnConfigs } = useVpnEvents();
|
|
const { user } = useCloudAuth();
|
|
const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id);
|
|
|
|
const [proxyOverrides, setProxyOverrides] = React.useState<
|
|
Record<string, string | null>
|
|
>({});
|
|
const [vpnOverrides, setVpnOverrides] = React.useState<
|
|
Record<string, string | null>
|
|
>({});
|
|
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
|
const [tagsOverrides, setTagsOverrides] = React.useState<
|
|
Record<string, string[]>
|
|
>({});
|
|
const [allTags, setAllTags] = React.useState<string[]>([]);
|
|
const [openTagsEditorFor, setOpenTagsEditorFor] = React.useState<
|
|
string | null
|
|
>(null);
|
|
const [openProxySelectorFor, setOpenProxySelectorFor] = React.useState<
|
|
string | null
|
|
>(null);
|
|
const [checkingProfileId, setCheckingProfileId] = React.useState<
|
|
string | null
|
|
>(null);
|
|
const [proxyCheckResults, setProxyCheckResults] = React.useState<
|
|
Record<string, ProxyCheckResult>
|
|
>({});
|
|
const [noteOverrides, setNoteOverrides] = React.useState<
|
|
Record<string, string | null>
|
|
>({});
|
|
const [openNoteEditorFor, setOpenNoteEditorFor] = React.useState<
|
|
string | null
|
|
>(null);
|
|
const [trafficSnapshots, setTrafficSnapshots] = React.useState<
|
|
Record<string, TrafficSnapshot>
|
|
>({});
|
|
const [trafficDialogProfile, setTrafficDialogProfile] = React.useState<{
|
|
id: string;
|
|
name?: string;
|
|
} | null>(null);
|
|
const [syncStatuses, setSyncStatuses] = React.useState<
|
|
Record<string, { status: string; error?: string }>
|
|
>({});
|
|
|
|
// Country proxy creation state (for inline proxy creation in dropdown)
|
|
const [countries, setCountries] = React.useState<LocationItem[]>([]);
|
|
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
|
|
const canCreateLocationProxy = false;
|
|
|
|
const loadCountries = React.useCallback(async () => {
|
|
if (countriesLoaded || !canCreateLocationProxy) return;
|
|
try {
|
|
const data = await invoke<LocationItem[]>("cloud_get_countries");
|
|
setCountries(data);
|
|
setCountriesLoaded(true);
|
|
} catch (e) {
|
|
console.error("Failed to load countries:", e);
|
|
}
|
|
}, [countriesLoaded]);
|
|
|
|
// Load cached check results for proxies
|
|
React.useEffect(() => {
|
|
const loadCachedResults = async () => {
|
|
const results: Record<string, ProxyCheckResult> = {};
|
|
const proxyIds = new Set<string>();
|
|
for (const profile of profiles) {
|
|
if (profile.proxy_id) {
|
|
proxyIds.add(profile.proxy_id);
|
|
}
|
|
}
|
|
for (const proxyId of proxyIds) {
|
|
try {
|
|
const cached = await invoke<ProxyCheckResult | null>(
|
|
"get_cached_proxy_check",
|
|
{ proxyId },
|
|
);
|
|
if (cached) {
|
|
results[proxyId] = cached;
|
|
}
|
|
} catch (_error) {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
setProxyCheckResults(results);
|
|
};
|
|
if (profiles.length > 0) {
|
|
void loadCachedResults();
|
|
}
|
|
}, [profiles]);
|
|
|
|
const loadAllTags = React.useCallback(async () => {
|
|
try {
|
|
const tags = await invoke<string[]>("get_all_tags");
|
|
setAllTags(tags);
|
|
} catch (error) {
|
|
console.error("Failed to load tags:", error);
|
|
}
|
|
}, []);
|
|
|
|
const handleProxySelection = React.useCallback(
|
|
async (profileId: string, proxyId: string | null) => {
|
|
try {
|
|
await invoke("update_profile_proxy", {
|
|
profileId,
|
|
proxyId,
|
|
});
|
|
setProxyOverrides((prev) => ({ ...prev, [profileId]: proxyId }));
|
|
setVpnOverrides((prev) => ({ ...prev, [profileId]: null }));
|
|
await emit("profile-updated");
|
|
} catch (error) {
|
|
console.error("Failed to update proxy settings:", error);
|
|
} finally {
|
|
setOpenProxySelectorFor(null);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleVpnSelection = React.useCallback(
|
|
async (profileId: string, vpnId: string | null) => {
|
|
try {
|
|
await invoke("update_profile_vpn", {
|
|
profileId,
|
|
vpnId,
|
|
});
|
|
setVpnOverrides((prev) => ({ ...prev, [profileId]: vpnId }));
|
|
setProxyOverrides((prev) => ({ ...prev, [profileId]: null }));
|
|
await emit("profile-updated");
|
|
} catch (error) {
|
|
console.error("Failed to update VPN settings:", error);
|
|
} finally {
|
|
setOpenProxySelectorFor(null);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleCreateCountryProxy = React.useCallback(
|
|
async (profileId: string, country: LocationItem) => {
|
|
try {
|
|
await invoke("create_cloud_location_proxy", {
|
|
name: country.name,
|
|
country: country.code,
|
|
region: null,
|
|
city: null,
|
|
isp: null,
|
|
});
|
|
await emit("stored-proxies-changed");
|
|
// Wait briefly for proxy list to update, then find and assign the new proxy
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
const updatedProxies =
|
|
await invoke<StoredProxy[]>("get_stored_proxies");
|
|
const newProxy = updatedProxies.find(
|
|
(p: StoredProxy) =>
|
|
p.is_cloud_derived && p.geo_country === country.code,
|
|
);
|
|
if (newProxy) {
|
|
await handleProxySelection(profileId, newProxy.id);
|
|
}
|
|
setOpenProxySelectorFor(null);
|
|
} catch (error) {
|
|
console.error("Failed to create country proxy:", error);
|
|
}
|
|
},
|
|
[handleProxySelection],
|
|
);
|
|
|
|
// Use shared browser state hook
|
|
const browserState = useBrowserState(
|
|
profiles,
|
|
runningProfiles,
|
|
isUpdating,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
crossOsUnlocked,
|
|
);
|
|
|
|
// 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;
|
|
error?: string;
|
|
}>("profile-sync-status", (event) => {
|
|
const { profile_id, status, error } = event.payload;
|
|
setSyncStatuses((prev) => ({
|
|
...prev,
|
|
[profile_id]: { status, error },
|
|
}));
|
|
});
|
|
} 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(
|
|
() => Array.from(runningProfiles).sort(),
|
|
[runningProfiles],
|
|
);
|
|
const runningCount = runningProfileIds.length;
|
|
React.useEffect(() => {
|
|
if (!browserState.isClient) return;
|
|
|
|
if (runningCount === 0) {
|
|
setTrafficSnapshots({});
|
|
return;
|
|
}
|
|
|
|
const fetchTrafficSnapshots = async () => {
|
|
try {
|
|
const allSnapshots = await invoke<TrafficSnapshot[]>(
|
|
"get_all_traffic_snapshots",
|
|
);
|
|
const newSnapshots: Record<string, TrafficSnapshot> = {};
|
|
for (const snapshot of allSnapshots) {
|
|
if (snapshot.profile_id) {
|
|
// Only keep snapshots for profiles that are currently running
|
|
if (runningProfileIds.includes(snapshot.profile_id)) {
|
|
const existing = newSnapshots[snapshot.profile_id];
|
|
if (!existing || snapshot.last_update > existing.last_update) {
|
|
newSnapshots[snapshot.profile_id] = snapshot;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
setTrafficSnapshots(newSnapshots);
|
|
} catch (error) {
|
|
console.error("Failed to fetch traffic snapshots:", error);
|
|
}
|
|
};
|
|
|
|
void fetchTrafficSnapshots();
|
|
const interval = setInterval(fetchTrafficSnapshots, 1000);
|
|
return () => clearInterval(interval);
|
|
}, [browserState.isClient, runningCount, runningProfileIds]);
|
|
|
|
// Clean up snapshots for profiles that are no longer running
|
|
React.useEffect(() => {
|
|
if (!browserState.isClient) return;
|
|
|
|
setTrafficSnapshots((prev) => {
|
|
const cleaned: Record<string, TrafficSnapshot> = {};
|
|
for (const [profileId, snapshot] of Object.entries(prev)) {
|
|
// Only keep snapshots for profiles that are currently running
|
|
if (runningProfileIds.includes(profileId)) {
|
|
cleaned[profileId] = snapshot;
|
|
}
|
|
}
|
|
// Only update if something was removed
|
|
if (Object.keys(cleaned).length !== Object.keys(prev).length) {
|
|
return cleaned;
|
|
}
|
|
return prev;
|
|
});
|
|
}, [browserState.isClient, runningProfileIds]);
|
|
|
|
// Clear launching/stopping spinners when backend reports running status changes
|
|
React.useEffect(() => {
|
|
if (!browserState.isClient) return;
|
|
let unlisten: (() => void) | undefined;
|
|
(async () => {
|
|
try {
|
|
unlisten = await listen<{ id: string; is_running: boolean }>(
|
|
"profile-running-changed",
|
|
(event) => {
|
|
const { id } = event.payload;
|
|
// Clear launching state for this profile if present
|
|
setLaunchingProfiles((prev) => {
|
|
if (!prev.has(id)) return prev;
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
// Clear stopping state for this profile if present
|
|
setStoppingProfiles((prev) => {
|
|
if (!prev.has(id)) return prev;
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
},
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to listen for profile running changes:", error);
|
|
}
|
|
})();
|
|
return () => {
|
|
if (unlisten) unlisten();
|
|
};
|
|
}, [browserState.isClient]);
|
|
|
|
// Keep stored proxies up-to-date by listening for changes emitted elsewhere in the app
|
|
React.useEffect(() => {
|
|
if (!browserState.isClient) return;
|
|
let unlisten: (() => void) | undefined;
|
|
(async () => {
|
|
try {
|
|
unlisten = await listen("stored-proxies-changed", () => {
|
|
// Also refresh tags on profile updates
|
|
void loadAllTags();
|
|
});
|
|
} catch (_err) {
|
|
// Best-effort only
|
|
}
|
|
})();
|
|
return () => {
|
|
if (unlisten) unlisten();
|
|
};
|
|
}, [browserState.isClient, loadAllTags]);
|
|
|
|
// Automatically deselect profiles that become running, updating, launching, or stopping
|
|
React.useEffect(() => {
|
|
const newSet = new Set(selectedProfiles);
|
|
let hasChanges = false;
|
|
|
|
for (const profileId of selectedProfiles) {
|
|
const profile = profiles.find((p) => p.id === profileId);
|
|
if (profile) {
|
|
const isRunning =
|
|
browserState.isClient && runningProfiles.has(profile.id);
|
|
const isLaunching = launchingProfiles.has(profile.id);
|
|
const isStopping = stoppingProfiles.has(profile.id);
|
|
|
|
if (isRunning || isLaunching || isStopping) {
|
|
newSet.delete(profileId);
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasChanges) {
|
|
onSelectedProfilesChange(Array.from(newSet));
|
|
}
|
|
}, [
|
|
profiles,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
browserState.isClient,
|
|
onSelectedProfilesChange,
|
|
selectedProfiles,
|
|
]);
|
|
|
|
// Update local sorting state when settings are loaded
|
|
React.useEffect(() => {
|
|
if (isLoaded && browserState.isClient) {
|
|
setSorting(getTableSorting());
|
|
}
|
|
}, [isLoaded, getTableSorting, browserState.isClient]);
|
|
|
|
// Handle sorting changes
|
|
const handleSortingChange = React.useCallback(
|
|
(updater: React.SetStateAction<SortingState>) => {
|
|
if (!browserState.isClient) return;
|
|
const newSorting =
|
|
typeof updater === "function" ? updater(sorting) : updater;
|
|
setSorting(newSorting);
|
|
updateSorting(newSorting);
|
|
},
|
|
[browserState.isClient, sorting, updateSorting],
|
|
);
|
|
|
|
const handleRename = React.useCallback(async () => {
|
|
if (!profileToRename || !newProfileName.trim()) return;
|
|
|
|
try {
|
|
setIsRenamingSaving(true);
|
|
await onRenameProfile(profileToRename.id, newProfileName.trim());
|
|
setProfileToRename(null);
|
|
setNewProfileName("");
|
|
setRenameError(null);
|
|
} catch (error) {
|
|
setRenameError(
|
|
error instanceof Error ? error.message : "Failed to rename profile",
|
|
);
|
|
} finally {
|
|
setIsRenamingSaving(false);
|
|
}
|
|
}, [profileToRename, newProfileName, onRenameProfile]);
|
|
|
|
// Cancel inline rename on outside click
|
|
React.useEffect(() => {
|
|
if (!profileToRename) return;
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
const target = event.target as Node | null;
|
|
if (
|
|
target &&
|
|
renameContainerRef.current &&
|
|
!renameContainerRef.current.contains(target)
|
|
) {
|
|
setProfileToRename(null);
|
|
setNewProfileName("");
|
|
setRenameError(null);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
};
|
|
}, [profileToRename]);
|
|
|
|
const handleDelete = async () => {
|
|
if (!profileToDelete) return;
|
|
|
|
setIsDeleting(true);
|
|
// Minimum loading time for visual feedback
|
|
const minLoadingTime = new Promise((r) => setTimeout(r, 300));
|
|
try {
|
|
await Promise.all([onDeleteProfile(profileToDelete), minLoadingTime]);
|
|
setProfileToDelete(null);
|
|
} catch (error) {
|
|
console.error("Failed to delete profile:", error);
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
// Handle icon/checkbox click
|
|
const handleIconClick = React.useCallback(
|
|
(profileId: string) => {
|
|
const profile = profiles.find((p) => p.id === profileId);
|
|
if (!profile) return;
|
|
|
|
// Prevent selection of profiles whose browsers are updating
|
|
if (!browserState.canSelectProfile(profile)) {
|
|
return;
|
|
}
|
|
|
|
setShowCheckboxes(true);
|
|
const newSet = new Set(selectedProfiles);
|
|
if (newSet.has(profileId)) {
|
|
newSet.delete(profileId);
|
|
} else {
|
|
newSet.add(profileId);
|
|
}
|
|
|
|
// Hide checkboxes if no profiles are selected
|
|
if (newSet.size === 0) {
|
|
setShowCheckboxes(false);
|
|
}
|
|
|
|
onSelectedProfilesChange(Array.from(newSet));
|
|
},
|
|
[profiles, browserState, onSelectedProfilesChange, selectedProfiles],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (browserState.isClient) {
|
|
void loadAllTags();
|
|
}
|
|
}, [browserState.isClient, loadAllTags]);
|
|
|
|
// Handle checkbox change
|
|
const handleCheckboxChange = React.useCallback(
|
|
(profileId: string, checked: boolean) => {
|
|
const newSet = new Set(selectedProfiles);
|
|
if (checked) {
|
|
newSet.add(profileId);
|
|
} else {
|
|
newSet.delete(profileId);
|
|
}
|
|
|
|
// Hide checkboxes if no profiles are selected
|
|
if (newSet.size === 0) {
|
|
setShowCheckboxes(false);
|
|
}
|
|
|
|
onSelectedProfilesChange(Array.from(newSet));
|
|
},
|
|
[onSelectedProfilesChange, selectedProfiles],
|
|
);
|
|
|
|
// Handle select all checkbox
|
|
const handleToggleAll = React.useCallback(
|
|
(checked: boolean) => {
|
|
const newSet = checked
|
|
? new Set(
|
|
profiles
|
|
.filter((profile) => {
|
|
const isRunning =
|
|
browserState.isClient && runningProfiles.has(profile.id);
|
|
const isLaunching = launchingProfiles.has(profile.id);
|
|
const isStopping = stoppingProfiles.has(profile.id);
|
|
return !isRunning && !isLaunching && !isStopping;
|
|
})
|
|
.map((profile) => profile.id),
|
|
)
|
|
: new Set<string>();
|
|
|
|
setShowCheckboxes(checked);
|
|
onSelectedProfilesChange(Array.from(newSet));
|
|
},
|
|
[
|
|
profiles,
|
|
onSelectedProfilesChange,
|
|
browserState.isClient,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
],
|
|
);
|
|
|
|
// Memoize selectableProfiles calculation
|
|
const selectableProfiles = React.useMemo(() => {
|
|
return profiles.filter((profile) => {
|
|
const isRunning =
|
|
browserState.isClient && runningProfiles.has(profile.id);
|
|
const isLaunching = launchingProfiles.has(profile.id);
|
|
const isStopping = stoppingProfiles.has(profile.id);
|
|
return !isRunning && !isLaunching && !isStopping;
|
|
});
|
|
}, [
|
|
profiles,
|
|
browserState.isClient,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
]);
|
|
|
|
// Build table meta from volatile state so columns can stay stable
|
|
const tableMeta = React.useMemo<TableMeta>(
|
|
() => ({
|
|
t,
|
|
selectedProfiles,
|
|
selectableCount: selectableProfiles.length,
|
|
showCheckboxes,
|
|
isClient: browserState.isClient,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
isUpdating,
|
|
browserState,
|
|
|
|
// Tags editor state
|
|
tagsOverrides,
|
|
allTags,
|
|
openTagsEditorFor,
|
|
setAllTags,
|
|
setOpenTagsEditorFor,
|
|
setTagsOverrides,
|
|
|
|
// Note editor state
|
|
noteOverrides,
|
|
openNoteEditorFor,
|
|
setOpenNoteEditorFor,
|
|
setNoteOverrides,
|
|
|
|
// Proxy selector state
|
|
openProxySelectorFor,
|
|
setOpenProxySelectorFor,
|
|
proxyOverrides,
|
|
storedProxies,
|
|
handleProxySelection,
|
|
checkingProfileId,
|
|
proxyCheckResults,
|
|
|
|
// VPN selector state
|
|
vpnConfigs,
|
|
vpnOverrides,
|
|
handleVpnSelection,
|
|
|
|
// Selection helpers
|
|
isProfileSelected: (id: string) => selectedProfiles.includes(id),
|
|
handleToggleAll,
|
|
handleCheckboxChange,
|
|
handleIconClick,
|
|
|
|
// Rename helpers
|
|
handleRename,
|
|
setProfileToRename,
|
|
setNewProfileName,
|
|
setRenameError,
|
|
profileToRename,
|
|
newProfileName,
|
|
isRenamingSaving,
|
|
renameError,
|
|
|
|
// Launch/stop helpers
|
|
setLaunchingProfiles,
|
|
setStoppingProfiles,
|
|
onKillProfile,
|
|
onLaunchProfile,
|
|
|
|
// Overflow actions
|
|
onAssignProfilesToGroup,
|
|
onCloneProfile,
|
|
onConfigureCamoufox,
|
|
onCopyCookiesToProfile,
|
|
onOpenCookieManagement,
|
|
|
|
// Traffic snapshots (lightweight real-time data)
|
|
trafficSnapshots,
|
|
onOpenTrafficDialog: (profileId: string) => {
|
|
const profile = profiles.find((p) => p.id === profileId);
|
|
setTrafficDialogProfile({ id: profileId, name: profile?.name });
|
|
},
|
|
|
|
// Sync
|
|
syncStatuses,
|
|
onOpenProfileSyncDialog,
|
|
onToggleProfileSync,
|
|
crossOsUnlocked,
|
|
syncUnlocked,
|
|
|
|
// Country proxy creation
|
|
countries,
|
|
canCreateLocationProxy,
|
|
loadCountries,
|
|
handleCreateCountryProxy,
|
|
|
|
// Team locks
|
|
isProfileLockedByAnother: isProfileLocked,
|
|
getProfileLockEmail: (profileId: string) =>
|
|
getLockInfo(profileId)?.lockedByEmail,
|
|
|
|
// Synchronizer
|
|
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
|
|
onLaunchWithSync: onLaunchWithSync ?? (() => {}),
|
|
}),
|
|
[
|
|
t,
|
|
selectedProfiles,
|
|
selectableProfiles.length,
|
|
showCheckboxes,
|
|
browserState.isClient,
|
|
runningProfiles,
|
|
launchingProfiles,
|
|
stoppingProfiles,
|
|
isUpdating,
|
|
browserState,
|
|
tagsOverrides,
|
|
allTags,
|
|
openTagsEditorFor,
|
|
noteOverrides,
|
|
openNoteEditorFor,
|
|
openProxySelectorFor,
|
|
proxyOverrides,
|
|
storedProxies,
|
|
handleProxySelection,
|
|
checkingProfileId,
|
|
proxyCheckResults,
|
|
vpnConfigs,
|
|
vpnOverrides,
|
|
handleVpnSelection,
|
|
handleToggleAll,
|
|
handleCheckboxChange,
|
|
handleIconClick,
|
|
handleRename,
|
|
profileToRename,
|
|
newProfileName,
|
|
isRenamingSaving,
|
|
trafficSnapshots,
|
|
profiles,
|
|
renameError,
|
|
onKillProfile,
|
|
onLaunchProfile,
|
|
onAssignProfilesToGroup,
|
|
onCloneProfile,
|
|
onConfigureCamoufox,
|
|
onCopyCookiesToProfile,
|
|
onOpenCookieManagement,
|
|
syncStatuses,
|
|
onOpenProfileSyncDialog,
|
|
onToggleProfileSync,
|
|
crossOsUnlocked,
|
|
syncUnlocked,
|
|
countries,
|
|
loadCountries,
|
|
handleCreateCountryProxy,
|
|
isProfileLocked,
|
|
getLockInfo,
|
|
getProfileSyncInfo,
|
|
onLaunchWithSync,
|
|
],
|
|
);
|
|
|
|
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
|
|
() => [
|
|
{
|
|
id: "select",
|
|
header: ({ table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
return (
|
|
<span>
|
|
<Checkbox
|
|
checked={
|
|
meta.selectedProfiles.length === meta.selectableCount &&
|
|
meta.selectableCount !== 0
|
|
}
|
|
onCheckedChange={(value) => meta.handleToggleAll(!!value)}
|
|
aria-label="Select all"
|
|
className="cursor-pointer"
|
|
/>
|
|
</span>
|
|
);
|
|
},
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const browser = profile.browser;
|
|
const IconComponent = getProfileIcon(profile);
|
|
const isCrossOs = isCrossOsProfile(profile);
|
|
|
|
const isSelected = meta.isProfileSelected(profile.id);
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isDisabled = isRunning || isLaunching || isStopping;
|
|
|
|
// Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are
|
|
if (isCrossOs && !meta.showCheckboxes && !isSelected) {
|
|
const resolvedOs =
|
|
profile.host_os ||
|
|
profile.camoufox_config?.os ||
|
|
profile.wayfern_config?.os;
|
|
const osName = resolvedOs
|
|
? getOSDisplayName(resolvedOs)
|
|
: "another OS";
|
|
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
|
|
const OsIcon =
|
|
resolvedOs === "macos"
|
|
? FaApple
|
|
: resolvedOs === "windows"
|
|
? FaWindows
|
|
: FaLinux;
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="flex justify-center items-center w-4 h-4">
|
|
<button
|
|
type="button"
|
|
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
|
onClick={() => meta.handleIconClick(profile.id)}
|
|
aria-label="Select profile"
|
|
>
|
|
<span className="w-4 h-4 group">
|
|
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
|
|
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
|
|
</span>
|
|
</button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{crossOsTooltip}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
// Cross-OS profiles with checkboxes visible: show checkbox (selectable for bulk delete)
|
|
if (isCrossOs && (meta.showCheckboxes || isSelected)) {
|
|
const resolvedOs =
|
|
profile.host_os ||
|
|
profile.camoufox_config?.os ||
|
|
profile.wayfern_config?.os;
|
|
const osName = resolvedOs
|
|
? getOSDisplayName(resolvedOs)
|
|
: "another OS";
|
|
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
|
|
return (
|
|
<NonHoverableTooltip
|
|
content={<p>{crossOsTooltip}</p>}
|
|
sideOffset={4}
|
|
horizontalOffset={8}
|
|
>
|
|
<span className="flex justify-center items-center w-4 h-4">
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={(value) =>
|
|
meta.handleCheckboxChange(profile.id, !!value)
|
|
}
|
|
aria-label="Select row"
|
|
className="w-4 h-4"
|
|
/>
|
|
</span>
|
|
</NonHoverableTooltip>
|
|
);
|
|
}
|
|
|
|
if (isDisabled) {
|
|
const tooltipMessage = isRunning
|
|
? "Can't modify running profile"
|
|
: isLaunching
|
|
? "Can't modify profile while launching"
|
|
: isStopping
|
|
? "Can't modify profile while stopping"
|
|
: "Can't modify profile while browser is updating";
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="flex justify-center items-center w-4 h-4 cursor-not-allowed">
|
|
{IconComponent && (
|
|
<IconComponent className="w-4 h-4 opacity-50" />
|
|
)}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{tooltipMessage}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
const browserName = getBrowserDisplayName(browser);
|
|
|
|
if (meta.showCheckboxes || isSelected) {
|
|
return (
|
|
<NonHoverableTooltip
|
|
content={<p>{browserName}</p>}
|
|
sideOffset={4}
|
|
horizontalOffset={8}
|
|
>
|
|
<span className="flex justify-center items-center w-4 h-4">
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onCheckedChange={(value) =>
|
|
meta.handleCheckboxChange(profile.id, !!value)
|
|
}
|
|
aria-label="Select row"
|
|
className="w-4 h-4"
|
|
/>
|
|
</span>
|
|
</NonHoverableTooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<NonHoverableTooltip
|
|
content={<p>{browserName}</p>}
|
|
sideOffset={4}
|
|
horizontalOffset={8}
|
|
>
|
|
<span className="flex relative justify-center items-center w-4 h-4">
|
|
<button
|
|
type="button"
|
|
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
|
onClick={() => meta.handleIconClick(profile.id)}
|
|
aria-label="Select profile"
|
|
>
|
|
<span className="w-4 h-4 group">
|
|
{IconComponent && (
|
|
<IconComponent className="w-4 h-4 group-hover:hidden" />
|
|
)}
|
|
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
|
|
</span>
|
|
</button>
|
|
</span>
|
|
</NonHoverableTooltip>
|
|
);
|
|
},
|
|
enableSorting: false,
|
|
enableHiding: false,
|
|
size: 40,
|
|
},
|
|
{
|
|
id: "actions",
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isLockedByAnother = meta.isProfileLockedByAnother(profile.id);
|
|
const canLaunch =
|
|
meta.browserState.canLaunchProfile(profile) && !isLockedByAnother;
|
|
const lockEmail = meta.getProfileLockEmail(profile.id);
|
|
const tooltipContent = isLockedByAnother
|
|
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
|
|
: meta.browserState.getLaunchTooltipContent(profile);
|
|
|
|
const handleProfileStop = async (profile: BrowserProfile) => {
|
|
meta.setStoppingProfiles((prev: Set<string>) =>
|
|
new Set(prev).add(profile.id),
|
|
);
|
|
try {
|
|
await meta.onKillProfile(profile);
|
|
} catch (error) {
|
|
meta.setStoppingProfiles((prev: Set<string>) => {
|
|
const next = new Set(prev);
|
|
next.delete(profile.id);
|
|
return next;
|
|
});
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const handleProfileLaunch = async (profile: BrowserProfile) => {
|
|
meta.setLaunchingProfiles((prev: Set<string>) =>
|
|
new Set(prev).add(profile.id),
|
|
);
|
|
try {
|
|
await meta.onLaunchProfile(profile);
|
|
} catch (error) {
|
|
meta.setLaunchingProfiles((prev: Set<string>) => {
|
|
const next = new Set(prev);
|
|
next.delete(profile.id);
|
|
return next;
|
|
});
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const syncInfo = meta.getProfileSyncInfo(profile.id);
|
|
const isLeader = syncInfo?.isLeader === true;
|
|
const isFollower = syncInfo?.isLeader === false;
|
|
const isDesynced = isFollower && syncInfo?.failedAtUrl != null;
|
|
const stopTooltip = isLeader
|
|
? meta.t("profiles.synchronizer.stopLeader")
|
|
: isFollower
|
|
? meta.t("profiles.synchronizer.stopFollower", {
|
|
leaderName: syncInfo?.session.leader_profile_name ?? "",
|
|
})
|
|
: tooltipContent;
|
|
|
|
const handleStop = async () => {
|
|
if (isLeader && syncInfo) {
|
|
// Stop leader: invoke stop_sync_session which kills leader + all followers
|
|
try {
|
|
await invoke("stop_sync_session", {
|
|
sessionId: syncInfo.session.id,
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to stop sync session:", error);
|
|
}
|
|
} else if (isFollower && syncInfo) {
|
|
// Stop follower: remove from session
|
|
try {
|
|
await invoke("remove_sync_follower", {
|
|
sessionId: syncInfo.session.id,
|
|
followerProfileId: profile.id,
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to remove sync follower:", error);
|
|
}
|
|
} else {
|
|
await handleProfileStop(profile);
|
|
}
|
|
};
|
|
|
|
const buttonVariant = isRunning
|
|
? isFollower
|
|
? "secondary"
|
|
: "destructive"
|
|
: "default";
|
|
|
|
return (
|
|
<div className="flex gap-2 items-center">
|
|
{isDesynced && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<LuTriangleAlert className="w-4 h-4 text-warning" />
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{meta.t("profiles.synchronizer.desyncedTooltip", {
|
|
url: syncInfo?.failedAtUrl ?? "",
|
|
})}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="inline-flex">
|
|
<RippleButton
|
|
variant={buttonVariant}
|
|
size="sm"
|
|
disabled={!canLaunch || isLaunching || isStopping}
|
|
className={cn(
|
|
"min-w-[70px] h-7",
|
|
!canLaunch && "opacity-50 cursor-not-allowed",
|
|
canLaunch && "cursor-pointer",
|
|
isFollower && "border-accent",
|
|
)}
|
|
onClick={() =>
|
|
isRunning
|
|
? void handleStop()
|
|
: handleProfileLaunch(profile)
|
|
}
|
|
>
|
|
{isLaunching || isStopping ? (
|
|
<div className="flex gap-1 items-center">
|
|
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
|
</div>
|
|
) : isRunning ? (
|
|
"Stop"
|
|
) : (
|
|
"Launch"
|
|
)}
|
|
</RippleButton>
|
|
</span>
|
|
</TooltipTrigger>
|
|
{(stopTooltip || tooltipContent) && (
|
|
<TooltipContent>
|
|
{isRunning ? stopTooltip : tooltipContent}
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "name",
|
|
header: ({ column }) => {
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() =>
|
|
column.toggleSorting(column.getIsSorted() === "asc")
|
|
}
|
|
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
|
>
|
|
Name
|
|
{column.getIsSorted() === "asc" ? (
|
|
<LuChevronUp className="ml-2 w-4 h-4" />
|
|
) : column.getIsSorted() === "desc" ? (
|
|
<LuChevronDown className="ml-2 w-4 h-4" />
|
|
) : null}
|
|
</Button>
|
|
);
|
|
},
|
|
enableSorting: true,
|
|
sortingFn: "alphanumeric",
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original as BrowserProfile;
|
|
const rawName: string = row.getValue("name");
|
|
const name = getBrowserDisplayName(rawName);
|
|
const isEditing = meta.profileToRename?.id === profile.id;
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<div
|
|
ref={renameContainerRef}
|
|
className="overflow-visible relative"
|
|
>
|
|
<Input
|
|
autoFocus
|
|
value={meta.newProfileName}
|
|
onChange={(e) => {
|
|
meta.setNewProfileName(e.target.value);
|
|
if (meta.renameError) meta.setRenameError(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !(e.metaKey || e.ctrlKey)) {
|
|
void meta.handleRename();
|
|
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
void meta.handleRename();
|
|
} else if (e.key === "Escape") {
|
|
meta.setProfileToRename(null);
|
|
meta.setNewProfileName("");
|
|
meta.setRenameError(null);
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
if (
|
|
meta.newProfileName.trim().length > 0 &&
|
|
meta.newProfileName.trim() !== profile.name
|
|
) {
|
|
void meta.handleRename();
|
|
} else {
|
|
meta.setProfileToRename(null);
|
|
meta.setNewProfileName("");
|
|
meta.setRenameError(null);
|
|
}
|
|
}}
|
|
className="w-30 h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const display =
|
|
name.length < 14 ? (
|
|
<div className="font-medium text-left leading-none">{name}</div>
|
|
) : (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="leading-none">{trimName(name, 14)}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{name}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
|
|
const isCrossOs = isCrossOsProfile(profile);
|
|
const isCrossOsBlocked = isCrossOs;
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isDisabled =
|
|
isRunning || isLaunching || isStopping || isCrossOsBlocked;
|
|
const lockedEmail = meta.getProfileLockEmail(profile.id);
|
|
const isLocked = meta.isProfileLockedByAnother(profile.id);
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
|
|
isDisabled
|
|
? "opacity-60 cursor-not-allowed"
|
|
: "cursor-pointer hover:bg-accent/50",
|
|
)}
|
|
onClick={() => {
|
|
if (isDisabled) return;
|
|
meta.setProfileToRename(profile);
|
|
meta.setNewProfileName(profile.name);
|
|
meta.setRenameError(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (isDisabled) return;
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
meta.setProfileToRename(profile);
|
|
meta.setNewProfileName(profile.name);
|
|
meta.setRenameError(null);
|
|
}
|
|
}}
|
|
>
|
|
{display}
|
|
</button>
|
|
{isLocked && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span>
|
|
<LuLock className="w-3 h-3 text-muted-foreground" />
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{meta.t("sync.team.profileLocked", { email: lockedEmail })}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "tags",
|
|
header: "Tags",
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const isCrossOs = isCrossOsProfile(profile);
|
|
const isCrossOsBlocked = isCrossOs;
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isDisabled =
|
|
isRunning || isLaunching || isStopping || isCrossOsBlocked;
|
|
|
|
return (
|
|
<TagsCell
|
|
profile={profile}
|
|
isDisabled={isDisabled}
|
|
tagsOverrides={meta.tagsOverrides || {}}
|
|
allTags={meta.allTags || []}
|
|
setAllTags={meta.setAllTags}
|
|
openTagsEditorFor={meta.openTagsEditorFor || null}
|
|
setOpenTagsEditorFor={meta.setOpenTagsEditorFor}
|
|
setTagsOverrides={meta.setTagsOverrides}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "note",
|
|
header: "Note",
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const isCrossOs = isCrossOsProfile(profile);
|
|
const isCrossOsBlocked = isCrossOs;
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isDisabled =
|
|
isRunning || isLaunching || isStopping || isCrossOsBlocked;
|
|
|
|
return (
|
|
<NoteCell
|
|
profile={profile}
|
|
isDisabled={isDisabled}
|
|
noteOverrides={meta.noteOverrides || {}}
|
|
openNoteEditorFor={meta.openNoteEditorFor || null}
|
|
setOpenNoteEditorFor={meta.setOpenNoteEditorFor}
|
|
setNoteOverrides={meta.setNoteOverrides}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "proxy",
|
|
header: "Proxy / VPN",
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
const isCrossOs = isCrossOsProfile(profile);
|
|
const isCrossOsBlocked = isCrossOs;
|
|
const isRunning =
|
|
meta.isClient && meta.runningProfiles.has(profile.id);
|
|
const isLaunching = meta.launchingProfiles.has(profile.id);
|
|
const isStopping = meta.stoppingProfiles.has(profile.id);
|
|
const isDisabled =
|
|
isRunning || isLaunching || isStopping || isCrossOsBlocked;
|
|
|
|
const hasProxyOverride = Object.hasOwn(
|
|
meta.proxyOverrides,
|
|
profile.id,
|
|
);
|
|
const effectiveProxyId = hasProxyOverride
|
|
? meta.proxyOverrides[profile.id]
|
|
: (profile.proxy_id ?? null);
|
|
const effectiveProxy = effectiveProxyId
|
|
? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ??
|
|
null)
|
|
: null;
|
|
|
|
const hasVpnOverride = Object.hasOwn(meta.vpnOverrides, profile.id);
|
|
const effectiveVpnId = hasVpnOverride
|
|
? meta.vpnOverrides[profile.id]
|
|
: (profile.vpn_id ?? null);
|
|
const effectiveVpn = effectiveVpnId
|
|
? (meta.vpnConfigs.find((v) => v.id === effectiveVpnId) ?? null)
|
|
: null;
|
|
|
|
const hasAssignment = Boolean(effectiveProxy || effectiveVpn);
|
|
const displayName = effectiveVpn
|
|
? effectiveVpn.name
|
|
: effectiveProxy
|
|
? effectiveProxy.name
|
|
: "Not Selected";
|
|
const vpnBadge = effectiveVpn
|
|
? effectiveVpn.vpn_type === "WireGuard"
|
|
? "WG"
|
|
: "OVPN"
|
|
: null;
|
|
const tooltipText = hasAssignment ? displayName : null;
|
|
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
|
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
|
|
|
// When profile is running, show bandwidth chart instead of proxy selector
|
|
if (isRunning && meta.trafficSnapshots) {
|
|
const snapshot = meta.trafficSnapshots[profile.id];
|
|
const bandwidthData = snapshot?.recent_bandwidth
|
|
? [...snapshot.recent_bandwidth]
|
|
: [];
|
|
const currentBandwidth =
|
|
(snapshot?.current_bytes_sent || 0) +
|
|
(snapshot?.current_bytes_received || 0);
|
|
|
|
return (
|
|
<BandwidthMiniChart
|
|
key={`${profile.id}-${snapshot?.last_update || 0}-${bandwidthData.length}`}
|
|
data={bandwidthData}
|
|
currentBandwidth={currentBandwidth}
|
|
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex gap-2 items-center">
|
|
<Popover
|
|
open={isSelectorOpen}
|
|
onOpenChange={(open) =>
|
|
meta.setOpenProxySelectorFor(open ? profile.id : null)
|
|
}
|
|
>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
<span
|
|
className={cn(
|
|
"flex gap-2 items-center px-2 py-1 rounded",
|
|
isDisabled
|
|
? "opacity-60 cursor-not-allowed pointer-events-none"
|
|
: "cursor-pointer hover:bg-accent/50",
|
|
)}
|
|
>
|
|
{vpnBadge && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] px-1 py-0 leading-tight"
|
|
>
|
|
{vpnBadge}
|
|
</Badge>
|
|
)}
|
|
<span
|
|
className={cn(
|
|
"text-sm",
|
|
!hasAssignment && "text-muted-foreground",
|
|
)}
|
|
>
|
|
{hasAssignment
|
|
? trimName(displayName, 10)
|
|
: displayName}
|
|
</span>
|
|
</span>
|
|
</PopoverTrigger>
|
|
</TooltipTrigger>
|
|
{tooltipText && (
|
|
<TooltipContent>{tooltipText}</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
|
|
{!isDisabled && (
|
|
<PopoverContent
|
|
className="w-[240px] p-0"
|
|
align="end"
|
|
sideOffset={8}
|
|
>
|
|
<Command>
|
|
<CommandInput
|
|
placeholder={
|
|
meta.canCreateLocationProxy
|
|
? "Search proxies, VPNs, or countries..."
|
|
: "Search proxies or VPNs..."
|
|
}
|
|
onFocus={() => {
|
|
if (meta.canCreateLocationProxy)
|
|
void meta.loadCountries();
|
|
}}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
|
<CommandGroup>
|
|
<CommandItem
|
|
value="__none__"
|
|
onSelect={() =>
|
|
void meta.handleProxySelection(profile.id, null)
|
|
}
|
|
>
|
|
<LuCheck
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
selectedId === null
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
None
|
|
</CommandItem>
|
|
{meta.storedProxies
|
|
.filter(
|
|
(proxy: StoredProxy) =>
|
|
!proxy.is_cloud_managed &&
|
|
!proxy.is_cloud_derived,
|
|
)
|
|
.map((proxy: StoredProxy) => (
|
|
<CommandItem
|
|
key={proxy.id}
|
|
value={proxy.name}
|
|
onSelect={() =>
|
|
void meta.handleProxySelection(
|
|
profile.id,
|
|
proxy.id,
|
|
)
|
|
}
|
|
>
|
|
<LuCheck
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
effectiveProxyId === proxy.id &&
|
|
!effectiveVpn
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
{proxy.name}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
{meta.vpnConfigs.length > 0 && (
|
|
<CommandGroup heading="VPNs">
|
|
{meta.vpnConfigs.map((vpn) => (
|
|
<CommandItem
|
|
key={vpn.id}
|
|
value={`vpn-${vpn.name}`}
|
|
onSelect={() =>
|
|
void meta.handleVpnSelection(
|
|
profile.id,
|
|
vpn.id,
|
|
)
|
|
}
|
|
>
|
|
<LuCheck
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
effectiveVpnId === vpn.id
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
|
>
|
|
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
|
</Badge>
|
|
{vpn.name}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
{meta.canCreateLocationProxy &&
|
|
meta.countries.length > 0 && (
|
|
<CommandGroup heading="Create by country">
|
|
{meta.countries
|
|
.filter(
|
|
(c) =>
|
|
!meta.storedProxies.some(
|
|
(p) =>
|
|
p.is_cloud_derived &&
|
|
p.geo_country === c.code,
|
|
),
|
|
)
|
|
.map((country) => (
|
|
<CommandItem
|
|
key={`country-${country.code}`}
|
|
value={`create-${country.name}`}
|
|
onSelect={() =>
|
|
void meta.handleCreateCountryProxy(
|
|
profile.id,
|
|
country,
|
|
)
|
|
}
|
|
>
|
|
<span className="mr-2 h-4 w-4" />+{" "}
|
|
{country.name}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
)}
|
|
</Popover>
|
|
{effectiveProxy && !effectiveVpn && !isDisabled && (
|
|
<ProxyCheckButton
|
|
proxy={effectiveProxy}
|
|
profileId={profile.id}
|
|
checkingProfileId={meta.checkingProfileId}
|
|
cachedResult={meta.proxyCheckResults[effectiveProxy.id]}
|
|
setCheckingProfileId={setCheckingProfileId}
|
|
onCheckComplete={(result) => {
|
|
setProxyCheckResults((prev) => ({
|
|
...prev,
|
|
[effectiveProxy.id]: result,
|
|
}));
|
|
}}
|
|
onCheckFailed={(result) => {
|
|
setProxyCheckResults((prev) => ({
|
|
...prev,
|
|
[effectiveProxy.id]: result,
|
|
}));
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "sync",
|
|
header: "",
|
|
size: 24,
|
|
cell: ({ row, table }) => {
|
|
const profile = row.original;
|
|
const meta = table.options.meta as TableMeta;
|
|
const syncEntry = meta.syncStatuses[profile.id];
|
|
const liveStatus = syncEntry?.status as
|
|
| "syncing"
|
|
| "waiting"
|
|
| "synced"
|
|
| "error"
|
|
| "disabled"
|
|
| undefined;
|
|
|
|
const dot = getProfileSyncStatusDot(
|
|
profile,
|
|
liveStatus,
|
|
syncEntry?.error,
|
|
);
|
|
if (!dot) return null;
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="flex justify-center items-center w-3 h-3">
|
|
{dot.encrypted ? (
|
|
<LuLock
|
|
className={`w-3 h-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
|
|
/>
|
|
) : (
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
|
|
/>
|
|
)}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{dot.tooltip}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "settings",
|
|
size: 40,
|
|
cell: ({ row, table }) => {
|
|
const meta = table.options.meta as TableMeta;
|
|
const profile = row.original;
|
|
|
|
return (
|
|
<div className="flex justify-end items-center">
|
|
<Button
|
|
variant="ghost"
|
|
className="p-0 w-8 h-8"
|
|
disabled={!meta.isClient}
|
|
onClick={() => setProfileForInfoDialog(profile)}
|
|
>
|
|
<span className="sr-only">Profile info</span>
|
|
<LuInfo className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
[t],
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data: profiles,
|
|
columns,
|
|
state: {
|
|
sorting,
|
|
rowSelection,
|
|
},
|
|
onSortingChange: handleSortingChange,
|
|
onRowSelectionChange: handleRowSelectionChange,
|
|
enableRowSelection: (row) => {
|
|
const profile = row.original;
|
|
const isRunning =
|
|
browserState.isClient && runningProfiles.has(profile.id);
|
|
const isLaunching = launchingProfiles.has(profile.id);
|
|
const isStopping = stoppingProfiles.has(profile.id);
|
|
return !isRunning && !isLaunching && !isStopping;
|
|
},
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getRowId: (row) => row.id,
|
|
meta: tableMeta,
|
|
});
|
|
|
|
const platform = getCurrentOS();
|
|
|
|
return (
|
|
<>
|
|
<ScrollArea
|
|
className={cn(
|
|
"rounded-md border [&>div[data-slot='scroll-area-viewport']>div]:overflow-visible",
|
|
platform === "macos" ? "h-[340px]" : "h-[280px]",
|
|
)}
|
|
>
|
|
<Table className="overflow-visible">
|
|
<TableHeader className="overflow-visible">
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id} className="overflow-visible">
|
|
{headerGroup.headers.map((header) => {
|
|
return (
|
|
<TableHead
|
|
key={header.id}
|
|
style={{
|
|
width: header.column.columnDef.size
|
|
? `${header.column.getSize()}px`
|
|
: undefined,
|
|
}}
|
|
>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody className="overflow-visible">
|
|
{table.getRowModel().rows?.length ? (
|
|
table.getRowModel().rows.map((row) => {
|
|
const rowIsCrossOs = isCrossOsProfile(row.original);
|
|
const crossOsTitle = rowIsCrossOs
|
|
? t("crossOs.viewOnly", {
|
|
os: getOSDisplayName(
|
|
row.original.host_os ||
|
|
row.original.camoufox_config?.os ||
|
|
row.original.wayfern_config?.os ||
|
|
"",
|
|
),
|
|
})
|
|
: undefined;
|
|
return (
|
|
<TableRow
|
|
key={row.id}
|
|
data-state={row.getIsSelected() && "selected"}
|
|
title={crossOsTitle}
|
|
className={cn(
|
|
"overflow-visible hover:bg-accent/50",
|
|
rowIsCrossOs && "opacity-60",
|
|
)}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell
|
|
key={cell.id}
|
|
className="overflow-visible"
|
|
style={{
|
|
width: cell.column.columnDef.size
|
|
? `${cell.column.getSize()}px`
|
|
: undefined,
|
|
}}
|
|
>
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
);
|
|
})
|
|
) : (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={columns.length}
|
|
className="h-24 text-center"
|
|
>
|
|
No profiles found.
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</ScrollArea>
|
|
<DeleteConfirmationDialog
|
|
isOpen={profileToDelete !== null}
|
|
onClose={() => setProfileToDelete(null)}
|
|
onConfirm={handleDelete}
|
|
title="Delete Profile"
|
|
description={`This action cannot be undone. This will permanently delete the profile "${profileToDelete?.name}" and all its associated data.`}
|
|
confirmButtonText="Delete Profile"
|
|
isLoading={isDeleting}
|
|
/>
|
|
{profileForInfoDialog &&
|
|
(() => {
|
|
const infoProfile = profileForInfoDialog;
|
|
const infoIsRunning =
|
|
browserState.isClient && runningProfiles.has(infoProfile.id);
|
|
const infoIsLaunching = launchingProfiles.has(infoProfile.id);
|
|
const infoIsStopping = stoppingProfiles.has(infoProfile.id);
|
|
const infoIsCrossOs = isCrossOsProfile(infoProfile);
|
|
const infoIsDisabled =
|
|
infoIsRunning || infoIsLaunching || infoIsStopping || infoIsCrossOs;
|
|
return (
|
|
<ProfileInfoDialog
|
|
isOpen={profileForInfoDialog !== null}
|
|
onClose={() => setProfileForInfoDialog(null)}
|
|
profile={infoProfile}
|
|
storedProxies={storedProxies}
|
|
vpnConfigs={vpnConfigs}
|
|
onOpenTrafficDialog={(profileId) => {
|
|
const profile = profiles.find((p) => p.id === profileId);
|
|
setTrafficDialogProfile({ id: profileId, name: profile?.name });
|
|
}}
|
|
onOpenProfileSyncDialog={onOpenProfileSyncDialog}
|
|
onAssignProfilesToGroup={onAssignProfilesToGroup}
|
|
onConfigureCamoufox={onConfigureCamoufox}
|
|
onCopyCookiesToProfile={onCopyCookiesToProfile}
|
|
onOpenCookieManagement={onOpenCookieManagement}
|
|
onAssignExtensionGroup={onAssignExtensionGroup}
|
|
onOpenBypassRules={(profile) => setBypassRulesProfile(profile)}
|
|
onCloneProfile={onCloneProfile}
|
|
onLaunchWithSync={onLaunchWithSync}
|
|
onDeleteProfile={(profile) => {
|
|
setProfileForInfoDialog(null);
|
|
setProfileToDelete(profile);
|
|
}}
|
|
crossOsUnlocked={crossOsUnlocked}
|
|
isRunning={infoIsRunning}
|
|
isDisabled={infoIsDisabled}
|
|
isCrossOs={infoIsCrossOs}
|
|
syncStatuses={syncStatuses}
|
|
/>
|
|
);
|
|
})()}
|
|
<DataTableActionBar table={table}>
|
|
<DataTableActionBarSelection table={table} />
|
|
{onBulkGroupAssignment && (
|
|
<DataTableActionBarAction
|
|
tooltip="Assign to Group"
|
|
onClick={onBulkGroupAssignment}
|
|
size="icon"
|
|
>
|
|
<LuUsers />
|
|
</DataTableActionBarAction>
|
|
)}
|
|
{onBulkProxyAssignment && (
|
|
<DataTableActionBarAction
|
|
tooltip="Assign Proxy"
|
|
onClick={onBulkProxyAssignment}
|
|
size="icon"
|
|
>
|
|
<FiWifi />
|
|
</DataTableActionBarAction>
|
|
)}
|
|
{onBulkExtensionGroupAssignment && (
|
|
<DataTableActionBarAction
|
|
tooltip={
|
|
crossOsUnlocked
|
|
? "Assign Extension Group"
|
|
: "Assign Extension Group (Pro)"
|
|
}
|
|
onClick={onBulkExtensionGroupAssignment}
|
|
size="icon"
|
|
disabled={!crossOsUnlocked}
|
|
>
|
|
<span className="relative">
|
|
<LuPuzzle />
|
|
{!crossOsUnlocked && (
|
|
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
|
|
PRO
|
|
</span>
|
|
)}
|
|
</span>
|
|
</DataTableActionBarAction>
|
|
)}
|
|
{onBulkCopyCookies && (
|
|
<DataTableActionBarAction
|
|
tooltip={crossOsUnlocked ? "Copy Cookies" : "Copy Cookies (Pro)"}
|
|
onClick={onBulkCopyCookies}
|
|
size="icon"
|
|
disabled={!crossOsUnlocked}
|
|
>
|
|
<span className="relative">
|
|
<LuCookie />
|
|
{!crossOsUnlocked && (
|
|
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
|
|
PRO
|
|
</span>
|
|
)}
|
|
</span>
|
|
</DataTableActionBarAction>
|
|
)}
|
|
{onBulkDelete && (
|
|
<DataTableActionBarAction
|
|
tooltip="Delete"
|
|
onClick={onBulkDelete}
|
|
size="icon"
|
|
variant="destructive"
|
|
className="border-destructive bg-destructive/50 hover:bg-destructive/70"
|
|
>
|
|
<LuTrash2 />
|
|
</DataTableActionBarAction>
|
|
)}
|
|
</DataTableActionBar>
|
|
{trafficDialogProfile && (
|
|
<TrafficDetailsDialog
|
|
isOpen={trafficDialogProfile !== null}
|
|
onClose={() => setTrafficDialogProfile(null)}
|
|
profileId={trafficDialogProfile.id}
|
|
profileName={trafficDialogProfile.name}
|
|
/>
|
|
)}
|
|
<ProfileBypassRulesDialog
|
|
isOpen={bypassRulesProfile !== null}
|
|
onClose={() => setBypassRulesProfile(null)}
|
|
profileId={bypassRulesProfile?.id ?? null}
|
|
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
|
|
/>
|
|
</>
|
|
);
|
|
}
|