Upgrade Next.js 14 → 16, React 18 → 19, and dependencies (#2385)

## Summary
- Upgrade Next.js 14.2 → 16.1, React 18 → 19, React DOM 18 → 19
- Upgrade @sentry/nextjs 9 → 10, @auth0/nextjs-auth0 3 → 4, ESLint 8 → 9
- Migrate all async request APIs (cookies, params, searchParams are now
Promises)
- Migrate middleware.ts → proxy.ts (Next.js 16 convention)
- Rewrite ESLint config for flat config format
- New Auth0Client setup with backward-compatible AUTH0_DOMAIN derivation
- Turbopack browser-only resolveAlias for web-tree-sitter Node.js stubs

## Test plan
- [ ] `npm run build` passes
- [ ] `npm run lint` passes (0 errors, warnings only from React Compiler
rules)
- [ ] `npm run type-check` passes
- [ ] `npm run dev` starts successfully with Turbopack
- [ ] Auth login/logout flow works end-to-end
- [ ] Verify `AUTH0_DOMAIN` or `AUTH0_ISSUER_BASE_URL` env var is set in
deployment
This commit is contained in:
Kevin Turcios 2026-04-02 22:38:01 -05:00 committed by GitHub
parent c2feaf91f0
commit 5dca735fc8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 3575 additions and 4368 deletions

View file

@ -1,12 +0,0 @@
node_modules/
dist/
build/
coverage/
*.config.js
.eslintrc.mjs
.eslintrc.json
postcss.config.js
tailwind.config.js
// Comment out the ESLint line temporarily to allow for the build to pass
**/*.ts
**/*.js

View file

@ -1,20 +0,0 @@
module.exports = {
root: true,
extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: __dirname,
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
plugins: ["@typescript-eslint", "react"],
ignorePatterns: ["dist/**", "node_modules/**", "*.config.js", "*.config.mjs", ".eslintrc.js"],
rules: {
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-explicit-any": "warn",
},
}

View file

@ -0,0 +1,44 @@
import nextConfig from "eslint-config-next"
import prettier from "eslint-config-prettier"
// Find the config object that includes the @typescript-eslint plugin
// and add our custom rule there
const eslintConfig = [
...nextConfig.map(config => {
if (config.plugins?.["@typescript-eslint"]) {
return {
...config,
rules: {
...config.rules,
"@typescript-eslint/no-explicit-any": "warn",
},
}
}
return config
}),
prettier,
{
rules: {
"react/react-in-jsx-scope": "off",
// Downgrade React Compiler rules to warnings: pre-existing patterns, fix incrementally
"react-hooks/set-state-in-effect": "warn",
"react-hooks/error-boundaries": "warn",
"react-hooks/immutability": "warn",
"react-hooks/preserve-manual-memoization": "warn",
"react-hooks/purity": "warn",
"react-hooks/refs": "warn",
"react-hooks/static-components": "warn",
},
},
{
ignores: [
"dist/**",
"node_modules/**",
"*.config.js",
"*.config.mjs",
".next/**",
],
},
]
export default eslintConfig

View file

@ -1,3 +1,8 @@
import { dirname } from "path"
import { fileURLToPath } from "url"
const __dirname = dirname(fileURLToPath(import.meta.url))
/** @type {import("next").NextConfig} */
const nextConfig = {
transpilePackages: ["@codeflash-ai/common"],
@ -25,12 +30,22 @@ const nextConfig = {
return config
},
turbopack: {
root: __dirname,
resolveAlias: {
// Stub Node.js built-ins that web-tree-sitter tries to import in the browser.
// Uses { browser: ... } so aliases only apply to client bundles, not SSR.
'fs': { browser: './src/lib/empty-shim.js' },
'fs/promises': { browser: './src/lib/empty-shim.js' },
'path': { browser: './src/lib/empty-shim.js' },
'module': { browser: './src/lib/empty-shim.js' },
},
},
experimental: {
serverActions: {
allowedOrigins: ["app.codeflash.ai", "localhost:3000"],
bodySizeLimit: '5mb', // Increased from default 1mb to handle large PR creation payloads
},
instrumentationHook: true,
},
typescript: {
ignoreBuildErrors: false,

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,8 @@
"build": " npm install --loglevel verbose && npx prisma generate && npx next build",
"deploy": "az webapp up -n codeflash-webapp-2 --sku P1V2 --runtime NODE:20-lts",
"start": "node_modules/next/dist/bin/next start",
"lint": "next lint --fix",
"lint:check": "next lint",
"lint": "eslint --fix .",
"lint:check": "eslint .",
"test": "vitest",
"type-check": "tsc --noEmit",
"prisma:generate": "npx prisma generate",
@ -20,7 +20,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.74.0",
"@auth0/nextjs-auth0": "^3.3.0",
"@auth0/nextjs-auth0": "^4",
"@azure/msal-node": "^3.7.3",
"@codeflash-ai/common": "^1.0.30",
"@hookform/resolvers": "^3.3.2",
@ -37,11 +37,11 @@
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.1.4",
"@sentry/nextjs": "^9.34.0",
"@sentry/nextjs": "^10.38.0",
"@types/node": "^24.3.0",
"@types/pg": "^8.10.9",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"chart.js": "^4.4.9",
"class-variance-authority": "^0.7.0",
@ -51,10 +51,10 @@
"framer-motion": "^12.12.1",
"github-markdown-css": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.381.0",
"lucide-react": "^0.563.0",
"marked": "^16.1.1",
"next": "^14.2.32",
"next-themes": "^0.3.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"node-ts-cache": "^4.4.0",
"node-ts-cache-storage-memory": "^4.4.0",
"pg": "^8.11.3",
@ -62,9 +62,9 @@
"posthog-js": "1.127.0",
"posthog-node": "^4.0.1",
"prism-react-renderer": "^2.4.1",
"react": "^18",
"react": "19.2.4",
"react-chartjs-2": "^5.3.0",
"react-dom": "^18",
"react-dom": "19.2.4",
"react-hook-form": "^7.48.2",
"react-markdown": "^9.0.1",
"react-papaparse": "^4.4.0",
@ -82,18 +82,12 @@
"devDependencies": {
"@testing-library/react": "^16.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.0.1",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^8.57.0",
"eslint-config-next": "15.5.2",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.33.2",
"jsdom": "^24.1.0",
"lint-staged": "^15.4.3",
"prettier": "3.2.5",
@ -117,5 +111,9 @@
"**/*.{json,md}": [
"prettier --write"
]
},
"overrides": {
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3"
}
}

View file

@ -57,8 +57,8 @@ export async function fetchUserInfo(): Promise<{
error?: string
}> {
try {
const { getSession } = await import("@auth0/nextjs-auth0")
const session = await getSession()
const { auth0 } = await import("@/lib/auth0")
const session = await auth0.getSession()
if (!session?.user) {
return { error: "Unauthorized" }

View file

@ -1,5 +1,5 @@
import { redirect } from "next/navigation"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import Link from "next/link"
import { type JSX } from "react"
import { APP_ROUTES } from "@/lib/types"
@ -12,12 +12,13 @@ function isValidReturnUrl(url: string): boolean {
return false
}
export default async function AuthenticationPage({
searchParams,
}: {
searchParams: { returnTo?: string; error?: string }
}): Promise<JSX.Element> {
const session = await getSession()
export default async function AuthenticationPage(
props: {
searchParams: Promise<{ returnTo?: string; error?: string }>
}
): Promise<JSX.Element> {
const searchParams = await props.searchParams;
const session = await auth0.getSession()
if (session) {
// User is already logged in
@ -35,7 +36,7 @@ export default async function AuthenticationPage({
<h2 className="text-2xl font-bold">Login Error</h2>
<p className="mt-2">There was an error during login. Please try again.</p>
<Link
href="/api/auth/login"
href="/auth/login"
className="mt-4 inline-block rounded bg-blue-500 px-4 py-2 text-white"
>
Try Again
@ -52,6 +53,6 @@ export default async function AuthenticationPage({
: APP_ROUTES.BASE
console.log(`[Login Page] Redirecting to Auth0 with returnTo: ${returnTo}`)
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
const loginUrl = `/auth/login?returnTo=${encodeURIComponent(returnTo)}`
redirect(loginUrl)
}

View file

@ -1,6 +1,6 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { markUserCompletedOnboarding, submitOnboardingQuestions } from "@codeflash-ai/common"
import PostHogClient from "@/lib/posthog"
import { redirect } from "next/navigation"
@ -11,7 +11,7 @@ export async function SubmitFirstOnboardingPage(
selectedOptions: string[],
customOptionInput: string,
): Promise<void> {
const session = await getSession()
const session = await auth0.getSession()
if (session == null) {
console.log("No session, redirecting to login")
redirect("/login")
@ -43,11 +43,12 @@ export async function SubmitFirstOnboardingPage(
await submitOnboardingQuestions(user_id, email)
// Check for saved redirect URL after onboarding completion
const returnUrl = cookies().get("returnAfterOnboarding")?.value
const cookieStore = await cookies()
const returnUrl = cookieStore.get("returnAfterOnboarding")?.value
console.log("Checking for saved returnUrl:", returnUrl)
if (returnUrl) {
console.log("Found saved returnUrl, redirecting to:", returnUrl)
cookies().delete("returnAfterOnboarding")
cookieStore.delete("returnAfterOnboarding")
redirect(returnUrl)
} else {
console.log("No saved returnUrl, redirecting to /app/gettingstarted")
@ -56,7 +57,7 @@ export async function SubmitFirstOnboardingPage(
}
export async function SubmitSkipOnboardingPage(): Promise<void> {
const session = await getSession()
const session = await auth0.getSession()
if (session == null) {
console.log("No session, redirecting to login")
redirect("/login")
@ -84,11 +85,12 @@ export async function SubmitSkipOnboardingPage(): Promise<void> {
await markUserCompletedOnboarding(user_id)
// Checking for saved redirect URL after onboarding completion
const returnUrl = cookies().get("returnAfterOnboarding")?.value
const cookieStore = await cookies()
const returnUrl = cookieStore.get("returnAfterOnboarding")?.value
console.log(`Checking for saved returnTo URL: ${returnUrl}`)
if (returnUrl) {
console.log("Found saved returnUrl, redirecting to:", returnUrl)
cookies().delete("returnAfterOnboarding")
cookieStore.delete("returnAfterOnboarding")
redirect(returnUrl)
} else {
console.log("No saved returnUrl, redirecting to /app/gettingstarted")

View file

@ -1,6 +1,6 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import PostHogClient from "@/lib/posthog"
import { redirect } from "next/navigation"
@ -10,7 +10,7 @@ export async function SubmitSecondOnboardingPage(
pythonLibraries: string[] | null,
colleagueInviteEmail: string | null,
): Promise<void> {
const session = await getSession()
const session = await auth0.getSession()
if (session == null) {
console.log("No session, redirecting to login")
redirect("/login")

View file

@ -6,13 +6,13 @@ import {
getUserReferralData,
} from "@codeflash-ai/common"
import PostHogClient from "@/lib/posthog"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
export async function upsertReferralSource(
referralSource: string,
additionalComments?: string,
): Promise<any> {
const session = await getSession()
const session = await auth0.getSession()
if (session != null) {
setUserReferralData(session.user.sub, referralSource, additionalComments)
const posthog = PostHogClient()

View file

@ -1,4 +1,5 @@
"use client"
import { type JSX } from "react"
import { Button } from "@/components/ui/button"
import { Trash2 } from "lucide-react"
import { type cf_api_keys } from "@prisma/client"

View file

@ -116,7 +116,6 @@ export function CreateApiKeyDialog(): React.JSX.Element {
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<Form {...form}>
{/* eslint-disable-next-line @typescript-eslint/no-misused-promises */}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogHeader>
<DialogTitle>Create new API key</DialogTitle>

View file

@ -0,0 +1,20 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function ApiKeysLoading() {
return (
<div className="py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<Skeleton className="h-8 w-32 mb-6" />
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-lg border">
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-16 rounded-md" />
</div>
))}
</div>
</div>
)
}

View file

@ -1,5 +1,6 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { type JSX } from "react"
import { auth0 } from "@/lib/auth0"
import { CreateApiKeyDialog } from "./dialog-create-api-key"
import { Separator } from "@/components/ui/separator"
import { ApiKeyTable } from "./api-key-table"
@ -23,7 +24,7 @@ interface ApiKeyWithOrg extends cf_api_keys {
}
export default async function APIKeyGenerator(): Promise<JSX.Element> {
const session = await getSession()
const session = await auth0.getSession()
// Auth handled by middleware + layout
if (!session?.user) {
throw new Error("Authentication required")

View file

@ -1,5 +1,5 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import {
deleteAPIKeyById,
@ -16,7 +16,7 @@ export async function generateToken(
keyName: string,
organizationId?: string,
): Promise<{ success: boolean; token: string | undefined; err: string | undefined }> {
const user = await getSession()
const user = await auth0.getSession()
if (user == null) {
redirect("/login")
}
@ -63,7 +63,7 @@ export async function generateTokenForVsCode(
}
}
export async function deleteAPIKey(id: number): Promise<void> {
const user = await getSession()
const user = await auth0.getSession()
if (user == null) {
redirect("/login")
return

View file

@ -0,0 +1,20 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function BillingLoading() {
return (
<div className="py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<Skeleton className="h-8 w-32 mb-6" />
<div className="grid gap-6">
<div className="rounded-xl border p-6 space-y-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-8 w-24" />
<Skeleton className="h-4 w-64" />
</div>
<div className="rounded-xl border p-6 space-y-4">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-32 w-full rounded-md" />
</div>
</div>
</div>
)
}

View file

@ -1,11 +1,11 @@
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { BillingView } from "./billing-view"
import PostHogClient from "@/lib/posthog"
import { SUBSCRIPTION_PLANS, checkAndResetSubscriptionPeriod } from "@codeflash-ai/common"
export default async function BillingPage() {
const session = await getSession()
const session = await auth0.getSession()
if (!session?.user) return null
const userId = session.user.sub
try {

View file

@ -1,9 +1,9 @@
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import PostHogClient from "@/lib/posthog"
import GettingStartedClient from "./getting-started-client"
export default async function GettingStarted() {
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null
const userId = session.user.sub

View file

@ -1,10 +1,10 @@
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import { ReactNode } from "react"
import { hasCompletedOnboarding } from "@codeflash-ai/common"
export default async function DashboardLayout({ children }: { children: ReactNode }) {
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null
const completedOnboarding = await hasCompletedOnboarding(session.user.sub)

View file

@ -0,0 +1,21 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function MembersLoading() {
return (
<div className="py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<Skeleton className="h-8 w-40 mb-6" />
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-lg border">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-6 w-20 rounded-full" />
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,438 @@
"use client"
import { useState, useMemo } from "react"
import {
Clock,
GitPullRequest,
Search,
ChevronDown,
X,
RefreshCw,
Filter,
ArrowUpDown,
BookOpen,
} from "lucide-react"
import Image from "next/image"
import { Card } from "@/components/ui/card"
import Link from "next/link"
import { useRouter } from "next/navigation"
import type { RepositoryWithUsage } from "@/app/dashboard/action"
/** Serialized version for server→client boundary (Dates become ISO strings) */
type SerializedRepository = Omit<RepositoryWithUsage, "created_at" | "last_optimized"> & {
created_at: string
last_optimized: string | null
}
import { useOutsideClick } from "@/components/hooks/useOutsideClick"
function SearchBar({
searchQuery,
setSearchQuery,
}: {
searchQuery: string
setSearchQuery: (value: string) => void
}) {
return (
<div className="relative flex-1 group">
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search
size={18}
className="text-muted-foreground/70 group-focus-within:text-primary transition-colors"
/>
</div>
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="block w-full rounded-xl border border-border bg-background/60 p-3 pl-10 text-foreground focus:border-primary focus:ring-1 focus:ring-primary transition-all duration-200"
/>
{searchQuery && (
<button
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X size={18} className="opacity-70 hover:opacity-100" />
</button>
)}
</div>
</div>
)
}
function RepositoryCard({ repo }: { repo: SerializedRepository }) {
return (
<Link href={`/repositories/${repo.id}`}>
<Card
key={repo.id}
className="bg-card bg-muted/5 rounded-xl border border-border hover:border-primary/30 hover:shadow-md hover:shadow-primary/5 transition-all duration-300 overflow-hidden group"
>
<div className="p-5">
<div className="flex items-start">
<div className="mr-3 flex-shrink-0">
{repo.avatarUrl ? (
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full overflow-hidden border-2 border-border/50 group-hover:border-primary/20 transition-colors">
<Image
src={repo.avatarUrl}
alt={`${repo.organization} avatar`}
width={44}
height={44}
className="object-cover w-full h-full"
/>
</div>
) : (
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full bg-gradient-to-br from-primary/10 to-primary/30 flex items-center justify-center border-2 border-border group-hover:from-primary/20 group-hover:to-primary/40 transition-colors">
<span className="text-primary font-semibold">
{repo.name?.substring(0, 1).toUpperCase() || "?"}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center flex-wrap gap-1">
<h3 className="text-sm sm:text-base font-semibold text-primary hover:underline truncate">
{repo.name || "Unknown Repository"}
</h3>
<span
className={`ml-1 px-1.5 sm:px-2 py-0.5 text-xs font-medium rounded-full ${repo.is_private ? "bg-amber-100 text-amber-700" : "bg-emerald-100 text-emerald-700"}`}
>
{repo.is_private ? "Private" : "Public"}
</span>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mb-2">
{repo.full_name || repo.name}
</p>
<div className="flex items-center flex-wrap gap-1.5 sm:gap-2">
<span
className={`inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full text-xs ${repo.is_active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"}`}
>
<span
className={`inline-block w-1.5 sm:w-2 h-1.5 sm:h-2 rounded-full ${repo.is_active ? "bg-green-500" : "bg-gray-400"} mr-1 sm:mr-1.5`}
></span>
<span>{repo.is_active ? "Active" : "Inactive"}</span>
</span>
{repo.has_github_action && (
<span className="inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full bg-blue-50 text-xs text-blue-700">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="10"
height="10"
className="mr-1 fill-current sm:w-3 sm:h-3"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
</svg>
Action
</span>
)}
{repo.membersCount !== undefined && repo.membersCount > 0 && (
<span className="inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full bg-indigo-50 text-xs text-indigo-700">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-1 sm:w-3 sm:h-3"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
{repo.membersCount}
</span>
)}
</div>
</div>
</div>
{repo.last_optimized && (
<div className="mt-3 sm:mt-4 text-xs text-muted-foreground flex items-center">
<Clock size={10} className="mr-1 sm:w-3 sm:h-3" />
Last optimized: {new Date(repo.last_optimized).toLocaleDateString()}
</div>
)}
</div>
</Card>
</Link>
)
}
export function RepositoryList({ repositories }: { repositories: SerializedRepository[] }) {
const router = useRouter()
const [searchQuery, setSearchQuery] = useState("")
const [filter, setFilter] = useState<"all" | "active" | "public" | "private">("all")
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false)
const [sortBy, setSortBy] = useState<"name">("name")
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const filterDropdownRef = useOutsideClick(() => setIsFilterDropdownOpen(false))
const sortDropdownRef = useOutsideClick(() => setIsSortDropdownOpen(false))
const getSortLabel = (sortType: string) => {
switch (sortType) {
case "name":
return "Name"
default:
return "Last Optimized"
}
}
const handleRefresh = () => {
if (isRefreshing) return
setIsRefreshing(true)
router.refresh()
// Reset after a short delay since router.refresh() doesn't provide a completion callback
setTimeout(() => setIsRefreshing(false), 2000)
}
const filteredRepositories = useMemo(() => {
if (!repositories || !Array.isArray(repositories)) {
return []
}
let repos = repositories.filter(repo => {
if (!repo) return false
const matchesSearch =
searchQuery === "" ||
(repo.name && repo.name.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.full_name && repo.full_name.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.organization && repo.organization.toLowerCase().includes(searchQuery.toLowerCase()))
if (!matchesSearch) return false
switch (filter) {
case "active":
return repo.is_active
case "public":
return !repo.is_private
case "private":
return repo.is_private
default:
return true
}
})
switch (sortBy) {
case "name":
repos = repos.sort((a, b) => {
const nameA = a?.name || ""
const nameB = b?.name || ""
return nameA.localeCompare(nameB)
})
break
}
return repos
}, [repositories, searchQuery, filter, sortBy])
return (
<>
<div className="flex justify-between items-center mb-4 sm:mb-6">
<h2 className="text-base sm:text-lg font-semibold flex items-center">
<BookOpen size={18} className="mr-2 text-primary" />
Repository List
</h2>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm border border-border bg-background hover:bg-muted/50 transition-colors ${
isRefreshing ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<RefreshCw
size={12}
className={`text-muted-foreground sm:w-4 sm:h-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
{isRefreshing ? "Refreshing..." : "Refresh"}
</button>
</div>
<div className="flex flex-col md:flex-row gap-3 sm:gap-4 mb-5 sm:mb-6">
<SearchBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
<div className="flex gap-2">
<div className="relative" ref={filterDropdownRef}>
<button
onClick={() => setIsFilterDropdownOpen(!isFilterDropdownOpen)}
className="flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 text-xs sm:text-sm bg-background border border-border rounded-xl hover:border-primary/50 transition-colors focus:outline-none focus:ring-1 focus:ring-primary"
>
<Filter size={14} className="text-muted-foreground sm:w-4 sm:h-4" />
<span>
{filter === "all"
? "All"
: filter.charAt(0).toUpperCase() + filter.slice(1).replace(/-/g, " ")}
</span>
<ChevronDown
size={14}
className={`transition-transform text-muted-foreground sm:w-4 sm:h-4 ${isFilterDropdownOpen ? "rotate-180" : ""}`}
/>
</button>
{isFilterDropdownOpen && (
<div className="absolute z-10 mt-2 w-48 sm:w-52 bg-card rounded-xl shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
<button
onClick={() => {
setFilter("all")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "all" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "all" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
All repositories
</button>
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
setFilter("active")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "active" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "active" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Active
</button>
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
setFilter("public")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "public" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "public" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Public
</button>
<button
onClick={() => {
setFilter("private")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "private" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "private" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Private
</button>
</div>
</div>
)}
</div>
<div className="relative" ref={sortDropdownRef}>
<button
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
className="flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 text-xs sm:text-sm bg-background border border-border rounded-xl hover:border-primary/50 transition-colors focus:outline-none focus:ring-1 focus:ring-primary"
>
<ArrowUpDown size={14} className="text-muted-foreground sm:w-4 sm:h-4" />
<span>Sort: {getSortLabel(sortBy)}</span>
<ChevronDown
size={14}
className={`transition-transform text-muted-foreground sm:w-4 sm:h-4 ${isSortDropdownOpen ? "rotate-180" : ""}`}
/>
</button>
{isSortDropdownOpen && (
<div className="absolute right-0 z-10 mt-2 w-48 sm:w-52 bg-card rounded-xl shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
<button
onClick={() => {
setSortBy("name")
setIsSortDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${sortBy === "name" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{sortBy === "name" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Name
</button>
</div>
</div>
)}
</div>
</div>
</div>
{searchQuery && (
<div className="flex items-center mb-4 sm:mb-5 ml-1">
<span className="text-xs sm:text-sm text-muted-foreground mr-1 sm:mr-2">
Searching for:
</span>
<div className="bg-primary/10 text-primary px-2 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs sm:text-sm flex items-center gap-1 sm:gap-1.5">
<span>{searchQuery}</span>
<button
onClick={() => setSearchQuery("")}
className="text-primary hover:text-primary/80"
>
<X size={12} className="sm:w-4 sm:h-4" />
</button>
</div>
</div>
)}
{filteredRepositories.length === 0 ? (
<div className="text-center py-16 sm:py-20 bg-card/50 rounded-xl border border-dashed border-border">
<div className="inline-flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-muted/40 mb-3 sm:mb-4">
<Search size={24} className="text-muted-foreground sm:w-7 sm:h-7" />
</div>
<h3 className="text-base sm:text-lg font-medium mb-1 sm:mb-2">No repositories found</h3>
<p className="text-xs sm:text-sm text-muted-foreground max-w-md mx-auto">
{
"We couldn't find any repositories matching your search criteria. Try adjusting your filters or search term."
}
</p>
<button
onClick={() => {
setSearchQuery("")
setFilter("all")
}}
className="mt-4 sm:mt-5 px-4 sm:px-5 py-2 sm:py-2.5 bg-primary text-primary-foreground rounded-lg sm:rounded-xl text-xs sm:text-sm hover:bg-primary/90 transition-colors"
>
Clear filters
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-5">
{filteredRepositories.map(repo => (
<RepositoryCard key={repo.id} repo={repo} />
))}
</div>
)}
</>
)
}

View file

@ -0,0 +1,22 @@
"use client"
import { RefreshCw } from "lucide-react"
export default function RepositoriesError({ reset }: { error: Error; reset: () => void }) {
return (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">Something went wrong</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">
There was an error loading the repositories page.
</p>
<button
onClick={reset}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Try Again
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,5 @@
import { RepositoriesSkeleton } from "@/components/repositories/RepositoriesSkeleton"
export default function RepositoriesLoading() {
return <RepositoriesSkeleton />
}

View file

@ -1,692 +1,33 @@
"use client"
import { GitPullRequest } from "lucide-react"
import { getAccountContext } from "@/lib/server/get-account-context"
import { getAllRepositories } from "@/app/dashboard/action"
import { RepositoryList } from "./_components/RepositoryList"
import React, { useState, useMemo, useEffect, useCallback, useRef } from "react"
import {
Clock,
GitPullRequest,
Search,
ChevronDown,
X,
RefreshCw,
Filter,
ArrowUpDown,
BookOpen,
} from "lucide-react"
import Image from "next/image"
import { Card } from "@/components/ui/card"
import { getUserIdAndUsername } from "@/app/utils/auth"
import Link from "next/link"
import { getAllRepositories, RepositoryWithUsage } from "@/app/dashboard/action"
import { useViewMode } from "@/app/app/ViewModeContext"
// Error Boundary Component
class RepositoryErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error?: Error }
> {
constructor(props: { children: React.ReactNode }) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error("Repository page error:", error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">Something went wrong</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">
There was an error loading the repositories page.
</p>
<button
onClick={() => window.location.reload()}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Reload Page
</button>
</div>
</div>
)
}
return this.props.children
}
}
// Custom hook for debouncing
const useDebounce = (callback: () => void, delay: number) => {
const timeoutRef = useRef<NodeJS.Timeout>()
return useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(callback, delay)
}, [callback, delay])
}
// Custom hook for detecting clicks outside of an element
const useOutsideClick = (callback: () => void) => {
const ref = React.useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback()
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [callback])
return ref
}
// Enhanced search component
const SearchBar = ({
searchQuery,
setSearchQuery,
}: {
searchQuery: string
setSearchQuery: (value: string) => void
}) => {
return (
<div className="relative flex-1 group">
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search
size={18}
className="text-muted-foreground/70 group-focus-within:text-primary transition-colors"
/>
</div>
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="block w-full rounded-xl border border-border bg-background/60 p-3 pl-10 text-foreground focus:border-primary focus:ring-1 focus:ring-primary transition-all duration-200"
/>
{searchQuery && (
<button
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X size={18} className="opacity-70 hover:opacity-100" />
</button>
)}
</div>
</div>
)
}
// GitHub-style Repository Card Component
const RepositoryCard = ({ repo }: { repo: RepositoryWithUsage }) => (
<Link href={`/repositories/${repo.id}`}>
<Card
key={repo.id}
className="bg-card bg-muted/5 rounded-xl border border-border hover:border-primary/30 hover:shadow-md hover:shadow-primary/5 transition-all duration-300 overflow-hidden group"
>
<div className="p-5">
<div className="flex items-start">
{/* Circular avatar for organization */}
<div className="mr-3 flex-shrink-0">
{repo.avatarUrl ? (
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full overflow-hidden border-2 border-border/50 group-hover:border-primary/20 transition-colors">
<Image
src={repo.avatarUrl}
alt={`${repo.organization} avatar`}
width={44}
height={44}
className="object-cover w-full h-full"
/>
</div>
) : (
<div className="w-9 h-9 sm:w-11 sm:h-11 rounded-full bg-gradient-to-br from-primary/10 to-primary/30 flex items-center justify-center border-2 border-border group-hover:from-primary/20 group-hover:to-primary/40 transition-colors">
<span className="text-primary font-semibold">
{repo.name?.substring(0, 1).toUpperCase() || "?"}
</span>
</div>
)}
</div>
<div className="flex-1 min-w-0">
{/* Repository name with visibility badge */}
<div className="flex items-center flex-wrap gap-1">
<h3 className="text-sm sm:text-base font-semibold text-primary hover:underline truncate">
{repo.name || "Unknown Repository"}
</h3>
<span
className={`ml-1 px-1.5 sm:px-2 py-0.5 text-xs font-medium rounded-full ${repo.is_private ? "bg-amber-100 text-amber-700" : "bg-emerald-100 text-emerald-700"}`}
>
{repo.is_private ? "Private" : "Public"}
</span>
</div>
{/* Organization/full name */}
<p className="text-xs sm:text-sm text-muted-foreground mb-2">
{repo.full_name || repo.name}
</p>
{/* Repository stats - matching schema data */}
<div className="flex items-center flex-wrap gap-1.5 sm:gap-2">
{/* Active status */}
<span
className={`inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full text-xs ${repo.is_active ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"}`}
>
<span
className={`inline-block w-1.5 sm:w-2 h-1.5 sm:h-2 rounded-full ${repo.is_active ? "bg-green-500" : "bg-gray-400"} mr-1 sm:mr-1.5`}
></span>
<span>{repo.is_active ? "Active" : "Inactive"}</span>
</span>
{/* GitHub Action */}
{repo.has_github_action && (
<span className="inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full bg-blue-50 text-xs text-blue-700">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="10"
height="10"
className="mr-1 fill-current sm:w-3 sm:h-3"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
</svg>
Action
</span>
)}
{/* Members count if available */}
{repo.membersCount !== undefined && repo.membersCount > 0 && (
<span className="inline-flex items-center px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full bg-indigo-50 text-xs text-indigo-700">
<svg
xmlns="http://www.w3.org/2000/svg"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-1 sm:w-3 sm:h-3"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
</svg>
{repo.membersCount}
</span>
)}
</div>
</div>
</div>
{/* Last optimized date */}
{repo.last_optimized && (
<div className="mt-3 sm:mt-4 text-xs text-muted-foreground flex items-center">
<Clock size={10} className="mr-1 sm:w-3 sm:h-3" />
Last optimized: {new Date(repo.last_optimized).toLocaleDateString()}
</div>
)}
</div>
</Card>
</Link>
)
// Import skeleton loaders
import {
RepositoriesSkeleton,
RepositoriesRefreshingSkeleton,
} from "@/components/repositories/RepositoriesSkeleton"
// Loading State Component (now using skeleton loaders)
const RepositoriesLoading = ({ isRefreshing = false }: { isRefreshing?: boolean }) =>
isRefreshing ? (
<RepositoriesRefreshingSkeleton />
) : (
<RepositoriesSkeleton message="Loading repositories..." />
)
// Page Header Component
const PageHeader = ({ totalCount }: { totalCount: number }) => (
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-xl sm:text-2xl font-bold">Repositories</h1>
<div className="px-2 py-0.5 sm:px-2.5 sm:py-1 bg-primary/10 text-primary rounded-full text-xs sm:text-sm font-medium">
{totalCount} total
</div>
</div>
</div>
)
// Main component for repository list with filters
const RepositoryList = ({ repositories }: { repositories: RepositoryWithUsage[] }) => {
const [searchQuery, setSearchQuery] = useState("")
const [filter, setFilter] = useState<"all" | "active" | "public" | "private">("all")
const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false)
const [sortBy, setSortBy] = useState<"name">("name")
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false)
const filterDropdownRef = useOutsideClick(() => setIsFilterDropdownOpen(false))
const sortDropdownRef = useOutsideClick(() => setIsSortDropdownOpen(false))
const getSortLabel = (sortType: string) => {
switch (sortType) {
case "name":
return "Name"
default:
return "Last Optimized"
}
}
const filteredRepositories = useMemo(() => {
// Add safety check for repositories array
if (!repositories || !Array.isArray(repositories)) {
return []
}
let repos = repositories.filter(repo => {
// Add safety checks for repo properties
if (!repo) return false
// Search in name and full_name with safety checks
const matchesSearch =
searchQuery === "" ||
(repo.name && repo.name.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.full_name && repo.full_name.toLowerCase().includes(searchQuery.toLowerCase())) ||
(repo.organization && repo.organization.toLowerCase().includes(searchQuery.toLowerCase()))
if (!matchesSearch) return false
switch (filter) {
case "active":
return repo.is_active
case "public":
return !repo.is_private
case "private":
return repo.is_private
default:
return true
}
})
// Sort repositories with safety check
switch (sortBy) {
case "name":
repos = repos.sort((a, b) => {
const nameA = a?.name || ""
const nameB = b?.name || ""
return nameA.localeCompare(nameB)
})
break
}
return repos
}, [repositories, searchQuery, filter, sortBy])
return (
<>
<div className="flex flex-col md:flex-row gap-3 sm:gap-4 mb-5 sm:mb-6">
<SearchBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
<div className="flex gap-2">
<div className="relative" ref={filterDropdownRef}>
<button
onClick={() => setIsFilterDropdownOpen(!isFilterDropdownOpen)}
className="flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 text-xs sm:text-sm bg-background border border-border rounded-xl hover:border-primary/50 transition-colors focus:outline-none focus:ring-1 focus:ring-primary"
>
<Filter size={14} className="text-muted-foreground sm:w-4 sm:h-4" />
<span>
{filter === "all"
? "All"
: filter.charAt(0).toUpperCase() + filter.slice(1).replace(/-/g, " ")}
</span>
<ChevronDown
size={14}
className={`transition-transform text-muted-foreground sm:w-4 sm:h-4 ${isFilterDropdownOpen ? "rotate-180" : ""}`}
/>
</button>
{isFilterDropdownOpen && (
<div className="absolute z-10 mt-2 w-48 sm:w-52 bg-card rounded-xl shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
<button
onClick={() => {
setFilter("all")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "all" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "all" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
All repositories
</button>
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
setFilter("active")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "active" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "active" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Active
</button>
</div>
<div className="border-t border-border py-1">
<button
onClick={() => {
setFilter("public")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "public" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "public" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Public
</button>
<button
onClick={() => {
setFilter("private")
setIsFilterDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${filter === "private" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{filter === "private" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Private
</button>
</div>
</div>
)}
</div>
<div className="relative" ref={sortDropdownRef}>
<button
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
className="flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-2.5 sm:py-3 text-xs sm:text-sm bg-background border border-border rounded-xl hover:border-primary/50 transition-colors focus:outline-none focus:ring-1 focus:ring-primary"
>
<ArrowUpDown size={14} className="text-muted-foreground sm:w-4 sm:h-4" />
<span>Sort: {getSortLabel(sortBy)}</span>
<ChevronDown
size={14}
className={`transition-transform text-muted-foreground sm:w-4 sm:h-4 ${isSortDropdownOpen ? "rotate-180" : ""}`}
/>
</button>
{isSortDropdownOpen && (
<div className="absolute right-0 z-10 mt-2 w-48 sm:w-52 bg-card rounded-xl shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
<button
onClick={() => {
setSortBy("name")
setIsSortDropdownOpen(false)
}}
className={`w-full px-3 sm:px-4 py-2 sm:py-2.5 text-left hover:bg-muted flex items-center ${sortBy === "name" ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 sm:w-5 h-4 sm:h-5 mr-1.5 sm:mr-2 flex items-center justify-center">
{sortBy === "name" && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
Name
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Search indicator */}
{searchQuery && (
<div className="flex items-center mb-4 sm:mb-5 ml-1">
<span className="text-xs sm:text-sm text-muted-foreground mr-1 sm:mr-2">
Searching for:
</span>
<div className="bg-primary/10 text-primary px-2 sm:px-3 py-1 sm:py-1.5 rounded-full text-xs sm:text-sm flex items-center gap-1 sm:gap-1.5">
<span>{searchQuery}</span>
<button
onClick={() => setSearchQuery("")}
className="text-primary hover:text-primary/80"
>
<X size={12} className="sm:w-4 sm:h-4" />
</button>
</div>
</div>
)}
{filteredRepositories.length === 0 ? (
<div className="text-center py-16 sm:py-20 bg-card/50 rounded-xl border border-dashed border-border">
<div className="inline-flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-muted/40 mb-3 sm:mb-4">
<Search size={24} className="text-muted-foreground sm:w-7 sm:h-7" />
</div>
<h3 className="text-base sm:text-lg font-medium mb-1 sm:mb-2">No repositories found</h3>
<p className="text-xs sm:text-sm text-muted-foreground max-w-md mx-auto">
{
"We couldn't find any repositories matching your search criteria. Try adjusting your filters or search term."
}
</p>
<button
onClick={() => {
setSearchQuery("")
setFilter("all")
}}
className="mt-4 sm:mt-5 px-4 sm:px-5 py-2 sm:py-2.5 bg-primary text-primary-foreground rounded-lg sm:rounded-xl text-xs sm:text-sm hover:bg-primary/90 transition-colors"
>
Clear filters
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-5">
{filteredRepositories.map(repo => (
<RepositoryCard key={repo.id} repo={repo} />
))}
</div>
)}
</>
)
}
// Main page component
const maxRetries = 3
function RepositoriesPage() {
const [repositories, setRepositories] = useState<RepositoryWithUsage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const { currentOrg } = useViewMode()
const fetchRepositories = useCallback(
async (attempt = 0) => {
try {
setLoading(attempt === 0)
setError(null)
// Add a small delay for rapid refreshes and retries
if (attempt > 0) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000))
}
const data = await getUserIdAndUsername()
if (!data || !data.userId || !data.username) {
throw new Error("User authentication data not found")
}
const repos = await getAllRepositories(
currentOrg ? { orgId: currentOrg.id } : { userId: data.userId, username: data.username },
)
if (Array.isArray(repos)) {
setRepositories(repos)
setRetryCount(0) // Reset retry count on success
} else {
console.warn("Received non-array repositories data:", repos)
setRepositories([])
}
} catch (err) {
console.error(`Failed to fetch repositories (attempt ${attempt + 1}):`, err)
// If it's an auth error and we haven't exceeded retries, try again
if (
attempt < maxRetries &&
err instanceof Error &&
(err.message.includes("authentication") ||
err.message.includes("User authentication data not found") ||
err.message.includes("Unauthorized") ||
err.message.includes("No valid session found"))
) {
setRetryCount(attempt + 1)
return fetchRepositories(attempt + 1)
}
setError("Failed to load repositories. Please try again later.")
setRepositories([])
} finally {
setLoading(false)
setIsRefreshing(false)
}
},
[currentOrg],
)
// Debounced refresh to prevent rapid successive calls
const debouncedRefresh = useDebounce(() => {
setIsRefreshing(true)
fetchRepositories()
}, 300)
const handleRefresh = () => {
if (!isRefreshing && !loading) {
debouncedRefresh()
}
}
// Handle browser refresh with beforeunload
useEffect(() => {
const handleBeforeUnload = () => {
// Clear any pending timeouts
return null
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () => window.removeEventListener("beforeunload", handleBeforeUnload)
}, [])
useEffect(() => {
// Check if user was recently authenticated
const lastAuthCheck = localStorage.getItem("lastAuthCheck")
const now = Date.now()
// If last auth check was less than 2 seconds ago, wait a bit
if (lastAuthCheck && now - parseInt(lastAuthCheck) < 2000) {
const delay = 2000 - (now - parseInt(lastAuthCheck))
setTimeout(() => {
fetchRepositories()
}, delay)
} else {
// Add a small delay to prevent race conditions on rapid refreshes
const timeoutId = setTimeout(() => {
fetchRepositories()
}, 100)
const cleanup = () => clearTimeout(timeoutId)
return cleanup
}
// Update last auth check time
localStorage.setItem("lastAuthCheck", now.toString())
}, [fetchRepositories])
// Refresh Button Component
const RefreshButton = () => (
<button
onClick={handleRefresh}
disabled={isRefreshing || loading}
className={`flex items-center gap-1 sm:gap-2 px-3 sm:px-4 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm border border-border bg-background hover:bg-muted/50 transition-colors ${
isRefreshing || loading ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<RefreshCw
size={12}
className={`text-muted-foreground sm:w-4 sm:h-4 ${isRefreshing || loading ? "animate-spin" : ""}`}
/>
{isRefreshing ? "Refreshing..." : "Refresh"}
</button>
)
if (loading) {
return <RepositoriesLoading isRefreshing={isRefreshing} />
}
if (error) {
return (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">
Unable to Load Repositories
</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">{error}</p>
{retryCount > 0 && (
<p className="mb-3 text-xs text-red-600">
Retry attempt: {retryCount}/{maxRetries}
</p>
)}
<button
onClick={() => fetchRepositories()}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Try Again
</button>
</div>
</div>
)
}
export default async function RepositoriesPage() {
const accountPayload = await getAccountContext()
const repos = await getAllRepositories(accountPayload)
// Serialize Date objects for client component boundary
const repositories = (Array.isArray(repos) ? repos : []).map(repo => ({
...repo,
created_at: repo.created_at instanceof Date ? repo.created_at.toISOString() : repo.created_at,
last_optimized:
repo.last_optimized instanceof Date ? repo.last_optimized.toISOString() : repo.last_optimized,
}))
return (
<div className="flex-1 bg-background">
<div className="h-screen py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<PageHeader totalCount={repositories?.length || 0} />
<div className="min-h-screen py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-xl sm:text-2xl font-bold">Repositories</h1>
<div className="px-2 py-0.5 sm:px-2.5 sm:py-1 bg-primary/10 text-primary rounded-full text-xs sm:text-sm font-medium">
{repositories.length} total
</div>
</div>
</div>
<div className="bg-card p-4 sm:p-6 rounded-xl border border-border">
<div className="flex justify-between items-center mb-4 sm:mb-6">
<h2 className="text-base sm:text-lg font-semibold flex items-center">
<BookOpen size={18} className="mr-2 text-primary" />
Repository List
</h2>
<RefreshButton />
</div>
{!repositories || repositories.length === 0 ? (
{repositories.length === 0 ? (
<div className="flex justify-center items-center min-h-[300px] sm:min-h-[400px] w-full">
<div className="text-center py-12 sm:py-16 bg-muted/10 rounded-xl border border-dashed border-border max-w-lg w-full px-5 sm:px-8">
<div className="inline-flex items-center justify-center w-12 h-12 sm:w-16 sm:h-16 rounded-full bg-muted/20 mb-3 sm:mb-4">
@ -727,12 +68,3 @@ function RepositoriesPage() {
</div>
)
}
// Main export with error boundary
export default function RepositoriesPageWrapper() {
return (
<RepositoryErrorBoundary>
<RepositoriesPage />
</RepositoryErrorBoundary>
)
}

View file

@ -3,7 +3,7 @@
import { CF_API } from "@/app/api/const"
import { ActionResponse, createErrorResponse, createSuccessResponse } from "@/lib/action-response"
import { getRepositoriesForAccountCached } from "@/lib/services/repository-utils"
import { getAccessToken } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { AccountPayload, buildOptimizationOrCondition, prisma } from "@codeflash-ai/common"
import * as Sentry from "@sentry/nextjs"
@ -32,9 +32,9 @@ export interface GetStagingCodeParams {
export async function getStagingCodeFromApi(params: GetStagingCodeParams): Promise<ActionResponse<StagingCodeResponse>> {
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
const session = await getAccessToken({ refresh: true })
const session = await auth0.getAccessToken()
if (!cfapiUrl || !session?.accessToken) {
if (!cfapiUrl || !session?.token) {
return createErrorResponse("Please sign in to continue")
}
@ -43,7 +43,7 @@ export async function getStagingCodeFromApi(params: GetStagingCodeParams): Promi
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
Authorization: `Bearer ${session.token}`,
"X-CodeFlash-Source": "webapp",
},
body: JSON.stringify(params),
@ -92,9 +92,9 @@ export async function commitStagingCode(
commitMessage?: string,
): Promise<ActionResponse<CommitStagingCodeResponse>> {
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
const session = await getAccessToken({ refresh: true })
const session = await auth0.getAccessToken()
if (!cfapiUrl || !session?.accessToken) {
if (!cfapiUrl || !session?.token) {
return createErrorResponse("Please sign in to continue")
}
@ -103,7 +103,7 @@ export async function commitStagingCode(
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
Authorization: `Bearer ${session.token}`,
"X-CodeFlash-Source": "webapp",
},
body: JSON.stringify({
@ -275,9 +275,9 @@ export async function createPullRequest({
optimizedLineProfiler?: string
}): Promise<ActionResponse> {
const cfapiUrl = process.env.CODEFLASH_CFAPI_URL
const session = await getAccessToken({ refresh: true })
const session = await auth0.getAccessToken()
if (!cfapiUrl || !session?.accessToken) {
if (!cfapiUrl || !session?.token) {
return createErrorResponse("Please sign in to continue")
}
@ -291,7 +291,7 @@ export async function createPullRequest({
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
Authorization: `Bearer ${session.token}`,
"X-CodeFlash-Source": "webapp",
},
body: JSON.stringify({

View file

@ -3,7 +3,15 @@
import { useEffect, useState, useCallback, useRef } from "react"
import { useParams, useRouter } from "next/navigation"
import Image from "next/image"
import { Zap, CheckCircle, XCircle, MessageSquare, Loader2, GitCommit, BarChart3 } from "lucide-react"
import {
Zap,
CheckCircle,
XCircle,
MessageSquare,
Loader2,
GitCommit,
BarChart3,
} from "lucide-react"
import {
createPullRequest,
getOptimizationEventById,
@ -15,7 +23,12 @@ import {
commitStagingCode,
} from "./action"
import { getUserIdAndUsername } from "@/app/utils/auth"
import MonacoDiffEditorGithub from "@/components/Editor/monaco-diff-editor-github"
import dynamic from "next/dynamic"
const MonacoDiffEditorGithub = dynamic(
() => import("@/components/Editor/monaco-diff-editor-github"),
{ ssr: false },
)
import { toast } from "sonner"
import { MarkdownEditor } from "@/components/markdwon/markdown-editor"
import { MarkdownViewer } from "@/components/markdwon/markdown-viewer"
@ -653,8 +666,7 @@ export default function OptimizationReviewPage() {
// Check if we have empty diffContents for git_branch storage type (merged PR in privacy mode)
const isPrivacyModeWithNoDiff =
event.staging_storage_type === "git_branch" &&
Object.keys(diffContents).length === 0
event.staging_storage_type === "git_branch" && Object.keys(diffContents).length === 0
return (
<div className="min-h-screen bg-background">

View file

@ -0,0 +1,958 @@
"use client"
import { useState, useCallback, useRef, useEffect } from "react"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Search,
FileCode2,
Zap,
Clock,
ChevronLeft,
ChevronRight,
Filter,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react"
import { formatDistanceToNow } from "date-fns"
import { useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { getAllOptimizationEvents } from "../action"
import Image from "next/image"
import { ReviewQualityBadge } from "@/components/ui/quality_badge"
import type { AccountPayload } from "@codeflash-ai/common"
interface Repository {
id: string
full_name?: string
}
interface DiffContent {
oldContent: string
newContent: string
}
interface EventMetadata {
diffContents?: Record<string, DiffContent>
[key: string]: unknown
}
interface OptimizationEvent {
id: string
function_name?: string
file_path?: string
repository?: Repository | null | undefined
speedup_x?: number
speedup_pct?: number
metadata?: EventMetadata | null | undefined
created_at: string
status?: string
event_type?: string
trace_id: string
review_quality: string
}
interface FilterState {
search: string
repositoryId: string | null
status: string
eventType: string
reviewQuality: string
sortBy: string
page: number
}
interface OptimizationsTableProps {
initialEvents: OptimizationEvent[]
initialTotalCount: number
availableRepositories: Array<{ id: string; full_name: string }>
accountPayload: AccountPayload
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<div className="flex items-start gap-3">
<div className="h-4 w-4 bg-muted animate-pulse rounded mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0 space-y-2">
<div className="h-4 bg-muted animate-pulse rounded w-3/4" />
<div className="h-3 bg-muted animate-pulse rounded w-1/2" />
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 bg-muted animate-pulse rounded-full flex-shrink-0" />
<div className="h-4 bg-muted animate-pulse rounded w-32" />
</div>
</TableCell>
<TableCell className="text-center">
<div className="h-6 bg-muted animate-pulse rounded-full w-20 mx-auto" />
</TableCell>
<TableCell className="text-center">
<div className="h-6 bg-muted animate-pulse rounded-full w-24 mx-auto" />
</TableCell>
<TableCell className="text-center">
<div className="h-6 bg-muted animate-pulse rounded-full w-16 mx-auto" />
</TableCell>
<TableCell className="text-center">
<div className="h-6 bg-muted animate-pulse rounded-full w-24 mx-auto" />
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<div className="h-6 bg-muted animate-pulse rounded-full w-12" />
<div className="h-6 bg-muted animate-pulse rounded-full w-12" />
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<div className="h-3 w-3 bg-muted animate-pulse rounded flex-shrink-0" />
<div className="h-4 bg-muted animate-pulse rounded w-24" />
</div>
</TableCell>
</TableRow>
))}
</>
)
}
function calculateDiffStats(
diffContents: Record<string, { oldContent: string; newContent: string }>,
) {
let totalAdditions = 0
let totalDeletions = 0
Object.entries(diffContents).forEach(([, { oldContent, newContent }]) => {
const oldLines = oldContent.split("\n").filter(line => line.trim() !== "")
const newLines = newContent.split("\n").filter(line => line.trim() !== "")
const oldLineMap = new Map<string, number>()
const newLineMap = new Map<string, number>()
oldLines.forEach(line => {
const trimmed = line.trim()
oldLineMap.set(trimmed, (oldLineMap.get(trimmed) || 0) + 1)
})
newLines.forEach(line => {
const trimmed = line.trim()
newLineMap.set(trimmed, (newLineMap.get(trimmed) || 0) + 1)
})
for (const [line, oldCount] of Array.from(oldLineMap)) {
const newCount = newLineMap.get(line) || 0
if (oldCount > newCount) {
totalDeletions += oldCount - newCount
}
}
for (const [line, newCount] of Array.from(newLineMap)) {
const oldCount = oldLineMap.get(line) || 0
if (newCount > oldCount) {
totalAdditions += newCount - oldCount
}
}
})
return { totalAdditions, totalDeletions }
}
function ClickableTableRow({
event,
children,
onRowClick,
}: {
event: OptimizationEvent
children: React.ReactNode
onRowClick: (eventId: string) => void
}) {
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('a[href^="http"]')) {
return
}
onRowClick(event.trace_id)
},
[event.trace_id, onRowClick],
)
return (
<TableRow
key={event.id}
className="group cursor-pointer hover:bg-muted"
onClick={handleRowClick}
>
{children}
</TableRow>
)
}
export function OptimizationsTable({
initialEvents,
initialTotalCount,
availableRepositories,
accountPayload,
}: OptimizationsTableProps) {
const router = useRouter()
const [events, setEvents] = useState<OptimizationEvent[]>(initialEvents)
const [totalCount, setTotalCount] = useState(initialTotalCount)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filters, setFilters] = useState<FilterState>({
search: "",
repositoryId: null,
status: "all",
eventType: "all",
reviewQuality: "all",
sortBy: "created_at_desc",
page: 1,
})
const pageSize = 10
const isInitialMount = useRef(true)
const debounceTimer = useRef<NodeJS.Timeout>(undefined)
const loadEvents = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const filter: Record<string, string | null | { not: null }> = {}
if (filters.repositoryId === "none") {
filter.repository_id = null
} else if (filters.repositoryId) {
filter.repository_id = filters.repositoryId
}
if (filters.status !== "all") {
filter.status = filters.status
}
if (filters.eventType !== "all") {
filter.event_type = filters.eventType
}
if (filters.reviewQuality !== "all") {
filter.review_quality = filters.reviewQuality
}
const [sortField, sortDirection] = filters.sortBy.split("_").reduce(
(acc, part, index, arr) => {
if (index === arr.length - 1 && (part === "asc" || part === "desc")) {
return [acc[0], part]
}
return [acc[0] ? `${acc[0]}_${part}` : part, acc[1]]
},
["", "desc"] as [string, string],
)
const sort: Record<string, "asc" | "desc"> = {
[sortField]: sortDirection as "asc" | "desc",
}
const data = await getAllOptimizationEvents({
payload: accountPayload,
search: filters.search,
filter,
sort,
page: filters.page,
pageSize,
})
type RawEvent = OptimizationEvent & {
repository?: { id: string; full_name?: string; name?: string } | null
}
const transformedEvents: OptimizationEvent[] = (data?.events || []).map(
(event: RawEvent) => ({
...event,
metadata: event.metadata as EventMetadata | null | undefined,
repository: event.repository
? {
id: event.repository.id,
full_name: event.repository.full_name || event.repository.name,
}
: null,
}),
)
setEvents(transformedEvents)
setTotalCount(data?.totalCount || 0)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load events")
} finally {
setIsLoading(false)
}
}, [filters, accountPayload, pageSize])
// Load events when filters change (skip initial mount — server provided that data)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false
return
}
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
const hasSearchChanged = filters.search !== ""
if (hasSearchChanged) {
debounceTimer.current = setTimeout(() => {
loadEvents()
}, 300)
} else {
loadEvents()
}
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [filters, loadEvents])
const handleRowClick = useCallback(
(traceId: string) => {
router.push(`/review-optimizations/${traceId}`)
},
[router],
)
const updateFilter = useCallback((key: keyof FilterState, value: string | number | null) => {
setFilters(prev => ({
...prev,
[key]: value,
...(key !== "page" && { page: 1 }),
}))
}, [])
const clearFilters = useCallback(() => {
setFilters({
search: "",
repositoryId: null,
status: "all",
eventType: "all",
reviewQuality: "all",
sortBy: "created_at_desc",
page: 1,
})
}, [])
const hasActiveFilters =
filters.search ||
filters.repositoryId !== null ||
filters.status !== "all" ||
filters.eventType !== "all" ||
filters.reviewQuality !== "all" ||
filters.sortBy !== "created_at_desc"
const totalPages = Math.ceil(totalCount / pageSize)
const handlePageChange = useCallback(
(newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
updateFilter("page", newPage)
}
},
[totalPages, updateFilter],
)
const getSortIcon = useCallback(
(field: string) => {
if (filters.sortBy.startsWith(field)) {
return filters.sortBy.endsWith("_asc") ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
)
}
return <ArrowUpDown className="h-4 w-4 opacity-50" />
},
[filters.sortBy],
)
const toggleSort = useCallback(
(field: string) => {
const newSort = filters.sortBy.startsWith(field)
? filters.sortBy === `${field}_desc`
? `${field}_asc`
: `${field}_desc`
: `${field}_desc`
updateFilter("sortBy", newSort)
},
[filters.sortBy, updateFilter],
)
const getSpeedupBadge = useCallback((speedup?: number, speedupPct?: number) => {
if (typeof speedup !== "number" || typeof speedupPct !== "number") return null
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
const x = clamp(speedup, 1, 300)
const t = (x - 1) / 299
const hue = 158
const lightness = 95 - t * (95 - 35)
const saturation = 45 + t * (75 - 45)
const textColor = lightness < 60 ? "#fff" : "#047857"
const borderLightness = lightness > 60 ? lightness - 8 : lightness + 8
const borderSaturation = saturation > 70 ? saturation - 10 : saturation + 10
const bgColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`
const borderColor = `hsl(${hue}, ${borderSaturation}%, ${borderLightness}%)`
return (
<Badge
variant="default"
className="font-mono text-[11px] px-2 py-0.5 whitespace-nowrap font-medium"
style={{
backgroundColor: bgColor,
color: textColor,
border: `1px solid ${borderColor}`,
}}
>
{speedup.toFixed(2)}x ({speedupPct.toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}%)
</Badge>
)
}, [])
const getStatusBadge = useCallback((status?: string) => {
if (!status) return null
const variants: Record<string, { className: string; label: string }> = {
approved: {
className:
"bg-green-100 text-green-800 border-green-300 dark:bg-green-900/30 dark:text-green-100 dark:border-green-700",
label: "Approved",
},
rejected: {
className:
"bg-red-100 text-red-800 border-red-300 dark:bg-red-900/30 dark:text-red-100 dark:border-red-700",
label: "Rejected",
},
}
const variant = variants[status] || {
className: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100",
label: status,
}
return (
<Badge variant="secondary" className={variant.className}>
{variant.label}
</Badge>
)
}, [])
const getEventTypeBadge = useCallback((eventType?: string) => {
if (!eventType) return null
const variants: Record<string, { className: string; label: string }> = {
pr_created: {
className:
"bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/30 dark:text-blue-100 dark:border-blue-700",
label: "PR Created",
},
pr_merged: {
className:
"bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-900/30 dark:text-purple-100 dark:border-purple-700",
label: "PR Merged",
},
pr_closed: {
className:
"bg-orange-100 text-orange-800 border-orange-300 dark:bg-orange-900/30 dark:text-orange-100 dark:border-orange-700",
label: "PR Closed",
},
"no-pr": {
className:
"bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-800 dark:text-gray-100 dark:border-gray-600",
label: "Staged Changes",
},
}
const variant = variants[eventType] || {
className: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100",
label: eventType,
}
return (
<Badge variant="secondary" className={variant.className}>
{variant.label}
</Badge>
)
}, [])
return (
<div className="py-8 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Review Optimizations</h1>
</div>
{/* Search and Filters */}
<div className="mb-6">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search by function name, file path, or repository name..."
value={filters.search}
onChange={e => updateFilter("search", e.target.value)}
className="pl-10 w-full"
/>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Filter className="h-4 w-4" />
<span className="hidden sm:inline">Filters:</span>
</div>
<Select
value={filters.repositoryId || "all"}
onValueChange={value =>
updateFilter(
"repositoryId",
value === "all" ? null : value === "none" ? "none" : value,
)
}
>
<SelectTrigger
className={`w-[180px] sm:w-[220px] ${
filters.repositoryId === null
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="All Repositories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Repositories</SelectItem>
<SelectItem value="none">Without Repository</SelectItem>
{availableRepositories.map(repo => (
<SelectItem key={repo.id} value={repo.id}>
{repo.full_name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.status} onValueChange={value => updateFilter("status", value)}>
<SelectTrigger
className={`w-[120px] sm:w-[150px] ${
filters.status === "all"
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Reviews</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.eventType}
onValueChange={value => updateFilter("eventType", value)}
>
<SelectTrigger
className={`w-[120px] sm:w-[150px] ${
filters.eventType === "all"
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="Event Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Status</SelectItem>
<SelectItem value="pr_created">PR Created</SelectItem>
<SelectItem value="pr_merged">PR Merged</SelectItem>
<SelectItem value="pr_closed">PR Closed</SelectItem>
<SelectItem value="no-pr">Staged Changes</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.reviewQuality}
onValueChange={value => updateFilter("reviewQuality", value)}
>
<SelectTrigger
className={`w-[120px] sm:w-[150px] ${
filters.reviewQuality === "all"
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="Quality" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Quality</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
<Select value={filters.sortBy} onValueChange={value => updateFilter("sortBy", value)}>
<SelectTrigger
className={`w-[140px] sm:w-[200px] ${
filters.sortBy === "created_at_desc"
? "text-muted-foreground [&>span]:text-muted-foreground"
: ""
}`}
>
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="created_at_desc">Newest</SelectItem>
<SelectItem value="created_at_asc">Oldest</SelectItem>
<SelectItem value="speedup_x_desc">Speedup (Highest)</SelectItem>
<SelectItem value="speedup_x_asc">Speedup (Lowest)</SelectItem>
<SelectItem value="review_quality_desc">Quality (High to Low)</SelectItem>
<SelectItem value="review_quality_asc">Quality (Low to High)</SelectItem>
</SelectContent>
</Select>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="text-muted-foreground hover:text-foreground"
>
Clear
</Button>
)}
</div>
</div>
{error && (
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/20">
<p className="text-destructive text-sm">Error: {error}</p>
<Button
variant="outline"
size="sm"
onClick={loadEvents}
className="mt-2"
disabled={isLoading}
>
Retry
</Button>
</div>
)}
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[25%]">FUNCTION / FILE</TableHead>
<TableHead className="w-[18%]">REPOSITORY</TableHead>
<TableHead className="text-center">REVIEW</TableHead>
<TableHead className="text-center">STATUS</TableHead>
<TableHead
className="text-center cursor-pointer hover:bg-muted/50"
onClick={() => toggleSort("review_quality")}
>
<div className="flex items-center justify-center gap-1">
<span>QUALITY</span>
{getSortIcon("review_quality")}
</div>
</TableHead>
<TableHead
className="text-center cursor-pointer hover:bg-muted/50"
onClick={() => toggleSort("speedup_x")}
>
<div className="flex items-center justify-center gap-1">
<span>SPEEDUP</span>
{getSortIcon("speedup_x")}
</div>
</TableHead>
<TableHead className="text-center">CHANGES</TableHead>
<TableHead
className="text-right cursor-pointer hover:bg-muted/50"
onClick={() => toggleSort("created_at")}
>
<div className="flex items-center justify-end gap-1">
<span>CREATED</span>
{getSortIcon("created_at")}
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton />
) : events.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-12">
<div className="mx-auto max-w-lg text-center bg-muted/5 border border-dashed border-border rounded-xl px-6 py-10">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-muted/30 mb-4">
<Zap className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-base sm:text-lg font-semibold mb-2">
No optimizations to review yet
</h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-3">
Run `codeflash --all with --staging-review` in a repository or install the VS
Code extension to trigger your first optimization review.
</p>
{hasActiveFilters ? (
<p className="text-xs sm:text-sm text-muted-foreground">
Filters are currently hiding resultsclear them to see everything.
</p>
) : (
<div className="flex flex-col sm:flex-row items-center justify-center gap-2 sm:gap-3 mt-4">
<a
href="/onboarding"
className="w-full sm:w-auto inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-xs sm:text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
View setup steps
</a>
<a
href="https://docs.codeflash.ai/editor-plugins/vscode"
target="_blank"
rel="noopener noreferrer"
className="w-full sm:w-auto inline-flex items-center justify-center rounded-lg border border-border px-4 py-2 text-xs sm:text-sm font-medium text-foreground hover:bg-muted/40 transition-colors"
>
Install VS Code extension
</a>
</div>
)}
</div>
</TableCell>
</TableRow>
) : (
events.map((event: OptimizationEvent) => {
let diffStats: { totalAdditions: number; totalDeletions: number } = {
totalAdditions: 0,
totalDeletions: 0,
}
if (
event.metadata &&
typeof event.metadata === "object" &&
event.metadata !== null &&
typeof event.metadata.diffContents === "object" &&
event.metadata.diffContents !== null
) {
const diffContentsRaw = event.metadata.diffContents
if (diffContentsRaw && typeof diffContentsRaw === "object") {
let valid = true
for (const value of Object.values(diffContentsRaw as object)) {
if (
!value ||
typeof value !== "object" ||
typeof (value as DiffContent).oldContent !== "string" ||
typeof (value as DiffContent).newContent !== "string"
) {
valid = false
break
}
}
if (valid) {
const diffContents = diffContentsRaw as Record<
string,
{ oldContent: string; newContent: string }
>
diffStats = calculateDiffStats(diffContents)
}
}
}
return (
<ClickableTableRow key={event.id} event={event} onRowClick={handleRowClick}>
<TableCell className="w-auto min-w-0">
<div className="flex items-start gap-3">
<FileCode2 className="h-4 w-4 text-muted-foreground mt-1 flex-shrink-0" />
<div className="flex-1 min-w-0 overflow-hidden">
<div className="font-mono text-sm font-medium truncate">
{event.function_name || "Unknown Function"}
</div>
<div className="text-xs text-muted-foreground truncate">
{event.file_path || "No file path"}
</div>
</div>
</div>
</TableCell>
<TableCell className="w-auto min-w-0">
<div className="flex items-center gap-3">
{event.repository ? (
<>
<div className="relative h-8 w-8 flex-shrink-0">
{event.repository.full_name && (
<Image
src={`https://github.com/${event.repository.full_name.split("/")[0]}.png`}
alt={event.repository.full_name}
fill
className="rounded-full object-cover"
onError={e => {
e.currentTarget.style.display = "none"
}}
/>
)}
</div>
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1">
<span className="text-sm font-medium truncate">
{event.repository.full_name || "Unknown Repository"}
</span>
</div>
</div>
</>
) : (
<>
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<Zap className="h-4 w-4 text-muted-foreground" />
</div>
<span className="text-sm text-muted-foreground">
Untracked repository
</span>
</>
)}
</div>
</TableCell>
<TableCell className="text-center">{getStatusBadge(event.status)}</TableCell>
<TableCell className="text-center">
{getEventTypeBadge(event.event_type)}
</TableCell>
<TableCell className="text-center">
<ReviewQualityBadge quality={event.review_quality} />
</TableCell>
<TableCell className="text-center">
{getSpeedupBadge(event.speedup_x, event.speedup_pct)}
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2 flex-wrap">
{diffStats.totalAdditions > 0 && (
<Badge
variant="secondary"
className="bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-400 dark:border-emerald-800 whitespace-nowrap font-medium"
>
+{diffStats.totalAdditions}
</Badge>
)}
{diffStats.totalDeletions > 0 && (
<Badge
variant="secondary"
className="bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-950/50 dark:text-rose-400 dark:border-rose-800 whitespace-nowrap font-medium"
>
-{diffStats.totalDeletions}
</Badge>
)}
{diffStats.totalAdditions === 0 && diffStats.totalDeletions === 0 && (
<span className="text-muted-foreground text-xs"></span>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Clock className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(event.created_at), {
addSuffix: true,
})}
</span>
</div>
</TableCell>
</ClickableTableRow>
)
})
)}
</TableBody>
</Table>
</div>
{!isLoading && totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-muted-foreground">
Showing {(filters.page - 1) * pageSize + 1} to{" "}
{Math.min(filters.page * pageSize, totalCount)} of {totalCount} events
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page === 1}
onClick={() => handlePageChange(filters.page - 1)}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (filters.page <= 3) {
pageNum = i + 1
} else if (filters.page >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = filters.page - 2 + i
}
return (
<Button
key={i}
variant={filters.page === pageNum ? "default" : "outline"}
size="sm"
className="w-8 h-8 p-0"
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</Button>
)
})}
{totalPages > 5 && filters.page < totalPages - 2 && <span className="px-2">...</span>}
{totalPages > 5 && filters.page < totalPages - 2 && (
<Button
variant="outline"
size="sm"
className="w-8 h-8 p-0"
onClick={() => handlePageChange(totalPages)}
>
{totalPages}
</Button>
)}
</div>
<Button
variant="outline"
size="sm"
disabled={filters.page === totalPages}
onClick={() => handlePageChange(filters.page + 1)}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}

View file

@ -0,0 +1,22 @@
"use client"
import { RefreshCw } from "lucide-react"
export default function ReviewOptimizationsError({ reset }: { error: Error; reset: () => void }) {
return (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">Something went wrong</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">
There was an error loading the review optimizations page.
</p>
<button
onClick={reset}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Try Again
</button>
</div>
</div>
)
}

View file

@ -0,0 +1,59 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function ReviewOptimizationsLoading() {
return (
<div className="py-8 px-4">
<div className="mb-8">
<Skeleton className="h-9 w-64 mb-2" />
</div>
{/* Filter bar skeleton */}
<div className="mb-6">
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-10 flex-1 min-w-[200px] max-w-md rounded-md" />
<Skeleton className="h-10 w-[180px] rounded-md" />
<Skeleton className="h-10 w-[120px] rounded-md" />
<Skeleton className="h-10 w-[120px] rounded-md" />
<Skeleton className="h-10 w-[120px] rounded-md" />
<Skeleton className="h-10 w-[140px] rounded-md" />
</div>
</div>
{/* Table skeleton */}
<div className="rounded-lg border bg-card">
<div className="p-4 border-b">
<div className="flex gap-4">
<Skeleton className="h-4 w-[25%]" />
<Skeleton className="h-4 w-[18%]" />
<Skeleton className="h-4 w-[10%]" />
<Skeleton className="h-4 w-[10%]" />
<Skeleton className="h-4 w-[8%]" />
<Skeleton className="h-4 w-[10%]" />
<Skeleton className="h-4 w-[8%]" />
<Skeleton className="h-4 w-[11%]" />
</div>
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="p-4 border-b last:border-0">
<div className="flex items-center gap-4">
<div className="w-[25%] space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
<div className="w-[18%] flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-6 w-[10%] rounded-full" />
<Skeleton className="h-6 w-[10%] rounded-full" />
<Skeleton className="h-6 w-[8%] rounded-full" />
<Skeleton className="h-6 w-[10%] rounded-full" />
<Skeleton className="h-6 w-[8%] rounded-full" />
<Skeleton className="h-4 w-[11%]" />
</div>
</div>
))}
</div>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { type JSX, useEffect } from "react"
import { usePostHog } from "posthog-js/react"
export default function PostHogPageView(): JSX.Element | null {

View file

@ -1,175 +0,0 @@
import {
type AfterCallbackAppRoute,
type AppRouteHandlerFnContext,
getSession,
handleAuth,
handleCallback,
handleLogin,
handleLogout,
type Session,
} from "@auth0/nextjs-auth0"
import { type NextRequest, NextResponse } from "next/server"
import { createOrUpdateUser, hasCompletedOnboarding } from "@codeflash-ai/common"
import { trackUserLogin } from "@/lib/analytics/tracking"
import { cookies } from "next/headers"
import { APP_ROUTES } from "@/lib/types"
//In case we want to change some future variables to set redirect to marketing campaign
const LOGOUT_REDIRECT_URL =
process.env.CODEFLASH_LOGOUT_REDIRECT_URL ??
process.env.CODEFLASH_MARKETING_URL ??
"https://codeflash.ai"
// THIS IS THE KEY CHANGE - Your afterCallback was empty!
const afterCallback: AfterCallbackAppRoute = async (req: NextRequest, session: Session) => {
if (!session.user) {
return session
}
const user = session.user
console.log(`[Auth] Processing login for user: ${user.sub}`)
if (!user.sub || !user.nickname) {
console.error("[Auth] Missing required user fields")
return session
}
try {
// 1. SAVE TO DATABASE (moved from login page!)
console.log("[Auth] Saving user to database...")
await createOrUpdateUser(user.sub, user.nickname, user.email ?? null, user.name ?? null)
console.log("[Auth] User saved successfully")
// 2. TRACK LOGIN (moved from login page!)
await trackUserLogin({
userId: user.sub,
username: user.nickname,
email: user.email,
name: user.name,
})
// 3. CHECK ONBOARDING (moved from login page!)
const completedOnboarding = await hasCompletedOnboarding(user.sub)
console.log(`[Auth] Onboarding completed: ${completedOnboarding}`)
// 4. Decide where to redirect - Auth0 preserves returnTo from login
let intendedDestination = APP_ROUTES.BASE
// Try to get returnTo from multiple sources
const url = new URL(req.url)
// Method 1: From URL search params (direct from Auth0 redirect)
const returnToParam = url.searchParams.get("returnTo")
if (returnToParam) {
intendedDestination = returnToParam
console.log(`[Auth] Found returnTo in URL params: ${intendedDestination}`)
}
// Method 2: From state parameter (fallback)
const stateParam = url.searchParams.get("state")
if (stateParam && !returnToParam) {
try {
const state = JSON.parse(Buffer.from(stateParam, "base64").toString("utf-8"))
if (state.returnTo) {
intendedDestination = state.returnTo
console.log(`[Auth] Found returnTo in state: ${intendedDestination}`)
}
} catch (e) {
console.warn("[Auth] Failed to parse state:", e)
}
}
// check if the path is codeflash/auth/[token]
const isAuthPath =
intendedDestination.startsWith("/codeflash/auth") ||
intendedDestination.includes("/codeflash/auth")
console.log(`[Auth] isAuthPath: ${isAuthPath}`)
// Handle onboarding redirect
if (!completedOnboarding && !isAuthPath) {
session.returnTo = "/onboarding"
} else {
session.returnTo = intendedDestination
}
} catch (error) {
console.error("[Auth] Error in afterCallback:", error)
// Don't fail login even if our processing fails
session.returnTo = APP_ROUTES.BASE
}
return session
}
// Rest of your file stays mostly the same...
export const GET = handleAuth({
// Fixed login handler to preserve returnTo parameter
login: async (request: any, response: any) => {
console.log("Logging in")
try {
const req = request as NextRequest
const url = new URL(req.url)
const returnTo = url.searchParams.get("returnTo") || APP_ROUTES.BASE
console.log(`[Auth] Login with returnTo: ${returnTo}`)
return await handleLogin(req, response as AppRouteHandlerFnContext, {
returnTo,
authorizationParams: {
scope: "openid profile email offline_access",
},
})
} catch (error) {
console.error("Error logging in:", error)
return NextResponse.json({ error: "Failed to initiate login" }, { status: 500 })
}
},
// Your existing logout handler...
logout: async (request: any, response: any) => {
console.log("Logging out")
try {
return await handleLogout(request as NextRequest, response as AppRouteHandlerFnContext, {
returnTo: LOGOUT_REDIRECT_URL,
})
} catch (error) {
console.error("Error logging out:", error)
return NextResponse.redirect(LOGOUT_REDIRECT_URL)
}
},
// Updated callback handler
callback: async (req: any, res: any) => {
try {
const response = (await handleCallback(req as NextRequest, res as AppRouteHandlerFnContext, {
afterCallback, // NOW THIS DOES SOMETHING!
})) as NextResponse
const session = await getSession(req as NextRequest, response)
if (session != null) {
// Use the returnTo set by afterCallback
const returnTo = session.returnTo || APP_ROUTES.BASE
const isAbsolute = returnTo.includes(process.env.AUTH0_BASE_URL ?? "")
const redirectUrl = isAbsolute ? returnTo : `${process.env.AUTH0_BASE_URL}${returnTo}`
return NextResponse.redirect(redirectUrl, response)
} else {
return NextResponse.redirect(`${process.env.AUTH0_BASE_URL}/waitlist`, response)
}
} catch (error: any) {
console.error("Error in callback:", error)
// Your existing error handling...
if (error.status === 400 && error.message.search("allowlist-fail") !== -1) {
const re = /allowlist-fail\s(.*)\s(.*)\)/
const match = error.message.match(re)
if (match != null) {
const userId = match[1]
const userNickname = match[2]
return NextResponse.redirect(
`${process.env.AUTH0_BASE_URL}/waitlist?username=${userNickname}&userid=${userId}`,
)
}
}
// If error doesn't match any specific case, return error page
return NextResponse.redirect(`${process.env.AUTH0_BASE_URL}/login?error=callback_failed`)
}
},
})

View file

@ -57,7 +57,7 @@ function summarizeToolResult(toolName: string, result: string): string {
}
case "get_errors": {
if (result === "No errors in this trace.") return "No errors"
const count = lines.filter((l) => l.startsWith("[")).length
const count = lines.filter(l => l.startsWith("[")).length
return `Found ${count} errors`
}
case "get_llm_call_detail":
@ -92,7 +92,7 @@ async function processToolCalls(
}
const results = await Promise.all(
toolUseBlocks.map(async (block) => {
toolUseBlocks.map(async block => {
const result = await resolveToolCall(
block.name,
(block.input as Record<string, unknown>) ?? {},
@ -120,10 +120,7 @@ async function processToolCalls(
return results
}
function baseParams(
systemPrompt: string,
conversationMessages: Anthropic.MessageParam[],
) {
function baseParams(systemPrompt: string, conversationMessages: Anthropic.MessageParam[]) {
return {
model: "claude-opus-4-6" as const,
max_tokens: 32_000,
@ -154,10 +151,7 @@ export async function POST(request: NextRequest): Promise<Response> {
const { traceId, messages } = body
if (!traceId || !messages?.length) {
return Response.json(
{ error: "traceId and messages are required" },
{ status: 400 },
)
return Response.json({ error: "traceId and messages are required" }, { status: 400 })
}
const tracePrefix = traceId.substring(0, 33)
@ -168,7 +162,7 @@ export async function POST(request: NextRequest): Promise<Response> {
const indexed = indexTraceData(traceData)
const systemPrompt = buildSummaryPrompt(indexed)
const conversationMessages: Anthropic.MessageParam[] = messages.map((m) => ({
const conversationMessages: Anthropic.MessageParam[] = messages.map(m => ({
role: m.role,
content: m.content,
}))
@ -183,21 +177,25 @@ export async function POST(request: NextRequest): Promise<Response> {
try {
let toolRounds = 0
let emittedText = false
// eslint-disable-next-line no-constant-condition
while (true) {
enqueue(`data: ${JSON.stringify({ type: "status", message: toolRounds === 0 ? "Thinking…" : "Analyzing…" })}\n\n`)
enqueue(
`data: ${JSON.stringify({ type: "status", message: toolRounds === 0 ? "Thinking…" : "Analyzing…" })}\n\n`,
)
// Redact thinking blocks from prior rounds (each can be 10-50KB)
for (const msg of conversationMessages) {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue
for (const block of msg.content) {
if ((block as { type: string }).type === "thinking") {
(block as { thinking: string }).thinking = ""
;(block as { thinking: string }).thinking = ""
}
}
}
const messageStream = client.messages.stream(baseParams(systemPrompt, conversationMessages))
const messageStream = client.messages.stream(
baseParams(systemPrompt, conversationMessages),
)
const timeout = setTimeout(() => messageStream.abort(), ROUND_TIMEOUT_MS)
let response: Anthropic.Message
try {
@ -230,7 +228,7 @@ export async function POST(request: NextRequest): Promise<Response> {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue
for (const block of msg.content) {
if ((block as { type: string }).type === "thinking") {
(block as { thinking: string }).thinking = ""
;(block as { thinking: string }).thinking = ""
}
}
}
@ -252,9 +250,12 @@ export async function POST(request: NextRequest): Promise<Response> {
enqueue("data: [DONE]\n\n")
} catch (err) {
const message = err instanceof Anthropic.APIError
? `API error: ${err.status} ${err.message}`
: err instanceof Error ? err.message : "Stream error"
const message =
err instanceof Anthropic.APIError
? `API error: ${err.status} ${err.message}`
: err instanceof Error
? err.message
: "Stream error"
enqueue(`data: ${JSON.stringify({ error: message })}\n\n`)
} finally {
clearInterval(keepalive)

View file

@ -3,7 +3,8 @@ import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export async function POST(request: NextRequest, { params }: { params: { trace_id: string } }) {
export async function POST(request: NextRequest, props: { params: Promise<{ trace_id: string }> }) {
const params = await props.params;
try {
const { trace_id } = params
const body = await request.json()

View file

@ -1,7 +1,9 @@
"use client"
import { getUserOrganizations } from "@/components/dashboard/action"
import { UserProfile } from "@auth0/nextjs-auth0/client"
import { setOrgCookie } from "./org-cookie-action"
import type { User as UserProfile } from "@auth0/nextjs-auth0/types"
import { useRouter } from "next/navigation"
import React, {
createContext,
useContext,
@ -44,6 +46,7 @@ export function ViewModeProvider({
children: React.ReactNode
user?: UserProfile
}) {
const router = useRouter()
const [mode, setMode] = useState<ViewMode>("personal")
const [loading, setIsLoading] = useState<boolean>(true)
const [orgs, setOrgs] = useState<Organization[]>([])
@ -67,6 +70,10 @@ export function ViewModeProvider({
const finalOrgs = fetchedOrgs || orgsRef.current
setLocalStorageMode(newMode, orgId)
// Sync org cookie so server components can read it
const cookieOrgId = newMode === "organization" && orgId ? orgId : null
await setOrgCookie(cookieOrgId)
if (newMode === "organization" && orgId) {
const org = finalOrgs.find(o => o.id === orgId)
if (org) {
@ -80,8 +87,11 @@ export function ViewModeProvider({
setMode("personal")
setCurrentOrg(null)
}
// Trigger server re-render so server components pick up the new cookie
router.refresh()
},
[user?.sub, setLocalStorageMode],
[user?.sub, setLocalStorageMode, router],
)
useEffect(() => {

View file

@ -4,7 +4,8 @@ import { redirect } from "next/navigation"
* Catch-all route for legacy /app/* URLs
* Redirects to the corresponding route without the /app prefix
*/
export default function LegacyAppCatchAll({ params }: { params: { slug: string[] } }) {
const newPath = `/${params.slug.join("/")}`
redirect(newPath)
export default async function LegacyAppCatchAll(props: { params: Promise<{ slug: string[] }> }) {
const params = await props.params;
const newPath = `/${params.slug.join("/")}`
redirect(newPath)
}

View file

@ -0,0 +1,17 @@
"use server"
import { cookies } from "next/headers"
export async function setOrgCookie(orgId: string | null) {
const cookieStore = await cookies()
if (orgId) {
cookieStore.set("currentOrganizationId", orgId, {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365, // 1 year
})
} else {
cookieStore.delete("currentOrganizationId")
}
}

View file

@ -0,0 +1,77 @@
"use client"
import { useCallback, useMemo, useRef, useState } from "react"
import { CalendarDays, ChevronDown } from "lucide-react"
import { useRouter, useSearchParams } from "next/navigation"
import { useOutsideClick } from "@/components/hooks/useOutsideClick"
export function YearSelector({ selectedYear }: { selectedYear: number }) {
const router = useRouter()
const searchParams = useSearchParams()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useOutsideClick(() => setIsOpen(false))
const currentYear = new Date().getFullYear()
const availableYears = useMemo(() => {
const baseYear = 2025
return Array.from(
{ length: Math.max(1, currentYear - baseYear + 1) },
(_, i) => baseYear + i,
).filter(year => year <= currentYear)
}, [currentYear])
const handleYearChange = useCallback(
(year: number) => {
setIsOpen(false)
const params = new URLSearchParams(searchParams.toString())
if (year === currentYear) {
params.delete("year")
} else {
params.set("year", String(year))
}
const query = params.toString()
router.push(query ? `?${query}` : "/dashboard", { scroll: false })
},
[router, searchParams, currentYear],
)
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-background border border-border rounded-md hover:border-primary/50 transition-colors"
disabled={availableYears.length <= 1}
>
<CalendarDays size={12} className="text-muted-foreground" />
<span>{selectedYear}</span>
{availableYears.length > 1 && (
<ChevronDown
size={12}
className={`transition-transform text-muted-foreground ${isOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{isOpen && availableYears.length > 1 && (
<div className="absolute right-0 z-10 mt-1 w-32 bg-card rounded-md shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
{availableYears.map(year => (
<button
key={year}
onClick={() => handleYearChange(year)}
className={`w-full px-3 py-1.5 text-left hover:bg-muted flex items-center ${selectedYear === year ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 h-4 mr-1.5 flex items-center justify-center">
{selectedYear === year && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
{year}
</button>
))}
</div>
</div>
)}
</div>
)
}

View file

@ -1,10 +1,10 @@
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import { ReactNode } from "react"
import { hasCompletedOnboarding } from "@codeflash-ai/common"
export default async function DashboardLayout({ children }: { children: ReactNode }) {
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null
const completedOnboarding = await hasCompletedOnboarding(session.user.sub)

View file

@ -0,0 +1,5 @@
import { DashboardSkeleton } from "@/components/dashboard/DashboardSkeleton"
export default function DashboardLoading() {
return <DashboardSkeleton />
}

View file

@ -1,270 +1,72 @@
"use client"
import React, { useState, useMemo, useEffect, useCallback, memo, useRef } from "react"
import {
Lock,
Globe,
RefreshCw,
Zap,
Gauge,
FolderGit2,
BookOpen,
CalendarDays,
ChevronDown,
} from "lucide-react"
import { getDashboardData, RepositoryWithUsage } from "./action"
import { getUserIdAndUsername } from "@/app/utils/auth"
import { format, subDays } from "date-fns"
import { Suspense } from "react"
import { Lock, Globe, Zap, Gauge, FolderGit2, BookOpen } from "lucide-react"
import { getDashboardData } from "./action"
import { getAccountContext } from "@/lib/server/get-account-context"
import { ActiveUsersLeaderboard } from "@/components/dashboard/ActiveUsersLeaderboard"
import { CompactPullRequestActivityCard } from "@/components/dashboard/CompactPullRequestActivityCard"
import { DashboardErrorBoundary } from "@/components/dashboard/DashboardErrorBoundary"
import { MetricCard } from "@/components/dashboard/MetricCard"
import { OptimizationPRsTable } from "@/components/dashboard/OptimizationPRsTable"
import { DashboardSkeleton } from "@/components/dashboard/DashboardSkeleton"
import { useViewMode } from "../app/ViewModeContext"
import { useOutsideClick } from "@/components/hooks/useOutsideClick"
import { AccountPayload } from "@codeflash-ai/common"
import { YearSelector } from "./_components/YearSelector"
import { format, subDays } from "date-fns"
const ErrorDisplay = memo(({ error, onRetry }: { error: string; onRetry: () => void }) => (
<div className="flex justify-center items-center h-[70vh]">
<div className="bg-red-50 text-red-800 p-6 sm:p-8 rounded-xl max-w-md border border-red-200">
<h3 className="text-base sm:text-lg font-medium mb-2 sm:mb-3">Unable to Load Dashboard</h3>
<p className="mb-3 sm:mb-4 text-sm sm:text-base">{error}</p>
<button
onClick={onRetry}
className="flex items-center gap-1 sm:gap-2 w-full justify-center px-3 sm:px-4 py-2 sm:py-2.5 bg-red-100 hover:bg-red-200 text-red-800 rounded-lg text-xs sm:text-sm font-medium transition-colors"
>
<RefreshCw size={14} className="sm:w-4 sm:h-4" /> Try Again
</button>
</div>
</div>
))
ErrorDisplay.displayName = "ErrorDisplay"
function getDateRangeDisplay(): string {
const now = new Date()
const last30DaysStart = subDays(now, 30)
const startMonth = format(last30DaysStart, "MMMM")
const endMonth = format(now, "MMMM")
const startYear = format(last30DaysStart, "yyyy")
const endYear = format(now, "yyyy")
interface OptimizationStats {
totalAttempts: number
successfulAttempts: number
activeReposLast30Days: number
if (startMonth === endMonth && startYear === endYear) {
return `${startMonth} ${format(last30DaysStart, "d")}-${format(now, "d")}, ${startYear}`
}
if (startYear === endYear) {
return `${format(last30DaysStart, "MMMM d")} - ${format(now, "MMMM d")}, ${startYear}`
}
return `${format(last30DaysStart, "MMMM d, yyyy")} - ${format(now, "MMMM d, yyyy")}`
}
interface PrActivityData {
month: string
pr_created: number
pr_merged: number
pr_closed: number
}
interface ActiveUserData {
username: string
eventCount: number
avatarUrl: string
}
function Dashboard() {
const { currentOrg } = useViewMode()
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ year?: string }>
}) {
const params = await searchParams
const currentYear = new Date().getFullYear()
const parsedYear = params.year ? parseInt(params.year, 10) : currentYear
const selectedYear = Number.isNaN(parsedYear) ? currentYear : parsedYear
const [repositories, setRepositories] = useState<RepositoryWithUsage[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const accountPayload = await getAccountContext()
const { stats, repos } = await getDashboardData(accountPayload, selectedYear)
const [optimizationStats, setOptimizationStats] = useState<OptimizationStats>({
totalAttempts: 0,
successfulAttempts: 0,
activeReposLast30Days: 0,
})
const repositories = Array.isArray(repos) ? repos : []
const privateRepos = repositories.filter(repo => repo?.is_private).length
const publicRepos = repositories.length - privateRepos
const totalRepos = repositories.length
const [prActivityData, setPrActivityData] = useState<PrActivityData[]>([])
const [selectedYear, setSelectedYear] = useState<number>(currentYear)
const [isYearDropdownOpen, setIsYearDropdownOpen] = useState(false)
const yearDropdownRef = useOutsideClick(() => setIsYearDropdownOpen(false))
const dateRangeDisplay = getDateRangeDisplay()
const [activeUsersData, setActiveUsersData] = useState<ActiveUserData[]>([])
const [optimizationsTrend, setOptimizationsTrend] = useState<number[]>([])
const [optimizationsTrendDates, setOptimizationsTrendDates] = useState<string[]>([])
const [successfulOptimizationsTrend, setSuccessfulOptimizationsTrend] = useState<number[]>([])
const [successfulOptimizationsTrendDates, setSuccessfulOptimizationsTrendDates] = useState<
string[]
>([])
const [accountPayload, setAccountPayload] = useState<AccountPayload | null>(null)
const [isMobile, setIsMobile] = useState<boolean>(false)
const dateValues = useMemo(() => {
const now = new Date()
const last30DaysStart = subDays(now, 30)
const startMonth = format(last30DaysStart, "MMMM")
const endMonth = format(now, "MMMM")
const startYear = format(last30DaysStart, "yyyy")
const endYear = format(now, "yyyy")
function getDateRangeDisplay(): string {
if (startMonth === endMonth && startYear === endYear) {
return `${startMonth} ${format(last30DaysStart, "d")}-${format(now, "d")}, ${startYear}`
}
if (startYear === endYear) {
return `${format(last30DaysStart, "MMMM d")} - ${format(now, "MMMM d")}, ${startYear}`
}
return `${format(last30DaysStart, "MMMM d, yyyy")} - ${format(now, "MMMM d, yyyy")}`
}
return { now, last30DaysStart, dateRangeDisplay: getDateRangeDisplay() }
}, [])
const repoCounts = useMemo(() => {
if (!Array.isArray(repositories) || repositories.length === 0) {
return { privateRepos: 0, publicRepos: 0, totalRepos: 0 }
}
const privateRepos = repositories.filter(repo => repo?.is_private).length
const publicRepos = repositories.length - privateRepos
return { privateRepos, publicRepos, totalRepos: repositories.length }
}, [repositories])
const availableYears = useMemo(() => {
const baseYear = 2025
return Array.from(
{ length: Math.max(1, currentYear - baseYear + 1) },
(_, i) => baseYear + i,
).filter(year => year <= currentYear)
}, [currentYear])
useEffect(() => {
let timeoutId: NodeJS.Timeout
const handleResize = () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => setIsMobile(window.innerWidth < 640), 150)
}
if (typeof window !== "undefined") {
setIsMobile(window.innerWidth < 640)
window.addEventListener("resize", handleResize)
return () => {
clearTimeout(timeoutId)
window.removeEventListener("resize", handleResize)
}
}
}, [])
const currentOrgId = currentOrg?.id
const fetchingRef = useRef(false)
const fetchDashboardData = useCallback(async () => {
if (fetchingRef.current) return
fetchingRef.current = true
try {
setLoading(true)
setError(null)
const currentUser = await getUserIdAndUsername()
if (!currentUser?.userId || !currentUser?.username) {
throw new Error("User authentication data not found")
}
const payload: AccountPayload = currentOrgId
? { orgId: currentOrgId }
: { userId: currentUser.userId, username: currentUser.username }
// Store payload for the PR table component
setAccountPayload(payload)
const { stats, repos } = await getDashboardData(payload, selectedYear)
setRepositories(Array.isArray(repos) ? repos : [])
setOptimizationStats({
totalAttempts: stats.optimizations.total,
successfulAttempts: stats.optimizations.successful,
activeReposLast30Days: stats.activeReposLast30Days.length,
})
const optimizationValues = stats.optimizations.timeSeries.map(item => item.count)
const optimizationDates = stats.optimizations.timeSeries.map(item => item.date)
setOptimizationsTrend(optimizationValues)
setOptimizationsTrendDates(optimizationDates)
const successfulValues = stats.optimizations.successfulTimeSeries.map(item => item.count)
const successfulDates = stats.optimizations.successfulTimeSeries.map(item => item.date)
setSuccessfulOptimizationsTrend(successfulValues)
setSuccessfulOptimizationsTrendDates(successfulDates)
setPrActivityData(stats.pullRequests)
setActiveUsersData(stats.activeUsersLast30Days)
} catch (err) {
console.error("Dashboard data fetch error:", err)
setError("Failed to load dashboard data. Please try again later.")
setRepositories([])
setPrActivityData([])
setActiveUsersData([])
setOptimizationsTrend([])
setOptimizationsTrendDates([])
setSuccessfulOptimizationsTrend([])
setSuccessfulOptimizationsTrendDates([])
} finally {
setLoading(false)
fetchingRef.current = false
}
}, [selectedYear, currentOrgId])
useEffect(() => {
fetchDashboardData()
}, [fetchDashboardData])
const handleYearChange = useCallback((year: number) => {
setSelectedYear(year)
setIsYearDropdownOpen(false)
}, [])
if (loading) return <DashboardSkeleton />
if (error) return <ErrorDisplay error={error} onRetry={fetchDashboardData} />
const optimizationsTrend = stats.optimizations.timeSeries.map(item => item.count)
const optimizationsTrendDates = stats.optimizations.timeSeries.map(item => item.date)
const successfulOptimizationsTrend = stats.optimizations.successfulTimeSeries.map(
item => item.count,
)
const successfulOptimizationsTrendDates = stats.optimizations.successfulTimeSeries.map(
item => item.date,
)
return (
<div className="min-h-screen pb-8 py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
<div className="mb-6 sm:mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-xl sm:text-2xl font-bold">Dashboard</h1>
<div className="relative" ref={yearDropdownRef}>
<button
onClick={() => setIsYearDropdownOpen(!isYearDropdownOpen)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-background border border-border rounded-md hover:border-primary/50 transition-colors"
disabled={availableYears.length <= 1}
>
<CalendarDays size={12} className="text-muted-foreground" />
<span>{selectedYear}</span>
{availableYears.length > 1 && (
<ChevronDown
size={12}
className={`transition-transform text-muted-foreground ${isYearDropdownOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{isYearDropdownOpen && availableYears.length > 1 && (
<div className="absolute right-0 z-10 mt-1 w-32 bg-card rounded-md shadow-lg overflow-hidden border border-border animate-in fade-in-50 slide-in-from-top-5">
<div className="py-1">
{availableYears.map(year => (
<button
key={year}
onClick={() => handleYearChange(year)}
className={`w-full px-3 py-1.5 text-left hover:bg-muted flex items-center ${selectedYear === year ? "bg-primary/10 text-primary font-medium" : ""}`}
>
<span className="w-4 h-4 mr-1.5 flex items-center justify-center">
{selectedYear === year && (
<span className="w-1.5 h-1.5 rounded-full bg-primary"></span>
)}
</span>
{year}
</button>
))}
</div>
</div>
)}
</div>
<Suspense>
<YearSelector selectedYear={selectedYear} />
</Suspense>
</div>
</div>
{repoCounts.totalRepos === 0 && (
{totalRepos === 0 && (
<div className="mb-6 sm:mb-8">
<div className="rounded-xl border border-dashed border-border bg-muted/10 px-5 py-4 sm:px-6 sm:py-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
@ -298,19 +100,17 @@ function Dashboard() {
</div>
)}
{/* Optimization PRs Table - Positioned at the top */}
{accountPayload && (
<div className="mb-6 sm:mb-8">
<OptimizationPRsTable payload={accountPayload} />
</div>
)}
{/* Optimization PRs Table */}
<div className="mb-6 sm:mb-8">
<OptimizationPRsTable payload={accountPayload} />
</div>
<div className="grid grid-cols-1 gap-3 sm:gap-5 mb-6 sm:mb-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-5">
<MetricCard
title="Optimization Attempts"
value={optimizationStats.totalAttempts}
icon={<Zap size={isMobile ? 16 : 20} />}
value={stats.optimizations.total}
icon={<Zap />}
gradientFrom="bg-gradient-to-br from-blue-500/20"
gradientTo="to-blue-600/20"
iconColor="text-blue-500"
@ -324,9 +124,9 @@ function Dashboard() {
/>
<MetricCard
title="Optimizations Found"
value={optimizationStats.successfulAttempts}
value={stats.optimizations.successful}
subtitle=""
icon={<Gauge size={isMobile ? 16 : 20} />}
icon={<Gauge />}
gradientFrom="bg-gradient-to-br from-emerald-500/20"
gradientTo="to-emerald-600/20"
iconColor="text-emerald-500"
@ -344,8 +144,8 @@ function Dashboard() {
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3 sm:gap-5">
<MetricCard
title="Total Repositories"
value={repoCounts.totalRepos}
icon={<BookOpen size={isMobile ? 16 : 20} />}
value={totalRepos}
icon={<BookOpen />}
gradientFrom="bg-gradient-to-br from-blue-500/20"
gradientTo="to-blue-600/20"
iconColor="text-blue-500"
@ -355,20 +155,20 @@ function Dashboard() {
<MetricCard
title="Active Repositories"
value={optimizationStats.activeReposLast30Days}
value={stats.activeReposLast30Days.length}
subtitle="last 30 days"
icon={<FolderGit2 size={isMobile ? 16 : 20} />}
icon={<FolderGit2 />}
gradientFrom="bg-gradient-to-br from-purple-500/20"
gradientTo="to-purple-600/20"
iconColor="text-purple-500"
timeText={dateValues.dateRangeDisplay}
timeText={dateRangeDisplay}
showChart={false}
/>
<MetricCard
title="Private Repositories"
value={repoCounts.privateRepos}
icon={<Lock size={isMobile ? 16 : 20} />}
value={privateRepos}
icon={<Lock />}
gradientFrom="bg-gradient-to-br from-amber-500/20"
gradientTo="to-amber-600/20"
iconColor="text-amber-500"
@ -378,8 +178,8 @@ function Dashboard() {
<MetricCard
title="Public Repositories"
value={repoCounts.publicRepos}
icon={<Globe size={isMobile ? 16 : 20} />}
value={publicRepos}
icon={<Globe />}
gradientFrom="bg-gradient-to-br from-violet-500/20"
gradientTo="to-violet-600/20"
iconColor="text-violet-500"
@ -391,27 +191,15 @@ function Dashboard() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-5 mb-6 sm:mb-8 h-96 md:h-[500px]">
<CompactPullRequestActivityCard
prData={prActivityData}
prData={stats.pullRequests}
selectedYear={selectedYear}
onYearChange={handleYearChange}
className="h-full"
/>
<div className="h-full">
<ActiveUsersLeaderboard leaderboardData={activeUsersData} />
<ActiveUsersLeaderboard leaderboardData={stats.activeUsersLast30Days} />
</div>
</div>
</div>
)
}
const MemoizedDashboard = memo(Dashboard)
MemoizedDashboard.displayName = "Dashboard"
export default function DashboardWrapper() {
return (
<DashboardErrorBoundary>
<MemoizedDashboard />
</DashboardErrorBoundary>
)
}

View file

@ -1,10 +1,10 @@
/* Scoped observability theme - only affects pages wrapped with .obs-v2 */
@import "../styles/obs-theme.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Scoped observability theme - only affects pages wrapped with .obs-v2 */
@import "../styles/obs-theme.css";
@layer base {
:root {
/* Background and foreground */

View file

@ -1,23 +1,20 @@
import { type JSX } from "react"
import type { Metadata } from "next"
import { Inter as FontSans, JetBrains_Mono } from "next/font/google"
import "./globals.css"
import { cn } from "@/lib/utils"
import { ThemeProvider } from "@/components/theme-provider"
import { UserProvider } from "@auth0/nextjs-auth0/client"
import { Auth0Provider } from "@auth0/nextjs-auth0"
import { Toaster } from "@/components/ui/toaster"
import { Toaster as SonnerToaster } from "sonner"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import Script from "next/script"
import { PHProvider } from "./providers"
import dynamic from "next/dynamic"
import PostHogPageView from "./PostHogPageView"
import { ViewModeProvider } from "./app/ViewModeContext"
import { PrivacyModeProvider } from "./app/PrivacyModeContext"
import { ConditionalLayout } from "@/components/conditional-layout"
const PostHogPageView = dynamic(async () => await import("./PostHogPageView"), {
ssr: false,
})
const fontSans = FontSans({
subsets: ["latin"],
variable: "--font-sans",
@ -40,7 +37,7 @@ export default async function RootLayout({
}: {
children: React.ReactNode
}): Promise<JSX.Element> {
const session = await getSession()
const session = await auth0.getSession()
let intercomSnippet: string = `var APP_ID = "ljxo1nzr";
(function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){ic('reattach_activator');ic('update',w.intercomSettings);}else{var d=document;var i=function(){i.c(arguments);};i.q=[];i.c=function(args){i.q.push(args);};w.Intercom=i;var l=function(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='https://widget.intercom.io/widget/' + APP_ID;var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s, x);};if(document.readyState==='complete'){l();}else if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}}})();
`
@ -100,7 +97,7 @@ export default async function RootLayout({
</head>
<body className={cn("min-h-screen bg-background font-sans antialiased", fontSans.variable, jetbrainsMono.variable)}>
<PostHogPageView />
<UserProvider>
<Auth0Provider>
<ThemeProvider
attribute="class"
defaultTheme="system"
@ -115,7 +112,7 @@ export default async function RootLayout({
<Toaster />
<SonnerToaster position="top-right" richColors />
</ThemeProvider>
</UserProvider>
</Auth0Provider>
</body>
</PHProvider>
</html>

View file

@ -1,6 +1,6 @@
"use client"
import { memo } from "react"
import React, { memo } from "react"
import {
Clock,
CheckCircle2,
@ -14,7 +14,7 @@ import { CandidateContent } from "./candidate-content"
import { RankingContent, SummaryContent } from "./ranking-content"
import type { TimelineSection } from "./timeline-types"
function getStatusIcon(status: string): JSX.Element {
function getStatusIcon(status: string): React.JSX.Element {
switch (status) {
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500" />

View file

@ -16,19 +16,21 @@ import { CopyButton } from "@/components/observability/copy-button"
import { ParsedResponseView } from "@/components/observability/parsed-response-view"
interface LLMCallDetailPageProps {
params: {
params: Promise<{
id: string
}
}>
}
export async function generateMetadata({ params }: LLMCallDetailPageProps): Promise<Metadata> {
export async function generateMetadata(props: LLMCallDetailPageProps): Promise<Metadata> {
const params = await props.params;
return {
title: `LLM Call ${params.id.substring(0, 8)} - Observability`,
description: "View LLM call details for prompt engineering analysis",
}
}
export default async function LLMCallDetailPage({ params }: LLMCallDetailPageProps) {
export default async function LLMCallDetailPage(props: LLMCallDetailPageProps) {
const params = await props.params;
// Fetch LLM call details
const llmCall = await prisma.llm_calls.findUnique({
where: { id: params.id },

View file

@ -69,7 +69,8 @@ const getModels = unstable_cache(
{ revalidate: 300 }, // 5 minutes
)
export default async function LLMCallsPage({ searchParams }: { searchParams: SearchParams }) {
export default async function LLMCallsPage(props: { searchParams: Promise<SearchParams> }) {
const searchParams = await props.searchParams;
try {
const page = parseInt(searchParams.page || "1")
const pageSize = 50

View file

@ -20,12 +20,13 @@ import { CopyButton } from "@/components/observability/copy-button"
export const revalidate = 60
interface TracePageProps {
params: {
params: Promise<{
trace_id: string
}
}>
}
export default async function TracePage({ params }: TracePageProps) {
export default async function TracePage(props: TracePageProps) {
const params = await props.params
const { trace_id } = params
// Use prefix matching (first 33 chars) to group multi-model calls that share the same base trace_id

View file

@ -79,7 +79,8 @@ const getTotalTracesCount = unstable_cache(
{ revalidate: 30 },
)
export default async function TracesPage({ searchParams }: { searchParams: SearchParams }) {
export default async function TracesPage(props: { searchParams: Promise<SearchParams> }) {
const searchParams = await props.searchParams;
try {
const page = parseInt(searchParams.page || "1")
const pageSize = 50

View file

@ -1,10 +1,10 @@
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import { hasCompletedOnboarding } from "@codeflash-ai/common"
import { APP_ROUTES } from "@/lib/types"
export default async function RootPage() {
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null
const completedOnboarding = await hasCompletedOnboarding(session.user.sub)

View file

@ -1,4 +1,5 @@
"use client"
import { type JSX } from "react"
import posthog from "posthog-js"
import { PostHogProvider } from "posthog-js/react"

View file

@ -1,6 +1,6 @@
// cf-webapp/src/app/subscribe/pro/page.tsx
"use server"
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { redirect } from "next/navigation"
import { createCheckoutSession } from "@codeflash-ai/common"
import * as Sentry from "@sentry/nextjs"
@ -8,9 +8,10 @@ import * as Sentry from "@sentry/nextjs"
interface SearchParams {
period?: "monthly" | "yearly"
}
export default async function ProSubscribePage({ searchParams }: { searchParams: SearchParams }) {
export default async function ProSubscribePage(props: { searchParams: Promise<SearchParams> }) {
const searchParams = await props.searchParams;
const period: "monthly" | "yearly" = searchParams.period || "monthly"
const session = await getSession()
const session = await auth0.getSession()
if (!session) return null

View file

@ -2,19 +2,20 @@ import { PrismaClient } from "@prisma/client"
import { notFound } from "next/navigation"
import Link from "next/link"
import { ExperimentMetadata } from "@/lib/types" // Your defined types
import MonacoDiffViewer from "@/components/trace/monaco-diff-viewer" // The client component
import MonacoDiffViewer from "@/components/trace/monaco-diff-viewer"
import { Metadata } from "next" // For Next.js metadata API
import { getSession } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { isTeamMember } from "@/app/utils/auth"
interface TraceDetailsPageProps {
params: {
params: Promise<{
trace_id: string
}
}>
}
const prisma = new PrismaClient()
// Function to generate dynamic metadata (e.g., page title)
export async function generateMetadata({ params }: TraceDetailsPageProps): Promise<Metadata> {
export async function generateMetadata(props: TraceDetailsPageProps): Promise<Metadata> {
const params = await props.params
const { trace_id } = params
// Optionally fetch minimal data for title generation to avoid over-fetching
@ -57,7 +58,8 @@ export async function generateMetadata({ params }: TraceDetailsPageProps): Promi
}
// The main page component
export default async function TraceDetailsPage({ params }: TraceDetailsPageProps) {
export default async function TraceDetailsPage(props: TraceDetailsPageProps) {
const params = await props.params
const { trace_id } = params
if (!trace_id) {
@ -65,7 +67,7 @@ export default async function TraceDetailsPage({ params }: TraceDetailsPageProps
notFound()
}
const session = await getSession()
const session = await auth0.getSession()
if (!session?.user) return null
// Check team member access - only team members can view traces

View file

@ -1,11 +1,11 @@
"use server"
import { getSession, Session } from "@auth0/nextjs-auth0"
import { auth0 } from "@/lib/auth0"
import { cache } from "react"
import { isTeamMemberCheck } from "@/lib/team-members"
const getCachedSession = cache(async (): Promise<Session | null | undefined> => {
return getSession()
const getCachedSession = cache(async () => {
return auth0.getSession()
})
export async function getUserId(): Promise<string | null> {
@ -46,7 +46,7 @@ export async function requireTeamMember(): Promise<void> {
}
}
export const getAuthenticatedTeamSession = cache(async (): Promise<Session | null> => {
export const getAuthenticatedTeamSession = cache(async () => {
const session = await getCachedSession()
if (!session?.user) {

View file

@ -1,7 +1,7 @@
// components/Editor/monaco-diff-editor-github.tsx
"use client"
import React, { useState, useEffect, useRef, useCallback } from "react"
import React, { type JSX, useState, useEffect, useRef, useCallback } from "react"
import { Editor, DiffEditor } from "@monaco-editor/react"
import type { editor } from "monaco-editor"
import ReactMarkdown from "react-markdown"

View file

@ -7,6 +7,7 @@ import { Breadcrumb } from "./dashboard/bread-crumb"
import { AnnouncementTicker } from "@/components/announcements/announcement-ticker"
import { TOP_BAR_ANNOUNCEMENT } from "@/config/announcements"
import { cn } from "@/lib/utils"
import type { User } from "@auth0/nextjs-auth0/types"
const HIDDEN_PAGES = ["/onboarding", "/codeflash/auth", "/login", "/codeflash/auth/callback"]
@ -15,7 +16,7 @@ export function ConditionalLayout({
user,
}: {
children: React.ReactNode
user?: { sub?: string } | null
user?: User | null
}) {
const pathname = usePathname()
const [isAnnouncementVisible, setIsAnnouncementVisible] = useState(true)

View file

@ -1,6 +1,3 @@
"use client"
import React from "react"
import Image from "next/image"
import { Users, Crown } from "lucide-react"
@ -9,10 +6,10 @@ interface ActiveUsersLeaderboardProps {
className?: string
}
export const ActiveUsersLeaderboard: React.FC<ActiveUsersLeaderboardProps> = ({
export function ActiveUsersLeaderboard({
leaderboardData,
className,
}) => {
}: ActiveUsersLeaderboardProps) {
const safeLeaderboardData = Array.isArray(leaderboardData) ? leaderboardData : []
const needsScrollbar = safeLeaderboardData.length > 12

View file

@ -9,9 +9,19 @@ import {
GitPullRequestClosed,
GitPullRequest,
} from "lucide-react"
import { PullRequestActivityBarChart } from "./PullRequestActivityBarChart"
import { useRouter, useSearchParams } from "next/navigation"
import dynamic from "next/dynamic"
import { Skeleton } from "@/components/ui/skeleton"
import { useOutsideClick } from "../hooks/useOutsideClick"
const PullRequestActivityBarChart = dynamic(
() => import("./PullRequestActivityBarChart").then(mod => mod.PullRequestActivityBarChart),
{
ssr: false,
loading: () => <Skeleton className="h-full w-full rounded-md" />,
},
)
interface CompactPullRequestActivityCardProps {
prData: Array<{
month: string
@ -20,7 +30,7 @@ interface CompactPullRequestActivityCardProps {
pr_closed: number
}>
selectedYear: number
onYearChange: (year: number) => void
onYearChange?: (year: number) => void
className?: string
}
@ -30,7 +40,19 @@ export const CompactPullRequestActivityCard: React.FC<CompactPullRequestActivity
onYearChange,
className,
}) => {
const router = useRouter()
const searchParams = useSearchParams()
const [isYearDropdownOpen, setIsYearDropdownOpen] = useState(false)
const handleYearChange = (year: number) => {
if (onYearChange) {
onYearChange(year)
} else {
const params = new URLSearchParams(searchParams.toString())
params.set("year", String(year))
router.push(`?${params.toString()}`)
}
}
const yearDropdownRef = useOutsideClick(() => setIsYearDropdownOpen(false))
const safePrData = Array.isArray(prData) ? prData : []
@ -82,7 +104,7 @@ export const CompactPullRequestActivityCard: React.FC<CompactPullRequestActivity
<button
key={year}
onClick={() => {
onYearChange(year)
handleYearChange(year)
setIsYearDropdownOpen(false)
}}
className={`w-full px-3 py-1.5 text-left hover:bg-muted flex items-center ${selectedYear === year ? "bg-primary/10 text-primary font-medium" : ""}`}

View file

@ -1,13 +1,11 @@
"use client"
import React from "react"
import type { FC } from "react"
import { Skeleton } from "@/components/ui/skeleton"
/**
* Skeleton loader for MetricCard component
* Mimics the structure of the actual MetricCard with icon, title, value, and optional chart
*/
const MetricCardSkeleton: React.FC<{ showChart?: boolean }> = ({ showChart = true }) => (
const MetricCardSkeleton: FC<{ showChart?: boolean }> = ({ showChart = true }) => (
<div className="bg-card rounded-xl border border-border p-4 h-full">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
@ -38,7 +36,7 @@ const MetricCardSkeleton: React.FC<{ showChart?: boolean }> = ({ showChart = tru
* Skeleton loader for Pull Request Activity Card
* Mimics the PR activity chart card structure
*/
const PullRequestActivityCardSkeleton: React.FC = () => (
const PullRequestActivityCardSkeleton: FC = () => (
<div className="bg-card rounded-xl border border-border p-4 h-full flex flex-col">
<div className="flex items-center justify-between mb-3">
<div>
@ -72,7 +70,7 @@ const PullRequestActivityCardSkeleton: React.FC = () => (
* Skeleton loader for Active Users Leaderboard
* Mimics the leaderboard structure with user rows
*/
const ActiveUsersLeaderboardSkeleton: React.FC = () => (
const ActiveUsersLeaderboardSkeleton: FC = () => (
<div className="bg-card rounded-xl border border-border p-4 h-full flex flex-col">
<div className="mb-3">
<Skeleton className="h-5 w-40 mb-1" />
@ -105,7 +103,7 @@ const ActiveUsersLeaderboardSkeleton: React.FC = () => (
* Displays skeleton placeholders matching the full dashboard layout
* Used while dashboard data is being fetched
*/
export const DashboardSkeleton: React.FC = () => {
export const DashboardSkeleton: FC = () => {
return (
<div className="h-screen py-6 sm:py-8 px-4 sm:px-6 max-w-[1400px] mx-auto">
{/* Header skeleton */}

View file

@ -1,7 +1,13 @@
"use client"
import React, { useMemo } from "react"
import { SparkLineChart } from "./SparkLineChart"
import { useMemo } from "react"
import dynamic from "next/dynamic"
import { Skeleton } from "@/components/ui/skeleton"
const SparkLineChart = dynamic(() => import("./SparkLineChart").then(mod => mod.SparkLineChart), {
ssr: false,
loading: () => <Skeleton className="h-[60px] w-full rounded-md" />,
})
interface MetricCardProps {
title: string
@ -70,11 +76,9 @@ export const MetricCard: React.FC<MetricCardProps> = ({
</div>
</div>
<div
className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl ${gradientFrom} ${gradientTo} flex items-center justify-center ${iconColor}`}
className={`w-8 h-8 sm:w-10 sm:h-10 rounded-lg sm:rounded-xl ${gradientFrom} ${gradientTo} flex items-center justify-center ${iconColor} [&_svg]:w-4 [&_svg]:h-4 sm:[&_svg]:w-5 sm:[&_svg]:h-5`}
>
{React.cloneElement(icon as React.ReactElement, {
size: typeof window !== "undefined" && window.innerWidth < 640 ? 16 : 20,
})}
{icon}
</div>
</div>

View file

@ -27,7 +27,7 @@ import {
Zap,
BarChart3,
} from "lucide-react"
import { UserProfile } from "@auth0/nextjs-auth0/client"
import type { User as UserProfile } from "@auth0/nextjs-auth0/types"
import { SignOut } from "../ui/SignOut"
import { useViewMode } from "@/app/app/ViewModeContext"
import { usePrivacyMode } from "@/app/app/PrivacyModeContext"

View file

@ -3,7 +3,7 @@
import * as React from "react"
import { type JSX, useEffect, useState } from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
import { type ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps): JSX.Element {
const [isClient, setIsClient] = useState(false)

View file

@ -6,8 +6,8 @@ import { type JSX } from "react"
export function SignOut(): JSX.Element {
return (
/* eslint-disable-next-line @next/next/no-html-link-for-pages */
<a href="/api/auth/logout">
<a href="/auth/logout">
<Button
variant="ghost"
className="w-full justify-start text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"

View file

@ -26,7 +26,7 @@ export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}

View file

@ -21,14 +21,14 @@ interface FormFieldContextValue<
> {
name: TName
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
@ -37,7 +37,7 @@ const FormField = <
)
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
@ -45,7 +45,7 @@ const useFormField = () => {
const fieldState = getFieldState(fieldContext.name, formState)
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
@ -65,7 +65,7 @@ const useFormField = () => {
interface FormItemContextValue {
id: string
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue)
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
@ -142,7 +142,7 @@ const FormMessage = React.forwardRef<
const { error, formMessageId } = useFormField()
const body = error != null ? String(error?.message) : children
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (!body) {
return null
}

View file

@ -63,7 +63,7 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
// eslint-disable-next-line react/prop-types
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content

View file

@ -8,7 +8,7 @@ import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
// eslint-disable-next-line react/prop-types
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}

View file

@ -135,7 +135,7 @@ function dispatch(action: Action): void {
type Toast = Omit<ToasterToast, "id">
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
function toast({ ...props }: Toast) {
const id = genId()

View file

@ -1,6 +1,10 @@
import * as Sentry from "@sentry/nextjs"
export function register() {
// Sentry initialization is now handled by dedicated config files:
// - sentry.server.config.ts for server-side
// - sentry.client.config.ts for client-side
// This prevents duplicate initialization issues with Sentry v9
}
export const onRequestError = Sentry.captureRequestError

View file

@ -0,0 +1,119 @@
import { Auth0Client } from "@auth0/nextjs-auth0/server"
import { createOrUpdateUser, hasCompletedOnboarding } from "@codeflash-ai/common"
import { trackUserLogin } from "@/lib/analytics/tracking"
import { APP_ROUTES } from "@/lib/types"
import { NextResponse } from "next/server"
const LOGOUT_REDIRECT_URL =
process.env.CODEFLASH_LOGOUT_REDIRECT_URL ??
process.env.CODEFLASH_MARKETING_URL ??
"https://codeflash.ai"
function redirectTo(path: string): NextResponse {
const baseUrl = process.env.APP_BASE_URL || process.env.AUTH0_BASE_URL || "http://localhost:3000"
return NextResponse.redirect(new URL(path, baseUrl))
}
// Auth0 v4 expects AUTH0_DOMAIN; derive from v3's AUTH0_ISSUER_BASE_URL if needed
const auth0Domain =
process.env.AUTH0_DOMAIN ??
process.env.AUTH0_ISSUER_BASE_URL?.replace(/^https?:\/\//, "")
export const auth0 = new Auth0Client({
domain: auth0Domain,
authorizationParameters: {
scope: "openid profile email offline_access",
},
signInReturnToPath: APP_ROUTES.BASE,
async beforeSessionSaved(session, idToken) {
// Decode the ID token to get claims like nickname, name, picture
if (idToken) {
try {
const payload = JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString())
return {
...session,
user: {
...session.user,
nickname: payload.nickname ?? session.user.nickname,
name: payload.name ?? session.user.name,
picture: payload.picture ?? session.user.picture,
},
}
} catch {
// If decoding fails, return session as-is
}
}
return session
},
async onCallback(error, context, session) {
if (error) {
console.error("[Auth] Error in callback:", error)
const errorMessage = error.message || ""
if (errorMessage.includes("allowlist-fail")) {
const re = /allowlist-fail\s(.*)\s(.*)\)/
const match = errorMessage.match(re)
if (match != null) {
const userId = match[1]
const userNickname = match[2]
return redirectTo(`/waitlist?username=${userNickname}&userid=${userId}`)
}
}
return redirectTo("/login?error=callback_failed")
}
if (!session) {
return redirectTo("/waitlist")
}
const user = session.user
console.log(`[Auth] Processing login for user: ${user.sub}`)
if (!user.sub || !user.nickname) {
console.error("[Auth] Missing required user fields")
return redirectTo(context.returnTo || APP_ROUTES.BASE)
}
try {
// Save user to database
console.log("[Auth] Saving user to database...")
await createOrUpdateUser(user.sub, user.nickname, user.email ?? null, user.name ?? null)
console.log("[Auth] User saved successfully")
// Track login
await trackUserLogin({
userId: user.sub,
username: user.nickname,
email: user.email,
name: user.name,
})
// Check onboarding
const completedOnboarding = await hasCompletedOnboarding(user.sub)
console.log(`[Auth] Onboarding completed: ${completedOnboarding}`)
const intendedDestination = context.returnTo || APP_ROUTES.BASE
// Check if the path is codeflash/auth/[token]
const isAuthPath =
intendedDestination.startsWith("/codeflash/auth") ||
intendedDestination.includes("/codeflash/auth")
if (!completedOnboarding && !isAuthPath) {
return redirectTo("/onboarding")
}
return redirectTo(intendedDestination)
} catch (err) {
console.error("[Auth] Error in onCallback:", err)
return redirectTo(context.returnTo || APP_ROUTES.BASE)
}
},
routes: {
login: "/auth/login",
logout: "/auth/logout",
callback: "/auth/callback",
},
appBaseUrl: process.env.APP_BASE_URL || process.env.AUTH0_BASE_URL,
})

View file

@ -0,0 +1,3 @@
// Empty shim for Node.js modules that web-tree-sitter tries to import in the browser.
// Turbopack equivalent of webpack's resolve.fallback: { module: false }
module.exports = {}

View file

@ -0,0 +1,32 @@
import { cache } from "react"
import { cookies } from "next/headers"
import { auth0 } from "@/lib/auth0"
import { getUserOrganizations } from "@/components/dashboard/action"
import type { AccountPayload } from "@codeflash-ai/common"
/**
* Server-side utility to determine the current account context (personal or org).
* Reads the auth session + org cookie to build an AccountPayload for data fetching.
* Cached per request via React `cache()`.
*/
export const getAccountContext = cache(async (): Promise<AccountPayload> => {
const session = await auth0.getSession()
if (!session?.user?.sub || !session?.user?.nickname) {
throw new Error("User session not found or incomplete")
}
const cookieStore = await cookies()
const orgId = cookieStore.get("currentOrganizationId")?.value
if (orgId) {
// Validate user is a member of this org
const result = await getUserOrganizations(session.user.sub)
if (result.success && result.organizations?.some(org => org.id === orgId)) {
return { orgId }
}
// Invalid org cookie — fall through to personal mode
}
return { userId: session.user.sub, username: session.user.nickname }
})

View file

@ -1,13 +1,20 @@
import { getSession } from "@auth0/nextjs-auth0/edge"
import { type NextRequest, NextResponse } from "next/server"
import { auth0 } from "@/lib/auth0"
import { isTeamMemberCheck } from "@/lib/team-members"
export default async function middleware(req: NextRequest) {
export async function proxy(req: NextRequest) {
// Let Auth0 handle auth routes (/auth/login, /auth/callback, /auth/logout, etc.)
const authRes = await auth0.middleware(req)
const { pathname, origin, search } = req.nextUrl
// Skip auth check for certain paths
// For auth routes, return the Auth0 response directly
if (pathname.startsWith("/auth")) {
return authRes
}
// Skip auth check for public/static paths
const ignorePaths = [
"/api/auth",
"/_next",
"/favicon.ico",
"/manifest.json",
@ -15,16 +22,14 @@ export default async function middleware(req: NextRequest) {
"/onboarding",
]
if (ignorePaths.some(p => pathname.startsWith(p))) {
return NextResponse.next()
return authRes
}
const res = NextResponse.next()
const session = await getSession(req, res)
const session = await auth0.getSession(req)
// Handle unauthenticated user
if (!session?.user) {
if (pathname.startsWith("/api/") || pathname === "/api") {
// Return JSON error for actual API routes (must have /api/ or be exactly /api)
return NextResponse.json(
{
error: "not_authenticated",
@ -36,7 +41,7 @@ export default async function middleware(req: NextRequest) {
// Redirect to login for page routes with returnTo
const returnTo = encodeURIComponent(pathname + search)
const loginUrl = new URL(`/login?returnTo=${returnTo}`, origin)
const loginUrl = new URL(`/auth/login?returnTo=${returnTo}`, origin)
return NextResponse.redirect(loginUrl)
}
@ -46,30 +51,11 @@ export default async function middleware(req: NextRequest) {
}
}
return res
return authRes
}
export const config = {
matcher: [
"/",
"/app/:path*",
"/trace/:path*",
"/billing",
"/billing/:path*",
"/apikeys",
"/apikeys/:path*",
"/repositories",
"/repositories/:path*",
"/review-optimizations",
"/review-optimizations/:path*",
"/getting-started",
"/getting-started/:path*",
"/dashboard",
"/dashboard/:path*",
"/observability",
"/observability/:path*",
"/members",
"/members/:path*",
"/subscribe/:path*",
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
],
}

View file

@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@ -19,9 +23,20 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}