icebear0828 Claude Opus 4.6 commited on
Commit
b7d4394
·
1 Parent(s): b1107bc

feat: migrate dashboard to Preact + Vite

Browse files

- New web/ directory with Preact components, Tailwind CSS v3, Vite build
- i18n via Context + useT() hook (zero DOM scanning on language switch)
- Theme via ThemeProvider + useTheme() hook
- Ghost text technique for stable header layout across EN/ZH
- Backend: serve Vite build output from public/
- Docker: added frontend build step
- Build: npm run build now builds frontend first, then backend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

.gitignore CHANGED
@@ -1,6 +1,8 @@
1
  node_modules/
2
  dist/
3
  data/
 
 
4
  docs/
5
  bin/
6
  .env
 
1
  node_modules/
2
  dist/
3
  data/
4
+ public/assets/
5
+ public/index.html
6
  docs/
7
  bin/
8
  .env
Dockerfile CHANGED
@@ -7,13 +7,21 @@ RUN apt-get update && \
7
 
8
  WORKDIR /app
9
 
10
- # Install dependencies (postinstall downloads curl-impersonate for Linux)
11
  COPY package*.json ./
12
- RUN npm ci --omit=dev
13
 
14
- # Copy source and build
15
  COPY . .
16
- RUN npm run build
 
 
 
 
 
 
 
 
17
 
18
  # Persistent data mount point
19
  VOLUME /app/data
 
7
 
8
  WORKDIR /app
9
 
10
+ # Install backend dependencies (postinstall downloads curl-impersonate for Linux)
11
  COPY package*.json ./
12
+ RUN npm ci
13
 
14
+ # Copy source
15
  COPY . .
16
+
17
+ # Build frontend (Vite → public/)
18
+ RUN cd web && npm ci && npm run build
19
+
20
+ # Build backend (TypeScript → dist/)
21
+ RUN npx tsc
22
+
23
+ # Prune dev dependencies
24
+ RUN npm prune --omit=dev
25
 
26
  # Persistent data mount point
27
  VOLUME /app/data
package.json CHANGED
@@ -5,7 +5,9 @@
5
  "type": "module",
