diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..27e36b4 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# --- Firebase (client — safe to expose, but keep consistent) --- +NEXT_PUBLIC_FIREBASE_API_KEY= +NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= +NEXT_PUBLIC_FIREBASE_PROJECT_ID= +NEXT_PUBLIC_FIREBASE_APP_ID= +# --- Firebase admin (server only) --- +FIREBASE_PROJECT_ID= +FIREBASE_CLIENT_EMAIL= +FIREBASE_PRIVATE_KEY= +# --- Image providers (server only; leave blank to disable a provider) --- +OPENAI_API_KEY= +REPLICATE_API_TOKEN= +FAL_API_KEY= +STABILITY_API_KEY= +HUGGINGFACE_API_KEY= +# --- Storage --- +BLOB_READ_WRITE_TOKEN= +# --- Dev only: skip auth verification outside production --- +AUTH_DEV_BYPASS= +# --- Dev/e2e only: client-side mirror of AUTH_DEV_BYPASS so AuthGate skips +# the sign-in redirect (e.g. Playwright smoke tests). Inlined at build time — +# never set in production. --- +NEXT_PUBLIC_AUTH_DEV_BYPASS= +# --- Feature flags --- +# Enable YouTube video import and title-based form inference (set to "1" to enable) +FEATURE_YT_IMPORT= diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index a7f6126..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "env": { - "browser": true, - "es2021": true - }, - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "next", - "next/core-web-vitals", - "plugin:prettier/recommended" - ], - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaFeatures": { - "jsx": true - }, - "ecmaVersion": "latest", - "sourceType": "module" - }, - "plugins": [ - "@typescript-eslint", - "prettier" - ], - "rules": { - "semi": ["error", "always"], - "quotes": ["error", "double"], - "@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_" }], - "@typescript-eslint/no-explicit-any": "off", - "prettier/prettier": "error" - } -} - \ No newline at end of file diff --git a/.firebaserc b/.firebaserc deleted file mode 100644 index a8e095f..0000000 --- a/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "pixelai-30f7a" - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ab1ed55 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main, v2-rebuild] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm lint + + - run: pnpm test + + - run: pnpm build + env: + NEXT_PUBLIC_FIREBASE_API_KEY: ci-placeholder + NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ci.firebaseapp.com + NEXT_PUBLIC_FIREBASE_PROJECT_ID: ci-placeholder + NEXT_PUBLIC_FIREBASE_APP_ID: ci-placeholder + + # TODO: Add quota tests job when Java setup is available + # (Firebase emulator requires Java on PATH; out of scope for current CI) diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml deleted file mode 100644 index 42daf98..0000000 --- a/.github/workflows/firebase-hosting-merge.yml +++ /dev/null @@ -1,36 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Deploy Frontend To Firebase Hosting On Merge -'on': - push: - branches: - - main -jobs: - build_and_deploy: - runs-on: ubuntu-latest - environment: Mail Envs - env: - NEXT_FIREBASE_API_KEY: ${{ secrets.NEXT_FIREBASE_API_KEY }} - NEXT_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_FIREBASE_AUTH_DOMAIN }} - NEXT_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_FIREBASE_PROJECT_ID }} - NEXT_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_FIREBASE_STORAGE_BUCKET }} - NEXT_FIREBASE_SENDER_ID: ${{ secrets.NEXT_FIREBASE_SENDER_ID }} - NEXT_FIREBASE_APP_ID: ${{ secrets.NEXT_FIREBASE_APP_ID }} - NEXT_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_FIREBASE_MEASUREMENT_ID }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} - NEXT_CLERK_SECRET_KEY: ${{ secrets.NEXT_CLERK_SECRET_KEY }} - NEXT_CLERK_FRONTEND_URL: ${{ secrets.NEXT_CLERK_FRONTEND_URL }} - NEXT_CLERK_BACKEND_URL: ${{ secrets.NEXT_CLERK_BACKEND_URL }} - steps: - - uses: actions/checkout@v2 - - - name: Run frontend build - run: npm install --legacy-peer-deps && npm run build - - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: '${{ secrets.GITHUB_TOKEN }}' - firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_PIXELAI_30F7A }}' - channelId: live - projectId: pixelai-30f7a diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml deleted file mode 100644 index f22d961..0000000 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ /dev/null @@ -1,31 +0,0 @@ -# This file was auto-generated by the Firebase CLI -# https://github.com/firebase/firebase-tools - -name: Deploy Frontend To Firebase Hosting On PR -'on': pull_request -jobs: - build_and_preview: - if: '${{ github.event.pull_request.head.repo.full_name == github.repository }}' - runs-on: ubuntu-latest - environment: Mail Envs - env: - NEXT_FIREBASE_API_KEY: ${{ secrets.NEXT_FIREBASE_API_KEY }} - NEXT_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_FIREBASE_AUTH_DOMAIN }} - NEXT_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_FIREBASE_PROJECT_ID }} - NEXT_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_FIREBASE_STORAGE_BUCKET }} - NEXT_FIREBASE_SENDER_ID: ${{ secrets.NEXT_FIREBASE_SENDER_ID }} - NEXT_FIREBASE_APP_ID: ${{ secrets.NEXT_FIREBASE_APP_ID }} - NEXT_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_FIREBASE_MEASUREMENT_ID }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} - NEXT_CLERK_SECRET_KEY: ${{ secrets.NEXT_CLERK_SECRET_KEY }} - NEXT_CLERK_FRONTEND_URL: ${{ secrets.NEXT_CLERK_FRONTEND_URL }} - NEXT_CLERK_BACKEND_URL: ${{ secrets.NEXT_CLERK_BACKEND_URL }} - steps: - - uses: actions/checkout@v2 - - - run: npm install --legacy-peer-deps && npm run build - - uses: FirebaseExtended/action-hosting-deploy@v0 - with: - repoToken: '${{ secrets.GITHUB_TOKEN }}' - firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_PIXELAI_30F7A }}' - projectId: pixelai-30f7a diff --git a/.gitignore b/.gitignore index a065ba1..f15b163 100644 --- a/.gitignore +++ b/.gitignore @@ -1,134 +1,54 @@ -# Logs -logs -*.log +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug npm-debug.log* yarn-debug.log* yarn-error.log* -lerna-debug.log* .pnpm-debug.log* -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components +# firebase emulator logs +firebase-debug.log* +firestore-debug.log* +ui-debug.log* -# node-waf configuration -.lock-wscript +# env files (can opt-in for committing if needed) +.env* +!.env.example -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release +# vercel +.vercel -# Dependency directories -node_modules/ -jspm_packages/ -.firebase/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache +# typescript *.tsbuildinfo +next-env.d.ts -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -.firebase -./.firebase - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +# superpowers (internal task-planning artifacts, not part of the app) +.superpowers/ diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 58066b0..0000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "printWidth": 80, - "trailingComma": "es5", - "tabWidth": 2 -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 08df0a9..3ae623b 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,189 @@ -# Next.js & NextUI Template +# PixelAI + +A multi-provider AI thumbnail generator that lets you create custom thumbnails using OpenAI, Replicate, Fal, Stability, and HuggingFace. Built with Next.js, React, TypeScript, and powered by the AI SDK. + +## Features + +- **Multi-provider support**: Choose from five AI image providers (OpenAI, Replicate, Fal, Stability, HuggingFace) +- **Provider auto-detection**: Providers without configured API keys are automatically disabled in the UI +- **Quota system**: Free tier supports 10 image generations per day per user +- **Cloud storage**: Generated images are stored in Vercel Blob for fast, global access +- **Firestore history**: Track all generated images with owner-scoped access control +- **Firebase Auth**: Secure authentication with support for multiple providers +- **Type-safe**: Strict TypeScript with rigorous type checking throughout + +## Tech Stack + +- **Framework**: Next.js 16 App Router +- **React**: 19 (with React 19 concurrent features) +- **Language**: TypeScript (strict mode) +- **Styling**: Tailwind CSS v4, shadcn/ui (Base UI preset) +- **AI Integration**: AI SDK v7 (`ai@7`) with custom provider adapters +- **Auth & Database**: Firebase Auth + Firestore +- **Storage**: Vercel Blob +- **Testing**: Vitest 4 (three projects: node/quota/jsdom) + Playwright for e2e +- **Package Manager**: pnpm 10 +- **Runtime**: Node.js 22 + +## Getting Started + +### Prerequisites + +- Node.js 22+ +- pnpm 10+ +- Java (macOS: `brew install openjdk` then add to PATH for quota tests) + +### Installation + +1. **Clone and install**: + ```bash + git clone + cd pixelai + pnpm install + ``` + +2. **Set up environment**: + ```bash + cp .env.example .env.local + ``` + +3. **Configure Firebase** (in `.env.local`): + - `NEXT_PUBLIC_FIREBASE_API_KEY` + - `NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN` + - `NEXT_PUBLIC_FIREBASE_PROJECT_ID` + - `NEXT_PUBLIC_FIREBASE_APP_ID` + - `FIREBASE_PROJECT_ID` (admin) + - `FIREBASE_CLIENT_EMAIL` (admin) + - `FIREBASE_PRIVATE_KEY` (admin) + +4. **Configure at least one provider** (in `.env.local`): + - `OPENAI_API_KEY` — OpenAI image generation + - `REPLICATE_API_TOKEN` — Replicate models + - `FAL_API_KEY` — Fal (first-party AI SDK support) + - `STABILITY_API_KEY` — Stability AI + - `HUGGINGFACE_API_KEY` — HuggingFace + +5. **Configure storage** (in `.env.local`): + - `BLOB_READ_WRITE_TOKEN` — Vercel Blob access token + +6. **Dev-only bypass** (optional, never in production): + - `AUTH_DEV_BYPASS=1` — Skip auth verification during local development (server refuses this in production) + +7. **Feature flags** (optional): + - `FEATURE_YT_IMPORT` — YouTube URL import feature (not yet implemented) + +### Scripts + +| Script | Purpose | +| --- | --- | +| `pnpm dev` | Start local development server (default: `localhost:3000`) | +| `pnpm build` | Build for production | +| `pnpm start` | Start production server | +| `pnpm lint` | Run ESLint via Next.js linter | +| `pnpm test` | Run unit/integration tests (node + jsdom projects) | +| `pnpm test:quota` | Run quota tests against Firestore emulator (requires Java; see prerequisites) | +| `pnpm test:watch` | Run tests in watch mode | + +### Development -This is a template for creating applications using Next.js 14 (app directory) and NextUI (v2). +```bash +pnpm dev +# Open http://localhost:3000 +``` -[Try it on CodeSandbox](https://githubbox.com/nextui-org/next-app-template) +This starts Next.js dev server with full HMR and TypeScript checking. -## Technologies Used +### Testing -- [Next.js 14](https://nextjs.org/docs/getting-started) -- [NextUI v2](https://nextui.org/) -- [Tailwind CSS](https://tailwindcss.com/) -- [Tailwind Variants](https://tailwind-variants.org) -- [TypeScript](https://www.typescriptlang.org/) -- [Framer Motion](https://www.framer.com/motion/) -- [next-themes](https://github.com/pacocoursey/next-themes) +Run all tests: +```bash +pnpm test # Unit/integration (node + jsdom) +pnpm test:quota # Quota enforcement (Firestore emulator on port 8181) +pnpm exec playwright test # E2E tests (Task 16+) +``` -## How to Use +**Quota tests require Java on PATH**. On macOS: +```bash +brew install openjdk +export PATH="/opt/homebrew/opt/openjdk/bin:$PATH" +pnpm test:quota +``` -### Use the template with create-next-app +## Architecture -To create a new project based on this template using `create-next-app`, run the following command: +### Provider Registry +Image providers are defined in `lib/ai/registry.ts` (server metadata) and `lib/ai/provider-meta.ts` (client-safe metadata). Custom adapters for HuggingFace and Stability live in `lib/ai/adapters/`. -```bash -npx create-next-app -e https://github.com/nextui-org/next-app-template -``` +### Request Flow +1. Client selects provider and sends text prompt +2. POST `/api/generate` with Firebase ID token +3. Server verifies token and checks quota (Firestore transactional: 10/day limit) +4. Image generation via provider adapter (55s timeout) +5. Upload to Vercel Blob +6. Write generation record to Firestore +7. Return Blob URL to client -### Install dependencies +### History +View all generated images at `/history`. Images are owner-scoped via Firestore queries on `uid` field. Delete button removes Blob entry and Firestore doc. -You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`: +## Deployment -```bash -npm install -``` +### Vercel -### Run the development server +1. **Link project**: + ```bash + vercel link + ``` -```bash -npm run dev -``` +2. **Pull environment from Vercel** (or set manually): + ```bash + vercel pull + ``` + Ensure all vars from `.env.example` are set in your Vercel project dashboard. -### Setup pnpm (optional) +3. **Deploy preview** or **production**: + ```bash + vercel deploy # Preview + vercel --prod # Production + ``` -If you are using `pnpm`, you need to add the following code to your `.npmrc` file: +### Firebase Setup -```bash -public-hoist-pattern[]=*@nextui-org/* -``` +1. **Enable Auth providers** in [Firebase Console](https://console.firebase.google.com): + - Google, GitHub, etc. as needed by your login UI + +2. **Deploy Firestore rules**: + ```bash + firebase deploy --only firestore:rules + ``` + +3. **Create composite index** (on first production query error, or proactively via console): + - Collection: `generations` + - Fields: `uid` (Ascending), `createdAt` (Descending) + - Console path: Firestore → Indexes → Create Composite Index + +### Environment Variables in Vercel + +Set these in your Vercel project settings: +- All `NEXT_PUBLIC_FIREBASE_*` (public, safe to expose) +- All `FIREBASE_*` (admin; keep private) +- `OPENAI_API_KEY`, `REPLICATE_API_TOKEN`, `FAL_API_KEY`, `STABILITY_API_KEY`, `HUGGINGFACE_API_KEY` (provider keys; leave blank to disable) +- `BLOB_READ_WRITE_TOKEN` (Vercel Blob) + +**Never set `AUTH_DEV_BYPASS` in production** — the server explicitly refuses it. + +### Route Configuration + +Image generation has a 55-second timeout set via route segment config (Next.js `route.ts` `maxDuration`). No `vercel.json` needed. + +## Development Notes -After modifying the `.npmrc` file, you need to run `pnpm install` again to ensure that the dependencies are installed correctly. +- **TypeScript Strict Mode**: Full strict checking enabled; no `any` in product code +- **React Server Components**: App Router uses RSCs by default; client components marked with `'use client'` +- **Server Actions**: Use `'use server'` for mutations; Server Actions enforce type safety +- **Styling**: Tailwind v4 with shadcn/ui (Base UI preset) for accessible components +- **Testing**: Vitest for unit tests, Playwright for e2e flows (added in Task 16) ## License -Licensed under the [MIT license](https://github.com/nextui-org/next-app-template/blob/main/LICENSE). +MIT diff --git a/__mocks__/firebaseConfig.js b/__mocks__/firebaseConfig.js deleted file mode 100644 index 2e818fd..0000000 --- a/__mocks__/firebaseConfig.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable no-undef */ -export const getAuth = jest.fn(() => ({ - signInWithEmailAndPassword: jest.fn(), - createUserWithEmailAndPassword: jest.fn(), -})); - -export const getFirestore = jest.fn(() => ({ - collection: jest.fn(() => ({ - doc: jest.fn(() => ({ - set: jest.fn(), - get: jest.fn(), - })), - })), -})); - -export const initializeApp = jest.fn(); diff --git a/__tests__/app/about/page.test.jsx b/__tests__/app/about/page.test.jsx deleted file mode 100644 index cd1b72f..0000000 --- a/__tests__/app/about/page.test.jsx +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable no-undef */ -jest.mock("../../../lib/firebase/firebaseConfig"); - -import "@testing-library/jest-dom"; -import { render } from "@testing-library/react"; -import Page from "../../../app/about/page"; - -describe("About Us Page", () => { - it("renders wuthout crashing", () => { - render(); - }); -}); diff --git a/app/about/page.tsx b/app/about/page.tsx deleted file mode 100644 index fd8253e..0000000 --- a/app/about/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { PageLayout } from "@/components/layouts/pageLayout"; -import { title } from "@/components/primitives"; - -export default function AboutPage() { - return ( - -
-
-

