Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
10.5 kB
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
import SourceToggleBar, {
type SourceId,
ALL_SOURCE_IDS,
loadDisabledSources,
saveDisabledSources,
} from "./SourceToggleBar";
import FreeProxyRow, { type FreeProxyRowData } from "./FreeProxyRow";
type FreePoolStats = {
total: number;
inPool: number;
avgQuality: number | null;
lastSyncAt: string | null;
};
export default function FreePoolTab() {
const t = useTranslations("settings");
const [proxies, setProxies] = useState<FreeProxyRowData[]>([]);
const [stats, setStats] = useState<FreePoolStats | null>(null);
const [disabledSources, setDisabledSources] = useState<Set<SourceId>>(new Set());
const [filterProtocol, setFilterProtocol] = useState("");
const [filterCountry, setFilterCountry] = useState("");
const [minQuality, setMinQuality] = useState("");
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [addingIds, setAddingIds] = useState<Set<string>>(new Set());
const [bulkProgress, setBulkProgress] = useState<string | null>(null);
// Load persisted disabled-sources from localStorage on mount
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage hydration, runs once
setDisabledSources(loadDisabledSources());
}, []);
const handleToggleSource = useCallback((source: SourceId) => {
setDisabledSources((prev) => {
const next = new Set(prev);
if (next.has(source)) next.delete(source);
else next.add(source);
saveDisabledSources(next);
return next;
});
}, []);
const loadData = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s));
if (enabledSources.length < ALL_SOURCE_IDS.length) {
params.set("sources", enabledSources.join(","));
}
if (filterProtocol) params.set("protocol", filterProtocol);
if (filterCountry) params.set("country", filterCountry);
if (minQuality) params.set("minQuality", minQuality);
params.set("limit", "200");
const [proxiesRes, statsRes] = await Promise.all([
fetch(`/api/settings/free-proxies?${params.toString()}`),
fetch("/api/settings/free-proxies/stats"),
]);
if (proxiesRes.ok) {
const data = await proxiesRes.json();
setProxies(data.items || []);
}
if (statsRes.ok) {
const data = await statsRes.json();
setStats(data.stats || null);
}
} catch {}
setLoading(false);
}, [disabledSources, filterProtocol, filterCountry, minQuality]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on filter change
loadData();
}, [loadData]);
const handleSync = async () => {
setSyncing(true);
try {
const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s));
const body = enabledSources.length < ALL_SOURCE_IDS.length ? { sources: enabledSources } : {};
await fetch("/api/settings/free-proxies/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
await loadData();
} catch {}
setSyncing(false);
};
const handleAddToPool = async (id: string) => {
setAddingIds((prev) => new Set(prev).add(id));
try {
const res = await fetch(`/api/settings/free-proxies/${id}/add-to-pool`, {
method: "POST",
});
// #4878: gate on the parsed body, not just res.ok. The route used to return
// a default 200 with { success:false } on a failed connectivity probe, which
// flipped the row to "In Pool" optimistically even though nothing was added.
const data = await res.json().catch(() => null);
if (res.ok && data?.success) {
setProxies((prev) => prev.map((p) => (p.id === id ? { ...p, inPool: true } : p)));
}
} catch {}
setAddingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
};
const handleToggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleBulkAdd = async (ids: string[]) => {
if (!ids.length) return;
setBulkProgress("Testing proxies...");
try {
const res = await fetch("/api/settings/free-proxies/bulk-add-to-pool", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
const data = await res.json();
setBulkProgress(`${data.succeeded ?? 0} added, ${data.failed ?? 0} failed`);
await loadData();
setSelected(new Set());
} catch {}
setTimeout(() => setBulkProgress(null), 4000);
};
const notInPoolProxies = proxies.filter((p) => !p.inPool);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<SourceToggleBar disabledSources={disabledSources} onToggle={handleToggleSource} />
<div className="flex gap-2 ml-auto flex-wrap items-center">
<select
value={filterProtocol}
onChange={(e) => setFilterProtocol(e.target.value)}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1"
aria-label={t("proxyFreePoolFilterProtocol")}
>
<option value="">{t("proxyFreePoolProtocol")}</option>
{["http", "https", "socks4", "socks5"].map((p) => (
<option key={p} value={p}>
{p.toUpperCase()}
</option>
))}
</select>
<input
type="text"
placeholder={t("proxyFreePoolCountryPlaceholder")}
value={filterCountry}
onChange={(e) => setFilterCountry(e.target.value.toUpperCase().slice(0, 2))}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-28"
aria-label={t("proxyFreePoolFilterCountry")}
/>
<input
type="number"
placeholder={t("proxyFreePoolMinQualityPlaceholder")}
value={minQuality}
onChange={(e) => setMinQuality(e.target.value)}
min={0}
max={100}
className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-24"
aria-label={t("proxyFreePoolMinQualityLabel")}
/>
<Button size="sm" variant="secondary" icon="sync" onClick={handleSync} disabled={syncing}>
{syncing ? t("syncing") : t("proxyFreePoolSyncAll")}
</Button>
</div>
</div>
{stats && (
<div className="text-xs text-text-muted flex gap-4 flex-wrap">
<span>
{t("proxyFreePoolTotal")}: {stats.total}
</span>
<span>
{t("proxyFreePoolInPool")}: {stats.inPool}
</span>
{stats.avgQuality != null && (
<span>
{t("proxyFreePoolAvgQuality")}: {stats.avgQuality}
</span>
)}
{stats.lastSyncAt && (
<span>
{t("lastSync")}: {new Date(stats.lastSyncAt).toLocaleTimeString()}
</span>
)}
</div>
)}
{selected.size > 0 && (
<div className="flex items-center gap-2 p-2 bg-primary/10 rounded border border-primary/20">
<span className="text-xs">{t("proxyFreePoolSelected", { count: selected.size })}</span>
<Button size="sm" variant="primary" onClick={() => handleBulkAdd(Array.from(selected))}>
{t("proxyFreePoolAddSelected")}
</Button>
{bulkProgress && <span className="text-xs text-text-muted">{bulkProgress}</span>}
</div>
)}
{notInPoolProxies.length > 0 && selected.size === 0 && (
<div className="flex justify-end">
<Button
size="sm"
variant="secondary"
onClick={() => handleBulkAdd(notInPoolProxies.slice(0, 100).map((p) => p.id))}
>
{t("proxyFreePoolAddVisible")}
</Button>
</div>
)}
<div className="overflow-x-auto rounded border border-border bg-surface">
<table className="w-full text-sm">
<thead className="bg-surface-alt text-text-muted text-xs">
<tr>
<th className="px-3 py-2 text-left w-8" scope="col"></th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolSource")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolHostPort")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolType")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolCountry")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolQuality")}
</th>
<th className="px-3 py-2 text-left" scope="col">
{t("proxyFreePoolLatency")}
</th>
<th className="px-3 py-2 text-left" scope="col"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-text-muted">
{t("loading")}
</td>
</tr>
) : proxies.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-text-muted">
{t("proxyFreePoolEmpty")}
</td>
</tr>
) : (
proxies.map((p) => (
<FreeProxyRow
key={p.id}
proxy={p}
selected={selected.has(p.id)}
onToggleSelect={handleToggleSelect}
onAddToPool={handleAddToPool}
adding={addingIds.has(p.id)}
/>
))
)}
</tbody>
</table>
</div>
</div>
);
}