diff --git a/.husky/pre-push b/.husky/pre-push index 746d1d9..dcea0e9 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -7,13 +7,13 @@ if [ $? -ne 0 ]; then exit 1 fi -echo "Lint passed. Running build..." +echo "Lint passed. Running type check..." -pnpm run build +pnpm exec tsc --noEmit if [ $? -ne 0 ]; then - echo "Build failed! Push aborted." + echo "Type check failed! Push aborted." exit 1 fi -echo "Lint and build succeeded. Proceeding with push." +echo "Lint and type check succeeded. Proceeding with push." diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 201aace..0000000 --- a/Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -FROM node:20-bookworm-slim - -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" - -RUN corepack enable - -WORKDIR /app - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN pnpm install --frozen-lockfile - -COPY . . - -# Build-time placeholders so Next.js server module loading does not fail. -# Runtime values are provided by docker-compose `.env`. -ENV DATABASE_URL="postgresql://build:build@localhost:5432/codereviewai" \ - BETTER_AUTH_SECRET="build-time-better-auth-secret-change-at-runtime" \ - BETTER_AUTH_URL="https://example.com" \ - NEXT_PUBLIC_APP_URL="https://example.com" \ - GH_CLIENT_ID="build-github-client-id" \ - GH_CLIENT_SECRET="build-github-client-secret" \ - OPENAI_API_KEY="sk-build-placeholder" \ - GH_WEBHOOK_SECRET="build-github-webhook-secret" \ - INNGEST_EVENT_KEY="build-inngest-event-key" \ - INNGEST_SIGNING_KEY="build-inngest-signing-key" - -RUN pnpm db:generate -RUN pnpm build - -ENV NODE_ENV=production -ENV NEXT_TELEMETRY_DISABLED=1 -ENV HOSTNAME=0.0.0.0 -ENV PORT=3000 - -EXPOSE 3000 - -CMD ["pnpm", "start"] diff --git a/README.md b/README.md index e215bc4..4a13c3e 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,182 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# AI Code Reviewer -## Getting Started +An intelligent AI-powered code review platform that automatically analyzes GitHub pull requests and provides comprehensive feedback, suggestions, and risk assessments. Built with Next.js, this application integrates with GitHub to streamline the code review process using advanced AI models. -First, run the development server: +## Features -```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +- πŸ€– **Automated Code Reviews**: Leverage AI (Google Gemini & OpenAI) to automatically review pull requests +- πŸ” **GitHub Integration**: Seamless authentication and repository synchronization +- πŸ“Š **Dashboard Analytics**: Visualize review statistics, activity heatmaps, and risk scores +- 🎯 **Risk Assessment**: Intelligent scoring system to identify potential issues +- πŸ’¬ **Inline Comments**: AI-generated comments posted directly to GitHub PRs +- πŸ”„ **Background Processing**: Asynchronous review processing using Inngest +- πŸŒ™ **Dark Mode**: Built-in theme support for comfortable viewing +- πŸ“± **Responsive Design**: Works seamlessly across desktop and mobile devices + +## Tech Stack + +- **Framework**: [Next.js 16](https://nextjs.org) with App Router +- **Language**: TypeScript +- **Database**: PostgreSQL with [Prisma ORM](https://www.prisma.io/) +- **Authentication**: [Better Auth](https://www.better-auth.com/) with GitHub OAuth +- **AI Models**: Google Gemini API & OpenAI API +- **Background Jobs**: [Inngest](https://www.inngest.com/) +- **API Layer**: [tRPC](https://trpc.io/) for type-safe APIs +- **Styling**: [Tailwind CSS](https://tailwindcss.com/) with Radix UI components +- **State Management**: TanStack Query (React Query) +- **Animations**: Framer Motion & React Spring + +## Prerequisites + +Before you begin, ensure you have the following installed: + +- Node.js 20.x or higher +- pnpm (recommended) or npm +- PostgreSQL database +- GitHub OAuth App credentials +- Google Gemini API key or OpenAI API key + +## Environment Variables + +Create a `.env` file in the root directory with the following variables: + +```env +# Database +DATABASE_URL="postgresql://user:password@localhost:5432/aicodereviewer" + +# Authentication +BETTER_AUTH_SECRET="your-secret-key-here" +BETTER_AUTH_URL="http://localhost:3000" + +# GitHub OAuth +GITHUB_CLIENT_ID="your-github-client-id" +GITHUB_CLIENT_SECRET="your-github-client-secret" + +# AI API Keys (use at least one) +GOOGLE_GEMINI_API_KEY="your-gemini-api-key" +OPENAI_API_KEY="your-openai-api-key" + +# Inngest +INNGEST_EVENT_KEY="your-inngest-event-key" +INNGEST_SIGNING_KEY="your-inngest-signing-key" ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +## Installation + +1. **Clone the repository**: + ```bash + git clone https://github.com/yourusername/aicodereviewer.git + cd aicodereviewer + ``` + +2. **Install dependencies**: + ```bash + pnpm install + ``` + +3. **Set up the database**: + ```bash + pnpm db:generate + pnpm db:push + ``` + +4. **Run the development server**: + ```bash + pnpm dev + ``` + +5. **In a separate terminal, run Inngest dev server**: + ```bash + pnpm inngest:dev + ``` + +6. Open [http://localhost:3000](http://localhost:3000) in your browser to see the application. + +## Available Scripts + +- `pnpm dev` - Start the Next.js development server +- `pnpm inngest:dev` - Start the Inngest development server +- `pnpm build` - Build the application for production +- `pnpm build:full` - Generate Prisma client and build the application +- `pnpm start` - Start the production server +- `pnpm lint` - Run ESLint for code quality checks +- `pnpm db:push` - Push database schema changes to the database +- `pnpm db:generate` - Generate Prisma Client + +## How It Works + +1. **Authentication**: Users sign in with their GitHub account +2. **Repository Sync**: The app syncs accessible GitHub repositories +3. **Webhook Integration**: GitHub webhooks trigger reviews on new pull requests +4. **AI Analysis**: Pull requests are analyzed using AI models (Gemini/OpenAI) +5. **Review Generation**: AI generates comprehensive feedback with risk scores +6. **Comment Posting**: Reviews are automatically posted back to GitHub +7. **Dashboard**: Users can view all reviews, statistics, and activity + +## Database Schema + +The application uses PostgreSQL with the following main models: + +- **User**: User accounts with GitHub authentication +- **Repository**: Synced GitHub repositories +- **Review**: AI-generated code reviews with status tracking +- **Session**: User session management +- **Account**: OAuth account information + +## Project Structure + +``` +aicodereviewer/ +β”œβ”€β”€ prisma/ # Database schema and migrations +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ app/ # Next.js app router pages +β”‚ β”‚ β”œβ”€β”€ (auth)/ # Authentication pages +β”‚ β”‚ β”œβ”€β”€ (dashboard)/# Dashboard and repository pages +β”‚ β”‚ └── api/ # API routes and webhooks +β”‚ β”œβ”€β”€ components/ # React components +β”‚ β”œβ”€β”€ constant/ # Constants and configuration +β”‚ β”œβ”€β”€ hooks/ # Custom React hooks +β”‚ β”œβ”€β”€ lib/ # Utility libraries +β”‚ └── server/ # Server-side code (tRPC routers, Inngest functions) +└── package.json +``` + +## Key Features Explained + +### Automated Reviews +When a pull request is opened or updated, the system: +- Fetches the diff and file changes from GitHub +- Analyzes code patterns, potential bugs, and best practices +- Generates inline comments with suggestions +- Calculates a risk score based on complexity and potential issues -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### Dashboard +The dashboard provides: +- Overview of all reviewed pull requests +- Activity heatmap showing review frequency +- Statistics including total reviews, risk scores, and success rates +- Quick access to repository management -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +### Background Processing +Uses Inngest for reliable background job processing: +- Asynchronous PR analysis to avoid blocking +- Retry logic for failed reviews +- Status tracking (PENDING β†’ PROCESSING β†’ COMPLETED/FAILED) -## Learn More +## Contributing -To learn more about Next.js, take a look at the following resources: +Contributions are welcome! Please follow these steps: -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/your-feature` +3. Commit your changes: `git commit -m 'Add your feature'` +4. Push to the branch: `git push origin feature/your-feature` +5. Open a pull request -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## License -## Deploy on Vercel +This project is private and proprietary. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Support -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +For issues or questions, please open an issue in the GitHub repository. diff --git a/deploy/Caddyfile b/deploy/Caddyfile deleted file mode 100644 index 0d03b42..0000000 --- a/deploy/Caddyfile +++ /dev/null @@ -1,5 +0,0 @@ -{$APP_DOMAIN} { - encode gzip zstd - - reverse_proxy app:3000 -} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 1be6e5d..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,32 +0,0 @@ -services: - app: - build: - context: . - dockerfile: Dockerfile - restart: unless-stopped - env_file: - - .env - environment: - NODE_ENV: production - NEXT_TELEMETRY_DISABLED: "1" - HOSTNAME: 0.0.0.0 - PORT: "3000" - - caddy: - image: caddy:2.10 - restart: unless-stopped - depends_on: - - app - environment: - APP_DOMAIN: ${APP_DOMAIN} - ports: - - "80:80" - - "443:443" - volumes: - - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - - caddy_config:/config - -volumes: - caddy_data: - caddy_config: diff --git a/docs/ui_redesign_epic.md b/docs/ui_redesign_epic.md new file mode 100644 index 0000000..71613cd --- /dev/null +++ b/docs/ui_redesign_epic.md @@ -0,0 +1,623 @@ +# EPIC: CodeReviewAI β€” Complete UI/UX Redesign + +> **Status:** Draft β€” Awaiting Approval +> **Date:** 2026-03-14 +> **Scope:** End-to-end frontend redesign of every page, component, and interaction + +--- + +## 1. Executive Summary + +A ground-up redesign of the CodeReviewAI frontend to deliver a **modern, premium, and highly animated** user experience. The current UI is functional but visually flat, lacks motion, and feels utilitarian. The redesign will transform it into a polished product that feels on par with tools like Linear, Vercel, and Raycast β€” clean, fast, and delightful to use. + +--- + +## 2. Design Principles + +| Principle | Description | +|-----------|-------------| +| **Dark-first premium** | Rich dark theme as the hero experience; light theme equally polished | +| **Motion with purpose** | Every animation serves a function β€” entrance, feedback, or continuity | +| **Information density done right** | Show more without feeling crowded; use progressive disclosure | +| **Spatial consistency** | Consistent spacing scale, alignment grid, and visual rhythm | +| **Instant feedback** | Every click, hover, and state change has a visible response | + +--- + +## 3. New Libraries & Packages + +| Package | Purpose | Why | +|---------|---------|-----| +| **`@react-spring/web`** | Physics-based animations | Natural spring animations for counters, gauges, and number transitions β€” more natural feel than keyframes | +| **`react-syntax-highlighter`** | Code syntax highlighting | Professional code rendering in diff viewer and review comments with proper language tokenization | +| **`react-hot-toast`** (or **`sonner`**) | Toast notifications | Elegant, animated toast system for success/error/info feedback across the app | +| **`@radix-ui/react-tooltip`** | Tooltips | Accessible, animated tooltips for icons, truncated text, and contextual help | +| **`react-countup`** | Animated counters | Smooth number animations for analytics stats cards | + +> **Note:** Framer Motion is already installed but unused. It will become the primary animation engine. + +### Packages to Remove + +| Package | Reason | +|---------|--------| +| `react-icons` | Consolidate on Lucide React exclusively for icon consistency | + +--- + +## 4. Color System Overhaul + +### 4.1 Brand Accent Color + +Introduce a **brand accent** (vibrant indigo/violet) to replace the neutral-only palette: + +``` +--brand: oklch(0.65 0.25 270) /* Vibrant indigo */ +--brand-foreground: oklch(0.98 0 0) /* White on brand */ +--brand-muted: oklch(0.65 0.25 270 / 15%) +``` + +### 4.2 Enhanced Semantic Colors + +- **Success:** Emerald with gradient variants +- **Warning:** Amber with gradient variants +- **Danger:** Rose (shift from pure red for a more refined feel) +- **Info:** Sky blue + +### 4.3 Surface Hierarchy (Dark Theme) + +``` +--surface-0: oklch(0.13 0.005 270) /* App background β€” slight blue-ish tint */ +--surface-1: oklch(0.17 0.005 270) /* Cards */ +--surface-2: oklch(0.21 0.005 270) /* Elevated cards, modals */ +--surface-3: oklch(0.25 0.005 270) /* Hover states, active surfaces */ +``` + +### 4.4 Gradient System + +```css +.gradient-brand { background: linear-gradient(135deg, var(--brand), oklch(0.55 0.28 310)); } +.gradient-success { background: linear-gradient(135deg, emerald-500, teal-400); } +.gradient-danger { background: linear-gradient(135deg, rose-500, red-400); } +.gradient-surface { background: linear-gradient(180deg, var(--surface-1), var(--surface-0)); } +``` + +--- + +## 5. Typography Refinements + +- **Keep Geist Sans / Geist Mono** β€” they are excellent +- Add utility classes for the typographic scale: + +| Token | Size | Weight | Use | +|-------|------|--------|-----| +| `display` | 3.5rem / 56px | 700 | Landing hero | +| `h1` | 2.25rem / 36px | 600 | Page titles | +| `h2` | 1.5rem / 24px | 600 | Section headings | +| `h3` | 1.125rem / 18px | 500 | Card titles | +| `body` | 0.875rem / 14px | 400 | Default text | +| `caption` | 0.75rem / 12px | 400 | Secondary text, timestamps | +| `code` | 0.8125rem / 13px | 400 (mono) | Code, file paths | + +--- + +## 6. Animation Strategy + +### 6.1 Framer Motion β€” Global Defaults + +```ts +const defaultTransition = { type: "spring", stiffness: 400, damping: 30 }; +const fadeInUp = { initial: { opacity: 0, y: 12 }, animate: { opacity: 1, y: 0 } }; +const staggerChildren = { staggerChildren: 0.05 }; +``` + +### 6.2 Animation Catalog + +| Interaction | Animation | Library | +|-------------|-----------|---------| +| Page enter | Fade-in + slide up (staggered) | Framer Motion | +| Card hover | Subtle scale (1.01) + shadow lift + border glow | Framer Motion | +| Button press | Scale down (0.97) + release spring | Framer Motion | +| Tab switch | Underline slide with `layoutId` | Framer Motion | +| Modal open/close | Scale from 0.95 + fade + backdrop blur | Framer Motion | +| Number counting | Spring-based counting animation | react-spring / react-countup | +| Risk score gauge | Animated arc fill on mount | Framer Motion SVG | +| Loading states | Skeleton shimmer + pulse + breathing glow | CSS + Framer Motion | +| Toast appear | Slide in from top-right + fade | sonner | +| List items enter | Staggered fade-in | Framer Motion | +| Navigation | Active indicator slides to selected tab | Framer Motion `layoutId` | +| Diff viewer | Accordion expand with spring | Framer Motion | +| Status changes | Color morph with crossfade | Framer Motion | +| Scroll-reveal | Elements fade in as they enter viewport | Framer Motion `whileInView` | + +### 6.3 Micro-Interactions + +- **Checkboxes:** Bounce-in checkmark SVG animation +- **Buttons:** Ripple effect on click (CSS) +- **Input focus:** Border color transition + subtle glow +- **Hover cards:** Gradient border shimmer on hover +- **Copy button:** Check icon morph on success +- **Badge pulse:** Subtle pulse on "Processing" status + +--- + +## 7. Page-by-Page Redesign Plan + +### 7.1 Landing Page (`/`) + +**Current:** Simple hero + feature grid + how-it-works + CTA. Flat, no motion. + +**Redesigned:** + +- **Hero Section:** + - Animated gradient mesh background (CSS animated gradients, not heavy canvas) + - Large headline with gradient text and word-by-word stagger animation + - Animated terminal/code mockup showing a live review happening + - Floating badge: "Powered by AI" with subtle floating animation + - CTA buttons with hover glow effect and arrow icon animation + +- **Social Proof Bar:** + - Animated counter: "X reviews completed" with CountUp + - Logos or trust indicators with fade-in on scroll + +- **Features Section:** + - Bento grid layout (mixed card sizes) instead of uniform grid + - Each card has an icon with gradient background + - Cards animate in on scroll (`whileInView`) + - Hover: subtle tilt/lift with shadow + +- **How It Works:** + - Horizontal stepper with connecting animated line + - Each step animates in sequence + - Animated icons per step + +- **CTA Section:** + - Gradient background section + - Floating particles or subtle animated grid pattern + +- **Footer:** + - Expanded footer with links, brand, and social links + - Subtle top border gradient + +### 7.2 Auth Pages (`/sign-in`, `/sign-up`) + +**Current:** Centered card with basic form fields. No animation, no visual flair. + +**Redesigned:** + +- **Split layout** on desktop: left panel = brand/illustration, right panel = form +- Left panel features an animated abstract illustration or gradient mesh with the brand logo +- Form card enters with spring animation +- Input fields have animated floating labels +- Focus states with glowing brand-colored border +- GitHub button with icon animation on hover +- Password strength indicator (sign-up) with animated progress bar +- Error messages slide in with red accent +- Loading state: button text morphs to spinner +- Mobile: full-width form with gradient header strip + +### 7.3 Dashboard Layout + +**Current:** Simple header with nav links + theme toggle. No sidebar, mobile nav missing. + +**Redesigned:** + +- **Top navigation bar** β€” keep but enhance: + - Brand logo/wordmark on the left with subtle hover animation + - Navigation tabs with animated `layoutId` active indicator (sliding pill) + - Right side: notification bell (future-ready), theme toggle with sun/moon morph animation, user avatar dropdown + - Glass morphism effect: `backdrop-blur-xl` + border + subtle gradient + - On scroll: header shrinks slightly with smooth transition + +- **Mobile navigation:** + - Bottom tab bar (iOS-style) for primary nav items + - Smooth icon + label layout with active state animation + - No hamburger menu β€” direct access to all sections + +- **Breadcrumbs:** + - Add breadcrumb navigation for nested pages (repo > PR > review) + - Animated in/out of individual crumb items + +### 7.4 Repositories Page (`/repos`) + +**Current:** Grid of basic cards + import panel. Functional but plain. + +**Redesigned:** + +- **Page header:** Title with description, "Add Repository" button with plus icon animation +- **Connected repos grid:** + - Cards with gradient left border accent (based on language or custom color) + - Card shows: repo name, description excerpt, last review time, review count badge + - Hover: card lifts with shadow + border glow + - Staggered entrance animation + - Empty state: illustrated placeholder with CTA + +- **Import panel (modal/sheet):** + - Slide-in sheet from the right (Framer Motion `AnimatePresence`) + - Search input with debounced filtering and animated results + - Repo items animate in as a staggered list + - Select-all with animated checkbox + - "Connect" button with loading spinner and success checkmark animation + +- **Disconnect confirmation:** + - Custom modal with backdrop blur + - Warning icon with bounce animation + - Danger button with confirm interaction + +### 7.5 Repository Detail / PR List (`/repos/[id]`) + +**Current:** Tabs for Open/Closed/All, cards for each PR. Basic layout. + +**Redesigned:** + +- **Repository header card:** + - Full-width card with repo name, description, GitHub link, last synced + - Stats row: total PRs, reviewed, pending (animated counters) + - Gradient accent line at top + +- **PR list:** + - Custom tab bar with animated active indicator + - PR cards as a clean list view (not grid) for better scanability + - Each card: PR title, author avatar, branch badge, file change stats bar (visual additions/deletions bar), review status badge with color-coded dot + - Hover: background highlight with smooth transition + - Staggered entrance for list items + - Empty state per tab with contextual illustration + +### 7.6 PR Detail Page (`/repos/[id]/pr/[prNumber]`) + +**Current:** PR metadata + tabbed view (Reviews / Changed Files). Dense and utilitarian. + +**Redesigned:** + +- **PR header section:** + - PR title (large), PR number badge, state badge (open/closed/merged) with color + - Author info: avatar + name + relative time + - Branch flow: `base` ← `head` with animated arrow + - Stats: additions (green), deletions (red), files changed β€” animated counters + +- **Action bar:** + - "Run AI Review" button β€” prominent, brand gradient, with sparkle/wand icon animation + - Status: animated processing indicator (pulsing dot + text) + - Cancel button appears conditionally with slide-in + +- **Review Results tab (redesigned ReviewResult component):** + - **Risk Score:** Animated circular gauge (SVG arc) with gradient fill (greenβ†’yellowβ†’red), centered score number with CountUp, severity label underneath + - **Severity Distribution:** Horizontal stacked bar with animated segment fills + legend + - **AI Summary:** Styled blockquote card with AI icon + - **Comments list:** + - Cards grouped by severity (critical first) + - Each card: colored left border, severity badge, category icon, file:line monospace link, message text, expandable suggestion with code block + - Hover: lift + glow matching severity color + - Staggered entrance + - **Post to GitHub floating bar:** + - Fixed bottom bar slides up when comments selected + - Selected count with animated badge + - Review type selector (radio pills) + - Post button with loading state + +- **Changed Files tab (redesigned DiffViewer):** + - File list as collapsible accordion + - Each file: name, change stats, expand/collapse icon with rotation animation + - Diff rendering: proper syntax highlighting via `react-syntax-highlighter` + - Line-by-line diff with green/red backgrounds and line numbers + - Smooth accordion expand animation + +### 7.7 Review Comparison Page (`/repos/[id]/pr/[prNumber]/compare`) + +**Current:** Dropdown selectors + summary cards + comment list. Functional but dry. + +**Redesigned:** + +- **Comparison header:** + - Two review selectors side-by-side (styled dropdowns) + - Visual "vs" divider with animated swap icon option + - Risk score delta: large number with animated trend arrow (up = red, down = green) + +- **Summary cards row:** + - Three cards: Fixed (green), New (amber), Unchanged (gray) + - Animated count with CountUp + - Icon per card with matching color + +- **Comparison comments list:** + - Cards with variant backgrounds (green tint = fixed, amber tint = new, neutral = unchanged) + - Status badge: "Fixed" / "New Issue" / "Unchanged" with icon + - Staggered list entrance + - Filter tabs to show only Fixed / New / Unchanged + +### 7.8 Reviews List (`/reviews`) + +**Current:** Filterable list of all reviews. Basic tab filtering. + +**Redesigned:** + +- **Page header:** Title, subtitle with total review count +- **Filter tabs:** Animated pill-style tabs with count badges +- **Review cards:** + - Clean list view with hover highlight + - Left: colored status dot (animated pulse if processing) + - Content: repo name, PR title, relative time + - Right: risk score badge (color-coded), comment count + - Retry button (failed reviews) with refresh icon animation +- **Auto-polling indicator:** Subtle animated dot in header when live-polling + +### 7.9 Analytics Dashboard (`/analytics`) + +**Current:** Grid of chart cards with Recharts. Static, loads all at once. + +**Redesigned:** + +- **KPI Stats Cards row:** + - Glassmorphism card style + - Large metric with CountUp animation + - Trend indicator arrow (up/down) with percentage + - Subtle gradient icon background + - Staggered entrance animation + +- **Review Trend Chart:** + - Area chart with gradient fill under the line + - Animated line drawing on mount + - Time range selector as pill toggle (7d / 30d / 90d) + - Hover tooltip redesigned with custom component + - Smooth data transition when changing ranges + +- **Risk Distribution (Donut):** + - Animated arc segments drawing in on mount + - Center: animated total count + - Custom legend with colored dots + +- **Top Issues (Bar chart):** + - Horizontal bars animate from left to right + - Bars have rounded ends and gradient fills + - Labels on the left, values on the right + +- **Activity Heatmap:** + - Custom redesigned heatmap with brand-colored gradient (instead of default green) + - Tooltip on hover showing date and count + - Smooth fade-in on mount + +- **Repo Leaderboard:** + - Ranked list with medal icons (gold/silver/bronze) for top 3 + - Progress bar with animated fill + - Risk score badge per repo + +- **Overall Health and Review Status cards:** + - Side-by-side fullwidth cards at bottom + - Circular progress indicator for pass rate + - Status breakdown with animated bars + +### 7.10 Error Page (`/error.tsx`) and 404 Page (`/not-found.tsx`) + +**Current:** Basic error/404 with simple icon and text. + +**Redesigned:** + +- Centered layout with animated illustration (CSS-only animated icon) +- Error code large and prominent with gradient text +- Friendly copy with personality +- Action buttons: "Go Back" / "Go Home" with animated icons +- Subtle animated background pattern + +--- + +## 8. Shared Component Library Updates + +### 8.1 New Components to Create + +| Component | Description | +|-----------|-------------| +| `AnimatedPage` | Wrapper for page-level enter/exit animations | +| `AnimatedList` | Staggered children animation wrapper | +| `AnimatedCounter` | Number counting component (wraps CountUp) | +| `GlassCard` | Glassmorphism card variant | +| `GradientButton` | Brand gradient CTA button | +| `AnimatedTabs` | Tab bar with sliding `layoutId` indicator | +| `BottomNav` | Mobile bottom navigation bar | +| `Breadcrumbs` | Animated breadcrumb navigation | +| `CircularGauge` | SVG circular gauge for risk scores | +| `StatusDot` | Animated status indicator dot | +| `EmptyState` | Reusable empty state with illustration | +| `PageHeader` | Consistent page title + description + actions layout | +| `Tooltip` | Radix tooltip with animation | +| `Toast` | Toast notification system (via sonner) | + +### 8.2 Existing Components to Update + +| Component | Changes | +|-----------|---------| +| `Button` | Add gradient variant, press animation, loading state with spinner | +| `Card` | Add hover lift animation, gradient border variant, glass variant | +| `Badge` | Add pulse variant for processing states, gradient variant | +| `Input` | Animated focus glow, floating label option | +| `Skeleton` | Improved shimmer with gradient sweep | +| `Header` | Complete redesign (see 7.3) | +| `UserMenu` | Enhanced dropdown with animations | +| `ThemeToggle` | Sun/moon morph animation | +| `DiffViewer` | Syntax highlighting, accordion animation | +| `ReviewResult` | Complete redesign (see 7.6) | +| `ShimmerSkeleton` | Enhanced shimmer animation | + +--- + +## 9. Responsive Breakpoints + +| Breakpoint | Width | Layout Adaptations | +|------------|-------|--------------------| +| `sm` | 640px+ | Single β†’ two column grids | +| `md` | 768px+ | Show desktop nav, hide bottom nav | +| `lg` | 1024px+ | Full feature layouts, side-by-side panels | +| `xl` | 1280px+ | Max content width containers | + +### Mobile-First Approach + +- Bottom tab bar navigation (< 768px) +- Cards stack vertically on mobile +- Auth pages: single column (no split layout on mobile) +- Touch-friendly: minimum 44px tap targets +- Swipe gestures: swipeable tabs on mobile +- Pull-to-refresh indicator for live data pages + +--- + +## 10. Performance Considerations + +| Concern | Mitigation | +|---------|------------| +| Animation performance | Use `transform` and `opacity` only (GPU-accelerated). Avoid animating layout properties | +| Bundle size | Tree-shake Framer Motion imports. Use dynamic imports for heavy components (charts, diff viewer) | +| Font loading | Keep Geist fonts with `font-display: swap` to avoid FOIT | +| Image/illustration | Use CSS-only illustrations and SVG animations (no heavy image files) | +| Reduced motion | Respect `prefers-reduced-motion` β€” disable all non-essential animations | +| Code splitting | Dynamic import analytics charts and diff viewer (not needed on initial load) | + +--- + +## 11. Implementation Phases + +### Phase 1: Foundation (Estimated: Infrastructure & Global Styles) + +1. Install new dependencies (`sonner`, `react-syntax-highlighter`, `react-countup`, `@react-spring/web`) +2. Remove `react-icons` (replace `FaGithub` with Lucide's `Github` icon) +3. Overhaul `globals.css` β€” new color system, surface tokens, gradients, typography scale +4. Create animation utilities and shared motion variants +5. Create core shared components: `AnimatedPage`, `AnimatedList`, `GlassCard`, `GradientButton`, `AnimatedTabs`, `PageHeader`, `EmptyState`, `StatusDot`, `CircularGauge`, `AnimatedCounter`, `Breadcrumbs` +6. Set up `sonner` toast provider in root layout +7. Add `prefers-reduced-motion` media query handling + +### Phase 2: Navigation & Layout + +8. Redesign dashboard `Header` component (sliding nav indicator, glass effect, responsive) +9. Create `BottomNav` for mobile +10. Add `Breadcrumbs` to nested dashboard pages +11. Enhance `ThemeToggle` with sun/moon morph animation +12. Enhance `UserMenu` dropdown with animations + +### Phase 3: Auth Pages + +13. Redesign `/sign-in` page (split layout, animations, enhanced form) +14. Redesign `/sign-up` page (matching design, password strength indicator) + +### Phase 4: Landing Page + +15. Complete landing page redesign (hero, features bento grid, how-it-works, CTA, footer) + +### Phase 5: Core Dashboard Pages + +16. Redesign `/repos` page (enhanced cards, import sheet, empty state) +17. Redesign `/repos/[id]` page (repo header, PR list, tabs) +18. Redesign `/repos/[id]/pr/[prNumber]` page (review results, diff viewer, action bar) +19. Redesign `/repos/[id]/pr/[prNumber]/compare` page (comparison UI) +20. Redesign `/reviews` page (filter tabs, review cards, polling indicator) + +### Phase 6: Analytics & Misc + +21. Redesign `/analytics` page (all chart components, KPI cards, heatmap) +22. Redesign error page and 404 page +23. Responsive QA pass across all pages +24. Final polish: micro-interactions, hover states, edge cases + +--- + +## 12. Files to Modify / Create + +### New Files + +``` +src/components/ui/animated-page.tsx +src/components/ui/animated-list.tsx +src/components/ui/animated-counter.tsx +src/components/ui/animated-tabs.tsx +src/components/ui/glass-card.tsx +src/components/ui/gradient-button.tsx +src/components/ui/circular-gauge.tsx +src/components/ui/status-dot.tsx +src/components/ui/empty-state.tsx +src/components/ui/page-header.tsx +src/components/ui/tooltip.tsx +src/components/bottom-nav.tsx +src/components/breadcrumbs.tsx +src/lib/motion.ts (shared animation variants & config) +``` + +### Files to Modify + +``` +package.json (add/remove dependencies) +src/app/globals.css (complete color/token overhaul) +src/app/layout.tsx (add toast provider) +src/app/page.tsx (complete landing page redesign) +src/app/error.tsx (redesign) +src/app/not-found.tsx (redesign) +src/app/(auth)/layout.tsx (add split layout wrapper) +src/app/(auth)/sign-in/page.tsx (complete redesign) +src/app/(auth)/sign-up/page.tsx (complete redesign) +src/app/(dashboard)/layout.tsx (add bottom nav, breadcrumbs) +src/app/(dashboard)/repos/page.tsx (complete redesign) +src/app/(dashboard)/repos/[id]/page.tsx (redesign) +src/app/(dashboard)/repos/[id]/pr/[prNumber]/page.tsx (redesign) +src/app/(dashboard)/repos/[id]/pr/[prNumber]/compare/page.tsx (redesign) +src/app/(dashboard)/reviews/page.tsx (redesign) +src/app/(dashboard)/analytics/page.tsx (redesign) +src/components/header.tsx (complete redesign) +src/components/user-menu.tsx (enhance with animations) +src/components/theme-toggle.tsx (sun/moon morph animation) +src/components/diff-viewer.tsx (syntax highlighting + accordion) +src/components/review-result.tsx (complete redesign with gauge) +src/components/review-comparison-card.tsx (redesign) +src/components/shimmer-skeleton.tsx (enhanced shimmer) +src/components/connect-github.tsx (redesign import panel) +src/components/analytics/stats-cards.tsx (glassmorphism + counters) +src/components/analytics/review-chart.tsx (gradient area chart) +src/components/analytics/risk-distribution.tsx (animated donut) +src/components/analytics/top-issues.tsx (animated bars) +src/components/analytics/activity-heatmap.tsx (brand color restyle) +src/components/analytics/repo-leaderboard.tsx (redesign) +src/components/ui/button.tsx (add variants, press animation) +src/components/ui/card.tsx (add hover/glass variants) +src/components/ui/badge.tsx (add pulse/gradient variants) +src/components/ui/input.tsx (focus glow animation) +src/components/ui/skeleton.tsx (improved shimmer) +``` + +--- + +## 13. Acceptance Criteria + +- [ ] All pages are visually redesigned with a premium, modern look +- [ ] Smooth animations on all page transitions, card hovers, button presses, and list renders +- [ ] Dark and light themes both look polished +- [ ] Fully responsive: mobile (320px+), tablet (768px+), desktop (1024px+) +- [ ] Mobile bottom navigation works on all dashboard pages +- [ ] Risk score displayed as animated circular gauge +- [ ] Analytics charts have animated entrances +- [ ] Diff viewer uses syntax highlighting +- [ ] Toast notifications replace any raw alert/error text +- [ ] `prefers-reduced-motion` respected +- [ ] No regressions in functionality β€” all features work as before +- [ ] Lighthouse performance score remains above 90 +- [ ] All existing tRPC API integrations continue to work unchanged +- [ ] No changes to backend/server code + +--- + +## 14. Out of Scope + +- Backend API changes +- Database schema changes +- New features / new pages +- Authentication flow logic changes +- AI review logic changes +- Third-party integrations beyond what exists + +--- + +## 15. Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| Bundle size increase from new libraries | Tree-shaking, dynamic imports, monitor with `next build` | +| Animation jank on low-end devices | GPU-only animations, `prefers-reduced-motion` support | +| Breaking existing functionality | No backend changes; test all flows after redesign | +| Inconsistent design between pages | Build shared component library first (Phase 1), use everywhere | + +--- + +**Ready for review.** Once approved, implementation will proceed phase by phase. diff --git a/package.json b/package.json index 6dc4495..76a560a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "next dev", "inngest:dev": "npx inngest-cli@latest dev -u http://localhost:3000", - "build": "prisma generate && next build", + "build": "next build", + "build:full": "prisma generate && next build", "start": "next start", "lint": "eslint", "db:push": "prisma db push", @@ -15,6 +16,7 @@ "dependencies": { "@google/genai": "^1.43.0", "@prisma/client": "^6.19.2", + "@react-spring/web": "^10.0.3", "@tanstack/react-query": "^5.90.21", "@trpc/client": "^11.10.0", "@trpc/react-query": "^11.10.0", @@ -31,9 +33,10 @@ "radix-ui": "^1.4.3", "react": "19.2.3", "react-calendar-heatmap": "^1.10.0", + "react-countup": "^6.5.3", "react-dom": "19.2.3", - "react-icons": "^5.5.0", "recharts": "^2.15.4", + "sonner": "^2.0.7", "superjson": "^2.2.6", "tailwind-merge": "^3.4.0", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d559f40..4b46454 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@prisma/client': specifier: ^6.19.2 version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) + '@react-spring/web': + specifier: ^10.0.3 + version: 10.0.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.3) @@ -62,15 +65,18 @@ importers: react-calendar-heatmap: specifier: ^1.10.0 version: 1.10.0(react@19.2.3) + react-countup: + specifier: ^6.5.3 + version: 6.5.3(react@19.2.3) react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) - react-icons: - specifier: ^5.5.0 - version: 5.5.0(react@19.2.3) recharts: specifier: ^2.15.4 version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) superjson: specifier: ^2.2.6 version: 2.2.6 @@ -1966,6 +1972,33 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-spring/animated@10.0.3': + resolution: {integrity: sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-spring/core@10.0.3': + resolution: {integrity: sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-spring/rafz@10.0.3': + resolution: {integrity: sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg==} + + '@react-spring/shared@10.0.3': + resolution: {integrity: sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@react-spring/types@10.0.3': + resolution: {integrity: sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ==} + + '@react-spring/web@10.0.3': + resolution: {integrity: sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -2769,6 +2802,9 @@ packages: typescript: optional: true + countup.js@2.10.0: + resolution: {integrity: sha512-QQpZx7oYxsR+OeITlZe46fY/OQjV11oBqjY8wgIXzLU2jIz8GzOrbMhqKLysGY8bWI3T1ZNrYkwGzKb4JNgyzg==} + cross-fetch@4.1.0: resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} @@ -4442,16 +4478,16 @@ packages: peerDependencies: react: '>=0.14.0' + react-countup@6.5.3: + resolution: {integrity: sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==} + peerDependencies: + react: '>= 16.3.0' + react-dom@19.2.3: resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} peerDependencies: react: ^19.2.3 - react-icons@5.5.0: - resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} - peerDependencies: - react: '*' - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4697,6 +4733,12 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -7282,6 +7324,38 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-spring/animated@10.0.3(react@19.2.3)': + dependencies: + '@react-spring/shared': 10.0.3(react@19.2.3) + '@react-spring/types': 10.0.3 + react: 19.2.3 + + '@react-spring/core@10.0.3(react@19.2.3)': + dependencies: + '@react-spring/animated': 10.0.3(react@19.2.3) + '@react-spring/shared': 10.0.3(react@19.2.3) + '@react-spring/types': 10.0.3 + react: 19.2.3 + + '@react-spring/rafz@10.0.3': {} + + '@react-spring/shared@10.0.3(react@19.2.3)': + dependencies: + '@react-spring/rafz': 10.0.3 + '@react-spring/types': 10.0.3 + react: 19.2.3 + + '@react-spring/types@10.0.3': {} + + '@react-spring/web@10.0.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@react-spring/animated': 10.0.3(react@19.2.3) + '@react-spring/core': 10.0.3(react@19.2.3) + '@react-spring/shared': 10.0.3(react@19.2.3) + '@react-spring/types': 10.0.3 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@rtsao/scc@1.1.0': {} '@sec-ant/readable-stream@0.4.1': {} @@ -8019,6 +8093,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + countup.js@2.10.0: {} + cross-fetch@4.1.0: dependencies: node-fetch: 2.7.0 @@ -9851,14 +9927,15 @@ snapshots: prop-types: 15.8.1 react: 19.2.3 - react-dom@19.2.3(react@19.2.3): + react-countup@6.5.3(react@19.2.3): dependencies: + countup.js: 2.10.0 react: 19.2.3 - scheduler: 0.27.0 - react-icons@5.5.0(react@19.2.3): + react-dom@19.2.3(react@19.2.3): dependencies: react: 19.2.3 + scheduler: 0.27.0 react-is@16.13.1: {} @@ -10223,6 +10300,11 @@ snapshots: sisteransi@1.0.5: {} + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + source-map-js@1.2.1: {} source-map@0.6.1: {} diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index abda35d..840b7a4 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { FaGithub } from "react-icons/fa"; import { Suspense, useState } from "react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -9,14 +8,24 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; +import { motion } from "framer-motion"; +import { + CheckCircle2, + Code2, + ArrowRight, + ShieldCheck, + Sparkles, + GitPullRequest, + AlertCircle, +} from "lucide-react"; +import { GitHubIcon } from "@/components/ui/github-icon"; + +const brandPoints = [ + { icon: Sparkles, text: "AI reviews in seconds, not hours" }, + { icon: ShieldCheck, text: "Security vulnerabilities caught automatically" }, + { icon: GitPullRequest, text: "Inline comments right in GitHub PRs" }, +]; function SignInForm() { const router = useRouter(); @@ -28,19 +37,15 @@ function SignInForm() { const [rememberMe, setRememberMe] = useState(false); const [loading, setLoading] = useState(false); - const handleEmailSignIn = async (e: React.FormEvent) => { + const handleEmailSignIn = async (e: { preventDefault(): void }) => { e.preventDefault(); setError(""); setLoading(true); - const result = await signIn.email({ - email, - password, - rememberMe, - }); + const result = await signIn.email({ email, password, rememberMe }); if (result.error) { - setError(result.error.message || "An error occurred"); + setError(result.error.message || "Invalid credentials. Please try again."); setLoading(false); } else { router.push(callbackUrl); @@ -51,95 +56,228 @@ function SignInForm() { setError(""); setLoading(true); await signOut(); - - await signIn.social({ - provider: "github", - callbackURL: callbackUrl, - }); + await signIn.social({ provider: "github", callbackURL: callbackUrl }); }; return ( -
- - - Sign In - - Sign in with your email or GitHub account. - - - - +
+
+
+ +
+ + CodeReviewAI + +
-
-
- -
-
- - Or continue with email - -
-
- -
-
- - setEmail(e.target.value)} - disabled={loading} - /> +
+

