feat: add cookies copying functionality

This commit is contained in:
zhom
2026-01-11 01:35:05 +04:00
parent e9c084d6a4
commit cddc4544b0
16 changed files with 1328 additions and 21 deletions
+41
View File
@@ -5,6 +5,7 @@ import { listen } from "@tauri-apps/api/event";
import { getCurrent } from "@tauri-apps/plugin-deep-link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
@@ -82,6 +83,10 @@ export default function Home() {
useState(false);
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
useState(false);
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
string[]
>([]);
const [selectedGroupId, setSelectedGroupId] = useState<string>("default");
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
string[]
@@ -585,6 +590,28 @@ export default function Home() {
setSelectedProfiles([]);
}, [selectedProfiles, handleAssignProfilesToProxy]);
const handleBulkCopyCookies = useCallback(() => {
if (selectedProfiles.length === 0) return;
const eligibleProfiles = profiles.filter(
(p) =>
selectedProfiles.includes(p.id) &&
(p.browser === "wayfern" || p.browser === "camoufox"),
);
if (eligibleProfiles.length === 0) {
showErrorToast(
"Cookie copy only works with Wayfern and Camoufox profiles",
);
return;
}
setSelectedProfilesForCookies(eligibleProfiles.map((p) => p.id));
setCookieCopyDialogOpen(true);
}, [selectedProfiles, profiles]);
const handleCopyCookiesToProfile = useCallback((profile: BrowserProfile) => {
setSelectedProfilesForCookies([profile.id]);
setCookieCopyDialogOpen(true);
}, []);
const handleGroupAssignmentComplete = useCallback(async () => {
// No need to manually reload - useProfileEvents will handle the update
setGroupAssignmentDialogOpen(false);
@@ -780,6 +807,7 @@ export default function Home() {
onDeleteProfile={handleDeleteProfile}
onRenameProfile={handleRenameProfile}
onConfigureCamoufox={handleConfigureCamoufox}
onCopyCookiesToProfile={handleCopyCookiesToProfile}
runningProfiles={runningProfiles}
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
@@ -790,6 +818,7 @@ export default function Home() {
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
onBulkCopyCookies={handleBulkCopyCookies}
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
onToggleProfileSync={handleToggleProfileSync}
/>
@@ -894,6 +923,18 @@ export default function Home() {
storedProxies={storedProxies}
/>
<CookieCopyDialog
isOpen={cookieCopyDialogOpen}
onClose={() => {
setCookieCopyDialogOpen(false);
setSelectedProfilesForCookies([]);
}}
selectedProfiles={selectedProfilesForCookies}
profiles={profiles}
runningProfiles={runningProfiles}
onCopyComplete={() => setSelectedProfilesForCookies([])}
/>
<DeleteConfirmationDialog
isOpen={showBulkDeleteConfirmation}
onClose={() => setShowBulkDeleteConfirmation(false)}
+561
View File
@@ -0,0 +1,561 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
LuChevronDown,
LuChevronRight,
LuCookie,
LuSearch,
} from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getBrowserIcon } from "@/lib/browser-utils";
import type {
BrowserProfile,
CookieCopyRequest,
CookieCopyResult,
CookieReadResult,
DomainCookies,
SelectedCookie,
UnifiedCookie,
} from "@/types";
import { RippleButton } from "./ui/ripple";
interface CookieCopyDialogProps {
isOpen: boolean;
onClose: () => void;
selectedProfiles: string[];
profiles: BrowserProfile[];
runningProfiles: Set<string>;
onCopyComplete?: () => void;
}
type SelectionState = {
[domain: string]: {
allSelected: boolean;
cookies: Set<string>;
};
};
export function CookieCopyDialog({
isOpen,
onClose,
selectedProfiles,
profiles,
runningProfiles,
onCopyComplete,
}: CookieCopyDialogProps) {
const [sourceProfileId, setSourceProfileId] = useState<string | null>(null);
const [cookieData, setCookieData] = useState<CookieReadResult | null>(null);
const [isLoadingCookies, setIsLoadingCookies] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [selection, setSelection] = useState<SelectionState>({});
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(
new Set(),
);
const [error, setError] = useState<string | null>(null);
const eligibleSourceProfiles = useMemo(() => {
return profiles.filter(
(p) => p.browser === "wayfern" || p.browser === "camoufox",
);
}, [profiles]);
const targetProfiles = useMemo(() => {
return profiles.filter(
(p) =>
selectedProfiles.includes(p.id) &&
p.id !== sourceProfileId &&
(p.browser === "wayfern" || p.browser === "camoufox"),
);
}, [profiles, selectedProfiles, sourceProfileId]);
const filteredDomains = useMemo(() => {
if (!cookieData) return [];
if (!searchQuery.trim()) return cookieData.domains;
const query = searchQuery.toLowerCase();
return cookieData.domains.filter(
(d) =>
d.domain.toLowerCase().includes(query) ||
d.cookies.some((c) => c.name.toLowerCase().includes(query)),
);
}, [cookieData, searchQuery]);
const selectedCookieCount = useMemo(() => {
let count = 0;
for (const domain of Object.keys(selection)) {
const domainSelection = selection[domain];
if (domainSelection.allSelected) {
const domainData = cookieData?.domains.find((d) => d.domain === domain);
count += domainData?.cookie_count || 0;
} else {
count += domainSelection.cookies.size;
}
}
return count;
}, [selection, cookieData]);
const loadCookies = useCallback(async (profileId: string) => {
setIsLoadingCookies(true);
setError(null);
setCookieData(null);
setSelection({});
try {
const result = await invoke<CookieReadResult>("read_profile_cookies", {
profileId,
});
setCookieData(result);
} catch (err) {
console.error("Failed to load cookies:", err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsLoadingCookies(false);
}
}, []);
const handleSourceChange = useCallback(
(profileId: string) => {
setSourceProfileId(profileId);
void loadCookies(profileId);
},
[loadCookies],
);
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setSelection((prev) => {
const current = prev[domain];
const allSelected = current?.allSelected || false;
if (allSelected) {
const newSelection = { ...prev };
delete newSelection[domain];
return newSelection;
} else {
return {
...prev,
[domain]: {
allSelected: true,
cookies: new Set(cookies.map((c) => c.name)),
},
};
}
});
},
[],
);
const toggleCookie = useCallback(
(domain: string, cookieName: string, totalCookies: number) => {
setSelection((prev) => {
const current = prev[domain] || {
allSelected: false,
cookies: new Set<string>(),
};
const newCookies = new Set(current.cookies);
if (newCookies.has(cookieName)) {
newCookies.delete(cookieName);
} else {
newCookies.add(cookieName);
}
const allSelected = newCookies.size === totalCookies;
if (newCookies.size === 0) {
const newSelection = { ...prev };
delete newSelection[domain];
return newSelection;
}
return {
...prev,
[domain]: {
allSelected,
cookies: newCookies,
},
};
});
},
[],
);
const toggleExpand = useCallback((domain: string) => {
setExpandedDomains((prev) => {
const next = new Set(prev);
if (next.has(domain)) {
next.delete(domain);
} else {
next.add(domain);
}
return next;
});
}, []);
const buildSelectedCookies = useCallback((): SelectedCookie[] => {
const result: SelectedCookie[] = [];
for (const [domain, domainSelection] of Object.entries(selection)) {
if (domainSelection.allSelected) {
result.push({ domain, name: "" });
} else {
for (const cookieName of domainSelection.cookies) {
result.push({ domain, name: cookieName });
}
}
}
return result;
}, [selection]);
const handleCopy = useCallback(async () => {
if (!sourceProfileId || targetProfiles.length === 0) return;
const runningTargets = targetProfiles.filter((p) =>
runningProfiles.has(p.id),
);
if (runningTargets.length > 0) {
toast.error(
`Cannot copy cookies: ${runningTargets.map((p) => p.name).join(", ")} ${
runningTargets.length === 1 ? "is" : "are"
} still running`,
);
return;
}
setIsCopying(true);
setError(null);
try {
const selectedCookies = buildSelectedCookies();
const request: CookieCopyRequest = {
source_profile_id: sourceProfileId,
target_profile_ids: targetProfiles.map((p) => p.id),
selected_cookies: selectedCookies,
};
const results = await invoke<CookieCopyResult[]>("copy_profile_cookies", {
request,
});
let totalCopied = 0;
let totalReplaced = 0;
const errors: string[] = [];
for (const result of results) {
totalCopied += result.cookies_copied;
totalReplaced += result.cookies_replaced;
errors.push(...result.errors);
}
if (errors.length > 0) {
toast.error(`Some errors occurred: ${errors.join(", ")}`);
} else {
toast.success(
`Successfully copied ${totalCopied + totalReplaced} cookies (${totalReplaced} replaced)`,
);
onCopyComplete?.();
onClose();
}
} catch (err) {
console.error("Failed to copy cookies:", err);
toast.error(
`Failed to copy cookies: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
setIsCopying(false);
}
}, [
sourceProfileId,
targetProfiles,
runningProfiles,
buildSelectedCookies,
onCopyComplete,
onClose,
]);
useEffect(() => {
if (isOpen) {
setSourceProfileId(null);
setCookieData(null);
setSelection({});
setSearchQuery("");
setExpandedDomains(new Set());
setError(null);
}
}, [isOpen]);
const canCopy =
sourceProfileId &&
targetProfiles.length > 0 &&
selectedCookieCount > 0 &&
!isCopying;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LuCookie className="w-5 h-5" />
Copy Cookies
</DialogTitle>
<DialogDescription>
Copy cookies from a source profile to {selectedProfiles.length}{" "}
selected profile{selectedProfiles.length !== 1 ? "s" : ""}.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-4">
<div className="space-y-2">
<Label>Source Profile</Label>
<Select
value={sourceProfileId ?? undefined}
onValueChange={handleSourceChange}
>
<SelectTrigger>
<SelectValue placeholder="Select a profile to copy cookies from" />
</SelectTrigger>
<SelectContent>
{eligibleSourceProfiles.map((profile) => {
const IconComponent = getBrowserIcon(profile.browser);
const isRunning = runningProfiles.has(profile.id);
return (
<SelectItem
key={profile.id}
value={profile.id}
disabled={isRunning}
>
<div className="flex items-center gap-2">
{IconComponent && <IconComponent className="w-4 h-4" />}
<span>{profile.name}</span>
{isRunning && (
<span className="text-xs text-muted-foreground">
(running)
</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Target Profiles ({targetProfiles.length})</Label>
<div className="p-2 bg-muted rounded-md max-h-20 overflow-y-auto">
{targetProfiles.length === 0 ? (
<p className="text-sm text-muted-foreground">
{sourceProfileId
? "No other Wayfern/Camoufox profiles selected"
: "Select a source profile first"}
</p>
) : (
<div className="flex flex-wrap gap-1">
{targetProfiles.map((p) => (
<span
key={p.id}
className="inline-flex items-center gap-1 px-2 py-0.5 bg-background rounded text-sm"
>
{p.name}
{runningProfiles.has(p.id) && (
<span className="text-xs text-destructive">
(running)
</span>
)}
</span>
))}
</div>
)}
</div>
</div>
{sourceProfileId && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>
Select Cookies{" "}
{cookieData && (
<span className="text-muted-foreground">
({selectedCookieCount} of {cookieData.total_count}{" "}
selected)
</span>
)}
</Label>
</div>
<div className="relative">
<LuSearch className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search domains or cookies..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
{isLoadingCookies ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin h-6 w-6 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="p-4 text-center text-destructive bg-destructive/10 rounded-md">
{error}
</div>
) : filteredDomains.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
{searchQuery
? "No matching cookies found"
: "No cookies found"}
</div>
) : (
<ScrollArea className="h-[250px] border rounded-md">
<div className="p-2 space-y-1">
{filteredDomains.map((domain) => (
<DomainRow
key={domain.domain}
domain={domain}
selection={selection}
isExpanded={expandedDomains.has(domain.domain)}
onToggleDomain={toggleDomain}
onToggleCookie={toggleCookie}
onToggleExpand={toggleExpand}
/>
))}
</div>
</ScrollArea>
)}
<p className="text-xs text-muted-foreground">
Existing cookies with the same name and domain will be replaced.
Other cookies will be kept.
</p>
</div>
)}
</div>
<DialogFooter>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isCopying}
>
Cancel
</RippleButton>
<LoadingButton
isLoading={isCopying}
onClick={() => void handleCopy()}
disabled={!canCopy}
>
Copy {selectedCookieCount > 0 ? `${selectedCookieCount} ` : ""}
Cookie{selectedCookieCount !== 1 ? "s" : ""}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface DomainRowProps {
domain: DomainCookies;
selection: SelectionState;
isExpanded: boolean;
onToggleDomain: (domain: string, cookies: UnifiedCookie[]) => void;
onToggleCookie: (
domain: string,
cookieName: string,
totalCookies: number,
) => void;
onToggleExpand: (domain: string) => void;
}
function DomainRow({
domain,
selection,
isExpanded,
onToggleDomain,
onToggleCookie,
onToggleExpand,
}: DomainRowProps) {
const domainSelection = selection[domain.domain];
const isAllSelected = domainSelection?.allSelected || false;
const selectedCount = domainSelection?.cookies.size || 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
return (
<div>
<div className="flex items-center gap-2 p-2 hover:bg-accent/50 rounded">
<Checkbox
checked={isAllSelected || isPartial}
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
className={isPartial ? "opacity-70" : ""}
/>
<button
type="button"
className="flex items-center gap-1 flex-1 text-left bg-transparent border-none cursor-pointer"
onClick={() => onToggleExpand(domain.domain)}
>
{isExpanded ? (
<LuChevronDown className="w-4 h-4" />
) : (
<LuChevronRight className="w-4 h-4" />
)}
<span className="font-medium">{domain.domain}</span>
<span className="text-xs text-muted-foreground">
({domain.cookie_count})
</span>
</button>
</div>
{isExpanded && (
<div className="ml-8 pl-2 border-l space-y-1">
{domain.cookies.map((cookie) => {
const isSelected =
domainSelection?.cookies.has(cookie.name) || false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
>
<Checkbox
checked={isSelected || isAllSelected}
onCheckedChange={() =>
onToggleCookie(
domain.domain,
cookie.name,
domain.cookie_count,
)
}
/>
<span className="truncate">{cookie.name}</span>
</div>
);
})}
</div>
)}
</div>
);
}
+18
View File
@@ -634,6 +634,15 @@ export function CreateProfileDialog({
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!isCreateDisabled &&
!isCreating
) {
handleCreate();
}
}}
placeholder="Enter profile name"
/>
</div>
@@ -967,6 +976,15 @@ export function CreateProfileDialog({
id="profile-name"
value={profileName}
onChange={(e) => setProfileName(e.target.value)}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
!isCreateDisabled &&
!isCreating
) {
handleCreate();
}
}}
placeholder="Enter profile name"
/>
</div>
+18 -4
View File
@@ -54,6 +54,7 @@ import {
LuDownload,
LuRefreshCw,
LuTriangleAlert,
LuX,
} from "react-icons/lu";
import type { ExternalToast } from "sonner";
import { RippleButton } from "./ui/ripple";
@@ -64,6 +65,7 @@ interface BaseToastProps {
description?: string;
duration?: number;
action?: ExternalToast["action"];
onCancel?: () => void;
}
interface LoadingToastProps extends BaseToastProps {
@@ -163,7 +165,7 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
}
export function UnifiedToast(props: ToastProps) {
const { title, description, type, action } = props;
const { title, description, type, action, onCancel } = props;
const stage = "stage" in props ? props.stage : undefined;
const progress = "progress" in props ? props.progress : undefined;
@@ -171,9 +173,21 @@ export function UnifiedToast(props: ToastProps) {
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold leading-tight text-foreground">
{title}
</p>
<div className="flex items-center justify-between">
<p className="text-sm font-semibold leading-tight text-foreground">
{title}
</p>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="ml-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors flex-shrink-0"
aria-label="Cancel"
>
<LuX className="w-3 h-3" />
</button>
)}
</div>
{/* Download progress */}
{type === "download" &&
+3 -2
View File
@@ -1,4 +1,5 @@
import { LuLoaderCircle } from "react-icons/lu";
import { cn } from "@/lib/utils";
import {
type RippleButtonProps as ButtonProps,
RippleButton as UIButton,
@@ -8,10 +9,10 @@ type Props = ButtonProps & {
isLoading: boolean;
"aria-label"?: string;
};
export const LoadingButton = ({ isLoading, ...props }: Props) => {
export const LoadingButton = ({ isLoading, className, ...props }: Props) => {
return (
<UIButton
className="grid place-items-center"
className={cn("grid place-items-center", className)}
{...props}
disabled={props.disabled || isLoading}
>
+31 -1
View File
@@ -19,6 +19,7 @@ import {
LuCheck,
LuChevronDown,
LuChevronUp,
LuCookie,
LuTrash2,
LuUsers,
} from "react-icons/lu";
@@ -157,6 +158,7 @@ type TableMeta = {
// Overflow actions
onAssignProfilesToGroup?: (profileIds: string[]) => void;
onConfigureCamoufox?: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
// Traffic snapshots (lightweight real-time data)
trafficSnapshots: Record<string, TrafficSnapshot>;
@@ -672,6 +674,7 @@ interface ProfilesDataTableProps {
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
onConfigureCamoufox: (profile: BrowserProfile) => void;
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
@@ -682,6 +685,7 @@ interface ProfilesDataTableProps {
onBulkDelete?: () => void;
onBulkGroupAssignment?: () => void;
onBulkProxyAssignment?: () => void;
onBulkCopyCookies?: () => void;
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
onToggleProfileSync?: (profile: BrowserProfile) => void;
}
@@ -693,6 +697,7 @@ export function ProfilesDataTable({
onDeleteProfile,
onRenameProfile,
onConfigureCamoufox,
onCopyCookiesToProfile,
runningProfiles,
isUpdating,
onAssignProfilesToGroup,
@@ -701,6 +706,7 @@ export function ProfilesDataTable({
onBulkDelete,
onBulkGroupAssignment,
onBulkProxyAssignment,
onBulkCopyCookies,
onOpenProfileSyncDialog,
onToggleProfileSync,
}: ProfilesDataTableProps) {
@@ -1115,8 +1121,10 @@ export function ProfilesDataTable({
if (!profileToDelete) return;
setIsDeleting(true);
// Minimum loading time for visual feedback
const minLoadingTime = new Promise((r) => setTimeout(r, 300));
try {
await onDeleteProfile(profileToDelete);
await Promise.all([onDeleteProfile(profileToDelete), minLoadingTime]);
setProfileToDelete(null);
} catch (error) {
console.error("Failed to delete profile:", error);
@@ -1302,6 +1310,7 @@ export function ProfilesDataTable({
// Overflow actions
onAssignProfilesToGroup,
onConfigureCamoufox,
onCopyCookiesToProfile,
// Traffic snapshots (lightweight real-time data)
trafficSnapshots,
@@ -1350,6 +1359,7 @@ export function ProfilesDataTable({
onLaunchProfile,
onAssignProfilesToGroup,
onConfigureCamoufox,
onCopyCookiesToProfile,
syncStatuses,
onOpenProfileSyncDialog,
onToggleProfileSync,
@@ -2010,6 +2020,17 @@ export function ProfilesDataTable({
Change Fingerprint
</DropdownMenuItem>
)}
{(profile.browser === "camoufox" ||
profile.browser === "wayfern") &&
meta.onCopyCookiesToProfile && (
<DropdownMenuItem
onClick={() => {
meta.onCopyCookiesToProfile?.(profile);
}}
>
Copy Cookies to Profile
</DropdownMenuItem>
)}
{meta.onOpenProfileSyncDialog && (
<DropdownMenuItem
onClick={() => {
@@ -2160,6 +2181,15 @@ export function ProfilesDataTable({
<FiWifi />
</DataTableActionBarAction>
)}
{onBulkCopyCookies && (
<DataTableActionBarAction
tooltip="Copy Cookies"
onClick={onBulkCopyCookies}
size="icon"
>
<LuCookie />
</DataTableActionBarAction>
)}
{onBulkDelete && (
<DataTableActionBarAction
tooltip="Delete"
+1 -1
View File
@@ -56,7 +56,7 @@ export function CopyToClipboard({
}`}
/>
<LuCheck
className={`absolute inset-0 m-auto h-4 w-4 transition-all duration-300 ${
className={`absolute inset-0 m-auto h-4 w-4 text-foreground transition-all duration-300 ${
copied ? "scale-100" : "scale-0"
}`}
/>
+14 -5
View File
@@ -284,11 +284,20 @@ export function useBrowserDownload() {
? formatTime(progress.eta_seconds)
: "calculating...";
showDownloadToast(browserName, progress.version, "downloading", {
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
});
const toastId = `download-${browserName.toLowerCase()}-${progress.version}`;
showDownloadToast(
browserName,
progress.version,
"downloading",
{
percentage: progress.percentage,
speed: speedMBps,
eta: etaText,
},
{
onCancel: () => dismissToast(toastId),
},
);
} else if (progress.stage === "extracting") {
showDownloadToast(browserName, progress.version, "extracting");
} else if (progress.stage === "verifying") {
+1
View File
@@ -92,6 +92,7 @@ export function useVersionUpdater() {
found: progress.new_versions_found,
current_browser: currentBrowserName,
},
onCancel: () => dismissToast("unified-version-update"),
});
} else if (progress.status === "completed") {
setIsUpdating(false);
+9 -1
View File
@@ -8,6 +8,7 @@ interface BaseToastProps {
description?: string;
duration?: number;
action?: ExternalToast["action"];
onCancel?: () => void;
}
interface LoadingToastProps extends BaseToastProps {
@@ -143,7 +144,7 @@ export function showDownloadToast(
| "completed"
| "downloading (twilight rolling release)",
progress?: { percentage: number; speed?: string; eta?: string },
options?: { suppressCompletionToast?: boolean },
options?: { suppressCompletionToast?: boolean; onCancel?: () => void },
) {
const title =
stage === "completed"
@@ -162,12 +163,18 @@ export function showDownloadToast(
return;
}
// Only show cancel button during active downloading, not for completed/extracting/verifying
const showCancel =
stage === "downloading" ||
stage === "downloading (twilight rolling release)";
return showToast({
type: "download",
title,
stage,
progress,
id: `download-${browserName.toLowerCase()}-${version}`,
onCancel: showCancel ? options?.onCancel : undefined,
});
}
@@ -237,6 +244,7 @@ export function showUnifiedVersionUpdateToast(
current_browser?: string;
};
duration?: number;
onCancel?: () => void;
},
) {
return showToast({
+45
View File
@@ -371,3 +371,48 @@ export interface FilteredTrafficStats {
domains: Record<string, DomainAccess>;
unique_ips: string[];
}
// Cookie copy types
export interface UnifiedCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
is_secure: boolean;
is_http_only: boolean;
same_site: number;
creation_time: number;
last_accessed: number;
}
export interface DomainCookies {
domain: string;
cookies: UnifiedCookie[];
cookie_count: number;
}
export interface CookieReadResult {
profile_id: string;
browser_type: string;
domains: DomainCookies[];
total_count: number;
}
export interface SelectedCookie {
domain: string;
name: string;
}
export interface CookieCopyRequest {
source_profile_id: string;
target_profile_ids: string[];
selected_cookies: SelectedCookie[];
}
export interface CookieCopyResult {
target_profile_id: string;
cookies_copied: number;
cookies_replaced: number;
errors: string[];
}