import { useState, useEffect, useCallback } from "preact/hooks"; import type { Account } from "../types"; export function useAccounts() { const [list, setList] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [lastUpdated, setLastUpdated] = useState(null); const [addVisible, setAddVisible] = useState(false); const [addInfo, setAddInfo] = useState(""); const [addError, setAddError] = useState(""); const loadAccounts = useCallback(async (fresh = false) => { setRefreshing(true); try { const url = fresh ? "/auth/accounts?quota=fresh" : "/auth/accounts?quota=true"; const resp = await fetch(url); const data = await resp.json(); setList(data.accounts || []); setLastUpdated(new Date()); } catch { setList([]); } finally { setLoading(false); setRefreshing(false); } }, []); useEffect(() => { loadAccounts(); }, [loadAccounts]); // Auto-poll cached quota every 30s useEffect(() => { const timer = setInterval(() => loadAccounts(), 30_000); return () => clearInterval(timer); }, [loadAccounts]); // Listen for OAuth callback success useEffect(() => { const handler = async (event: MessageEvent) => { if (event.data?.type === "oauth-callback-success") { setAddVisible(false); setAddInfo("accountAdded"); await loadAccounts(); } }; window.addEventListener("message", handler); return () => window.removeEventListener("message", handler); }, [loadAccounts]); const startAdd = useCallback(async () => { setAddInfo(""); setAddError(""); try { const resp = await fetch("/auth/login-start", { method: "POST" }); const data = await resp.json(); if (!resp.ok || !data.authUrl) { throw new Error(data.error || "failedStartLogin"); } window.open(data.authUrl, "oauth_add", "width=600,height=700,scrollbars=yes"); setAddVisible(true); // Poll for new account + focus/visibility detection const prevResp = await fetch("/auth/accounts"); const prevData = await prevResp.json(); const prevCount = prevData.accounts?.length || 0; let checking = false; const checkForNewAccount = async () => { if (checking) return; checking = true; try { const r = await fetch("/auth/accounts"); const d = await r.json(); if ((d.accounts?.length || 0) > prevCount) { cleanup(); setAddVisible(false); setAddInfo("accountAdded"); await loadAccounts(); } } catch {} finally { checking = false; } }; // Focus event — check immediately when window regains focus const onFocus = () => { checkForNewAccount(); }; window.addEventListener("focus", onFocus); // Visibility change — check when tab becomes visible const onVisible = () => { if (document.visibilityState === "visible") checkForNewAccount(); }; document.addEventListener("visibilitychange", onVisible); // Interval polling as fallback const pollTimer = setInterval(checkForNewAccount, 2000); const cleanup = () => { clearInterval(pollTimer); window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisible); }; setTimeout(cleanup, 5 * 60 * 1000); } catch (err) { setAddError(err instanceof Error ? err.message : "failedStartLogin"); } }, [loadAccounts]); const submitRelay = useCallback( async (callbackUrl: string) => { setAddInfo(""); setAddError(""); if (!callbackUrl.trim()) { setAddError("pleasePassCallback"); return; } try { const resp = await fetch("/auth/code-relay", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ callbackUrl }), }); const data = await resp.json(); if (resp.ok && data.success) { setAddVisible(false); setAddInfo("accountAdded"); await loadAccounts(); } else { setAddError(data.error || "failedExchangeCode"); } } catch (err) { setAddError( "networkError" + (err instanceof Error ? err.message : String(err)) ); } }, [loadAccounts] ); const deleteAccount = useCallback( async (id: string) => { try { const resp = await fetch("/auth/accounts/" + encodeURIComponent(id), { method: "DELETE", }); if (!resp.ok) { const data = await resp.json(); return data.error || "failedDeleteAccount"; } await loadAccounts(); return null; } catch (err) { return "networkError" + (err instanceof Error ? err.message : ""); } }, [loadAccounts] ); const patchLocal = useCallback((accountId: string, patch: Partial) => { setList((prev) => prev.map((a) => a.id === accountId ? { ...a, ...patch } : a)); }, []); const exportAccounts = useCallback(async (selectedIds?: string[]) => { const params = selectedIds && selectedIds.length > 0 ? `?ids=${selectedIds.join(",")}` : ""; const resp = await fetch(`/auth/accounts/export${params}`); const data = await resp.json() as { accounts: Array<{ id: string }> }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; const date = new Date().toISOString().slice(0, 10); a.download = `accounts-export-${date}.json`; a.style.display = "none"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }, []); const importAccounts = useCallback(async (file: File): Promise<{ success: boolean; added: number; updated: number; failed: number; errors: string[]; }> => { const text = await file.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid JSON file"] }; } // Support both { accounts: [...] } (export format) and raw array const accounts = Array.isArray(parsed) ? parsed : Array.isArray(parsed.accounts) ? parsed.accounts : null; if (!accounts) { return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid format: expected { accounts: [...] }"] }; } const resp = await fetch("/auth/accounts/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accounts }), }); const result = await resp.json(); if (resp.ok) { await loadAccounts(); } return result; }, [loadAccounts]); const batchDelete = useCallback(async (ids: string[]): Promise => { try { const resp = await fetch("/auth/accounts/batch-delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids }), }); if (!resp.ok) { const data = await resp.json(); return data.error || "Batch delete failed"; } await loadAccounts(); return null; } catch (err) { return "networkError" + (err instanceof Error ? err.message : ""); } }, [loadAccounts]); const batchSetStatus = useCallback(async (ids: string[], status: "active" | "disabled"): Promise => { try { const resp = await fetch("/auth/accounts/batch-status", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ids, status }), }); if (!resp.ok) { const data = await resp.json(); return data.error || "Batch status change failed"; } await loadAccounts(); return null; } catch (err) { return "networkError" + (err instanceof Error ? err.message : ""); } }, [loadAccounts]); return { list, loading, refreshing, lastUpdated, addVisible, addInfo, addError, refresh: useCallback(() => loadAccounts(true), [loadAccounts]), patchLocal, startAdd, submitRelay, deleteAccount, exportAccounts, importAccounts, batchDelete, batchSetStatus, }; }