Spaces:
Paused
Paused
| import { useState, useEffect, useRef, useContext } from "preact/hooks"; | |
| import { createContext } from "preact"; | |
| import type { ComponentChildren } from "preact"; | |
| import { I18nProvider } from "../../shared/i18n/context"; | |
| import { ThemeProvider } from "../../shared/theme/context"; | |
| import { Header } from "./components/Header"; | |
| import { UpdateModal } from "./components/UpdateModal"; | |
| import { AccountList } from "./components/AccountList"; | |
| import { AddAccount } from "./components/AddAccount"; | |
| import { ProxyPool } from "./components/ProxyPool"; | |
| import { ApiConfig } from "./components/ApiConfig"; | |
| import { AnthropicSetup } from "./components/AnthropicSetup"; | |
| import { CodeExamples } from "./components/CodeExamples"; | |
| import { SettingsPanel } from "./components/SettingsPanel"; | |
| import { QuotaSettings } from "./components/QuotaSettings"; | |
| import { RotationSettings } from "./components/RotationSettings"; | |
| import { TestConnection } from "./components/TestConnection"; | |
| import { Footer } from "./components/Footer"; | |
| import { ProxySettings } from "./pages/ProxySettings"; | |
| import { AccountManagement } from "./pages/AccountManagement"; | |
| import { UsageStats } from "./pages/UsageStats"; | |
| import { useAccounts } from "../../shared/hooks/use-accounts"; | |
| import { useProxies } from "../../shared/hooks/use-proxies"; | |
| import { useStatus } from "../../shared/hooks/use-status"; | |
| import { useUpdateStatus } from "../../shared/hooks/use-update-status"; | |
| import { useI18n } from "../../shared/i18n/context"; | |
| import { useDashboardAuth } from "../../shared/hooks/use-dashboard-auth"; | |
| /** Context for dashboard session state (logout button, remote session indicator). */ | |
| const DashboardAuthCtx = createContext<{ onLogout?: () => void }>({}); | |
| function useDashboardAuthCtx() { return useContext(DashboardAuthCtx); } | |
| function useUpdateMessage() { | |
| const { t } = useI18n(); | |
| const update = useUpdateStatus(); | |
| let msg: string | null = null; | |
| let color = "text-primary"; | |
| if (!update.checking && update.result) { | |
| const parts: string[] = []; | |
| const r = update.result; | |
| if (r.proxy?.error) { | |
| parts.push(`Proxy: ${r.proxy.error}`); | |
| color = "text-red-500"; | |
| } else if (r.proxy?.update_available) { | |
| parts.push(t("updateAvailable")); | |
| color = "text-amber-500"; | |
| } | |
| if (r.codex?.error) { | |
| parts.push(`Codex: ${r.codex.error}`); | |
| color = "text-red-500"; | |
| } else if (r.codex_update_in_progress) { | |
| parts.push(t("fingerprintUpdating")); | |
| } else if (r.codex?.version_changed) { | |
| parts.push(`Codex: v${r.codex.current_version}`); | |
| color = "text-blue-500"; | |
| } | |
| msg = parts.length > 0 ? parts.join(" · ") : t("upToDate"); | |
| } else if (!update.checking && update.error) { | |
| msg = update.error; | |
| color = "text-red-500"; | |
| } | |
| const hasUpdate = update.status?.proxy.update_available ?? false; | |
| const proxyUpdateInfo = hasUpdate | |
| ? { | |
| mode: update.status!.proxy.mode, | |
| commits: update.status!.proxy.commits, | |
| release: update.status!.proxy.release, | |
| } | |
| : null; | |
| return { ...update, msg, color, hasUpdate, proxyUpdateInfo }; | |
| } | |
| function Dashboard() { | |
| const accounts = useAccounts(); | |
| const proxies = useProxies(); | |
| const status = useStatus(accounts.list.length); | |
| const update = useUpdateMessage(); | |
| const { onLogout } = useDashboardAuthCtx(); | |
| const [showModal, setShowModal] = useState(false); | |
| const prevUpdateAvailable = useRef(false); | |
| // Auto-open modal when update becomes available after a check | |
| // (Electron has its own native auto-updater — don't show web modal) | |
| useEffect(() => { | |
| if (update.hasUpdate && !prevUpdateAvailable.current && update.proxyUpdateInfo?.mode !== "electron") { | |
| setShowModal(true); | |
| } | |
| prevUpdateAvailable.current = update.hasUpdate; | |
| }, [update.hasUpdate, update.proxyUpdateInfo?.mode]); | |
| const handleProxyChange = async (accountId: string, proxyId: string) => { | |
| accounts.patchLocal(accountId, { proxyId }); | |
| await proxies.assignProxy(accountId, proxyId); | |
| }; | |
| return ( | |
| <> | |
| <Header | |
| onAddAccount={accounts.startAdd} | |
| onCheckUpdate={update.checkForUpdate} | |
| onOpenUpdateModal={() => setShowModal(true)} | |
| checking={update.checking} | |
| updateStatusMsg={update.msg} | |
| updateStatusColor={update.color} | |
| version={update.status?.proxy.version ?? null} | |
| commit={update.status?.proxy.commit ?? null} | |
| hasUpdate={update.hasUpdate} | |
| onLogout={onLogout} | |
| /> | |
| <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center"> | |
| <div class="flex flex-col w-full max-w-[960px] gap-6"> | |
| <AddAccount | |
| visible={accounts.addVisible} | |
| onSubmitRelay={accounts.submitRelay} | |
| addInfo={accounts.addInfo} | |
| addError={accounts.addError} | |
| /> | |
| <AccountList | |
| accounts={accounts.list} | |
| loading={accounts.loading} | |
| onDelete={accounts.deleteAccount} | |
| onRefresh={accounts.refresh} | |
| refreshing={accounts.refreshing} | |
| lastUpdated={accounts.lastUpdated} | |
| proxies={proxies.proxies} | |
| onProxyChange={handleProxyChange} | |
| onExport={accounts.exportAccounts} | |
| onImport={accounts.importAccounts} | |
| /> | |
| <ProxyPool proxies={proxies} /> | |
| <ApiConfig | |
| baseUrl={status.baseUrl} | |
| apiKey={status.apiKey} | |
| models={status.models} | |
| selectedModel={status.selectedModel} | |
| onModelChange={status.setSelectedModel} | |
| modelFamilies={status.modelFamilies} | |
| selectedEffort={status.selectedEffort} | |
| onEffortChange={status.setSelectedEffort} | |
| selectedSpeed={status.selectedSpeed} | |
| onSpeedChange={status.setSelectedSpeed} | |
| /> | |
| <AnthropicSetup | |
| apiKey={status.apiKey} | |
| selectedModel={status.selectedModel} | |
| reasoningEffort={status.selectedEffort} | |
| serviceTier={status.selectedSpeed} | |
| /> | |
| <CodeExamples | |
| baseUrl={status.baseUrl} | |
| apiKey={status.apiKey} | |
| model={status.selectedModel} | |
| reasoningEffort={status.selectedEffort} | |
| serviceTier={status.selectedSpeed} | |
| /> | |
| <SettingsPanel /> | |
| <QuotaSettings /> | |
| <RotationSettings /> | |
| <TestConnection /> | |
| </div> | |
| </main> | |
| <Footer updateStatus={update.status} /> | |
| {update.proxyUpdateInfo && ( | |
| <UpdateModal | |
| open={showModal} | |
| onClose={() => setShowModal(false)} | |
| mode={update.proxyUpdateInfo.mode} | |
| commits={update.proxyUpdateInfo.commits} | |
| release={update.proxyUpdateInfo.release} | |
| onApply={update.applyUpdate} | |
| applying={update.applying} | |
| restarting={update.restarting} | |
| restartFailed={update.restartFailed} | |
| updateSteps={update.updateSteps} | |
| /> | |
| )} | |
| </> | |
| ); | |
| } | |
| function useHash(): string { | |
| const [hash, setHash] = useState(location.hash); | |
| useEffect(() => { | |
| const handler = () => setHash(location.hash); | |
| window.addEventListener("hashchange", handler); | |
| return () => window.removeEventListener("hashchange", handler); | |
| }, []); | |
| return hash; | |
| } | |
| function PageRouter({ hash }: { hash: string }) { | |
| switch (hash) { | |
| case "#/proxy-settings": return <ProxySettingsPage />; | |
| case "#/account-management": return <AccountManagement />; | |
| case "#/usage-stats": return <UsageStats />; | |
| default: return <Dashboard />; | |
| } | |
| } | |
| function LoginGate({ children }: { children: ComponentChildren }) { | |
| const { t } = useI18n(); | |
| const auth = useDashboardAuth(); | |
| const [password, setPassword] = useState(""); | |
| if (auth.status === "loading") { | |
| return ( | |
| <div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-bg-dark"> | |
| <div class="animate-pulse text-slate-400 dark:text-text-dim text-sm">Loading...</div> | |
| </div> | |
| ); | |
| } | |
| if (auth.status === "login") { | |
| const handleSubmit = (e: Event) => { | |
| e.preventDefault(); | |
| if (password.trim()) auth.login(password.trim()); | |
| }; | |
| return ( | |
| <div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-bg-dark px-4"> | |
| <div class="w-full max-w-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-2xl shadow-lg p-8"> | |
| <div class="flex flex-col items-center gap-2 mb-6"> | |
| <div class="flex items-center justify-center size-12 rounded-full bg-primary/10 text-primary border border-primary/20"> | |
| <svg class="size-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /> | |
| </svg> | |
| </div> | |
| <h1 class="text-lg font-bold text-slate-800 dark:text-text-main">{t("dashboardLogin")}</h1> | |
| <p class="text-xs text-slate-500 dark:text-text-dim text-center">{t("dashboardLoginRequired")}</p> | |
| </div> | |
| <form onSubmit={handleSubmit} class="flex flex-col gap-4"> | |
| <div> | |
| <label class="block text-xs font-medium text-slate-600 dark:text-text-dim mb-1.5">{t("dashboardPassword")}</label> | |
| <input | |
| type="password" | |
| value={password} | |
| onInput={(e) => setPassword((e.target as HTMLInputElement).value)} | |
| class="w-full px-3 py-2 rounded-lg border border-gray-200 dark:border-border-dark bg-slate-50 dark:bg-bg-dark text-sm text-slate-800 dark:text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition-colors" | |
| placeholder="proxy_api_key" | |
| autofocus | |
| /> | |
| </div> | |
| {auth.error && ( | |
| <p class="text-xs text-red-500 font-medium"> | |
| {auth.error.includes("Too many") ? t("dashboardTooManyAttempts") : t("dashboardLoginError")} | |
| </p> | |
| )} | |
| <button | |
| type="submit" | |
| class="w-full py-2.5 bg-primary hover:bg-primary-hover text-white text-sm font-semibold rounded-lg transition-colors shadow-sm active:scale-[0.98]" | |
| > | |
| {t("dashboardLoginBtn")} | |
| </button> | |
| </form> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const ctxValue = auth.isRemoteSession ? { onLogout: auth.logout } : {}; | |
| return <DashboardAuthCtx.Provider value={ctxValue}>{children}</DashboardAuthCtx.Provider>; | |
| } | |
| export function App() { | |
| const hash = useHash(); | |
| return ( | |
| <I18nProvider> | |
| <ThemeProvider> | |
| <LoginGate> | |
| <PageRouter hash={hash} /> | |
| </LoginGate> | |
| </ThemeProvider> | |
| </I18nProvider> | |
| ); | |
| } | |
| function ProxySettingsPage() { | |
| const update = useUpdateMessage(); | |
| return ( | |
| <> | |
| <Header | |
| onAddAccount={() => { location.hash = ""; }} | |
| onCheckUpdate={update.checkForUpdate} | |
| checking={update.checking} | |
| updateStatusMsg={update.msg} | |
| updateStatusColor={update.color} | |
| version={update.status?.proxy.version ?? null} | |
| commit={update.status?.proxy.commit ?? null} | |
| isProxySettings | |
| hasUpdate={update.hasUpdate} | |
| /> | |
| <ProxySettings /> | |
| </> | |
| ); | |
| } | |