6
  "scripts": {
7
  "dev": "tsx watch src/index.ts",
8
- "build": "tsc",
 
 
9
  "start": "node dist/index.js",
10
  "check-update": "tsx scripts/check-update.ts",
11
  "check-update:watch": "tsx scripts/check-update.ts --watch",
 
5
  "type": "module",
6
  "scripts": {
7
  "dev": "tsx watch src/index.ts",
8
+ "dev:web": "cd web && npm run dev",
9
+ "build:web": "cd web && npm install && npm run build",
10
+ "build": "npm run build:web && tsc",
11
  "start": "node dist/index.js",
12
  "check-update": "tsx scripts/check-update.ts",
13
  "check-update:watch": "tsx scripts/check-update.ts --watch",
src/index.ts CHANGED
@@ -68,14 +68,15 @@ async function main() {
68
  const host = config.server.host;
69
 
70
  const poolSummary = accountPool.getPoolSummary();
 
71
 
72
  console.log(`
73
  ╔══════════════════════════════════════════╗
74
  ║ Codex Proxy Server ║
75
  ╠══════════════════════════════════════════╣
76
  ║ Status: ${accountPool.isAuthenticated() ? "Authenticated ✓" : "Not logged in "} ║
77
- ║ Listen: http://${host}:${port} ║
78
- ║ API: http://${host}:${port}/v1 ║
79
  ╚══════════════════════════════════════════╝
80
  `);
81
 
@@ -86,7 +87,6 @@ async function main() {
86
  console.log(` Key: ${accountPool.getProxyApiKey()}`);
87
  console.log(` Pool: ${poolSummary.active} active / ${poolSummary.total} total accounts`);
88
  } else {
89
- const displayHost = host === "0.0.0.0" ? "localhost" : host;
90
  console.log(` Open http://${displayHost}:${port} to login`);
91
  }
92
  console.log();
 
68
  const host = config.server.host;
69
 
70
  const poolSummary = accountPool.getPoolSummary();
71
+ const displayHost = (host === "0.0.0.0" || host === "::") ? "localhost" : host;
72
 
73
  console.log(`
74
  ╔══════════════════════════════════════════╗
75
  ║ Codex Proxy Server ║
76
  ╠══════════════════════════════════════════╣
77
  ║ Status: ${accountPool.isAuthenticated() ? "Authenticated ✓" : "Not logged in "} ║
78
+ ║ Listen: http://${displayHost}:${port} ║
79
+ ║ API: http://${displayHost}:${port}/v1 ║
80
  ╚══════════════════════════════════════════╝
81
  `);
82
 
 
87
  console.log(` Key: ${accountPool.getProxyApiKey()}`);
88
  console.log(` Pool: ${poolSummary.active} active / ${poolSummary.total} total accounts`);
89
  } else {
 
90
  console.log(` Open http://${displayHost}:${port} to login`);
91
  }
92
  console.log();
src/routes/web.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { Hono } from "hono";
 
2
  import { readFileSync, existsSync } from "fs";
3
  import { resolve } from "path";
4
  import type { AccountPool } from "../auth/account-pool.js";
@@ -10,14 +11,17 @@ export function createWebRoutes(accountPool: AccountPool): Hono {
10
 
11
  const publicDir = resolve(process.cwd(), "public");
12
 
 
 
 
13
  app.get("/", (c) => {
14
  try {
15
- const html = readFileSync(resolve(publicDir, "dashboard.html"), "utf-8");
16
  return c.html(html);
17
  } catch (err) {
18
  const msg = err instanceof Error ? err.message : String(err);
19
  console.error(`[Web] Failed to read HTML file: ${msg}`);
20
- return c.html("<h1>Codex Proxy</h1><p>UI files not found. The API is still available at /v1/chat/completions</p>");
21
  }
22
  });
23
 
 
1
  import { Hono } from "hono";
2
+ import { serveStatic } from "@hono/node-server/serve-static";
3
  import { readFileSync, existsSync } from "fs";
4
  import { resolve } from "path";
5
  import type { AccountPool } from "../auth/account-pool.js";
 
11
 
12
  const publicDir = resolve(process.cwd(), "public");
13
 
14
+ // Serve Vite build assets
15
+ app.use("/assets/*", serveStatic({ root: "./public" }));
16
+
17
  app.get("/", (c) => {
18
  try {
19
+ const html = readFileSync(resolve(publicDir, "index.html"), "utf-8");
20
  return c.html(html);
21
  } catch (err) {
22
  const msg = err instanceof Error ? err.message : String(err);
23
  console.error(`[Web] Failed to read HTML file: ${msg}`);
24
+ return c.html("<h1>Codex Proxy</h1><p>UI files not found. Run 'npm run build:web' first. The API is still available at /v1/chat/completions</p>");
25
  }
26
  });
27
 
web/index.html ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Codex Proxy Developer Dashboard</title>
7
+ <script>
8
+ try {
9
+ const t = localStorage.getItem('codex-proxy-theme');
10
+ if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches))
11
+ document.documentElement.classList.add('dark');
12
+ } catch {}
13
+ </script>
14
+ </head>
15
+ <body class="bg-bg-light dark:bg-bg-dark font-display text-slate-900 dark:text-text-main antialiased min-h-screen flex flex-col transition-colors">
16
+ <div id="app"></div>
17
+ <script type="module" src="/src/main.tsx"></script>
18
+ </body>
19
+ </html>
web/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
web/package.json ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "codex-proxy-web",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "preview": "vite preview"
9
+ },
10
+ "dependencies": {
11
+ "preact": "^10.25.0"
12
+ },
13
+ "devDependencies": {
14
+ "@preact/preset-vite": "^2.9.0",
15
+ "autoprefixer": "^10.4.20",
16
+ "postcss": "^8.4.49",
17
+ "tailwindcss": "^3.4.17",
18
+ "typescript": "^5.5.0",
19
+ "vite": "^6.0.0"
20
+ }
21
+ }
web/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
web/src/App.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { I18nProvider } from "./i18n/context";
2
+ import { ThemeProvider } from "./theme/context";
3
+ import { Header } from "./components/Header";
4
+ import { AccountList } from "./components/AccountList";
5
+ import { AddAccount } from "./components/AddAccount";
6
+ import { ApiConfig } from "./components/ApiConfig";
7
+ import { CodeExamples } from "./components/CodeExamples";
8
+ import { Footer } from "./components/Footer";
9
+ import { useAccounts } from "./hooks/use-accounts";
10
+ import { useStatus } from "./hooks/use-status";
11
+
12
+ function Dashboard() {
13
+ const accounts = useAccounts();
14
+ const status = useStatus();
15
+
16
+ return (
17
+ <>
18
+ <Header onAddAccount={accounts.startAdd} />
19
+ <main class="flex-grow px-4 md:px-8 lg:px-40 py-8 flex justify-center">
20
+ <div class="flex flex-col w-full max-w-[960px] gap-6">
21
+ <AddAccount
22
+ visible={accounts.addVisible}
23
+ onSubmitRelay={accounts.submitRelay}
24
+ addInfo={accounts.addInfo}
25
+ addError={accounts.addError}
26
+ />
27
+ <AccountList
28
+ accounts={accounts.list}
29
+ loading={accounts.loading}
30
+ onDelete={accounts.deleteAccount}
31
+ />
32
+ <ApiConfig
33
+ baseUrl={status.baseUrl}
34
+ apiKey={status.apiKey}
35
+ models={status.models}
36
+ selectedModel={status.selectedModel}
37
+ onModelChange={status.setSelectedModel}
38
+ />
39
+ <CodeExamples
40
+ baseUrl={status.baseUrl}
41
+ apiKey={status.apiKey}
42
+ model={status.selectedModel}
43
+ />
44
+ </div>
45
+ </main>
46
+ <Footer />
47
+ </>
48
+ );
49
+ }
50
+
51
+ export function App() {
52
+ return (
53
+ <I18nProvider>
54
+ <ThemeProvider>
55
+ <Dashboard />
56
+ </ThemeProvider>
57
+ </I18nProvider>
58
+ );
59
+ }
web/src/components/AccountCard.tsx ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback } from "preact/hooks";
2
+ import { useT } from "../i18n/context";
3
+ import { useI18n } from "../i18n/context";
4
+ import { formatNumber, formatResetTime } from "../utils/format";
5
+ import type { Account } from "../hooks/use-accounts";
6
+
7
+ const avatarColors = [
8
+ ["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
9
+ ["bg-amber-100 dark:bg-[#3d2c16]", "text-amber-600 dark:text-amber-500"],
10
+ ["bg-blue-100 dark:bg-[#1a2a3f]", "text-blue-600 dark:text-blue-400"],
11
+ ["bg-emerald-100 dark:bg-[#112a1f]", "text-emerald-600 dark:text-emerald-400"],
12
+ ["bg-red-100 dark:bg-[#3f1a1a]", "text-red-600 dark:text-red-400"],
13
+ ];
14
+
15
+ const statusStyles: Record<string, [string, string]> = {
16
+ active: [
17
+ "bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
18
+ "active",
19
+ ],
20
+ expired: [
21
+ "bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
22
+ "expired",
23
+ ],
24
+ rate_limited: [
25
+ "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30",
26
+ "rateLimited",
27
+ ],
28
+ refreshing: [
29
+ "bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30",
30
+ "refreshing",
31
+ ],
32
+ disabled: [
33
+ "bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
34
+ "disabled",
35
+ ],
36
+ };
37
+
38
+ interface AccountCardProps {
39
+ account: Account;
40
+ index: number;
41
+ onDelete: (id: string) => Promise<string | null>;
42
+ }
43
+
44
+ export function AccountCard({ account, index, onDelete }: AccountCardProps) {
45
+ const t = useT();
46
+ const { lang } = useI18n();
47
+ const email = account.email || "Unknown";
48
+ const initial = email.charAt(0).toUpperCase();
49
+ const [bgColor, textColor] = avatarColors[index % avatarColors.length];
50
+ const usage = account.usage || {};
51
+ const requests = usage.request_count ?? 0;
52
+ const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
53
+ const plan = account.planType || t("freeTier");
54
+
55
+ const [statusCls, statusKey] = statusStyles[account.status] || statusStyles.disabled;
56
+
57
+ const handleDelete = useCallback(async () => {
58
+ if (!confirm(t("removeConfirm"))) return;
59
+ const err = await onDelete(account.id);
60
+ if (err) alert(err);
61
+ }, [account.id, onDelete, t]);
62
+
63
+ // Quota
64
+ const q = account.quota;
65
+ const rl = q?.rate_limit;
66
+ const pct = rl?.used_percent != null ? Math.round(rl.used_percent) : null;
67
+ const barColor =
68
+ pct == null ? "bg-primary" : pct >= 90 ? "bg-red-500" : pct >= 60 ? "bg-amber-500" : "bg-primary";
69
+ const pctColor =
70
+ pct == null
71
+ ? "text-primary"
72
+ : pct >= 90
73
+ ? "text-red-500"
74
+ : pct >= 60
75
+ ? "text-amber-600 dark:text-amber-500"
76
+ : "text-primary";
77
+ const resetAt = rl?.reset_at ? formatResetTime(rl.reset_at, lang === "zh") : null;
78
+
79
+ return (
80
+ <div class="group bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50 relative">
81
+ {/* Delete button */}
82
+ <button
83
+ onClick={handleDelete}
84
+ class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 p-1.5 text-slate-300 dark:text-text-dim hover:text-red-500 transition-all rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
85
+ title={t("deleteAccount")}
86
+ >
87
+ <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
88
+ <path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
89
+ </svg>
90
+ </button>
91
+
92
+ {/* Header */}
93
+ <div class="flex justify-between items-start mb-4">
94
+ <div class="flex items-center gap-3">
95
+ <div class={`size-10 rounded-full ${bgColor} ${textColor} flex items-center justify-center font-bold text-lg`}>
96
+ {initial}
97
+ </div>
98
+ <div>
99
+ <h3 class="text-[0.82rem] font-semibold leading-tight">{email}</h3>
100
+ <p class="text-xs text-slate-500 dark:text-text-dim">{plan}</p>
101
+ </div>
102
+ </div>
103
+ <span class={`px-2.5 py-1 rounded-full ${statusCls} text-xs font-medium border`}>
104
+ {t(statusKey as any)}
105
+ </span>
106
+ </div>
107
+
108
+ {/* Stats */}
109
+ <div class="space-y-2">
110
+ <div class="flex justify-between text-[0.78rem]">
111
+ <span class="text-slate-500 dark:text-text-dim">{t("totalRequests")}</span>
112
+ <span class="font-medium">{formatNumber(requests)}</span>
113
+ </div>
114
+ <div class="flex justify-between text-[0.78rem]">
115
+ <span class="text-slate-500 dark:text-text-dim">{t("tokensUsed")}</span>
116
+ <span class="font-medium">{formatNumber(tokens)}</span>
117
+ </div>
118
+ </div>
119
+
120
+ {/* Quota bar */}
121
+ {rl && (
122
+ <div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
123
+ <div class="flex justify-between text-[0.78rem] mb-1.5">
124
+ <span class="text-slate-500 dark:text-text-dim">{t("rateLimit")}</span>
125
+ {rl.limit_reached ? (
126
+ <span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">
127
+ {t("limitReached")}
128
+ </span>
129
+ ) : pct != null ? (
130
+ <span class={`font-medium ${pctColor}`}>
131
+ {pct}% {t("used")}
132
+ </span>
133
+ ) : (
134
+ <span class="font-medium text-primary">{t("ok")}</span>
135
+ )}
136
+ </div>
137
+ {pct != null && (
138
+ <div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
139
+ <div class={`${barColor} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
140
+ </div>
141
+ )}
142
+ {resetAt && (
143
+ <p class="text-xs text-slate-400 dark:text-text-dim mt-1">
144
+ {t("resetsAt")} {resetAt}
145
+ </p>
146
+ )}
147
+ </div>
148
+ )}
149
+ </div>
150
+ );
151
+ }
web/src/components/AccountList.tsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useT } from "../i18n/context";
2
+ import { AccountCard } from "./AccountCard";
3
+ import type { Account } from "../hooks/use-accounts";
4
+
5
+ interface AccountListProps {
6
+ accounts: Account[];
7
+ loading: boolean;
8
+ onDelete: (id: string) => Promise<string | null>;
9
+ }
10
+
11
+ export function AccountList({ accounts, loading, onDelete }: AccountListProps) {
12
+ const t = useT();
13
+
14
+ return (
15
+ <section class="flex flex-col gap-4">
16
+ <div class="flex items-end justify-between">
17
+ <div class="flex flex-col gap-1">
18
+ <h2 class="text-[0.95rem] font-bold tracking-tight">{t("connectedAccounts")}</h2>
19
+ <p class="text-slate-500 dark:text-text-dim text-[0.8rem]">{t("connectedAccountsDesc")}</p>
20
+ </div>
21
+ </div>
22
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
23
+ {loading ? (
24
+ <div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">
25
+ {t("loadingAccounts")}
26
+ </div>
27
+ ) : accounts.length === 0 ? (
28
+ <div class="md:col-span-2 text-center py-8 text-slate-400 dark:text-text-dim text-sm bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl transition-colors">
29
+ {t("noAccounts")}
30
+ </div>
31
+ ) : (
32
+ accounts.map((acct, i) => (
33
+ <AccountCard key={acct.id} account={acct} index={i} onDelete={onDelete} />
34
+ ))
35
+ )}
36
+ </div>
37
+ </section>
38
+ );
39
+ }
web/src/components/AddAccount.tsx ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from "preact/hooks";
2
+ import { useT } from "../i18n/context";
3
+
4
+ interface AddAccountProps {
5
+ visible: boolean;
6
+ onSubmitRelay: (callbackUrl: string) => Promise<void>;
7
+ addInfo: string;
8
+ addError: string;
9
+ }
10
+
11
+ export function AddAccount({ visible, onSubmitRelay, addInfo, addError }: AddAccountProps) {
12
+ const t = useT();
13
+ const [input, setInput] = useState("");
14
+ const [submitting, setSubmitting] = useState(false);
15
+
16
+ const handleSubmit = useCallback(async () => {
17
+ setSubmitting(true);
18
+ await onSubmitRelay(input);
19
+ setSubmitting(false);
20
+ setInput("");
21
+ }, [input, onSubmitRelay]);
22
+
23
+ if (!visible && !addInfo && !addError) return null;
24
+
25
+ return (
26
+ <>
27
+ {addInfo && (
28
+ <p class="text-sm text-primary">{t(addInfo as any)}</p>
29
+ )}
30
+ {addError && (
31
+ <p class="text-sm text-red-500">{t(addError as any)}</p>
32
+ )}
33
+ {visible && (
34
+ <section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
35
+ <ol class="text-sm text-slate-500 dark:text-text-dim mb-4 space-y-1.5 list-decimal list-inside">
36
+ <li dangerouslySetInnerHTML={{ __html: t("addStep1") }} />
37
+ <li dangerouslySetInnerHTML={{ __html: t("addStep2") }} />
38
+ <li dangerouslySetInnerHTML={{ __html: t("addStep3") }} />
39
+ </ol>
40
+ <div class="flex gap-3">
41
+ <input
42
+ type="text"
43
+ value={input}
44
+ onInput={(e) => setInput((e.target as HTMLInputElement).value)}
45
+ placeholder={t("pasteCallback")}
46
+ class="flex-1 px-3 py-2.5 bg-slate-50 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-mono text-slate-600 dark:text-text-main focus:ring-2 focus:ring-primary/50 focus:border-primary outline-none transition-colors"
47
+ />
48
+ <button
49
+ onClick={handleSubmit}
50
+ disabled={submitting}
51
+ class="px-4 py-2.5 bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-lg text-sm font-medium text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-border-dark transition-colors"
52
+ >
53
+ {submitting ? t("submitting") : t("submit")}
54
+ </button>
55
+ </div>
56
+ </section>
57
+ )}
58
+ </>
59
+ );
60
+ }
web/src/components/ApiConfig.tsx ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useT } from "../i18n/context";
2
+ import { CopyButton } from "./CopyButton";
3
+ import { useCallback } from "preact/hooks";
4
+
5
+ interface ApiConfigProps {
6
+ baseUrl: string;
7
+ apiKey: string;
8
+ models: string[];
9
+ selectedModel: string;
10
+ onModelChange: (model: string) => void;
11
+ }
12
+
13
+ export function ApiConfig({ baseUrl, apiKey, models, selectedModel, onModelChange }: ApiConfigProps) {
14
+ const t = useT();
15
+
16
+ const getBaseUrl = useCallback(() => baseUrl, [baseUrl]);
17
+ const getApiKey = useCallback(() => apiKey, [apiKey]);
18
+
19
+ return (
20
+ <section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
21
+ <div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4">
22
+ <div class="flex items-center gap-2">
23
+ <svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
24
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
25
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
26
+ </svg>
27
+ <h2 class="text-[0.95rem] font-bold">{t("apiConfig")}</h2>
28
+ </div>
29
+ </div>
30
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
31
+ {/* Base URL */}
32
+ <div class="space-y-1.5">
33
+ <label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("baseProxyUrl")}</label>
34
+ <div class="relative flex items-center">
35
+ <input
36
+ class="w-full pl-3 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all"
37
+ type="text"
38
+ value={baseUrl}
39
+ readOnly
40
+ />
41
+ <CopyButton getText={getBaseUrl} class="absolute right-2" titleKey="copyUrl" />
42
+ </div>
43
+ </div>
44
+ {/* Default Model */}
45
+ <div class="space-y-1.5">
46
+ <label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("defaultModel")}</label>
47
+ <div class="relative">
48
+ <select
49
+ class="w-full appearance-none pl-3 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors"
50
+ value={selectedModel}
51
+ onChange={(e) => onModelChange((e.target as HTMLSelectElement).value)}
52
+ >
53
+ {models.map((m) => (
54
+ <option key={m} value={m}>{m}</option>
55
+ ))}
56
+ </select>
57
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 dark:text-text-dim">
58
+ <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
59
+ <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
60
+ </svg>
61
+ </div>
62
+ </div>
63
+ </div>
64
+ {/* API Key */}
65
+ <div class="space-y-1.5 md:col-span-2">
66
+ <label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("yourApiKey")}</label>
67
+ <div class="relative flex items-center">
68
+ <div class="absolute left-3 text-slate-400 dark:text-text-dim">
69
+ <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
70
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
71
+ </svg>
72
+ </div>
73
+ <input
74
+ class="w-full pl-10 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all tracking-wider"
75
+ type="text"
76
+ value={apiKey}
77
+ readOnly
78
+ />
79
+ <CopyButton getText={getApiKey} class="absolute right-2" titleKey="copyApiKey" />
80
+ </div>
81
+ <p class="text-xs text-slate-400 dark:text-text-dim mt-1">{t("apiKeyHint")}</p>
82
+ </div>
83
+ </div>
84
+ </section>
85
+ );
86
+ }
web/src/components/CodeExamples.tsx ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useMemo, useCallback } from "preact/hooks";
2
+ import { useT } from "../i18n/context";
3
+ import { CopyButton } from "./CopyButton";
4
+
5
+ type Protocol = "openai" | "anthropic" | "gemini";
6
+ type CodeLang = "python" | "node" | "curl";
7
+
8
+ const protocols: { id: Protocol; label: string }[] = [
9
+ { id: "openai", label: "OpenAI" },
10
+ { id: "anthropic", label: "Anthropic" },
11
+ { id: "gemini", label: "Gemini" },
12
+ ];
13
+
14
+ const langs: { id: CodeLang; label: string }[] = [
15
+ { id: "python", label: "Python" },
16
+ { id: "node", label: "Node.js" },
17
+ { id: "curl", label: "cURL" },
18
+ ];
19
+
20
+ function buildExamples(
21
+ baseUrl: string,
22
+ apiKey: string,
23
+ model: string,
24
+ origin: string
25
+ ): Record<string, string> {
26
+ return {
27
+ "openai-python": `from openai import OpenAI
28
+
29
+ client = OpenAI(
30
+ base_url="${baseUrl}",
31
+ api_key="${apiKey}",
32
+ )
33
+
34
+ response = client.chat.completions.create(
35
+ model="${model}",
36
+ messages=[{"role": "user", "content": "Hello"}],
37
+ )
38
+ print(response.choices[0].message.content)`,
39
+
40
+ "openai-curl": `curl ${baseUrl}/chat/completions \\
41
+ -H "Content-Type: application/json" \\
42
+ -H "Authorization: Bearer ${apiKey}" \\
43
+ -d '{
44
+ "model": "${model}",
45
+ "messages": [{"role": "user", "content": "Hello"}]
46
+ }'`,
47
+
48
+ "openai-node": `import OpenAI from "openai";
49
+
50
+ const client = new OpenAI({
51
+ baseURL: "${baseUrl}",
52
+ apiKey: "${apiKey}",
53
+ });
54
+
55
+ const stream = await client.chat.completions.create({
56
+ model: "${model}",
57
+ messages: [{ role: "user", content: "Hello" }],
58
+ stream: true,
59
+ });
60
+ for await (const chunk of stream) {
61
+ process.stdout.write(chunk.choices[0]?.delta?.content || "");
62
+ }`,
63
+
64
+ "anthropic-python": `import anthropic
65
+
66
+ client = anthropic.Anthropic(
67
+ base_url="${origin}/v1",
68
+ api_key="${apiKey}",
69
+ )
70
+
71
+ message = client.messages.create(
72
+ model="claude-sonnet-4-20250514",
73
+ max_tokens=1024,
74
+ messages=[{"role": "user", "content": "Hello"}],
75
+ )
76
+ print(message.content[0].text)`,
77
+
78
+ "anthropic-curl": `curl ${origin}/v1/messages \\
79
+ -H "Content-Type: application/json" \\
80
+ -H "x-api-key: ${apiKey}" \\
81
+ -H "anthropic-version: 2023-06-01" \\
82
+ -d '{
83
+ "model": "claude-sonnet-4-20250514",
84
+ "max_tokens": 1024,
85
+ "messages": [{"role": "user", "content": "Hello"}]
86
+ }'`,
87
+
88
+ "anthropic-node": `import Anthropic from "@anthropic-ai/sdk";
89
+
90
+ const client = new Anthropic({
91
+ baseURL: "${origin}/v1",
92
+ apiKey: "${apiKey}",
93
+ });
94
+
95
+ const message = await client.messages.create({
96
+ model: "claude-sonnet-4-20250514",
97
+ max_tokens: 1024,
98
+ messages: [{ role: "user", content: "Hello" }],
99
+ });
100
+ console.log(message.content[0].text);`,
101
+
102
+ "gemini-python": `from google import genai
103
+
104
+ client = genai.Client(
105
+ api_key="${apiKey}",
106
+ http_options={"base_url": "${origin}/v1beta"},
107
+ )
108
+
109
+ response = client.models.generate_content(
110
+ model="gemini-2.5-pro",
111
+ contents="Hello",
112
+ )
113
+ print(response.text)`,
114
+
115
+ "gemini-curl": `curl "${origin}/v1beta/models/gemini-2.5-pro:generateContent?key=${apiKey}" \\
116
+ -H "Content-Type: application/json" \\
117
+ -d '{
118
+ "contents": [{"role": "user", "parts": [{"text": "Hello"}]}]
119
+ }'`,
120
+
121
+ "gemini-node": `import { GoogleGenAI } from "@google/genai";
122
+
123
+ const ai = new GoogleGenAI({
124
+ apiKey: "${apiKey}",
125
+ httpOptions: { baseUrl: "${origin}/v1beta" },
126
+ });
127
+
128
+ const response = await ai.models.generateContent({
129
+ model: "gemini-2.5-pro",
130
+ contents: "Hello",
131
+ });
132
+ console.log(response.text);`,
133
+ };
134
+ }
135
+
136
+ interface CodeExamplesProps {
137
+ baseUrl: string;
138
+ apiKey: string;
139
+ model: string;
140
+ }
141
+
142
+ export function CodeExamples({ baseUrl, apiKey, model }: CodeExamplesProps) {
143
+ const t = useT();
144
+ const [protocol, setProtocol] = useState<Protocol>("openai");
145
+ const [codeLang, setCodeLang] = useState<CodeLang>("python");
146
+
147
+ const origin = typeof window !== "undefined" ? window.location.origin : "";
148
+ const examples = useMemo(
149
+ () => buildExamples(baseUrl, apiKey, model, origin),
150
+ [baseUrl, apiKey, model, origin]
151
+ );
152
+
153
+ const currentCode = examples[`${protocol}-${codeLang}`] || "Loading...";
154
+ const getCode = useCallback(() => currentCode, [currentCode]);
155
+
156
+ const protoActive =
157
+ "px-6 py-3 text-[0.82rem] font-semibold text-primary border-b-2 border-primary bg-white dark:bg-card-dark transition-colors";
158
+ const protoInactive =
159
+ "px-6 py-3 text-[0.82rem] font-medium text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d] border-b-2 border-transparent transition-colors";
160
+ const langActive =
161
+ "px-3 py-1.5 text-xs font-semibold rounded bg-white dark:bg-[#21262d] text-slate-800 dark:text-text-main shadow-sm border border-transparent dark:border-border-dark transition-all";
162
+ const langInactive =
163
+ "px-3 py-1.5 text-xs font-medium rounded text-slate-500 dark:text-text-dim hover:text-slate-700 dark:hover:text-text-main hover:bg-white/50 dark:hover:bg-[#21262d] border border-transparent transition-all";
164
+
165
+ return (
166
+ <section class="flex flex-col gap-4">
167
+ <h2 class="text-[0.95rem] font-bold">{t("integrationExamples")}</h2>
168
+ <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl overflow-hidden shadow-sm transition-colors">
169
+ {/* Protocol Tabs */}
170
+ <div class="flex border-b border-gray-200 dark:border-border-dark bg-slate-50/50 dark:bg-bg-dark/30">
171
+ {protocols.map((p) => (
172
+ <button
173
+ key={p.id}
174
+ onClick={() => setProtocol(p.id)}
175
+ class={protocol === p.id ? protoActive : protoInactive}
176
+ >
177
+ {p.label}
178
+ </button>
179
+ ))}
180
+ </div>
181
+ {/* Language Tabs & Code */}
182
+ <div class="p-5">
183
+ <div class="flex items-center justify-between mb-4">
184
+ <div class="flex gap-2 p-1 bg-slate-100 dark:bg-bg-dark dark:border dark:border-border-dark rounded-lg">
185
+ {langs.map((l) => (
186
+ <button
187
+ key={l.id}
188
+ onClick={() => setCodeLang(l.id)}
189
+ class={codeLang === l.id ? langActive : langInactive}
190
+ >
191
+ {l.label}
192
+ </button>
193
+ ))}
194
+ </div>
195
+ </div>
196
+ {/* Code Block */}
197
+ <div class="relative group rounded-lg overflow-hidden bg-[#0d1117] text-slate-300 font-mono text-xs border border-slate-800 dark:border-border-dark">
198
+ <div class="absolute right-2 top-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
199
+ <CopyButton getText={getCode} variant="label" />
200
+ </div>
201
+ <div class="p-4 overflow-x-auto">
202
+ <pre class="m-0"><code>{currentCode}</code></pre>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </section>
208
+ );
209
+ }
web/src/components/CopyButton.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useCallback } from "preact/hooks";
2
+ import { clipboardCopy } from "../utils/clipboard";
3
+ import { useT } from "../i18n/context";
4
+
5
+ interface CopyButtonProps {
6
+ getText: () => string;
7
+ class?: string;
8
+ titleKey?: string;
9
+ variant?: "icon" | "label";
10
+ }
11
+
12
+ const SVG_COPY = (
13
+ <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
14
+ <rect x="9" y="9" width="13" height="13" rx="2" />
15
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
16
+ </svg>
17
+ );
18
+
19
+ const SVG_CHECK = (
20
+ <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
21
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
22
+ </svg>
23
+ );
24
+
25
+ const SVG_FAIL = (
26
+ <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
27
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
28
+ </svg>
29
+ );
30
+
31
+ export function CopyButton({ getText, class: className, titleKey, variant = "icon" }: CopyButtonProps) {
32
+ const t = useT();
33
+ const [state, setState] = useState<"idle" | "ok" | "fail">("idle");
34
+
35
+ const handleCopy = useCallback(async () => {
36
+ const ok = await clipboardCopy(getText());
37
+ setState(ok ? "ok" : "fail");
38
+ setTimeout(() => setState("idle"), 2000);
39
+ }, [getText]);
40
+
41
+ if (variant === "label") {
42
+ const bgClass =
43
+ state === "ok"
44
+ ? "bg-primary hover:bg-primary-hover"
45
+ : state === "fail"
46
+ ? "bg-red-600 hover:bg-red-700"
47
+ : "bg-slate-700 hover:bg-slate-600";
48
+
49
+ return (
50
+ <button
51
+ onClick={handleCopy}
52
+ class={`flex items-center gap-1.5 px-3 py-1.5 ${bgClass} text-white rounded text-xs font-medium transition-colors ${className || ""}`}
53
+ >
54
+ <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
55
+ <rect x="9" y="9" width="13" height="13" rx="2" />
56
+ <path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
57
+ </svg>
58
+ <span>
59
+ {state === "ok" ? t("copied") : state === "fail" ? t("copyFailed") : t("copy")}
60
+ </span>
61
+ </button>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <button
67
+ onClick={handleCopy}
68
+ class={`p-1.5 transition-colors rounded-md hover:bg-slate-100 dark:hover:bg-border-dark ${
69
+ state === "ok"
70
+ ? "text-primary"
71
+ : state === "fail"
72
+ ? "text-red-500"
73
+ : "text-slate-400 dark:text-text-dim hover:text-primary"
74
+ } ${className || ""}`}
75
+ title={titleKey ? t(titleKey as any) : undefined}
76
+ >
77
+ {state === "ok" ? SVG_CHECK : state === "fail" ? SVG_FAIL : SVG_COPY}
78
+ </button>
79
+ );
80
+ }
web/src/components/Footer.tsx ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useT } from "../i18n/context";
2
+
3
+ export function Footer() {
4
+ const t = useT();
5
+
6
+ return (
7
+ <footer class="mt-auto border-t border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark py-6 transition-colors">
8
+ <div class="container mx-auto px-4 text-center">
9
+ <p class="text-[0.8rem] text-slate-500 dark:text-text-dim">{t("footer")}</p>
10
+ </div>
11
+ </footer>
12
+ );
13
+ }
web/src/components/Header.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useI18n } from "../i18n/context";
2
+ import { useTheme } from "../theme/context";
3
+
4
+ const SVG_MOON = (
5
+ <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
6
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
7
+ </svg>
8
+ );
9
+
10
+ const SVG_SUN = (
11
+ <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
12
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
13
+ </svg>
14
+ );
15
+
16
+ interface HeaderProps {
17
+ onAddAccount: () => void;
18
+ }
19
+
20
+ export function Header({ onAddAccount }: HeaderProps) {
21
+ const { lang, toggleLang, t } = useI18n();
22
+ const { isDark, toggle: toggleTheme } = useTheme();
23
+
24
+ return (
25
+ <header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
26
+ <div class="px-4 md:px-8 lg:px-40 flex h-14 items-center justify-center">
27
+ <div class="flex w-full max-w-[960px] items-center justify-between">
28
+ {/* Logo & Title */}
29
+ <div class="flex items-center gap-3">
30
+ <div class="flex items-center justify-center size-8 rounded-full bg-primary/10 text-primary border border-primary/20">
31
+ <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
32
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
33
+ </svg>
34
+ </div>
35
+ <h1 class="text-[0.9rem] font-bold tracking-tight">Codex Proxy</h1>
36
+ </div>
37
+ {/* Actions */}
38
+ <div class="flex items-center gap-3">
39
+ <div class="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
40
+ <span class="relative flex h-2.5 w-2.5">
41
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
42
+ <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-primary" />
43
+ </span>
44
+ <span class="text-xs font-semibold text-primary inline-grid">
45
+ <span class="invisible col-start-1 row-start-1">Server Online</span>
46
+ <span class="col-start-1 row-start-1">{t("serverOnline")}</span>
47
+ </span>
48
+ </div>
49
+ {/* Language Toggle */}
50
+ <button
51
+ onClick={toggleLang}
52
+ class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
53
+ title="\u4e2d/EN"
54
+ >
55
+ <span class="text-xs font-bold inline-flex items-center justify-center w-5">{lang === "en" ? "EN" : "\u4e2d"}</span>
56
+ </button>
57
+ {/* Theme Toggle */}
58
+ <button
59
+ onClick={toggleTheme}
60
+ class="p-2 rounded-lg text-slate-500 dark:text-text-dim hover:bg-slate-100 dark:hover:bg-border-dark transition-colors"
61
+ title={t("toggleTheme")}
62
+ >
63
+ {isDark ? SVG_SUN : SVG_MOON}
64
+ </button>
65
+ {/* Add Account */}
66
+ <button
67
+ onClick={onAddAccount}
68
+ class="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-hover text-white text-xs font-semibold rounded-lg transition-colors shadow-sm active:scale-95"
69
+ >
70
+ <svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
71
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
72
+ </svg>
73
+ <span class="inline-grid">
74
+ <span class="invisible col-start-1 row-start-1">Add Account</span>
75
+ <span class="col-start-1 row-start-1">{t("addAccount")}</span>
76
+ </span>
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </header>
82
+ );
83
+ }
web/src/hooks/use-accounts.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "preact/hooks";
2
+
3
+ export interface AccountQuota {
4
+ rate_limit?: {
5
+ used_percent?: number | null;
6
+ limit_reached?: boolean;
7
+ reset_at?: number | null;
8
+ };
9
+ }
10
+
11
+ export interface Account {
12
+ id: string;
13
+ email: string;
14
+ status: string;
15
+ planType?: string;
16
+ usage?: {
17
+ request_count?: number;
18
+ input_tokens?: number;
19
+ output_tokens?: number;
20
+ };
21
+ quota?: AccountQuota;
22
+ }
23
+
24
+ export function useAccounts() {
25
+ const [list, setList] = useState<Account[]>([]);
26
+ const [loading, setLoading] = useState(true);
27
+ const [addVisible, setAddVisible] = useState(false);
28
+ const [addInfo, setAddInfo] = useState("");
29
+ const [addError, setAddError] = useState("");
30
+
31
+ const loadAccounts = useCallback(async () => {
32
+ try {
33
+ const resp = await fetch("/auth/accounts?quota=true");
34
+ const data = await resp.json();
35
+ setList(data.accounts || []);
36
+ } catch (err) {
37
+ setList([]);
38
+ } finally {
39
+ setLoading(false);
40
+ }
41
+ }, []);
42
+
43
+ useEffect(() => {
44
+ loadAccounts();
45
+ }, [loadAccounts]);
46
+
47
+ // Listen for OAuth callback success
48
+ useEffect(() => {
49
+ const handler = async (event: MessageEvent) => {
50
+ if (event.data?.type === "oauth-callback-success") {
51
+ setAddVisible(false);
52
+ setAddInfo("accountAdded");
53
+ await loadAccounts();
54
+ }
55
+ };
56
+ window.addEventListener("message", handler);
57
+ return () => window.removeEventListener("message", handler);
58
+ }, [loadAccounts]);
59
+
60
+ const startAdd = useCallback(async () => {
61
+ setAddInfo("");
62
+ setAddError("");
63
+ try {
64
+ const resp = await fetch("/auth/login-start", { method: "POST" });
65
+ const data = await resp.json();
66
+ if (!resp.ok || !data.authUrl) {
67
+ throw new Error(data.error || "failedStartLogin");
68
+ }
69
+ window.open(data.authUrl, "oauth_add", "width=600,height=700,scrollbars=yes");
70
+ setAddVisible(true);
71
+
72
+ // Poll for new account
73
+ const prevResp = await fetch("/auth/accounts");
74
+ const prevData = await prevResp.json();
75
+ const prevCount = prevData.accounts?.length || 0;
76
+
77
+ const pollTimer = setInterval(async () => {
78
+ try {
79
+ const r = await fetch("/auth/accounts");
80
+ const d = await r.json();
81
+ if ((d.accounts?.length || 0) > prevCount) {
82
+ clearInterval(pollTimer);
83
+ setAddVisible(false);
84
+ setAddInfo("accountAdded");
85
+ await loadAccounts();
86
+ }
87
+ } catch {}
88
+ }, 2000);
89
+
90
+ setTimeout(() => clearInterval(pollTimer), 5 * 60 * 1000);
91
+ } catch (err) {
92
+ setAddError(err instanceof Error ? err.message : "failedStartLogin");
93
+ }
94
+ }, [loadAccounts]);
95
+
96
+ const submitRelay = useCallback(
97
+ async (callbackUrl: string) => {
98
+ setAddInfo("");
99
+ setAddError("");
100
+ if (!callbackUrl.trim()) {
101
+ setAddError("pleasePassCallback");
102
+ return;
103
+ }
104
+ try {
105
+ const resp = await fetch("/auth/code-relay", {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({ callbackUrl }),
109
+ });
110
+ const data = await resp.json();
111
+ if (resp.ok && data.success) {
112
+ setAddVisible(false);
113
+ setAddInfo("accountAdded");
114
+ await loadAccounts();
115
+ } else {
116
+ setAddError(data.error || "failedExchangeCode");
117
+ }
118
+ } catch (err) {
119
+ setAddError(
120
+ "networkError" + (err instanceof Error ? err.message : String(err))
121
+ );
122
+ }
123
+ },
124
+ [loadAccounts]
125
+ );
126
+
127
+ const deleteAccount = useCallback(
128
+ async (id: string) => {
129
+ try {
130
+ const resp = await fetch("/auth/accounts/" + encodeURIComponent(id), {
131
+ method: "DELETE",
132
+ });
133
+ if (!resp.ok) {
134
+ const data = await resp.json();
135
+ return data.error || "failedDeleteAccount";
136
+ }
137
+ await loadAccounts();
138
+ return null;
139
+ } catch (err) {
140
+ return "networkError" + (err instanceof Error ? err.message : "");
141
+ }
142
+ },
143
+ [loadAccounts]
144
+ );
145
+
146
+ return {
147
+ list,
148
+ loading,
149
+ addVisible,
150
+ addInfo,
151
+ addError,
152
+ startAdd,
153
+ submitRelay,
154
+ deleteAccount,
155
+ };
156
+ }
web/src/hooks/use-status.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect, useCallback } from "preact/hooks";
2
+
3
+ export function useStatus() {
4
+ const [baseUrl, setBaseUrl] = useState("Loading...");
5
+ const [apiKey, setApiKey] = useState("Loading...");
6
+ const [models, setModels] = useState<string[]>(["codex"]);
7
+ const [selectedModel, setSelectedModel] = useState("codex");
8
+
9
+ const loadModels = useCallback(async () => {
10
+ try {
11
+ const resp = await fetch("/v1/models");
12
+ const data = await resp.json();
13
+ const ids: string[] = data.data.map((m: { id: string }) => m.id);
14
+ if (ids.length > 0) {
15
+ setModels(ids);
16
+ const preferred = ids.find((n) => n.includes("5.3-codex"));
17
+ if (preferred) setSelectedModel(preferred);
18
+ }
19
+ } catch {
20
+ setModels(["codex"]);
21
+ }
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ async function loadStatus() {
26
+ try {
27
+ const resp = await fetch("/auth/status");
28
+ const data = await resp.json();
29
+ if (!data.authenticated) return;
30
+ setBaseUrl(`${window.location.origin}/v1`);
31
+ setApiKey(data.proxy_api_key || "any-string");
32
+ await loadModels();
33
+ } catch (err) {
34
+ console.error("Status load error:", err);
35
+ }
36
+ }
37
+ loadStatus();
38
+ }, [loadModels]);
39
+
40
+ return { baseUrl, apiKey, models, selectedModel, setSelectedModel };
41
+ }
web/src/i18n/context.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext } from "preact";
2
+ import { useContext, useState, useCallback } from "preact/hooks";
3
+ import { translations, type LangCode, type TranslationKey } from "./translations";
4
+ import type { ComponentChildren } from "preact";
5
+
6
+ interface I18nContextValue {
7
+ lang: LangCode;
8
+ toggleLang: () => void;
9
+ t: (key: TranslationKey) => string;
10
+ }
11
+
12
+ const I18nContext = createContext<I18nContextValue>(null!);
13
+
14
+ function getInitialLang(): LangCode {
15
+ try {
16
+ const saved = localStorage.getItem("codex-proxy-lang");
17
+ if (saved === "en" || saved === "zh") return saved;
18
+ } catch {}
19
+ return navigator.language.startsWith("zh") ? "zh" : "en";
20
+ }
21
+
22
+ export function I18nProvider({ children }: { children: ComponentChildren }) {
23
+ const [lang, setLang] = useState<LangCode>(getInitialLang);
24
+
25
+ const toggleLang = useCallback(() => {
26
+ setLang((prev) => {
27
+ const next = prev === "en" ? "zh" : "en";
28
+ localStorage.setItem("codex-proxy-lang", next);
29
+ return next;
30
+ });
31
+ }, []);
32
+
33
+ const t = useCallback(
34
+ (key: TranslationKey): string => {
35
+ return translations[lang][key] ?? translations.en[key] ?? key;
36
+ },
37
+ [lang]
38
+ );
39
+
40
+ return (
41
+ <I18nContext.Provider value={{ lang, toggleLang, t }}>
42
+ {children}
43
+ </I18nContext.Provider>
44
+ );
45
+ }
46
+
47
+ export function useT() {
48
+ return useContext(I18nContext).t;
49
+ }
50
+
51
+ export function useI18n() {
52
+ return useContext(I18nContext);
53
+ }
web/src/i18n/translations.ts ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const translations = {
2
+ en: {
3
+ serverOnline: "Server Online",
4
+ addAccount: "Add Account",
5
+ toggleTheme: "Toggle theme",
6
+ connectedAccounts: "Connected Accounts",
7
+ connectedAccountsDesc:
8
+ "Manage your AI model proxy services and usage limits.",
9
+ loadingAccounts: "Loading accounts...",
10
+ noAccounts: 'No accounts connected. Click "Add Account" to get started.',
11
+ deleteAccount: "Delete account",
12
+ removeConfirm: "Remove this account?",
13
+ accountAdded: "Account added successfully!",
14
+ active: "Active",
15
+ expired: "Expired",
16
+ rateLimited: "Rate Limited",
17
+ refreshing: "Refreshing",
18
+ disabled: "Disabled",
19
+ freeTier: "Free Tier",
20
+ totalRequests: "Total Requests",
21
+ tokensUsed: "Tokens Used",
22
+ rateLimit: "Rate Limit",
23
+ limitReached: "Limit Reached",
24
+ used: "Used",
25
+ ok: "OK",
26
+ resetsAt: "Resets at",
27
+ apiConfig: "API Configuration",
28
+ baseProxyUrl: "Base Proxy URL",
29
+ defaultModel: "Default Model",
30
+ yourApiKey: "Your API Key",
31
+ apiKeyHint:
32
+ "Use this key to authenticate requests to the proxy. Do not share it.",
33
+ copyUrl: "Copy URL",
34
+ copyApiKey: "Copy API Key",
35
+ integrationExamples: "Integration Examples",
36
+ copy: "Copy",
37
+ addStep1:
38
+ 'Complete the login in the popup window (if blocked, right-click "Add Account" and open the link in a new tab).',
39
+ addStep2:
40
+ 'After login, the browser will redirect to a <code class="text-xs bg-slate-100 dark:bg-bg-dark px-1.5 py-0.5 rounded">localhost:1455/auth/callback?...</code> page (it may show "unable to connect" — that\'s normal).',
41
+ addStep3:
42
+ 'Copy the <strong class="text-slate-700 dark:text-text-main">full URL</strong> from the address bar and paste it below.',
43
+ pasteCallback: "Paste callback URL here",
44
+ submit: "Submit",
45
+ submitting: "Submitting...",
46
+ pleasePassCallback: "Please paste the callback URL",
47
+ failedStartLogin: "Failed to start login",
48
+ failedExchangeCode: "Failed to exchange code",
49
+ failedDeleteAccount: "Failed to delete account.",
50
+ networkError: "Network error: ",
51
+ copied: "Copied!",
52
+ copyFailed: "Failed",
53
+ footer: "\u00a9 2025 Codex Proxy. All rights reserved.",
54
+ },
55
+ zh: {
56
+ serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d",
57
+ addAccount: "\u6dfb\u52a0\u8d26\u6237",
58
+ toggleTheme: "\u5207\u6362\u4e3b\u9898",
59
+ connectedAccounts: "\u5df2\u8fde\u63a5\u8d26\u6237",
60
+ connectedAccountsDesc:
61
+ "\u7ba1\u7406\u4f60\u7684 AI \u6a21\u578b\u4ee3\u7406\u670d\u52a1\u548c\u7528\u91cf\u9650\u5236\u3002",
62
+ loadingAccounts: "\u6b63\u5728\u52a0\u8f7d\u8d26\u6237...",
63
+ noAccounts:
64
+ "\u6682\u65e0\u5df2\u8fde\u63a5\u7684\u8d26\u6237\u3002\u70b9\u51fb\u300c\u6dfb\u52a0\u8d26\u6237\u300d\u5f00\u59cb\u4f7f\u7528\u3002",
65
+ deleteAccount: "\u5220\u9664\u8d26\u6237",
66
+ removeConfirm:
67
+ "\u786e\u5b9a\u8981\u79fb\u9664\u6b64\u8d26\u6237\u5417\uff1f",
68
+ accountAdded: "\u8d26\u6237\u6dfb\u52a0\u6210\u529f\uff01",
69
+ active: "\u6d3b\u8dc3",
70
+ expired: "\u5df2\u8fc7\u671f",
71
+ rateLimited: "\u5df2\u9650\u901f",
72
+ refreshing: "\u5237\u65b0\u4e2d",
73
+ disabled: "\u5df2\u7981\u7528",
74
+ freeTier: "\u514d\u8d39\u7248",
75
+ totalRequests: "\u603b\u8bf7\u6c42\u6570",
76
+ tokensUsed: "Token \u7528\u91cf",
77
+ rateLimit: "\u901f\u7387\u9650\u5236",
78
+ limitReached: "\u5df2\u8fbe\u4e0a\u9650",
79
+ used: "\u5df2\u4f7f\u7528",
80
+ ok: "\u6b63\u5e38",
81
+ resetsAt: "\u91cd\u7f6e\u65f6\u95f4",
82
+ apiConfig: "API \u914d\u7f6e",
83
+ baseProxyUrl: "\u4ee3\u7406 URL",
84
+ defaultModel: "\u9ed8\u8ba4\u6a21\u578b",
85
+ yourApiKey: "API \u5bc6\u94a5",
86
+ apiKeyHint:
87
+ "\u4f7f\u7528\u6b64\u5bc6\u94a5\u5411\u4ee3\u7406\u53d1\u9001\u8ba4\u8bc1\u8bf7\u6c42\uff0c\u8bf7\u52ff\u6cc4\u9732\u3002",
88
+ copyUrl: "\u590d\u5236 URL",
89
+ copyApiKey: "\u590d\u5236 API \u5bc6\u94a5",
90
+ integrationExamples: "\u96c6\u6210\u793a\u4f8b",
91
+ copy: "\u590d\u5236",
92
+ addStep1:
93
+ "\u5728\u5f39\u51fa\u7684\u7a97\u53e3\u4e2d\u5b8c\u6210\u767b\u5f55\uff08\u5982\u5f39\u7a97\u88ab\u62e6\u622a\uff0c\u53f3\u952e\u300c\u6dfb\u52a0\u8d26\u6237\u300d\u6309\u94ae\u5728\u65b0\u6807\u7b7e\u9875\u6253\u5f00\u94fe\u63a5\uff09\u3002",
94
+ addStep2:
95
+ '\u767b\u5f55\u6210\u529f\u540e\uff0c\u6d4f\u89c8\u5668\u4f1a\u8df3\u8f6c\u5230 <code class="text-xs bg-slate-100 dark:bg-bg-dark px-1.5 py-0.5 rounded">localhost:1455/auth/callback?...</code> \u9875\u9762\uff08\u53ef\u80fd\u663e\u793a\u201c\u65e0\u6cd5\u8bbf\u95ee\u201d\u2014\u2014\u8fd9\u662f\u6b63\u5e38\u7684\uff09\u3002',
96
+ addStep3:
97
+ '\u590d\u5236\u5730\u5740\u680f\u4e2d\u7684<strong class="text-slate-700 dark:text-text-main">\u5b8c\u6574 URL</strong>\uff0c\u7c98\u8d34\u5230\u4e0b\u65b9\u8f93\u5165\u6846\u3002',
98
+ pasteCallback: "\u7c98\u8d34\u56de\u8c03 URL",
99
+ submit: "\u63d0\u4ea4",
100
+ submitting: "\u63d0\u4ea4\u4e2d...",
101
+ pleasePassCallback: "\u8bf7\u7c98\u8d34\u56de\u8c03 URL",
102
+ failedStartLogin: "\u767b\u5f55\u542f\u52a8\u5931\u8d25",
103
+ failedExchangeCode: "\u6388\u6743\u7801\u4ea4\u6362\u5931\u8d25",
104
+ failedDeleteAccount: "\u5220\u9664\u8d26\u6237\u5931\u8d25\u3002",
105
+ networkError: "\u7f51\u7edc\u9519\u8bef\uff1a",
106
+ copied: "\u5df2\u590d\u5236\uff01",
107
+ copyFailed: "\u5931\u8d25",
108
+ footer:
109
+ "\u00a9 2025 Codex Proxy\u3002\u4fdd\u7559\u6240\u6709\u6743\u5229\u3002",
110
+ },
111
+ } as const;
112
+
113
+ export type LangCode = keyof typeof translations;
114
+ export type TranslationKey = keyof (typeof translations)["en"];
web/src/index.css ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --primary: 16 162 53;
7
+ --primary-hover: 14 140 46;
8
+ }
9
+
10
+ .dark {
11
+ --primary: 16 163 127;
12
+ --primary-hover: 14 140 108;
13
+ }
14
+
15
+ /* Dark scrollbar for code blocks */
16
+ pre::-webkit-scrollbar { height: 8px; }
17
+ pre::-webkit-scrollbar-track { background: #0d1117; }
18
+ pre::-webkit-scrollbar-thumb { background: #30363d; border-radius: 4px; }
19
+ pre::-webkit-scrollbar-thumb:hover { background: #8b949e; }
web/src/main.tsx ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ import { render } from "preact";
2
+ import { App } from "./App";
3
+ import "./index.css";
4
+
5
+ render(<App />, document.getElementById("app")!);
web/src/theme/context.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext } from "preact";
2
+ import { useContext, useState, useCallback } from "preact/hooks";
3
+ import type { ComponentChildren } from "preact";
4
+
5
+ interface ThemeContextValue {
6
+ isDark: boolean;
7
+ toggle: () => void;
8
+ }
9
+
10
+ const ThemeContext = createContext<ThemeContextValue>(null!);
11
+
12
+ function getInitialDark(): boolean {
13
+ try {
14
+ const saved = localStorage.getItem("codex-proxy-theme");
15
+ if (saved === "dark") return true;
16
+ if (saved === "light") return false;
17
+ } catch {}
18
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
19
+ }
20
+
21
+ export function ThemeProvider({ children }: { children: ComponentChildren }) {
22
+ const [isDark, setIsDark] = useState(getInitialDark);
23
+
24
+ const toggle = useCallback(() => {
25
+ setIsDark((prev) => {
26
+ const next = !prev;
27
+ localStorage.setItem("codex-proxy-theme", next ? "dark" : "light");
28
+ if (next) {
29
+ document.documentElement.classList.add("dark");
30
+ } else {
31
+ document.documentElement.classList.remove("dark");
32
+ }
33
+ return next;
34
+ });
35
+ }, []);
36
+
37
+ return (
38
+ <ThemeContext.Provider value={{ isDark, toggle }}>
39
+ {children}
40
+ </ThemeContext.Provider>
41
+ );
42
+ }
43
+
44
+ export function useTheme() {
45
+ return useContext(ThemeContext);
46
+ }
web/src/utils/clipboard.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function fallbackCopy(text: string): boolean {
2
+ const ta = document.createElement("textarea");
3
+ ta.value = text;
4
+ ta.style.cssText = "position:fixed;left:-9999px;opacity:0";
5
+ document.body.appendChild(ta);
6
+ ta.select();
7
+ let ok = false;
8
+ try {
9
+ ok = document.execCommand("copy");
10
+ } catch {}
11
+ document.body.removeChild(ta);
12
+ return ok;
13
+ }
14
+
15
+ export async function clipboardCopy(text: string): Promise<boolean> {
16
+ if (navigator.clipboard?.writeText) {
17
+ try {
18
+ await navigator.clipboard.writeText(text);
19
+ return true;
20
+ } catch {}
21
+ }
22
+ return fallbackCopy(text);
23
+ }
web/src/utils/format.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function formatNumber(n: number): string {
2
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
3
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
4
+ return String(n);
5
+ }
6
+
7
+ export function formatResetTime(unixSec: number, isZh: boolean): string {
8
+ const d = new Date(unixSec * 1000);
9
+ const now = new Date();
10
+ const time = d.toLocaleTimeString(undefined, {
11
+ hour: "2-digit",
12
+ minute: "2-digit",
13
+ second: "2-digit",
14
+ });
15
+
16
+ if (
17
+ d.getFullYear() === now.getFullYear() &&
18
+ d.getMonth() === now.getMonth() &&
19
+ d.getDate() === now.getDate()
20
+ ) {
21
+ return time;
22
+ }
23
+
24
+ const tomorrow = new Date(now);
25
+ tomorrow.setDate(tomorrow.getDate() + 1);
26
+ if (
27
+ d.getFullYear() === tomorrow.getFullYear() &&
28
+ d.getMonth() === tomorrow.getMonth() &&
29
+ d.getDate() === tomorrow.getDate()
30
+ ) {
31
+ return (isZh ? "\u660e\u5929 " : "Tomorrow ") + time;
32
+ }
33
+
34
+ const date = d.toLocaleDateString(undefined, {
35
+ month: "short",
36
+ day: "numeric",
37
+ });
38
+ return date + " " + time;
39
+ }
web/tailwind.config.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "tailwindcss";
2
+
3
+ export default {
4
+ content: ["./index.html", "./src/**/*.{ts,tsx}"],
5
+ darkMode: "class",
6
+ theme: {
7
+ extend: {
8
+ colors: {
9
+ primary: "rgb(var(--primary) / <alpha-value>)",
10
+ "primary-hover": "rgb(var(--primary-hover) / <alpha-value>)",
11
+ "bg-light": "#f6f8f6",
12
+ "bg-dark": "#0d1117",
13
+ "card-dark": "#161b22",
14
+ "border-dark": "#30363d",
15
+ "text-main": "#e6edf3",
16
+ "text-dim": "#8b949e",
17
+ },
18
+ fontFamily: {
19
+ display: [
20
+ "system-ui",
21
+ "-apple-system",
22
+ "BlinkMacSystemFont",
23
+ "Segoe UI",
24
+ "Roboto",
25
+ "sans-serif",
26
+ ],
27
+ mono: [
28
+ "ui-monospace",
29
+ "SFMono-Regular",
30
+ "SF Mono",
31
+ "Menlo",
32
+ "Consolas",
33
+ "Liberation Mono",
34
+ "monospace",
35
+ ],
36
+ },
37
+ borderRadius: {
38
+ DEFAULT: "0.25rem",
39
+ lg: "0.5rem",
40
+ xl: "0.75rem",
41
+ full: "9999px",
42
+ },
43
+ },
44
+ },
45
+ plugins: [],
46
+ } satisfies Config;
web/tsconfig.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "noImplicitAny": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "jsx": "react-jsx",
14
+ "jsxImportSource": "preact"
15
+ },
16
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
17
+ }
web/vite.config.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vite";
2
+ import preact from "@preact/preset-vite";
3
+
4
+ export default defineConfig({
5
+ plugins: [preact()],
6
+ build: {
7
+ outDir: "../public",
8
+ emptyOutDir: false,
9
+ },
10
+ server: {
11
+ port: 5173,
12
+ proxy: {
13
+ "/v1": "http://localhost:8080",
14
+ "/auth": "http://localhost:8080",
15
+ "/health": "http://localhost:8080",
16
+ "/debug": "http://localhost:8080",
17
+ },
18
+ },
19
+ });