[skip ci] Initial commit — CodexBar Mobile Expo app
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
name: Build Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
- name: Setup EAS
|
||||||
|
uses: expo/expo-github-action@v8
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Android APK
|
||||||
|
run: eas build --platform android --profile preview --non-interactive --output ./build/codexbar.apk
|
||||||
|
|
||||||
|
- name: Upload APK artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: codexbar-android-${{ gitea.sha }}
|
||||||
|
path: ./build/codexbar.apk
|
||||||
|
retention-days: 30
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "codexbar.mobile",
|
"name": "codexbar.mobile",
|
||||||
"slug": "codexbar.mobile",
|
"slug": "codexbar-mobile",
|
||||||
|
"scheme": "codexbar",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
"supportsTablet": true
|
"supportsTablet": true
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
|
"package": "dev.reversed.codexbar",
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#E6F4FE",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
@@ -20,6 +22,12 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
}
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
"expo-status-bar",
|
||||||
|
"expo-secure-store",
|
||||||
|
"expo-document-picker"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Tabs } from "expo-router";
|
||||||
|
import { MaterialIcons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
export default function TabLayout() {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
tabBarActiveTintColor: "#10a37f",
|
||||||
|
tabBarStyle: { backgroundColor: "#ffffff" },
|
||||||
|
headerStyle: { backgroundColor: "#ffffff" },
|
||||||
|
headerTitleStyle: { fontWeight: "600" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="codex"
|
||||||
|
options={{
|
||||||
|
title: "Codex",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<MaterialIcons name="auto-awesome" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="claude"
|
||||||
|
options={{
|
||||||
|
title: "Claude",
|
||||||
|
tabBarIcon: ({ color, size }) => (
|
||||||
|
<MaterialIcons name="psychology" size={size} color={color} />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useClaudeUsage } from "@/hooks/useClaudeUsage";
|
||||||
|
import { SectionCard } from "@/components/SectionCard";
|
||||||
|
import { UsageStat } from "@/components/UsageStat";
|
||||||
|
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||||
|
|
||||||
|
export default function ClaudeTab() {
|
||||||
|
const {
|
||||||
|
sessionKey,
|
||||||
|
pendingKey,
|
||||||
|
setPendingKey,
|
||||||
|
usage,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
saveKey,
|
||||||
|
refresh,
|
||||||
|
clearKey,
|
||||||
|
} = useClaudeUsage();
|
||||||
|
|
||||||
|
const canSave = pendingKey.trim().startsWith("sk-ant-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-neutral-50 dark:bg-neutral-950" edges={["bottom"]}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
className="flex-1"
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 px-4 pt-4"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={status === "loading"}
|
||||||
|
onRefresh={refresh}
|
||||||
|
tintColor="#d97706"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ paddingBottom: 32 }}
|
||||||
|
>
|
||||||
|
{/* Configure */}
|
||||||
|
<SectionCard title="Configuration">
|
||||||
|
<ErrorMessage
|
||||||
|
message={error && (status === "idle" || status === "error") ? error : null}
|
||||||
|
/>
|
||||||
|
{sessionKey ? (
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<View className="flex-row items-center gap-x-2">
|
||||||
|
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
<Text className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Configured
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={clearKey}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Text className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
|
Clear
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View>
|
||||||
|
<TextInput
|
||||||
|
value={pendingKey}
|
||||||
|
onChangeText={setPendingKey}
|
||||||
|
placeholder="sk-ant-..."
|
||||||
|
placeholderTextColor="#a3a3a3"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
className="border border-neutral-200 dark:border-neutral-700 rounded-xl px-3 py-2.5 text-sm text-neutral-900 dark:text-white bg-white dark:bg-neutral-800 mb-3"
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={saveKey}
|
||||||
|
disabled={!canSave}
|
||||||
|
className={`py-3 px-4 rounded-xl ${
|
||||||
|
canSave ? "bg-amber-500" : "bg-neutral-200 dark:bg-neutral-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-center font-semibold text-sm ${
|
||||||
|
canSave ? "text-white" : "text-neutral-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Save Session Key
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-xs text-neutral-400 mt-2">
|
||||||
|
Chrome DevTools → Application → Cookies → claude.ai → sessionKey
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Usage */}
|
||||||
|
<SectionCard title="Usage">
|
||||||
|
{status === "idle" && !sessionKey && (
|
||||||
|
<Text className="text-sm text-neutral-400 text-center py-4">
|
||||||
|
Enter your Claude session key to see usage
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{status === "loading" && (
|
||||||
|
<ActivityIndicator color="#d97706" className="py-4" />
|
||||||
|
)}
|
||||||
|
{status === "error" && error && <ErrorMessage message={error} />}
|
||||||
|
{status === "success" && usage && (
|
||||||
|
<View>
|
||||||
|
<UsageStat
|
||||||
|
label="5-hour window"
|
||||||
|
percent={usage.five_hour.utilization}
|
||||||
|
resetAtISO={usage.five_hour.resets_at}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UsageStat
|
||||||
|
label="7-day window"
|
||||||
|
percent={usage.seven_day.utilization}
|
||||||
|
resetAtISO={usage.seven_day.resets_at}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(usage.seven_day_sonnet || usage.seven_day_opus) && (
|
||||||
|
<View className="mt-2 p-3 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">
|
||||||
|
By Model (7-day)
|
||||||
|
</Text>
|
||||||
|
{usage.seven_day_sonnet && (
|
||||||
|
<UsageStat
|
||||||
|
label="Sonnet"
|
||||||
|
percent={usage.seven_day_sonnet.utilization}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{usage.seven_day_opus && (
|
||||||
|
<UsageStat
|
||||||
|
label="Opus"
|
||||||
|
percent={usage.seven_day_opus.utilization}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import {
|
||||||
|
ScrollView,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
RefreshControl,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { useCodexUsage } from "@/hooks/useCodexUsage";
|
||||||
|
import { SectionCard } from "@/components/SectionCard";
|
||||||
|
import { UsageStat } from "@/components/UsageStat";
|
||||||
|
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||||
|
import { windowLabel } from "@/lib/timeUtils";
|
||||||
|
|
||||||
|
export default function CodexTab() {
|
||||||
|
const { auth, usage, status, error, importAuthFile, refresh, clearAuth } =
|
||||||
|
useCodexUsage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-neutral-50 dark:bg-neutral-950" edges={["bottom"]}>
|
||||||
|
<ScrollView
|
||||||
|
className="flex-1 px-4 pt-4"
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={status === "loading"}
|
||||||
|
onRefresh={refresh}
|
||||||
|
tintColor="#10a37f"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
contentContainerStyle={{ paddingBottom: 32 }}
|
||||||
|
>
|
||||||
|
{/* Configure */}
|
||||||
|
<SectionCard title="Configuration">
|
||||||
|
<ErrorMessage message={error && status === "idle" ? error : null} />
|
||||||
|
{auth ? (
|
||||||
|
<View className="flex-row items-center justify-between">
|
||||||
|
<View className="flex-row items-center gap-x-2">
|
||||||
|
<View className="w-2 h-2 rounded-full bg-green-500" />
|
||||||
|
<Text className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Configured
|
||||||
|
</Text>
|
||||||
|
{auth.accountId && (
|
||||||
|
<Text className="text-xs text-neutral-400">
|
||||||
|
({auth.accountId.slice(0, 8)}…)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={clearAuth}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<Text className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
|
Clear
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={importAuthFile}
|
||||||
|
className="flex-row items-center justify-center gap-x-2 py-3 px-4 rounded-xl bg-brand-codex"
|
||||||
|
>
|
||||||
|
<Text className="text-white font-semibold text-sm">
|
||||||
|
Import auth.json
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text className="text-xs text-neutral-400 text-center mt-2">
|
||||||
|
Located at ~/.codex/auth.json
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* Usage */}
|
||||||
|
<SectionCard title="Usage">
|
||||||
|
{status === "idle" && !auth && (
|
||||||
|
<Text className="text-sm text-neutral-400 text-center py-4">
|
||||||
|
Import your auth.json to see usage
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{status === "loading" && (
|
||||||
|
<ActivityIndicator color="#10a37f" className="py-4" />
|
||||||
|
)}
|
||||||
|
{status === "error" && error && (
|
||||||
|
<ErrorMessage message={error} />
|
||||||
|
)}
|
||||||
|
{status === "success" && usage && (
|
||||||
|
<View>
|
||||||
|
{/* Plan badge */}
|
||||||
|
<View className="flex-row items-center mb-4">
|
||||||
|
<View className="px-2.5 py-1 rounded-full bg-green-100 dark:bg-green-900/40">
|
||||||
|
<Text className="text-xs font-semibold text-green-700 dark:text-green-300 capitalize">
|
||||||
|
{usage.plan_type}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<UsageStat
|
||||||
|
label={`Primary (${windowLabel(usage.rate_limit.primary_window.limit_window_seconds)})`}
|
||||||
|
percent={usage.rate_limit.primary_window.used_percent}
|
||||||
|
resetAtSeconds={usage.rate_limit.primary_window.reset_at}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UsageStat
|
||||||
|
label={`Secondary (${windowLabel(usage.rate_limit.secondary_window.limit_window_seconds)})`}
|
||||||
|
percent={usage.rate_limit.secondary_window.used_percent}
|
||||||
|
resetAtSeconds={usage.rate_limit.secondary_window.reset_at}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Credits */}
|
||||||
|
<View className="mt-2 p-3 rounded-xl bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-2">
|
||||||
|
Credits
|
||||||
|
</Text>
|
||||||
|
{usage.credits.unlimited ? (
|
||||||
|
<Text className="text-sm font-medium text-green-600 dark:text-green-400">
|
||||||
|
Unlimited
|
||||||
|
</Text>
|
||||||
|
) : usage.credits.has_credits ? (
|
||||||
|
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
|
${Number(usage.credits.balance).toFixed(2)} remaining
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-neutral-400">No credits</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import "../global.css";
|
||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<StatusBar style="auto" />
|
||||||
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return <Redirect href="/(tabs)/codex" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: [
|
||||||
|
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||||
|
"nativewind/babel",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { View, Text } from "react-native";
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
TOKEN_EXPIRED: "Your session token has expired. Please reconfigure.",
|
||||||
|
MISSING_ACCESS_TOKEN: "auth.json is missing the accessToken field.",
|
||||||
|
INVALID_JSON: "The selected file is not valid JSON.",
|
||||||
|
NO_ORGS_FOUND: "No Claude organizations found for this session key.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function humanize(code: string): string {
|
||||||
|
if (code.startsWith("HTTP_ERROR_")) {
|
||||||
|
return `Unexpected server error (HTTP ${code.replace("HTTP_ERROR_", "")}).`;
|
||||||
|
}
|
||||||
|
return ERROR_MESSAGES[code] ?? `Unexpected error: ${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorMessageProps {
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorMessage({ message }: ErrorMessageProps) {
|
||||||
|
if (!message) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-xl px-3 py-2.5 mb-3">
|
||||||
|
<Text className="text-sm text-red-700 dark:text-red-300">{humanize(message)}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { View } from "react-native";
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBarColor(percent: number): string {
|
||||||
|
if (percent >= 85) return "bg-red-500";
|
||||||
|
if (percent >= 60) return "bg-yellow-400";
|
||||||
|
return "bg-green-500";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBar({ percent }: ProgressBarProps) {
|
||||||
|
const clamped = Math.max(0, Math.min(100, percent));
|
||||||
|
return (
|
||||||
|
<View className="h-2.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-700">
|
||||||
|
<View
|
||||||
|
className={`h-2.5 rounded-full ${getBarColor(clamped)}`}
|
||||||
|
style={{ width: `${clamped}%` }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Text } from "react-native";
|
||||||
|
import { formatResetCountdown, formatResetCountdownISO } from "@/lib/timeUtils";
|
||||||
|
|
||||||
|
interface ResetCountdownProps {
|
||||||
|
resetAtSeconds?: number;
|
||||||
|
resetAtISO?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResetCountdown({ resetAtSeconds, resetAtISO }: ResetCountdownProps) {
|
||||||
|
const getLabel = () => {
|
||||||
|
if (resetAtSeconds !== undefined) return formatResetCountdown(resetAtSeconds);
|
||||||
|
if (resetAtISO) return formatResetCountdownISO(resetAtISO);
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const [label, setLabel] = useState(getLabel);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLabel(getLabel());
|
||||||
|
const interval = setInterval(() => setLabel(getLabel()), 60_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [resetAtSeconds, resetAtISO]);
|
||||||
|
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text className="text-xs text-neutral-500 dark:text-neutral-400">{label}</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { View, Text } from "react-native";
|
||||||
|
|
||||||
|
interface SectionCardProps {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
accentClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectionCard({ title, children, accentClass }: SectionCardProps) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`rounded-2xl bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 p-4 mb-4 ${accentClass ?? ""}`}
|
||||||
|
>
|
||||||
|
<Text className="text-xs font-semibold uppercase tracking-widest text-neutral-500 dark:text-neutral-400 mb-3">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { View, Text } from "react-native";
|
||||||
|
import { ProgressBar } from "./ProgressBar";
|
||||||
|
import { ResetCountdown } from "./ResetCountdown";
|
||||||
|
|
||||||
|
interface UsageStatProps {
|
||||||
|
label: string;
|
||||||
|
percent: number;
|
||||||
|
resetAtSeconds?: number;
|
||||||
|
resetAtISO?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageStat({ label, percent, resetAtSeconds, resetAtISO }: UsageStatProps) {
|
||||||
|
return (
|
||||||
|
<View className="gap-y-1.5 mb-4">
|
||||||
|
<View className="flex-row justify-between items-center">
|
||||||
|
<Text className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm font-semibold text-neutral-900 dark:text-white">
|
||||||
|
{Math.round(percent)}%
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ProgressBar percent={percent} />
|
||||||
|
<ResetCountdown resetAtSeconds={resetAtSeconds} resetAtISO={resetAtISO} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { fetchClaudeOrgs, fetchClaudeUsage } from "@/lib/api/claudeApi";
|
||||||
|
import {
|
||||||
|
loadClaudeSessionKey,
|
||||||
|
saveClaudeSessionKey,
|
||||||
|
clearClaudeSessionKey,
|
||||||
|
} from "@/lib/storage";
|
||||||
|
import type { ClaudeUsageResponse } from "@/types/claude";
|
||||||
|
|
||||||
|
type Status = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
export function useClaudeUsage() {
|
||||||
|
const [sessionKey, setSessionKey] = useState<string | null>(null);
|
||||||
|
const [pendingKey, setPendingKey] = useState("");
|
||||||
|
const [usage, setUsage] = useState<ClaudeUsageResponse | null>(null);
|
||||||
|
const [status, setStatus] = useState<Status>("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadClaudeSessionKey().then((stored) => {
|
||||||
|
if (stored) setSessionKey(stored);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveKey = useCallback(async () => {
|
||||||
|
const trimmed = pendingKey.trim();
|
||||||
|
if (!trimmed.startsWith("sk-ant-")) {
|
||||||
|
setError("Session key must start with sk-ant-");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveClaudeSessionKey(trimmed);
|
||||||
|
setSessionKey(trimmed);
|
||||||
|
setPendingKey("");
|
||||||
|
setError(null);
|
||||||
|
}, [pendingKey]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sessionKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionKey) void refresh();
|
||||||
|
}, [sessionKey]);
|
||||||
|
|
||||||
|
const clearKey = useCallback(async () => {
|
||||||
|
await clearClaudeSessionKey();
|
||||||
|
setSessionKey(null);
|
||||||
|
setUsage(null);
|
||||||
|
setStatus("idle");
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionKey,
|
||||||
|
pendingKey,
|
||||||
|
setPendingKey,
|
||||||
|
usage,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
saveKey,
|
||||||
|
refresh,
|
||||||
|
clearKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { fetchCodexUsage } from "@/lib/api/codexApi";
|
||||||
|
import { loadCodexAuth, saveCodexAuth, clearCodexAuth } from "@/lib/storage";
|
||||||
|
import { pickAndReadCodexAuth } from "@/lib/fileReader";
|
||||||
|
import type { CodexUsageResponse, CodexAuth } from "@/types/codex";
|
||||||
|
|
||||||
|
type Status = "idle" | "loading" | "success" | "error";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCodexAuth().then((stored) => {
|
||||||
|
if (stored) setAuth(stored);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const importAuthFile = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const parsed = await pickAndReadCodexAuth();
|
||||||
|
await saveCodexAuth(parsed);
|
||||||
|
setAuth(parsed);
|
||||||
|
setError(null);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : "UNKNOWN_ERROR";
|
||||||
|
if (msg !== "PICKER_CANCELLED") setError(msg);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (auth) void refresh();
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
|
const clearAuth = useCallback(async () => {
|
||||||
|
await clearCodexAuth();
|
||||||
|
setAuth(null);
|
||||||
|
setUsage(null);
|
||||||
|
setStatus("idle");
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { auth, usage, status, error, importAuthFile, refresh, clearAuth };
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { ClaudeOrg, ClaudeUsageResponse } from "@/types/claude";
|
||||||
|
|
||||||
|
const BASE_URL = "https://claude.ai/api";
|
||||||
|
|
||||||
|
function cookieHeader(sessionKey: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Cookie: `sessionKey=${sessionKey}`,
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Origin": "https://claude.ai",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClaudeOrgs(sessionKey: string): Promise<ClaudeOrg[]> {
|
||||||
|
const response = await fetch(`${BASE_URL}/organizations`, {
|
||||||
|
headers: cookieHeader(sessionKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new Error("TOKEN_EXPIRED");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ClaudeOrg[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClaudeUsage(
|
||||||
|
sessionKey: string,
|
||||||
|
orgUuid: string
|
||||||
|
): Promise<ClaudeUsageResponse> {
|
||||||
|
const response = await fetch(`${BASE_URL}/organizations/${orgUuid}/usage`, {
|
||||||
|
headers: cookieHeader(sessionKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new Error("TOKEN_EXPIRED");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ClaudeUsageResponse>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { CodexAuth, CodexUsageResponse } from "@/types/codex";
|
||||||
|
|
||||||
|
const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||||
|
|
||||||
|
export async function fetchCodexUsage(auth: CodexAuth): Promise<CodexUsageResponse> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `Bearer ${auth.accessToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (auth.accountId) {
|
||||||
|
headers["ChatGPT-Account-Id"] = auth.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(USAGE_URL, { headers });
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new Error("TOKEN_EXPIRED");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<CodexUsageResponse>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as DocumentPicker from "expo-document-picker";
|
||||||
|
import { File } from "expo-file-system/next";
|
||||||
|
import type { CodexAuth } from "@/types/codex";
|
||||||
|
|
||||||
|
export async function pickAndReadCodexAuth(): Promise<CodexAuth> {
|
||||||
|
const result = await DocumentPicker.getDocumentAsync({
|
||||||
|
type: "application/json",
|
||||||
|
copyToCacheDirectory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled) {
|
||||||
|
throw new Error("PICKER_CANCELLED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { uri } = result.assets[0];
|
||||||
|
const file = new File(uri);
|
||||||
|
const text = file.text();
|
||||||
|
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error("INVALID_JSON");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = parsed["accessToken"] as string | undefined;
|
||||||
|
const accountId = parsed["accountId"] as string | undefined;
|
||||||
|
|
||||||
|
if (!accessToken || typeof accessToken !== "string") {
|
||||||
|
throw new Error("MISSING_ACCESS_TOKEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accessToken, accountId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
|
||||||
|
const KEYS = {
|
||||||
|
CODEX_AUTH: "codexbar_codex_auth",
|
||||||
|
CLAUDE_SESSION_KEY: "codexbar_claude_session_key",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function saveCodexAuth(auth: {
|
||||||
|
accessToken: string;
|
||||||
|
accountId?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(KEYS.CODEX_AUTH, JSON.stringify(auth));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCodexAuth(): Promise<{
|
||||||
|
accessToken: string;
|
||||||
|
accountId?: string;
|
||||||
|
} | null> {
|
||||||
|
const raw = await SecureStore.getItemAsync(KEYS.CODEX_AUTH);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw) as { accessToken: string; accountId?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveClaudeSessionKey(key: string): Promise<void> {
|
||||||
|
await SecureStore.setItemAsync(KEYS.CLAUDE_SESSION_KEY, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadClaudeSessionKey(): Promise<string | null> {
|
||||||
|
return SecureStore.getItemAsync(KEYS.CLAUDE_SESSION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCodexAuth(): Promise<void> {
|
||||||
|
await SecureStore.deleteItemAsync(KEYS.CODEX_AUTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearClaudeSessionKey(): Promise<void> {
|
||||||
|
await SecureStore.deleteItemAsync(KEYS.CLAUDE_SESSION_KEY);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export function formatResetCountdown(resetAtSeconds: number): string {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const diffSeconds = resetAtSeconds - nowSeconds;
|
||||||
|
|
||||||
|
if (diffSeconds <= 0) return "resetting now";
|
||||||
|
if (diffSeconds < 60) return "resets in <1m";
|
||||||
|
|
||||||
|
const hours = Math.floor(diffSeconds / 3600);
|
||||||
|
const minutes = Math.floor((diffSeconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (hours > 0 && minutes > 0) return `resets in ${hours}h ${minutes}m`;
|
||||||
|
if (hours > 0) return `resets in ${hours}h`;
|
||||||
|
return `resets in ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatResetCountdownISO(isoString: string): string {
|
||||||
|
const resetAtSeconds = Math.floor(new Date(isoString).getTime() / 1000);
|
||||||
|
return formatResetCountdown(resetAtSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function windowLabel(limitWindowSeconds: number): string {
|
||||||
|
if (limitWindowSeconds <= 3600) return "1h window";
|
||||||
|
if (limitWindowSeconds <= 86400) return "24h window";
|
||||||
|
return `${Math.round(limitWindowSeconds / 3600)}h window`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const { getDefaultConfig } = require("expo/metro-config");
|
||||||
|
const { withNativeWind } = require("nativewind/metro");
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
module.exports = withNativeWind(config, { input: "./global.css" });
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="nativewind/types" />
|
||||||
|
|
||||||
|
declare module "*.css" {
|
||||||
|
const content: unknown;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
Generated
+2599
-135
File diff suppressed because it is too large
Load Diff
+14
-2
@@ -1,15 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "codexbar.mobile",
|
"name": "codexbar.mobile",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.ts",
|
"main": "expo-router/entry",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.1.1",
|
||||||
|
"babel-preset-expo": "~56.0.0",
|
||||||
"expo": "~56.0.12",
|
"expo": "~56.0.12",
|
||||||
|
"expo-constants": "~56.0.18",
|
||||||
|
"expo-document-picker": "~56.0.4",
|
||||||
|
"expo-file-system": "~56.0.8",
|
||||||
|
"expo-linking": "~56.0.14",
|
||||||
|
"expo-router": "~56.2.11",
|
||||||
|
"expo-secure-store": "~56.0.4",
|
||||||
"expo-status-bar": "~56.0.4",
|
"expo-status-bar": "~56.0.4",
|
||||||
|
"nativewind": "^4.2.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-native": "0.85.3"
|
"react-native": "0.85.3",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "4.25.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./components/**/*.{js,jsx,ts,tsx}",
|
||||||
|
"./hooks/**/*.{js,jsx,ts,tsx}",
|
||||||
|
],
|
||||||
|
presets: [require("nativewind/preset")],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
codex: "#10a37f",
|
||||||
|
claude: "#d97706",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
+10
-1
@@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"extends": "expo/tsconfig.base",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.d.ts",
|
||||||
|
"nativewind-env.d.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface ClaudeOrg {
|
||||||
|
uuid: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaudeWindowUsage {
|
||||||
|
utilization: number;
|
||||||
|
resets_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaudeUsageResponse {
|
||||||
|
five_hour: ClaudeWindowUsage;
|
||||||
|
seven_day: ClaudeWindowUsage;
|
||||||
|
seven_day_sonnet?: ClaudeWindowUsage;
|
||||||
|
seven_day_opus?: ClaudeWindowUsage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export interface CodexAuth {
|
||||||
|
accessToken: string;
|
||||||
|
accountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageWindow {
|
||||||
|
used_percent: number;
|
||||||
|
reset_at: number;
|
||||||
|
limit_window_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodexUsageResponse {
|
||||||
|
plan_type: string;
|
||||||
|
rate_limit: {
|
||||||
|
primary_window: UsageWindow;
|
||||||
|
secondary_window: UsageWindow;
|
||||||
|
};
|
||||||
|
credits: {
|
||||||
|
has_credits: boolean;
|
||||||
|
unlimited: boolean;
|
||||||
|
balance: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user