"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([]); const [stats, setStats] = useState(null); const [disabledSources, setDisabledSources] = useState>(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>(new Set()); const [addingIds, setAddingIds] = useState>(new Set()); const [bulkProgress, setBulkProgress] = useState(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 (
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")} /> 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")} />
{stats && (
{t("proxyFreePoolTotal")}: {stats.total} {t("proxyFreePoolInPool")}: {stats.inPool} {stats.avgQuality != null && ( {t("proxyFreePoolAvgQuality")}: {stats.avgQuality} )} {stats.lastSyncAt && ( {t("lastSync")}: {new Date(stats.lastSyncAt).toLocaleTimeString()} )}
)} {selected.size > 0 && (
{t("proxyFreePoolSelected", { count: selected.size })} {bulkProgress && {bulkProgress}}
)} {notInPoolProxies.length > 0 && selected.size === 0 && (
)}
{loading ? ( ) : proxies.length === 0 ? ( ) : ( proxies.map((p) => ( )) )}
{t("proxyFreePoolSource")} {t("proxyFreePoolHostPort")} {t("proxyFreePoolType")} {t("proxyFreePoolCountry")} {t("proxyFreePoolQuality")} {t("proxyFreePoolLatency")}
{t("loading")}
{t("proxyFreePoolEmpty")}
); }