Spaces:
Runtime error
Runtime error
| "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> | |
| ); | |
| } | |