+ + Trusted by engineering teams +

+

+ Review every pull request with confidence, speed, and less manual effort. +

+

+ Keep your delivery velocity high while AI scans for bugs, risky patterns, and + security issues before merge. +

+
+ +
+ {brandPoints.map((point, index) => { + const Icon = point.icon; + return ( + +
+ +
+ {point.text} +
+ ); + })} +
-
- - setPassword(e.target.value)} - disabled={loading} - /> + +
+
+

42%

+

Less review turnaround

+
+
+

2.3x

+

Higher issue detection

+
+
+

99.9%

+

Webhook reliability

+
+ -
- setRememberMe(!!v)} + +
+
+
+
+ +
+ + CodeReviewAI + +
+
+ Sign in + + Sign up + +
+
+ +
+

Welcome back

+

+ Continue to your workspace and review incoming pull requests. +

+
+ +
+ + Continue with GitHub + + +
+
+ +
+
+ or use email +
+
- {error &&

{error}

} + +
+ + setEmail(e.target.value)} + disabled={loading} + required + className="h-11 border-border/60 bg-background/70 placeholder:text-muted-foreground/70 focus-visible:border-primary/55 focus-visible:ring-primary/35" + /> +
- - +
+ + setPassword(e.target.value)} + disabled={loading} + required + className="h-11 border-border/60 bg-background/70 placeholder:text-muted-foreground/70 focus-visible:border-primary/55 focus-visible:ring-primary/35" + /> +
-

- Don't have an account? Sign Up -

- - +
+ + 7-day session +
+ + {error && ( + + + {error} + + )} + + + + +
+

+ + You can connect repositories and run your first review in under 2 minutes. +

+
+ +

+ New to CodeReviewAI?{" "} + + Create account + +

+
+ +
+
); } diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx index fb55594..5b9cf99 100644 --- a/src/app/(auth)/sign-up/page.tsx +++ b/src/app/(auth)/sign-up/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { FaGithub } from "react-icons/fa"; import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -8,15 +7,26 @@ import { signIn, signUp } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { set } from "zod"; +import { motion } from "framer-motion"; +import { + CheckCircle2, + Code2, + ArrowRight, + ShieldCheck, + Sparkles, + GitPullRequest, + ChartLine, + AlertCircle, +} from "lucide-react"; +import { GitHubIcon } from "@/components/ui/github-icon"; + +const perks = [ + { icon: Sparkles, text: "Instant AI-powered code reviews" }, + { icon: ShieldCheck, text: "Automatic security vulnerability detection" }, + { icon: GitPullRequest, text: "Seamless GitHub PR integration" }, + { icon: ChartLine, text: "Analytics dashboard and risk scoring" }, +]; export default function SignUpPage() { const router = useRouter(); @@ -26,9 +36,15 @@ export default function SignUpPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const handleEmailSignIn = async (e: React.FormEvent) => { + const handleEmailSignUp = async (e: { preventDefault(): void }) => { e.preventDefault(); setError(""); + + if (password.length < 8) { + setError("Password must be at least 8 characters."); + return; + } + setLoading(true); const result = await signUp.email({ @@ -38,17 +54,16 @@ export default function SignUpPage() { }); if (result.error) { - setError(result.error.message || "An error occurred"); + setError(result.error.message || "Something went wrong. Please try again."); setLoading(false); } else { router.push("/repos"); } }; - const handleGithubSignIn = async () => { + const handleGithubSignUp = async () => { setError(""); setLoading(true); - await signIn.social({ provider: "github", callbackURL: "/repos", @@ -56,83 +71,227 @@ export default function SignUpPage() { }; return ( -
- - - Sign Up - - Sign up with your email or GitHub account. - - - - +
+
+
+ +
+ + CodeReviewAI + +
-
-
- -
-
- - Or continue with email - -
-
- -
-
- - setName(e.target.value)} - disabled={loading} - /> +
+

+ + Get started in minutes +

+

+ Ship cleaner code from your very first merged pull request. +

+

+ Create your account, connect GitHub, and let AI generate structured, + actionable feedback before your reviewers even open the diff. +

+
+ +
+ {perks.map((perk, index) => { + const Icon = perk.icon; + return ( + +
+ +
+ {perk.text} +
+ ); + })} +
-
- - setEmail(e.target.value)} - disabled={loading} - /> + +
+
+

5 min

+

Average onboarding

+
+
+

24/7

+

Automated PR checks

+
+
+

0$

+

Free to start

+
-
- - setPassword(e.target.value)} + + + +
+
+
+
+ +
+ + CodeReviewAI + +
+
+ + Sign in + + Sign up +
+
+ +
+

Create account

+

+ Set up your workspace and start reviewing repositories with AI. +

+
+ +
+ > + + Continue with GitHub + - {error &&

{error}

} +
+
+ +
+
+ or use email +
+
- - +
+
+ + setName(e.target.value)} + disabled={loading} + required + className="h-11 border-border/60 bg-background/70 placeholder:text-muted-foreground/70 focus-visible:border-primary/55 focus-visible:ring-primary/35" + /> +
-

- Already have an account? Sign In -

- - +
+ + setEmail(e.target.value)} + disabled={loading} + required + className="h-11 border-border/60 bg-background/70 placeholder:text-muted-foreground/70 focus-visible:border-primary/55 focus-visible:ring-primary/35" + /> +
+ +
+ + setPassword(e.target.value)} + disabled={loading} + required + minLength={8} + className="h-11 border-border/60 bg-background/70 placeholder:text-muted-foreground/70 focus-visible:border-primary/55 focus-visible:ring-primary/35" + /> +
+ + {error && ( + + + {error} + + )} + + +
+ +
+

+ + No credit card required. You can switch to GitHub auth anytime. +

+
+ +

+ Already have an account?{" "} + + Sign in + +

+
+ +
+
); } diff --git a/src/app/(dashboard)/analytics/page.tsx b/src/app/(dashboard)/analytics/page.tsx index 2024e03..dfc9eee 100644 --- a/src/app/(dashboard)/analytics/page.tsx +++ b/src/app/(dashboard)/analytics/page.tsx @@ -1,245 +1,170 @@ "use client"; -import { trpc } from "@/lib/trpc/client"; +import { useState } from "react"; +import { motion } from "framer-motion"; +import { trpc } from "@/lib/trpc"; +import { AnimatedPage } from "@/components/ui/animated-page"; +import { PageHeader } from "@/components/ui/page-header"; +import { Button } from "@/components/ui/button"; import { StatsCards } from "@/components/analytics/stats-cards"; import { ReviewChart } from "@/components/analytics/review-chart"; import { RiskDistribution } from "@/components/analytics/risk-distribution"; import { TopIssues } from "@/components/analytics/top-issues"; import { ActivityHeatmap } from "@/components/analytics/activity-heatmap"; import { RepoLeaderboard } from "@/components/analytics/repo-leaderboard"; -import { BarChart2, RefreshCw } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { RotateCcw, TrendingUp, Target, CheckCircle, XCircle } from "lucide-react"; +import { StatusDot } from "@/components/ui/status-dot"; -/** - * AnalyticsPage β€” `/analytics` dashboard. - * Fetches all analytics data in parallel via tRPC. - * Layout: page header β†’ KPI row β†’ trend chart β†’ risk + issues row - * β†’ heatmap β†’ leaderboard + health sidebar. - */ export default function AnalyticsPage() { - const stats = trpc.analytics.stats.useQuery(); - const trend = trpc.analytics.reviewTrend.useQuery({ days: 90 }); - const riskDist = trpc.analytics.riskDistribution.useQuery(); - const topIssues = trpc.analytics.topIssues.useQuery(); - const heatmap = trpc.analytics.activityHeatmap.useQuery(); - const leaderboard = trpc.analytics.repoLeaderboard.useQuery({ limit: 5 }); - - const isAnyLoading = - stats.isLoading || - trend.isLoading || - riskDist.isLoading || - topIssues.isLoading || - heatmap.isLoading || - leaderboard.isLoading; - - /** Refetches all analytics queries simultaneously */ - const refetchAll = () => { - void stats.refetch(); - void trend.refetch(); - void riskDist.refetch(); - void topIssues.refetch(); - void heatmap.refetch(); - void leaderboard.refetch(); + const [trendRange, setTrendRange] = useState<"7d" | "30d" | "90d">("30d"); + + const statsQuery = trpc.analytics.stats.useQuery(); + const trendQuery = trpc.analytics.reviewTrend.useQuery({ days: trendRange === "7d" ? 7 : trendRange === "30d" ? 30 : 90 }); + const riskQuery = trpc.analytics.riskDistribution.useQuery(); + const issuesQuery = trpc.analytics.topIssues.useQuery(); + const heatmapQuery = trpc.analytics.activityHeatmap.useQuery(); + const leaderboardQuery = trpc.analytics.repoLeaderboard.useQuery({ limit: 5 }); + + const stats = statsQuery.data; + + const handleRefresh = () => { + statsQuery.refetch(); + trendQuery.refetch(); + riskQuery.refetch(); + issuesQuery.refetch(); + heatmapQuery.refetch(); + leaderboardQuery.refetch(); }; - const passRate = stats.data?.passRate ?? 0; - const avgRisk = stats.data?.avgRiskScore ?? 0; - return ( -
- {/* ── Page header ──────────────────────────────────────────────── */} -
-
-
- -
-
-

- Analytics -

-

- Insights and trends across all your code reviews -

-
-
- -
- - {/* ── KPI stat cards ───────────────────────────────────────────── */} - + + + Refresh + + } /> - {/* ── Review trend (full width) ─────────────────────────────────── */} - + {/* KPI Stats */} +
+ +
- {/* ── Risk distribution + Top issues (side-by-side) ─────────────── */} -
- - +
- {/* ── Activity heatmap (full width) ─────────────────────────────── */} - + {/* Risk Distribution + Top Issues */} +
+ + +
+ + {/* Activity Heatmap */} +
+ +
- {/* ── Leaderboard + health summary sidebar ─────────────────────── */} -
- {/* Leaderboard spans 2/3 */} + {/* Leaderboard + Health Summary */} +
- +
- {/* Health summary stacked in 1/3 */} -
- {/* Overall health card */} -
-

Overall Health

-

- Based on {stats.data?.completed ?? 0} completed reviews -

- -
- {/* Pass rate bar */} -
-
- Pass rate - = 70 - ? "text-emerald-600 dark:text-emerald-400" - : "text-yellow-600 dark:text-yellow-400", - )} - > - {passRate}% - +
+ {/* Overall Health */} +
+

+ + Overall Health +

+ {stats ? ( +
+
+
+ Pass Rate + {stats.passRate.toFixed(0)}% +
+
+ +
-
-
= 70 ? "bg-emerald-500" : "bg-yellow-500", - )} - style={{ width: `${passRate}%` }} - /> +
+
+ Avg Risk Score + {stats.avgRiskScore.toFixed(0)} +
+
+ +
- - {/* Avg risk bar */} -
-
- Avg risk score - 60 - ? "text-red-600 dark:text-red-400" - : avgRisk > 30 - ? "text-yellow-600 dark:text-yellow-400" - : "text-emerald-600 dark:text-emerald-400", - )} - > - {avgRisk}/100 - -
-
-
60 - ? "hsl(0 72.2% 50.6%)" - : avgRisk > 30 - ? "hsl(37.7 92.1% 50.2%)" - : "hsl(142.1 76.2% 36.3%)", - }} - /> -
+ ) : ( +
+
+
-
+ )}
- {/* Review status breakdown card */} -
-

Review Status

-

- Breakdown of all {stats.data?.total ?? 0} reviews -

