diff --git a/bun.lock b/bun.lock index d47bf6b81..62c850467 100644 --- a/bun.lock +++ b/bun.lock @@ -59,6 +59,7 @@ "@emotion/styled": "^11.14.1", "@junhoyeo/blackhole": "^1.0.1", "@neondatabase/serverless": "^0.10.4", + "@primer/octicons-react": "^19.21.1", "@primer/primitives": "^11.3.1", "@primer/react": "^38.3.0", "date-fns": "^4.1.0", diff --git a/packages/frontend/.env.example b/packages/frontend/.env.example index 1fd03b44b..505a7badb 100644 --- a/packages/frontend/.env.example +++ b/packages/frontend/.env.example @@ -1,12 +1,22 @@ -# Database (PostgreSQL - Railway, Neon, Vercel Postgres, etc.) -DATABASE_URL=postgres://user:password@host:5432/database +# Database (PostgreSQL) +# For local development, you can use a local PostgreSQL instance or a free cloud provider: +# - Local: postgresql://user:password@localhost:5432/tokscale +# - Railway: postgresql://...@...railway.app:5432/railway +# - Neon: postgresql://...@...neon.tech/tokscale +# - Vercel Postgres: postgres://...@...vercel-storage.com/verceldb +DATABASE_URL=postgresql://user:password@localhost:5432/tokscale -# GitHub OAuth -# Create an OAuth App at: https://github.com/settings/developers -# Authorization callback URL: https://your-domain.com/api/auth/github/callback -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= +# GitHub OAuth (for authentication) +# Create a GitHub OAuth App at: https://github.com/settings/developers +# - Homepage URL: http://localhost:3000 +# - Authorization callback URL: http://localhost:3000/api/auth/github/callback +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret -# Application URL (no trailing slash) -# Used for OAuth redirects and generating absolute URLs +# Public URL (used for OAuth redirects) +# For local development: http://localhost:3000 +# For production: https://your-domain.com NEXT_PUBLIC_URL=http://localhost:3000 + +# Optional: Session secret (auto-generated if not set) +# AUTH_SECRET=your_random_secret_string diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 147aafb3e..702fc0170 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -16,6 +16,7 @@ "@emotion/styled": "^11.14.1", "@junhoyeo/blackhole": "^1.0.1", "@neondatabase/serverless": "^0.10.4", + "@primer/octicons-react": "^19.21.1", "@primer/primitives": "^11.3.1", "@primer/react": "^38.3.0", "date-fns": "^4.1.0", diff --git a/packages/frontend/src/app/(main)/page.tsx b/packages/frontend/src/app/(main)/page.tsx index 209e64bed..97c06b71e 100644 --- a/packages/frontend/src/app/(main)/page.tsx +++ b/packages/frontend/src/app/(main)/page.tsx @@ -62,62 +62,62 @@ export default function LeaderboardPage() { }, [period, page]); return ( -
+
-

+

Leaderboard

-

+

See who's using the most AI tokens

-

+

Total Tokens

-

+

{data ? formatNumber(data.stats.totalTokens) : "-"}

-

+

Total Cost

-

+

{data ? formatCurrency(data.stats.totalCost) : "-"}

-

+

Users

-

+

{data ? data.stats.uniqueUsers : "-"}

-

+

Submissions

-

+

{data ? data.stats.totalSubmissions : "-"}