About

-
-
-
- ); -} diff --git a/app/api/generate-thumbnail/route.ts b/app/api/generate-thumbnail/route.ts deleted file mode 100644 index 76f8290..0000000 --- a/app/api/generate-thumbnail/route.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { generateThumbnail, ThumbnailGenerationOptions } from "@/lib/ai"; - -// Enhanced error response helper -function createErrorResponse( - message: string, - status: number = 500, - type?: string -) { - return NextResponse.json( - { - error: message, - type: type || "api", - success: false, - timestamp: new Date().toISOString(), - }, - { status } - ); -} - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { - prompt, - style = "tech", - model = "sdxl", - quality = "balanced", - provider = "huggingface", - userId, - refinementPrompt, - } = body; - - // Enhanced input validation - if (!prompt || typeof prompt !== "string" || prompt.trim().length === 0) { - return createErrorResponse( - "Please enter a video description", - 400, - "validation" - ); - } - - if (prompt.length > 500) { - return createErrorResponse( - "Description must be less than 500 characters", - 400, - "validation" - ); - } - - if (prompt.trim().length < 5) { - return createErrorResponse( - "Description must be at least 5 characters long", - 400, - "validation" - ); - } - - // Validate parameters - const validProviders = ["huggingface", "stability"]; - const validQualities = ["fast", "balanced", "high"]; - const validStyles = ["tech", "gaming", "tutorial", "lifestyle"]; - - if (!validProviders.includes(provider)) { - return createErrorResponse( - "Invalid AI provider selected. Please choose a valid provider.", - 400, - "validation" - ); - } - - if (!validQualities.includes(quality)) { - return createErrorResponse( - "Invalid quality setting selected. Please choose a valid quality level.", - 400, - "validation" - ); - } - - if (!validStyles.includes(style)) { - return createErrorResponse( - "Invalid style selected. Please choose a valid style.", - 400, - "validation" - ); - } - - // Check for API key based on provider - let apiKeyMissing = false; - let apiKeyName = ""; - - switch (provider) { - case "huggingface": - apiKeyMissing = !process.env.HUGGINGFACE_API_KEY; - apiKeyName = "HUGGINGFACE_API_KEY"; - break; - case "stability": - apiKeyMissing = !process.env.STABILITY_API_KEY; - apiKeyName = "STABILITY_API_KEY"; - break; - } - - if (apiKeyMissing) { - return createErrorResponse( - `AI service is not configured (${apiKeyName} missing). Please contact support.`, - 500, - "api" - ); - } - - // Generate thumbnail with enhanced error handling - const options: ThumbnailGenerationOptions = { - prompt: prompt.trim(), - style, - model, - quality, - provider, - userId, - refinementPrompt: refinementPrompt?.trim(), - }; - - console.log("Generating thumbnail with options:", { - prompt: prompt.substring(0, 50) + "...", - style, - model, - quality, - provider, - userId: userId ? "***" : "none", - refinement: refinementPrompt ? "yes" : "no", - }); - - const result = await generateThumbnail(options); - - // Convert blob to base64 for response - const arrayBuffer = await result.imageBlob.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString("base64"); - const dataUrl = `data:image/png;base64,${base64}`; - - return NextResponse.json({ - success: true, - imageUrl: dataUrl, - prompt: result.prompt, - style: result.style, - model: result.model, - provider: result.provider, - parameters: result.parameters, - timestamp: new Date().toISOString(), - }); - } catch (error) { - console.error("API Error:", error); - - // Enhanced error categorization - if (error instanceof Error) { - const errorMessage = error.message.toLowerCase(); - - // Network/connection errors - if (errorMessage.includes("fetch") || errorMessage.includes("network")) { - return createErrorResponse( - "Unable to connect to AI service. Please check your internet connection.", - 503, - "network" - ); - } - - // Rate limiting errors - if ( - errorMessage.includes("rate") || - errorMessage.includes("limit") || - errorMessage.includes("quota") - ) { - return createErrorResponse( - "Too many requests. Please wait a moment before trying again.", - 429, - "quota" - ); - } - - // Model-specific errors - if (errorMessage.includes("model") || errorMessage.includes("loading")) { - return createErrorResponse( - "AI model is currently unavailable. Try switching to a different model or wait a moment.", - 503, - "model" - ); - } - - // Authentication errors - if ( - errorMessage.includes("unauthorized") || - errorMessage.includes("forbidden") - ) { - return createErrorResponse( - "AI service authentication failed. Please contact support.", - 401, - "api" - ); - } - - // Timeout errors - if ( - errorMessage.includes("timeout") || - errorMessage.includes("aborted") - ) { - return createErrorResponse( - "Request timed out. Please try again with a shorter description.", - 408, - "network" - ); - } - - // Return the actual error message for debugging - return createErrorResponse( - `Generation failed: ${error.message}`, - 500, - "api" - ); - } - - // Fallback for unknown errors - return createErrorResponse( - "An unexpected error occurred. Please try again.", - 500, - "unknown" - ); - } -} - -export async function GET() { - return NextResponse.json({ - message: "PixelAI Thumbnail Generation API", - version: "1.0.0", - status: "online", - supportedStyles: ["tech", "gaming", "tutorial", "lifestyle"], - supportedModels: ["sdxl", "flux", "realistic"], - supportedQualities: ["fast", "balanced", "high"], - limits: { - maxPromptLength: 500, - minPromptLength: 5, - }, - timestamp: new Date().toISOString(), - }); -} diff --git a/app/api/generate/route.test.ts b/app/api/generate/route.test.ts new file mode 100644 index 0000000..5b23be3 --- /dev/null +++ b/app/api/generate/route.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/auth/verify-request", () => ({ verifyRequest: vi.fn() })); +vi.mock("@/lib/quota/consume", () => ({ consumeQuota: vi.fn() })); +vi.mock("@/lib/storage/blob", () => ({ saveGeneratedImage: vi.fn(async () => ({ url: "https://blob.test/img.png" })) })); +vi.mock("@/lib/firebase/admin", () => ({ + adminDb: vi.fn(() => ({ collection: () => ({ doc: () => ({ id: "gen123", set: vi.fn(async () => undefined) }) }) })), +})); +vi.mock("ai", () => ({ + generateImage: vi.fn(async () => ({ + image: { uint8Array: new Uint8Array([1]), mediaType: "image/png" }, + })), +})); + +import { generateImage } from "ai"; +import { verifyRequest } from "@/lib/auth/verify-request"; +import { consumeQuota } from "@/lib/quota/consume"; +import { PROVIDERS } from "@/lib/ai/registry"; +import { POST } from "./route"; + +const validBody = { + provider: "replicate", + spec: { subject: "a red fox", stylePreset: "tech" }, +}; +const makeReq = (body: unknown) => + new Request("http://test/api/generate", { method: "POST", body: JSON.stringify(body) }); + +describe("POST /api/generate", () => { + beforeEach(() => { + vi.mocked(verifyRequest).mockResolvedValue({ uid: "u1" }); + vi.mocked(consumeQuota).mockResolvedValue({ ok: true, remaining: 9 }); + process.env.REPLICATE_API_TOKEN = "r8-test"; + }); + + it("returns 401 without valid auth", async () => { + vi.mocked(verifyRequest).mockResolvedValue(null); + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(401); + expect((await res.json()).error.code).toBe("unauthenticated"); + }); + + it("returns 400 on invalid body", async () => { + const res = await POST(makeReq({ provider: "nope", spec: {} })); + expect(res.status).toBe(400); + }); + + it("returns 429 when quota is exhausted", async () => { + vi.mocked(consumeQuota).mockResolvedValue({ ok: false, resetAt: new Date() }); + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(429); + expect((await res.json()).error.code).toBe("quota_exceeded"); + }); + + it("generates, persists, and returns the blob url", async () => { + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.url).toBe("https://blob.test/img.png"); + expect(json.provider).toBe("replicate"); + expect(vi.mocked(generateImage).mock.calls[0][0]).toMatchObject({ aspectRatio: "16:9" }); + }); + + it("maps generation failures through classifyError", async () => { + vi.mocked(generateImage).mockRejectedValueOnce(new DOMException("timeout", "TimeoutError")); + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(504); + }); + + it("returns 502 with a generic provider_error when consumeQuota throws", async () => { + vi.mocked(consumeQuota).mockRejectedValue(new Error("firestore transaction contention on users/u1")); + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(502); + const json = await res.json(); + expect(json.error.code).toBe("provider_error"); + expect(json.error.message).not.toContain("firestore"); + expect(json.error.message).not.toContain("contention"); + }); + + it("mode=performance selects the provider's performance default model", async () => { + const spy = vi.spyOn(PROVIDERS.replicate, "createModel"); + const res = await POST(makeReq({ ...validBody, mode: "performance" })); + expect(res.status).toBe(200); + expect(spy).toHaveBeenCalledWith(PROVIDERS.replicate.defaultModel.performance); + spy.mockRestore(); + }); + + it("defaults to the quality model when mode is omitted", async () => { + const spy = vi.spyOn(PROVIDERS.replicate, "createModel"); + const res = await POST(makeReq(validBody)); + expect(res.status).toBe(200); + expect(spy).toHaveBeenCalledWith(PROVIDERS.replicate.defaultModel.quality); + spy.mockRestore(); + }); +}); diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts new file mode 100644 index 0000000..b84b3d6 --- /dev/null +++ b/app/api/generate/route.ts @@ -0,0 +1,106 @@ +import { generateImage } from "ai"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getProvider, isModelAllowed } from "@/lib/ai/registry"; +import { buildPrompt, STYLE_PRESETS, EMOTION_PRESETS } from "@/lib/ai/prompt-builder"; +import { classifyError } from "@/lib/api/errors"; +import { verifyRequest } from "@/lib/auth/verify-request"; +import { adminDb } from "@/lib/firebase/admin"; +import { buildGenerationDoc } from "@/lib/ai/generation-doc"; +import { consumeQuota } from "@/lib/quota/consume"; +import { saveGeneratedImage } from "@/lib/storage/blob"; + +export const maxDuration = 60; + +const TIMEOUT_MS = 55_000; + +const bodySchema = z.object({ + provider: z.enum(["openai", "replicate", "fal", "stability", "huggingface"]), + modelId: z.string().min(1).max(200).optional(), + mode: z.enum(["performance", "quality"]).optional(), + spec: z.object({ + title: z.string().max(200).optional(), + subject: z.string().min(3).max(500), + stylePreset: z.enum(Object.keys(STYLE_PRESETS) as [string, ...string[]]), + emotionPreset: z.enum(Object.keys(EMOTION_PRESETS) as [string, ...string[]]).optional(), + overlayText: z.string().max(80).optional(), + }), +}); + +function err(code: string, message: string, status: number) { + return NextResponse.json({ error: { code, message } }, { status }); +} + +export async function POST(req: Request) { + const requestId = crypto.randomUUID().slice(0, 8); + + const authed = await verifyRequest(req); + if (!authed) return err("unauthenticated", "Sign in to generate images.", 401); + + let parsed; + try { + parsed = bodySchema.safeParse(await req.json()); + } catch { + return err("invalid_input", "Request body must be JSON.", 400); + } + if (!parsed.success) return err("invalid_input", parsed.error.issues[0]?.message ?? "Invalid input.", 400); + const { provider: providerKey, spec, mode } = parsed.data; + + const provider = getProvider(providerKey); + if (!process.env[provider.envKey]) return err("invalid_input", `${provider.displayName} is not configured.`, 400); + const modelId = parsed.data.modelId ?? provider.defaultModel[mode ?? "quality"]; + if (!isModelAllowed(providerKey, modelId)) return err("invalid_input", "Unknown model for this provider.", 400); + + try { + // Quota is consumed only after auth + validation succeed, and before we spend + // provider time/cost on generation. A thrown error here (or from buildPrompt) + // falls through to the catch below and is reported via the same error contract. + const quota = await consumeQuota(authed.uid, 1); + if (!quota.ok) { + return err("quota_exceeded", `Daily limit reached. Resets at ${quota.resetAt.toISOString()}.`, 429); + } + + const { prompt, negativePrompt } = buildPrompt(spec); + const dims = provider.dimensionFormat === "size" ? { size: "1792x1024" as const } : { aspectRatio: "16:9" as const }; + const started = Date.now(); + + const { image } = await generateImage({ + model: provider.createModel(modelId), + prompt, + ...dims, + ...(providerKey !== "openai" ? { seed: Math.floor(Math.random() * 1_000_000) } : {}), + providerOptions: { + stability: { negativePrompt }, + huggingface: { negativePrompt }, + }, + abortSignal: AbortSignal.timeout(TIMEOUT_MS), + }); + const timingMs = Date.now() - started; + + const genRef = adminDb().collection("generations").doc(); + const { url } = await saveGeneratedImage({ + uid: authed.uid, + generationId: genRef.id, + bytes: image.uint8Array, + contentType: image.mediaType ?? "image/png", + }); + await genRef.set( + buildGenerationDoc({ + uid: authed.uid, + prompt, + spec, + provider: providerKey, + modelId, + dimensions: dims, + blobUrl: url, + timingMs, + }), + ); + + return NextResponse.json({ generationId: genRef.id, url, provider: providerKey, modelId, timingMs }); + } catch (e) { + const mapped = classifyError(e); + console.error(`[generate:${requestId}] ${providerKey}/${modelId} failed:`, e); + return err(mapped.code, mapped.message, mapped.status); + } +} diff --git a/app/api/generations/[id]/route.test.ts b/app/api/generations/[id]/route.test.ts new file mode 100644 index 0000000..2276b86 --- /dev/null +++ b/app/api/generations/[id]/route.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface MockDocSnapshot { + exists: boolean; + get: (key: string) => string | undefined; +} + +let mockGetFn: ReturnType; +let mockDeleteFn: ReturnType; + +vi.mock("@/lib/auth/verify-request", () => ({ verifyRequest: vi.fn() })); +vi.mock("@/lib/storage/blob", () => ({ deleteGeneratedImage: vi.fn() })); +vi.mock("@/lib/firebase/admin", () => ({ + adminDb: vi.fn(() => ({ + collection: () => ({ + doc: () => ({ + get: mockGetFn, + delete: mockDeleteFn, + }), + }), + })), +})); + +import { verifyRequest } from "@/lib/auth/verify-request"; +import { deleteGeneratedImage } from "@/lib/storage/blob"; +import { DELETE } from "./route"; + +const makeReq = () => new Request("http://test/api/generations/gen123", { method: "DELETE" }); + +describe("DELETE /api/generations/[id]", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(verifyRequest).mockResolvedValue({ uid: "u1" }); + vi.mocked(deleteGeneratedImage).mockResolvedValue(undefined); + + mockGetFn = vi.fn(async (): Promise => ({ + exists: true, + get: (key: string) => { + const data: Record = { uid: "u1", blobUrl: "https://blob.test/img.png" }; + return data[key]; + }, + })); + mockDeleteFn = vi.fn(async () => undefined); + }); + + it("returns 401 without valid auth", async () => { + vi.mocked(verifyRequest).mockResolvedValue(null); + const res = await DELETE(makeReq(), { params: Promise.resolve({ id: "gen123" }) }); + expect(res.status).toBe(401); + expect((await res.json()).error.code).toBe("unauthenticated"); + }); + + it("returns 404 when doc does not exist", async () => { + mockGetFn = vi.fn(async (): Promise => ({ exists: false, get: () => undefined })); + const res = await DELETE(makeReq(), { params: Promise.resolve({ id: "gen123" }) }); + expect(res.status).toBe(404); + expect((await res.json()).error.code).toBe("invalid_input"); + }); + + it("returns 404 when uid does not match (no existence leak)", async () => { + mockGetFn = vi.fn(async (): Promise => ({ + exists: true, + get: (key: string) => { + const data: Record = { uid: "u2", blobUrl: "https://blob.test/img.png" }; + return data[key]; + }, + })); + const res = await DELETE(makeReq(), { params: Promise.resolve({ id: "gen123" }) }); + expect(res.status).toBe(404); + expect((await res.json()).error.code).toBe("invalid_input"); + // Verify delete was not called + expect(mockDeleteFn).not.toHaveBeenCalled(); + expect(vi.mocked(deleteGeneratedImage)).not.toHaveBeenCalled(); + }); + + it("returns identical 404 response for missing doc and uid mismatch", async () => { + // Get 404 response when doc does not exist + mockGetFn = vi.fn(async (): Promise => ({ exists: false, get: () => undefined })); + const resMissing = await DELETE(makeReq(), { params: Promise.resolve({ id: "gen123" }) }); + const bodyMissing = await resMissing.json(); + + // Reset and get 404 response when uid does not match + vi.clearAllMocks(); + vi.mocked(verifyRequest).mockResolvedValue({ uid: "u1" }); + mockGetFn = vi.fn(async (): Promise => ({ + exists: true, + get: (key: string) => { + const data: Record = { uid: "u2", blobUrl: "https://blob.test/img.png" }; + return data[key]; + }, + })); + const resMismatch = await DELETE(makeReq(), { params: Promise.resolve({ id: "gen123" }) }); + const bodyMismatch = await resMismatch.json(); + + // Assert responses are deeply equal (anti-leak property) + expect(bodyMissing).toEqual(bodyMismatch); + }); + + it("deletes blob then doc and returns 200 on happy path", async () => { + const res = await DELETE(makeReq(), { params: Promise.resolve({ id: "gen123" }) }); + expect(res.status).toBe(200); + expect((await res.json()).ok).toBe(true); + + // Verify blob delete was called before doc delete + expect(vi.mocked(deleteGeneratedImage)).toHaveBeenCalledWith("https://blob.test/img.png"); + expect(mockDeleteFn).toHaveBeenCalled(); + + const blobCallOrder = vi.mocked(deleteGeneratedImage).mock.invocationCallOrder[0]; + const docCallOrder = mockDeleteFn.mock.invocationCallOrder[0]; + expect(blobCallOrder < docCallOrder).toBe(true); + }); + + it("skips blob delete when blobUrl is absent but still deletes doc", async () => { + mockGetFn = vi.fn(async (): Promise => ({ + exists: true, + get: (key: string) => { + const data: Record = { uid: "u1" }; + return data[key]; + }, + })); + const res = await DELETE(makeReq(), { params: Promise.resolve({ id: "gen123" }) }); + expect(res.status).toBe(200); + expect((await res.json()).ok).toBe(true); + expect(vi.mocked(deleteGeneratedImage)).not.toHaveBeenCalled(); + expect(mockDeleteFn).toHaveBeenCalled(); + }); + + it("returns 502 with provider_error when deleteGeneratedImage rejects", async () => { + vi.mocked(deleteGeneratedImage).mockRejectedValueOnce(new Error("Blob deletion failed")); + const res = await DELETE(makeReq(), { params: Promise.resolve({ id: "gen123" }) }); + expect(res.status).toBe(502); + const body = await res.json(); + expect(body.error.code).toBe("provider_error"); + expect(body.error.message).toBe("Delete failed. Please try again."); + expect(body.error.message).not.toContain("Blob deletion failed"); + // Verify doc.delete was NOT called due to error + expect(mockDeleteFn).not.toHaveBeenCalled(); + }); +}); diff --git a/app/api/generations/[id]/route.ts b/app/api/generations/[id]/route.ts new file mode 100644 index 0000000..4da3404 --- /dev/null +++ b/app/api/generations/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { verifyRequest } from "@/lib/auth/verify-request"; +import { adminDb } from "@/lib/firebase/admin"; +import { deleteGeneratedImage } from "@/lib/storage/blob"; + +export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const authed = await verifyRequest(_req); + if (!authed) return NextResponse.json({ error: { code: "unauthenticated", message: "Sign in." } }, { status: 401 }); + + try { + const { id } = await params; + const ref = adminDb().collection("generations").doc(id); + const snap = await ref.get(); + if (!snap.exists || snap.get("uid") !== authed.uid) + return NextResponse.json({ error: { code: "invalid_input", message: "Not found." } }, { status: 404 }); + + const blobUrl = snap.get("blobUrl") as string | undefined; + if (blobUrl) await deleteGeneratedImage(blobUrl); + await ref.delete(); + return NextResponse.json({ ok: true }); + } catch (e) { + const requestId = Math.random().toString(36).slice(2, 9); + console.error(`[delete:${requestId}]`, e); + return NextResponse.json( + { error: { code: "provider_error", message: "Delete failed. Please try again." } }, + { status: 502 } + ); + } +} diff --git a/app/api/infer-from-title/route.test.ts b/app/api/infer-from-title/route.test.ts new file mode 100644 index 0000000..38120f8 --- /dev/null +++ b/app/api/infer-from-title/route.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi, afterEach } from "vitest"; + +vi.mock("@/lib/auth/verify-request", () => ({ verifyRequest: vi.fn() })); +vi.mock("ai", () => ({ + generateObject: vi.fn(), +})); +vi.mock("@ai-sdk/openai", () => ({ + openai: vi.fn(() => ({ id: "gpt-4o-mini" })), +})); + +import { verifyRequest } from "@/lib/auth/verify-request"; +import { generateObject } from "ai"; +import { POST } from "./route"; + +const makeReq = (body: unknown) => + new Request("http://test/api/infer-from-title", { method: "POST", body: JSON.stringify(body) }); + +describe("POST /api/infer-from-title", () => { + beforeEach(() => { + vi.mocked(verifyRequest).mockResolvedValue({ uid: "u1" }); + vi.stubEnv("FEATURE_YT_IMPORT", "1"); + vi.stubEnv("OPENAI_API_KEY", "sk-test"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns 401 without valid auth", async () => { + vi.mocked(verifyRequest).mockResolvedValue(null); + const res = await POST(makeReq({ title: "Test Video Title" })); + expect(res.status).toBe(401); + const json = await res.json(); + expect(json.error.code).toBe("unauthenticated"); + }); + + it("returns 404 when feature flag is off", async () => { + vi.stubEnv("FEATURE_YT_IMPORT", ""); + const res = await POST(makeReq({ title: "Test Video Title" })); + expect(res.status).toBe(404); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + expect(json.error.message).toBe("Not found."); + }); + + it("returns 400 for title that is too short", async () => { + const res = await POST(makeReq({ title: "ab" })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + }); + + it("returns 400 for title that is too long", async () => { + const longTitle = "a".repeat(201); + const res = await POST(makeReq({ title: longTitle })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + }); + + it("returns 400 for missing title", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + }); + + it("returns 400 for invalid input even when OPENAI_API_KEY is not set", async () => { + vi.stubEnv("OPENAI_API_KEY", ""); + const res = await POST(makeReq({ title: "ab" })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + }); + + it("returns 502 when OPENAI_API_KEY is not set and input is valid", async () => { + vi.stubEnv("OPENAI_API_KEY", ""); + const res = await POST(makeReq({ title: "Test Video Title" })); + expect(res.status).toBe(502); + const json = await res.json(); + expect(json.error.code).toBe("provider_error"); + expect(json.error.message).toContain("not configured"); + }); + + it("returns 200 with inferred spec for valid title", async () => { + vi.mocked(generateObject).mockResolvedValueOnce({ + object: { + subject: "person with shocked expression", + stylePreset: "MrBeast/high-energy", + emotionPreset: "shocked", + overlayText: "UNBELIEVABLE", + }, + } as unknown as Awaited>); + + const res = await POST(makeReq({ title: "I Survived 100 Days in the Wild" })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.subject).toBe("person with shocked expression"); + expect(json.stylePreset).toBe("MrBeast/high-energy"); + expect(json.emotionPreset).toBe("shocked"); + expect(json.overlayText).toBe("UNBELIEVABLE"); + }); + + it("returns 200 without emotionPreset when not provided by model", async () => { + vi.mocked(generateObject).mockResolvedValueOnce({ + object: { + subject: "person with surprised expression", + stylePreset: "tutorial/clean", + }, + } as unknown as Awaited>); + + const res = await POST(makeReq({ title: "How to Build a Website" })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.subject).toBe("person with surprised expression"); + expect(json.stylePreset).toBe("tutorial/clean"); + expect(json.emotionPreset).toBeUndefined(); + }); + + it("returns 200 without overlayText when not provided by model", async () => { + vi.mocked(generateObject).mockResolvedValueOnce({ + object: { + subject: "coding interface", + stylePreset: "tech-explainer", + }, + } as unknown as Awaited>); + + const res = await POST(makeReq({ title: "Advanced TypeScript" })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.subject).toBe("coding interface"); + expect(json.stylePreset).toBe("tech-explainer"); + expect(json.overlayText).toBeUndefined(); + }); + + it("returns 502 when generateObject throws", async () => { + vi.mocked(generateObject).mockRejectedValueOnce(new Error("API error")); + + const res = await POST(makeReq({ title: "Test Video Title" })); + expect(res.status).toBe(502); + const json = await res.json(); + expect(json.error.code).toBe("provider_error"); + }); +}); diff --git a/app/api/infer-from-title/route.ts b/app/api/infer-from-title/route.ts new file mode 100644 index 0000000..86a1bc3 --- /dev/null +++ b/app/api/infer-from-title/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server"; +import { generateObject } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { z } from "zod"; +import { verifyRequest } from "@/lib/auth/verify-request"; +import { EMOTION_PRESETS, STYLE_PRESETS } from "@/lib/ai/prompt-builder"; + +const bodySchema = z.object({ + title: z.string().min(3).max(200), +}); + +const inferenceSchema = z.object({ + subject: z.string().min(1), + stylePreset: z.enum(Object.keys(STYLE_PRESETS) as [string, ...string[]]), + emotionPreset: z.enum(Object.keys(EMOTION_PRESETS) as [string, ...string[]]).optional(), + overlayText: z.string().max(80).optional(), +}); + +function err(code: string, message: string, status: number) { + return NextResponse.json({ error: { code, message } }, { status }); +} + +export async function POST(req: Request) { + const authed = await verifyRequest(req); + if (!authed) return err("unauthenticated", "Sign in to infer from title.", 401); + + if (process.env.FEATURE_YT_IMPORT !== "1") { + return err("invalid_input", "Not found.", 404); + } + + let parsed; + try { + parsed = bodySchema.safeParse(await req.json()); + } catch { + return err("invalid_input", "Request body must be JSON.", 400); + } + if (!parsed.success) return err("invalid_input", parsed.error.issues[0]?.message ?? "Invalid input.", 400); + + if (!process.env.OPENAI_API_KEY) { + return err("provider_error", "Title inference is not configured.", 502); + } + + const { title } = parsed.data; + + try { + const { object } = await generateObject({ + model: openai("gpt-4o-mini"), + schema: inferenceSchema, + system: `You are an expert YouTube thumbnail strategist. Given a video title, you identify the best visual elements to make a high-CTR thumbnail. + +Guidelines: +- "subject" should describe a concrete, photographable scene or subject (avoid abstract descriptions) +- Choose the style preset that best matches the video's niche and energy +- Choose the emotion that would make a viewer most likely to click +- "overlayText" should be a punchy 1-6 word hook — not just a repeat of the title`, + prompt: `Video title: "${title}" + +Infer the best thumbnail subject, style preset, emotion preset, and overlay text for this YouTube video.`, + temperature: 0.4, + abortSignal: AbortSignal.timeout(10_000), + }); + + return NextResponse.json({ + subject: object.subject, + stylePreset: object.stylePreset, + emotionPreset: object.emotionPreset, + overlayText: object.overlayText, + }); + } catch (e) { + console.error("[infer-from-title] Error:", e); + return err("provider_error", "Failed to infer from title. Please try again.", 502); + } +} diff --git a/app/api/providers/route.ts b/app/api/providers/route.ts new file mode 100644 index 0000000..9727753 --- /dev/null +++ b/app/api/providers/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { enabledProviders } from "@/lib/ai/registry"; + +export function GET() { + return NextResponse.json({ + providers: enabledProviders(), + featureYtImport: process.env.FEATURE_YT_IMPORT === "1", + }); +} diff --git a/app/api/test-ai/route.ts b/app/api/test-ai/route.ts deleted file mode 100644 index 8b486ef..0000000 --- a/app/api/test-ai/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextResponse } from "next/server"; -import { testAllProviders, getProviderStatus } from "@/lib/ai"; - -export async function GET() { - try { - console.log("🧪 Testing AI Providers..."); - - // Test environment variables - const envStatus = { - HUGGINGFACE_API_KEY: process.env.HUGGINGFACE_API_KEY ? "Set" : "Not Set", - STABILITY_API_KEY: process.env.STABILITY_API_KEY ? "Set" : "Not Set", - }; - - console.log("Environment variables:", envStatus); - - // Test all providers - const providerResults = await testAllProviders(); - console.log("Provider test results:", providerResults); - - // Get detailed provider status - const providerStatus = await getProviderStatus(); - console.log("Provider status:", providerStatus); - - return NextResponse.json({ - success: true, - environmentVariables: envStatus, - providerResults, - providerStatus, - timestamp: new Date().toISOString(), - }); - } catch (error) { - console.error("❌ Test failed:", error); - - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : "Unknown error", - timestamp: new Date().toISOString(), - }, - { status: 500 } - ); - } -} diff --git a/app/api/youtube-metadata/route.test.ts b/app/api/youtube-metadata/route.test.ts new file mode 100644 index 0000000..3db74e1 --- /dev/null +++ b/app/api/youtube-metadata/route.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/auth/verify-request", () => ({ verifyRequest: vi.fn() })); + +import { verifyRequest } from "@/lib/auth/verify-request"; +import { POST } from "./route"; + +const makeReq = (body: unknown) => + new Request("http://test/api/youtube-metadata", { method: "POST", body: JSON.stringify(body) }); + +describe("POST /api/youtube-metadata", () => { + beforeEach(() => { + vi.mocked(verifyRequest).mockResolvedValue({ uid: "u1" }); + vi.stubEnv("FEATURE_YT_IMPORT", "1"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns 401 without valid auth", async () => { + vi.mocked(verifyRequest).mockResolvedValue(null); + const res = await POST(makeReq({ url: "https://youtube.com/watch?v=abc123def45" })); + expect(res.status).toBe(401); + const json = await res.json(); + expect(json.error.code).toBe("unauthenticated"); + }); + + it("returns 404 when feature flag is off", async () => { + vi.stubEnv("FEATURE_YT_IMPORT", ""); + const res = await POST(makeReq({ url: "https://youtube.com/watch?v=abc123def45" })); + expect(res.status).toBe(404); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + expect(json.error.message).toBe("Not found."); + }); + + it("returns 400 for invalid URL", async () => { + const res = await POST(makeReq({ url: "not-a-youtube-url" })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + }); + + it("returns 400 for URL that is too long", async () => { + const longUrl = "https://youtube.com/watch?v=" + "a".repeat(501); + const res = await POST(makeReq({ url: longUrl })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + }); + + it("returns 400 for missing URL", async () => { + const res = await POST(makeReq({})); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + }); + + it("returns 200 with metadata for valid YouTube URL", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.includes("oembed")) { + return new Response( + JSON.stringify({ + title: "Test Video", + author_name: "Test Channel", + thumbnail_url: "https://i.ytimg.com/vi/abc123def45/hqdefault.jpg", + }), + { status: 200 } + ); + } + return new Response("Not found", { status: 404 }); + }) + ); + + const res = await POST(makeReq({ url: "https://youtube.com/watch?v=abc123def45" })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.title).toBe("Test Video"); + expect(json.author).toBe("Test Channel"); + expect(json.thumbnailUrl).toMatch(/i\.ytimg\.com/); + }); + + it("returns 400 when oEmbed returns 404", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("Not found", { status: 404 })) + ); + + const res = await POST(makeReq({ url: "https://youtube.com/watch?v=abc123def45" })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + expect(json.error.message).toBe("Video not found."); + }); + + it("returns 502 when fetch throws", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("Network error"); + }) + ); + + const res = await POST(makeReq({ url: "https://youtube.com/watch?v=abc123def45" })); + expect(res.status).toBe(502); + const json = await res.json(); + expect(json.error.code).toBe("provider_error"); + }); + + it("accepts youtu.be shortlink format", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.includes("oembed")) { + return new Response( + JSON.stringify({ + title: "Short Link Test", + author_name: "Test Channel", + thumbnail_url: "https://i.ytimg.com/vi/abc123def45/hqdefault.jpg", + }), + { status: 200 } + ); + } + return new Response("Not found", { status: 404 }); + }) + ); + + const res = await POST(makeReq({ url: "https://youtu.be/abc123def45" })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.title).toBe("Short Link Test"); + }); + + it("rejects evilyoutube.com subdomain bypass", async () => { + const res = await POST(makeReq({ url: "https://evilyoutube.com/watch?v=abc123def45" })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + expect(json.error.message).toBe("Invalid YouTube URL."); + }); + + it("rejects redirect bypass attempts", async () => { + const res = await POST(makeReq({ url: "https://evil.com/redirect?u=youtube.com/watch?v=abc123def45" })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + }); + + it("rejects youtube.com.evil.com superdomain bypass", async () => { + const res = await POST(makeReq({ url: "https://youtube.com.evil.com/watch?v=abc123def45" })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + }); + + it("accepts m.youtube.com mobile format", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.includes("oembed")) { + return new Response( + JSON.stringify({ + title: "Mobile Test", + author_name: "Test Channel", + thumbnail_url: "https://i.ytimg.com/vi/abc123def45/hqdefault.jpg", + }), + { status: 200 } + ); + } + return new Response("Not found", { status: 404 }); + }) + ); + + const res = await POST(makeReq({ url: "https://m.youtube.com/watch?v=abc123def45" })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.title).toBe("Mobile Test"); + }); + + it("returns 400 when oEmbed returns 500", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("Server error", { status: 500 })) + ); + + const res = await POST(makeReq({ url: "https://youtube.com/watch?v=abc123def45" })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error.code).toBe("invalid_input"); + expect(json.error.message).toBe("Video not found."); + }); +}); diff --git a/app/api/youtube-metadata/route.ts b/app/api/youtube-metadata/route.ts new file mode 100644 index 0000000..39e7926 --- /dev/null +++ b/app/api/youtube-metadata/route.ts @@ -0,0 +1,139 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { verifyRequest } from "@/lib/auth/verify-request"; + +const TIMEOUT_MS = 10_000; +const ALLOWED_HOSTS = ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be"]; +const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/; + +const bodySchema = z.object({ + url: z.string().min(1).max(500), +}); + +function extractVideoId(url: string): string | null { + const trimmed = url.trim(); + + // Parse URL with hostname validation + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + + // Validate hostname against allowlist + const hostname = parsed.hostname || ""; + if (!ALLOWED_HOSTS.includes(hostname)) { + return null; + } + + let videoId: string | null = null; + + // Extract video ID based on hostname and path/search + if (hostname === "youtu.be") { + // youtu.be/ + videoId = parsed.pathname.slice(1); + } else { + // youtube.com variants + const pathname = parsed.pathname; + + if (pathname.startsWith("/watch")) { + // /watch?v= + videoId = parsed.searchParams.get("v"); + } else if (pathname.startsWith("/shorts/")) { + // /shorts/ + videoId = pathname.slice(8); // Remove "/shorts/" + } else if (pathname.startsWith("/embed/")) { + // /embed/ + videoId = pathname.slice(7); // Remove "/embed/" + } + } + + // Validate video ID format + if (videoId && VIDEO_ID_PATTERN.test(videoId)) { + return videoId; + } + + return null; +} + +function getThumbnailUrl(videoId: string, quality: "maxresdefault" | "hqdefault" = "maxresdefault"): string { + return `https://i.ytimg.com/vi/${videoId}/${quality}.jpg`; +} + +function err(code: string, message: string, status: number) { + return NextResponse.json({ error: { code, message } }, { status }); +} + +export async function POST(req: Request) { + const authed = await verifyRequest(req); + if (!authed) return err("unauthenticated", "Sign in to fetch YouTube metadata.", 401); + + if (process.env.FEATURE_YT_IMPORT !== "1") { + return err("invalid_input", "Not found.", 404); + } + + let parsed; + try { + parsed = bodySchema.safeParse(await req.json()); + } catch { + return err("invalid_input", "Request body must be JSON.", 400); + } + if (!parsed.success) return err("invalid_input", parsed.error.issues[0]?.message ?? "Invalid URL.", 400); + + const { url } = parsed.data; + + try { + const videoId = extractVideoId(url); + if (!videoId) { + return err("invalid_input", "Invalid YouTube URL.", 400); + } + + const canonicalUrl = `https://www.youtube.com/watch?v=${videoId}`; + const oEmbedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(canonicalUrl)}&format=json`; + + let response: Response; + try { + response = await fetch(oEmbedUrl, { + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + if (/timeout/i.test(message) || /abort/i.test(message)) { + return err("provider_error", "YouTube took too long to respond.", 502); + } + return err("provider_error", "Could not reach YouTube.", 502); + } + + if (!response.ok) { + return err("invalid_input", "Video not found.", 400); + } + + let payload: { + title: string; + author_name?: string; + thumbnail_url?: string; + }; + + try { + payload = await response.json(); + } catch { + return err("provider_error", "Could not parse YouTube response.", 502); + } + + if (!payload.title) { + return err("provider_error", "YouTube returned metadata without a title.", 502); + } + + const thumbnailUrl = getThumbnailUrl(videoId, "maxresdefault"); + + return NextResponse.json({ + title: payload.title, + author: payload.author_name ?? "", + thumbnailUrl, + }); + } catch (e) { + console.error("[youtube-metadata] Error:", e); + return err("provider_error", "Failed to fetch YouTube metadata.", 502); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 4cf88fc..d40dfa6 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,664 +1,15 @@ -"use client"; -import { useState, useEffect, Suspense } from "react"; -import { - Button, - Card, - CardBody, - CardHeader, - Image, - Chip, - Select, - SelectItem, - Progress, - Spinner, - RadioGroup, - Radio, - Textarea, -} from "@nextui-org/react"; -import { PageLayout } from "@/components/layouts/pageLayout"; -import { useUser } from "@/contexts/userContext"; -import { useMessage } from "@/contexts/messageContext"; -import { title, subtitle, button } from "@/components/primitives"; -import { useSearchParams } from "next/navigation"; -import { AnimatedDiv } from "@/components/motion"; -import { Download, Sparkles, Zap, Shield } from "lucide-react"; +import { Suspense } from "react"; +import { AuthGate } from "@/components/auth/auth-gate"; +import { Generator } from "@/components/generator/generator"; -type ThumbnailStyle = "tech" | "gaming" | "tutorial" | "lifestyle"; - -interface GenerationResult { - success: boolean; - imageUrl: string; - prompt: string; - style: string; - model: string; - provider: string; - parameters: { - steps: number; - guidance_scale: number; - width: number; - height: number; - }; -} - -interface ErrorInfo { - type: "validation" | "api" | "network" | "quota" | "model"; - message: string; - retryable: boolean; -} - -const authDisabled = process.env.NODE_ENV === "development"; - -// Simplified style options with visual indicators -const styleOptions = [ - { - key: "tech", - label: "Tech & Reviews", - icon: "💻", - description: "Modern, clean tech product presentations", - color: "primary" as const, - }, - { - key: "gaming", - label: "Gaming", - icon: "🎮", - description: "Vibrant gaming content with energy", - color: "secondary" as const, - }, - { - key: "tutorial", - label: "Tutorial", - icon: "📚", - description: "Educational and instructional content", - color: "success" as const, - }, - { - key: "lifestyle", - label: "Lifestyle", - icon: "✨", - description: "Personal and lifestyle content", - color: "warning" as const, - }, -]; - -// Simplified provider options -const providerOptions = [ - { - id: "stability", - name: "Stability AI", - description: "Best Quality • Free", - icon: , - badge: "Recommended", - badgeColor: "success" as const, - }, - { - id: "huggingface", - name: "HuggingFace", - description: "Good Quality • Free with limits", - icon: , - badge: "Backup", - badgeColor: "primary" as const, - }, -]; - -// Simplified quality options -const qualityOptions = [ - { value: "fast", label: "Fast", description: "Quick generation (~10s)" }, - { value: "balanced", label: "Balanced", description: "Good quality (~20s)" }, - { value: "high", label: "High", description: "Best quality (~30s)" }, -]; - -// Quick prompt suggestions -const promptSuggestions = [ - "iPhone 15 Pro Max review with surprised reaction", - "Gaming setup tour with RGB lighting", - "How to cook perfect pasta tutorial", - "Morning routine lifestyle content", - "Unboxing the latest tech gadget", - "Minecraft building tutorial castle", -]; - -function categorizeError(error: any): ErrorInfo { - const errorMessage = error?.message || error?.toString() || "Unknown error"; - const lowerMessage = errorMessage.toLowerCase(); - - if (lowerMessage.includes("enter a video description")) { - return { - type: "validation", - message: "Please enter a description for your content", - retryable: false, - }; - } - - if (lowerMessage.includes("rate") || lowerMessage.includes("quota")) { - return { - type: "quota", - message: "Too many requests. Please wait a moment and try again.", - retryable: true, - }; - } - - if (lowerMessage.includes("network") || lowerMessage.includes("fetch")) { - return { - type: "network", - message: "Network error. Please check your connection and try again.", - retryable: true, - }; - } - - if ( - lowerMessage.includes("api key") || - lowerMessage.includes("unauthorized") - ) { - return { - type: "api", - message: "AI service not configured. Please try a different provider.", - retryable: false, - }; - } - - return { - type: "api", - message: "Something went wrong. Please try again.", - retryable: true, - }; -} - -function DashboardContent() { - const { user, loading: userLoading } = useUser(); - const { message } = useMessage(); - const searchParams = useSearchParams(); - - // Simplified state management - const [prompt, setPrompt] = useState(""); - const [style, setStyle] = useState("tech"); - const [provider, setProvider] = useState("stability"); - const [quality, setQuality] = useState("balanced"); - const [loading, setLoading] = useState(false); - const [progress, setProgress] = useState(0); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - - // Handle search parameters - useEffect(() => { - const searchPrompt = searchParams.get("prompt"); - if (searchPrompt) { - setPrompt(searchPrompt); - } - }, [searchParams]); - - const handleGenerate = async () => { - if (!prompt.trim()) { - setError({ - type: "validation", - message: "Please enter a description for your content", - retryable: false, - }); - return; - } - - setLoading(true); - setError(null); - setResult(null); - setProgress(0); - - // Smooth progress animation - const progressInterval = setInterval(() => { - setProgress((prev) => Math.min(prev + 8, 90)); - }, 600); - - try { - const userId = authDisabled ? "demo-user" : user?.uid; - const response = await fetch("/api/generate-thumbnail", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - prompt, - style, - quality, - provider, - userId, - }), - }); - - clearInterval(progressInterval); - setProgress(100); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - setResult(data); - message("🎉 Thumbnail generated successfully!", "success"); - } else { - const errorInfo = categorizeError(data.error); - setError(errorInfo); - } - } catch (err) { - clearInterval(progressInterval); - const errorInfo = categorizeError(err); - setError(errorInfo); - console.error("Generation error:", err); - } finally { - setLoading(false); - setTimeout(() => setProgress(0), 2000); - } - }; - - const handleSuggestionClick = (suggestion: string) => { - setPrompt(suggestion); - setError(null); - }; - - const handleDownload = () => { - if (!result) return; - const link = document.createElement("a"); - link.href = result.imageUrl; - link.download = `thumbnail-${Date.now()}.png`; - link.click(); - }; - - if (userLoading) { - return ( - -
- -
-
- ); - } - - return ( - -
- {/* Header */} - -

Create Perfect 

-

Thumbnails

-

- Generate eye-catching thumbnails in seconds with AI's power -

-
- -
- {/* Generation Form */} -
- {/* Step 1: Describe Your Content */} - - - -
-
- 1 -
-
-

- Describe Your Content -

-

- What's your video about? -

-
-
-
- -