const { useState: useStateS, useMemo: useMemoS, useRef: useRefS, useEffect: useEffectS } = React;
function Sidebar({ active, onChange, instancesCount, sidebarStyle, codeStats, codeFolders, onSelectFolder, selectedCode, liveStatus, user, onLogout }) {
const showLabels = sidebarStyle !== "icons";
const items = [
{ id: "scheduler", label: "Scheduler", icon: I.Clock },
{ id: "instances", label: "Instances", icon: I.Server, count: instancesCount },
{ id: "refresher", label: "Refresher", icon: I.Refresh }];
return (
);
}
function SidebarItem({ active, onClick, label, icon: Cmp, count, showLabels }) {
return (
);
}
function SidebarFolder({ folder, selected, onSelect }) {
const colorForCode = useColorForCode();
const c = colorForCode(folder.code);
return (
{if (!selected) e.currentTarget.style.background = 'var(--card)';}}
onMouseLeave={(e) => {if (!selected) e.currentTarget.style.background = 'transparent';}}
onClick={onSelect}>
{selected && }
{folder.code}
{folder.size}
);
}
/* =========================================================================
TOPBAR
========================================================================= */
function Topbar({ title, subtitle, search, setSearch, right, breadcrumbs }) {
return (
);
}
function InstancesScreen({
instances, codes, codeFolders, density, tableZebra,
selected, setSelected,
filterCode, setFilterCode,
filterStatus, setFilterStatus,
onLaunch, onStop, onReset, askConfirm
}) {
const [search, setSearch] = useStateS("");
const [activeId, setActiveId] = useStateS(null);
const stats = useMemoS(() => {
const s = { running: 0, booting: 0, idle: 0, stopped: 0, error: 0 };
instances.forEach((i) => { s[i.status] = (s[i.status] || 0) + 1; });
return s;
}, [instances]);
const filtered = useMemoS(() => {
let out = instances;
if (filterCode) out = out.filter((p) => p.code === filterCode);
if (filterStatus) out = out.filter((p) => p.status === filterStatus);
if (search.trim()) {
const s = search.toLowerCase();
out = out.filter((p) =>
p.name.toLowerCase().includes(s) ||
p.id.toLowerCase().includes(s) ||
(p.code || "").toLowerCase().includes(s) ||
p.account.toLowerCase().includes(s) ||
(p.task || "").toLowerCase().includes(s)
);
}
return out;
}, [instances, search, filterCode, filterStatus]);
const groups = useMemoS(() => {
const m = new Map();
filtered.forEach((i) => {
const key = i.code || "__none__";
if (!m.has(key)) m.set(key, []);
m.get(key).push(i);
});
return [...m.entries()].map(([code, list]) => ({ code, list }));
}, [filtered]);
const [collapsed, setCollapsed] = useStateS(() => new Set());
const toggleGroup = (code) => setCollapsed((prev) => {
const n = new Set(prev);
n.has(code) ? n.delete(code) : n.add(code);
return n;
});
const visibleOrder = useMemoS(() => {
const out = [];
groups.forEach((g) => {if (!collapsed.has(g.code)) out.push(...g.list.map((i) => i.id));});
return out;
}, [groups, collapsed]);
const onRowClick = (_e, id) => setActiveId(id);
useEffectS(() => {
const onKey = (e) => {
const tag = (e.target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea") return;
if (e.key === "Escape") setActiveId(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const active = activeId ? instances.find((p) => p.id === activeId) : null;
const folderFilter = filterCode ? codeFolders.find((f) => f.code === filterCode) : null;
const breadcrumbs = folderFilter ?
[{ label: "Instances", onClick: () => setFilterCode(null) }, { label: folderFilter.code }] :
[{ label: "Instances" }];
return (
} />
{filterStatus &&
filter
{STATUSES[filterStatus].label}
}
{/* Статистика по статусам — наглядная сводка вместо multi-select toolbar */}
{[
{ key: 'running', label: 'in game', color: 'var(--ok)', dot: '#5EE083' },
{ key: 'booting', label: 'starting', color: 'var(--warn)', dot: '#D7B074' },
{ key: 'idle', label: 'idle', color: 'var(--ink-3)', dot: '#777' },
{ key: 'stopped', label: 'stopped', color: 'var(--ink-4)', dot: '#555' },
{ key: 'error', label: 'error', color: 'var(--bad)', dot: '#FF6B6B' },
].map(({ key, label, dot }) => {
const v = stats[key] || 0;
const dim = v === 0;
return (
{v}
{label}
);
})}
{filtered.length === 0 ?
:
| Instance |
Task |
Status |
Map (live) |
Account |
{groups.map((g) =>
toggleGroup(g.code)}
activeId={activeId}
density={density}
zebra={tableZebra}
onRowClick={onRowClick} />
)}
}
{active &&
setActiveId(null)}
onLaunch={onLaunch}
onStop={onStop}
onReset={onReset} />
}
);
}
function GroupBlock({ group, isCollapsed, onToggle, activeId, density, zebra, onRowClick }) {
const list = group.list;
const colorForCode = useColorForCode();
const c = colorForCode(group.code);
const counts = list.reduce((acc, i) => {acc[i.status] = (acc[i.status] || 0) + 1;return acc;}, {});
return (
<>
|
{list.length} instance{list.length !== 1 ? 's' : ''}
{counts.running > 0 && ● {counts.running}}
{counts.booting > 0 && ◐ {counts.booting}}
{counts.stopped > 0 && ○ {counts.stopped}}
{counts.error > 0 && ✕ {counts.error}}
{counts.idle > 0 && ○ {counts.idle}}
|
{!isCollapsed && list.map((p, i) =>
onRowClick(e, p.id)} />
)}
>);
}
function Th({ children, width, style }) {
return (
{children} | );
}
function TaskCell({ task, status }) {
const inactive = status === "stopped" || status === "error" || !task || task === "—";
const color = inactive ? 'var(--ink-5)' : 'var(--ink-2)';
return (
{!inactive && (status === "running" || status === "booting") &&
}
{task || "—"}
);
}
function InstanceRow({ instance, active, zebra, density, onClick }) {
const padY = density === "compact" ? 8 : 12;
const className = active ? "row-active anim-row" : "anim-row";
const rowBg = active ? 'var(--card-hi)' : zebra ? 'var(--panel-2)' : 'transparent';
return (
{if (!active) e.currentTarget.style.background = 'var(--panel-2)';}}
onMouseLeave={(e) => {if (!active) e.currentTarget.style.background = zebra ? 'var(--panel-2)' : 'transparent';}}>
{instance.name}
{instance.id}
|
|
|
{instance.code ?
:
—}
|
{instance.account}
|
);
}
function Td({ children, style, stop }) {
return e.stopPropagation() : undefined}>{children} | ;
}
function FilterMenu({ filterStatus, setFilterStatus }) {
const [open, setOpen] = useStateS(false);
const ref = useRefS(null);
return (
<>
setOpen(false)} anchorRef={ref} width={230}>
{Object.entries(STATUSES).map(([id, s]) =>
{setFilterStatus(filterStatus === id ? null : id);setOpen(false);}} style={{
padding: '7px 10px', borderRadius: 5, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10,
background: filterStatus === id ? 'var(--panel-2)' : 'transparent',
color: filterStatus === id ? 'var(--ink)' : 'var(--ink-2)', fontSize: 13.5
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--panel-2)'}
onMouseLeave={(e) => {if (filterStatus !== id) e.currentTarget.style.background = 'transparent';}}>
{s.label}
{filterStatus === id && }
)}
>);
}
function EmptyState() {
return (
No instances match the filter
);
}
/* =========================================================================
MAP CODES — clean list view
========================================================================= */
function MapCodesScreen({ instances, codes, onCreateCode }) {
const [search, setSearch] = useStateS("");
const [createOpen, setCreateOpen] = useStateS(false);
const colorForCode = useColorForCode();
const counts = useMemoS(() => {
const m = {};
codes.forEach((c) => m[c] = 0);
instances.forEach((i) => {if (m[i.code] != null) m[i.code]++;});
return m;
}, [instances, codes]);
const filtered = codes.filter((c) => !search || c.toLowerCase().includes(search.toLowerCase()));
const total = instances.length;
return (
setCreateOpen(true)}> New code} />
{filtered.map((c, i) => {
const color = colorForCode(c);
const count = counts[c];
const pct = total ? count / total * 100 : 0;
return (
e.currentTarget.style.background = 'var(--card-hi)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
{c}
{color.name}
0 ? 'var(--ink)' : 'var(--ink-5)', minWidth: 36, textAlign: 'right' }}>{count}
);
})}
{filtered.length === 0 &&
No codes match the filter
}
{createOpen && setCreateOpen(false)} onCreate={onCreateCode} />}
);
}
// Fortnite map code: ровно 12 цифр через дефисы 0000-0000-0000.
const MAP_CODE_RE_S = /^\d{4}-\d{4}-\d{4}$/;
function formatMapCodeS(raw) {
const d = (raw || "").replace(/\D/g, "").slice(0, 12);
const parts = [];
if (d.length > 0) parts.push(d.slice(0, 4));
if (d.length > 4) parts.push(d.slice(4, 8));
if (d.length > 8) parts.push(d.slice(8, 12));
return parts.join("-");
}
function CreateCodeModal({ codes, onClose, onCreate }) {
const [name, setName] = useStateS("");
const trimmed = name.trim();
const exists = codes.includes(trimmed);
const valid = MAP_CODE_RE_S.test(trimmed) && !exists;
const previewColor = CODE_PALETTE[codes.length % CODE_PALETTE.length];
const submit = () => {if (valid) {onCreate(trimmed);onClose();}};
return (
<>
New map code
Fortnite map code in format 0000-0000-0000 (12 digits). Example: 9695-5615-4966
setName(formatMapCodeS(e.target.value))}
onKeyDown={(e) => {if (e.key === 'Enter') submit();}}
placeholder="0000-0000-0000"
inputMode="numeric"
autoFocus className="mono" />
{trimmed && !valid &&
{exists ? `Code "${trimmed}" already exists` : 'Format: 0000-0000-0000'}
}
{valid &&
preview
{trimmed}
· {previewColor.name}
}
>);
}
function StatsScreen({ instances, codes, codeFolders, tzSync, onTzSyncChange }) {
const stats = useMemoS(() => {
const out = { total: instances.length, running: 0, idle: 0, booting: 0, stopped: 0, error: 0 };
instances.forEach((i) => out[i.status] = (out[i.status] || 0) + 1);
return out;
}, [instances]);
const colorForCode = useColorForCode();
const segs = [
{ k: 'running', l: 'Running', col: 'var(--ok)' },
{ k: 'booting', l: 'Booting', col: 'var(--warn)' },
{ k: 'idle', l: 'Idle', col: 'var(--ink-3)' },
{ k: 'stopped', l: 'Stopped', col: 'var(--ink-5)' },
{ k: 'error', l: 'Error', col: 'var(--bad)' }];
return (
{}
Total instances
{stats.total}
{segs.map((s) => {
const v = stats[s.k] || 0;
const pct = stats.total ? v / stats.total * 100 : 0;
return
;
})}
{segs.map((s) =>
{s.l}
{stats[s.k] || 0}
)}
{/* By folder — clean list */}
By folder
{codeFolders.length} active
{codeFolders.length === 0 &&
No folders in use
}
{codeFolders.map((f, i) => {
const folderInstances = instances.filter((x) => x.code === f.code);
const fStats = folderInstances.reduce((acc, x) => {acc[x.status] = (acc[x.status] || 0) + 1;return acc;}, {});
const col = colorForCode(f.code);
const pct = stats.total ? folderInstances.length / stats.total * 100 : 0;
return (
{f.code}
{fStats.running > 0 && {fStats.running} live}
{fStats.error > 0 && {fStats.error} err}
{!fStats.running && !fStats.error && inactive}
{folderInstances.length}
);
})}
{/* Global controls */}
);
}
function Switch({ checked, onChange }) {
return (
);
}
/* =========================================================================
REFRESHER SCREEN — управление аккаунтами Epic: куки, рефреш, статус
========================================================================= */
function RefresherScreen() {
const PAGE_SIZE = 100;
const [accounts, setAccounts] = useStateS([]);
const [total, setTotal] = useStateS(0);
const [search, setSearch] = useStateS("");
const [loading, setLoading] = useStateS(true);
const [hasMore, setHasMore] = useStateS(true);
const [refreshing, setRefreshing] = useStateS(new Set());
const [detail, setDetail] = useStateS(null); // account_id для детальной панели
const [workerOnline, setWorkerOnline] = useStateS(null); // null=checking, true=ok, false=err
const scrollRef = useRefS(null);
const loadingRef = useRefS(false);
const load = (append = false) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
const offset = append ? accounts.length : 0;
fetch(`/api/accounts?limit=${PAGE_SIZE}&offset=${offset}`)
.then((r) => { setWorkerOnline(r.ok); return r.json(); })
.then((d) => {
const next = d.accounts || [];
const nextTotal = d.total || 0;
setAccounts((prev) => {
if (!append) return next;
const merged = new Map(prev.map((a) => [a.account_id, a]));
next.forEach((a) => merged.set(a.account_id, a));
return Array.from(merged.values());
});
setTotal(nextTotal);
const loaded = append ? accounts.length + next.length : next.length;
setHasMore(next.length > 0 && loaded < nextTotal);
})
.catch(() => { setWorkerOnline(false); })
.finally(() => {
loadingRef.current = false;
setLoading(false);
});
};
useEffectS(() => { load(); }, []);
useEffectS(() => {
const el = scrollRef.current;
if (!el || loading || !hasMore) return;
if (el.scrollHeight <= el.clientHeight + 40) load(true);
}, [accounts.length, loading, hasMore]);
const handleAccountsScroll = (e) => {
const el = e.currentTarget;
if (loading || !hasMore) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 180) load(true);
};
const filtered = useMemoS(() => {
if (!search.trim()) return accounts;
const s = search.toLowerCase();
return accounts.filter((a) =>
a.email.toLowerCase().includes(s) ||
(a.display_name || "").toLowerCase().includes(s) ||
a.account_id.toLowerCase().includes(s)
);
}, [accounts, search]);
const handleRefresh = async (account_id) => {
setRefreshing((prev) => new Set([...prev, account_id]));
try {
const r = await fetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account_id }),
});
const d = await r.json();
if (!d.ok) console.warn("refresh failed:", d.message);
} catch (e) {
console.error(e);
} finally {
setRefreshing((prev) => { const n = new Set(prev); n.delete(account_id); return n; });
load();
}
};
const handleRefreshAll = async () => {
await fetch("/api/refresh/all", { method: "POST" });
setTimeout(load, 3000);
};
const [codeModal, setCodeModal] = useStateS(null); // {email, code, error}
const handleMailCode = async (account_id, email) => {
try {
const r = await fetch(`/api/accounts/${account_id}/code`, { method: "POST" });
const d = await r.json();
if (d.code) {
await navigator.clipboard.writeText(d.code).catch(() => {});
setCodeModal({ email, code: d.code, error: null });
} else {
setCodeModal({ email, code: null, error: d.info || d.detail || 'unknown' });
}
} catch (e) {
setCodeModal({ email, code: null, error: e.message });
}
};
const stats = useMemoS(() => {
const ok = accounts.filter((a) => a.refresh_status === "ok").length;
const err = accounts.filter((a) => a.refresh_status === "error").length;
const pending = accounts.filter((a) => a.refresh_status === "pending" || !a.refresh_status).length;
return { ok, err, pending };
}, [accounts]);
return (
}
/>
{/* Статистика */}
{workerOnline === null ? '...' : workerOnline ? 'online' : 'offline'}
{loading && accounts.length === 0 ? (
Loading…
) : filtered.length === 0 ? (
{accounts.length === 0 ? "No accounts. Register accounts first." : "No accounts match the filter."}
) : (
| Account |
Session expires |
Last refresh |
Cookies |
Status |
Device |
|
{filtered.map((a) => (
handleRefresh(a.account_id)}
onMailCode={() => handleMailCode(a.account_id, a.email)}
onDetail={() => setDetail(detail === a.account_id ? null : a.account_id)}
isDetailed={detail === a.account_id}
/>
))}
)}
{loading && accounts.length > 0 && (
Loading more...
)}
{detail && (
a.account_id === detail)}
onClose={() => setDetail(null)}
/>
)}
{codeModal && (
setCodeModal(null)}
/>
)}
);
}
function StatBadge({ label, value, color }) {
return (
{value}
{label}
);
}
function StatPill({ label, value, color, bg, border }) {
return (
{value}
{label}
);
}
function fmtTs(ts) {
if (!ts) return "—";
const d = new Date(ts * 1000);
const now = Date.now();
const diff = (d.getTime() - now) / 1000;
// для expires — показываем "через N дней" если в будущем
if (diff > 0) {
const days = Math.floor(diff / 86400);
if (days > 0) return `${days}d left`;
const h = Math.floor(diff / 3600);
return `${h}h left`;
}
// в прошлом
const ago = -diff;
if (ago < 60) return "just now";
if (ago < 3600) return `${Math.floor(ago / 60)}m ago`;
if (ago < 86400) return `${Math.floor(ago / 3600)}h ago`;
const days = Math.floor(ago / 86400);
return `${days}d ago`;
}
function fmtDate(ts) {
if (!ts) return "—";
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function AccountRefresherRow({ account: a, isRefreshing, onRefresh, onMailCode, onDetail, isDetailed }) {
const refreshColor = {
ok: 'var(--ok)',
error: 'var(--bad)',
pending: 'var(--ink-4)',
}[a.refresh_status] || 'var(--ink-4)';
const deviceColor = {
ok: 'var(--ok)',
dead: 'var(--bad)',
unknown: 'var(--ink-4)',
}[a.device_auth_status] || 'var(--ink-4)';
const sessionExpires = a.session_ap_expires;
const sessionColor = !sessionExpires ? 'var(--ink-5)'
: sessionExpires * 1000 < Date.now() ? 'var(--bad)'
: sessionExpires * 1000 < Date.now() + 30 * 86400 * 1000 ? 'var(--warn)'
: 'var(--ok)';
return (
{ if (!isDetailed) e.currentTarget.style.background = 'var(--panel-2)'; }}
onMouseLeave={(e) => { if (!isDetailed) e.currentTarget.style.background = 'transparent'; }}
onClick={onDetail}
>
{a.email}
{a.display_name || a.account_id.slice(0, 16) + '…'}
|
{sessionExpires ? fmtTs(sessionExpires) : '—'}
{sessionExpires && (
{fmtDate(sessionExpires)}
)}
|
{a.last_refresh ? fmtTs(a.last_refresh) : 'never'}
|
{a.cookies_count ?? 0}
|
{a.refresh_status || 'pending'}
|
{a.device_auth_status || 'unknown'}
|
|
);
}
function CodeModal({ data, onClose }) {
const { email, code, error } = data;
return (
e.stopPropagation()}
style={{
width: 360, background: 'var(--card)',
border: '1px solid var(--line)', borderRadius: 12,
padding: 24, boxShadow: '0 12px 40px rgba(0,0,0,.12)',
}}
>
{email}
{code ? (
<>
{code}
Copied to clipboard
>
) : (
<>
{error || 'no code'}
>
)}
);
}
function AccountDetailPanel({ accountId, account: initialAccount, onClose }) {
const [account, setAccount] = useStateS(initialAccount || null);
const [logs, setLogs] = useStateS([]);
const [exchangeCode, setExchangeCode] = useStateS(null);
const [loadingExchange, setLoadingExchange] = useStateS(false);
const [copied, setCopied] = useStateS(null);
const [tab, setTab] = useStateS('info');
useEffectS(() => {
setAccount(initialAccount || null);
fetch(`/api/accounts/${accountId}/refresh-log?limit=10`)
.then((r) => r.json())
.then((d) => setLogs(d.logs || []));
}, [accountId, initialAccount]);
const copyToClipboard = (key, text) => {
if (!text || text === "—") return;
navigator.clipboard.writeText(text).then(() => {
setCopied(key);
setTimeout(() => setCopied(null), 1500);
});
};
const getExchange = async () => {
setLoadingExchange(true);
setExchangeCode(null);
try {
const r = await fetch("/api/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account_id: accountId }),
});
const d = await r.json();
if (d.exchange_code) setExchangeCode(d.exchange_code);
else setExchangeCode("ERROR: " + (d.detail || "unknown"));
} catch (e) {
setExchangeCode("ERROR: " + e.message);
} finally {
setLoadingExchange(false);
}
};
return (
{/* Header */}
Account detail
{account &&
{account.account_id.slice(0, 16)}…
}
{/* Tabs */}
{account && (
{['info', 'cookies', 'log'].map((t) => {
const on = tab === t;
return (
);
})}
)}
{!account ? (
Loading…
) : tab === 'info' ? (
<>
{/* Credentials */}
{/* Email account */}
{/* Session */}
{/* Exchange */}
{exchangeCode && (
exchange code · 5 min
{!exchangeCode.startsWith("ERROR") && (
)}
{exchangeCode}
)}
>
) : tab === 'cookies' ? (
<>
{account.cookies_count} cookies
{(account.cookies || []).map((c, i) => (
0 ? '1px solid var(--line-soft)' : 'none',
cursor: 'pointer',
}}
onClick={() => copyToClipboard('ck_'+i, c.value)}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', borderTop: i > 0 ? '1px solid var(--line-soft)' : 'none', cursor: 'pointer', background: copied === 'ck_'+i ? 'color-mix(in oklab, var(--ok) 8%, transparent)' : 'transparent' }}>
{c.name}
{c.value}
{copied === 'ck_'+i ? : }
))}
>
) : (
/* Refresh log tab */
logs.length === 0 ? (
No refresh history
) : (
{logs.map((l, i) => (
0 ? '1px solid var(--line-soft)' : 'none' }}>
{l.status}
{fmtTs(l.ts)}
{l.message &&
{l.message.slice(0, 100)}
}
{l.proxy &&
via {l.proxy.split(':')[0]}
}
))}
)
)}
);
}
function CopyField({ label, value, mono, small, secret, copyKey, copied, onCopy, badge, color, noCopy }) {
const [show, setShow] = useStateS(!secret);
const isCopied = copied === copyKey;
const canCopy = !noCopy && value && value !== "—";
return (
{label}
{badge && }
{value}
{secret && (
)}
{canCopy && (
)}
);
}
/* =========================================================================
LOGIN SCREEN
========================================================================= */
function LoginScreen({ onLogin }) {
const [username, setUsername] = useStateS("");
const [password, setPassword] = useStateS("");
const [error, setError] = useStateS(null);
const [loading, setLoading] = useStateS(false);
const [captchaReady, setCaptchaReady] = useStateS(false);
const hcaptchaRef = useRefS(null);
const [showHcaptcha, setShowHcaptcha] = useStateS(false);
const [hcaptchaLoaded, setHcaptchaLoaded] = useStateS(false);
const [turnstileToken, setTurnstileToken] = useStateS(null);
// Когда showHcaptcha становится true — грузим скрипт если нужно
useEffectS(() => {
if (!showHcaptcha) return;
if (window.hcaptcha) { setHcaptchaLoaded(true); return; }
if (document.getElementById('hc-script')) return;
const s = document.createElement('script');
s.id = 'hc-script';
s.src = 'https://js.hcaptcha.com/1/api.js?render=explicit';
s.async = true;
s.onload = () => setHcaptchaLoaded(true);
document.head.appendChild(s);
}, [showHcaptcha]);
// Когда скрипт загружен И div уже в DOM — рендерим виджет
useEffectS(() => {
if (!showHcaptcha || !hcaptchaLoaded || !hcaptchaRef.current) return;
if (hcaptchaRef.current._hcMounted) return;
window.hcaptcha.render(hcaptchaRef.current, {
sitekey: '441af294-49f0-452c-9ea2-dc30178a20b6',
theme: 'dark',
});
hcaptchaRef.current._hcMounted = true;
}, [showHcaptcha, hcaptchaLoaded]);
// Turnstile invisible
useEffectS(() => {
if (document.getElementById('cf-ts-script')) return;
const script = document.createElement('script');
script.id = 'cf-ts-script';
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
script.onload = () => {
window._cfWidget = window.turnstile.render('#cf-ts-box', {
sitekey: '0x4AAAAAAC1HuZAb9oE1NMY6',
size: 'invisible',
callback: (t) => setTurnstileToken(t),
'error-callback': () => { setTurnstileToken(null); setShowHcaptcha(true); },
'expired-callback': () => setTurnstileToken(null),
});
};
document.head.appendChild(script);
}, []);
const submit = async (e) => {
e.preventDefault();
setError(null);
setLoading(true);
let hcaptcha_token = null;
let turnstile_token = turnstileToken;
if (!turnstile_token && window.turnstile && window._cfWidget !== undefined) {
try { window.turnstile.execute(window._cfWidget); } catch (_) {}
await new Promise((resolve) => {
let n = 0;
const id = setInterval(() => {
try { turnstile_token = window.turnstile.getResponse(window._cfWidget); } catch (_) {}
if (turnstile_token || ++n > 25) { clearInterval(id); resolve(); }
}, 200);
});
}
try { if (window.hcaptcha) hcaptcha_token = window.hcaptcha.getResponse(); } catch (_) {}
try {
const r = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, hcaptcha_token, turnstile_token }),
});
const d = await r.json();
if (r.ok && d.ok) {
onLogin(d.user);
} else {
const detail = d.detail || '';
if (detail === 'captcha_required' || detail === 'captcha_invalid') {
// Показываем hCaptcha — Turnstile не сработал
setShowHcaptcha(true);
if (detail === 'captcha_invalid') setError('Captcha check failed, please solve it below');
} else {
setError(detail || 'Invalid credentials');
}
try { if (window.hcaptcha) window.hcaptcha.reset(); } catch (_) {}
try { if (window._cfWidget !== undefined) { window.turnstile.reset(window._cfWidget); setTurnstileToken(null); } } catch (_) {}
}
} catch (err) {
setError('Network error: ' + err.message);
} finally {
setLoading(false);
}
};
return (
{/* Logo + title */}
Epic
Dashboard
Sign in to continue
{/* Form */}
);
}
Object.assign(window, { Sidebar, Topbar, InstancesScreen, MapCodesScreen, StatsScreen, RefresherScreen, LoginScreen });