Files
donutbrowser/src/components/cookie-management-dialog.tsx
T
2026-05-15 15:44:20 +04:00

687 lines
22 KiB
TypeScript

"use client";
import { invoke } from "@tauri-apps/api/core";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Label } from "@/components/ui/label";
import { RippleButton } from "@/components/ui/ripple";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
BrowserProfile,
CookieReadResult,
DomainCookies,
UnifiedCookie,
} from "@/types";
interface CookieImportResult {
cookies_imported: number;
cookies_replaced: number;
errors: string[];
}
interface CookieManagementDialogProps {
isOpen: boolean;
onClose: () => void;
profile: BrowserProfile | null;
initialTab?: "import" | "export";
}
type SelectionState = Record<
string,
{
allSelected: boolean;
cookies: Set<string>;
}
>;
const countCookies = (content: string): number => {
const trimmed = content.trim();
if (trimmed.startsWith("[")) {
try {
const arr = JSON.parse(trimmed);
if (Array.isArray(arr)) return arr.length;
} catch {
// Fall through to Netscape counting
}
}
return content.split("\n").filter((line) => {
const l = line.trim();
return l && !l.startsWith("#");
}).length;
};
function formatJsonCookies(cookies: UnifiedCookie[]): string {
const arr = cookies.map((c) => {
const sameSite =
c.same_site === 1
? "lax"
: c.same_site === 2
? "strict"
: "no_restriction";
return {
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.is_secure,
httpOnly: c.is_http_only,
sameSite,
expirationDate: c.expires,
session: c.expires === 0,
hostOnly: !c.domain.startsWith("."),
};
});
return JSON.stringify(arr, null, 2);
}
function formatNetscapeCookies(cookies: UnifiedCookie[]): string {
const lines = ["# Netscape HTTP Cookie File"];
for (const c of cookies) {
const flag = c.domain.startsWith(".") ? "TRUE" : "FALSE";
const secure = c.is_secure ? "TRUE" : "FALSE";
lines.push(
`${c.domain}\t${flag}\t${c.path}\t${secure}\t${c.expires}\t${c.name}\t${c.value}`,
);
}
return lines.join("\n");
}
function initSelectionFromCookieData(data: CookieReadResult): SelectionState {
const sel: SelectionState = {};
for (const d of data.domains) {
sel[d.domain] = {
allSelected: true,
cookies: new Set(d.cookies.map((c) => c.name)),
};
}
return sel;
}
export function CookieManagementDialog({
isOpen,
onClose,
profile,
initialTab = "import",
}: CookieManagementDialogProps) {
const { t } = useTranslation();
// Import state
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [cookieCount, setCookieCount] = useState(0);
const [isImporting, setIsImporting] = useState(false);
const [importResult, setImportResult] = useState<CookieImportResult | null>(
null,
);
// Export state
const [format, setFormat] = useState<"netscape" | "json">("json");
const [isExporting, setIsExporting] = useState(false);
const [exportCookieData, setExportCookieData] =
useState<CookieReadResult | null>(null);
const [isLoadingExportCookies, setIsLoadingExportCookies] = useState(false);
const [exportSelection, setExportSelection] = useState<SelectionState>({});
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(
new Set(),
);
const [activeTab, setActiveTab] = useState<string>(initialTab);
const selectedExportCount = useMemo(() => {
let count = 0;
for (const domain of Object.keys(exportSelection)) {
const ds = exportSelection[domain];
if (ds.allSelected) {
const domainData = exportCookieData?.domains.find(
(d) => d.domain === domain,
);
count += domainData?.cookie_count ?? 0;
} else {
count += ds.cookies.size;
}
}
return count;
}, [exportSelection, exportCookieData]);
const loadExportCookies = useCallback(
async (profileId: string) => {
if (exportCookieData) return;
setIsLoadingExportCookies(true);
try {
const result = await invoke<CookieReadResult>("read_profile_cookies", {
profileId,
});
setExportCookieData(result);
setExportSelection(initSelectionFromCookieData(result));
} catch (err) {
toast.error(
t("cookies.management.loadFailed", {
error: err instanceof Error ? err.message : String(err),
}),
);
} finally {
setIsLoadingExportCookies(false);
}
},
[exportCookieData, t],
);
useEffect(() => {
if (activeTab === "export" && profile && !exportCookieData) {
void loadExportCookies(profile.id);
}
}, [activeTab, profile, exportCookieData, loadExportCookies]);
const resetImportState = useCallback(() => {
setFileContent(null);
setFileName(null);
setCookieCount(0);
setIsImporting(false);
setImportResult(null);
}, []);
const resetExportState = useCallback(() => {
setFormat("json");
setIsExporting(false);
setExportCookieData(null);
setExportSelection({});
setExpandedDomains(new Set());
}, []);
const handleClose = useCallback(() => {
resetImportState();
resetExportState();
setActiveTab(initialTab);
onClose();
}, [resetImportState, resetExportState, onClose, initialTab]);
const handleTabChange = useCallback(
(tab: string) => {
setActiveTab(tab);
resetImportState();
if (tab !== "export") {
resetExportState();
}
},
[resetImportState, resetExportState],
);
const handleFileRead = useCallback(
(file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
setFileContent(content);
setFileName(file.name);
setCookieCount(countCookies(content));
};
reader.onerror = () => {
toast.error(t("cookies.management.fileReadError"));
};
reader.readAsText(file);
},
[t],
);
const handleImport = useCallback(async () => {
if (!fileContent || !profile) return;
setIsImporting(true);
try {
const result = await invoke<CookieImportResult>(
"import_cookies_from_file",
{
profileId: profile.id,
content: fileContent,
},
);
setImportResult(result);
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setIsImporting(false);
}
}, [fileContent, profile]);
const getSelectedCookies = useCallback((): UnifiedCookie[] => {
if (!exportCookieData) return [];
const result: UnifiedCookie[] = [];
for (const domain of exportCookieData.domains) {
const ds = exportSelection[domain.domain];
if (!ds) continue;
if (ds.allSelected) {
result.push(...domain.cookies);
} else {
result.push(...domain.cookies.filter((c) => ds.cookies.has(c.name)));
}
}
return result;
}, [exportCookieData, exportSelection]);
const handleExport = useCallback(async () => {
if (!profile) return;
setIsExporting(true);
try {
const cookies = getSelectedCookies();
const content =
format === "json"
? formatJsonCookies(cookies)
: formatNetscapeCookies(cookies);
const ext = format === "json" ? "json" : "txt";
const defaultName = `${profile.name}_cookies.${ext}`;
const filePath = await save({
defaultPath: defaultName,
filters: [
{
name: format === "json" ? "JSON" : "Text",
extensions: [ext],
},
],
});
if (!filePath) {
setIsExporting(false);
return;
}
await writeTextFile(filePath, content);
toast.success(t("cookies.export.success"));
handleClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
} finally {
setIsExporting(false);
}
}, [profile, format, getSelectedCookies, handleClose, t]);
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setExportSelection((prev) => {
// `prev[domain]` is `undefined` when the domain was previously fully
// deselected (entries are deleted on empty — see toggleCookie). Treat
// missing as "not selected" so re-enabling falls through to the add
// branch instead of crashing on `.allSelected`.
if (prev[domain]?.allSelected) {
const next = { ...prev };
delete next[domain];
return next;
}
return {
...prev,
[domain]: {
allSelected: true,
cookies: new Set(cookies.map((c) => c.name)),
},
};
});
},
[],
);
const toggleCookie = useCallback(
(domain: string, cookieName: string, totalCookies: number) => {
setExportSelection((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);
}
if (newCookies.size === 0) {
const next = { ...prev };
delete next[domain];
return next;
}
return {
...prev,
[domain]: {
allSelected: newCookies.size === totalCookies,
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 toggleSelectAll = useCallback(() => {
if (!exportCookieData) return;
if (selectedExportCount === exportCookieData.total_count) {
setExportSelection({});
} else {
setExportSelection(initSelectionFromCookieData(exportCookieData));
}
}, [exportCookieData, selectedExportCount]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
</DialogHeader>
<Tabs
defaultValue={initialTab}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="import">
{t("cookies.management.tabImport")}
</TabsTrigger>
<TabsTrigger value="export">
{t("cookies.management.tabExport")}
</TabsTrigger>
</TabsList>
<TabsContent value="import" className="space-y-4 mt-4">
{!fileContent && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("cookies.management.importDescription")}
</p>
<div
role="button"
tabIndex={0}
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
onClick={() =>
document.getElementById("cookie-file-input")?.click()
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
document.getElementById("cookie-file-input")?.click();
}
}}
>
<LuUpload className="size-10 text-muted-foreground mb-4" />
<p className="text-sm text-muted-foreground text-center">
{t("cookies.management.dropPrompt")}
<br />
<span className="text-xs">
{t("cookies.management.fileFormats")}
</span>
</p>
<input
id="cookie-file-input"
type="file"
accept=".txt,.cookies,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileRead(file);
e.target.value = "";
}}
/>
</div>
</div>
)}
{fileContent && !importResult && (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
<div>
<div className="font-medium">{fileName}</div>
<div className="text-sm text-muted-foreground">
{t("cookies.management.cookiesFound", {
count: cookieCount,
})}
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<RippleButton variant="outline" onClick={resetImportState}>
{t("cookies.management.backButton")}
</RippleButton>
<LoadingButton
isLoading={isImporting}
onClick={() => void handleImport()}
disabled={cookieCount === 0}
>
{t("cookies.management.importButton")}
</LoadingButton>
</div>
</div>
)}
{importResult && (
<div className="space-y-4">
<div className="p-4 rounded-lg bg-success/10">
<div className="font-medium text-success">
{t("cookies.management.importedSuccess", {
imported: importResult.cookies_imported,
replaced: importResult.cookies_replaced,
})}
</div>
{importResult.errors.length > 0 && (
<div className="mt-2 text-sm text-muted-foreground">
{t("cookies.management.linesSkipped", {
count: importResult.errors.length,
})}
</div>
)}
</div>
<div className="flex justify-end">
<RippleButton onClick={handleClose}>
{t("cookies.management.doneButton")}
</RippleButton>
</div>
</div>
)}
</TabsContent>
<TabsContent value="export" className="space-y-3 mt-4">
<div className="space-y-2">
<Label>{t("cookies.export.formatLabel")}</Label>
<Select
value={format}
onValueChange={(v) => {
setFormat(v as "netscape" | "json");
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="json">
{t("cookies.export.json")}
</SelectItem>
<SelectItem value="netscape">
{t("cookies.export.netscape")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>
{t("cookies.management.cookiesLabel")}{" "}
{exportCookieData && (
<span className="text-muted-foreground font-normal">
{t("cookies.management.selectionStatus", {
selected: selectedExportCount,
total: exportCookieData.total_count,
})}
</span>
)}
</Label>
{exportCookieData && exportCookieData.total_count > 0 && (
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={toggleSelectAll}
>
{selectedExportCount === exportCookieData.total_count
? t("cookies.management.deselectAll")
: t("cookies.management.selectAll")}
</button>
)}
</div>
{isLoadingExportCookies ? (
<div className="flex items-center justify-center h-24">
<div className="animate-spin size-5 border-2 border-primary border-t-transparent rounded-full" />
</div>
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
{t("cookies.management.noCookies")}
</div>
) : (
<FadingScrollArea className="h-[200px]">
<div className="p-2 space-y-1">
{exportCookieData.domains.map((domain) => (
<ExportDomainRow
key={domain.domain}
domain={domain}
selection={exportSelection}
isExpanded={expandedDomains.has(domain.domain)}
onToggleDomain={toggleDomain}
onToggleCookie={toggleCookie}
onToggleExpand={toggleExpand}
/>
))}
</div>
</FadingScrollArea>
)}
</div>
<div className="flex justify-end gap-2">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.cancel")}
</RippleButton>
<LoadingButton
isLoading={isExporting}
onClick={() => void handleExport()}
disabled={selectedExportCount === 0}
>
{t("cookies.management.exportButton")}
</LoadingButton>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
interface ExportDomainRowProps {
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 ExportDomainRow({
domain,
selection,
isExpanded,
onToggleDomain,
onToggleCookie,
onToggleExpand,
}: ExportDomainRowProps) {
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-1.5 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 text-sm bg-transparent border-none cursor-pointer"
onClick={() => {
onToggleExpand(domain.domain);
}}
>
{isExpanded ? (
<LuChevronDown className="size-3.5" />
) : (
<LuChevronRight className="size-3.5" />
)}
<span className="font-medium truncate">{domain.domain}</span>
<span className="text-xs text-muted-foreground shrink-0">
({domain.cookie_count})
</span>
</button>
</div>
{isExpanded && (
<div className="ml-7 pl-2 border-l space-y-0.5">
{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>
);
}