@@ -144,18 +144,18 @@ export default function LeaderboardPage() { ) : (
{!data || data.users.length === 0 ? (
-

+

No submissions yet. Be the first!

-

+

Run{" "} tokscale login && tokscale submit @@ -167,36 +167,36 @@ export default function LeaderboardPage() { @@ -208,7 +208,7 @@ export default function LeaderboardPage() { key={user.userId} className="transition-colors hover:opacity-80" style={{ - borderBottom: index < data.users.length - 1 ? "1px solid #262627" : "none", + borderBottom: index < data.users.length - 1 ? "1px solid var(--color-border-default)" : "none", }} > ))} @@ -282,9 +282,9 @@ export default function LeaderboardPage() { {data.pagination.totalPages > 1 && (
-

+

Showing {(data.pagination.page - 1) * data.pagination.limit + 1}- {Math.min(data.pagination.page * data.pagination.limit, data.pagination.totalUsers)} of{" "} {data.pagination.totalUsers} @@ -304,20 +304,20 @@ export default function LeaderboardPage() {

-

+

Join the Leaderboard

-

+

Install Tokscale CLI and submit your usage data:

-
- $ npx tokscale login +
+ $ npx tokscale login
-
- $ npx tokscale submit +
+ $ npx tokscale submit
diff --git a/packages/frontend/src/app/device/DeviceClient.tsx b/packages/frontend/src/app/device/DeviceClient.tsx new file mode 100644 index 000000000..b98cca4e0 --- /dev/null +++ b/packages/frontend/src/app/device/DeviceClient.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface User { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; +} + +export default function DeviceClient() { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [code, setCode] = useState(""); + const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); + const [error, setError] = useState(""); + + useEffect(() => { + fetch("/api/auth/session") + .then((res) => res.json()) + .then((data) => { + setUser(data.user); + setIsLoading(false); + }) + .catch(() => { + setIsLoading(false); + }); + }, []); + + const handleCodeChange = (e: React.ChangeEvent) => { + let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""); + + if (value.length > 4) { + value = value.slice(0, 4) + "-" + value.slice(4, 8); + } + + setCode(value); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setStatus("loading"); + setError(""); + + try { + const response = await fetch("/api/auth/device/authorize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userCode: code }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Invalid code"); + } + + setStatus("success"); + } catch (err) { + setStatus("error"); + setError(err instanceof Error ? err.message : "Something went wrong"); + } + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+
+
+
+ + + +
+

+ Authorize CLI +

+

+ Connect your terminal to Token Tracker +

+
+ + {!user ? ( +
+

+ Sign in with GitHub to authorize the CLI. +

+ + + + + Sign in with GitHub + +
+ ) : status === "success" ? ( +
+
+ + + +
+

+ Device Authorized! +

+

+ You can close this window and return to your terminal. +

+
+ ) : ( +
+
+

+ Enter the code shown in your terminal: +

+ +
+ + {error && ( +

{error}

+ )} + + + +

+ Signed in as{" "} + + {user.username} + +

+ + )} +
+
+
+ ); +} diff --git a/packages/frontend/src/app/device/page.tsx b/packages/frontend/src/app/device/page.tsx index bf4889be1..9918bbf67 100644 --- a/packages/frontend/src/app/device/page.tsx +++ b/packages/frontend/src/app/device/page.tsx @@ -1,203 +1,11 @@ -"use client"; +import type { Metadata } from 'next'; +import DeviceClient from './DeviceClient'; -import { useState, useEffect } from "react"; - -interface User { - id: string; - username: string; - displayName: string | null; - avatarUrl: string | null; -} +export const metadata: Metadata = { + title: 'Device Authorization - Token Usage', + description: 'Authorize your device to sync token usage data', +}; export default function DevicePage() { - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [code, setCode] = useState(""); - const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); - const [error, setError] = useState(""); - - useEffect(() => { - fetch("/api/auth/session") - .then((res) => res.json()) - .then((data) => { - setUser(data.user); - setIsLoading(false); - }) - .catch(() => { - setIsLoading(false); - }); - }, []); - - const handleCodeChange = (e: React.ChangeEvent) => { - let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ""); - - if (value.length > 4) { - value = value.slice(0, 4) + "-" + value.slice(4, 8); - } - - setCode(value); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setStatus("loading"); - setError(""); - - try { - const response = await fetch("/api/auth/device/authorize", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userCode: code }), - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || "Invalid code"); - } - - setStatus("success"); - } catch (err) { - setStatus("error"); - setError(err instanceof Error ? err.message : "Something went wrong"); - } - }; - - if (isLoading) { - return ( -
-
Loading...
-
- ); - } - - return ( -
-
-
-
-
- - - -
-

- Authorize CLI -

-

- Connect your terminal to Token Tracker -

-
- - {!user ? ( -
-

- Sign in with GitHub to authorize the CLI. -

- - - - - Sign in with GitHub - -
- ) : status === "success" ? ( -
-
- - - -
-

- Device Authorized! -

-

- You can close this window and return to your terminal. -

-
- ) : ( -
-
-

- Enter the code shown in your terminal: -

- -
- - {error && ( -

{error}

- )} - - - -

- Signed in as{" "} - - {user.username} - -

- - )} -
-
-
- ); + return ; } diff --git a/packages/frontend/src/app/globals.css b/packages/frontend/src/app/globals.css index d6835c68d..bb031c9ae 100644 --- a/packages/frontend/src/app/globals.css +++ b/packages/frontend/src/app/globals.css @@ -57,8 +57,8 @@ } body { - background: #141415; - color: #FFFFFF; + background: var(--background); + color: var(--foreground); font-family: var(--font-figtree), system-ui, -apple-system, sans-serif; font-feature-settings: "cv02", "cv03", "cv04", "cv11"; -webkit-font-smoothing: antialiased; @@ -78,10 +78,10 @@ a:hover { text-decoration: none; } -.bg-canvas { background-color: #141415; } -.bg-canvas-subtle { background-color: #1F1F20; } -.bg-card { background-color: #1F1F20; } -.text-fg { color: #FFFFFF; } -.text-fg-muted { color: #696969; } -.border-default { border-color: #262627; } -.border-subtle { border-color: rgba(255, 255, 255, 0.1); } +.bg-canvas { background-color: var(--color-canvas-default); } +.bg-canvas-subtle { background-color: var(--color-canvas-subtle); } +.bg-card { background-color: var(--color-card-bg); } +.text-fg { color: var(--color-fg-default); } +.text-fg-muted { color: var(--color-fg-muted); } +.border-default { border-color: var(--color-border-default); } +.border-subtle { border-color: var(--color-border-subtle); } diff --git a/packages/frontend/src/app/local/LocalClient.tsx b/packages/frontend/src/app/local/LocalClient.tsx new file mode 100644 index 000000000..9565d9be8 --- /dev/null +++ b/packages/frontend/src/app/local/LocalClient.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import type { TokenContributionData } from "@/lib/types"; +import { DataInput } from "@/components/DataInput"; +import { GraphContainer } from "@/components/GraphContainer"; +import { Navigation } from "@/components/layout/Navigation"; +import { Footer } from "@/components/layout/Footer"; + +export default function LocalClient() { + const [data, setData] = useState(null); + + return ( +
+ + +
+
+

+ Local Viewer +

+

+ View your token usage data locally without submitting +

+
+ + {!data ? ( + + ) : ( +
+
+
+ Data loaded: + + {data.meta.dateRange.start} - {data.meta.dateRange.end} + + | + + ${data.summary.totalCost.toFixed(2)} total + + | + + {data.summary.activeDays} active days + + +
+
+ +
+ )} +
+ +
+
+ ); +} diff --git a/packages/frontend/src/app/local/page.tsx b/packages/frontend/src/app/local/page.tsx index 48ce9f405..30a7a0016 100644 --- a/packages/frontend/src/app/local/page.tsx +++ b/packages/frontend/src/app/local/page.tsx @@ -1,65 +1,11 @@ -"use client"; +import type { Metadata } from 'next'; +import LocalClient from './LocalClient'; -import { useState } from "react"; -import type { TokenContributionData } from "@/lib/types"; -import { DataInput } from "@/components/DataInput"; -import { GraphContainer } from "@/components/GraphContainer"; -import { Navigation } from "@/components/layout/Navigation"; -import { Footer } from "@/components/layout/Footer"; +export const metadata: Metadata = { + title: 'Local Data - Token Usage', + description: 'View your local AI token usage data', +}; export default function LocalViewerPage() { - const [data, setData] = useState(null); - - return ( -
- - -
-
-

- Local Viewer -

-

- View your token usage data locally without submitting -

-
- - {!data ? ( - - ) : ( -
-
-
- Data loaded: - - {data.meta.dateRange.start} - {data.meta.dateRange.end} - - | - - ${data.summary.totalCost.toFixed(2)} total - - | - - {data.summary.activeDays} active days - - -
-
- -
- )} -
- -
-
- ); + return ; } diff --git a/packages/frontend/src/app/settings/SettingsClient.tsx b/packages/frontend/src/app/settings/SettingsClient.tsx new file mode 100644 index 000000000..96d3f48b6 --- /dev/null +++ b/packages/frontend/src/app/settings/SettingsClient.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { Avatar, Button, Flash } from "@primer/react"; +import { KeyIcon } from "@primer/octicons-react"; +import { Navigation } from "@/components/layout/Navigation"; +import { Footer } from "@/components/layout/Footer"; + +interface User { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + email: string | null; +} + +interface ApiToken { + id: string; + name: string; + createdAt: string; + lastUsedAt: string | null; +} + +export default function SettingsClient() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [tokens, setTokens] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetch("/api/auth/session") + .then((res) => res.json()) + .then((data) => { + if (!data.user) { + router.push("/api/auth/github?returnTo=/settings"); + return; + } + setUser(data.user); + setIsLoading(false); + }) + .catch(() => { + router.push("/"); + }); + + fetch("/api/settings/tokens") + .then((res) => res.json()) + .then((data) => { + if (data.tokens) { + setTokens(data.tokens); + } + }) + .catch(() => {}); + }, [router]); + + const handleRevokeToken = async (tokenId: string) => { + if (!confirm("Are you sure you want to revoke this token?")) return; + + try { + const response = await fetch(`/api/settings/tokens/${tokenId}`, { + method: "DELETE", + }); + + if (response.ok) { + setTokens(tokens.filter((t) => t.id !== tokenId)); + } + } catch { + alert("Failed to revoke token"); + } + }; + + if (isLoading) { + return ( +
+ +
+
Loading...
+
+
+
+ ); + } + + if (!user) { + return null; + } + + return ( +
+ + +
+

+ Settings +

+ +
+

+ Profile +

+
+ +
+

+ {user.displayName || user.username} +

+

+ @{user.username} +

+ {user.email && ( +

+ {user.email} +

+ )} +
+
+ + Profile information is synced from GitHub and cannot be edited here. + +
+ +
+

+ API Tokens +

+

+ Tokens are created when you run{" "} + + tokscale login + {" "} + from the CLI. +

+ + {tokens.length === 0 ? ( +
+ +

No API tokens yet.

+

+ Run{" "} + + tokscale login + {" "} + to create one. +

+
+ ) : ( +
+ {tokens.map((token) => ( +
+
+ +
+

+ {token.name} +

+

+ Created {new Date(token.createdAt).toLocaleDateString()} + {token.lastUsedAt && ( + <> - Last used {new Date(token.lastUsedAt).toLocaleDateString()} + )} +

+
+
+ +
+ ))} +
+ )} +
+ + +
+ +
+
+ ); +} diff --git a/packages/frontend/src/app/settings/page.tsx b/packages/frontend/src/app/settings/page.tsx index 827f87550..5e8dbfa52 100644 --- a/packages/frontend/src/app/settings/page.tsx +++ b/packages/frontend/src/app/settings/page.tsx @@ -1,229 +1,11 @@ -"use client"; +import type { Metadata } from 'next'; +import SettingsClient from './SettingsClient'; -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { Avatar, Button, Flash } from "@primer/react"; -import { TrashIcon, KeyIcon } from "@primer/octicons-react"; -import { Navigation } from "@/components/layout/Navigation"; -import { Footer } from "@/components/layout/Footer"; - -interface User { - id: string; - username: string; - displayName: string | null; - avatarUrl: string | null; - email: string | null; -} - -interface ApiToken { - id: string; - name: string; - createdAt: string; - lastUsedAt: string | null; -} +export const metadata: Metadata = { + title: 'Settings - Token Usage', + description: 'Manage your account settings and API tokens', +}; export default function SettingsPage() { - const router = useRouter(); - const [user, setUser] = useState(null); - const [tokens, setTokens] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetch("/api/auth/session") - .then((res) => res.json()) - .then((data) => { - if (!data.user) { - router.push("/api/auth/github?returnTo=/settings"); - return; - } - setUser(data.user); - setIsLoading(false); - }) - .catch(() => { - router.push("/"); - }); - - fetch("/api/settings/tokens") - .then((res) => res.json()) - .then((data) => { - if (data.tokens) { - setTokens(data.tokens); - } - }) - .catch(() => {}); - }, [router]); - - const handleRevokeToken = async (tokenId: string) => { - if (!confirm("Are you sure you want to revoke this token?")) return; - - try { - const response = await fetch(`/api/settings/tokens/${tokenId}`, { - method: "DELETE", - }); - - if (response.ok) { - setTokens(tokens.filter((t) => t.id !== tokenId)); - } - } catch { - alert("Failed to revoke token"); - } - }; - - if (isLoading) { - return ( -
- -
-
Loading...
-
-
-
- ); - } - - if (!user) { - return null; - } - - return ( -
- - -
-

- Settings -

- -
-

- Profile -

-
- -
-

- {user.displayName || user.username} -

-

- @{user.username} -

- {user.email && ( -

- {user.email} -

- )} -
-
- - Profile information is synced from GitHub and cannot be edited here. - -
- -
-

- API Tokens -

-

- Tokens are created when you run{" "} - - tokscale login - {" "} - from the CLI. -

- - {tokens.length === 0 ? ( -
- -

No API tokens yet.

-

- Run{" "} - - tokscale login - {" "} - to create one. -

-
- ) : ( -
- {tokens.map((token) => ( -
-
- -
-

- {token.name} -

-

- Created {new Date(token.createdAt).toLocaleDateString()} - {token.lastUsedAt && ( - <> - Last used {new Date(token.lastUsedAt).toLocaleDateString()} - )} -

-
-
- -
- ))} -
- )} -
- -
-

- Danger Zone -

-

- Deleting your account will remove all your submissions and cannot be undone. -

- -
-
- -
-
- ); + return ; } diff --git a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx new file mode 100644 index 000000000..3b924a7d5 --- /dev/null +++ b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { Navigation } from "@/components/layout/Navigation"; +import { Footer } from "@/components/layout/Footer"; +import { ProfileSkeleton } from "@/components/Skeleton"; +import { + ProfileHeader, + ProfileTabBar, + TokenBreakdown, + ProfileModels, + ProfileActivity, + ProfileEmptyActivity, + ProfileStats, + type ProfileUser, + type ProfileStatsData, + type ProfileTab, + type ModelUsage, +} from "@/components/profile"; +import type { TokenContributionData, DailyContribution, SourceType } from "@/lib/types"; +import { calculateCurrentStreak, calculateLongestStreak } from "@/lib/utils"; + +interface ProfileData { + user: { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + createdAt: string; + rank: number | null; + }; + stats: { + totalTokens: number; + totalCost: number; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + submissionCount: number; + activeDays: number; + }; + dateRange: { + start: string | null; + end: string | null; + }; + sources: string[]; + models: string[]; + modelUsage?: ModelUsage[]; + contributions: DailyContribution[]; +} + +export default function ProfilePageClient() { + const params = useParams(); + const username = params.username as string; + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("activity"); + + useEffect(() => { + fetch(`/api/users/${username}`) + .then((res) => { + if (!res.ok) throw new Error("User not found"); + return res.json(); + }) + .then((result) => { + setData(result); + setIsLoading(false); + }) + .catch((err) => { + setError(err.message); + setIsLoading(false); + }); + }, [username]); + + const graphData: TokenContributionData | null = useMemo(() => { + if (!data || data.contributions.length === 0) return null; + + const contributions = data.contributions; + const totalCost = data.stats.totalCost; + const totalTokens = data.stats.totalTokens; + const maxCost = Math.max(...contributions.map((c) => c.totals.cost), 0); + + const yearMap = new Map(); + for (const day of contributions) { + const year = day.date.split("-")[0]; + const existing = yearMap.get(year); + if (existing) { + existing.totalTokens += day.totals.tokens; + existing.totalCost += day.totals.cost; + if (day.date < existing.start) existing.start = day.date; + if (day.date > existing.end) existing.end = day.date; + } else { + yearMap.set(year, { + totalTokens: day.totals.tokens, + totalCost: day.totals.cost, + start: day.date, + end: day.date, + }); + } + } + + const years = Array.from(yearMap.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([year, stats]) => ({ + year, + totalTokens: stats.totalTokens, + totalCost: stats.totalCost, + range: { start: stats.start, end: stats.end }, + })); + + return { + meta: { + generatedAt: new Date().toISOString(), + version: "1.0.0", + dateRange: { + start: data.dateRange.start || contributions[0]?.date || "", + end: data.dateRange.end || contributions[contributions.length - 1]?.date || "", + }, + }, + summary: { + totalTokens, + totalCost, + totalDays: contributions.length, + activeDays: data.stats.activeDays, + averagePerDay: data.stats.activeDays > 0 ? totalCost / data.stats.activeDays : 0, + maxCostInSingleDay: maxCost, + sources: data.sources as SourceType[], + models: data.models, + }, + years, + contributions: contributions as DailyContribution[], + }; + }, [data]); + + const user: ProfileUser | null = useMemo(() => { + if (!data) return null; + return { + username: data.user.username, + displayName: data.user.displayName, + avatarUrl: data.user.avatarUrl, + rank: data.user.rank, + }; + }, [data]); + + const stats: ProfileStatsData | null = useMemo(() => { + if (!data) return null; + return { + totalTokens: data.stats.totalTokens, + totalCost: data.stats.totalCost, + inputTokens: data.stats.inputTokens, + outputTokens: data.stats.outputTokens, + cacheReadTokens: data.stats.cacheReadTokens, + cacheWriteTokens: data.stats.cacheCreationTokens, + activeDays: data.stats.activeDays, + submissionCount: data.stats.submissionCount, + }; + }, [data]); + + if (isLoading) { + return ( +
+ +
+ +
+
+
+ ); + } + + if (error || !data || !user || !stats) { + return ( +
+ +
+
+

+ User Not Found +

+

+ The user @{username} doesn't exist or hasn't submitted any data yet. +

+ + Back to Leaderboard + +
+
+
+
+ ); + } + + return ( +
+ + +
+
+ + + + + {activeTab === "activity" && ( + graphData ? ( +
+ + m !== "")[0]} + /> +
+ ) : + )} + {activeTab === "breakdown" && } + {activeTab === "models" && } +
+
+ +
+
+ ); +} diff --git a/packages/frontend/src/app/u/[username]/page.tsx b/packages/frontend/src/app/u/[username]/page.tsx index 8e5369dd7..b59e925cc 100644 --- a/packages/frontend/src/app/u/[username]/page.tsx +++ b/packages/frontend/src/app/u/[username]/page.tsx @@ -1,235 +1,23 @@ -"use client"; - -import { useState, useEffect, useMemo } from "react"; -import { useParams } from "next/navigation"; -import Link from "next/link"; -import { Navigation } from "@/components/layout/Navigation"; -import { Footer } from "@/components/layout/Footer"; -import { ProfileSkeleton } from "@/components/Skeleton"; -import { - ProfileHeader, - ProfileTabBar, - TokenBreakdown, - ProfileModels, - ProfileActivity, - ProfileEmptyActivity, - ProfileStats, - type ProfileUser, - type ProfileStatsData, - type ProfileTab, - type ModelUsage, -} from "@/components/profile"; -import type { TokenContributionData, DailyContribution, SourceType } from "@/lib/types"; -import { calculateCurrentStreak, calculateLongestStreak } from "@/lib/utils"; - -interface ProfileData { - user: { - id: string; - username: string; - displayName: string | null; - avatarUrl: string | null; - createdAt: string; - rank: number | null; - }; - stats: { - totalTokens: number; - totalCost: number; - inputTokens: number; - outputTokens: number; - cacheReadTokens: number; - cacheCreationTokens: number; - submissionCount: number; - activeDays: number; +import type { Metadata } from 'next'; +import ProfilePageClient from './ProfilePageClient'; + +export async function generateMetadata({ params }: { params: Promise<{ username: string }> }): Promise { + const { username } = await params; + return { + title: `@${username} - Token Usage`, + description: `View ${username}'s AI token usage statistics and cost breakdown`, + openGraph: { + title: `@${username}'s Token Usage`, + description: `AI token usage statistics for ${username}`, + type: 'profile', + }, + twitter: { + card: 'summary', + title: `@${username}'s Token Usage`, + }, }; - dateRange: { - start: string | null; - end: string | null; - }; - sources: string[]; - models: string[]; - modelUsage?: ModelUsage[]; - contributions: DailyContribution[]; } export default function ProfilePage() { - const params = useParams(); - const username = params.username as string; - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState("activity"); - - useEffect(() => { - fetch(`/api/users/${username}`) - .then((res) => { - if (!res.ok) throw new Error("User not found"); - return res.json(); - }) - .then((result) => { - setData(result); - setIsLoading(false); - }) - .catch((err) => { - setError(err.message); - setIsLoading(false); - }); - }, [username]); - - const graphData: TokenContributionData | null = useMemo(() => { - if (!data || data.contributions.length === 0) return null; - - const contributions = data.contributions; - const totalCost = data.stats.totalCost; - const totalTokens = data.stats.totalTokens; - const maxCost = Math.max(...contributions.map((c) => c.totals.cost), 0); - - const yearMap = new Map(); - for (const day of contributions) { - const year = day.date.split("-")[0]; - const existing = yearMap.get(year); - if (existing) { - existing.totalTokens += day.totals.tokens; - existing.totalCost += day.totals.cost; - if (day.date < existing.start) existing.start = day.date; - if (day.date > existing.end) existing.end = day.date; - } else { - yearMap.set(year, { - totalTokens: day.totals.tokens, - totalCost: day.totals.cost, - start: day.date, - end: day.date, - }); - } - } - - const years = Array.from(yearMap.entries()) - .sort((a, b) => a[0].localeCompare(b[0])) - .map(([year, stats]) => ({ - year, - totalTokens: stats.totalTokens, - totalCost: stats.totalCost, - range: { start: stats.start, end: stats.end }, - })); - - return { - meta: { - generatedAt: new Date().toISOString(), - version: "1.0.0", - dateRange: { - start: data.dateRange.start || contributions[0]?.date || "", - end: data.dateRange.end || contributions[contributions.length - 1]?.date || "", - }, - }, - summary: { - totalTokens, - totalCost, - totalDays: contributions.length, - activeDays: data.stats.activeDays, - averagePerDay: data.stats.activeDays > 0 ? totalCost / data.stats.activeDays : 0, - maxCostInSingleDay: maxCost, - sources: data.sources as SourceType[], - models: data.models, - }, - years, - contributions: contributions as DailyContribution[], - }; - }, [data]); - - const user: ProfileUser | null = useMemo(() => { - if (!data) return null; - return { - username: data.user.username, - displayName: data.user.displayName, - avatarUrl: data.user.avatarUrl, - rank: data.user.rank, - }; - }, [data]); - - const stats: ProfileStatsData | null = useMemo(() => { - if (!data) return null; - return { - totalTokens: data.stats.totalTokens, - totalCost: data.stats.totalCost, - inputTokens: data.stats.inputTokens, - outputTokens: data.stats.outputTokens, - cacheReadTokens: data.stats.cacheReadTokens, - cacheWriteTokens: data.stats.cacheCreationTokens, - activeDays: data.stats.activeDays, - submissionCount: data.stats.submissionCount, - }; - }, [data]); - - if (isLoading) { - return ( -
- -
- -
-
-
- ); - } - - if (error || !data || !user || !stats) { - return ( -
- -
-
-

- User Not Found -

-

- The user @{username} doesn't exist or hasn't submitted any data yet. -

- - Back to Leaderboard - -
-
-
-
- ); - } - - return ( -
- - -
-
- - - - - {activeTab === "activity" && ( - graphData ? ( -
- - m !== "")[0]} - /> -
- ) : - )} - {activeTab === "breakdown" && } - {activeTab === "models" && } -
-
- -
-
- ); + return ; } diff --git a/packages/frontend/src/components/BreakdownPanel.tsx b/packages/frontend/src/components/BreakdownPanel.tsx index 9dfdfa834..14186ca59 100644 --- a/packages/frontend/src/components/BreakdownPanel.tsx +++ b/packages/frontend/src/components/BreakdownPanel.tsx @@ -20,19 +20,22 @@ export function BreakdownPanel({ day, onClose, palette }: BreakdownPanelProps) { return (
-
-

+
+

{formatDateFull(day.date)} - Detailed Breakdown

@@ -135,7 +135,7 @@ export function DataInput({ onDataLoaded }: DataInputProps) { onClick={loadSampleData} disabled={isLoading} className="px-6 py-3 rounded-full font-semibold text-sm disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:shadow-md hover:-translate-y-0.5 active:translate-y-0" - style={{ backgroundColor: "#262627", color: "#FFFFFF" }} + style={{ backgroundColor: "var(--color-bg-subtle)", color: "var(--color-fg-default)" }} > {isLoading ? ( @@ -169,17 +169,17 @@ export function DataInput({ onDataLoaded }: DataInputProps) {
-

+

How to get your data

-
    +
    1. Install token-tracker:{" "} npx tsx src/cli.ts graph @@ -188,7 +188,7 @@ export function DataInput({ onDataLoaded }: DataInputProps) { Run the graph command:{" "} tokscale graph diff --git a/packages/frontend/src/components/GraphContainer.tsx b/packages/frontend/src/components/GraphContainer.tsx index 5d5c2ca40..ee96437c7 100644 --- a/packages/frontend/src/components/GraphContainer.tsx +++ b/packages/frontend/src/components/GraphContainer.tsx @@ -86,7 +86,7 @@ export function GraphContainer({ data }: GraphContainerProps) {
      -
      +
      -

      +

      {totalContributions.toLocaleString()} {" "}token usage entries {selectedYear && ( @@ -97,11 +102,12 @@ export function GraphControls({ ) : ( @@ -115,19 +121,21 @@ export function GraphControls({
      {availableSources.length > 1 && ( -
      - Filter: +
      + Filter: {availableSources.map((source) => { const isSelected = sourceFilter.length === 0 || sourceFilter.includes(source); return ( @@ -146,8 +155,9 @@ export function GraphControls({ {sourceFilter.length === availableSources.length && ( @@ -156,7 +166,7 @@ export function GraphControls({ )}
      - Less + Less {[0, 1, 2, 3, 4].map((level) => (
      ))} - More + More
      diff --git a/packages/frontend/src/components/Skeleton.tsx b/packages/frontend/src/components/Skeleton.tsx index 21dc9d1ff..49f2f4407 100644 --- a/packages/frontend/src/components/Skeleton.tsx +++ b/packages/frontend/src/components/Skeleton.tsx @@ -8,7 +8,7 @@ export function Skeleton({ className = "" }: SkeletonProps) { return (
      ); } @@ -21,7 +21,7 @@ export function LeaderboardSkeleton() {
      @@ -35,7 +35,7 @@ export function LeaderboardSkeleton() { >
      @@ -48,7 +48,7 @@ export function LeaderboardSkeleton() {
      @@ -92,7 +92,7 @@ export function ProfileSkeleton() {
      diff --git a/packages/frontend/src/components/StatsPanel.tsx b/packages/frontend/src/components/StatsPanel.tsx index 216b6b192..eced8adb0 100644 --- a/packages/frontend/src/components/StatsPanel.tsx +++ b/packages/frontend/src/components/StatsPanel.tsx @@ -22,16 +22,16 @@ export function StatsPanel({ data, palette }: StatsPanelProps) { const bestDay = findBestDay(contributions); return ( -
      -

      - Statistics -

      +
      +

      + Statistics +

      @@ -46,15 +46,15 @@ export function StatsPanel({ data, palette }: StatsPanelProps) {
      -
      - +
      + Sources: {summary.sources.map((source) => ( {source} @@ -75,17 +75,17 @@ interface StatItemProps { function StatItem({ label, value, subValue, highlightColor, highlight }: StatItemProps) { return (
      -
      +
      {label}
      {value}
      {subValue && ( -
      +
      {subValue}
      )} diff --git a/packages/frontend/src/components/TabBar.tsx b/packages/frontend/src/components/TabBar.tsx index ba6084fd7..a6e496e26 100644 --- a/packages/frontend/src/components/TabBar.tsx +++ b/packages/frontend/src/components/TabBar.tsx @@ -16,34 +16,62 @@ export function TabBar({ activeTab, onTabChange, }: TabBarProps) { + const handleKeyDown = (e: React.KeyboardEvent, currentIndex: number) => { + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + const nextIndex = (currentIndex + 1) % tabs.length; + onTabChange(tabs[nextIndex].id); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length; + onTabChange(tabs[prevIndex].id); + } else if (e.key === "Home") { + e.preventDefault(); + onTabChange(tabs[0].id); + } else if (e.key === "End") { + e.preventDefault(); + onTabChange(tabs[tabs.length - 1].id); + } + }; + return (
      - {tabs.map((tab) => ( - - ))} + + {tab.label} + + + ); + })}
      ); } diff --git a/packages/frontend/src/components/ThemeToggle.tsx b/packages/frontend/src/components/ThemeToggle.tsx deleted file mode 100644 index db0626bb0..000000000 --- a/packages/frontend/src/components/ThemeToggle.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { Monitor, Moon, Sun } from "lucide-react"; -import type { ThemePreference } from "@/lib/useSettings"; - -const SIZES = { - buttonSize: 28, - containerPadding: 3, - iconSize: 14, -} as const; - -const CONTAINER_WIDTH = SIZES.buttonSize * 3 + SIZES.containerPadding * 2; -const CONTAINER_HEIGHT = SIZES.buttonSize + SIZES.containerPadding * 2; - -const themes = [ - { value: "light" as const, label: "Light theme", icon: Sun }, - { value: "dark" as const, label: "Dark theme", icon: Moon }, - { value: "system" as const, label: "System theme", icon: Monitor }, -] as const; - -interface ThemeToggleProps { - theme: ThemePreference; - onThemeChange: (theme: ThemePreference) => void; - mounted: boolean; -} - -export function ThemeToggle({ theme, onThemeChange, mounted }: ThemeToggleProps) { - const activeIndex = themes.findIndex((t) => t.value === theme); - const indicatorX = SIZES.containerPadding + activeIndex * SIZES.buttonSize; - - if (!mounted) { - return ( -

Rank User Tokens Cost Submissions @@ -222,7 +222,7 @@ export default function LeaderboardPage() { ? "#9CA3AF" : user.rank === 3 ? "#D97706" - : "#696969", + : "var(--color-fg-muted)", }} > #{user.rank} @@ -241,13 +241,13 @@ export default function LeaderboardPage() {

{user.displayName || user.username}

@{user.username}

@@ -257,7 +257,7 @@ export default function LeaderboardPage() {
{formatNumber(user.totalTokens)} @@ -265,13 +265,13 @@ export default function LeaderboardPage() { {formatCurrency(user.totalCost)} - {user.submissionCount} + {user.submissionCount}