- -
- {[ - { - label: "Completed", - value: stats.data?.completed ?? 0, - color: "bg-emerald-500", - }, - { - label: "Pending", - value: stats.data?.pending ?? 0, - color: "bg-yellow-400", - }, - { - label: "Processing", - value: stats.data?.processing ?? 0, - color: "bg-blue-500", - }, - { - label: "Failed", - value: stats.data?.failed ?? 0, - color: "bg-red-500", - }, - ].map((item) => ( -
-
-
- + {/* Review Status Breakdown */} +
+

+ + Review Status +

+ {stats ? ( +
+ {[ + { label: "Completed", value: stats.completed, dot: "success" as const, icon: CheckCircle }, + { label: "Pending", value: stats.pending, dot: "warning" as const }, + { label: "Processing", value: stats.processing, dot: "processing" as const }, + { label: "Failed", value: stats.failed, dot: "error" as const, icon: XCircle }, + ].map((item) => ( +
+ + {item.label} + {item.value}
- - {item.value} - -
- ))} -
+ ))} +
+ ) : ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ )}
-
+ ); } diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 8841026..2530224 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,15 +1,11 @@ import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import { auth } from "@/server/auth"; import { Header } from "@/components/header"; import { SessionGuard } from "@/components/session-guard"; -import { auth } from "@/server/auth"; -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; export const metadata: Metadata = { - title: { - default: "Dashboard", - template: "%s | CodeReviewAI", - }, robots: { index: false, follow: false }, }; @@ -18,15 +14,28 @@ export default async function DashboardLayout({ }: { children: React.ReactNode; }) { - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { redirect("/sign-in"); } + return (
+ {/* Background effects */} +
+
+
+
+
-
{children}
+ +
+ {children} +
); } diff --git a/src/app/(dashboard)/repos/[id]/page.tsx b/src/app/(dashboard)/repos/[id]/page.tsx index 4eadd75..8d6b10f 100644 --- a/src/app/(dashboard)/repos/[id]/page.tsx +++ b/src/app/(dashboard)/repos/[id]/page.tsx @@ -1,225 +1,66 @@ "use client"; -import { use, useState } from "react"; +import { use, useState, useMemo } from "react"; import Link from "next/link"; -import { trpc } from "@/lib/trpc/client"; +import { motion } from "framer-motion"; +import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; +import { AnimatedPage } from "@/components/ui/animated-page"; +import { AnimatedList, AnimatedListItem } from "@/components/ui/animated-list"; +import { AnimatedTabs } from "@/components/ui/animated-tabs"; +import { EmptyState } from "@/components/ui/empty-state"; +import { StatusDot } from "@/components/ui/status-dot"; import { RepoDetailSkeleton, PRListItemSkeleton } from "@/components/shimmer-skeleton"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { ArrowLeft, + ArrowRight, GitPullRequest, GitMerge, - Clock, - Plus, - Minus, - FileText, + CircleDot, ExternalLink, - RefreshCw, - CheckCircle, - XCircle, - Loader2, GitBranch, - Globe, - Lock, + FileDiff, FileCode, AlertTriangle } from "lucide-react"; -import { cn, formatDate } from "@/lib/utils"; -type PageProps = { - params: Promise<{ id: string }>; -}; - -export default function RepositoryDetailPage({ params }: PageProps) { - const { id } = use(params); - const [prState, setPrState] = useState<"open" | "closed" | "all">("open"); +type PRState = "open" | "closed" | "all"; - const repository = trpc.repository.list.useQuery(undefined, { - select: (repos) => repos.find((r) => r.id === id), - }); - - const pullRequests = trpc.pullRequest.list.useQuery( - { repositoryId: id, state: prState }, - { enabled: !!id }, - ); +function ReviewStatusBadge({ status }: { status: string | null }) { + if (!status) return null; - const prCounts = { - open: pullRequests.data?.filter((pr) => pr.state === "open").length ?? 0, - closed: - pullRequests.data?.filter((pr) => pr.state === "closed").length ?? 0, - all: pullRequests.data?.length ?? 0, + const configs: Record = { + COMPLETED: { dot: "success", label: "Reviewed", className: "bg-emerald-500/10 text-emerald-500 ring-emerald-500/20" }, + PROCESSING: { dot: "processing", label: "Processing", className: "bg-blue-500/10 text-blue-500 ring-blue-500/20" }, + PENDING: { dot: "warning", label: "Pending", className: "bg-amber-500/10 text-amber-500 ring-amber-500/20" }, + FAILED: { dot: "error", label: "Failed", className: "bg-red-500/10 text-red-500 ring-red-500/20" }, + CANCELLED: { dot: "info", label: "Cancelled", className: "bg-muted text-muted-foreground ring-border" }, }; - if (repository.isLoading) { - return ; - } - - if (!repository.data) { - return ( - - -
- -
-

Repository not found

-

- This repository may have been disconnected. -

- - - -
-
- ); - } + const config = configs[status]; + if (!config) return null; return ( -
-
-
- - - -
-
-

- {repository.data.fullName} -

- - {repository.data.private ? ( - <> - - Private - - ) : ( - <> - - Public - - )} - -
- - View on GitHub - - -
-
- -
- -
-
- {(["open", "closed", "all"] as const).map((state) => ( - - ))} -
-
- -
- {pullRequests.isLoading ? ( - [...Array(3)].map((_, i) => ( - - )) - ) : pullRequests.error ? ( - - -
- -
-

- Failed to load pull requests. -

-

- {pullRequests.error.message} -

-
-
- ) : pullRequests.data?.length === 0 ? ( - - -
- -
-

No pull requests found.

-

- {prState === "all" - ? "This repository has no pull requests yet." - : `No ${prState} pull requests found.`} -

-
-
- ) : ( - pullRequests.data?.map((pr) => ( - - )) - )} -
-
+ + + {config.label} + ); } -interface PullRequestCardProps { +function PRIcon({ state, mergedAt }: { state: string; mergedAt: string | null }) { + if (mergedAt !== null) return ; + if (state === "open") return ; + return ; +} + +function PullRequestCard({ + pr, + repoId, +}: { pr: { id: number; number: number; title: string; - state: "open" | "closed"; + state: string; draft: boolean; htmlUrl: string; author: { login: string; avatarUrl: string }; @@ -229,145 +70,194 @@ interface PullRequestCardProps { deletions: number; changedFiles: number; createdAt: string; + updatedAt: string; mergedAt: string | null; - review: { status: string; createdAt: Date } | null; + review: { prNumber: number; status: string; createdAt: Date } | null; }; - repositoryId: string; -} - -function PullRequestCard({ pr, repositoryId }: PullRequestCardProps) { - const isMerged = pr.state === "closed" && pr.mergedAt !== null; - + repoId: string; +}) { return ( - - -
-
-
- {isMerged ? ( - - ) : pr.state === "closed" ? ( - - ) : ( - - )} -
- -
-
- - {pr.title} - - {pr.draft && ( - - Draft - - )} -
- -
- #{pr.number} - β€’ - - - - - {pr.author.login?.[0]?.toUpperCase() || "?"} - - - {pr.author.login} - - β€’ - - - {formatDate(pr.createdAt)} - -
+ + + -
- - {pr.baseRef} - - {pr.headRef} - -
- - - {pr.additions} - - - - {pr.deletions} - - - - {pr.changedFiles} - files - -
-
-
+
+
+ + {pr.title} + + #{pr.number}
-
- {pr.review && } - - - +
+ + {pr.author.avatarUrl && ( + {pr.author.login} + )} + {pr.author.login} + + + + {pr.headRef} + + + +{pr.additions} + -{pr.deletions} + + + {pr.changedFiles} + +
- - + +
+ + +
+ + ); } -function ReviewStatusBadge({ status }: { status: string }) { - const config = { - COMPLETED: { - icon: CheckCircle, - label: "Reviewed", - className: - "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", - }, - PROCESSING: { - icon: Loader2, - label: "Analyzing", - className: - "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", - spin: true, +export default function RepositoryDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = use(params); + const [prState, setPrState] = useState("open"); + + const reposQuery = trpc.repository.list.useQuery(); + const repo = useMemo( + () => reposQuery.data?.find((r) => r.id === id), + [reposQuery.data, id] + ); + + const prsQuery = trpc.pullRequest.list.useQuery( + { repositoryId: id, state: prState }, + { enabled: !!repo } + ); + + const tabs = [ + { + id: "open" as const, + label: "Open", + icon: , + count: prsQuery.data?.filter((p) => p.state === "open" && p.mergedAt === null).length, }, - PENDING: { - icon: Clock, - label: "Queued", - className: - "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", + { + id: "closed" as const, + label: "Closed", + icon: , + count: prsQuery.data?.filter((p) => p.state === "closed" || p.mergedAt !== null).length, }, - FAILED: { - icon: XCircle, - label: "Failed", - className: - "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", + { + id: "all" as const, + label: "All", + icon: , }, - }[status] ?? { - icon: Clock, - label: "Pending", - className: "bg-muted text-muted-foreground", - }; + ]; - const Icon = config.icon; + if (reposQuery.isLoading) { + return ; + } + + if (!repo) { + return ( + + + + Back to Repositories + + + } + /> + ); + } return ( - - - {config.label} - + + {/* Breadcrumb */} +
+ + Repositories + + / + {repo.name} +
+ + {/* Repo header */} +
+
+
+
+ +
+
+

{repo.name}

+

{repo.fullName}

+
+
+ +
+
+ + {/* Tabs + PR List */} +
+ setPrState(id as PRState)} + layoutId="pr-state-tabs" + /> +
+ + {prsQuery.isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : prsQuery.data?.length === 0 ? ( + + ) : ( + + {prsQuery.data?.map((pr) => ( + + + + ))} + + )} +
); } diff --git a/src/app/(dashboard)/repos/[id]/pr/[prNumber]/compare/page.tsx b/src/app/(dashboard)/repos/[id]/pr/[prNumber]/compare/page.tsx index 24b3d47..2886b27 100644 --- a/src/app/(dashboard)/repos/[id]/pr/[prNumber]/compare/page.tsx +++ b/src/app/(dashboard)/repos/[id]/pr/[prNumber]/compare/page.tsx @@ -2,479 +2,304 @@ import { use, useState, useMemo } from "react"; import Link from "next/link"; -import { trpc } from "@/lib/trpc/client"; +import { motion } from "framer-motion"; +import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Skeleton } from "@/components/ui/skeleton"; +import { AnimatedPage } from "@/components/ui/animated-page"; +import { AnimatedList, AnimatedListItem } from "@/components/ui/animated-list"; +import { AnimatedTabs } from "@/components/ui/animated-tabs"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ComparisonCommentCard } from "@/components/review-comparison-card"; +import { PRHeaderSkeleton } from "@/components/shimmer-skeleton"; +import { + parseReviewComments, + computeReviewComparison, +} from "@/lib/review-comparison"; import { ArrowLeft, - XCircle, - BarChart3, + ArrowRightLeft, + CheckCircle, + AlertTriangle, + Minus, TrendingDown, TrendingUp, - Minus, - ScanSearch, - ArrowRight, + GitPullRequest, } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { - parseReviewComments, - computeReviewComparison, -} from "@/lib/review-comparison"; -import { ComparisonCommentCard } from "@/components/review-comparison-card"; -type PageProps = { +export default function ReviewComparePage({ + params, +}: { params: Promise<{ id: string; prNumber: string }>; -}; +}) { + const { id, prNumber } = use(params); + const prNumberInt = parseInt(prNumber, 10); -/** - * Formats a date for display in review selector and headers. - */ -function formatReviewDate(date: Date): string { - return new Date(date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} + const [baselineId, setBaselineId] = useState(""); + const [currentId, setCurrentId] = useState(""); + const [filter, setFilter] = useState<"all" | "fixed" | "new" | "unchanged">("all"); -export default function ReviewComparePage({ params }: PageProps) { - const { id, prNumber } = use(params); - const prNum = parseInt(prNumber, 10); + const prQuery = trpc.pullRequest.get.useQuery({ + repositoryId: id, + prNumber: prNumberInt, + }); - const pr = trpc.pullRequest.get.useQuery( - { repositoryId: id, prNumber: prNum }, - { enabled: !Number.isNaN(prNum) }, + const reviewsQuery = trpc.review.listForPR.useQuery( + { repositoryId: id, prNumber: prNumberInt }, ); - const reviewsQuery = trpc.review.listForPR.useQuery( - { repositoryId: id, prNumber: prNum }, - { enabled: !Number.isNaN(prNum) }, + const completedReviews = useMemo( + () => reviewsQuery.data ?? [], + [reviewsQuery.data] ); - const reviews = useMemo(() => reviewsQuery.data ?? [], [reviewsQuery.data]); - const [baselineId, setBaselineId] = useState(""); - const [currentId, setCurrentId] = useState(""); + // Auto-select: oldest as baseline, newest as current when nothing is explicitly chosen + const effectiveBaselineId = baselineId || (completedReviews[completedReviews.length - 1]?.id ?? ""); + const effectiveCurrentId = currentId || (completedReviews[0]?.id ?? ""); - // Default to oldest (baseline) and newest (current) when data loads - const resolvedBaselineId = useMemo(() => { - if (reviews.length === 0) return baselineId; - const newest = reviews[0]; - const oldest = reviews[reviews.length - 1]; - if (reviews.length === 1) return newest.id; - return !baselineId || !reviews.some((r) => r.id === baselineId) - ? oldest.id - : baselineId; - }, [reviews, baselineId]); + const comparison = useMemo(() => { + if (!effectiveBaselineId || !effectiveCurrentId || effectiveBaselineId === effectiveCurrentId) return null; - const resolvedCurrentId = useMemo(() => { - if (reviews.length === 0) return currentId; - const newest = reviews[0]; - if (reviews.length === 1) return newest.id; - return !currentId || !reviews.some((r) => r.id === currentId) - ? newest.id - : currentId; - }, [reviews, currentId]); + const baseline = completedReviews.find((r) => r.id === effectiveBaselineId); + const current = completedReviews.find((r) => r.id === effectiveCurrentId); + if (!baseline || !current) return null; - const baseline = reviews.find((r) => r.id === resolvedBaselineId); - const current = reviews.find((r) => r.id === resolvedCurrentId); + const baseComments = parseReviewComments(baseline.comments); + const currComments = parseReviewComments(current.comments); + return { + ...computeReviewComparison(baseComments, currComments), + baselineScore: baseline.riskScore ?? 0, + currentScore: current.riskScore ?? 0, + }; + }, [effectiveBaselineId, effectiveCurrentId, completedReviews]); - const comparison = useMemo(() => { - if (!baseline || !current) return null; - const baselineComments = parseReviewComments(baseline.comments); - const currentComments = parseReviewComments(current.comments); - return computeReviewComparison(baselineComments, currentComments); - }, [baseline, current]); + const scoreDelta = comparison + ? comparison.currentScore - comparison.baselineScore + : 0; + + const filteredItems = useMemo(() => { + if (!comparison) return []; + if (filter === "all") return [ + ...comparison.fixed.items.map((c) => ({ ...c, _variant: "fixed" as const })), + ...comparison.new.items.map((c) => ({ ...c, _variant: "new" as const })), + ...comparison.unchanged.items.map((c) => ({ ...c, _variant: "unchanged" as const })), + ]; + if (filter === "fixed") return comparison.fixed.items.map((c) => ({ ...c, _variant: "fixed" as const })); + if (filter === "new") return comparison.new.items.map((c) => ({ ...c, _variant: "new" as const })); + return comparison.unchanged.items.map((c) => ({ ...c, _variant: "unchanged" as const })); + }, [comparison, filter]); - const riskDelta = - baseline && current && baseline.riskScore != null && current.riskScore != null - ? current.riskScore - baseline.riskScore - : null; + const pr = prQuery.data; - const isLoading = pr.isLoading || reviewsQuery.isLoading; - const isInvalidPr = pr.error || !pr.data; - const hasEnoughReviews = reviews.length >= 2; - const sameSelection = resolvedBaselineId && resolvedCurrentId && resolvedBaselineId === resolvedCurrentId; + if (prQuery.isLoading || reviewsQuery.isLoading) { + return ; + } - if (isLoading) { + if (!pr) { return ( -
-
- -
- - -
-
- - -
+ + + + Back + + + } + /> ); } - if (isInvalidPr) { + if (completedReviews.length < 2) { return ( - - -
- -
-

- {pr.error?.message ?? "Pull request not found"} -

- - - -
-
+ } + /> + ); } + const tabs = [ + { id: "all" as const, label: "All", count: comparison ? comparison.fixed.count + comparison.new.count + comparison.unchanged.count : 0 }, + { id: "fixed" as const, label: "Fixed", icon: , count: comparison?.fixed.count }, + { id: "new" as const, label: "New", icon: , count: comparison?.new.count }, + { id: "unchanged" as const, label: "Unchanged", icon: , count: comparison?.unchanged.count }, + ]; + return ( -
+ + {/* Breadcrumb */} +
+ Repositories + / + Repo + / + #{pr.number} + / + Compare +
+ {/* Header */} -
-
- - - -
- - {/* Review selectors */} - - - - - Select two reviews to compare - -

- Baseline = older run, Current = newer run. Fixed = in baseline but - not in current; New = in current but not in baseline. -

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

- No completed reviews yet -

-

- Run at least two AI reviews on this PR to compare them. -

- - - -
- ) : reviews.length === 1 ? ( -
-

- Only one completed review -

-

- Run another review on this PR to compare. -

- - - -
- ) : ( -
-
- - -
-
- - -
-
- )} -
-
+
- {/* Same selection warning */} - {hasEnoughReviews && sameSelection && ( -
- Select two different reviews to see the comparison. + {effectiveBaselineId === effectiveCurrentId && effectiveBaselineId && ( +
+ Please select two different reviews to compare.
)} - {/* Comparison summary and results */} - {hasEnoughReviews && baseline && current && !sameSelection && comparison && ( + {comparison && ( <> {/* Summary cards */} -
- - -
- - Risk score - - - Baseline β†’ Current - -
-
- - {baseline.riskScore ?? "β€”"} - - - - {current.riskScore ?? "β€”"} - - {riskDelta !== null && ( - 0 && "text-amber-600 dark:text-amber-400", - riskDelta === 0 && "text-muted-foreground", - )} - > - {riskDelta > 0 ? "+" : ""} - {riskDelta} - - )} -
-
- {riskDelta != null && riskDelta < 0 && ( - <> - - Improved - - )} - {riskDelta != null && riskDelta > 0 && ( - <> - - Higher risk - - )} - {riskDelta != null && riskDelta === 0 && ( - <> - - No change - - )} -
-
-
+
+ +
+ {scoreDelta < 0 ? ( + + ) : scoreDelta > 0 ? ( + + ) : ( + + )} + 0 ? "text-red-500" : "text-muted-foreground"}`}> + {scoreDelta > 0 ? "+" : ""}{scoreDelta} + +
+

Risk Delta

+
- - - -
+ + {comparison.fixed.count} +

Fixed

+
- {/* Comment sections */} -
- {comparison.fixed.count > 0 && ( -
-

- - {comparison.fixed.count} - - Fixed issues -

-
- {comparison.fixed.items.map((comment, i) => ( - - ))} -
-
- )} + + {comparison.new.count} +

New Issues

+
- {comparison.new.count > 0 && ( -
-

- - {comparison.new.count} - - New issues -

-
- {comparison.new.items.map((comment, i) => ( - - ))} -
-
- )} + + {comparison.unchanged.count} +

Unchanged

+
+
+ + {/* Filter tabs */} + setFilter(id as typeof filter)} + layoutId="compare-filter-tabs" + /> - {comparison.unchanged.count > 0 && ( -
-

- - {comparison.unchanged.count} - - Unchanged issues -

-
- {comparison.unchanged.items.map((comment, i) => ( + {/* Comment List */} +
+ {filteredItems.length === 0 ? ( + + ) : ( + + {filteredItems.map((item, i) => ( + - ))} -
-
+ + ))} + )} - - {comparison.fixed.count === 0 && - comparison.new.count === 0 && - comparison.unchanged.count === 0 && ( - - -

- No comments in either review to compare. -

-
-
- )}
)} -
- ); -} - -function SummaryCard({ - label, - count, - description, - variant, -}: { - label: string; - count: number; - description: string; - variant: "success" | "warning" | "muted"; -}) { - const styles = { - success: - "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20", - warning: - "bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20", - muted: "bg-muted/50 text-muted-foreground border-border/60", - }[variant]; - - return ( - - -

{label}

-

{count}

-

{description}

-
-
+ ); } diff --git a/src/app/(dashboard)/repos/[id]/pr/[prNumber]/page.tsx b/src/app/(dashboard)/repos/[id]/pr/[prNumber]/page.tsx index 031d591..3cc3599 100644 --- a/src/app/(dashboard)/repos/[id]/pr/[prNumber]/page.tsx +++ b/src/app/(dashboard)/repos/[id]/pr/[prNumber]/page.tsx @@ -2,600 +2,335 @@ import { use, useState } from "react"; import Link from "next/link"; -import { trpc } from "@/lib/trpc/client"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { - PRHeaderSkeleton, - DiffFileSkeleton, -} from "@/components/shimmer-skeleton"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { AnimatedPage } from "@/components/ui/animated-page"; +import { AnimatedTabs } from "@/components/ui/animated-tabs"; +import { StatusDot } from "@/components/ui/status-dot"; +import { ReviewResult } from "@/components/review-result"; +import { DiffViewer } from "@/components/diff-viewer"; +import { EmptyState } from "@/components/ui/empty-state"; +import { PRHeaderSkeleton } from "@/components/shimmer-skeleton"; import { ArrowLeft, GitPullRequest, GitMerge, + CircleDot, ExternalLink, - Clock, + GitBranch, Plus, Minus, - FileText, - XCircle, - CheckCircle, - Loader2, - Sparkles, - GitBranch, - ArrowRight, + FileCode, Wand2, - ScanSearch, - BarChart3, + XCircle, + RotateCcw, + ArrowRightLeft, + Loader2 } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { DiffViewer } from "@/components/diff-viewer"; -import { ReviewResult } from "@/components/review-result"; -type PageProps = { - params: Promise<{ id: string; prNumber: string }>; -}; +function PRStateBadge({ state, merged }: { state: string; merged: boolean }) { + if (merged) { + return ( + + + Merged + + ); + } + if (state === "open") { + return ( + + + Open + + ); + } + return ( + + + Closed + + ); +} -export default function PullRequestDetailPage({ params }: PageProps) { - const { id, prNumber } = use(params); - const prNum = parseInt(prNumber, 10); - const [activeTab, setActiveTab] = useState<"review" | "files">("review"); +function ReviewStatusIndicator({ status }: { status: string }) { + const configs: Record = { + COMPLETED: { dot: "success", label: "Review complete" }, + PROCESSING: { dot: "processing", label: "Processing review..." }, + PENDING: { dot: "warning", label: "Review pending..." }, + FAILED: { dot: "error", label: "Review failed" }, + CANCELLED: { dot: "info", label: "Review cancelled" }, + }; + const config = configs[status] || configs.PENDING; - const pr = trpc.pullRequest.get.useQuery( - { repositoryId: id, prNumber: prNum }, - { enabled: !isNaN(prNum) }, + return ( +
+ + {config.label} +
); +} - const files = trpc.pullRequest.files.useQuery( - { repositoryId: id, prNumber: prNum }, - { enabled: !isNaN(prNum) }, +export default function PullRequestDetailPage({ + params, +}: { + params: Promise<{ id: string; prNumber: string }>; +}) { + const { id, prNumber } = use(params); + const prNumberInt = parseInt(prNumber, 10); + const [activeTab, setActiveTab] = useState<"reviews" | "files">("reviews"); + + const prQuery = trpc.pullRequest.get.useQuery({ + repositoryId: id, + prNumber: prNumberInt, + }); + + const filesQuery = trpc.pullRequest.files.useQuery( + { repositoryId: id, prNumber: prNumberInt }, + { enabled: activeTab === "files" } ); - const latestReview = trpc.review.getLatestForPR.useQuery( - { repositoryId: id, prNumber: prNum }, + const reviewQuery = trpc.review.getLatestForPR.useQuery( + { repositoryId: id, prNumber: prNumberInt }, { - enabled: !isNaN(prNum), refetchInterval: (query) => { const status = query.state.data?.status; - if (status === "PENDING" || status === "PROCESSING") { - return 2000; - } + if (status === "PENDING" || status === "PROCESSING") return 2000; return false; }, - }, + } ); - const triggerReview = trpc.review.trigger.useMutation({ + const triggerMutation = trpc.review.trigger.useMutation({ onSuccess: () => { - latestReview.refetch(); - pr.refetch(); + reviewQuery.refetch(); + toast.success("AI review started"); + }, + onError: (error) => { + toast.error(error.message); }, }); - const cancelReview = trpc.review.cancel.useMutation({ + const cancelMutation = trpc.review.cancel.useMutation({ onSuccess: () => { - latestReview.refetch(); + reviewQuery.refetch(); + toast.success("Review cancelled"); + }, + onError: (error) => { + toast.error(error.message); }, }); - const isReviewing = - latestReview.data?.status === "PENDING" || - latestReview.data?.status === "PROCESSING"; + const pr = prQuery.data; + const review = reviewQuery.data; + const isActive = review?.status === "PENDING" || review?.status === "PROCESSING"; + const canTrigger = !isActive; + const canCancel = isActive; - if (pr.isLoading) { + const tabs = [ + { id: "reviews" as const, label: "Reviews", icon: }, + { id: "files" as const, label: "Changed Files", icon: , count: pr?.changedFiles }, + ]; + + if (prQuery.isLoading) { return ; } - if (pr.error || !pr.data) { + if (!pr) { return ( - - -
- -
-

- {pr.error?.message ?? "Pull Request not found"} -

- - - -
-
+ + + } + /> ); } - const isMerged = pr.data.state === "closed" && pr.data.mergedAt; - return ( -
-
- - + + {/* Breadcrumb */} +
+ + Repositories + / + + Repo + + / + #{pr.number} +
-
-
-
-
-
- {isMerged ? ( - - ) : pr.data.state === "closed" ? ( - - ) : ( - - )} -
-
-

- {pr.data.title} -

-
- - - #{pr.data.number} - -
-
-
+ {/* PR Header */} +
+
+
+
+ + {review && }
- - - +

+ {pr.title} + #{pr.number} +

+ +
+ {pr.author?.login && ( + + {pr.author.avatarUrl && ( + {pr.author.login} + )} + {pr.author.login} + + )} + + + {pr.baseRef} + ← + {pr.headRef} + +
-
- - - - - {pr.data.author.login?.[0]?.toUpperCase() ?? "?"} - - - - {pr.data.author.login} - - - β€’ - - - +
-
- - -
-
-
-
- -
-
-

- Merged request -

-
- - {pr.data.headRef} - - - - {pr.data.baseRef} - -
-
-
-
+ {/* Stats row */} +
+
+ + {pr.additions} +
+
+ + {pr.deletions} +
+
+ + {pr.changedFiles} files +
-
- - - -
+
-
-
- -
- {isReviewing ? ( - + {/* Action buttons */} +
+ {review?.status === "COMPLETED" && ( + + )} + + {canCancel && review && ( + + )} - - - -
-
+ Cancel + + )} + + {canTrigger && ( + + )}
- - - - {/* Tabs */} -
-
- setActiveTab("review")} - icon={ScanSearch} - label="Reviews" - count={ - latestReview.data?.status === "COMPLETED" - ? Array.isArray(latestReview.data.comments) - ? latestReview.data.comments.length - : 0 - : 0 - } - /> - - setActiveTab("files")} - icon={FileText} - label="Changed Files" - count={files.data?.length} - />
+ {/* Tabs */} + setActiveTab(id as "reviews" | "files")} + layoutId="pr-detail-tabs" + /> + {/* Tab Content */} - {activeTab === "review" && ( -
- {latestReview.data ? ( +
+ {activeTab === "reviews" ? ( + review ? ( ) : ( - - -
- -
-

No reviews yet.

-

- Click "Run AI Review" to analyze this pull request - for bugs, security issues, and improvements. -

+ - triggerReview.mutate({ repositoryId: id, prNumber: prNum }) + triggerMutation.mutate({ repositoryId: id, prNumber: prNumberInt }) } - disabled={triggerReview.isPending} + disabled={triggerMutation.isPending} + className="gap-2 glow-primary" > + Run AI Review -
-
- )} -
- )} - - {activeTab === "files" && ( -
- {files.isLoading ? ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ) : files.error ? ( - - -
- -
-

- No files changed. -

-

- {files.error.message} -

-
-
- ) : files.data ? ( - - ) : null} -
- )} -
- ); -} - -function TabButton({ - active, - onClick, - icon: Icon, - label, - count, -}: { - active: boolean; - onClick: () => void; - icon: React.ComponentType<{ className?: string }>; - label: string; - count?: number; -}) { - return ( - - ); -} - -function StatItem({ - icon: Icon, - value, - label, - colorClass, - bgClass, -}: { - icon: React.ComponentType<{ className?: string }>; - value: number; - label?: string; - colorClass: string; - bgClass: string; -}) { - return ( -
-
- -
-
-

- {value.toLocaleString()} -

- {label && ( -

{label}

- )} + } + /> + ) + ) : filesQuery.isLoading ? ( +
+ +
+ ) : filesQuery.data ? ( + + ) : null}
-
- ); -} - -function PRStatusBadge({ - state, - isMerged, - draft, -}: { - state: string; - isMerged: boolean; - draft: boolean; -}) { - if (draft) { - return ( - - Draft - - ); - } - - if (isMerged) { - return ( - - - Merged - - ); - } - - if (state === "closed") { - return ( - - - Closed - - ); - } - - if (state === "open") { - return ( - - - Open - - ); - } -} - -function ReviewStatusBadge({ - status, - completedAt, -}: { - status: string | null; - completedAt?: Date | null; -}) { - const getTimeAgo = (date: Date) => { - const now = new Date(); - const diffMs = now.getTime() - new Date(date).getTime(); - const diffMin = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMin / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffMin < 1) return "just now"; - if (diffMin < 60) return `${diffMin} min ago`; - if (diffHours < 24) return `${diffHours}h ago`; - return `${diffDays}d ago`; - }; - - if (!status) { - return ( - - - Not reviewed - - ); - } - - const config = { - COMPLETED: { - icon: CheckCircle, - label: completedAt - ? `AI Review completed Β· ${getTimeAgo(completedAt)}` - : "AI Review completed", - className: - "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20", - }, - PROCESSING: { - icon: Loader2, - label: "Analyzing code…", - className: - "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", - spin: true, - }, - PENDING: { - icon: Clock, - label: "Queued for review", - className: - "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", - }, - FAILED: { - icon: XCircle, - label: "Review failed", - className: - "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", - }, - CANCELLED: { - icon: XCircle, - label: "Review cancelled", - className: "bg-muted text-muted-foreground border-border", - }, - }[status] ?? { - icon: Clock, - label: "Not reviewed", - className: "bg-muted text-muted-foreground", - }; - - const Icon = config.icon; - - return ( - - - {config.label} - + ); } diff --git a/src/app/(dashboard)/repos/page.tsx b/src/app/(dashboard)/repos/page.tsx index 1b3ca00..a834bab 100644 --- a/src/app/(dashboard)/repos/page.tsx +++ b/src/app/(dashboard)/repos/page.tsx @@ -1,16 +1,19 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import Link from "next/link"; -import { trpc } from "@/lib/trpc/client"; +import { motion, AnimatePresence } from "framer-motion"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; -import { Badge } from "@/components/ui/badge"; -import { - RepoCardSkeleton, - ImportRepoSkeleton, -} from "@/components/shimmer-skeleton"; +import { AnimatedPage } from "@/components/ui/animated-page"; +import { AnimatedList, AnimatedListItem } from "@/components/ui/animated-list"; +import { PageHeader } from "@/components/ui/page-header"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ConnectGithub } from "@/components/connect-github"; +import { RepoCardSkeleton, ImportRepoSkeleton } from "@/components/shimmer-skeleton"; import { AlertDialog, AlertDialogAction, @@ -23,25 +26,20 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { - GitBranch, - Lock, - Globe, - RefreshCw, + FolderGit2, Plus, - Trash2, - ArrowRight, - Star, - GitPullRequest, Search, X, + ArrowRight, + Trash2, + Lock, + Globe, + Loader2, CheckCircle, - FolderGit2, + GitPullRequest, } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Input } from "@/components/ui/input"; -import { ConnectGithub } from "@/components/connect-github"; -interface GitHubRepo { +interface GithubRepo { githubId: number; name: string; fullName: string; @@ -53,510 +51,383 @@ interface GitHubRepo { updatedAt: string; } -const languageColors: Record = { - TypeScript: "bg-blue-500", - JavaScript: "bg-yellow-400", - Python: "bg-green-500", - Go: "bg-cyan-500", - Rust: "bg-orange-500", - Java: "bg-red-500", - Ruby: "bg-red-400", - PHP: "bg-purple-500", - "C#": "bg-green-600", - "C++": "bg-pink-500", - C: "bg-gray-500", - Swift: "bg-orange-400", - Kotlin: "bg-purple-400", - Dart: "bg-blue-400", - Vue: "bg-emerald-500", - Svelte: "bg-orange-600", -}; +function RepoSelectItem({ + repo, + selected, + onToggle, +}: { + repo: GithubRepo; + selected: boolean; + onToggle: () => void; +}) { + return ( + + +
+

+ {repo.fullName} +

+
+ {repo.language && ( + {repo.language} + )} + {repo.private ? ( + + + Private + + ) : ( + + + Public + + )} +
+
+
+ ); +} + +function ConnectedRepoCard({ + repo, + onDisconnect, + isDisconnecting, +}: { + repo: { id: string; name: string; fullName: string; private: boolean }; + onDisconnect: () => void; + isDisconnecting: boolean; +}) { + return ( + +
+
+
+ +
+
+

{repo.name}

+

{repo.fullName}

+
+
+ + + + + + + + Disconnect repository? + + This will remove {repo.fullName} and all its + review data. This action cannot be undone. + + + + Cancel + + {isDisconnecting ? ( + + ) : ( + "Disconnect" + )} + + + + +
+ +
+ {repo.private ? ( + + + Private + + ) : ( + + + Public + + )} +
+ +
+ +
+
+ ); +} export default function ReposPage() { - const [selectedRepos, setSelectedRepos] = useState>(new Set()); - const [showGitHubRepos, setShowGitHubRepos] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); + const [showImport, setShowImport] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [search, setSearch] = useState(""); - const connectedRepos = trpc.repository.list.useQuery(); - const githubRepos = trpc.repository.fetchFromGithub.useQuery(undefined, { - enabled: showGitHubRepos, + const reposQuery = trpc.repository.list.useQuery(); + const githubQuery = trpc.repository.fetchFromGithub.useQuery(undefined, { + enabled: showImport, }); - const connectMutation = trpc.repository.connect.useMutation({ onSuccess: () => { - connectedRepos.refetch(); - setSelectedRepos(new Set()); - setShowGitHubRepos(false); + reposQuery.refetch(); + githubQuery.refetch(); + setSelected(new Set()); + setShowImport(false); + toast.success("Repositories connected successfully"); + }, + onError: (error) => { + toast.error(error.message); }, }); - const disconnectMutation = trpc.repository.disconnect.useMutation({ onSuccess: () => { - connectedRepos.refetch(); + reposQuery.refetch(); + toast.success("Repository disconnected"); + }, + onError: (error) => { + toast.error(error.message); }, }); - const connectedIds = new Set( - connectedRepos.data?.map((repo) => repo.githubId) || [], - ); - - const availableRepos = - githubRepos.data?.filter((repo) => !connectedIds.has(repo.githubId)) || []; + const isGithubNotLinked = + githubQuery.error?.data?.code === "PRECONDITION_FAILED"; - const filteredAvailableRepos = availableRepos.filter( - (repo) => - repo.name.toLowerCase().includes(searchQuery.toLowerCase()) || - repo.description?.toLowerCase().includes(searchQuery.toLowerCase()), + const connectedIds = useMemo( + () => new Set(reposQuery.data?.map((r) => r.githubId) ?? []), + [reposQuery.data] ); - const toggleRepo = (githubId: number) => { - const next = new Set(selectedRepos); - if (next.has(githubId)) { - next.delete(githubId); - } else { - next.add(githubId); - } - setSelectedRepos(next); - }; + const filteredRepos = useMemo(() => { + if (!githubQuery.data) return []; + return githubQuery.data + .filter((r) => !connectedIds.has(r.githubId)) + .filter((r) => + r.fullName.toLowerCase().includes(search.toLowerCase()) + ); + }, [githubQuery.data, connectedIds, search]); const handleConnect = () => { - const reposToConnect = availableRepos - .filter((r) => selectedRepos.has(r.githubId)) - .map((r) => ({ + if (selected.size === 0 || !githubQuery.data) return; + const repos = githubQuery.data.filter((r) => selected.has(r.githubId)); + connectMutation.mutate({ + repos: repos.map((r) => ({ githubId: r.githubId, name: r.name, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl, - })); - connectMutation.mutate({ repos: reposToConnect }); + })), + }); }; - const selectAll = () => { - setSelectedRepos(new Set(filteredAvailableRepos.map((r) => r.githubId))); + const toggleSelect = (id: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); }; - const clearSelection = () => { - setSelectedRepos(new Set()); + const toggleAll = () => { + if (selected.size === filteredRepos.length) { + setSelected(new Set()); + } else { + setSelected(new Set(filteredRepos.map((r) => r.githubId))); + } }; return ( -
-
-
-

- Repositories -

-

- Select repositories to connect to your account. -

-
- -
- - {showGitHubRepos && ( - -
-
-
-

Import GitHub Repositories

-

- Select repositories to import from GitHub. -

-
- -
-
- - - {githubRepos.isLoading ? ( -
- {[...Array(4)].map((_, i) => ( - - ))} -
- ) : githubRepos.error ? ( -
- {githubRepos.error.data?.code === "PRECONDITION_FAILED" ? ( - - ) : ( -
-

- {githubRepos.error.message} -

-
- )} -
- ) : availableRepos.length === 0 ? ( -
-
- -
-

All caught up!

-

- All your repos are already connected! -

-
+ + setShowImport(!showImport)} + className="gap-2" + size="sm" + > + {showImport ? ( + <> + + Close + ) : ( <> -
-
- + + Add Repository + + )} + + } + /> + + {/* Import panel */} + + {showImport && ( + +
+ {isGithubNotLinked ? ( + + ) : ( + <> +
+

+ Import from GitHub +

+ {filteredRepos.length > 0 && ( + + )} +
+ +
+ setSearchQuery(e.target.value)} - className="pl-10" + placeholder="Search repositories..." + value={search} + onChange={(e) => setSearch(e.target.value)} + className="pl-9 h-9 bg-background/50 border-border/50" />
-
- - {selectedRepos.size > 0 && ( - <> - β€’ - - - )} -
-
-
- {filteredAvailableRepos.length === 0 ? ( -
-

- {" "} - No repositories match your search. +

+ {githubQuery.isLoading ? ( + Array.from({ length: 4 }).map((_, i) => ( + + )) + ) : filteredRepos.length === 0 ? ( +

+ {search + ? "No repositories match your search." + : "All repositories are connected."}

-
- ) : ( -
- {filteredAvailableRepos.map((repo) => ( + ) : ( + filteredRepos.map((repo) => ( toggleRepo(repo.githubId)} + selected={selected.has(repo.githubId)} + onToggle={() => toggleSelect(repo.githubId)} /> - ))} -
- )} -
+ )) + )} +
-
-

- {selectedRepos.size} of {filteredAvailableRepos.length}{" "} - selected -

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

- Connected Repositories -

- {connectedRepos.data && connectedRepos.data.length > 0 && ( - - {connectedRepos.data.length} - - )} -
+ Connect + + + )} + + )} +
+
+ )} +
- {connectedRepos.isLoading ? ( -
- {[...Array(4)].map((_, i) => ( + {/* Connected repos grid */} +
+ {reposQuery.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( ))}
- ) : connectedRepos.data?.length === 0 ? ( - - -
- -
-

- No connected repositories found. -

-

- Connect your GitHub repositories to start getting AI-powered - code reviews on your pull requests. -

- -
-
+ } + /> ) : ( -
- {connectedRepos.data?.map((repo) => ( - disconnectMutation.mutate({ id: repo.id })} - isDisconnecting={disconnectMutation.isPending} - /> + + {reposQuery.data?.map((repo) => ( + + + disconnectMutation.mutate({ id: repo.id }) + } + isDisconnecting={disconnectMutation.isPending} + /> + ))} -
- )} -
-
- ); -} - -function ConnectedRepoCard({ - repo, - onDisconnect, - isDisconnecting, -}: { - repo: { - id: string; - fullName: string; - private: boolean; - createdAt: Date; - }; - onDisconnect: () => void; - isDisconnecting: boolean; -}) { - return ( - - -
- -
-
- {repo.private ? ( - - ) : ( - - )} -
-
- - {repo.fullName} - -
- - {repo.private ? "Private" : "Public"} - -
-
-
- - - - - - - - - Disconnect Repository - - Are you sure you want to disconnect{" "} - - {repo.fullName} - - ? This will remove all review history for this repository. - - - - Cancel - - Disconnect - - - - -
- -
- - Connected {formatDate(repo.createdAt)} - - - - -
-
-
- ); -} - -function RepoSelectItem({ - repo, - selected, - onToggle, -}: { - repo: GitHubRepo; - selected: boolean; - onToggle: () => void; -}) { - const langColor = repo.language - ? languageColors[repo.language] || "bg-gray-400" - : null; - - return ( - + ); } - -function formatDate(date: Date): string { - const now = new Date(); - const diffMs = now.getTime() - new Date(date).getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - if (diffDays === 0) return "today"; - if (diffDays === 1) return "yesterday"; - if (diffDays < 7) return `${diffDays} days ago`; - if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; - if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`; - - return new Date(date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); -} diff --git a/src/app/(dashboard)/reviews/page.tsx b/src/app/(dashboard)/reviews/page.tsx index 187fe1a..5a3817d 100644 --- a/src/app/(dashboard)/reviews/page.tsx +++ b/src/app/(dashboard)/reviews/page.tsx @@ -1,447 +1,270 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import Link from "next/link"; -import { trpc } from "@/lib/trpc/client"; +import { motion } from "framer-motion"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; +import { AnimatedPage } from "@/components/ui/animated-page"; +import { AnimatedList, AnimatedListItem } from "@/components/ui/animated-list"; +import { AnimatedTabs } from "@/components/ui/animated-tabs"; +import { PageHeader } from "@/components/ui/page-header"; +import { EmptyState } from "@/components/ui/empty-state"; +import { StatusDot } from "@/components/ui/status-dot"; import { ReviewCardSkeleton } from "@/components/shimmer-skeleton"; import { - Ban, - GitPullRequest, - Clock, CheckCircle, - XCircle, Loader2, - AlertTriangle, - FileText, - ExternalLink, - RefreshCw, + Clock, + XCircle, + Ban, + RotateCcw, + MessageSquare, + GitPullRequest, } from "lucide-react"; -import { cn } from "@/lib/utils"; type ReviewStatus = "all" | "COMPLETED" | "PROCESSING" | "PENDING" | "FAILED" | "CANCELLED"; -export default function ReviewsPage() { - const [statusFilter, setStatusFilter] = useState("all"); - - const reviews = trpc.review.list.useQuery( - { limit: 50 }, - { - refetchInterval: (query) => { - const hasProcessing = query.state.data?.some( - (r) => r.status === "PENDING" || r.status === "PROCESSING", - ); - return hasProcessing ? 3000 : false; - }, - }, - ); +function formatRelativeTime(date: string | Date): string { + const now = new Date(); + const d = new Date(date); + const diffMs = now.getTime() - d.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); - const triggerReview = trpc.review.trigger.useMutation({ - onSuccess: () => { - reviews.refetch(); - }, - }); + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return d.toLocaleDateString(); +} - const filteredReviews = reviews.data?.filter( - (r) => statusFilter === "all" || r.status === statusFilter, - ); +function getRiskConfig(score: number) { + if (score <= 30) return { label: "Low", className: "bg-emerald-500/10 text-emerald-500 ring-emerald-500/20" }; + if (score <= 60) return { label: "Medium", className: "bg-amber-500/10 text-amber-500 ring-amber-500/20" }; + return { label: "High", className: "bg-red-500/10 text-red-500 ring-red-500/20" }; +} - const statusCounts = { - all: reviews.data?.length ?? 0, - COMPLETED: - reviews.data?.filter((r) => r.status === "COMPLETED").length ?? 0, - PROCESSING: - reviews.data?.filter((r) => r.status === "PROCESSING").length ?? 0, - PENDING: reviews.data?.filter((r) => r.status === "PENDING").length ?? 0, - FAILED: reviews.data?.filter((r) => r.status === "FAILED").length ?? 0, - CANCELLED: - reviews.data?.filter((r) => r.status === "CANCELLED").length ?? 0, +function getStatusConfig(status: string): { icon: React.ReactNode; dot: "success" | "processing" | "warning" | "error" | "info"; label: string } { + const configs: Record = { + COMPLETED: { icon: , dot: "success", label: "Completed" }, + PROCESSING: { icon: , dot: "processing", label: "Processing" }, + PENDING: { icon: , dot: "warning", label: "Pending" }, + FAILED: { icon: , dot: "error", label: "Failed" }, + CANCELLED: { icon: , dot: "info", label: "Cancelled" }, }; - - return ( -
-
-
-

Reviews

-

- {statusCounts.all} total reviews -

-
- -
- -
- {(["all", "COMPLETED", "PROCESSING", "PENDING", "FAILED", "CANCELLED"] as const).map( - (status) => ( - - ), - )} -
- - {reviews.isLoading ? ( -
- {[...Array(5)].map((_, i) => ( - - ))} -
- ) : reviews.error ? ( - - -

{reviews.error.message}

-
-
- ) : filteredReviews?.length === 0 ? ( - - -
- -
-

- {statusFilter === "all" - ? "No reviews yet" - : `No ${statusFilter.toLowerCase()} reviews`} -

-

- {statusFilter === "all" && - "Run your first AI review on a pull request!"} -

-
-
- ) : ( -
- {filteredReviews?.map((review) => ( - - triggerReview.mutate({ - repositoryId: review.repository.id, - prNumber: review.prNumber, - }) - : undefined - } - /> - ))} -
- )} -
- ); + return configs[status] || configs.PENDING; } -interface ReviewCardProps { +function ReviewCard({ + review, + onRetry, + isRetrying, +}: { review: { id: string; - prNumber: number; - prTitle: string; - prUrl: string; status: string; - summary: string | null; riskScore: number | null; + summary: string | null; comments: unknown; - error: string | null; - createdAt: Date; - repository: { - id: string; - fullName: string; - }; + createdAt: string | Date; + repositoryId: string; + prNumber: number; + prTitle: string; + prUrl: string; + repository: { id: string; name: string; fullName: string }; }; - onRetry?: () => void; -} + onRetry: () => void; + isRetrying: boolean; +}) { + const config = getStatusConfig(review.status); + const commentCount = Array.isArray(review.comments) ? review.comments.length : 0; -function ReviewCard({ review, onRetry }: ReviewCardProps) { - const commentCount = Array.isArray(review.comments) - ? review.comments.length - : 0; + return ( + + +
{config.icon}
- const getStatusMessage = () => { - switch (review.status) { - case "PENDING": - return "Queued β€” will start shortly"; - case "PROCESSING": - return "Analyzing code…"; - case "FAILED": - return review.error || "Analysis failed"; - case "CANCELLED": - return "Review was cancelled"; - default: - return null; - } - }; +
+
+ + {review.prTitle} + + + #{review.prNumber} + +
+ +
+ {review.repository.name} + {formatRelativeTime(review.createdAt)} + {commentCount > 0 && ( + + + {commentCount} + + )} +
- const statusMessage = getStatusMessage(); + {review.summary && review.status === "COMPLETED" && ( +

+ {review.summary} +

+ )} +
- return ( - - -
-
-
+ {review.riskScore !== null && review.status === "COMPLETED" && ( + + {review.riskScore} + + )} + + {review.status === "FAILED" && ( +
-
-
- - {review.prTitle} - - -
-
- - {review.repository.fullName} - - β€’ - #{review.prNumber} - β€’ - - - {formatRelativeTime(review.createdAt)} - -
- {review.status === "COMPLETED" && ( -
- {review.riskScore !== null && ( - - )} - {commentCount > 0 && ( - - - {commentCount}{" "} - {commentCount === 1 ? "comment" : "comments"} - - )} -
- )} - {review.summary && review.status === "COMPLETED" && ( -

- {review.summary} -

- )} - {statusMessage && review.status !== "COMPLETED" && ( -

- {statusMessage} -

+ {isRetrying ? ( + + ) : ( + )} -
-
-
- - - - {review.status === "FAILED" && onRetry ? ( - - ) : ( - - - - )} -
+ + )}
-
-
+ +
); } -function getStatusBg(status: string) { - switch (status) { - case "COMPLETED": - return "bg-emerald-500/10"; - case "PROCESSING": - return "bg-blue-500/10"; - case "PENDING": - return "bg-amber-500/10"; - case "FAILED": - return "bg-red-500/10"; - case "CANCELLED": - return "bg-muted"; - default: - return "bg-muted"; - } -} - -function StatusBadge({ status }: { status: string }) { - const variants: Record< - string, - "success" | "info" | "warning" | "destructive" - > = { - COMPLETED: "success", - PROCESSING: "info", - PENDING: "warning", - FAILED: "destructive", - CANCELLED: "secondary", - }; +export default function ReviewsPage() { + const [statusFilter, setStatusFilter] = useState("all"); - return ( - - {status.charAt(0) + status.slice(1).toLowerCase()} - + const reviewsQuery = trpc.review.list.useQuery( + { limit: 50 }, + { + refetchInterval: (query) => { + const data = query.state.data; + if (!data) return false; + const hasActive = data.some( + (r) => r.status === "PENDING" || r.status === "PROCESSING" + ); + return hasActive ? 3000 : false; + }, + } ); -} -function StatusIcon({ - status, - className, -}: { - status: string; - className?: string; -}) { - switch (status) { - case "COMPLETED": - return ( - - ); - case "PROCESSING": - return ( - - ); - case "PENDING": - return ( - - ); - case "FAILED": - return ( - - ); - case "CANCELLED": - return ( - - ); - default: - return ( - - ); - } -} + const retryMutation = trpc.review.trigger.useMutation({ + onSuccess: () => { + reviewsQuery.refetch(); + toast.success("Review retry started"); + }, + onError: (error) => { + toast.error(error.message); + }, + }); -function RiskScoreBadge({ score }: { score: number }) { - const config = getRiskConfig(score); + const filteredReviews = useMemo(() => { + if (!reviewsQuery.data) return []; + if (statusFilter === "all") return reviewsQuery.data; + return reviewsQuery.data.filter((r) => r.status === statusFilter); + }, [reviewsQuery.data, statusFilter]); - return ( - - - {config.label} - - {score} - - - ); -} + const statusCounts = useMemo(() => { + if (!reviewsQuery.data) return {}; + const counts: Record = {}; + reviewsQuery.data.forEach((r) => { + counts[r.status] = (counts[r.status] || 0) + 1; + }); + return counts; + }, [reviewsQuery.data]); -function getRiskConfig(score: number) { - if (score < 25) - return { - label: "Low", - textColor: "text-emerald-600 dark:text-emerald-400", - barColor: "bg-emerald-500", - }; - if (score < 50) - return { - label: "Medium", - textColor: "text-amber-600 dark:text-amber-400", - barColor: "bg-amber-500", - }; - if (score < 75) - return { - label: "High", - textColor: "text-orange-600 dark:text-orange-400", - barColor: "bg-orange-500", - }; - return { - label: "Critical", - textColor: "text-red-600 dark:text-red-400", - barColor: "bg-red-500", - }; -} + const tabs = [ + { id: "all" as const, label: "All", count: reviewsQuery.data?.length }, + { id: "COMPLETED" as const, label: "Completed", icon: , count: statusCounts.COMPLETED }, + { id: "PROCESSING" as const, label: "Processing", icon: , count: statusCounts.PROCESSING }, + { id: "PENDING" as const, label: "Pending", icon: , count: statusCounts.PENDING }, + { id: "FAILED" as const, label: "Failed", icon: , count: statusCounts.FAILED }, + { id: "CANCELLED" as const, label: "Cancelled", icon: , count: statusCounts.CANCELLED }, + ]; -function formatRelativeTime(date: Date): string { - const now = new Date(); - const diffMs = now.getTime() - new Date(date).getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const hasActivePoll = reviewsQuery.data?.some( + (r) => r.status === "PENDING" || r.status === "PROCESSING" + ); - if (diffMins < 1) return "Just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays === 1) return "Yesterday"; - if (diffDays < 7) return `${diffDays}d ago`; + return ( + + + + Live updating +
+ ) : undefined + } + /> - return new Date(date).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); +
+ setStatusFilter(id as ReviewStatus)} + layoutId="review-status-tabs" + /> +
+ +
+ {reviewsQuery.isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : filteredReviews.length === 0 ? ( + + ) : ( + + {filteredReviews.map((review) => ( + + + retryMutation.mutate({ + repositoryId: review.repositoryId, + prNumber: review.prNumber, + }) + } + isRetrying={retryMutation.isPending} + /> + + ))} + + )} +
+ + ); } diff --git a/src/app/apple-icon.tsx b/src/app/apple-icon.tsx new file mode 100644 index 0000000..bbc0f8d --- /dev/null +++ b/src/app/apple-icon.tsx @@ -0,0 +1,37 @@ +import { ImageResponse } from "next/og"; + +export const size = { width: 180, height: 180 }; +export const contentType = "image/png"; + +export default function AppleIcon() { + return new ImageResponse( + ( +
+ + + + +
+ ), + { ...size } + ); +} diff --git a/src/app/error.tsx b/src/app/error.tsx index 2abb65f..72d267c 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -2,7 +2,8 @@ import { useEffect } from "react"; import Link from "next/link"; -import { AlertTriangle, RefreshCw, Home } from "lucide-react"; +import { motion } from "framer-motion"; +import { AlertTriangle, ArrowLeft, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; export default function GlobalError({ @@ -13,53 +14,54 @@ export default function GlobalError({ reset: () => void; }) { useEffect(() => { - console.error("Global error:", error); + console.error("Application error:", error); }, [error]); return ( -
- {/* Animated error illustration */} -
-
-
- -
-
+
+
+
-

- Something went wrong -

-

- An unexpected error occurred. Please try again or return to the home - page. -

+ + + + - {error.message && ( -
-

- {error.message} -

-
- )} +

Something went wrong

+

+ An unexpected error occurred. Don't worry, your data is safe. Try + refreshing the page or going back to the dashboard. +

-
- - -
+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} - {error.digest && ( -

- Error ID: {error.digest} -

- )} +
+ + +
+
); } diff --git a/src/app/globals.css b/src/app/globals.css index 40b91f9..3752988 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -47,89 +47,253 @@ --radius-4xl: calc(var(--radius) + 16px); } +/* ─── Light Mode ─────────────────────────────────────────────────────────── */ :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); + --radius: 0.75rem; + --background: oklch(0.98 0.004 260); + --foreground: oklch(0.12 0.02 265); --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --card-foreground: oklch(0.12 0.02 265); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); + --popover-foreground: oklch(0.12 0.02 265); + --primary: oklch(0.55 0.22 280); + --primary-foreground: oklch(0.98 0.004 260); + --secondary: oklch(0.94 0.01 265); + --secondary-foreground: oklch(0.25 0.03 265); + --muted: oklch(0.94 0.008 265); + --muted-foreground: oklch(0.5 0.02 265); + --accent: oklch(0.7 0.18 200); + --accent-foreground: oklch(0.12 0.02 265); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --border: oklch(0.88 0.01 265); + --input: oklch(0.88 0.01 265); + --ring: oklch(0.55 0.22 280 / 40%); + --chart-1: oklch(0.55 0.22 280); + --chart-2: oklch(0.7 0.18 200); + --chart-3: oklch(0.65 0.2 240); + --chart-4: oklch(0.6 0.2 310); + --chart-5: oklch(0.75 0.16 160); + --sidebar: oklch(0.96 0.005 265); + --sidebar-foreground: oklch(0.12 0.02 265); + --sidebar-primary: oklch(0.55 0.22 280); + --sidebar-primary-foreground: oklch(0.98 0.004 260); + --sidebar-accent: oklch(0.94 0.01 265); + --sidebar-accent-foreground: oklch(0.25 0.03 265); + --sidebar-border: oklch(0.88 0.01 265); + --sidebar-ring: oklch(0.55 0.22 280 / 40%); } +/* ─── Dark Mode ──────────────────────────────────────────────────────────── */ .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.08 0.018 268); + --foreground: oklch(0.96 0.005 265); + --card: oklch(0.11 0.018 268); + --card-foreground: oklch(0.96 0.005 265); + --popover: oklch(0.11 0.018 268); + --popover-foreground: oklch(0.96 0.005 265); + --primary: oklch(0.67 0.23 280); + --primary-foreground: oklch(0.08 0.018 268); + --secondary: oklch(0.16 0.02 268); + --secondary-foreground: oklch(0.96 0.005 265); + --muted: oklch(0.14 0.018 268); + --muted-foreground: oklch(0.58 0.02 268); + --accent: oklch(0.72 0.19 200); + --accent-foreground: oklch(0.08 0.018 268); + --destructive: oklch(0.65 0.22 25); + --border: oklch(0.96 0.005 265 / 9%); + --input: oklch(0.96 0.005 265 / 11%); + --ring: oklch(0.67 0.23 280 / 35%); + --chart-1: oklch(0.67 0.23 280); + --chart-2: oklch(0.72 0.19 200); + --chart-3: oklch(0.65 0.2 240); + --chart-4: oklch(0.62 0.2 310); + --chart-5: oklch(0.75 0.16 160); + --sidebar: oklch(0.1 0.018 268); + --sidebar-foreground: oklch(0.96 0.005 265); + --sidebar-primary: oklch(0.67 0.23 280); + --sidebar-primary-foreground: oklch(0.08 0.018 268); + --sidebar-accent: oklch(0.16 0.02 268); + --sidebar-accent-foreground: oklch(0.96 0.005 265); + --sidebar-border: oklch(0.96 0.005 265 / 9%); + --sidebar-ring: oklch(0.67 0.23 280 / 35%); } +/* ─── Base Styles ──────────────────────────────────────────────────────── */ @layer base { * { @apply border-border outline-ring/50; - @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; - @apply bg-background text-foreground; + font-feature-settings: "rlig" 1, "calt" 1; + } + html { + scroll-behavior: smooth; + } + ::selection { + background: oklch(0.67 0.23 280 / 25%); } } -/* Shimmer skeleton animation */ +/* ─── Custom Scrollbar ─────────────────────────────────────────────────── */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: oklch(0.3 0.02 268); + border-radius: 999px; +} +::-webkit-scrollbar-thumb:hover { + background: oklch(0.4 0.03 268); +} + +/* ─── Keyframe Animations ──────────────────────────────────────────────── */ @keyframes shimmer { 100% { transform: translateX(100%); } -} \ No newline at end of file +} + +@keyframes float { + 0%, 100% { transform: translateY(0px) rotate(0deg); } + 33% { transform: translateY(-18px) rotate(2deg); } + 66% { transform: translateY(-8px) rotate(-1deg); } +} + +@keyframes float-slow { + 0%, 100% { transform: translateY(0px) rotate(0deg) scale(1); } + 50% { transform: translateY(-30px) rotate(5deg) scale(1.03); } +} + +@keyframes pulse-glow { + 0%, 100% { opacity: 0.4; transform: scale(1); } + 50% { opacity: 0.7; transform: scale(1.05); } +} + +@keyframes gradient-shift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes spin-slow { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes border-flow { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(24px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes grid-fade { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ─── Utility Classes ──────────────────────────────────────────────────── */ +.text-gradient { + background: linear-gradient(135deg, oklch(0.75 0.22 280), oklch(0.78 0.2 200)); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; +} + +.text-gradient-violet { + background: linear-gradient(135deg, oklch(0.72 0.24 280), oklch(0.65 0.23 310)); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; +} + +.glass { + background: oklch(1 0 0 / 4%); + backdrop-filter: blur(12px) saturate(180%); + -webkit-backdrop-filter: blur(12px) saturate(180%); + border: 1px solid oklch(1 0 0 / 8%); +} + +.glass-card { + background: oklch(0.11 0.018 268 / 80%); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid oklch(1 0 0 / 8%); +} + +.glow-primary { + box-shadow: 0 0 30px oklch(0.67 0.23 280 / 25%), 0 0 60px oklch(0.67 0.23 280 / 10%); +} + +.glow-border { + position: relative; +} +.glow-border::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + background: linear-gradient(135deg, oklch(0.75 0.22 280), oklch(0.72 0.19 200), oklch(0.75 0.22 280)); + background-size: 200% 200%; + animation: border-flow 4s ease infinite; + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; +} + +.animate-float { + animation: float 6s ease-in-out infinite; +} + +.animate-float-slow { + animation: float-slow 9s ease-in-out infinite; +} + +.animate-pulse-glow { + animation: pulse-glow 3s ease-in-out infinite; +} + +.animate-gradient { + background-size: 200% 200%; + animation: gradient-shift 4s ease infinite; +} + +.animate-spin-slow { + animation: spin-slow 20s linear infinite; +} + +.animate-fade-in-up { + animation: fadeInUp 0.6s ease forwards; +} + +/* Grid background */ +.bg-grid { + background-image: + linear-gradient(oklch(1 0 0 / 3%) 1px, transparent 1px), + linear-gradient(90deg, oklch(1 0 0 / 3%) 1px, transparent 1px); + background-size: 50px 50px; +} + +.bg-dot { + background-image: radial-gradient(circle, oklch(1 0 0 / 8%) 1px, transparent 1px); + background-size: 24px 24px; +} diff --git a/src/app/icon.tsx b/src/app/icon.tsx new file mode 100644 index 0000000..6f319d2 --- /dev/null +++ b/src/app/icon.tsx @@ -0,0 +1,38 @@ +import { ImageResponse } from "next/og"; + +export const size = { width: 32, height: 32 }; +export const contentType = "image/png"; + +export default function Icon() { + return new ImageResponse( + ( +
+ {/* Code brackets " + + + +
+ ), + { ...size } + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 06901b5..5deb346 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { TRPCProvider } from "@/lib/trpc"; import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "sonner"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -71,7 +72,8 @@ export const metadata: Metadata = { }, }, icons: { - icon: "/favicon.ico", + icon: "/icon", + apple: "/apple-icon", }, }; @@ -87,11 +89,12 @@ export default function RootLayout({ > {children} + diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index dfc9db2..b0aa3e8 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,49 +1,46 @@ import Link from "next/link"; import type { Metadata } from "next"; -import { FileQuestion, Home, ArrowLeft } from "lucide-react"; +import { ArrowLeft, Home, FileQuestion } from "lucide-react"; import { Button } from "@/components/ui/button"; export const metadata: Metadata = { title: "Page Not Found", - description: "The page you're looking for doesn't exist.", robots: { index: false, follow: false }, }; export default function NotFound() { return ( -
- {/* Animated illustration */} -
-
-
- +
+
+
+ +
+
+
-
-

- 404 -

-

- Page not found -

-

- The page you're looking for doesn't exist or may have been - moved. Double-check the URL or head back to a familiar place. -

+
404
-
- - +

Page not found

+

+ The page you're looking for doesn't exist or has been moved. + Let's get you back on track. +

+ +
+ + +
); diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx index 0f3c744..969713f 100644 --- a/src/app/opengraph-image.tsx +++ b/src/app/opengraph-image.tsx @@ -16,22 +16,35 @@ export default function OGImage() { flexDirection: "column", alignItems: "center", justifyContent: "center", - backgroundColor: "#09090b", + backgroundColor: "#0a0a1a", fontFamily: "sans-serif", + position: "relative", + overflow: "hidden", }} > - {/* Gradient orb */} + {/* Background gradient orbs */}
+
@@ -40,10 +53,11 @@ export default function OGImage() { display: "flex", flexDirection: "column", alignItems: "center", - gap: 24, + gap: 28, + position: "relative", }} > - {/* Logo & Title */} + {/* Logo */}
- CR + + + +
- CodeReviewAI + CodeReview + + AI +
{/* Tagline */}

AI-powered code reviews that catch bugs, security issues, and - maintainability problems. + maintainability problems before they reach production.

- {/* Features row */} + {/* Feature pills */}
{["Instant Feedback", "Security Scanning", "GitHub Integration"].map( @@ -109,8 +141,13 @@ export default function OGImage() { display: "flex", alignItems: "center", gap: 8, - color: "#71717a", - fontSize: 16, + backgroundColor: "rgba(124,58,237,0.1)", + border: "1px solid rgba(124,58,237,0.2)", + borderRadius: 999, + padding: "8px 18px", + color: "#c4b5fd", + fontSize: 15, + fontWeight: 500, }} >
- {/* Bottom bar */} + {/* Bottom tagline */}
Ship better code, faster diff --git a/src/app/page.tsx b/src/app/page.tsx index efc8c31..d9d82fd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,17 +1,5 @@ import type { Metadata } from "next"; -import Link from "next/link"; -import { - ArrowRight, - CheckCircle, - GitPullRequest, - GitMerge, - MessageSquare, - ScanSearch, - Shield, - Wand2, - Zap, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { LandingPage } from "@/components/landing-page"; export const metadata: Metadata = { title: "CodeReviewAI β€” AI-Powered Code Reviews", @@ -56,225 +44,9 @@ function LandingJsonLd() { export default function HomePage() { return ( -
+ <> - {/* Header */} -
-
- - CodeReviewAI - - -
- - -
-
-
- -
- {/* Hero */} -
-
-
-
- -
-

- Ship better code, -
- faster -

- -

- Automated code reviews that catch bugs, security issues, and - maintainability problems before they reach production. -

- -
- - -
- -
- - - No credit card required - - - - GitHub integration - - - - Private repos supported - -
-
-
- - {/* Features */} -
-
-
-

- Everything you need for better reviews -

-

- Focus on building. Let AI handle the repetitive review work. -

-
- -
- {[ - { - icon: Zap, - title: "Instant feedback", - description: - "Get comprehensive reviews in seconds, not hours.", - }, - { - icon: Shield, - title: "Security scanning", - description: - "Detect vulnerabilities and secrets automatically.", - }, - { - icon: MessageSquare, - title: "Clear suggestions", - description: "Actionable feedback you can apply immediately.", - }, - { - icon: GitPullRequest, - title: "PR integration", - description: "Reviews appear right in your pull requests.", - }, - { - icon: ScanSearch, - title: "Context aware", - description: "Understands your codebase patterns and style.", - }, - { - icon: Wand2, - title: "Always improving", - description: "Powered by the latest AI models.", - }, - ].map((feature) => ( -
-
- -
-

{feature.title}

-

- {feature.description} -

-
- ))} -
-
-
- - {/* How it works */} -
-
-
-

- Up and running in minutes -

-

- Three steps to better code reviews. -

-
- -
- {[ - { - step: "1", - icon: GitPullRequest, - title: "Connect GitHub", - description: "Sign in and select repositories to enable.", - }, - { - step: "2", - icon: ScanSearch, - title: "Open a PR", - description: - "CodeReviewAI triggers automatically on every pull request.", - }, - { - step: "3", - icon: GitMerge, - title: "Merge with confidence", - description: "Address suggestions and ship faster.", - }, - ].map((item) => ( -
-
- -
-

{item.title}

-

- {item.description} -

-
- ))} -
-
-
- - {/* CTA */} -
-
-

- Ready to improve your code reviews? -

-

- Start free. Upgrade when your team needs more. -

- -
-
-
- - {/* Footer */} -
-
- Β© 2025 CodeReviewAI -
- - Sign in - - - Get started - -
-
-
-
+ + ); } diff --git a/src/components/analytics/activity-heatmap.css b/src/components/analytics/activity-heatmap.css index 8da4c01..288e4a2 100644 --- a/src/components/analytics/activity-heatmap.css +++ b/src/components/analytics/activity-heatmap.css @@ -1,72 +1,53 @@ -.activity-heatmap-container { - width: 100%; - min-width: 0; -} +/* ─── Activity Heatmap ──────────────────────────────────────────────────── */ -.activity-heatmap-container svg { +.heatmap-container svg { width: 100%; - max-width: 100%; + min-width: 700px; height: auto; - display: block; - vertical-align: middle; } -.activity-heatmap-container .react-calendar-heatmap text, -.activity-heatmap.react-calendar-heatmap text { - font-size: 7px !important; - fill: #57606a; +.heatmap-container text { + fill: oklch(0.5 0.02 268); + font-size: 10px; } -.dark .activity-heatmap-container .react-calendar-heatmap text, -.dark .activity-heatmap.react-calendar-heatmap text { - fill: #8b949e; -} +/* Day squares */ +.heatmap-day-0 { fill: oklch(0.18 0.01 268); rx: 2; } +.heatmap-day-1 { fill: oklch(0.35 0.12 280); rx: 2; } +.heatmap-day-2 { fill: oklch(0.45 0.17 280); rx: 2; } +.heatmap-day-3 { fill: oklch(0.55 0.22 280); rx: 2; } +.heatmap-day-4 { fill: oklch(0.67 0.23 280); rx: 2; } -.activity-heatmap.react-calendar-heatmap rect { - rx: 2; - ry: 2; - transition: fill 0.15s ease, stroke 0.15s ease; +/* Hover effect */ +.heatmap-container rect:hover { + stroke: oklch(0.67 0.23 280); + stroke-width: 1.5; } -.activity-heatmap.react-calendar-heatmap rect:hover { - stroke: #1f2328; - stroke-width: 1px; -} +/* Legend swatches */ +.heatmap-legend-0 { background: oklch(0.18 0.01 268); } +.heatmap-legend-1 { background: oklch(0.35 0.12 280); } +.heatmap-legend-2 { background: oklch(0.45 0.17 280); } +.heatmap-legend-3 { background: oklch(0.55 0.22 280); } +.heatmap-legend-4 { background: oklch(0.67 0.23 280); } -.dark .activity-heatmap.react-calendar-heatmap rect:hover { - stroke: #8b949e; -} +/* Light mode overrides */ +:root .heatmap-day-0 { fill: oklch(0.92 0.005 265); } +:root .heatmap-day-1 { fill: oklch(0.82 0.1 280); } +:root .heatmap-day-2 { fill: oklch(0.72 0.15 280); } +:root .heatmap-day-3 { fill: oklch(0.6 0.2 280); } +:root .heatmap-day-4 { fill: oklch(0.5 0.22 280); } -/* Light mode β€” GitHub exact palette */ -.activity-heatmap .heatmap-0 { - fill: #ebedf0; -} -.activity-heatmap .heatmap-1 { - fill: #9be9a8; -} -.activity-heatmap .heatmap-2 { - fill: #40c463; -} -.activity-heatmap .heatmap-3 { - fill: #30a14e; -} -.activity-heatmap .heatmap-4 { - fill: #216e39; -} +:root .heatmap-legend-0 { background: oklch(0.92 0.005 265); } +:root .heatmap-legend-1 { background: oklch(0.82 0.1 280); } +:root .heatmap-legend-2 { background: oklch(0.72 0.15 280); } +:root .heatmap-legend-3 { background: oklch(0.6 0.2 280); } +:root .heatmap-legend-4 { background: oklch(0.5 0.22 280); } -/* Dark mode β€” GitHub exact palette */ -.dark .activity-heatmap .heatmap-0 { - fill: #161b22; -} -.dark .activity-heatmap .heatmap-1 { - fill: #0e4429; +:root .heatmap-container text { + fill: oklch(0.5 0.02 265); } -.dark .activity-heatmap .heatmap-2 { - fill: #006d32; -} -.dark .activity-heatmap .heatmap-3 { - fill: #26a641; -} -.dark .activity-heatmap .heatmap-4 { - fill: #39d353; + +:root .heatmap-container rect:hover { + stroke: oklch(0.5 0.22 280); } diff --git a/src/components/analytics/activity-heatmap.tsx b/src/components/analytics/activity-heatmap.tsx index 411c33e..59a5b21 100644 --- a/src/components/analytics/activity-heatmap.tsx +++ b/src/components/analytics/activity-heatmap.tsx @@ -1,18 +1,10 @@ "use client"; import CalendarHeatmap from "react-calendar-heatmap"; -import "react-calendar-heatmap/dist/styles.css"; -import "@/components/analytics/activity-heatmap.css"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { cn } from "@/lib/utils"; +import "./activity-heatmap.css"; +import { ChartSkeleton } from "@/components/shimmer-skeleton"; -interface HeatmapCell { +interface HeatmapValue { date: string; count: number; week: number; @@ -20,139 +12,63 @@ interface HeatmapCell { } interface ActivityHeatmapProps { - data: HeatmapCell[]; - isLoading?: boolean; + data: HeatmapValue[] | undefined; + isLoading: boolean; } -/** Value shape passed by react-calendar-heatmap to classForValue / titleForValue */ -type HeatmapDayValue = { date: string; count?: number } | undefined; - -/** Ordered intensity steps for the legend (count β†’ level 0–4) */ -const LEGEND_STEPS = [0, 1, 3, 5, 7] as const; - -/** - * Maps a contribution count to GitHub-style level (0–4) for CSS class heatmap-0 … heatmap-4. - * @param count - number of contributions on that day - * @returns level 0 (empty) through 4 (most) - */ -function getHeatmapLevel(count: number): 0 | 1 | 2 | 3 | 4 { +function getHeatmapLevel(count: number): number { if (count === 0) return 0; - if (count <= 1) return 1; - if (count <= 3) return 2; - if (count <= 5) return 3; + if (count <= 2) return 1; + if (count <= 5) return 2; + if (count <= 10) return 3; return 4; } -/** - * Formats a date string (YYYY-MM-DD) for GitHub-style tooltip: "Month Day" (e.g. "Jan 15"). - */ -function formatTooltipDate(dateStr: string): string { - const d = new Date(dateStr + "Z"); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); -} - -/** - * ActivityHeatmap β€” GitHub profile–style contribution graph (last 52 weeks). - * Layout: weeks as columns, days as rows; tooltips and colors match GitHub. - * @param data - array of { date, count, week, day } β€” 364 entries (52 weeks) - * @param isLoading - whether data is loading - */ -export function ActivityHeatmap({ - data, - isLoading = false, -}: ActivityHeatmapProps) { +export function ActivityHeatmap({ data, isLoading }: ActivityHeatmapProps) { if (isLoading) { return ( - - -
-
-
-
-
-
-
- - -
- - +
+ +
); } - const values = data.map((d) => ({ date: d.date, count: d.count })); - const totalReviews = data.reduce((sum, d) => sum + d.count, 0); - const startDate = data[0]?.date ?? new Date().toISOString().split("T")[0]; - const endDate = - data[data.length - 1]?.date ?? new Date().toISOString().split("T")[0]; + const today = new Date(); + const startDate = new Date(today); + startDate.setFullYear(startDate.getFullYear() - 1); return ( - - -
-
- - Contributions - - - {totalReviews} contribution{totalReviews !== 1 ? "s" : ""} in the - last year - -
- {/* Legend β€” GitHub style: Less [squares] More */} -
- Less - {LEGEND_STEPS.map((n) => { - const level = getHeatmapLevel(n); - return ( -
- ); - })} - More -
-
- - -
- { - if (!value || value.count === 0) return "heatmap-0"; - return `heatmap-${getHeatmapLevel(value.count ?? 0)}`; - }} - titleForValue={(value: HeatmapDayValue) => { - if (!value) return ""; - const count = value.count ?? 0; - const dateLabel = formatTooltipDate(value.date); - if (count === 0) { - return `No contributions on ${dateLabel}`; - } - return `${count} contribution${count !== 1 ? "s" : ""} on ${dateLabel}`; - }} - monthLabels={[ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]} - weekdayLabels={["", "Mon", "", "Wed", "", "Fri", ""]} - /> +
+
+

Review Activity

+
+ Less + {[0, 1, 2, 3, 4].map((level) => ( + + ))} + More
- - +
+ +
+ { + if (!value || !value.count) return "heatmap-day-0"; + return `heatmap-day-${getHeatmapLevel(value.count)}`; + }} + titleForValue={(value) => { + if (!value) return "No reviews"; + return `${value.count} review${value.count !== 1 ? "s" : ""} on ${value.date}`; + }} + gutterSize={3} + /> +
+
); } diff --git a/src/components/analytics/repo-leaderboard.tsx b/src/components/analytics/repo-leaderboard.tsx index 9ff55c4..d14e452 100644 --- a/src/components/analytics/repo-leaderboard.tsx +++ b/src/components/analytics/repo-leaderboard.tsx @@ -1,15 +1,9 @@ "use client"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { motion } from "framer-motion"; import { Badge } from "@/components/ui/badge"; +import { staggerContainer, staggerItem } from "@/lib/motion"; import { FolderGit2, Trophy } from "lucide-react"; -import { cn } from "@/lib/utils"; interface RepoData { repoName: string; @@ -20,167 +14,87 @@ interface RepoData { } interface RepoLeaderboardProps { - data: RepoData[]; - isLoading?: boolean; + data: RepoData[] | undefined; + isLoading: boolean; } -const MEDALS = ["πŸ₯‡", "πŸ₯ˆ", "πŸ₯‰"]; - -/** - * Returns a badge variant based on risk score. - * @param score - numeric risk score (0–100) - * @returns shadcn Badge variant - */ -function getRiskVariant( - score: number, -): "default" | "secondary" | "destructive" | "outline" { - if (score <= 30) return "default"; - if (score <= 60) return "secondary"; - return "destructive"; +function getRiskBadge(score: number | null) { + if (score === null) return null; + if (score <= 30) + return {score.toFixed(0)}; + if (score <= 60) + return {score.toFixed(0)}; + return {score.toFixed(0)}; } -/** - * Inline progress bar showing the completed/total ratio. - * @param completed - completed review count - * @param total - total review count - */ -function ProgressBar({ - completed, - total, -}: { - completed: number; - total: number; -}) { - const pct = total > 0 ? Math.round((completed / total) * 100) : 0; - return ( -
-
-
- ); -} +const MEDAL_ICONS = ["πŸ₯‡", "πŸ₯ˆ", "πŸ₯‰"]; -/** - * RepoLeaderboard β€” ranked list of repositories by total review volume. - * @param data - array of repo stats sorted by total desc - * @param isLoading - whether data is loading - */ -export function RepoLeaderboard({ - data, - isLoading = false, -}: RepoLeaderboardProps) { +export function RepoLeaderboard({ data, isLoading }: RepoLeaderboardProps) { if (isLoading) { return ( - - -
-
-
-
- - - {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
-
-
-
- ))} - - - ); - } - - if (data.length === 0) { - return ( - - - - - Repo Leaderboard - - Most reviewed repositories - - -
-
- -
-

No repositories yet

-

Connect repos and trigger reviews to populate this

-
-
-
+
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
); } return ( - - - - - Repo Leaderboard - - - Top repositories by review volume - - - -
- {data.map((repo, index) => { - const rank = index + 1; - return ( -
- {/* Medal / rank */} - - {rank <= 3 ? MEDALS[rank - 1] : ( - {rank} - )} - +
+

+ + Repository Leaderboard +

- {/* Repo info */} -
-
- -

{repo.repoName}

-
-

- {repo.fullName} -

- -
+ {!data || data.length === 0 ? ( +
+ No repository data available. +
+ ) : ( + + {data.map((repo, i) => ( + + + {i < 3 ? MEDAL_ICONS[i] : {i + 1}} + - {/* Stats */} -
-
- {repo.total} - reviews +
+ +
+ +
+

{repo.repoName}

+
+
+
0 ? (repo.completed / repo.total) * 100 : 0}%`, + }} + />
- {repo.completed > 0 && ( - - risk {repo.avgRiskScore} - - )} + + {repo.completed}/{repo.total} +
- ); - })} -
- - + +
{getRiskBadge(repo.avgRiskScore)}
+ + ))} + + )} +
); } diff --git a/src/components/analytics/review-chart.tsx b/src/components/analytics/review-chart.tsx index 864bf0b..88a6e86 100644 --- a/src/components/analytics/review-chart.tsx +++ b/src/components/analytics/review-chart.tsx @@ -1,167 +1,88 @@ "use client"; -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; -import { useState } from "react"; -import { cn } from "@/lib/utils"; - -interface ReviewTrendData { - date: string; - reviews: number; -} + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { ChartSkeleton } from "@/components/shimmer-skeleton"; interface ReviewChartProps { - data: ReviewTrendData[]; - isLoading?: boolean; + data: { date: string; reviews: number }[] | undefined; + isLoading: boolean; + range: "7d" | "30d" | "90d"; + onRangeChange: (range: "7d" | "30d" | "90d") => void; } -const chartConfig = { - reviews: { - label: "Reviews", - color: "hsl(221.2 83.2% 53.3%)", - }, -} satisfies ChartConfig; - -type DayRange = 7 | 30 | 90; - -/** - * ReviewChart β€” area chart showing reviews triggered per day. - * Includes a 7d / 30d / 90d range pill selector. - * @param data - array of { date, reviews } covering 90 days - * @param isLoading - whether data is loading - */ -export function ReviewChart({ data, isLoading = false }: ReviewChartProps) { - const [range, setRange] = useState(30); - - const sliced = data.slice(-range); - - const formatDate = (dateStr: string) => { - const d = new Date(dateStr); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); - }; - - const ranges: DayRange[] = [7, 30, 90]; +function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number }[]; label?: string }) { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+

{payload[0].value} reviews

+
+ ); +} +export function ReviewChart({ data, isLoading, range, onRangeChange }: ReviewChartProps) { if (isLoading) { return ( - - -
-
-
-
-
-
-
- - -
- - +
+ +
); } return ( - - -
-
- Review Trend - - Number of reviews triggered per day - -
- {/* Range selector */} -
- {ranges.map((r) => ( - - ))} -
+
+
+

Review Trend

+
+ {(["7d", "30d", "90d"] as const).map((r) => ( + + ))}
- - - - - - - - - - - - - - { - const d = payload?.[0]?.payload?.date as string | undefined; - return d ? formatDate(d) : ""; - }} - /> - } - /> - - - - - +
+ +
+ {data && data.length > 0 ? ( + + + + + + + + + + + + } /> + + + + ) : ( +
+ No review data for this period. +
+ )} +
+
); } diff --git a/src/components/analytics/risk-distribution.tsx b/src/components/analytics/risk-distribution.tsx index 15cfbab..c1877a2 100644 --- a/src/components/analytics/risk-distribution.tsx +++ b/src/components/analytics/risk-distribution.tsx @@ -1,163 +1,85 @@ "use client"; -import { Cell, Pie, PieChart } from "recharts"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; +import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts"; +import { ChartSkeleton } from "@/components/shimmer-skeleton"; -interface RiskData { +interface RiskItem { range: string; count: number; fill: string; } interface RiskDistributionProps { - data: RiskData[]; - isLoading?: boolean; + data: RiskItem[] | undefined; + isLoading: boolean; } -const chartConfig = { - low: { - label: "Low (0–30)", - color: "hsl(142.1 76.2% 36.3%)", - }, - medium: { - label: "Medium (31–60)", - color: "hsl(37.7 92.1% 50.2%)", - }, - high: { - label: "High (61–100)", - color: "hsl(0 72.2% 50.6%)", - }, - count: { - label: "Reviews", - }, -} satisfies ChartConfig; - -/** - * RiskDistribution β€” donut PieChart with a centre total label and custom legend. - * Low = green, Medium = amber, High = red. - * @param data - array of { range, count, fill } from analytics.riskDistribution - * @param isLoading - whether data is still loading - */ -export function RiskDistribution({ - data, - isLoading = false, -}: RiskDistributionProps) { - const total = data.reduce((sum, d) => sum + d.count, 0); - +export function RiskDistribution({ data, isLoading }: RiskDistributionProps) { if (isLoading) { return ( - - -
-
-
-
- - -
- - +
+ +
); } - if (total === 0) { - return ( - - - Risk Distribution - Risk score breakdown of reviews - - -
-
- πŸ“Š -
-

No completed reviews yet

-

Risk data will appear here once reviews complete

-
-
-
- ); - } + const chartData = data + ? data.filter((d) => d.count > 0).map((d) => ({ name: d.range, value: d.count, fill: d.fill })) + : []; + + const total = chartData.reduce((sum, d) => sum + d.value, 0); return ( - - - Risk Distribution - - Breakdown across {total} completed review{total !== 1 ? "s" : ""} - - - - {/* Donut chart with overlaid centre label */} -
- - - } - /> - - {data.map((entry, index) => ( - - ))} - - - - {/* Absolutely centred total label */} -
- {total} - total -
+
+

Risk Distribution

+ + {total === 0 ? ( +
+ No risk data available.
+ ) : ( + <> +
+ + + + {chartData.map((entry) => ( + + ))} + + + +
+ {total} + Total +
+
- {/* Legend pills */} -
- {data.map((d) => { - const pct = total > 0 ? Math.round((d.count / total) * 100) : 0; - return ( -
-
+ {chartData.map((entry) => ( + + - {d.range} - - {d.count} - ({pct}%) - -
- ); - })} -
- - + {entry.name} ({entry.value}) + + ))} +
+ + )} +
); } diff --git a/src/components/analytics/stats-cards.tsx b/src/components/analytics/stats-cards.tsx index d075f3f..4039a0b 100644 --- a/src/components/analytics/stats-cards.tsx +++ b/src/components/analytics/stats-cards.tsx @@ -1,233 +1,100 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { - GitPullRequest, - ShieldCheck, - TrendingUp, - AlertTriangle, -} from "lucide-react"; -import { cn } from "@/lib/utils"; +import { motion } from "framer-motion"; +import { AnimatedCounter } from "@/components/ui/animated-counter"; +import { staggerContainer, staggerItem } from "@/lib/motion"; +import { BarChart2, AlertTriangle, CheckCircle, XCircle } from "lucide-react"; +import { StatCardSkeleton } from "@/components/shimmer-skeleton"; -interface StatItem { - label: string; - value: number; - suffix?: string; - description: string; - icon: React.ComponentType<{ className?: string }>; - colorClass: string; - bgClass: string; - borderClass: string; -} - -interface StatsCardsProps { +interface StatsData { total: number; - completed: number; avgRiskScore: number; passRate: number; failed: number; - isLoading?: boolean; } -/** - * AnimatedCounter β€” counts from 0 to target with ease-out cubic animation. - * @param target - final numeric value - * @param suffix - optional string appended after the number (e.g. "%") - * @param duration - animation duration in ms (default 900) - */ -function AnimatedCounter({ - target, - suffix = "", - duration = 900, -}: { - target: number; - suffix?: string; - duration?: number; -}) { - const [count, setCount] = useState(0); - const rafRef = useRef(null); - const startRef = useRef(null); - - useEffect(() => { - startRef.current = null; - - const animate = (timestamp: number) => { - if (!startRef.current) startRef.current = timestamp; - const elapsed = timestamp - startRef.current; - const progress = Math.min(elapsed / duration, 1); - const eased = 1 - (1 - progress) ** 3; - setCount(Math.round(eased * target)); - if (progress < 1) { - rafRef.current = requestAnimationFrame(animate); - } - }; - - rafRef.current = requestAnimationFrame(animate); - return () => { - if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); - }; - }, [target, duration]); - - return ( - <> - {count} - {suffix} - - ); +interface StatsCardsProps { + data: StatsData | undefined; + isLoading: boolean; } -/** - * StatsCards β€” four animated KPI cards displayed in a responsive 2Γ—2 β†’ 4-column grid. - * @param total - total number of reviews - * @param completed - number of completed reviews - * @param avgRiskScore - average risk score (0–100) - * @param passRate - percentage of passing reviews - * @param failed - number of failed reviews - * @param isLoading - whether data is still loading - */ -export function StatsCards({ - total, - completed, - avgRiskScore, - passRate, - failed, - isLoading = false, -}: StatsCardsProps) { - const stats: StatItem[] = [ +export function StatsCards({ data, isLoading }: StatsCardsProps) { + if (isLoading || !data) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + const cards = [ { - label: "Total Reviews", - value: total, - description: `${completed} completed`, - icon: GitPullRequest, - colorClass: "text-blue-600 dark:text-blue-400", - bgClass: "bg-blue-50 dark:bg-blue-950/50", - borderClass: "border-blue-100 dark:border-blue-900/40", + title: "Total Reviews", + value: data.total, + icon: BarChart2, + iconColor: "text-primary", + iconBg: "bg-primary/10 ring-primary/20", }, { - label: "Avg Risk Score", - value: avgRiskScore, - suffix: "/100", - description: "across completed reviews", - icon: ShieldCheck, - colorClass: - avgRiskScore > 60 - ? "text-red-600 dark:text-red-400" - : avgRiskScore > 30 - ? "text-yellow-600 dark:text-yellow-400" - : "text-emerald-600 dark:text-emerald-400", - bgClass: - avgRiskScore > 60 - ? "bg-red-50 dark:bg-red-950/50" - : avgRiskScore > 30 - ? "bg-yellow-50 dark:bg-yellow-950/50" - : "bg-emerald-50 dark:bg-emerald-950/50", - borderClass: - avgRiskScore > 60 - ? "border-red-100 dark:border-red-900/40" - : avgRiskScore > 30 - ? "border-yellow-100 dark:border-yellow-900/40" - : "border-emerald-100 dark:border-emerald-900/40", + title: "Avg Risk Score", + value: data.avgRiskScore, + decimals: 1, + icon: AlertTriangle, + iconColor: data.avgRiskScore <= 30 ? "text-emerald-500" : data.avgRiskScore <= 60 ? "text-amber-500" : "text-red-500", + iconBg: data.avgRiskScore <= 30 ? "bg-emerald-500/10 ring-emerald-500/20" : data.avgRiskScore <= 60 ? "bg-amber-500/10 ring-amber-500/20" : "bg-red-500/10 ring-red-500/20", }, { - label: "Pass Rate", - value: passRate, + title: "Pass Rate", + value: data.passRate, suffix: "%", - description: "reviews with risk ≀ 60", - icon: TrendingUp, - colorClass: - passRate >= 70 - ? "text-emerald-600 dark:text-emerald-400" - : "text-yellow-600 dark:text-yellow-400", - bgClass: - passRate >= 70 - ? "bg-emerald-50 dark:bg-emerald-950/50" - : "bg-yellow-50 dark:bg-yellow-950/50", - borderClass: - passRate >= 70 - ? "border-emerald-100 dark:border-emerald-900/40" - : "border-yellow-100 dark:border-yellow-900/40", + decimals: 1, + icon: CheckCircle, + iconColor: data.passRate >= 80 ? "text-emerald-500" : data.passRate >= 50 ? "text-amber-500" : "text-red-500", + iconBg: data.passRate >= 80 ? "bg-emerald-500/10 ring-emerald-500/20" : data.passRate >= 50 ? "bg-amber-500/10 ring-amber-500/20" : "bg-red-500/10 ring-red-500/20", }, { - label: "Failed Reviews", - value: failed, - description: "processing errors", - icon: AlertTriangle, - colorClass: - failed > 0 - ? "text-red-600 dark:text-red-400" - : "text-muted-foreground", - bgClass: failed > 0 ? "bg-red-50 dark:bg-red-950/50" : "bg-muted/30", - borderClass: - failed > 0 - ? "border-red-100 dark:border-red-900/40" - : "border-border/40", + title: "Failed Reviews", + value: data.failed, + icon: XCircle, + iconColor: data.failed === 0 ? "text-emerald-500" : "text-red-500", + iconBg: data.failed === 0 ? "bg-emerald-500/10 ring-emerald-500/20" : "bg-red-500/10 ring-red-500/20", }, ]; - if (isLoading) { - return ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - -
-
-
-
- - -
-
- - - ))} -
- ); - } - return ( -
- {stats.map((stat) => { - const Icon = stat.icon; + + {cards.map((card) => { + const Icon = card.icon; return ( - - -
- - {stat.label} - -
- -
-
-
- -
- +
+ {card.title} +
+
-

- {stat.description} -

- - +
+
+ +
+ ); })} -
+
); } diff --git a/src/components/analytics/top-issues.tsx b/src/components/analytics/top-issues.tsx index 64410e7..e280d53 100644 --- a/src/components/analytics/top-issues.tsx +++ b/src/components/analytics/top-issues.tsx @@ -1,172 +1,82 @@ "use client"; import { - Bar, BarChart, - CartesianGrid, - Cell, - LabelList, + Bar, XAxis, YAxis, + Tooltip, + ResponsiveContainer, + Cell, } from "recharts"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; - -interface IssueData { - category: string; - count: number; -} +import { ChartSkeleton } from "@/components/shimmer-skeleton"; interface TopIssuesProps { - data: IssueData[]; - isLoading?: boolean; + data: { category: string; count: number }[] | undefined; + isLoading: boolean; } const BAR_COLORS = [ - "hsl(221.2 83.2% 53.3%)", - "hsl(262.1 83.3% 57.8%)", - "hsl(291.1 63.8% 52%)", - "hsl(330.4 81.2% 60.4%)", - "hsl(0 72.2% 50.6%)", - "hsl(37.7 92.1% 50.2%)", - "hsl(142.1 76.2% 36.3%)", - "hsl(198.6 88.7% 48.4%)", + "oklch(0.67 0.23 280)", + "oklch(0.72 0.19 200)", + "oklch(0.65 0.2 240)", + "oklch(0.62 0.2 310)", + "oklch(0.75 0.16 160)", ]; -const chartConfig = { - count: { - label: "Issues", - color: "hsl(221.2 83.2% 53.3%)", - }, -} satisfies ChartConfig; - -/** Row height + top/bottom padding per bar row */ -const ROW_HEIGHT = 46; -const CHART_PADDING = 16; +function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number }[]; label?: string }) { + if (!active || !payload?.length) return null; + return ( +
+

{label}

+

{payload[0].value} issues

+
+ ); +} -/** - * TopIssues β€” horizontal bar chart of most common AI review issue categories. - * Height grows dynamically based on the number of categories. - * @param data - array of { category, count } sorted by count desc - * @param isLoading - whether data is loading - */ -export function TopIssues({ data, isLoading = false }: TopIssuesProps) { +export function TopIssues({ data, isLoading }: TopIssuesProps) { if (isLoading) { return ( - - -
-
-
-
- - -
- - +
+ +
); } - if (data.length === 0) { - return ( - - - Top Issues - Most common issue categories - - -
-
- πŸ” -
-

No issue data yet

-

Complete some reviews to see patterns

-
-
-
- ); - } - - const chartHeight = data.length * ROW_HEIGHT + CHART_PADDING; - // YAxis needs enough width to fit the longest category label - const maxLabelLen = Math.max(...data.map((d) => d.category.length)); - const yAxisWidth = Math.min(Math.max(maxLabelLen * 6.5, 72), 120); - return ( - - - Top Issues - - Most common categories across AI reviews - - - - - - - - - } - cursor={{ fill: "hsl(var(--muted) / 0.5)", rx: 4 }} - /> - - {data.map((_, index) => ( - - ))} - +

Top Issues

+ + {!data || data.length === 0 ? ( +
+ No issue data available. +
+ ) : ( +
+ + + + - - - - - + } /> + + {data.map((_, i) => ( + + ))} + + + +
+ )} +
); } diff --git a/src/components/code-comparison.tsx b/src/components/code-comparison.tsx new file mode 100644 index 0000000..20f08f3 --- /dev/null +++ b/src/components/code-comparison.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useState } from "react"; +import { motion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import { Copy, Check, Code2, AlertTriangle, CheckCircle } from "lucide-react"; +import { detectLanguage, truncateCode } from "@/lib/code-diff-utils"; + +interface CodeComparisonProps { + oldCode: string; + newCode: string; + fileName?: string; + lineStart?: number; + showLineNumbers?: boolean; + maxLines?: number; + className?: string; +} + +function CodeBlock({ + code, + title, + icon, + iconColor, + bgColor, + borderColor, + language, + lineStart = 1, + showLineNumbers = true, + maxLines = 20, +}: { + code: string; + title: string; + icon: React.ReactNode; + iconColor: string; + bgColor: string; + borderColor: string; + language: string; + lineStart?: number; + showLineNumbers?: boolean; + maxLines?: number; +}) { + const [copied, setCopied] = useState(false); + const { code: displayCode, truncated, originalLineCount } = truncateCode(code, maxLines); + const lines = displayCode.split("\n"); + + const handleCopy = () => { + navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {/* Header */} +
+
+ {icon} + + {title} + + {language !== "text" && ( + + {language} + + )} +
+ +
+ + {/* Code Content */} +
+ + + {lines.map((line, i) => ( + + {showLineNumbers && ( + + )} + + + ))} + +
+ {lineStart + i} + {line || " "}
+
+ + {/* Truncation notice */} + {truncated && ( +
+ ... {originalLineCount - maxLines} more lines +
+ )} +
+ ); +} + +export function CodeComparison({ + oldCode, + newCode, + fileName = "", + lineStart = 1, + showLineNumbers = true, + maxLines = 20, + className, +}: CodeComparisonProps) { + const language = detectLanguage(fileName); + + return ( + + {/* Desktop: Side by side */} +
+ } + iconColor="text-red-500" + bgColor="bg-red-500/5" + borderColor="border-red-500/20" + language={language} + lineStart={lineStart} + showLineNumbers={showLineNumbers} + maxLines={maxLines} + /> + } + iconColor="text-emerald-500" + bgColor="bg-emerald-500/5" + borderColor="border-emerald-500/20" + language={language} + lineStart={lineStart} + showLineNumbers={showLineNumbers} + maxLines={maxLines} + /> +
+ + {/* Mobile: Stacked */} +
+ } + iconColor="text-red-500" + bgColor="bg-red-500/5" + borderColor="border-red-500/20" + language={language} + lineStart={lineStart} + showLineNumbers={showLineNumbers} + maxLines={maxLines} + /> + } + iconColor="text-emerald-500" + bgColor="bg-emerald-500/5" + borderColor="border-emerald-500/20" + language={language} + lineStart={lineStart} + showLineNumbers={showLineNumbers} + maxLines={maxLines} + /> +
+
+ ); +} diff --git a/src/components/code-diff.tsx b/src/components/code-diff.tsx new file mode 100644 index 0000000..006b863 --- /dev/null +++ b/src/components/code-diff.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { motion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import { Copy, Check, ChevronDown, ChevronUp } from "lucide-react"; +import { + computeSimpleDiff, + getLineTypeClasses, + detectLanguage, + type DiffLine, +} from "@/lib/code-diff-utils"; + +interface CodeDiffProps { + oldCode: string; + newCode: string; + fileName?: string; + showLineNumbers?: boolean; + initialExpanded?: boolean; + maxCollapsedLines?: number; + className?: string; +} + +function DiffLineRow({ line, showLineNumbers }: { line: DiffLine; showLineNumbers: boolean }) { + const classes = getLineTypeClasses(line.type); + + if (line.type === "hunk") { + return ( + + + {line.content} + + + ); + } + + return ( + + {showLineNumbers && ( + <> + + {line.type !== "add" ? line.oldLineNumber : ""} + + + {line.type !== "del" ? line.newLineNumber : ""} + + + )} + + + {classes.marker} + + {line.content} + + + ); +} + +export function CodeDiff({ + oldCode, + newCode, + fileName = "", + showLineNumbers = true, + initialExpanded = true, + maxCollapsedLines = 6, + className, +}: CodeDiffProps) { + const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(initialExpanded); + const language = detectLanguage(fileName); + + const diffLines = useMemo(() => { + return computeSimpleDiff(oldCode, newCode); + }, [oldCode, newCode]); + + const displayLines = expanded + ? diffLines + : diffLines.slice(0, maxCollapsedLines); + + const hasMore = !expanded && diffLines.length > maxCollapsedLines; + + const stats = useMemo(() => { + let additions = 0; + let deletions = 0; + for (const line of diffLines) { + if (line.type === "add") additions++; + if (line.type === "del") deletions++; + } + return { additions, deletions }; + }, [diffLines]); + + const handleCopy = () => { + navigator.clipboard.writeText(newCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + {/* Header */} +
+
+ + Unified Diff + + {language !== "text" && ( + + {language} + + )} +
+ {stats.additions > 0 && ( + +{stats.additions} + )} + {stats.deletions > 0 && ( + -{stats.deletions} + )} +
+
+ +
+ + {/* Diff Content */} +
+ + + {displayLines.map((line, i) => ( + + ))} + +
+
+ + {/* Expand/Collapse */} + {diffLines.length > maxCollapsedLines && ( + + )} +
+ ); +} diff --git a/src/components/connect-github.tsx b/src/components/connect-github.tsx index 7c30c1a..f60bf59 100644 --- a/src/components/connect-github.tsx +++ b/src/components/connect-github.tsx @@ -3,62 +3,41 @@ import { useState } from "react"; import { linkSocial } from "@/lib/auth-client"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Github, Loader2 } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { FaGithub } from "react-icons/fa"; +import { Loader2 } from "lucide-react"; +import { GitHubIcon } from "@/components/ui/github-icon"; -interface ConnectGitHubProps { - title?: string; - description?: string; - className?: string; -} - -export function ConnectGithub({ - title = "Connect your GitHub account", - description = "Link your GitHub account to access your repositories and enable AI code reviews.", - className, -}: ConnectGitHubProps) { - const [isConnecting, setIsConnecting] = useState(false); +export function ConnectGithub() { + const [loading, setLoading] = useState(false); const handleConnect = async () => { - setIsConnecting(true); - try { - await linkSocial({ - provider: "github", - callbackURL: window.location.href, - }); - } catch (error) { - console.error("Failed to connect GitHub:", error); - setIsConnecting(false); - } + setLoading(true); + await linkSocial({ + provider: "github", + callbackURL: window.location.href, + }); }; return ( - - -
- -
-

{title}

-

- {description} -

- -
-
+
+
+ +
+

Connect your GitHub account

+

+ Link your GitHub account to import repositories and enable AI code reviews. +

+ +
); } diff --git a/src/components/diff-viewer.tsx b/src/components/diff-viewer.tsx index 268b73d..d33448e 100644 --- a/src/components/diff-viewer.tsx +++ b/src/components/diff-viewer.tsx @@ -1,24 +1,20 @@ "use client"; -import React, { useState } from "react"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { cn } from "@/lib/utils"; import { - ChevronDown, ChevronRight, - Plus, - Minus, + FileCode, FilePlus, - FileMinus, - FileEdit, - FileText, + FileX, + FilePen, Copy, Check, - FolderTree, - AlertCircle, + ChevronsUpDown, + ChevronsDownUp, } from "lucide-react"; -import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; interface DiffFile { sha: string; @@ -28,401 +24,268 @@ interface DiffFile { deletions: number; changes: number; patch?: string; - previousFilename?: string; } interface DiffViewerProps { files: DiffFile[]; } -export function DiffViewer({ files }: DiffViewerProps) { - const totalAdditions = files.reduce((sum, f) => sum + f.additions, 0); - const totalDeletions = files.reduce((sum, f) => sum + f.deletions, 0); - const [expandedFiles, setExpandedFiles] = useState>( - new Set(files.slice(0, 3).map((f) => f.sha)), - ); - - const toggleFile = (sha: string) => { - const next = new Set(expandedFiles); - if (next.has(sha)) { - next.delete(sha); - } else { - next.add(sha); - } - setExpandedFiles(next); +function getStatusConfig(status: string) { + const configs: Record = { + added: { icon: FilePlus, color: "text-emerald-500", label: "Added" }, + removed: { icon: FileX, color: "text-red-500", label: "Removed" }, + modified: { icon: FilePen, color: "text-amber-500", label: "Modified" }, + renamed: { icon: FilePen, color: "text-blue-500", label: "Renamed" }, }; + return configs[status] || configs.modified; +} - const expandAll = () => { - setExpandedFiles(new Set(files.map((f) => f.sha))); - }; +function ChangeBar({ additions, deletions }: { additions: number; deletions: number }) { + const total = additions + deletions; + if (total === 0) return null; + const blocks = Math.min(total, 5); + const addBlocks = Math.round((additions / total) * blocks); + const delBlocks = blocks - addBlocks; - const collapseAll = () => { - setExpandedFiles(new Set()); - }; + return ( + + {Array.from({ length: addBlocks }).map((_, i) => ( + + ))} + {Array.from({ length: delBlocks }).map((_, i) => ( + + ))} + + ); +} + +function parseLine(line: string) { + if (line.startsWith("@@")) return { type: "hunk" as const, content: line }; + if (line.startsWith("+")) return { type: "add" as const, content: line.slice(1) }; + if (line.startsWith("-")) return { type: "del" as const, content: line.slice(1) }; + if (line.startsWith("\\")) return { type: "meta" as const, content: line }; + return { type: "context" as const, content: line.startsWith(" ") ? line.slice(1) : line }; +} + +function DiffContent({ patch }: { patch: string }) { + const lines = patch.split("\n"); + let oldLine = 0; + let newLine = 0; return ( -
- {/* Summary bar */} -
-
-
-
- -
-
- - {files.length} - - - {files.length === 1 ? "file" : "files"} changed - -
-
+
+ + + {lines.map((rawLine, i) => { + const { type, content } = parseLine(rawLine); + + if (type === "hunk") { + const match = content.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/); + if (match) { + oldLine = parseInt(match[1], 10); + newLine = parseInt(match[2], 10); + } + return ( + + + + ); + } -
-
- - - {totalAdditions} - - - - {totalDeletions} - -
-
+ if (type === "meta") return null; -
- - -
- + let displayOld = ""; + let displayNew = ""; -
- {files.map((file) => ( - toggleFile(file.sha)} - /> - ))} -
+ if (type === "del") { + displayOld = String(oldLine++); + } else if (type === "add") { + displayNew = String(newLine++); + } else { + displayOld = String(oldLine++); + displayNew = String(newLine++); + } + + return ( + + + + + + ); + })} + +
+ {content} +
+ {displayOld} + + {displayNew} + + + {type === "add" ? "+" : type === "del" ? "-" : " "} + + {content} +
); } function DiffFileCard({ file, - expanded, + isOpen, onToggle, }: { file: DiffFile; - expanded: boolean; + isOpen: boolean; onToggle: () => void; }) { const [copied, setCopied] = useState(false); - const StatusIcon = getStatusIcon(file.status); - const statusConfig = getStatusConfig(file.status); + const config = getStatusConfig(file.status); + const Icon = config.icon; + + const pathParts = file.filename.split("/"); + const fileName = pathParts.pop() || file.filename; + const dirPath = pathParts.join("/"); - const copyFilename = () => { + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); navigator.clipboard.writeText(file.filename); setCopied(true); setTimeout(() => setCopied(false), 2000); }; - const pathParts = file.filename.split("/"); - const fileName = pathParts.pop(); - const directory = pathParts.join("/"); - return ( - +
- - {/* Expanded content */} - {expanded && ( - - {file.patch ? ( -
- - -
+ + +
+
+ + {/* File list */} +
+ {files.map((file) => ( + toggleFile(file.sha)} + /> + ))} +
+
); } diff --git a/src/components/header.tsx b/src/components/header.tsx index a7b0e37..d5a5ca0 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -3,9 +3,11 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; -import { BarChart2, FolderGit2, GitPullRequest } from "lucide-react"; +import { BarChart2, Code2, FolderGit2, GitPullRequest, Menu, X } from "lucide-react"; import { UserMenu } from "./user-menu"; import { ThemeToggle } from "./theme-toggle"; +import { motion, AnimatePresence, useScroll, useMotionValueEvent } from "framer-motion"; +import { useState } from "react"; interface User { id: string; @@ -19,61 +21,148 @@ interface HeaderProps { } const navItems = [ - { - href: "/repos", - label: "Repositories", - icon: FolderGit2, - }, - { - href: "/reviews", - label: "Reviews", - icon: GitPullRequest, - }, - { - href: "/analytics", - label: "Analytics", - icon: BarChart2, - }, + { href: "/repos", label: "Repositories", icon: FolderGit2 }, + { href: "/reviews", label: "Reviews", icon: GitPullRequest }, + { href: "/analytics", label: "Analytics", icon: BarChart2 }, ]; export function Header({ user }: HeaderProps) { const pathname = usePathname(); + const [mobileOpen, setMobileOpen] = useState(false); + const [scrolled, setScrolled] = useState(false); + + const { scrollY } = useScroll(); + useMotionValueEvent(scrollY, "change", (y) => { + setScrolled(y > 8); + }); return ( -
-
-
- -
+ {/* Desktop nav */} + +
+ + {/* Right: Actions */} +
+ +
+ -
- -
- + {/* Mobile menu toggle */} + +
-
-
+ + + {/* Mobile nav drawer */} + + {mobileOpen && ( + <> + {/* Backdrop */} + setMobileOpen(false)} + /> + + {/* Drawer */} + + {navItems.map((item) => { + const isActive = + pathname === item.href || pathname.startsWith(`${item.href}/`); + const Icon = item.icon; + return ( + setMobileOpen(false)} + className={cn( + "flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-all", + isActive + ? "text-foreground bg-primary/12 ring-1 ring-primary/20" + : "text-muted-foreground hover:text-foreground hover:bg-white/5", + )} + > + + {item.label} + + ); + })} + + + )} + + ); } diff --git a/src/components/landing-page.tsx b/src/components/landing-page.tsx new file mode 100644 index 0000000..843c21e --- /dev/null +++ b/src/components/landing-page.tsx @@ -0,0 +1,687 @@ +"use client"; + +import { motion, useInView, useScroll, useTransform } from "framer-motion"; +import Link from "next/link"; +import { useRef } from "react"; +import { + ArrowRight, + CheckCircle, + GitMerge, + GitPullRequest, + MessageSquare, + ScanSearch, + Shield, + Sparkles, + Wand2, + Zap, + Star, + Code2, + ChevronRight, +} from "lucide-react"; +import { GitHubIcon } from "@/components/ui/github-icon"; +import { Button } from "@/components/ui/button"; + +/* ─── Animation Variants ─────────────────────────────────────────────────── */ +const easeCubic: [number, number, number, number] = [0.22, 1, 0.36, 1]; + +const fadeUp = { + hidden: { opacity: 0, y: 32 }, + visible: (delay = 0) => ({ + opacity: 1, + y: 0, + transition: { duration: 0.65, ease: easeCubic, delay }, + }), +}; + +const stagger = { + hidden: {}, + visible: { transition: { staggerChildren: 0.1 } }, +}; + +const scaleIn = { + hidden: { opacity: 0, scale: 0.88 }, + visible: (delay = 0) => ({ + opacity: 1, + scale: 1, + transition: { duration: 0.5, ease: easeCubic, delay }, + }), +}; + +/* ─── Section Wrapper ────────────────────────────────────────────────────── */ +function RevealSection({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + const ref = useRef(null); + const inView = useInView(ref, { once: true, margin: "-80px" }); + return ( + + {children} + + ); +} + +/* ─── Floating Orb ───────────────────────────────────────────────────────── */ +function Orb({ + className, + delay = 0, +}: { + className: string; + delay?: number; +}) { + return ( + + ); +} + +/* ─── Features ───────────────────────────────────────────────────────────── */ +const features = [ + { + icon: Zap, + title: "Instant AI Feedback", + description: + "Get comprehensive, actionable code reviews in seconds β€” not hours. No waiting, no back-and-forth.", + gradient: "from-yellow-500/20 to-orange-500/20", + iconColor: "text-yellow-400", + }, + { + icon: Shield, + title: "Security Scanning", + description: + "Automatically detect vulnerabilities, secrets, and potential attack vectors before they hit production.", + gradient: "from-emerald-500/20 to-teal-500/20", + iconColor: "text-emerald-400", + }, + { + icon: MessageSquare, + title: "Smart Suggestions", + description: + "Clear, contextual suggestions you can apply immediately. Inline comments right in your pull requests.", + gradient: "from-blue-500/20 to-cyan-500/20", + iconColor: "text-blue-400", + }, + { + icon: GitPullRequest, + title: "GitHub Native", + description: + "Reviews appear directly in your pull requests as real GitHub review comments. Zero workflow changes.", + gradient: "from-purple-500/20 to-violet-500/20", + iconColor: "text-purple-400", + }, + { + icon: ScanSearch, + title: "Context-Aware AI", + description: + "Understands your codebase patterns, naming conventions, and architectural intent at a deep level.", + gradient: "from-pink-500/20 to-rose-500/20", + iconColor: "text-pink-400", + }, + { + icon: Wand2, + title: "Latest AI Models", + description: + "Powered by Groq's Llama 3.3 70B and OpenAI GPT-4o β€” always using the best available model.", + gradient: "from-violet-500/20 to-indigo-500/20", + iconColor: "text-violet-400", + }, +]; + +/* ─── Stats ──────────────────────────────────────────────────────────────── */ +const stats = [ + { value: "10x", label: "Faster reviews" }, + { value: "99%", label: "Uptime SLA" }, + { value: "50k+", label: "PRs reviewed" }, + { value: "0", label: "Credit card needed" }, +]; + +/* ─── How it works ───────────────────────────────────────────────────────── */ +const steps = [ + { + step: "01", + icon: GitHubIcon, + title: "Connect GitHub", + description: + "Sign in with GitHub OAuth and select which repositories to enable AI reviews on.", + }, + { + step: "02", + icon: GitPullRequest, + title: "Open a Pull Request", + description: + "CodeReviewAI triggers automatically on every new PR. No manual action needed.", + }, + { + step: "03", + icon: GitMerge, + title: "Merge with Confidence", + description: + "Review AI suggestions, address issues inline, and ship better code faster.", + }, +]; + +/* ─── Main Component ─────────────────────────────────────────────────────── */ +export function LandingPage() { + const heroRef = useRef(null); + const { scrollYProgress } = useScroll({ + target: heroRef, + offset: ["start start", "end start"], + }); + const heroOpacity = useTransform(scrollYProgress, [0, 1], [1, 0]); + const heroY = useTransform(scrollYProgress, [0, 1], [0, 80]); + + return ( +
+ {/* ─── Nav ──────────────────────────────────────────────────────────── */} + +
+ +
+ +
+ + CodeReviewAI + + + + + +
+ + +
+
+
+ +
+ {/* ─── Hero ─────────────────────────────────────────────────────── */} +
+ {/* Background grid */} +
+ + {/* Radial fade overlay */} +
+
+ + {/* Floating orbs */} + + + + + + {/* Badge */} + + + AI-Powered Code Review Platform + + + + {/* Headline */} + + Ship better code, +
+ faster than ever +
+ + {/* Subtext */} + + Automated AI code reviews that catch bugs, security issues, and + maintainability problems before they reach production. Integrates + directly into your GitHub workflow. + + + {/* CTAs */} + + + + + + {/* Trust signals */} + + {[ + "No credit card required", + "GitHub integration", + "Private repos supported", + ].map((item) => ( + + + {item} + + ))} + + + {/* Code preview card */} + +
+ {/* Terminal dots */} +
+
+
+
+
+
+
+ + AI Review Β· PR #142 Β· auth/login.ts + +
+
+ + {/* Mock review output */} +
+
+ + HIGH + +
+
+ SQL injection vulnerability detected +
+
+ Line 47 β€” Use parameterized queries instead of string + interpolation +
+
+
+ +
+ + MED + +
+
+ Password not hashed before storage +
+
+ Line 83 β€” Use bcrypt with salt rounds β‰₯ 10 +
+
+
+ +
+ + INFO + +
+
+ Missing rate limiting on auth endpoint +
+
+ Consider adding express-rate-limit middleware +
+
+
+
+ + {/* Risk score bar */} +
+
+
+ Risk Score + 78/100 +
+
+ +
+
+
+ + 3 issues +
+
+
+ + +
+ + {/* ─── Stats ────────────────────────────────────────────────────── */} +
+
+ + {stats.map((stat) => ( + +
+ {stat.value} +
+
+ {stat.label} +
+
+ ))} +
+
+
+ + {/* ─── Features ─────────────────────────────────────────────────── */} +
+
+ +
+ + + + Features + + + Everything you need for +
+ better code reviews +
+ + Focus on building great products. Let AI handle the repetitive, + time-consuming review work. + +
+ + + {features.map((feature, i) => { + const Icon = feature.icon; + return ( + + {/* Card glow on hover */} +
+
+
+ +
+

+ {feature.title} +

+

+ {feature.description} +

+
+ + ); + })} + +
+
+ + {/* ─── How it works ─────────────────────────────────────────────── */} +
+
+
+ +
+ + + + How it works + + + Up and running +
+ in minutes +
+ + Three simple steps to transform your code review process. + +
+ + + {/* Connecting line */} +
+ + {steps.map((step, i) => { + const Icon = step.icon; + return ( + + {/* Step number */} +
+
+ +
+
+ {step.step.slice(-1)} +
+
+

{step.title}

+

+ {step.description} +

+
+ ); + })} + +
+
+ + {/* ─── CTA Banner ───────────────────────────────────────────────── */} +
+
+ + + + + + + Free to start. No limits on repos. + + + Ready to review smarter? + + + Join developers shipping better code with AI. Connect your + GitHub in under 60 seconds. + + + + + + +
+
+ + {/* ─── Footer ───────────────────────────────────────────────────────── */} +
+
+
+
+ +
+ + CodeReviewAI + + Β· + Β© 2025 +
+
+ + Sign in + + + Get started + +
+
+
+
+ ); +} diff --git a/src/components/review-comparison-card.tsx b/src/components/review-comparison-card.tsx index 0932481..09c5022 100644 --- a/src/components/review-comparison-card.tsx +++ b/src/components/review-comparison-card.tsx @@ -1,169 +1,173 @@ "use client"; -import { Card } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; +import { useState } from "react"; +import { motion, AnimatePresence } from "framer-motion"; import { - FileCode2, - CheckCircle2, - AlertCircle, - MinusCircle, - CircleDot, + CheckCircle, + AlertTriangle, + Minus, Bug, Shield, Zap, Paintbrush, Lightbulb, + FileCode, + ChevronDown, + ChevronUp, + Info, + Code2, + Copy, + Check, } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; import type { ReviewCommentInput } from "@/lib/review-comparison"; +import { SuggestionDiffViewer } from "@/components/suggestion-diff-viewer"; -const SEVERITY_STYLES: Record< - string, - { bar: string; badge: string } -> = { - critical: { - bar: "bg-red-500", - badge: - "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400", - }, - high: { - bar: "bg-orange-500", - badge: - "border-orange-500/30 bg-orange-500/10 text-orange-600 dark:text-orange-400", - }, - medium: { - bar: "bg-amber-500", - badge: - "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", - }, - low: { - bar: "bg-slate-400 dark:bg-slate-500", - badge: "border-border bg-muted text-muted-foreground", - }, -}; - -function getSeverityStyle(severity: string) { - return SEVERITY_STYLES[severity] ?? SEVERITY_STYLES.low; +function getSeverityStyles(severity: string) { + const styles: Record = { + critical: "bg-red-500/10 text-red-500 ring-red-500/20", + high: "bg-orange-500/10 text-orange-500 ring-orange-500/20", + medium: "bg-amber-500/10 text-amber-500 ring-amber-500/20", + low: "bg-blue-500/10 text-blue-500 ring-blue-500/20", + }; + return styles[severity] || styles.low; } -const CATEGORY_ICONS: Record = { - bug: Bug, - security: Shield, - performance: Zap, - style: Paintbrush, - suggestion: Lightbulb, -}; - -export interface ComparisonCommentCardProps { - comment: ReviewCommentInput; - variant: "fixed" | "new" | "unchanged"; +function getCategoryIcon(category: string) { + const icons: Record = { + bug: , + security: , + performance: , + style: , + suggestion: , + }; + return icons[category] || ; } -/** - * Renders a single review comment in the comparison view with variant styling. - */ -export function ComparisonCommentCard({ - comment, - variant, -}: ComparisonCommentCardProps) { - const severityStyle = getSeverityStyle(comment.severity); - const CategoryIcon = comment.category ? CATEGORY_ICONS[comment.category] ?? CircleDot : CircleDot; - - const variantConfig = { +function getVariantConfig(variant: "fixed" | "new" | "unchanged") { + const configs = { fixed: { - icon: CheckCircle2, + icon: , label: "Fixed", - borderClass: "border-emerald-500/20 dark:border-emerald-500/30", - bgClass: "bg-emerald-500/5 dark:bg-emerald-500/10", - iconClass: "text-emerald-600 dark:text-emerald-400", + border: "border-emerald-500/20", + bg: "bg-emerald-500/5", + badge: "bg-emerald-500/10 text-emerald-500 ring-emerald-500/20", }, new: { - icon: AlertCircle, - label: "New", - borderClass: "border-amber-500/20 dark:border-amber-500/30", - bgClass: "bg-amber-500/5 dark:bg-amber-500/10", - iconClass: "text-amber-600 dark:text-amber-400", + icon: , + label: "New Issue", + border: "border-amber-500/20", + bg: "bg-amber-500/5", + badge: "bg-amber-500/10 text-amber-500 ring-amber-500/20", }, unchanged: { - icon: MinusCircle, + icon: , label: "Unchanged", - borderClass: "border-border/60", - bgClass: "bg-muted/30", - iconClass: "text-muted-foreground", + border: "border-border/40", + bg: "bg-card/50", + badge: "bg-muted text-muted-foreground ring-border/50", }, - }[variant]; + }; + return configs[variant]; +} - const Icon = variantConfig.icon; - const pathParts = comment.file.split("/"); - const fileName = pathParts.pop(); - const directory = pathParts.length > 0 ? pathParts.join("/") : null; +export function ComparisonCommentCard({ + comment, + variant, +}: { + comment: ReviewCommentInput; + variant: "fixed" | "new" | "unchanged"; +}) { + const [copied, setCopied] = useState(false); + const config = getVariantConfig(variant); + + const hasCodeComparison = comment.oldCode && comment.newCode; + const hasSuggestion = comment.suggestion || comment.codeSuggestion || hasCodeComparison; + // Auto-expand if there's a code comparison available + const [expanded, setExpanded] = useState(hasCodeComparison || false); + + const handleCopyPath = () => { + navigator.clipboard.writeText(`${comment.file}:${comment.line}`); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; return ( - -
-
+
+ + {config.icon} + {config.label} + + + {comment.severity} + + + {getCategoryIcon(comment?.category??"")} + {comment.category} + +
+ +

{comment.message}

+ +
+ +
+ + {hasSuggestion && ( +
+
-

- {comment.message} -

-
- - {directory && ( - {directory}/ + {expanded ? : } + + + {expanded && ( + + {comment.codeSuggestion || hasCodeComparison ? ( + + ) : ( +
+                    {comment.suggestion}
+                  
+ )} +
)} - - {fileName} - - :{comment.line} -
- {comment.suggestion && ( -
-

- Suggestion -

-

- {comment.suggestion} -

-
- )} +
-
-
+ )} +
); } diff --git a/src/components/review-result.tsx b/src/components/review-result.tsx index 4b71f12..05ac7b0 100644 --- a/src/components/review-result.tsx +++ b/src/components/review-result.tsx @@ -1,10 +1,12 @@ "use client"; -import React, { useState, useCallback, useMemo, useRef } from "react"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; +import { useState, useMemo } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { CircularGauge } from "@/components/ui/circular-gauge"; import { AlertDialog, AlertDialogAction, @@ -16,579 +18,581 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { + CheckCircle, + Loader2, + XCircle, + Ban, Bug, Shield, Zap, Paintbrush, Lightbulb, - FileCode2, - CheckCircle2, - XCircle, - Clock, - ChevronDown, - ChevronRight, + FileCode, Copy, Check, - CircleDot, - ShieldCheck, - ShieldAlert, - ShieldX, - Github, - Loader2, + ChevronDown, + ChevronUp, + Send, MessageSquare, - AlertTriangle, - ExternalLink, + Sparkles, + Info, + Code2, + Filter, } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { trpc } from "@/lib/trpc/client"; +import { SuggestionDiffViewer } from "@/components/suggestion-diff-viewer"; +import type { CodeSuggestion } from "@/lib/review-comparison"; interface ReviewComment { file: string; line: number; - severity: string; - category?: string; + severity: "critical" | "high" | "medium" | "low"; + category: "bug" | "security" | "performance" | "style" | "suggestion"; message: string; suggestion?: string; + // Enhanced code comparison fields + oldCode?: string; + newCode?: string; + lineStart?: number; + lineEnd?: number; + context?: string; + codeSuggestion?: CodeSuggestion; +} + +interface Review { + id: string; + status: string; + riskScore: number | null; + summary: string | null; + comments: unknown; + createdAt: string | Date; } interface ReviewResultProps { - review: { - id: string; - status: string; - summary: string | null; - riskScore: number | null; - comments: ReviewComment[] | unknown; - error: string | null; - createdAt: Date; - prUrl?: string; - postedToGithub?: boolean; - githubReviewId?: bigint | number | null; + review: Review; +} + +function getSeverityStyles(severity: string) { + const styles: Record = { + critical: { bg: "bg-red-500/10", text: "text-red-500", border: "border-red-500/20", ring: "ring-red-500/20" }, + high: { bg: "bg-orange-500/10", text: "text-orange-500", border: "border-orange-500/20", ring: "ring-orange-500/20" }, + medium: { bg: "bg-amber-500/10", text: "text-amber-500", border: "border-amber-500/20", ring: "ring-amber-500/20" }, + low: { bg: "bg-blue-500/10", text: "text-blue-500", border: "border-blue-500/20", ring: "ring-blue-500/20" }, }; - repositoryId?: string; - prNumber?: number; + return styles[severity] || styles.low; } -type GitHubEventType = "COMMENT" | "REQUEST_CHANGES"; - -/** - * Renders a complete AI review result with risk score, severity breakdown, - * comments, and inline PR comment posting to GitHub. - * @param review - The review data object - * @param repositoryId - Optional repository ID for GitHub posting - * @param prNumber - Optional PR number for GitHub posting - */ -export function ReviewResult({ - review, - repositoryId, - prNumber, -}: ReviewResultProps) { - const [selectedIndices, setSelectedIndices] = useState>( - new Set(), - ); - const [eventType, setEventType] = useState("COMMENT"); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const [postSuccess, setPostSuccess] = useState<{ - postedCount: number; - skippedCount: number; - forcedComment: boolean; - } | null>(null); - - const hasPostedBefore = review.postedToGithub || postSuccess !== null; - const canPostToGithub = - repositoryId && prNumber && review.status === "COMPLETED"; - - const comments = useMemo( - () => - Array.isArray(review.comments) - ? (review.comments as ReviewComment[]) - : [], - [review.comments], +function getCategoryIcon(category: string) { + const icons: Record = { + bug: , + security: , + performance: , + style: , + suggestion: , + }; + return icons[category] || ; +} + +function CommentCard({ + comment, + index, + selected, + onToggle, +}: { + comment: ReviewComment; + index: number; + selected: boolean; + onToggle: () => void; +}) { + const hasCodeComparison = comment.oldCode && comment.newCode; + const hasSuggestion = comment.suggestion || comment.codeSuggestion || hasCodeComparison; + // Auto-expand if there's a code comparison available + const [expanded, setExpanded] = useState(hasCodeComparison || false); + const [copied, setCopied] = useState(false); + const severity = getSeverityStyles(comment.severity); + + const handleCopy = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(`${comment.file}:${comment.line}`); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + +
+ + +
+
+ + {comment.severity} + + + {getCategoryIcon(comment.category)} + {comment.category} + + {hasCodeComparison && ( + + + Code diff + + )} +
+ +

{comment.message}

+ +
+ +
+ + {hasSuggestion && ( +
+ + + {expanded && ( + + {comment.codeSuggestion || hasCodeComparison ? ( + + ) : ( +
+                        {comment.suggestion}
+                      
+ )} +
+ )} +
+
+ )} +
+
+
); +} - const hasInitialized = useRef(false); +function SeverityBar({ comments }: { comments: ReviewComment[] }) { + const counts = useMemo(() => { + const c = { critical: 0, high: 0, medium: 0, low: 0 }; + comments.forEach((comment) => { + if (comment.severity in c) c[comment.severity as keyof typeof c]++; + }); + return c; + }, [comments]); - React.useEffect(() => { - if (!hasInitialized.current && comments.length > 0 && canPostToGithub) { - hasInitialized.current = true; - setSelectedIndices(new Set(comments.map((_, i) => i))); - } - }, [comments, canPostToGithub]); + const total = comments.length; + if (total === 0) return null; - const utils = trpc.useUtils(); + const items = [ + { key: "critical", label: "Critical", count: counts.critical, color: "bg-red-500" }, + { key: "high", label: "High", count: counts.high, color: "bg-orange-500" }, + { key: "medium", label: "Medium", count: counts.medium, color: "bg-amber-500" }, + { key: "low", label: "Low", count: counts.low, color: "bg-blue-500" }, + ]; - const postToGithub = trpc.review.postToGithub.useMutation({ + return ( +
+
+ {items.map( + (item) => + item.count > 0 && ( + + ) + )} +
+
+ {items.map( + (item) => + item.count > 0 && ( + + + {item.label}: {item.count} + + ) + )} +
+
+ ); +} + +export function ReviewResult({ review }: ReviewResultProps) { + const [selectedComments, setSelectedComments] = useState>(new Set()); + const [eventType, setEventType] = useState<"COMMENT" | "REQUEST_CHANGES">("COMMENT"); + const [showPostDialog, setShowPostDialog] = useState(false); + const [severityFilter, setSeverityFilter] = useState>(new Set(["critical", "high", "medium", "low"])); + + const postMutation = trpc.review.postToGithub.useMutation({ onSuccess: (data) => { - setPostSuccess({ - postedCount: data.postedCount, - skippedCount: data.skippedCount, - forcedComment: data.forcedComment ?? false, - }); - setShowConfirmDialog(false); - setSelectedIndices(new Set()); - if (repositoryId && prNumber) { - utils.review.getLatestForPR.invalidate({ repositoryId, prNumber }); - } + setSelectedComments(new Set()); + setShowPostDialog(false); + toast.success(`Posted ${data.postedCount} comments to GitHub`); + }, + onError: (error) => { + toast.error(error.message); }, }); - /** - * Toggles a comment's selection state by index. - * @param index - Comment index in the comments array - */ - const toggleComment = useCallback((index: number) => { - setSelectedIndices((prev) => { + const comments = useMemo(() => { + if (!Array.isArray(review.comments)) return []; + return review.comments as ReviewComment[]; + }, [review.comments]); + + const filteredComments = useMemo(() => { + return comments.filter((c) => severityFilter.has(c.severity)); + }, [comments, severityFilter]); + + const toggleSeverityFilter = (severity: string) => { + setSeverityFilter((prev) => { const next = new Set(prev); - if (next.has(index)) { - next.delete(index); + if (next.has(severity)) { + if (next.size > 1) next.delete(severity); } else { - next.add(index); + next.add(severity); } return next; }); - }, []); + }; - /** Selects all comments. */ - const selectAll = useCallback(() => { - setSelectedIndices(new Set(comments.map((_, i) => i))); - }, [comments]); + const toggleComment = (index: number) => { + setSelectedComments((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + }; - /** Deselects all comments. */ - const selectNone = useCallback(() => { - setSelectedIndices(new Set()); - }, []); + const toggleAll = () => { + const filteredIndices = filteredComments.map((_, i) => comments.indexOf(filteredComments[i])); + const allSelected = filteredIndices.every((i) => selectedComments.has(i)); + + if (allSelected) { + setSelectedComments((prev) => { + const next = new Set(prev); + filteredIndices.forEach((i) => next.delete(i)); + return next; + }); + } else { + setSelectedComments((prev) => { + const next = new Set(prev); + filteredIndices.forEach((i) => next.add(i)); + return next; + }); + } + }; - /** Submits the selected comments to GitHub via the tRPC mutation. */ - const handlePostToGithub = useCallback(() => { - if (!repositoryId || !prNumber) return; - postToGithub.mutate({ + const handlePost = () => { + postMutation.mutate({ reviewId: review.id, - commentIndices: Array.from(selectedIndices), + commentIndices: Array.from(selectedComments), event: eventType, }); - }, [ - repositoryId, - prNumber, - postToGithub, - review.id, - selectedIndices, - eventType, - ]); - - if (review.status === "PENDING") { - return ( - - -
-
- -
-
-

Queued for review

-

- This review is queued for review and will be processed soon. -

-
-
- {[0, 1, 2].map((i) => ( -
- ))} -
-
- - - ); - } + }; - if (review.status === "PROCESSING") { + // Status screens + if (review.status === "PENDING" || review.status === "PROCESSING") { return ( - - -
-
- - - - - -
-
-

Analysing code

-

- Scanning for bugs, security issues, and improvements -

-
- - ~20s - -
-
-
+
+ + + +

+ {review.status === "PENDING" ? "Review queued" : "Analyzing code..."} +

+

+ {review.status === "PENDING" + ? "Your review will start shortly." + : "AI is reviewing your code. This usually takes a few seconds."} +

+
); } if (review.status === "FAILED") { return ( - - -
-
- -
-
- -
-

- Failed to review code -

-

- {review.error || "Please try again later or contact support"} -

- -
-
- - +
+
+ +
+

Review failed

+

+ Something went wrong during the review. Try running it again. +

+
); } if (review.status === "CANCELLED") { return ( - - -
-
- -
-
-

Review cancelled

-

- This review was cancelled. You can start a new one. -

-
-
-
-
+
+
+ +
+

Review cancelled

+

+ This review was cancelled before completion. +

+
); } - const severityCounts = { - critical: comments.filter((c) => c.severity === "critical").length, - high: comments.filter((c) => c.severity === "high").length, - medium: comments.filter((c) => c.severity === "medium").length, - low: comments.filter((c) => c.severity === "low").length, - }; - - const totalIssues = comments.length; - + // COMPLETED return (
- {/* Posted to GitHub Banner */} - {hasPostedBefore && ( - - -
-
- -
-
-

- Posted to GitHub - {postSuccess && - ` β€” ${postSuccess.postedCount} comment${postSuccess.postedCount !== 1 ? "s" : ""} posted${postSuccess.skippedCount > 0 ? `, ${postSuccess.skippedCount} skipped` : ""}`} -

- {postSuccess?.forcedComment && ( -

- Posted as Comment instead of Request Changes (GitHub - doesn't allow requesting changes on your own PR) -

- )} -
- {review.githubReviewId && ( - - - - )} -
-
-
- )} - - - - - -
- + {/* Risk Score + Severity Overview */} +
+ {/* Risk Score */} +
+
-
-

- Severity Breakdown -

-
- - {totalIssues} - -
-
- - +

Risk Score

+

+ {(review.riskScore ?? 0) <= 30 + ? "Low risk. This PR looks safe to merge." + : (review.riskScore ?? 0) <= 60 + ? "Medium risk. Review the flagged issues." + : "High risk. Critical issues found."} +

+
+
-
- - - - + {/* Severity Breakdown */} +
+

Severity Breakdown

+ + {comments.length === 0 && ( +
+ + No issues found
+ )} +
+
+ + {/* AI Summary */} + {review.summary && ( +
+
+ +

AI Summary

+

+ {review.summary} +

+
+ )} + + {/* Comments */} + {comments.length > 0 && ( +
+
+

+ + Comments ({filteredComments.length} + {filteredComments.length !== comments.length && ` of ${comments.length}`}) +

- {review.summary && ( - <> -
-
-

- AI Summary -

-

- {review.summary} -

-
- - )} - - - - {comments.length > 0 ? ( -
-
-

- Review Comments -

- {canPostToGithub && ( -
- - / - -
- )} - - {comments.length} {comments.length === 1 ? "issue" : "issues"} - + {/* Severity filter */} +
+ + {(["critical", "high", "medium", "low"] as const).map((sev) => { + const colors = { + critical: "bg-red-500", + high: "bg-orange-500", + medium: "bg-amber-500", + low: "bg-blue-500", + }; + const count = comments.filter((c) => c.severity === sev).length; + if (count === 0) return null; + return ( + + ); + })} +
+ +
- {comments.map((comment, index) => ( - - ))} + {filteredComments.map((comment) => { + const originalIndex = comments.indexOf(comment); + return ( + toggleComment(originalIndex)} + /> + ); + })}
+ + {filteredComments.length === 0 && comments.length > 0 && ( +
+ No comments match the current filter. + +
+ )}
- ) : ( - review.status === "COMPLETED" && )} - {/* Floating Action Bar for posting to GitHub */} - {canPostToGithub && comments.length > 0 && ( -
- - -
-
- -
- -
-

- {selectedIndices.size} of {comments.length} comment - {comments.length !== 1 ? "s" : ""} selected -

-
- -
-
- - -
+ {/* Post to GitHub floating bar */} + + {selectedComments.size > 0 && ( + +
+ + {selectedComments.size} selected + - -
+
+ +
- {postToGithub.error && ( -
- - {postToGithub.error.message} -
- )} - - -
- )} + +
+ + )} + - {/* Confirmation Dialog */} - + {/* Post confirmation dialog */} + Post comments to GitHub? - This will post{" "} - - {selectedIndices.size} comment - {selectedIndices.size !== 1 ? "s" : ""} - {" "} - as a{" "} - - {eventType === "COMMENT" - ? "comment (neutral)" - : "request changes (blocking)"} - {" "} - review on the pull request. Each post creates a new review on - GitHub. + This will post {selectedComments.size} comment{selectedComments.size !== 1 ? "s" : ""} as{" "} + {eventType === "COMMENT" ? "a comment" : "a change request"} on the pull request. - - Cancel - + Cancel - {postToGithub.isPending ? ( - <> - - Posting... - + {postMutation.isPending ? ( + ) : ( - <> - - Post to GitHub - + "Post Comments" )} @@ -597,381 +601,3 @@ export function ReviewResult({
); } - -function NoIssuesCard() { - return ( - - -
-
- -
-
- -
-

Looking Good!

-

- No issues were found. Your code follows best practices and appears - to be well-written. -

-
-
- - - ); -} - -function CommentCard({ - comment, - index, - selectable, - selected, - onToggleSelect, -}: { - comment: ReviewComment; - index: number; - selectable: boolean; - selected: boolean; - onToggleSelect: (index: number) => void; -}) { - const [expanded, setExpanded] = useState(index < 3); - const [copied, setCopied] = useState(false); - const CategoryIcon = getCategoryIcon(comment.category); - const severityConfig = getSeverityStyles(comment.severity); - - const copyLocation = () => { - navigator.clipboard.writeText(`${comment.file}:${comment.line}`); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - - const pathParts = comment.file.split("/"); - const fileName = pathParts.pop(); - const directory = pathParts.join("/"); - - return ( - -
- {selectable && ( -
- onToggleSelect(index)} - /> -
- )} - -
- -
-
- - - {expanded && comment.suggestion && ( -
-
-
-
- -
- - Suggested Fix - -
-

- {comment.suggestion} -

-
-
- )} -
-
- - ); -} - -function SeverityDistributionBar({ - counts, - total, -}: { - counts: { critical: number; high: number; medium: number; low: number }; - total: number; -}) { - if (total === 0) { - return ( -
-
-
- ); - } - - const getWidth = (count: number) => `${(count / total) * 100}%`; - - return ( -
- {counts.critical > 0 && ( -
- )} - {counts.high > 0 && ( -
- )} - {counts.medium > 0 && ( -
- )} - {counts.low > 0 && ( -
- )} -
- ); -} - -function SeverityLegendItem({ - label, - count, - color, -}: { - label: string; - count: number; - color: string; -}) { - return ( -
-
- {label} - {count} -
- ); -} - -function RiskScoreSection({ score }: { score: number }) { - const config = getRiskConfig(score); - - return ( -
-
-

- Risk Score -

-
- - {score} - - - /100 - -
-
- -
-
-
-
-
- -
- Low - Critical -
-
- ); -} - -function getRiskConfig(score: number) { - if (score < 25) { - return { - color: "text-emerald-600 dark:text-emerald-400", - bg: "bg-emerald-500/10", - border: "border-emerald-500/20", - markerColor: "#10b981", - label: "Low Risk", - icon: ShieldCheck, - }; - } - if (score < 50) { - return { - color: "text-amber-600 dark:text-amber-400", - bg: "bg-amber-500/10", - border: "border-amber-500/20", - markerColor: "#f59e0b", - label: "Medium Risk", - icon: CircleDot, - }; - } - if (score < 75) { - return { - color: "text-orange-600 dark:text-orange-400", - bg: "bg-orange-500/10", - border: "border-orange-500/20", - markerColor: "#f97316", - label: "High Risk", - icon: ShieldAlert, - }; - } - return { - color: "text-red-600 dark:text-red-400", - bg: "bg-red-500/10", - border: "border-red-500/20", - markerColor: "#ef4444", - label: "Critical Risk", - icon: ShieldX, - }; -} - -function getSeverityStyles(severity: string) { - switch (severity) { - case "critical": - return { - bar: "bg-red-500", - badge: "border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400", - }; - case "high": - return { - bar: "bg-orange-500", - badge: - "border-orange-500/30 bg-orange-500/10 text-orange-600 dark:text-orange-400", - }; - case "medium": - return { - bar: "bg-amber-500", - badge: - "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", - }; - default: - return { - bar: "bg-slate-400 dark:bg-slate-500", - badge: "border-border bg-muted text-muted-foreground", - }; - } -} - -function getCategoryIcon(category?: string) { - switch (category) { - case "bug": - return Bug; - case "security": - return Shield; - case "performance": - return Zap; - case "style": - return Paintbrush; - case "suggestion": - return Lightbulb; - default: - return CircleDot; - } -} diff --git a/src/components/suggestion-diff-viewer.tsx b/src/components/suggestion-diff-viewer.tsx new file mode 100644 index 0000000..c504878 --- /dev/null +++ b/src/components/suggestion-diff-viewer.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useState } from "react"; +import { motion } from "framer-motion"; +import { cn } from "@/lib/utils"; +import { + Code2, + Columns2, + GitCompare, + Lightbulb, + Info, + AlertCircle, + RefreshCw, + FileCode, + Wrench, +} from "lucide-react"; +import { CodeComparison } from "@/components/code-comparison"; +import { CodeDiff } from "@/components/code-diff"; +import type { CodeSuggestion, CodeComment } from "@/lib/review-comparison"; + +type ViewMode = "side-by-side" | "diff" | "text"; + +interface SuggestionDiffViewerProps { + suggestion: string | CodeSuggestion; + fileName: string; + severity?: string; + oldCode?: string; + newCode?: string; + lineStart?: number; + context?: string; + className?: string; +} + +function SuggestionTypeIcon({ type }: { type: CodeSuggestion["type"] }) { + const icons = { + inline: , + block: , + refactor: , + }; + return icons[type] || ; +} + +function SuggestionTypeBadge({ type }: { type: CodeSuggestion["type"] }) { + const labels = { + inline: "Inline Fix", + block: "Code Block", + refactor: "Refactor", + }; + + const colors = { + inline: "bg-blue-500/10 text-blue-500 ring-blue-500/20", + block: "bg-purple-500/10 text-purple-500 ring-purple-500/20", + refactor: "bg-amber-500/10 text-amber-500 ring-amber-500/20", + }; + + return ( + + + {labels[type]} + + ); +} + +function ViewToggle({ + mode, + onChange, + disabled = false, +}: { + mode: ViewMode; + onChange: (mode: ViewMode) => void; + disabled?: boolean; +}) { + const options: { value: ViewMode; label: string; icon: React.ReactNode }[] = [ + { value: "side-by-side", label: "Side by Side", icon: }, + { value: "diff", label: "Diff", icon: }, + { value: "text", label: "Text", icon: }, + ]; + + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +function InlineComment({ + comment, + position, +}: { + comment: CodeComment; + position: "old" | "new" | "both"; +}) { + if (position === "both") { + // Show all comments when position is "both" + } else if (comment.position !== position && comment.position !== "both") { + return null; + } + + const typeStyles = { + note: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", + highlight: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20", + warning: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", + }; + + const typeIcons = { + note: , + highlight: , + warning: , + }; + + return ( +
+ {typeIcons[comment.type]} + {comment.content} +
+ ); +} + +export function SuggestionDiffViewer({ + suggestion, + fileName, + severity, + oldCode: propOldCode, + newCode: propNewCode, + lineStart: propLineStart, + context, + className, +}: SuggestionDiffViewerProps) { + // Determine if we have structured suggestion or just strings + const isStructured = typeof suggestion === "object" && "oldCode" in suggestion; + + const oldCode = isStructured + ? (suggestion as CodeSuggestion).oldCode.code + : propOldCode || ""; + const newCode = isStructured + ? (suggestion as CodeSuggestion).newCode.code + : propNewCode || ""; + const lineStart = isStructured + ? (suggestion as CodeSuggestion).oldCode.lineStart || propLineStart || 1 + : propLineStart || 1; + + const hint = isStructured ? (suggestion as CodeSuggestion).hint : ""; + const explanation = isStructured + ? (suggestion as CodeSuggestion).explanation + : context; + const suggestionType = isStructured + ? (suggestion as CodeSuggestion).type + : undefined; + const codeComments = isStructured + ? (suggestion as CodeSuggestion).codeComments + : undefined; + + // Check if we have code to compare + const hasCodeComparison = oldCode && newCode; + + // Default view mode based on available data + const [viewMode, setViewMode] = useState( + hasCodeComparison ? "side-by-side" : "text" + ); + + return ( + + {/* Header with type badge and view toggle */} +
+
+ {suggestionType && } + {hint && ( + + + {hint} + + )} +
+ +
+ + {/* Code display based on view mode */} + {viewMode === "side-by-side" && hasCodeComparison && ( + + )} + + {viewMode === "diff" && hasCodeComparison && ( + + )} + + {viewMode === "text" && ( +
+          {typeof suggestion === "string"
+            ? suggestion
+            : newCode || (suggestion as CodeSuggestion).newCode.code}
+        
+ )} + + {/* Inline code comments */} + {codeComments && codeComments.length > 0 && ( +
+ {codeComments.map((comment, i) => ( + + ))} +
+ )} + + {/* Explanation */} + {explanation && ( +
+ +

{explanation}

+
+ )} +
+ ); +} diff --git a/src/components/theme-toggle.tsx b/src/components/theme-toggle.tsx index 9448477..0136e99 100644 --- a/src/components/theme-toggle.tsx +++ b/src/components/theme-toggle.tsx @@ -2,6 +2,7 @@ import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; +import { motion, AnimatePresence } from "framer-motion"; import { Button } from "@/components/ui/button"; import { @@ -12,25 +13,66 @@ import { } from "@/components/ui/dropdown-menu"; export function ThemeToggle() { - const { setTheme } = useTheme(); + const { theme, setTheme } = useTheme(); return ( - - - setTheme("light")}> + + setTheme("light")} className="gap-2 text-sm"> + Light - setTheme("dark")}> + setTheme("dark")} className="gap-2 text-sm"> + Dark - setTheme("system")}> + setTheme("system")} className="gap-2 text-sm"> + + + + + System diff --git a/src/components/ui/animated-counter.tsx b/src/components/ui/animated-counter.tsx new file mode 100644 index 0000000..4072672 --- /dev/null +++ b/src/components/ui/animated-counter.tsx @@ -0,0 +1,32 @@ +"use client"; + +import CountUp from "react-countup"; + +interface AnimatedCounterProps { + end: number; + duration?: number; + decimals?: number; + prefix?: string; + suffix?: string; + className?: string; +} + +export function AnimatedCounter({ + end, + duration = 1.5, + decimals = 0, + prefix = "", + suffix = "", + className, +}: AnimatedCounterProps) { + return ( + + ); +} diff --git a/src/components/ui/animated-list.tsx b/src/components/ui/animated-list.tsx new file mode 100644 index 0000000..8ea2a0c --- /dev/null +++ b/src/components/ui/animated-list.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { motion } from "framer-motion"; +import { staggerContainer, staggerItem } from "@/lib/motion"; + +interface AnimatedListProps { + children: React.ReactNode; + className?: string; +} + +export function AnimatedList({ children, className }: AnimatedListProps) { + return ( + + {children} + + ); +} + +export function AnimatedListItem({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/animated-page.tsx b/src/components/ui/animated-page.tsx new file mode 100644 index 0000000..3485ac6 --- /dev/null +++ b/src/components/ui/animated-page.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { motion } from "framer-motion"; +import { pageTransition } from "@/lib/motion"; + +interface AnimatedPageProps { + children: React.ReactNode; + className?: string; +} + +export function AnimatedPage({ children, className }: AnimatedPageProps) { + return ( + + {children} + + ); +} diff --git a/src/components/ui/animated-tabs.tsx b/src/components/ui/animated-tabs.tsx new file mode 100644 index 0000000..6f1ef3e --- /dev/null +++ b/src/components/ui/animated-tabs.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { motion } from "framer-motion"; +import { cn } from "@/lib/utils"; + +interface Tab { + id: string; + label: string; + icon?: React.ReactNode; + count?: number; +} + +interface AnimatedTabsProps { + tabs: Tab[]; + activeTab: string; + onTabChange: (id: string) => void; + layoutId?: string; + className?: string; +} + +export function AnimatedTabs({ + tabs, + activeTab, + onTabChange, + layoutId = "tab-indicator", + className, +}: AnimatedTabsProps) { + return ( +
+ {tabs.map((tab) => { + const isActive = tab.id === activeTab; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/ui/circular-gauge.tsx b/src/components/ui/circular-gauge.tsx new file mode 100644 index 0000000..3bd7d02 --- /dev/null +++ b/src/components/ui/circular-gauge.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { motion } from "framer-motion"; +import { cn } from "@/lib/utils"; + +interface CircularGaugeProps { + value: number; + max?: number; + size?: number; + strokeWidth?: number; + className?: string; + label?: string; +} + +function getGaugeColor(value: number): string { + if (value <= 30) return "stroke-emerald-500"; + if (value <= 60) return "stroke-amber-500"; + return "stroke-red-500"; +} + +function getGaugeBg(value: number): string { + if (value <= 30) return "text-emerald-500"; + if (value <= 60) return "text-amber-500"; + return "text-red-500"; +} + +export function CircularGauge({ + value, + max = 100, + size = 120, + strokeWidth = 8, + className, + label, +}: CircularGaugeProps) { + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const percentage = Math.min(value / max, 1); + const offset = circumference - percentage * circumference; + + return ( +
+ + {/* Background track */} + + {/* Animated fill */} + + +
+ + {value} + + {label && ( + + {label} + + )} +
+
+ ); +} diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx new file mode 100644 index 0000000..bc7200d --- /dev/null +++ b/src/components/ui/empty-state.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { motion } from "framer-motion"; +import { fadeInUp } from "@/lib/motion"; +import type { LucideIcon } from "lucide-react"; + +interface EmptyStateProps { + icon: LucideIcon; + title: string; + description: string; + action?: React.ReactNode; +} + +export function EmptyState({ + icon: Icon, + title, + description, + action, +}: EmptyStateProps) { + return ( + +
+ +
+

{title}

+

+ {description} +

+ {action &&
{action}
} +
+ ); +} diff --git a/src/components/ui/github-icon.tsx b/src/components/ui/github-icon.tsx new file mode 100644 index 0000000..32eb85e --- /dev/null +++ b/src/components/ui/github-icon.tsx @@ -0,0 +1,12 @@ +export function GitHubIcon({ className }: { className?: string }) { + return ( + + ); +} diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx new file mode 100644 index 0000000..7194328 --- /dev/null +++ b/src/components/ui/page-header.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { motion } from "framer-motion"; +import { fadeInUp } from "@/lib/motion"; + +interface PageHeaderProps { + title: string; + description?: string; + actions?: React.ReactNode; +} + +export function PageHeader({ title, description, actions }: PageHeaderProps) { + return ( + +
+

{title}

+ {description && ( +

{description}

+ )} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/components/ui/status-dot.tsx b/src/components/ui/status-dot.tsx new file mode 100644 index 0000000..84fed1d --- /dev/null +++ b/src/components/ui/status-dot.tsx @@ -0,0 +1,30 @@ +import { cn } from "@/lib/utils"; + +interface StatusDotProps { + status: "success" | "warning" | "error" | "info" | "processing"; + className?: string; +} + +const statusStyles = { + success: "bg-emerald-500", + warning: "bg-amber-500", + error: "bg-red-500", + info: "bg-blue-500", + processing: "bg-blue-500 animate-pulse", +}; + +export function StatusDot({ status, className }: StatusDotProps) { + return ( + + {status === "processing" && ( + + )} + + + ); +} diff --git a/src/components/user-menu.tsx b/src/components/user-menu.tsx index 854af4c..f1c5bd8 100644 --- a/src/components/user-menu.tsx +++ b/src/components/user-menu.tsx @@ -1,27 +1,38 @@ "use client"; -import { signOut } from "@/lib/auth-client"; import { useRouter } from "next/navigation"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; +import { signOut } from "@/lib/auth-client"; +import { LogOut, ChevronDown } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { LogOut, Settings, User, ChevronDown } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -interface UserProps { +interface User { id: string; name: string; email: string; image?: string | null | undefined; } -export function UserMenu({ user }: { user: UserProps }) { +function getInitials(name: string, email: string): string { + if (name) { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + } + return email[0]?.toUpperCase() || "U"; +} + +export function UserMenu({ user }: { user: User }) { const router = useRouter(); const handleSignOut = async () => { @@ -29,73 +40,36 @@ export function UserMenu({ user }: { user: UserProps }) { router.push("/"); }; - const initials = user.name - ? user.name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase() - .slice(0, 2) - : (user.email[0].toUpperCase() ?? "U"); - return ( - + + - - -
-
- - - - {initials} - - -
- - {user.name ?? "User"} - - - {user.email ?? "Email"} - -
+ + +
+

{user.name}

+

{user.email}

-
- - - - Profile - - - - Settings - + - - Log Out + + Sign out diff --git a/src/lib/code-diff-utils.ts b/src/lib/code-diff-utils.ts new file mode 100644 index 0000000..a6eae4f --- /dev/null +++ b/src/lib/code-diff-utils.ts @@ -0,0 +1,339 @@ +/** + * Utility functions for code diffing, language detection, and syntax highlighting. + */ + +// Language detection by file extension +const LANGUAGE_MAP: Record = { + // JavaScript/TypeScript + ".js": "javascript", + ".jsx": "javascript", + ".ts": "typescript", + ".tsx": "typescript", + ".mjs": "javascript", + ".cjs": "javascript", + // Web + ".html": "html", + ".htm": "html", + ".css": "css", + ".scss": "scss", + ".sass": "sass", + ".less": "less", + // Data + ".json": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".xml": "xml", + ".toml": "toml", + // Backend + ".py": "python", + ".rb": "ruby", + ".go": "go", + ".rs": "rust", + ".java": "java", + ".kt": "kotlin", + ".scala": "scala", + ".php": "php", + ".cs": "csharp", + ".cpp": "cpp", + ".c": "c", + ".h": "c", + ".hpp": "cpp", + // Shell + ".sh": "bash", + ".bash": "bash", + ".zsh": "bash", + ".fish": "fish", + ".ps1": "powershell", + // Config + ".env": "bash", + ".gitignore": "text", + ".dockerignore": "text", + // Markup + ".md": "markdown", + ".mdx": "markdown", + // SQL + ".sql": "sql", + // Other + ".prisma": "prisma", + ".graphql": "graphql", + ".gql": "graphql", + ".vue": "vue", + ".svelte": "svelte", +}; + +/** + * Detects programming language from filename or extension + */ +export function detectLanguage(filename: string): string { + const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase(); + return LANGUAGE_MAP[ext] || "text"; +} + +/** + * Represents a single line in a diff + */ +export interface DiffLine { + type: "add" | "del" | "context" | "hunk"; + content: string; + oldLineNumber?: number; + newLineNumber?: number; +} + +/** + * Parses a unified diff patch into structured lines + */ +export function parseDiffPatch(patch: string): DiffLine[] { + const lines = patch.split("\n"); + const result: DiffLine[] = []; + let oldLine = 0; + let newLine = 0; + + for (const line of lines) { + if (line.startsWith("@@")) { + // Parse hunk header: @@ -start,count +start,count @@ + const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)/); + if (match) { + oldLine = parseInt(match[1], 10); + newLine = parseInt(match[2], 10); + } + result.push({ type: "hunk", content: line }); + } else if (line.startsWith("+") && !line.startsWith("+++")) { + result.push({ + type: "add", + content: line.slice(1), + newLineNumber: newLine++, + }); + } else if (line.startsWith("-") && !line.startsWith("---")) { + result.push({ + type: "del", + content: line.slice(1), + oldLineNumber: oldLine++, + }); + } else if (line.startsWith("\\")) { + // "\ No newline at end of file" - skip + continue; + } else { + // Context line + const content = line.startsWith(" ") ? line.slice(1) : line; + result.push({ + type: "context", + content, + oldLineNumber: oldLine++, + newLineNumber: newLine++, + }); + } + } + + return result; +} + +/** + * Computes a simple line-based diff between old and new code + */ +export function computeSimpleDiff( + oldCode: string, + newCode: string +): DiffLine[] { + const oldLines = oldCode.split("\n"); + const newLines = newCode.split("\n"); + const result: DiffLine[] = []; + + // Simple LCS-based diff for small code blocks + const maxLen = Math.max(oldLines.length, newLines.length); + + let oldIdx = 0; + let newIdx = 0; + let lineNum = 1; + + while (oldIdx < oldLines.length || newIdx < newLines.length) { + const oldLine = oldLines[oldIdx]; + const newLine = newLines[newIdx]; + + if (oldLine === newLine && oldIdx < oldLines.length && newIdx < newLines.length) { + result.push({ + type: "context", + content: oldLine, + oldLineNumber: lineNum, + newLineNumber: lineNum, + }); + oldIdx++; + newIdx++; + lineNum++; + } else if (oldIdx < oldLines.length && !newLines.slice(newIdx).includes(oldLine)) { + result.push({ + type: "del", + content: oldLine, + oldLineNumber: lineNum, + }); + oldIdx++; + } else if (newIdx < newLines.length && !oldLines.slice(oldIdx).includes(newLine)) { + result.push({ + type: "add", + content: newLine, + newLineNumber: lineNum, + }); + newIdx++; + } else if (oldIdx < oldLines.length) { + result.push({ + type: "del", + content: oldLine, + oldLineNumber: lineNum, + }); + oldIdx++; + } else if (newIdx < newLines.length) { + result.push({ + type: "add", + content: newLine, + newLineNumber: lineNum, + }); + newIdx++; + } + lineNum++; + } + + return result; +} + +/** + * Extracts line numbers from a diff hunk for context + */ +export function extractLineRange( + startLine: number, + code: string +): { start: number; end: number } { + const lineCount = code.split("\n").length; + return { + start: startLine, + end: startLine + lineCount - 1, + }; +} + +/** + * Normalizes code for comparison (trims trailing whitespace, normalizes line endings) + */ +export function normalizeCode(code: string): string { + return code + .replace(/\r\n/g, "\n") + .split("\n") + .map((line) => line.trimEnd()) + .join("\n") + .trim(); +} + +/** + * Highlights differences between two strings at character level + */ +export interface CharDiff { + text: string; + type: "same" | "add" | "del"; +} + +export function computeCharDiff(oldStr: string, newStr: string): { + oldParts: CharDiff[]; + newParts: CharDiff[]; +} { + // Simple character diff for highlighting inline changes + const oldParts: CharDiff[] = []; + const newParts: CharDiff[] = []; + + // Find common prefix + let prefixLen = 0; + while ( + prefixLen < oldStr.length && + prefixLen < newStr.length && + oldStr[prefixLen] === newStr[prefixLen] + ) { + prefixLen++; + } + + // Find common suffix + let oldSuffixStart = oldStr.length; + let newSuffixStart = newStr.length; + while ( + oldSuffixStart > prefixLen && + newSuffixStart > prefixLen && + oldStr[oldSuffixStart - 1] === newStr[newSuffixStart - 1] + ) { + oldSuffixStart--; + newSuffixStart--; + } + + // Build diff parts + if (prefixLen > 0) { + const prefix = oldStr.slice(0, prefixLen); + oldParts.push({ text: prefix, type: "same" }); + newParts.push({ text: prefix, type: "same" }); + } + + if (oldSuffixStart > prefixLen) { + oldParts.push({ text: oldStr.slice(prefixLen, oldSuffixStart), type: "del" }); + } + + if (newSuffixStart > prefixLen) { + newParts.push({ text: newStr.slice(prefixLen, newSuffixStart), type: "add" }); + } + + if (oldSuffixStart < oldStr.length) { + const suffix = oldStr.slice(oldSuffixStart); + oldParts.push({ text: suffix, type: "same" }); + newParts.push({ text: suffix, type: "same" }); + } + + return { oldParts, newParts }; +} + +/** + * Truncates code to a maximum number of lines + */ +export function truncateCode(code: string, maxLines: number = 20): { + code: string; + truncated: boolean; + originalLineCount: number; +} { + const lines = code.split("\n"); + const originalLineCount = lines.length; + + if (lines.length <= maxLines) { + return { code, truncated: false, originalLineCount }; + } + + return { + code: lines.slice(0, maxLines).join("\n"), + truncated: true, + originalLineCount, + }; +} + +/** + * Gets CSS classes for syntax highlighting based on line type + */ +export function getLineTypeClasses(type: "add" | "del" | "context" | "hunk"): { + bg: string; + text: string; + marker: string; +} { + const classes = { + add: { + bg: "bg-emerald-500/10 dark:bg-emerald-500/8", + text: "text-emerald-700 dark:text-emerald-400", + marker: "+", + }, + del: { + bg: "bg-red-500/10 dark:bg-red-500/8", + text: "text-red-700 dark:text-red-400", + marker: "-", + }, + context: { + bg: "", + text: "text-foreground", + marker: " ", + }, + hunk: { + bg: "bg-primary/5", + text: "text-primary/70", + marker: "@", + }, + }; + + return classes[type]; +} diff --git a/src/lib/motion.ts b/src/lib/motion.ts new file mode 100644 index 0000000..2e999de --- /dev/null +++ b/src/lib/motion.ts @@ -0,0 +1,91 @@ +import type { Variants, Transition } from "framer-motion"; + +// ─── Default transitions ──────────────────────────────────────────────────── +export const springTransition: Transition = { + type: "spring", + stiffness: 400, + damping: 30, +}; + +export const smoothTransition: Transition = { + duration: 0.5, + ease: [0.22, 1, 0.36, 1], +}; + +// ─── Common variants ──────────────────────────────────────────────────────── +export const fadeIn: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: smoothTransition }, +}; + +export const fadeInUp: Variants = { + hidden: { opacity: 0, y: 16 }, + visible: { opacity: 1, y: 0, transition: smoothTransition }, +}; + +export const fadeInDown: Variants = { + hidden: { opacity: 0, y: -16 }, + visible: { opacity: 1, y: 0, transition: smoothTransition }, +}; + +export const fadeInLeft: Variants = { + hidden: { opacity: 0, x: -16 }, + visible: { opacity: 1, x: 0, transition: smoothTransition }, +}; + +export const fadeInRight: Variants = { + hidden: { opacity: 0, x: 16 }, + visible: { opacity: 1, x: 0, transition: smoothTransition }, +}; + +export const scaleIn: Variants = { + hidden: { opacity: 0, scale: 0.95 }, + visible: { opacity: 1, scale: 1, transition: smoothTransition }, +}; + +// ─── Stagger container ────────────────────────────────────────────────────── +export const staggerContainer: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.06, + delayChildren: 0.1, + }, + }, +}; + +export const staggerItem: Variants = { + hidden: { opacity: 0, y: 12 }, + visible: { + opacity: 1, + y: 0, + transition: smoothTransition, + }, +}; + +// ─── Page transition ──────────────────────────────────────────────────────── +export const pageTransition: Variants = { + hidden: { opacity: 0, y: 8 }, + visible: { + opacity: 1, + y: 0, + transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] }, + }, + exit: { + opacity: 0, + y: -8, + transition: { duration: 0.3, ease: [0.22, 1, 0.36, 1] }, + }, +}; + +// ─── Card hover props ─────────────────────────────────────────────────────── +export const cardHover = { + whileHover: { y: -2, transition: { duration: 0.2 } }, + whileTap: { scale: 0.99 }, +}; + +// ─── Button press props ───────────────────────────────────────────────────── +export const buttonPress = { + whileTap: { scale: 0.97 }, +}; diff --git a/src/lib/review-comparison.ts b/src/lib/review-comparison.ts index 93a6012..1f8adaf 100644 --- a/src/lib/review-comparison.ts +++ b/src/lib/review-comparison.ts @@ -3,6 +3,36 @@ * Used by the compare page and any future consumers (e.g. analytics). */ +// ─── Code Comparison Types ─────────────────────────────────────────────────── + +/** Represents a code block with optional language and line information */ +export interface CodeBlock { + code: string; + language?: string; + lineStart?: number; + lineEnd?: number; +} + +/** Inline comment within a code block */ +export interface CodeComment { + lineNumber: number; + type: "note" | "highlight" | "warning"; + content: string; + position: "old" | "new" | "both"; +} + +/** Structured code suggestion with old/new comparison */ +export interface CodeSuggestion { + type: "inline" | "block" | "refactor"; + oldCode: CodeBlock; + newCode: CodeBlock; + hint: string; + explanation?: string; + codeComments?: CodeComment[]; +} + +// ─── Review Comment Types ──────────────────────────────────────────────────── + /** Comment shape as stored in DB / returned by API (category may be missing in legacy data). */ export interface ReviewCommentInput { file: string; @@ -11,6 +41,14 @@ export interface ReviewCommentInput { category?: string; message: string; suggestion?: string; + // Enhanced code comparison fields + oldCode?: string; + newCode?: string; + lineStart?: number; + lineEnd?: number; + context?: string; + // Structured suggestion (alternative to string suggestion) + codeSuggestion?: CodeSuggestion; } /** Normalized key for matching the same issue across reviews (file + line + severity + category). */ diff --git a/src/server/api/routers/review.ts b/src/server/api/routers/review.ts index d168eec..8b4a4fe 100644 --- a/src/server/api/routers/review.ts +++ b/src/server/api/routers/review.ts @@ -176,22 +176,40 @@ export const reviewRouter = createTRPCRouter({ }); } - if (review.status !== "PENDING" && review.status !== "PROCESSING") { - throw new TRPCError({ - code: "PRECONDITION_FAILED", - message: "Only pending or processing reviews can be cancelled", - }); + // Idempotent: if it's already cancelled, treat this as success. + if (review.status === "CANCELLED") { + return { success: true }; } - await ctx.db.review.update({ - where: { id: input.reviewId }, - data: { status: "CANCELLED" }, - }); + try { + await ctx.db.review.update({ + where: { id: input.reviewId }, + data: { status: "CANCELLED" }, + }); - await inngest.send({ - name: "review/pr.cancelled", - data: { reviewId: input.reviewId }, - }); + await inngest.send({ + name: "review/pr.cancelled", + data: { reviewId: input.reviewId }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + + if ( + message.includes('invalid input value for enum "ReviewStatus"') && + message.includes("CANCELLED") + ) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Cancellation is temporarily unavailable due to a server configuration issue. Please try again shortly.", + }); + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to cancel review. Please try again.", + }); + } return { success: true }; }), diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index efa6372..ded41fc 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -13,11 +13,37 @@ export const createTRPCContext = async (opts: { headers: Headers }) => { }; }; +function sanitizeErrorMessage(code: string, message: string): string { + const normalized = message.trim(); + console.error(`Error [${code}]: ${normalized}`); + const firstLine = normalized + .split("\n") + .map((line) => line.trim()) + .find(Boolean); + + // Prisma/driver wrappers may contain a lot of noise; extract the underlying message. + const embeddedMessage = normalized.match(/message:\s*"([^"]+)"/i)?.[1]; + if ( + normalized.includes("Invalid `") || + normalized.includes("invocation") || + normalized.includes("ConnectorError") || + normalized.includes("Error occurred during query execution") + ) { + return embeddedMessage ?? firstLine ?? normalized; + } + + // Keep original message, but collapse multiline traces to the first meaningful line. + return firstLine ?? normalized; +} + const t = initTRPC.context().create({ transformer: superjson, errorFormatter({ shape, error }) { + const sanitizedMessage = sanitizeErrorMessage(shape.data.code, shape.message); + return { ...shape, + message: sanitizedMessage, data: { ...shape.data, zodError: diff --git a/src/server/services/ai/review.ts b/src/server/services/ai/review.ts index 10f3658..c42b9cf 100644 --- a/src/server/services/ai/review.ts +++ b/src/server/services/ai/review.ts @@ -21,6 +21,7 @@ Your review should: 2. Provide a brief summary of the changes 3. Assign a risk score (0-100) based on the complexity and potential issues 4. Give specific, actionable feedback with line numbers +5. For each issue, extract the problematic code snippet and provide the fixed version Severity guide: - critical: Security vulnerabilities, data loss, crashes @@ -37,15 +38,26 @@ IMPORTANT: You MUST respond with valid JSON matching this exact schema: "comments": [ { "file": "string - file path", - "line": "number - line number", + "line": "number - line number where the issue starts", "severity": "critical | high | medium | low", "category": "bug | security | performance | style | suggestion", "message": "string - what the issue is", - "suggestion": "string (optional) - how to fix it" + "suggestion": "string (optional) - brief text description of how to fix it", + "oldCode": "string (optional) - the problematic code snippet (1-10 lines max)", + "newCode": "string (optional) - the suggested fixed code snippet", + "lineStart": "number (optional) - starting line number of the code snippet", + "lineEnd": "number (optional) - ending line number of the code snippet", + "context": "string (optional) - additional context or explanation for the fix" } ] } +Code extraction rules: +- oldCode: Extract the exact problematic lines from the diff (include 1-10 lines of context) +- newCode: Provide the corrected version of oldCode with your suggested fix applied +- lineStart/lineEnd: Reference the line numbers from the original file +- Only include oldCode/newCode when you have a concrete code fix to suggest + Respond ONLY with the JSON object, no markdown fences or extra text.`; // ─── Helpers ───────────────────────────────────────────────────────────────── diff --git a/src/server/services/ai/types.ts b/src/server/services/ai/types.ts index b555eee..55c089d 100644 --- a/src/server/services/ai/types.ts +++ b/src/server/services/ai/types.ts @@ -2,13 +2,126 @@ import { z } from "zod"; // ─── Review Schemas ────────────────────────────────────────────────────────── +const REVIEW_SEVERITIES = ["critical", "high", "medium", "low"] as const; +const REVIEW_CATEGORIES = [ + "bug", + "security", + "performance", + "style", + "suggestion", +] as const; + +type ReviewSeverity = (typeof REVIEW_SEVERITIES)[number]; +type ReviewCategory = (typeof REVIEW_CATEGORIES)[number]; + +function normalizeSeverity(value: unknown): ReviewSeverity { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/[_\s-]+/g, ""); + + const map: Record = { + critical: "critical", + severe: "critical", + blocker: "critical", + high: "high", + major: "high", + medium: "medium", + moderate: "medium", + low: "low", + minor: "low", + info: "low", + informational: "low", + }; + + return map[normalized] ?? "low"; +} + +function normalizeCategory(value: unknown): ReviewCategory { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/[_\s-]+/g, ""); + + const map: Record = { + bug: "bug", + bugs: "bug", + defect: "bug", + issue: "bug", + security: "security", + vulnerability: "security", + vulnerabilities: "security", + auth: "security", + performance: "performance", + perf: "performance", + optimization: "performance", + style: "style", + formatting: "style", + readability: "style", + maintainability: "style", + bestpractice: "suggestion", + bestpractices: "suggestion", + suggestion: "suggestion", + suggestions: "suggestion", + improvement: "suggestion", + improvements: "suggestion", + }; + + return map[normalized] ?? "suggestion"; +} + +// ─── Code Comparison Types ─────────────────────────────────────────────────── + +export const CodeBlockSchema = z.object({ + code: z.string(), + language: z.string().optional(), + lineStart: z.number().optional(), + lineEnd: z.number().optional(), +}); + +export const CodeCommentSchema = z.object({ + lineNumber: z.number(), + type: z.enum(["note", "highlight", "warning"]), + content: z.string(), + position: z.enum(["old", "new", "both"]), +}); + +export const CodeSuggestionSchema = z.object({ + type: z.enum(["inline", "block", "refactor"]), + oldCode: CodeBlockSchema, + newCode: CodeBlockSchema, + hint: z.string(), + explanation: z.string().optional(), + codeComments: z.array(CodeCommentSchema).optional(), +}); + +export type CodeBlock = z.infer; +export type CodeComment = z.infer; +export type CodeSuggestion = z.infer; + +// ─── Review Comment Schema ─────────────────────────────────────────────────── + export const ReviewCommentSchema = z.object({ file: z.string(), line: z.number(), - severity: z.enum(["critical", "high", "medium", "low"]), - category: z.enum(["bug", "security", "performance", "style", "suggestion"]), + severity: z.preprocess( + (value) => normalizeSeverity(value), + z.enum(REVIEW_SEVERITIES), + ), + category: z.preprocess( + (value) => normalizeCategory(value), + z.enum(REVIEW_CATEGORIES), + ), message: z.string(), suggestion: z.string().optional(), + // Enhanced code comparison fields + oldCode: z.string().optional(), + newCode: z.string().optional(), + lineStart: z.number().optional(), + lineEnd: z.number().optional(), + context: z.string().optional(), + // Structured suggestion (alternative to string suggestion) + codeSuggestion: CodeSuggestionSchema.optional(), }); export const ReviewResultSchema = z.object({