feat: production-ready UI with onboarding wizard and dashboard
- Onboarding flow (welcome -> Codex setup -> Claude setup -> done) with dark-themed screens and step indicators; completion stored in SecureStore - Smart root redirect checks onboarding state on launch - Dashboard tab shows both services at a glance via ServiceStatusCard; useFocusEffect + reload() keeps it fresh when credentials change in tabs - Settings tab manages credentials and exposes re-run setup wizard - Polished Codex and Claude detail screens with service headers and empty states - 4-tab layout (Home, Codex, Claude, Settings) with dark-mode tab bar - Both hooks gain reload() for cross-tab credential refresh Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+54
-21
@@ -9,6 +9,35 @@ import type { ClaudeUsageResponse } from "@/types/claude";
|
||||
|
||||
type Status = "idle" | "loading" | "success" | "error";
|
||||
|
||||
async function doFetch(
|
||||
sessionKey: string,
|
||||
set: {
|
||||
sessionKey: (v: string | null) => void;
|
||||
usage: (v: ClaudeUsageResponse | null) => void;
|
||||
status: (v: Status) => void;
|
||||
error: (v: string | null) => void;
|
||||
}
|
||||
) {
|
||||
set.status("loading");
|
||||
set.error(null);
|
||||
try {
|
||||
const orgs = await fetchClaudeOrgs(sessionKey);
|
||||
if (!orgs.length) throw new Error("NO_ORGS_FOUND");
|
||||
const orgUuid = orgs[0].uuid;
|
||||
const data = await fetchClaudeUsage(sessionKey, orgUuid);
|
||||
set.usage(data);
|
||||
set.status("success");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
set.error(msg);
|
||||
set.status("error");
|
||||
if (msg === "TOKEN_EXPIRED") {
|
||||
await clearClaudeSessionKey();
|
||||
set.sessionKey(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useClaudeUsage() {
|
||||
const [sessionKey, setSessionKey] = useState<string | null>(null);
|
||||
const [pendingKey, setPendingKey] = useState("");
|
||||
@@ -16,12 +45,18 @@ export function useClaudeUsage() {
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setters = { sessionKey: setSessionKey, usage: setUsage, status: setStatus, error: setError };
|
||||
|
||||
useEffect(() => {
|
||||
loadClaudeSessionKey().then((stored) => {
|
||||
if (stored) setSessionKey(stored);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionKey) void doFetch(sessionKey, setters);
|
||||
}, [sessionKey]);
|
||||
|
||||
const saveKey = useCallback(async () => {
|
||||
const trimmed = pendingKey.trim();
|
||||
if (!trimmed.startsWith("sk-ant-")) {
|
||||
@@ -36,29 +71,26 @@ export function useClaudeUsage() {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!sessionKey) return;
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const orgs = await fetchClaudeOrgs(sessionKey);
|
||||
if (!orgs.length) throw new Error("NO_ORGS_FOUND");
|
||||
const orgUuid = orgs[0].uuid;
|
||||
const data = await fetchClaudeUsage(sessionKey, orgUuid);
|
||||
setUsage(data);
|
||||
setStatus("success");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
setError(msg);
|
||||
setStatus("error");
|
||||
if (msg === "TOKEN_EXPIRED") {
|
||||
await clearClaudeSessionKey();
|
||||
setSessionKey(null);
|
||||
}
|
||||
}
|
||||
await doFetch(sessionKey, setters);
|
||||
}, [sessionKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionKey) void refresh();
|
||||
}, [sessionKey]);
|
||||
// Re-reads the session key from storage and fetches fresh data. Call from
|
||||
// useFocusEffect so the dashboard stays current when the key is saved from
|
||||
// another tab. If the string value is unchanged, React bails out on setState
|
||||
// so we trigger the fetch directly.
|
||||
const reload = useCallback(async () => {
|
||||
const stored = await loadClaudeSessionKey();
|
||||
if (!stored) {
|
||||
setSessionKey(null);
|
||||
setUsage(null);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
// If already set to the same string, setState bails out and [sessionKey]
|
||||
// effect won't fire, so call the fetch directly.
|
||||
setSessionKey(stored);
|
||||
void doFetch(stored, setters);
|
||||
}, []);
|
||||
|
||||
const clearKey = useCallback(async () => {
|
||||
await clearClaudeSessionKey();
|
||||
@@ -77,6 +109,7 @@ export function useClaudeUsage() {
|
||||
error,
|
||||
saveKey,
|
||||
refresh,
|
||||
reload,
|
||||
clearKey,
|
||||
};
|
||||
}
|
||||
|
||||
+48
-19
@@ -6,18 +6,50 @@ import type { CodexUsageResponse, CodexAuth } from "@/types/codex";
|
||||
|
||||
type Status = "idle" | "loading" | "success" | "error";
|
||||
|
||||
async function doFetch(
|
||||
auth: CodexAuth,
|
||||
set: {
|
||||
auth: (v: CodexAuth | null) => void;
|
||||
usage: (v: CodexUsageResponse | null) => void;
|
||||
status: (v: Status) => void;
|
||||
error: (v: string | null) => void;
|
||||
}
|
||||
) {
|
||||
set.status("loading");
|
||||
set.error(null);
|
||||
try {
|
||||
const data = await fetchCodexUsage(auth);
|
||||
set.usage(data);
|
||||
set.status("success");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
set.error(msg);
|
||||
set.status("error");
|
||||
if (msg === "TOKEN_EXPIRED") {
|
||||
await clearCodexAuth();
|
||||
set.auth(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useCodexUsage() {
|
||||
const [auth, setAuth] = useState<CodexAuth | null>(null);
|
||||
const [usage, setUsage] = useState<CodexUsageResponse | null>(null);
|
||||
const [status, setStatus] = useState<Status>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setters = { auth: setAuth, usage: setUsage, status: setStatus, error: setError };
|
||||
|
||||
useEffect(() => {
|
||||
loadCodexAuth().then((stored) => {
|
||||
if (stored) setAuth(stored);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth) void doFetch(auth, setters);
|
||||
}, [auth]);
|
||||
|
||||
const importAuthFile = useCallback(async () => {
|
||||
try {
|
||||
const parsed = await pickAndReadCodexAuth();
|
||||
@@ -32,26 +64,23 @@ export function useCodexUsage() {
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!auth) return;
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchCodexUsage(auth);
|
||||
setUsage(data);
|
||||
setStatus("success");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||
setError(msg);
|
||||
setStatus("error");
|
||||
if (msg === "TOKEN_EXPIRED") {
|
||||
await clearCodexAuth();
|
||||
setAuth(null);
|
||||
}
|
||||
}
|
||||
await doFetch(auth, setters);
|
||||
}, [auth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth) void refresh();
|
||||
}, [auth]);
|
||||
// Re-reads auth from storage and fetches fresh data. Call from useFocusEffect
|
||||
// so the dashboard stays current when credentials are saved from another tab.
|
||||
const reload = useCallback(async () => {
|
||||
const stored = await loadCodexAuth();
|
||||
if (!stored) {
|
||||
setAuth(null);
|
||||
setUsage(null);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
// Always pass a fresh object so the [auth] effect fires even if the content
|
||||
// is identical (JSON.parse returns a new reference each time, which is fine).
|
||||
setAuth(stored);
|
||||
}, []);
|
||||
|
||||
const clearAuth = useCallback(async () => {
|
||||
await clearCodexAuth();
|
||||
@@ -61,5 +90,5 @@ export function useCodexUsage() {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return { auth, usage, status, error, importAuthFile, refresh, clearAuth };
|
||||
return { auth, usage, status, error, importAuthFile, refresh, reload, clearAuth };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user