mirror of
https://github.com/codeflash-ai/codeflash-internal.git
synced 2026-05-04 18:25:18 +00:00
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:
parent
c2feaf91f0
commit
5dca735fc8
74 changed files with 3575 additions and 4368 deletions
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
44
js/cf-webapp/eslint.config.mjs
Normal file
44
js/cf-webapp/eslint.config.mjs
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
3203
js/cf-webapp/package-lock.json
generated
3203
js/cf-webapp/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
20
js/cf-webapp/src/app/(dashboard)/apikeys/loading.tsx
Normal file
20
js/cf-webapp/src/app/(dashboard)/apikeys/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
js/cf-webapp/src/app/(dashboard)/billing/loading.tsx
Normal file
20
js/cf-webapp/src/app/(dashboard)/billing/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
21
js/cf-webapp/src/app/(dashboard)/members/loading.tsx
Normal file
21
js/cf-webapp/src/app/(dashboard)/members/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
22
js/cf-webapp/src/app/(dashboard)/repositories/error.tsx
Normal file
22
js/cf-webapp/src/app/(dashboard)/repositories/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { RepositoriesSkeleton } from "@/components/repositories/RepositoriesSkeleton"
|
||||
|
||||
export default function RepositoriesLoading() {
|
||||
return <RepositoriesSkeleton />
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 results—clear 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
17
js/cf-webapp/src/app/app/org-cookie-action.ts
Normal file
17
js/cf-webapp/src/app/app/org-cookie-action.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
77
js/cf-webapp/src/app/dashboard/_components/YearSelector.tsx
Normal file
77
js/cf-webapp/src/app/dashboard/_components/YearSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
5
js/cf-webapp/src/app/dashboard/loading.tsx
Normal file
5
js/cf-webapp/src/app/dashboard/loading.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { DashboardSkeleton } from "@/components/dashboard/DashboardSkeleton"
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return <DashboardSkeleton />
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"use client"
|
||||
import { type JSX } from "react"
|
||||
import posthog from "posthog-js"
|
||||
import { PostHogProvider } from "posthog-js/react"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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" : ""}`}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
119
js/cf-webapp/src/lib/auth0.ts
Normal file
119
js/cf-webapp/src/lib/auth0.ts
Normal 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,
|
||||
})
|
||||
3
js/cf-webapp/src/lib/empty-shim.js
Normal file
3
js/cf-webapp/src/lib/empty-shim.js
Normal 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 = {}
|
||||
32
js/cf-webapp/src/lib/server/get-account-context.ts
Normal file
32
js/cf-webapp/src/lib/server/get-account-context.ts
Normal 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 }
|
||||
})
|
||||
|
|
@ -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).*)",
|
||||
],
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue