icebear0828
refactor: migrate electron from branch to npm workspace
178e38e
raw
history blame
10.9 kB
import { useState, useCallback, useRef } from "preact/hooks";
import { useT } from "@shared/i18n/context";
import type { TranslationKey } from "@shared/i18n/translations";
import type { AssignmentAccount } from "@shared/hooks/use-proxy-assignments";
import type { ProxyEntry } from "@shared/types";
const PAGE_SIZE = 50;
const statusStyles: Record<string, [string, TranslationKey]> = {
active: [
"bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
"active",
],
expired: [
"bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
"expired",
],
rate_limited: [
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30",
"rateLimited",
],
refreshing: [
"bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30",
"refreshing",
],
disabled: [
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
"disabled",
],
};
interface AccountTableProps {
accounts: AssignmentAccount[];
proxies: ProxyEntry[];
selectedIds: Set<string>;
onSelectionChange: (ids: Set<string>) => void;
onSingleProxyChange: (accountId: string, proxyId: string) => void;
filterGroup: string | null;
statusFilter: string;
onStatusFilterChange: (status: string) => void;
}
export function AccountTable({
accounts,
proxies,
selectedIds,
onSelectionChange,
onSingleProxyChange,
filterGroup,
statusFilter,
onStatusFilterChange,
}: AccountTableProps) {
const t = useT();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const lastClickedIndex = useRef<number | null>(null);
// Filter accounts
let filtered = accounts;
// By group
if (filterGroup) {
filtered = filtered.filter((a) => a.proxyId === filterGroup);
}
// By status
if (statusFilter && statusFilter !== "all") {
filtered = filtered.filter((a) => a.status === statusFilter);
}
// By search
if (search) {
const lower = search.toLowerCase();
filtered = filtered.filter((a) => a.email.toLowerCase().includes(lower));
}
const totalCount = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageAccounts = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const pageAccountsRef = useRef(pageAccounts);
pageAccountsRef.current = pageAccounts;
// Reset page when filters change
const handleSearch = useCallback((value: string) => {
setSearch(value);
setPage(0);
}, []);
const handleStatusFilter = useCallback(
(value: string) => {
onStatusFilterChange(value);
setPage(0);
},
[onStatusFilterChange],
);
// Select/deselect
const toggleSelect = useCallback(
(id: string, index: number, shiftKey: boolean) => {
const newSet = new Set(selectedIds);
const currentPage = pageAccountsRef.current;
if (shiftKey && lastClickedIndex.current !== null) {
// Range select
const start = Math.min(lastClickedIndex.current, index);
const end = Math.max(lastClickedIndex.current, index);
for (let i = start; i <= end; i++) {
if (currentPage[i]) {
newSet.add(currentPage[i].id);
}
}
} else {
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
}
lastClickedIndex.current = index;
onSelectionChange(newSet);
},
[selectedIds, onSelectionChange],
);
const toggleSelectAll = useCallback(() => {
const currentPage = pageAccountsRef.current;
const pageIds = currentPage.map((a) => a.id);
const allSelected = pageIds.every((id) => selectedIds.has(id));
const newSet = new Set(selectedIds);
if (allSelected) {
for (const id of pageIds) newSet.delete(id);
} else {
for (const id of pageIds) newSet.add(id);
}
onSelectionChange(newSet);
}, [selectedIds, onSelectionChange]);
const allPageSelected = pageAccounts.length > 0 && pageAccounts.every((a) => selectedIds.has(a.id));
return (
<div class="flex flex-col gap-3">
{/* Filters */}
<div class="flex items-center gap-2">
<input
type="text"
placeholder={t("searchAccount")}
value={search}
onInput={(e) => handleSearch((e.target as HTMLInputElement).value)}
class="flex-1 px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
/>
<select
value={statusFilter}
onChange={(e) => handleStatusFilter((e.target as HTMLSelectElement).value)}
class="px-3 py-2 text-sm border border-gray-200 dark:border-border-dark rounded-lg bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
>
<option value="all">{t("allStatuses")}</option>
<option value="active">{t("active")}</option>
<option value="expired">{t("expired")}</option>
<option value="rate_limited">{t("rateLimited")}</option>
<option value="disabled">{t("disabled")}</option>
</select>
</div>
{/* Table */}
<div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden">
{/* Table Header */}
<div class="flex items-center gap-3 px-4 py-2.5 border-b border-gray-100 dark:border-border-dark bg-slate-50 dark:bg-bg-dark text-xs text-slate-500 dark:text-text-dim font-medium">
<label class="flex items-center cursor-pointer shrink-0" title={t("selectAll")}>
<input
type="checkbox"
checked={allPageSelected}
onChange={toggleSelectAll}
class="rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer"
/>
</label>
<span class="flex-1 min-w-0">Email</span>
<span class="w-20 text-center hidden sm:block">{t("statusFilter")}</span>
<span class="w-40 text-center hidden md:block">{t("proxyAssignment")}</span>
</div>
{/* Rows */}
{pageAccounts.length === 0 ? (
<div class="px-4 py-8 text-center text-sm text-slate-400 dark:text-text-dim">
{t("noAccounts")}
</div>
) : (
pageAccounts.map((acct, idx) => {
const isSelected = selectedIds.has(acct.id);
const [statusCls, statusKey] = statusStyles[acct.status] || statusStyles.disabled;
return (
<div
key={acct.id}
class={`flex items-center gap-3 px-4 py-2.5 border-b border-gray-50 dark:border-border-dark/50 transition-colors cursor-pointer ${
isSelected
? "bg-primary/5 dark:bg-primary/10"
: "hover:bg-slate-50 dark:hover:bg-border-dark/30"
}`}
onClick={(e) => {
if ((e.target as HTMLElement).tagName === "SELECT") return;
toggleSelect(acct.id, idx, e.shiftKey);
}}
>
<label class="flex items-center shrink-0" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleSelect(acct.id, idx, false)}
class="rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary cursor-pointer"
/>
</label>
<span class="flex-1 min-w-0 text-sm font-medium truncate text-slate-700 dark:text-text-main">
{acct.email}
</span>
<span class="w-20 hidden sm:flex justify-center">
<span class={`px-2 py-0.5 rounded-full text-[0.65rem] font-medium border ${statusCls}`}>
{t(statusKey)}
</span>
</span>
<span class="w-40 hidden md:block" onClick={(e) => e.stopPropagation()}>
<select
value={acct.proxyId || "global"}
onChange={(e) => onSingleProxyChange(acct.id, (e.target as HTMLSelectElement).value)}
class="w-full text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-border-dark bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
>
<option value="global">{t("globalDefault")}</option>
<option value="direct">{t("directNoProxy")}</option>
<option value="auto">{t("autoRoundRobin")}</option>
{proxies.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
</option>
))}
</select>
</span>
</div>
);
})
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div class="flex items-center justify-between text-xs text-slate-500 dark:text-text-dim">
<span>
{t("totalItems")} {totalCount}
</span>
<div class="flex items-center gap-2">
<button
onClick={() => setPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
class="px-2.5 py-1 rounded-md border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("prevPage")}
</button>
<span class="font-medium">
{safePage + 1} / {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
class="px-2.5 py-1 rounded-md border border-gray-200 dark:border-border-dark hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("nextPage")}
</button>
</div>
</div>
)}
{/* Hint */}
<p class="text-[0.65rem] text-slate-400 dark:text-text-dim text-center">
{t("shiftSelectHint")}
</p>
</div>
);
}