diff --git a/app/api/enhance-prompt/route.test.ts b/app/api/enhance-prompt/route.test.ts new file mode 100644 index 0000000..83be0fb --- /dev/null +++ b/app/api/enhance-prompt/route.test.ts @@ -0,0 +1,255 @@ +/** + * Tests for the Enhance Prompt API Route + * + * Tests authentication, rate limiting, and prompt enhancement functionality. + */ +import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest" +import { NextRequest } from "next/server" +import { POST } from "./route" + +// Mock Clerk auth +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn(), +})) + +// Mock Convex fetchMutation +vi.mock("convex/nextjs", () => ({ + fetchMutation: vi.fn(), +})) + +// Mock the Convex API import +vi.mock("@/convex/_generated/api", () => ({ + api: { + rateLimits: { + checkRateLimit: "rateLimits:checkRateLimit", + }, + }, +})) + +// Mock prompt enhancement +vi.mock("@/lib/prompt-enhancement", () => ({ + enhancePrompt: vi.fn(), + enhanceNegativePrompt: vi.fn(), + PromptEnhancementError: class PromptEnhancementError extends Error { + code: string + status?: number + constructor(message: string, code: string, status?: number) { + super(message) + this.code = code + this.status = status + } + }, +})) + +import { auth } from "@clerk/nextjs/server" +import { fetchMutation } from "convex/nextjs" +import { enhancePrompt, enhanceNegativePrompt, PromptEnhancementError } from "@/lib/prompt-enhancement" + +function createMockRequest(body: Record): NextRequest { + const request = new NextRequest("http://localhost:3000/api/enhance-prompt", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }) + return request +} + +describe("/api/enhance-prompt", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe("Authentication", () => { + it("should return 401 when user is not authenticated", async () => { + ; (auth as Mock).mockResolvedValue({ userId: null }) + + const request = createMockRequest({ prompt: "test", type: "prompt" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ + success: false, + error: { + code: "UNAUTHORIZED", + message: "Authentication required", + }, + }) + }) + + it("should proceed when user is authenticated", async () => { + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60000, + }) + ; (enhancePrompt as Mock).mockResolvedValue({ enhancedText: "enhanced prompt" }) + + const request = createMockRequest({ prompt: "test", type: "prompt" }) + const response = await POST(request) + + expect(response.status).toBe(200) + }) + }) + + describe("Rate Limiting", () => { + it("should return 429 when rate limit is exceeded", async () => { + const resetAt = Date.now() + 30000 + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt, + }) + + const request = createMockRequest({ prompt: "test", type: "prompt" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(429) + expect(data).toEqual({ + success: false, + error: { + code: "RATE_LIMIT_EXCEEDED", + message: "Too many requests. Please try again later.", + }, + }) + expect(response.headers.get("Retry-After")).toBeDefined() + expect(response.headers.get("X-RateLimit-Remaining")).toBe("0") + }) + + it("should include rate limit headers on successful response", async () => { + const resetAt = Date.now() + 60000 + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt, + }) + ; (enhancePrompt as Mock).mockResolvedValue({ enhancedText: "enhanced" }) + + const request = createMockRequest({ prompt: "test", type: "prompt" }) + const response = await POST(request) + + expect(response.headers.get("X-RateLimit-Remaining")).toBe("9") + expect(response.headers.get("X-RateLimit-Reset")).toBe(String(resetAt)) + }) + }) + + describe("Validation", () => { + beforeEach(() => { + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60000, + }) + }) + + it("should return 400 when prompt is missing", async () => { + const request = createMockRequest({ type: "prompt" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error.code).toBe("VALIDATION_ERROR") + expect(data.error.message).toBe("Prompt is required") + }) + + it("should return 400 when prompt is empty", async () => { + const request = createMockRequest({ prompt: " ", type: "prompt" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error.code).toBe("VALIDATION_ERROR") + }) + + it("should return 400 when type is invalid", async () => { + const request = createMockRequest({ prompt: "test", type: "invalid" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.error.code).toBe("VALIDATION_ERROR") + expect(data.error.message).toBe("Type must be 'prompt' or 'negative'") + }) + }) + + describe("Prompt Enhancement", () => { + beforeEach(() => { + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60000, + }) + }) + + it("should enhance positive prompt successfully", async () => { + ; (enhancePrompt as Mock).mockResolvedValue({ enhancedText: "enhanced positive prompt" }) + + const request = createMockRequest({ prompt: "a cat", type: "prompt" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ + success: true, + data: { enhancedText: "enhanced positive prompt" }, + }) + expect(enhancePrompt).toHaveBeenCalledWith("a cat", expect.any(Object)) + }) + + it("should enhance negative prompt successfully", async () => { + ; (enhanceNegativePrompt as Mock).mockResolvedValue({ enhancedText: "enhanced negative prompt" }) + + const request = createMockRequest({ prompt: "a cat", negativePrompt: "blurry", type: "negative" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data.enhancedText).toBe("enhanced negative prompt") + expect(enhanceNegativePrompt).toHaveBeenCalledWith("a cat", "blurry", expect.any(Object)) + }) + }) + + describe("Error Handling", () => { + beforeEach(() => { + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60000, + }) + }) + + it("should handle PromptEnhancementError with custom status", async () => { + const error = new (PromptEnhancementError as unknown as new (message: string, code: string, status?: number) => Error & { code: string; status?: number })("API Error", "API_ERROR", 503) + ; (enhancePrompt as Mock).mockRejectedValue(error) + + const request = createMockRequest({ prompt: "test", type: "prompt" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(503) + expect(data.error.code).toBe("API_ERROR") + }) + + it("should handle unknown errors with 500 status", async () => { + ; (enhancePrompt as Mock).mockRejectedValue(new Error("Unknown error")) + + const request = createMockRequest({ prompt: "test", type: "prompt" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(500) + expect(data.error.code).toBe("INTERNAL_ERROR") + }) + }) +}) diff --git a/app/api/enhance-prompt/route.ts b/app/api/enhance-prompt/route.ts index fd0c0b2..1c1c731 100644 --- a/app/api/enhance-prompt/route.ts +++ b/app/api/enhance-prompt/route.ts @@ -3,14 +3,21 @@ * * Server-side endpoint for prompt enhancement using OpenRouter. * Handles both prompt and negative prompt enhancement requests. + * + * Security: + * - Requires authentication (returns 401 if not authenticated) + * - Rate limited to 10 requests per minute per user (returns 429 if exceeded) */ +import { auth } from "@clerk/nextjs/server" +import { fetchMutation } from "convex/nextjs" import { - enhanceNegativePrompt, - enhancePrompt, - PromptEnhancementError, + enhanceNegativePrompt, + enhancePrompt, + PromptEnhancementError, } from "@/lib/prompt-enhancement" import { NextRequest, NextResponse } from "next/server" +import { api } from "@/convex/_generated/api" /** * Request body schema @@ -57,6 +64,48 @@ export async function POST( request: NextRequest ): Promise> { try { + // Authentication check + const { userId } = await auth() + if (!userId) { + return NextResponse.json( + { + success: false, + error: { + code: "UNAUTHORIZED", + message: "Authentication required", + }, + }, + { status: 401 } + ) + } + + // Rate limit check + const rateLimitResult = await fetchMutation(api.rateLimits.checkRateLimit, { + userId, + endpoint: "enhance-prompt", + }) + + if (!rateLimitResult.allowed) { + const retryAfter = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000) + return NextResponse.json( + { + success: false, + error: { + code: "RATE_LIMIT_EXCEEDED", + message: "Too many requests. Please try again later.", + }, + }, + { + status: 429, + headers: { + "Retry-After": String(retryAfter), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": String(rateLimitResult.resetAt), + }, + } + ) + } + const body = (await request.json()) as EnhancePromptRequest // Validate request @@ -92,12 +141,20 @@ export async function POST( ? await enhancePrompt(body.prompt, { abortSignal: request.signal }) : await enhanceNegativePrompt(body.prompt, body.negativePrompt, { abortSignal: request.signal }) - return NextResponse.json({ - success: true, - data: { - enhancedText: result.enhancedText, + return NextResponse.json( + { + success: true, + data: { + enhancedText: result.enhancedText, + }, }, - }) + { + headers: { + "X-RateLimit-Remaining": String(rateLimitResult.remaining), + "X-RateLimit-Reset": String(rateLimitResult.resetAt), + }, + } + ) } catch (error) { // Handle cancellation if (error instanceof Error && error.name === "AbortError") { @@ -141,3 +198,4 @@ export async function POST( ) } } + diff --git a/app/api/suggestions/route.test.ts b/app/api/suggestions/route.test.ts new file mode 100644 index 0000000..7bdf891 --- /dev/null +++ b/app/api/suggestions/route.test.ts @@ -0,0 +1,238 @@ +/** + * Tests for the Suggestions API Route + * + * Tests authentication, rate limiting, and suggestion generation functionality. + */ +import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest" +import { NextRequest } from "next/server" +import { POST } from "./route" + +// Mock Clerk auth +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn(), +})) + +// Mock Convex fetchMutation +vi.mock("convex/nextjs", () => ({ + fetchMutation: vi.fn(), +})) + +// Mock the Convex API import +vi.mock("@/convex/_generated/api", () => ({ + api: { + rateLimits: { + checkRateLimit: "rateLimits:checkRateLimit", + }, + }, +})) + +// Mock prompt enhancement (suggestions) +vi.mock("@/lib/prompt-enhancement", () => ({ + generateSuggestions: vi.fn(), +})) + +import { auth } from "@clerk/nextjs/server" +import { fetchMutation } from "convex/nextjs" +import { generateSuggestions } from "@/lib/prompt-enhancement" + +function createMockRequest(body: Record): NextRequest { + const request = new NextRequest("http://localhost:3000/api/suggestions", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }) + return request +} + +describe("/api/suggestions", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + describe("Authentication", () => { + it("should return 401 when user is not authenticated", async () => { + ; (auth as Mock).mockResolvedValue({ userId: null }) + + const request = createMockRequest({ prompt: "test" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ + success: false, + error: { + code: "UNAUTHORIZED", + message: "Authentication required", + }, + }) + }) + + it("should proceed when user is authenticated", async () => { + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: Date.now() + 60000, + }) + ; (generateSuggestions as Mock).mockResolvedValue({ suggestions: ["suggestion 1"] }) + + const request = createMockRequest({ prompt: "test" }) + const response = await POST(request) + + expect(response.status).toBe(200) + }) + }) + + describe("Rate Limiting", () => { + it("should return 429 when rate limit is exceeded", async () => { + const resetAt = Date.now() + 30000 + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt, + }) + + const request = createMockRequest({ prompt: "test" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(429) + expect(data).toEqual({ + success: false, + error: { + code: "RATE_LIMIT_EXCEEDED", + message: "Too many requests. Please try again later.", + }, + }) + expect(response.headers.get("Retry-After")).toBeDefined() + expect(response.headers.get("X-RateLimit-Remaining")).toBe("0") + }) + + it("should include rate limit headers on successful response", async () => { + const resetAt = Date.now() + 60000 + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt, + }) + ; (generateSuggestions as Mock).mockResolvedValue({ suggestions: [] }) + + const request = createMockRequest({ prompt: "" }) + const response = await POST(request) + + expect(response.headers.get("X-RateLimit-Remaining")).toBe("19") + expect(response.headers.get("X-RateLimit-Reset")).toBe(String(resetAt)) + }) + + it("should use suggestions endpoint for rate limiting", async () => { + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: Date.now() + 60000, + }) + ; (generateSuggestions as Mock).mockResolvedValue({ suggestions: [] }) + + const request = createMockRequest({ prompt: "test" }) + await POST(request) + + expect(fetchMutation).toHaveBeenCalledWith( + "rateLimits:checkRateLimit", + { userId: "user_123", endpoint: "suggestions" } + ) + }) + }) + + describe("Suggestion Generation", () => { + beforeEach(() => { + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: Date.now() + 60000, + }) + }) + + it("should generate suggestions successfully", async () => { + const suggestions = ["add lighting", "add colors", "add style"] + ; (generateSuggestions as Mock).mockResolvedValue({ suggestions }) + + const request = createMockRequest({ prompt: "a beautiful landscape" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toEqual({ + success: true, + data: { suggestions }, + }) + }) + + it("should handle empty prompt", async () => { + ; (generateSuggestions as Mock).mockResolvedValue({ suggestions: [] }) + + const request = createMockRequest({ prompt: "" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.data.suggestions).toEqual([]) + }) + + it("should handle missing prompt", async () => { + ; (generateSuggestions as Mock).mockResolvedValue({ suggestions: [] }) + + const request = createMockRequest({}) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(200) + // Should not error, just return empty suggestions + expect(data.success).toBe(true) + }) + }) + + describe("Error Handling", () => { + beforeEach(() => { + ; (auth as Mock).mockResolvedValue({ userId: "user_123" }) + ; (fetchMutation as Mock).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: Date.now() + 60000, + }) + }) + + it("should return empty suggestions on error (graceful degradation)", async () => { + ; (generateSuggestions as Mock).mockRejectedValue(new Error("API Error")) + + const request = createMockRequest({ prompt: "test" }) + const response = await POST(request) + const data = await response.json() + + // Should return success with empty suggestions to not break UI + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(data.data.suggestions).toEqual([]) + }) + + it("should handle AbortError with 499 status", async () => { + const abortError = new Error("Aborted") + abortError.name = "AbortError" + ; (generateSuggestions as Mock).mockRejectedValue(abortError) + + const request = createMockRequest({ prompt: "test" }) + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(499) + expect(data.error.code).toBe("CANCELLED") + }) + }) +}) diff --git a/app/api/suggestions/route.ts b/app/api/suggestions/route.ts index 4ad31c9..06a8a74 100644 --- a/app/api/suggestions/route.ts +++ b/app/api/suggestions/route.ts @@ -3,10 +3,17 @@ * * Server-side endpoint for generating contextual prompt suggestions. * Designed for high-frequency, low-latency calls. + * + * Security: + * - Requires authentication (returns 401 if not authenticated) + * - Rate limited to 20 requests per minute per user (returns 429 if exceeded) */ +import { auth } from "@clerk/nextjs/server" +import { fetchMutation } from "convex/nextjs" import { generateSuggestions } from "@/lib/prompt-enhancement" import { NextRequest, NextResponse } from "next/server" +import { api } from "@/convex/_generated/api" /** * Request body schema @@ -49,6 +56,48 @@ export async function POST( request: NextRequest ): Promise> { try { + // Authentication check + const { userId } = await auth() + if (!userId) { + return NextResponse.json( + { + success: false, + error: { + code: "UNAUTHORIZED", + message: "Authentication required", + }, + }, + { status: 401 } + ) + } + + // Rate limit check + const rateLimitResult = await fetchMutation(api.rateLimits.checkRateLimit, { + userId, + endpoint: "suggestions", + }) + + if (!rateLimitResult.allowed) { + const retryAfter = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000) + return NextResponse.json( + { + success: false, + error: { + code: "RATE_LIMIT_EXCEEDED", + message: "Too many requests. Please try again later.", + }, + }, + { + status: 429, + headers: { + "Retry-After": String(retryAfter), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": String(rateLimitResult.resetAt), + }, + } + ) + } + const body = (await request.json()) as SuggestionsRequest // Validate request - allow empty prompt (returns empty suggestions) @@ -59,12 +108,20 @@ export async function POST( abortSignal: request.signal, }) - return NextResponse.json({ - success: true, - data: { - suggestions: result.suggestions, + return NextResponse.json( + { + success: true, + data: { + suggestions: result.suggestions, + }, }, - }) + { + headers: { + "X-RateLimit-Remaining": String(rateLimitResult.remaining), + "X-RateLimit-Reset": String(rateLimitResult.resetAt), + }, + } + ) } catch (error) { // Handle cancellation if (error instanceof Error && error.name === "AbortError") { @@ -90,3 +147,4 @@ export async function POST( }) } } + diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 8291bc3..2ca5f9a 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -12,6 +12,9 @@ import { uploadImage, generateImageKey } from "@/lib/storage" const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"] +/** + * Succcesful upload response schema. + */ interface UploadResponse { success: true data: { @@ -22,6 +25,9 @@ interface UploadResponse { } } +/** + * Error response schema. + */ interface UploadError { success: false error: { @@ -30,6 +36,10 @@ interface UploadError { } } +/** + * Handles image upload requests. + * Validates file type and size, then uploads to Cloudflare R2. + */ export async function POST( request: NextRequest ): Promise> { diff --git a/app/api/user/balance/route.ts b/app/api/user/balance/route.ts index 4bcd240..3541894 100644 --- a/app/api/user/balance/route.ts +++ b/app/api/user/balance/route.ts @@ -67,11 +67,22 @@ export async function GET(): Promise { // Build authorization header const authHeader = getAuthorizationHeader(apiKey) - - // Debug: log that we're making the request (mask the key) - const maskedKey = apiKey ? `${apiKey.substring(0, 6)}...${apiKey.substring(apiKey.length - 4)}` : 'undefined' - console.log(`[/api/user/balance] Fetching balance with key: ${maskedKey}`) - console.log(`[/api/user/balance] Authorization header present: ${!!authHeader}`) + + // Safely mask the API key for logging (only in development) + // Handles edge cases: short keys, empty keys, malformed keys + const maskApiKey = (key: string | undefined): string => { + if (!key) return "[none]" + const len = key.length + if (len <= 8) return "****" + if (len >= 16) return `${key.slice(0, 4)}...${key.slice(-4)}` + // Medium keys (9-15) + return `${key.slice(0, 2)}...${key.slice(-2)}` + } + + // Only log in development to avoid leaking any info in production + if (process.env.NODE_ENV === "development") { + console.log(`[/api/user/balance] Fetching balance with key: ${maskApiKey(apiKey)}`) + } // Fetch balance from Pollinations API const response = await fetch(POLLINATIONS_BALANCE_URL, { diff --git a/app/layout.tsx b/app/layout.tsx index 505ae8f..1f0a0c4 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,7 +5,7 @@ import { ThemeProvider } from "@/components/theme-provider" import { Toaster } from "@/components/ui/sonner" import { Analytics } from "@vercel/analytics/next" import { SpeedInsights } from "@vercel/speed-insights/next" -import type { Metadata } from "next" +import type { Metadata, Viewport } from "next" import { Bricolage_Grotesque, Geist, Geist_Mono } from "next/font/google" import type React from "react" import "./globals.css" @@ -14,11 +14,65 @@ const geist = Geist({ subsets: ["latin"], variable: "--font-geist-sans" }) const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono" }) const bricolage = Bricolage_Grotesque({ subsets: ["latin"], variable: "--font-bricolage" }) +/** + * Viewport configuration for the application. + * Controls scaling, theme colors, and layout behavior on different devices. + */ +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 5, + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "white" }, + { media: "(prefers-color-scheme: dark)", color: "black" }, + ], +} + +/** + * Global metadata configuration for the application. + * Includes title, description, OpenGraph, Twitter, and other SEO-related tags. + */ export const metadata: Metadata = { - title: "Bloom Studio - Free AI Image Generation", + title: { + default: "Bloom Studio - Powerful AI Image Generation", + template: "%s | Bloom Studio", + }, description: - "Create stunning AI-generated images with Pollinations.AI. Configure models, dimensions, and advanced parameters for free.", - generator: "v0.app", + "Create stunning AI-generated images with Bloom Studio. Configure models, dimensions, and advanced parameters for free. Experience the next generation of creative tools.", + metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || "https://bloomstudio.fun"), + applicationName: "Bloom Studio", + authors: [{ name: "Bloom Studio Team" }], + generator: "Next.js", + keywords: [ + "AI", + "Image Generation", + "Stable Diffusion", + "Flux", + "Art", + "Creative", + "Kling", + "Image Gen", + "Chatgpt", + "Bloom Studio", + "Midjourney", + ], + referrer: "origin-when-cross-origin", + creator: "Bloom Studio", + publisher: "Bloom Studio", + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, + }, + alternates: { + canonical: "/", + }, icons: { icon: [ { @@ -36,8 +90,48 @@ export const metadata: Metadata = { ], apple: "/apple-icon.png", }, + openGraph: { + type: "website", + locale: "en_US", + url: "/", + title: "Bloom Studio - Powerful AI Image Generation", + description: + "Create stunning AI-generated images with Bloom Studio. Configure models, dimensions, and advanced parameters for free.", + siteName: "Bloom Studio", + images: [ + { + url: "/branding/bloom-studio_logo.png", + width: 1200, + height: 630, + alt: "Bloom Studio Interface", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "Bloom Studio - Powerful AI Image Generation", + description: + "Create stunning AI-generated images with Bloom Studio. Configure models, dimensions, and advanced parameters for free.", + images: ["/branding/bloom-studio_logo.png"], + creator: "@bloomstudio", + }, + appleWebApp: { + capable: true, + title: "Bloom Studio", + statusBarStyle: "black-translucent", + }, + formatDetection: { + telephone: false, + }, } +/** + * Root layout component that wraps all pages in the application. + * Provides global providers (Theme, Clerk, Convex, React Query) and basic HTML structure. + * + * @param props - Component props + * @param props.children - The child components to render within the layout + */ export default function RootLayout({ children, }: Readonly<{ diff --git a/app/page.tsx b/app/page.tsx index 6473478..58bdfb6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -20,6 +20,12 @@ const GL = dynamic(() => import("@/components/gl/gl").then((mod) => ({ default: ), }) +/** + * Landing Page Component + * + * The main entry point for the application. Displays the hero section, + * WebGL background, and value proposition. + */ export default function LandingPage() { const [hovering, setHovering] = useState(false) const { isSignedIn, isLoaded } = useUser() diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index e458463..7279791 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -251,9 +251,9 @@ function PricingContent() {

vs Leonardo.ai (at 140 tokens per image)

- {/* PixelStream */} + {/* Bloom Studio */}
-
PixelStream
+
Bloom Studio
900 images
$5/mo
Best Value
diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..ab115b5 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,21 @@ +import type { MetadataRoute } from "next" + +/** + * Generates the robots.txt metadata for search engine crawlers. + * Defines allow/disallow rules and points to the sitemap. + * + * @returns {MetadataRoute.Robots} The robots configuration object. + */ +export default function robots(): MetadataRoute.Robots { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://bloomstudio.fun" + + return { + rules: { + userAgent: "*", + allow: "/", + disallow: ["/api/", "/studio/"], // Disallow private/internal paths if needed, but usually studio might be public? + // Keeping it simple for now, usually you want to allow everything unless it's strictly private + }, + sitemap: `${baseUrl}/sitemap.xml`, + } +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..83c0ed5 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,15 @@ +import type { MetadataRoute } from "next" + +export default function sitemap(): MetadataRoute.Sitemap { + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://bloomstudio.fun" + + return [ + { + url: baseUrl, + lastModified: new Date(), + changeFrequency: "daily", + priority: 1, + }, + // Add other static pages here + ] +} diff --git a/bun.lock b/bun.lock index 4ba5c6b..dea94c0 100644 --- a/bun.lock +++ b/bun.lock @@ -41,13 +41,13 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "10.7.7", "@react-three/fiber": "9.4.2", - "@stripe/stripe-js": "^8.6.0", - "@tanstack/react-query": "^5.90.12", - "@tanstack/react-virtual": "^3.13.14", + "@stripe/stripe-js": "^8.6.1", + "@tanstack/react-query": "^5.90.16", + "@tanstack/react-virtual": "^3.13.16", "@types/three": "^0.182.0", "@vercel/analytics": "1.6.1", "@vercel/speed-insights": "^1.3.1", - "ai": "^6.0.3", + "ai": "^6.0.11", "autoprefixer": "^10.4.23", "babel-plugin-react-compiler": "^1.0.0", "class-variance-authority": "^0.7.1", @@ -62,12 +62,12 @@ "expo-asset": "^12.0.12", "expo-file-system": "^19.0.21", "expo-gl": "^16.0.9", - "framer-motion": "^12.23.26", + "framer-motion": "^12.23.28", "input-otp": "^1.4.2", "leva": "0.10.1", "lucide-react": "^0.562.0", "maath": "0.10.8", - "next": "16.1.0", + "next": "16.1.1", "next-themes": "^0.4.6", "r3f-perf": "^7.2.3", "react": "19.2.3", @@ -75,7 +75,7 @@ "react-dom": "19.2.3", "react-hook-form": "^7.69.0", "react-native": "^0.83.1", - "react-resizable-panels": "^4.0.13", + "react-resizable-panels": "^4.0.16", "react-select": "^5.10.2", "react-zoom-pan-pinch": "^3.7.0", "recharts": "2.15.4", @@ -115,11 +115,11 @@ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.2", "", { "dependencies": { "@ai-sdk/provider": "3.0.0", "@ai-sdk/provider-utils": "4.0.1", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.8", "", { "dependencies": { "@ai-sdk/provider": "3.0.1", "@ai-sdk/provider-utils": "4.0.3", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZH3j9LSQxR05QTORH3C7WnPWFylraRhElOrNbZ64Zl/tZrPbWcc4bDar453haLNgY2rEwp3BqGzk5qxYifqPXQ=="], - "@ai-sdk/provider": ["@ai-sdk/provider@3.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-2lR4w7mr9XrydzxBSjir4N6YMGdXD+Np1Sh0RXABh7tWdNFFwIeRI1Q+SaYZMbfL8Pg8RRLcrxQm51yxTLhokg=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.1", "", { "dependencies": { "@ai-sdk/provider": "3.0.0", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.3", "", { "dependencies": { "@ai-sdk/provider": "3.0.1", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Vo2p61dDld8Dy/O66zKQpE4nqHojiEEYEjZcSbICjE7h8Z6QmHzBfd+ss/paIDdyXyS0yHmC1GoRYYKo89cqZQ=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -687,25 +687,25 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], - "@next/env": ["@next/env@16.1.0", "", {}, "sha512-Dd23XQeFHmhf3KBW76leYVkejHlCdB7erakC2At2apL1N08Bm+dLYNP+nNHh0tzUXfPQcNcXiQyacw0PG4Fcpw=="], + "@next/env": ["@next/env@16.1.1", "", {}, "sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.1.1", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-Ovb/6TuLKbE1UiPcg0p39Ke3puyTCIKN9hGbNItmpQsp+WX3qrjO3WaMVSi6JHr9X1NrmthqIguVHodMJbh/dw=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-onHq8dl8KjDb8taANQdzs3XmIqQWV3fYdslkGENuvVInFQzZnuBYYOG2HGHqqtvgmEU7xWzhgndXXxnhk4Z3fQ=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JS3m42ifsVSJjSTzh27nW+Igfha3NdBOFScr9C80hHGrWx55pTrVL23RJbqir7k7/15SKlrLHhh/MQzqBBYrQA=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Am6VJTp8KhLuAH13tPrAoVIXzuComlZlMwGr++o2KDjWiKPe3VwpxYhgV6I4gKls2EnsIMggL4y7GdXyDdJcFA=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-hbyKtrDGUkgkyQi1m1IyD3q4I/3m9ngr+V93z4oKHrPcmxwNL5iMWORvLSGAf2YujL+6HxgVvZuCYZfLfb4bGw=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-fVicfaJT6QfghNyg8JErZ+EMNQ812IS0lmKfbmC01LF1nFBcKfcs4Q75Yy8IqnsCqH/hZwGhqzj3IGVfWV6vpA=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/fvHet+EYckFvRLQ0jPHJCUI5/B56+2DpI1xDSvi80r/3Ez+Eaa2Yq4tJcRTaB1kqj/HrYKn8Yplm9bNoMJpwQ=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TojQnDRoX7wJWXEEwdfuJtakMDW64Q7NrxQPviUnfYJvAx5/5wcGE+1vZzQ9F17m+SdpFeeXuOr6v3jbyusYMQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-quhNFVySW4QwXiZkZ34SbfzNBm27vLrxZ2HwTfFFO1BBP0OY1+pI0nbyewKeq1FriqU+LZrob/cm26lwsiAi8Q=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-6JW0z2FZUK5iOVhUIWqE4RblAhUj1EwhZ/MwteGb//SpFTOHydnhbp3868gxalwea+mbOLWO6xgxj9wA9wNvNw=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-+DK/akkAvvXn5RdYN84IOmLkSy87SCmpofJPdB8vbLmf01BzntPBSYXnMvnEEv/Vcf3HYJwt24QZ/s6sWAwOMQ=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Tr0j94MphimCCks+1rtYPzQFK+faJuhHWCegU9S9gDlgyOk8Y3kPmO64UcjyzZAlligeBtYZ/2bEyrKq0d2wqQ=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Ncwbw2WJ57Al5OX0k4chM68DKhEPlrXBaSXDCi2kPi5f4d8b3ejr3RRJGfKBLrn2YJL5ezNS7w2TZLHSti8CMw=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -1039,7 +1039,7 @@ "@stitches/react": ["@stitches/react@1.2.8", "", { "peerDependencies": { "react": ">= 16.3.0" } }, "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA=="], - "@stripe/stripe-js": ["@stripe/stripe-js@8.6.0", "", {}, "sha512-EB0/GGgs4hfezzkiMkinlRgWtjz8fSdwVQhwYS7Sg/RQrSvuNOz+ssPjD+lAzqaYTCB0zlbrt0fcqVziLJrufQ=="], + "@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -1073,13 +1073,13 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], - "@tanstack/query-core": ["@tanstack/query-core@5.90.12", "", {}, "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="], - "@tanstack/react-query": ["@tanstack/react-query@5.90.12", "", { "dependencies": { "@tanstack/query-core": "5.90.12" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg=="], + "@tanstack/react-query": ["@tanstack/react-query@5.90.16", "", { "dependencies": { "@tanstack/query-core": "5.90.16" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.14", "", { "dependencies": { "@tanstack/virtual-core": "3.13.14" }, "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" } }, "sha512-WG0d7mBD54eA7dgA3+sO5csS0B49QKqM6Gy5Rf31+Oq/LTKROQSao9m2N/vz1IqVragOKU5t5k1LAcqh/DfTxw=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.16", "", { "dependencies": { "@tanstack/virtual-core": "3.13.16" }, "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" } }, "sha512-y4xLKvLu6UZWiGdNcgk3yYlzCznYIV0m8dSyUzr3eAC0dHLos5V74qhUHxutYddFGgGU8sWLkp6H5c2RCrsrXw=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.14", "", {}, "sha512-b5Uvd8J2dc7ICeX9SRb/wkCxWk7pUwN214eEPAQsqrsktSKTCmyLxOQWSMgogBByXclZeAdgZ3k4o0fIYUIBqQ=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.16", "", {}, "sha512-njazUC8mDkrxWmyZmn/3eXrDcP8Msb3chSr4q6a65RmwdSbMlMCdnOphv6/8mLO7O3Fuza5s4M4DclmvAO5w0w=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -1239,7 +1239,7 @@ "@vercel/analytics": ["@vercel/analytics@1.6.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg=="], - "@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vercel/speed-insights": ["@vercel/speed-insights@1.3.1", "", { "peerDependencies": { "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@sveltejs/kit", "next", "react", "svelte", "vue", "vue-router"] }, "sha512-PbEr7FrMkUrGYvlcLHGkXdCkxnylCWePx7lPxxq36DNdfo9mcUjLOmqOyPDHAOgnfqgGGdmE3XI9L/4+5fr+vQ=="], @@ -1275,7 +1275,7 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ai": ["ai@6.0.3", "", { "dependencies": { "@ai-sdk/gateway": "3.0.2", "@ai-sdk/provider": "3.0.0", "@ai-sdk/provider-utils": "4.0.1", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ=="], + "ai": ["ai@6.0.11", "", { "dependencies": { "@ai-sdk/gateway": "3.0.8", "@ai-sdk/provider": "3.0.1", "@ai-sdk/provider-utils": "4.0.3", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uQAGp/5YAZZFcMNxLCg5vPZ4lNKn0NGvBeI+iY9yW2RvkonooMVfHPOum2lgqwugkoBbffzPtR7TXOFFyKJCOg=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -1737,7 +1737,7 @@ "fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="], - "framer-motion": ["framer-motion@12.23.26", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA=="], + "framer-motion": ["framer-motion@12.24.3", "", { "dependencies": { "motion-dom": "^12.24.3", "motion-utils": "^12.23.28", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-AUbjkQDNXMSK++Lzoy8ejUmbINAHmVuCNX8RBJ84zN0G9aqHyd1jS4RczecCk6tRVhnWlONwyIRMwf+PnZOYaQ=="], "freeport-async": ["freeport-async@2.0.0", "", {}, "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ=="], @@ -2131,9 +2131,9 @@ "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], - "motion-dom": ["motion-dom@12.23.23", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA=="], + "motion-dom": ["motion-dom@12.24.3", "", { "dependencies": { "motion-utils": "^12.23.28" } }, "sha512-ZjMZCwhTglim0LM64kC1iFdm4o+2P9IKk3rl/Nb4RKsb5p4O9HJ1C2LWZXOFdsRtp6twpqWRXaFKOduF30ntow=="], - "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], + "motion-utils": ["motion-utils@12.23.28", "", {}, "sha512-0W6cWd5Okoyf8jmessVK3spOmbyE0yTdNKujHctHH9XdAE4QDuZ1/LjSXC68rrhsJU+TkzXURC5OdSWh9ibOwQ=="], "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], @@ -2151,7 +2151,7 @@ "nested-error-stacks": ["nested-error-stacks@2.0.1", "", {}, "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A=="], - "next": ["next@16.1.0", "", { "dependencies": { "@next/env": "16.1.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.0", "@next/swc-darwin-x64": "16.1.0", "@next/swc-linux-arm64-gnu": "16.1.0", "@next/swc-linux-arm64-musl": "16.1.0", "@next/swc-linux-x64-gnu": "16.1.0", "@next/swc-linux-x64-musl": "16.1.0", "@next/swc-win32-arm64-msvc": "16.1.0", "@next/swc-win32-x64-msvc": "16.1.0", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Y+KbmDbefYtHDDQKLNrmzE/YYzG2msqo2VXhzh5yrJ54tx/6TmGdkR5+kP9ma7i7LwZpZMfoY3m/AoPPPKxtVw=="], + "next": ["next@16.1.1", "", { "dependencies": { "@next/env": "16.1.1", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.1", "@next/swc-darwin-x64": "16.1.1", "@next/swc-linux-arm64-gnu": "16.1.1", "@next/swc-linux-arm64-musl": "16.1.1", "@next/swc-linux-x64-gnu": "16.1.1", "@next/swc-linux-x64-musl": "16.1.1", "@next/swc-win32-arm64-msvc": "16.1.1", "@next/swc-win32-x64-msvc": "16.1.1", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-QI+T7xrxt1pF6SQ/JYFz95ro/mg/1Znk5vBebsWwbpejj1T0A23hO7GYEaVac9QUOT2BIMiuzm0L99ooq7k0/w=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], @@ -2315,7 +2315,7 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-resizable-panels": ["react-resizable-panels@4.0.13", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-tG6cPUARl8p8lAkRYFbYvoqiVNBatAMCair/wngBoHKfnHRYTFmgPa97mSrImKDmU0Go3friFCVbOtvbDMhzJA=="], + "react-resizable-panels": ["react-resizable-panels@4.2.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-BxDTFHxDCyCRPK54X5hpnhoLZbBslUrTTelQDRHo4107FXODjPSTqEWOPlFxE/ho0Vw4JrsRgaWdx6GIK073XA=="], "react-select": ["react-select@5.10.2", "", { "dependencies": { "@babel/runtime": "^7.12.0", "@emotion/cache": "^11.4.0", "@emotion/react": "^11.8.1", "@floating-ui/dom": "^1.0.1", "@types/react-transition-group": "^4.4.0", "memoize-one": "^6.0.0", "prop-types": "^15.6.0", "react-transition-group": "^4.3.0", "use-isomorphic-layout-effect": "^1.2.0" }, "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" } }, "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ=="], diff --git a/components/gallery/image-history.tsx b/components/gallery/image-history.tsx index 197fb0f..4bb0386 100644 --- a/components/gallery/image-history.tsx +++ b/components/gallery/image-history.tsx @@ -5,7 +5,7 @@ import { DeleteImageDialog } from "@/components/studio/delete-image-dialog" import { Button } from "@/components/ui/button" import { Skeleton } from "@/components/ui/skeleton" import { useDeleteGeneratedImage } from "@/hooks/mutations/use-delete-image" -import { useImageHistory } from "@/hooks/queries/use-image-history" +import { useImageHistoryWithDisplayData } from "@/hooks/queries/use-image-history" import { getModelDisplayName } from "@/lib/config/models" import { Loader2 } from "lucide-react" import Image from "next/image" @@ -15,7 +15,7 @@ import Image from "next/image" * Supports infinite scrolling with a "Load More" button. */ export function ImageHistory() { - const { results, status, loadMore } = useImageHistory() + const { results, status, loadMore } = useImageHistoryWithDisplayData() const deleteMutation = useDeleteGeneratedImage() const isLoading = status === "LoadingFirstPage" diff --git a/components/image-generator/advanced-settings.tsx b/components/image-generator/advanced-settings.tsx index cdbb79d..7ef7717 100644 --- a/components/image-generator/advanced-settings.tsx +++ b/components/image-generator/advanced-settings.tsx @@ -67,6 +67,9 @@ interface SettingToggleProps { disabled?: boolean } +/** + * Individual switch toggle with label and description. + */ function SettingToggle({ id, label, @@ -94,6 +97,12 @@ function SettingToggle({ ) } +/** + * Advanced Settings Panel Component. + * + * Provides controls for negative prompts, guidance scale, and various boolean flags + * (transparency, watermark, enhance, private, safe mode). + */ export function AdvancedSettings({ open, onOpenChange, @@ -141,9 +150,8 @@ export function AdvancedSettings({ > Advanced Settings @@ -167,10 +175,10 @@ export function AdvancedSettings({ enhanceNegativePromptText({ - prompt: mainPrompt, + onEnhance={() => enhanceNegativePromptText({ + prompt: mainPrompt, negativePrompt, - type: "negative" + type: "negative" })} onCancel={cancelNegativeEnhance} /> diff --git a/components/image-generator/image-display.tsx b/components/image-generator/image-display.tsx index 2914581..cbedc14 100644 --- a/components/image-generator/image-display.tsx +++ b/components/image-generator/image-display.tsx @@ -10,14 +10,28 @@ import type { GeneratedImage } from "@/types/pollinations" import { Badge } from "@/components/ui/badge" import { useImageDisplay } from "@/hooks/use-image-display" +/** + * Props for the ImageDisplay component. + */ interface ImageDisplayProps { + /** List of generated images to display in the gallery history */ images: GeneratedImage[] + /** The currently selected/displayed image */ currentImage: GeneratedImage | null + /** Callback to remove an image from history */ onRemove: (id: string) => void + /** Callback when an image is selected from history */ onSelect: (image: GeneratedImage) => void + /** Whether generation is currently in progress */ isGenerating: boolean } +/** + * Image Display Component. + * + * Renders the main generated image with loading states (animations) and action buttons. + * Also renders the history gallery below the main image. + */ export function ImageDisplay({ images, currentImage, onRemove, onSelect, isGenerating }: ImageDisplayProps) { const { copiedUrl, diff --git a/components/layout/header.tsx b/components/layout/header.tsx index ed12d3d..4acb68e 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -86,7 +86,12 @@ export function Header() { {item.label} {isActive && ( - + )}
diff --git a/components/providers/convex-client-provider.tsx b/components/providers/convex-client-provider.tsx index 4fc1a2c..a4a8ef9 100644 --- a/components/providers/convex-client-provider.tsx +++ b/components/providers/convex-client-provider.tsx @@ -11,11 +11,58 @@ import { ConvexReactClient } from "convex/react" import { ConvexProviderWithClerk } from "convex/react-clerk" import { useAuth } from "@clerk/nextjs" -if (!process.env.NEXT_PUBLIC_CONVEX_URL) { +// Environment variable validation +const CONVEX_URL = process.env.NEXT_PUBLIC_CONVEX_URL +const CLERK_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY +const CONVEX_DEPLOYMENT = process.env.CONVEX_DEPLOYMENT + +/** + * Detect true production environment. + * + * VERCEL_ENV is set by Vercel to 'production', 'preview', or 'development'. + * For local builds, we fall back to checking NODE_ENV and if localhost patterns are absent. + */ +const VERCEL_ENV = process.env.NEXT_PUBLIC_VERCEL_ENV +const IS_VERCEL_PRODUCTION = VERCEL_ENV === "production" +const IS_NODE_PRODUCTION = process.env.NODE_ENV === "production" + +if (!CONVEX_URL) { throw new Error("Missing NEXT_PUBLIC_CONVEX_URL in your .env file") } -const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL) +/** + * Production key validation. + * + * Only validates in true production environments (Vercel production deployment). + * Preview and development environments can safely use test keys. + */ +if (IS_VERCEL_PRODUCTION) { + // Validate Clerk publishable key is not a test key in production + if (CLERK_PUBLISHABLE_KEY?.startsWith("pk_test_")) { + throw new Error( + "[Security Error] Test Clerk publishable key detected in production deployment. " + + "Please configure NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY with a production key (pk_live_...)." + ) + } + + // Validate Convex Deployment is not a dev instance in production + if (CONVEX_DEPLOYMENT?.startsWith("dev:")) { + console.warn( + "[Security Warning] Convex Deployment appears to be a development instance. " + + "Ensure this is intentional for this production deployment." + ) + } +} else if (IS_NODE_PRODUCTION && !VERCEL_ENV) { + // Local production build - issue warning but don't block + if (CLERK_PUBLISHABLE_KEY?.startsWith("pk_test_")) { + console.warn( + "[Security Warning] Test Clerk publishable key detected in production build. " + + "In a true production deployment, use a production key (pk_live_...)." + ) + } +} + +const convex = new ConvexReactClient(CONVEX_URL) interface ConvexClientProviderProps { children: ReactNode diff --git a/components/studio/api-key-onboarding-modal.tsx b/components/studio/api-key-onboarding-modal.tsx index a4b4518..bf98f07 100644 --- a/components/studio/api-key-onboarding-modal.tsx +++ b/components/studio/api-key-onboarding-modal.tsx @@ -36,6 +36,12 @@ interface ApiKeyOnboardingModalProps { onClose?: () => void } +/** + * Modal for guiding users through the API key setup process. + * + * Handles checking for existing keys, generating new keys via external portal, + * and saving the key securely. Supports both automatic (on-mount) and controlled modes. + */ export function ApiKeyOnboardingModal({ onComplete, forceOpen, onClose }: ApiKeyOnboardingModalProps) { const [apiKey, setApiKey] = React.useState("") const [isSaving, setIsSaving] = React.useState(false) @@ -192,7 +198,7 @@ export function ApiKeyOnboardingModal({ onComplete, forceOpen, onClose }: ApiKey
- Click "Generate API Key", select "Secret Key", and copy it + Click "Create API Key", select "Secret Key", then generate and copy it
diff --git a/components/studio/batch/batch-progress-indicator.tsx b/components/studio/batch/batch-progress-indicator.tsx index 8b5eb64..1fe7bd4 100644 --- a/components/studio/batch/batch-progress-indicator.tsx +++ b/components/studio/batch/batch-progress-indicator.tsx @@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button" import { Progress } from "@/components/ui/progress" -import type { BatchJob } from "@/hooks/queries/use-batch-generation" +import type { BatchJob, BatchJobStatus } from "@/hooks/queries/use-batch-generation" import { Loader2, X } from "lucide-react" import * as React from "react" @@ -40,17 +40,19 @@ export const BatchProgressIndicator = React.memo(function BatchProgressIndicator ? `${estimatedMinutes}m remaining` : `${(estimatedMinutes / 60).toFixed(1)}h remaining` - const statusText = { + const statusText: Record = { pending: "Starting...", processing: `${completedCount}/${totalCount} complete`, + paused: `Paused (${completedCount}/${totalCount})`, completed: `Completed (${completedCount}/${totalCount})`, cancelled: `Cancelled (${completedCount}/${totalCount})`, failed: `Failed (${failedCount} errors)`, } - const statusColor = { + const statusColor: Record = { pending: "text-muted-foreground", processing: "text-primary", + paused: "text-amber-500", completed: "text-emerald-500", cancelled: "text-yellow-500", failed: "text-destructive", diff --git a/components/studio/features/history/gallery-feature.test.tsx b/components/studio/features/history/gallery-feature.test.tsx index 697515a..a258dd2 100644 --- a/components/studio/features/history/gallery-feature.test.tsx +++ b/components/studio/features/history/gallery-feature.test.tsx @@ -9,16 +9,10 @@ vi.mock("./gallery-view", () => ({ GalleryView: ({ activeImageId, onSelectImage, - onRemoveImage, - onDownloadImage, - onCopyImageUrl, thumbnailSize, }: { activeImageId?: string onSelectImage?: (image: GeneratedImage) => void - onRemoveImage?: (id: string) => void - onDownloadImage?: (image: GeneratedImage) => void - onCopyImageUrl?: (image: GeneratedImage) => void thumbnailSize?: string }) => { const mockImage: GeneratedImage = { @@ -39,52 +33,16 @@ vi.mock("./gallery-view", () => ({ > Select - - -
) }, })) -// Mock useDownloadImage -const mockDownload = vi.fn() -vi.mock("@/hooks/queries", () => ({ - useDownloadImage: () => ({ - download: mockDownload, - }), -})) - -vi.mock("@/lib/errors", () => ({ - showErrorToast: vi.fn(), -})) - describe("GalleryFeature", () => { const defaultProps: GalleryFeatureProps = {} beforeEach(() => { vi.clearAllMocks() - // Mock clipboard API - Object.assign(navigator, { - clipboard: { - writeText: vi.fn().mockResolvedValue(undefined), - }, - }) }) it("renders GalleryView", () => { @@ -127,34 +85,4 @@ describe("GalleryFeature", () => { expect.objectContaining({ id: "test-id" }) ) }) - - it("calls onRemoveImage when remove button clicked", async () => { - const onRemoveImage = vi.fn().mockResolvedValue(undefined) - render() - - fireEvent.click(screen.getByTestId("remove-btn")) - - expect(onRemoveImage).toHaveBeenCalledWith("test-id") - }) - - it("calls download function when download button clicked", () => { - render() - - fireEvent.click(screen.getByTestId("download-btn")) - - expect(mockDownload).toHaveBeenCalledWith({ - url: "https://example.com/image.jpg", - filename: "bloomstudio-test-id.jpg", - }) - }) - - it("copies URL to clipboard when copy button clicked", async () => { - render() - - fireEvent.click(screen.getByTestId("copy-btn")) - - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - "https://example.com/image.jpg" - ) - }) }) diff --git a/components/studio/features/history/gallery-feature.tsx b/components/studio/features/history/gallery-feature.tsx index 72d3a34..8f435cb 100644 --- a/components/studio/features/history/gallery-feature.tsx +++ b/components/studio/features/history/gallery-feature.tsx @@ -5,26 +5,20 @@ * * This component: * 1. Receives handlers from parent for image actions - * 2. Manages gallery-specific callbacks - * 3. Renders the GalleryView with all necessary props + * 2. Renders the GalleryView with all necessary props * - * The PersistentImageGallery already has internal state management for - * selection and filtering, so this feature is relatively thin. + * The PersistentImageGallery manages bulk actions (delete, visibility changes) + * internally via the selection mode and actions dropdown. */ -import { useDownloadImage } from "@/hooks/queries" -import { showErrorToast } from "@/lib/errors" -import type { GeneratedImage } from "@/types/pollinations" +import type { ThumbnailData } from "@/components/studio/gallery/image-gallery" import { GalleryView } from "./gallery-view" -import * as React from "react" export interface GalleryFeatureProps { /** Currently active image ID (for highlighting) */ activeImageId?: string /** Handle image selection (opens lightbox) */ - onSelectImage?: (image: GeneratedImage) => void - /** Handle image removal */ - onRemoveImage?: (id: string) => Promise + onSelectImage?: (image: ThumbnailData) => void /** Thumbnail size */ thumbnailSize?: "sm" | "md" | "lg" } @@ -37,43 +31,18 @@ export interface GalleryFeatureProps { * * ``` */ -export function GalleryFeature({ +export function GalleryFeature({ activeImageId, onSelectImage, - onRemoveImage, thumbnailSize = "md", }: GalleryFeatureProps) { - // Download functionality - const { download } = useDownloadImage({ - onError: (error) => { - showErrorToast(error) - }, - }) - - // Handle download action - const handleDownloadImage = React.useCallback((image: GeneratedImage) => { - download({ - url: image.url, - filename: `bloomstudio-${image.id}.jpg`, - }) - }, [download]) - - // Handle copy URL action - const handleCopyImageUrl = React.useCallback(async (image: GeneratedImage) => { - await navigator.clipboard.writeText(image.url) - }, []) - return ( ) diff --git a/components/studio/features/history/gallery-view.test.tsx b/components/studio/features/history/gallery-view.test.tsx index 6e0386e..9915120 100644 --- a/components/studio/features/history/gallery-view.test.tsx +++ b/components/studio/features/history/gallery-view.test.tsx @@ -3,23 +3,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen } from "@testing-library/react" import { GalleryView, type GalleryViewProps } from "./gallery-view" import type { GeneratedImage } from "@/types/pollinations" -import * as React from "react" // Mock PersistentImageGallery vi.mock("@/components/studio", () => ({ PersistentImageGallery: ({ activeImageId, onSelectImage, - onRemoveImage, - onDownloadImage, - onCopyImageUrl, thumbnailSize, }: { activeImageId?: string onSelectImage?: (image: GeneratedImage) => void - onRemoveImage?: (id: string) => void - onDownloadImage?: (image: GeneratedImage) => void - onCopyImageUrl?: (image: GeneratedImage) => void thumbnailSize?: string }) => (
@@ -31,24 +24,6 @@ vi.mock("@/components/studio", () => ({ > Select - - -
), })) @@ -99,33 +74,6 @@ describe("GalleryView", () => { expect(onSelectImage).toHaveBeenCalledTimes(1) }) - it("calls onRemoveImage when remove button clicked", () => { - const onRemoveImage = vi.fn() - render() - - screen.getByTestId("remove-btn").click() - - expect(onRemoveImage).toHaveBeenCalledWith("test-id") - }) - - it("calls onDownloadImage when download button clicked", () => { - const onDownloadImage = vi.fn() - render() - - screen.getByTestId("download-btn").click() - - expect(onDownloadImage).toHaveBeenCalledTimes(1) - }) - - it("calls onCopyImageUrl when copy button clicked", () => { - const onCopyImageUrl = vi.fn() - render() - - screen.getByTestId("copy-btn").click() - - expect(onCopyImageUrl).toHaveBeenCalledTimes(1) - }) - it("renders with styled container", () => { const { container } = render() diff --git a/components/studio/features/history/gallery-view.tsx b/components/studio/features/history/gallery-view.tsx index af0e384..74143b5 100644 --- a/components/studio/features/history/gallery-view.tsx +++ b/components/studio/features/history/gallery-view.tsx @@ -11,20 +11,14 @@ */ import { PersistentImageGallery } from "@/components/studio" -import type { GeneratedImage } from "@/types/pollinations" +import type { ThumbnailData } from "@/components/studio/gallery/image-gallery" import * as React from "react" export interface GalleryViewProps { /** Currently active image ID (for highlighting) */ activeImageId?: string - /** Handle image selection */ - onSelectImage?: (image: GeneratedImage) => void - /** Handle image removal */ - onRemoveImage?: (id: string) => void - /** Handle image download */ - onDownloadImage?: (image: GeneratedImage) => void - /** Handle copy image URL */ - onCopyImageUrl?: (image: GeneratedImage) => void + /** Handle image selection (opens in canvas/lightbox) */ + onSelectImage?: (image: ThumbnailData) => void /** Thumbnail size */ thumbnailSize?: "sm" | "md" | "lg" } @@ -32,9 +26,6 @@ export interface GalleryViewProps { export const GalleryView = React.memo(function GalleryView({ activeImageId, onSelectImage, - onRemoveImage, - onDownloadImage, - onCopyImageUrl, thumbnailSize = "md", }: GalleryViewProps) { return ( @@ -42,9 +33,6 @@ export const GalleryView = React.memo(function GalleryView({ diff --git a/components/studio/gallery/gallery-thumbnail.test.tsx b/components/studio/gallery/gallery-thumbnail.test.tsx index c32b0eb..31ba8c1 100644 --- a/components/studio/gallery/gallery-thumbnail.test.tsx +++ b/components/studio/gallery/gallery-thumbnail.test.tsx @@ -1,17 +1,10 @@ import type { GeneratedImage } from "@/types/pollinations" -import { fireEvent, render, screen } from "@testing-library/react" +import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { describe, expect, it, vi } from "vitest" import { GalleryThumbnail } from "./gallery-thumbnail" -// Mock dependencies -vi.mock("@/components/gallery/visibility-toggle", () => ({ - VisibilityToggle: ({ currentVisibility }: { currentVisibility: string }) => ( -
{currentVisibility}
- ), -})) - const mockImage: GeneratedImage = { id: "test-1", url: "https://example.com/image.jpg", @@ -39,7 +32,7 @@ describe("GalleryThumbnail", () => { expect(screen.getByTestId("gallery-thumbnail")).toBeInTheDocument() }) - it("calls onClick when clicked", async () => { + it("calls onClick when clicked (not in selection mode)", async () => { const onClick = vi.fn() render() @@ -47,7 +40,7 @@ describe("GalleryThumbnail", () => { expect(onClick).toHaveBeenCalledTimes(1) }) - it("shows active indicator when isActive", () => { + it("shows active indicator when isActive and not in selection mode", () => { render() expect(screen.getByTestId("active-indicator")).toBeInTheDocument() @@ -59,73 +52,84 @@ describe("GalleryThumbnail", () => { expect(screen.queryByTestId("active-indicator")).not.toBeInTheDocument() }) - it("shows checkbox when showCheckbox is true", () => { - render() - - expect(screen.getByTestId("thumbnail-checkbox")).toBeInTheDocument() - }) + it("does not show active indicator in selection mode even when active", () => { + render() - it("does not show checkbox by default", () => { - render() - - expect(screen.queryByTestId("thumbnail-checkbox")).not.toBeInTheDocument() - }) - - it("shows remove action when onRemove is provided", async () => { - const onRemove = vi.fn() - render() - - // Hover to show overlay - fireEvent.mouseEnter(screen.getByTestId("gallery-thumbnail")) - expect(screen.getByTestId("remove-action")).toBeInTheDocument() - }) - - it("calls onRemove when remove button is clicked", async () => { - const onRemove = vi.fn() - render() - - fireEvent.mouseEnter(screen.getByTestId("gallery-thumbnail")) - await userEvent.click(screen.getByTestId("remove-action")) - expect(onRemove).toHaveBeenCalledTimes(1) - }) - - it("shows copy action when onCopy is provided", async () => { - const onCopy = vi.fn() - render() - - fireEvent.mouseEnter(screen.getByTestId("gallery-thumbnail")) - expect(screen.getByTestId("copy-action")).toBeInTheDocument() + expect(screen.queryByTestId("active-indicator")).not.toBeInTheDocument() }) - it("calls onCopy when copy button is clicked", async () => { - const onCopy = vi.fn() - render() + it("shows selection indicator when showCheckbox is true", () => { + render() - fireEvent.mouseEnter(screen.getByTestId("gallery-thumbnail")) - await userEvent.click(screen.getByTestId("copy-action")) - expect(onCopy).toHaveBeenCalledTimes(1) + expect(screen.getByTestId("selection-indicator")).toBeInTheDocument() }) - it("shows download action when onDownload is provided", async () => { - const onDownload = vi.fn() - render() - - fireEvent.mouseEnter(screen.getByTestId("gallery-thumbnail")) - expect(screen.getByTestId("download-action")).toBeInTheDocument() - }) + it("does not show selection indicator by default", () => { + render() - it("calls onCheckedChange when checkbox is clicked", async () => { - const onCheckedChange = vi.fn() - render( - - ) - - await userEvent.click(screen.getByTestId("thumbnail-checkbox")) - expect(onCheckedChange).toHaveBeenCalledWith(true) + expect(screen.queryByTestId("selection-indicator")).not.toBeInTheDocument() + }) + + describe("selection mode behavior", () => { + it("toggles selection when clicked in selection mode", async () => { + const onCheckedChange = vi.fn() + render( + + ) + + await userEvent.click(screen.getByTestId("gallery-thumbnail")) + expect(onCheckedChange).toHaveBeenCalledWith(true) + }) + + it("toggles selection off when clicked while checked", async () => { + const onCheckedChange = vi.fn() + render( + + ) + + await userEvent.click(screen.getByTestId("gallery-thumbnail")) + expect(onCheckedChange).toHaveBeenCalledWith(false) + }) + + it("does not call onClick in selection mode", async () => { + const onClick = vi.fn() + const onCheckedChange = vi.fn() + render( + + ) + + await userEvent.click(screen.getByTestId("gallery-thumbnail")) + expect(onClick).not.toHaveBeenCalled() + expect(onCheckedChange).toHaveBeenCalled() + }) + + it("applies selected styling when checked", () => { + render( + + ) + + const thumbnail = screen.getByTestId("gallery-thumbnail") + expect(thumbnail).toHaveClass("border-primary") + }) }) it("applies custom className", () => { @@ -142,26 +146,4 @@ describe("GalleryThumbnail", () => { rerender() expect(screen.getByTestId("gallery-thumbnail")).toHaveClass("w-32", "h-32") }) - - it("shows visibility toggle when _id and visibility are provided", async () => { - const imageWithVisibility = { - ...mockImage, - _id: "test-id-123", - visibility: "public" as const - } - - render() - - // Hover to show overlay - fireEvent.mouseEnter(screen.getByTestId("gallery-thumbnail")) - expect(screen.getByTestId("visibility-toggle")).toBeInTheDocument() - expect(screen.getByText("public")).toBeInTheDocument() - }) - - it("does not show visibility toggle when visibility is missing", () => { - render() - - fireEvent.mouseEnter(screen.getByTestId("gallery-thumbnail")) - expect(screen.queryByTestId("visibility-toggle")).not.toBeInTheDocument() - }) }) diff --git a/components/studio/gallery/gallery-thumbnail.tsx b/components/studio/gallery/gallery-thumbnail.tsx index ef18991..253b72c 100644 --- a/components/studio/gallery/gallery-thumbnail.tsx +++ b/components/studio/gallery/gallery-thumbnail.tsx @@ -1,21 +1,20 @@ "use client"; /** - * GalleryThumbnail - Individual image thumbnail with hover actions + * GalleryThumbnail - Individual image thumbnail for gallery display * Follows SRP: Only manages single thumbnail display and interactions * * Performance: Wrapped in React.memo() to prevent unnecessary re-renders * when parent components re-render but thumbnail props haven't changed. + * + * Selection: When in selection mode (showCheckbox=true), clicking anywhere + * on the thumbnail toggles selection. Users can also use the bulk actions + * menu for operations like copy, download, delete, etc. */ -import { VisibilityToggle } from "@/components/gallery/visibility-toggle"; -import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Id } from "@/convex/_generated/dataModel"; import { cn } from "@/lib/utils"; -import { Copy, Download, Trash2 } from "lucide-react"; +import { Check } from "lucide-react"; import Image from "next/image"; import * as React from "react"; @@ -38,17 +37,11 @@ export interface GalleryThumbnailProps { isActive?: boolean; /** Whether this image is checked for bulk operations */ isChecked?: boolean; - /** Callback when thumbnail is clicked */ + /** Callback when thumbnail is clicked (used for viewing image in canvas) */ onClick?: () => void; - /** Callback to remove image */ - onRemove?: () => void; - /** Callback to copy image URL */ - onCopy?: () => void; - /** Callback to download image */ - onDownload?: () => void; /** Callback when checked state changes */ onCheckedChange?: (checked: boolean) => void; - /** Whether to show selection checkbox */ + /** Whether selection mode is active - clicking toggles selection */ showCheckbox?: boolean; /** Size variant */ size?: "sm" | "md" | "lg"; @@ -71,9 +64,6 @@ export const GalleryThumbnail = React.memo(function GalleryThumbnail({ isActive = false, isChecked = false, onClick, - onRemove, - onCopy, - onDownload, onCheckedChange, showCheckbox = false, size = "md", @@ -86,16 +76,29 @@ export const GalleryThumbnail = React.memo(function GalleryThumbnail({ setIsLoaded(false); }, [image.url]); + // Handle click - toggle selection in selection mode, otherwise call onClick + const handleClick = React.useCallback(() => { + if (showCheckbox) { + // In selection mode, clicking anywhere toggles selection + onCheckedChange?.(!isChecked); + } else { + // Normal mode - call the onClick handler (view in canvas) + onClick?.(); + } + }, [showCheckbox, onClick, onCheckedChange, isChecked]); + return ( {/* Image */} @@ -112,114 +115,25 @@ export const GalleryThumbnail = React.memo(function GalleryThumbnail({ unoptimized /> - {/* Hover Overlay */} -
- {/* Quick Actions */} -
- {onCopy && ( - - - - - Copy URL - - )} - - {onDownload && ( - - - - - Download - - )} - - {onRemove && ( - - - - - Remove - - )} - - {image._id && image.visibility && ( - - -
e.stopPropagation()}> - } - currentVisibility={image.visibility as "public" | "unlisted"} - /> -
-
- - {image.visibility === "public" ? "Visible to public" : "Private image"} - -
- )} -
-
- - {/* Selection Checkbox */} + {/* Selection Indicator - shown when in selection mode */} {showCheckbox && (
- { - onCheckedChange?.(checked as boolean); - }} - onClick={(e) => e.stopPropagation()} - className="bg-background/80 border-white/50" - data-testid="thumbnail-checkbox" - /> + {isChecked && }
)} {/* Active Indicator */} - {isActive && ( + {isActive && !showCheckbox && (
)} diff --git a/components/studio/gallery/image-gallery.test.tsx b/components/studio/gallery/image-gallery.test.tsx index ae6e131..3ab326c 100644 --- a/components/studio/gallery/image-gallery.test.tsx +++ b/components/studio/gallery/image-gallery.test.tsx @@ -221,7 +221,7 @@ describe("ImageGallery", () => { }) describe("bulk actions dropdown", () => { - it("shows bulk actions dropdown when items are selected", async () => { + it("shows bulk actions dropdown in selection mode", async () => { render( { ) expect(screen.getByTestId("bulk-actions-menu")).toBeInTheDocument() - expect(screen.getByText("Actions (2)")).toBeInTheDocument() + expect(screen.getByText("Actions")).toBeInTheDocument() }) - it("does not show bulk actions dropdown when no items selected", () => { + it("disables bulk actions dropdown when no items selected", () => { render( { /> ) - expect(screen.queryByTestId("bulk-actions-menu")).not.toBeInTheDocument() + expect(screen.getByTestId("bulk-actions-menu")).toBeInTheDocument() + expect(screen.getByTestId("bulk-actions-menu")).toBeDisabled() }) it("calls onDeleteSelected when delete option clicked", async () => { diff --git a/components/studio/gallery/image-gallery.tsx b/components/studio/gallery/image-gallery.tsx index d5095f2..ed11210 100644 --- a/components/studio/gallery/image-gallery.tsx +++ b/components/studio/gallery/image-gallery.tsx @@ -9,6 +9,7 @@ */ import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { DropdownMenu, DropdownMenuContent, @@ -19,7 +20,7 @@ import { import { ScrollArea } from "@/components/ui/scroll-area" import { cn } from "@/lib/utils" import { useVirtualizer } from "@tanstack/react-virtual" -import { CheckSquare, Eye, EyeOff, ImageOff, Loader2, MoreHorizontal, Square, Trash2 } from "lucide-react" +import { Eye, EyeOff, ImageOff, Loader2, MoreHorizontal, Trash2 } from "lucide-react" import * as React from "react" import { GalleryThumbnail } from "./gallery-thumbnail" @@ -47,9 +48,6 @@ interface ThumbnailItemProps { isActive: boolean isChecked: boolean onSelect: (image: ThumbnailData) => void - onRemove?: (id: string) => void - onCopy?: (image: ThumbnailData) => void - onDownload?: (image: ThumbnailData) => void onCheckedChange: (id: string, checked: boolean) => void showCheckbox: boolean size: "sm" | "md" | "lg" @@ -60,9 +58,6 @@ const ThumbnailItem = React.memo(function ThumbnailItem({ isActive, isChecked, onSelect, - onRemove, - onCopy, - onDownload, onCheckedChange, showCheckbox, size, @@ -72,18 +67,6 @@ const ThumbnailItem = React.memo(function ThumbnailItem({ onSelect(image) }, [onSelect, image]) - const handleRemove = React.useMemo(() => - onRemove ? () => onRemove(image.id) : undefined, - [onRemove, image.id]) - - const handleCopy = React.useMemo(() => - onCopy ? () => onCopy(image) : undefined, - [onCopy, image]) - - const handleDownload = React.useMemo(() => - onDownload ? () => onDownload(image) : undefined, - [onDownload, image]) - const handleChecked = React.useCallback((checked: boolean) => { onCheckedChange(image.id, checked) }, [onCheckedChange, image.id]) @@ -94,9 +77,6 @@ const ThumbnailItem = React.memo(function ThumbnailItem({ isActive={isActive} isChecked={isChecked} onClick={handleClick} - onRemove={handleRemove} - onCopy={handleCopy} - onDownload={handleDownload} onCheckedChange={handleChecked} showCheckbox={showCheckbox} size={size} @@ -133,9 +113,6 @@ interface VirtualizedGalleryGridProps { selectionMode: boolean thumbnailSize: "sm" | "md" | "lg" onSelect: (image: ThumbnailData) => void - onRemove?: (id: string) => void - onCopy?: (image: ThumbnailData) => void - onDownload?: (image: ThumbnailData) => void onCheckedChange: (id: string, checked: boolean) => void } @@ -146,9 +123,6 @@ const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid({ selectionMode, thumbnailSize, onSelect, - onRemove, - onCopy, - onDownload, onCheckedChange, }: VirtualizedGalleryGridProps) { const parentRef = React.useRef(null) @@ -209,9 +183,6 @@ const VirtualizedGalleryGrid = React.memo(function VirtualizedGalleryGrid({ isActive={activeImageId === image.id} isChecked={selectedIds.has(image.id)} onSelect={onSelect} - onRemove={onRemove} - onCopy={onCopy} - onDownload={onDownload} onCheckedChange={onCheckedChange} showCheckbox={selectionMode} size={thumbnailSize} @@ -231,14 +202,8 @@ export interface ImageGalleryProps { images: ThumbnailData[] /** Currently active/selected image ID */ activeImageId?: string - /** Callback when an image is selected */ + /** Callback when an image is selected (clicked to view in canvas) */ onSelectImage?: (image: ThumbnailData) => void - /** Callback when an image is removed */ - onRemoveImage?: (id: string) => void - /** Callback to download an image */ - onDownloadImage?: (image: ThumbnailData) => void - /** Callback to copy image URL */ - onCopyImageUrl?: (image: ThumbnailData) => void /** Whether bulk selection mode is enabled */ selectionMode?: boolean /** Set of selected image IDs */ @@ -277,9 +242,6 @@ export const ImageGallery = React.memo(function ImageGallery({ images, activeImageId, onSelectImage, - onRemoveImage, - onDownloadImage, - onCopyImageUrl, selectionMode = false, selectedIds = new Set(), onSelectionChange, @@ -326,23 +288,11 @@ export const ImageGallery = React.memo(function ImageGallery({ onSelectionChange?.(new Set()) }, [onSelectionChange]) - // Memoized handlers for individual thumbnails - avoid creating new functions in the map + // Memoized handler for individual thumbnails - avoid creating new functions in the map const handleImageClick = React.useCallback((image: ThumbnailData) => { onSelectImage?.(image) }, [onSelectImage]) - const handleImageRemove = React.useCallback((id: string) => { - onRemoveImage?.(id) - }, [onRemoveImage]) - - const handleImageCopy = React.useCallback((image: ThumbnailData) => { - onCopyImageUrl?.(image) - }, [onCopyImageUrl]) - - const handleImageDownload = React.useCallback((image: ThumbnailData) => { - onDownloadImage?.(image) - }, [onDownloadImage]) - // Loading state component - displayed inline within the gallery const loadingState = (
History ({images.length}) -
+
{selectionMode ? ( <> - - {selectedIds.size > 0 && ( - - - + + + {onMakeSelectedPublic && ( + + + Make Public + + )} + {onMakeSelectedPrivate && ( + + + Make Private + + )} + {(onMakeSelectedPublic || onMakeSelectedPrivate) && onDeleteSelected && ( + + )} + {onDeleteSelected && ( + - - Actions ({selectedIds.size}) - - - - {onMakeSelectedPublic && ( - - - Make Public - - )} - {onMakeSelectedPrivate && ( - - - Make Private - - )} - {(onMakeSelectedPublic || onMakeSelectedPrivate) && onDeleteSelected && ( - - )} - {onDeleteSelected && ( - - - Delete Selected - - )} - - - )} + + Delete Selected + + )} + + ) : null} {onToggleSelectionMode && ( @@ -497,9 +455,6 @@ export const ImageGallery = React.memo(function ImageGallery({ selectionMode={selectionMode} thumbnailSize={thumbnailSize} onSelect={handleImageClick} - onRemove={onRemoveImage ? handleImageRemove : undefined} - onCopy={onCopyImageUrl ? handleImageCopy : undefined} - onDownload={onDownloadImage ? handleImageDownload : undefined} onCheckedChange={handleCheckedChange} /> {onLoadMore && ( @@ -543,9 +498,6 @@ export const ImageGallery = React.memo(function ImageGallery({ isActive={activeImageId === image.id} isChecked={deferredSelectedIds.has(image.id)} onSelect={handleImageClick} - onRemove={onRemoveImage ? handleImageRemove : undefined} - onCopy={onCopyImageUrl ? handleImageCopy : undefined} - onDownload={onDownloadImage ? handleImageDownload : undefined} onCheckedChange={handleCheckedChange} showCheckbox={selectionMode} size={thumbnailSize} diff --git a/components/studio/gallery/persistent-image-gallery.test.tsx b/components/studio/gallery/persistent-image-gallery.test.tsx index 6e43a75..d030228 100644 --- a/components/studio/gallery/persistent-image-gallery.test.tsx +++ b/components/studio/gallery/persistent-image-gallery.test.tsx @@ -150,10 +150,10 @@ describe("PersistentImageGallery", () => { // Enter selection mode await user.click(screen.getByTestId("toggle-selection")) - // Select items by clicking checkboxes - const checkboxes = screen.getAllByTestId("thumbnail-checkbox") - await user.click(checkboxes[0]) - await user.click(checkboxes[1]) + // Select items by clicking thumbnails (whole card toggles selection in selection mode) + const thumbnails = screen.getAllByTestId("gallery-thumbnail") + await user.click(thumbnails[0]) + await user.click(thumbnails[1]) // Open bulk actions menu await user.click(screen.getByTestId("bulk-actions-menu")) @@ -177,9 +177,9 @@ describe("PersistentImageGallery", () => { // Enter selection mode await user.click(screen.getByTestId("toggle-selection")) - // Select first item - const checkboxes = screen.getAllByTestId("thumbnail-checkbox") - await user.click(checkboxes[0]) + // Select first item by clicking thumbnail + const thumbnails = screen.getAllByTestId("gallery-thumbnail") + await user.click(thumbnails[0]) // Open bulk actions menu await user.click(screen.getByTestId("bulk-actions-menu")) @@ -203,12 +203,12 @@ describe("PersistentImageGallery", () => { // Enter selection mode await user.click(screen.getByTestId("toggle-selection")) - // Select first item - const checkboxes = screen.getAllByTestId("thumbnail-checkbox") - await user.click(checkboxes[0]) + // Select first item by clicking thumbnail + const thumbnails = screen.getAllByTestId("gallery-thumbnail") + await user.click(thumbnails[0]) - // Verify item is selected - expect(screen.getByText("Actions (1)")).toBeInTheDocument() + // Verify item is selected (label shows selection count) + expect(screen.getByText("1 selected")).toBeInTheDocument() // Open bulk actions menu await user.click(screen.getByTestId("bulk-actions-menu")) @@ -218,11 +218,11 @@ describe("PersistentImageGallery", () => { // After action, selection should be cleared and mode exited await waitFor(() => { - expect(screen.queryByText("Actions")).not.toBeInTheDocument() + expect(screen.queryByText("1 selected")).not.toBeInTheDocument() }) }) - it("does not show bulk actions menu when no items selected", async () => { + it("disables bulk actions menu when no items selected", async () => { const user = userEvent.setup() render() @@ -230,8 +230,9 @@ describe("PersistentImageGallery", () => { // Enter selection mode await user.click(screen.getByTestId("toggle-selection")) - // No bulk actions menu should appear when nothing is selected - expect(screen.queryByTestId("bulk-actions-menu")).not.toBeInTheDocument() + // Bulk actions menu should be present but disabled when nothing is selected + expect(screen.getByTestId("bulk-actions-menu")).toBeInTheDocument() + expect(screen.getByTestId("bulk-actions-menu")).toBeDisabled() }) it("handles mutation error gracefully", async () => { @@ -244,9 +245,9 @@ describe("PersistentImageGallery", () => { // Enter selection mode await user.click(screen.getByTestId("toggle-selection")) - // Select first item - const checkboxes = screen.getAllByTestId("thumbnail-checkbox") - await user.click(checkboxes[0]) + // Select first item by clicking thumbnail + const thumbnails = screen.getAllByTestId("gallery-thumbnail") + await user.click(thumbnails[0]) // Open bulk actions menu await user.click(screen.getByTestId("bulk-actions-menu")) diff --git a/components/studio/layout/studio-shell.tsx b/components/studio/layout/studio-shell.tsx index f6efcd6..39e365a 100644 --- a/components/studio/layout/studio-shell.tsx +++ b/components/studio/layout/studio-shell.tsx @@ -56,7 +56,8 @@ import { useSubscriptionStatus } from "@/hooks/use-subscription-status" import { getModelSupportsNegativePrompt } from "@/lib/config/models" import { isTrialExpiredError, showAuthRequiredToast, showErrorToast } from "@/lib/errors" import { isLocalhost } from "@/lib/utils" -import type { GeneratedImage, ImageGenerationParams } from "@/types/pollinations" +import type { ImageGenerationParams } from "@/types/pollinations" +import type { ThumbnailData } from "@/components/studio/gallery/image-gallery" import { useConvexAuth } from "convex/react" import { useSearchParams } from "next/navigation" import * as React from "react" @@ -242,7 +243,7 @@ export function StudioShell({ defaultLayout }: StudioShellProps) { // ======================================== // Gallery Image Selection Handler // ======================================== - const handleSelectGalleryImage = React.useCallback((image: GeneratedImage) => { + const handleSelectGalleryImage = React.useCallback((image: ThumbnailData) => { studioUI.openLightbox(image) }, [studioUI]) @@ -375,7 +376,6 @@ export function StudioShell({ defaultLayout }: StudioShellProps) { ) diff --git a/components/studio/upgrade-modal.tsx b/components/studio/upgrade-modal.tsx index cc5d224..d2bf038 100644 --- a/components/studio/upgrade-modal.tsx +++ b/components/studio/upgrade-modal.tsx @@ -35,11 +35,15 @@ interface UpgradeModalProps { } const proFeatures = [ - { icon: Images, label: "900", description: "images per month" }, + { icon: Images, label: "900", description: "NanoBanana images/month" }, { icon: Palette, label: "10+", description: "AI models included" }, { icon: RefreshCw, label: "Daily", description: "quota refresh" }, ] +/** + * Modal dialog for upgrading to Pro subscription. + * Displayed when trial limits are reached or user explicitly requests upgrade. + */ export function UpgradeModal({ isOpen, onClose }: UpgradeModalProps) { const [isLoading, setIsLoading] = React.useState(false) const createCheckout = useAction(api.stripe.createSubscriptionCheckout) diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx index cb0b07b..e6db3f2 100644 --- a/components/ui/checkbox.tsx +++ b/components/ui/checkbox.tsx @@ -2,7 +2,7 @@ import * as React from "react" import * as CheckboxPrimitive from "@radix-ui/react-checkbox" -import { CheckIcon } from "lucide-react" +import { CheckIcon, MinusIcon } from "lucide-react" import { cn } from "@/lib/utils" @@ -14,7 +14,7 @@ function Checkbox({ - + {props.checked === "indeterminate" ? ( + + ) : ( + + )} ) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index daf4038..7cda2ab 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -10,6 +10,7 @@ import type * as batchGeneration from "../batchGeneration.js"; import type * as batchProcessor from "../batchProcessor.js"; +import type * as crons from "../crons.js"; import type * as favorites from "../favorites.js"; import type * as follows from "../follows.js"; import type * as generatedImages from "../generatedImages.js"; @@ -21,6 +22,7 @@ import type * as lib_r2 from "../lib/r2.js"; import type * as lib_retry from "../lib/retry.js"; import type * as lib_subscription from "../lib/subscription.js"; import type * as promptLibrary from "../promptLibrary.js"; +import type * as rateLimits from "../rateLimits.js"; import type * as referenceImages from "../referenceImages.js"; import type * as singleGeneration from "../singleGeneration.js"; import type * as singleGenerationProcessor from "../singleGenerationProcessor.js"; @@ -37,6 +39,7 @@ import type { declare const fullApi: ApiFromModules<{ batchGeneration: typeof batchGeneration; batchProcessor: typeof batchProcessor; + crons: typeof crons; favorites: typeof favorites; follows: typeof follows; generatedImages: typeof generatedImages; @@ -48,6 +51,7 @@ declare const fullApi: ApiFromModules<{ "lib/retry": typeof lib_retry; "lib/subscription": typeof lib_subscription; promptLibrary: typeof promptLibrary; + rateLimits: typeof rateLimits; referenceImages: typeof referenceImages; singleGeneration: typeof singleGeneration; singleGenerationProcessor: typeof singleGenerationProcessor; diff --git a/convex/batchProcessor.ts b/convex/batchProcessor.ts index 779f4d9..7c8c161 100644 --- a/convex/batchProcessor.ts +++ b/convex/batchProcessor.ts @@ -134,9 +134,11 @@ export const processBatchItem = internalAction({ } try { - // Generate a unique seed for each image in the batch - const seed = batchJob.generationParams.seed ?? Math.floor(Math.random() * 2147483647) - + // Pollinations API only accepts seeds up to int32 max (2147483647) + const INT32_MAX = 2147483647 + const rawSeed = batchJob.generationParams.seed ?? Math.floor(Math.random() * INT32_MAX) + const seed = Math.min(rawSeed, INT32_MAX) + // Build the generation URL const generationUrl = buildPollinationsUrl({ prompt: batchJob.generationParams.prompt, @@ -151,7 +153,8 @@ export const processBatchItem = internalAction({ image: batchJob.generationParams.image, }) - console.log(`${logger} Calling Pollinations: ${generationUrl}`) + // Log generation request without prompt (which may contain PII) + console.log(`${logger} Generating with model=${batchJob.generationParams.model}, size=${batchJob.generationParams.width}x${batchJob.generationParams.height}, seed=${seed}`) // Call Pollinations API with retry logic const result = await fetchWithRetry( @@ -188,7 +191,7 @@ export const processBatchItem = internalAction({ // Upload to R2 const r2Key = generateR2Key(batchJob.ownerId, contentType) console.log(`${logger} Uploading to R2: ${r2Key}`) - + const uploadResult = await uploadToR2(imageBuffer, r2Key, contentType) console.log(`${logger} Upload complete: ${uploadResult.url}`) diff --git a/convex/crons.ts b/convex/crons.ts new file mode 100644 index 0000000..4406bf1 --- /dev/null +++ b/convex/crons.ts @@ -0,0 +1,12 @@ +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; + +const crons = cronJobs(); + +crons.interval( + "cleanup expired rate limits", + { hours: 1 }, + internal.rateLimits.cleanupExpiredLimits, +); + +export default crons; diff --git a/convex/lib/r2.test.ts b/convex/lib/r2.test.ts index 2d85341..ced9a9b 100644 --- a/convex/lib/r2.test.ts +++ b/convex/lib/r2.test.ts @@ -4,12 +4,15 @@ import { describe, it, expect } from "vitest" import { generateR2Key } from "./r2" +import crypto from "crypto" describe("r2 utilities", () => { describe("generateR2Key", () => { it("generates key with correct format", () => { - const key = generateR2Key("user123", "image/jpeg") - expect(key).toMatch(/^generated\/user123\/\d+-[a-z0-9]+\.jpeg$/) + const userId = "user123" + const key = generateR2Key(userId, "image/jpeg") + // Expect hash (hex) and UUID (dashes allowed) + expect(key).toMatch(/^generated\/[a-f0-9]{64}\/\d+-[0-9a-f-]{36}\.jpeg$/) }) it("extracts extension from content type", () => { @@ -32,9 +35,13 @@ describe("r2 utilities", () => { expect(keys.size).toBe(100) }) - it("includes user ID in path", () => { - const key = generateR2Key("clerk_12345", "image/jpeg") - expect(key).toContain("clerk_12345") + it("does NOT include raw user ID in path but includes hash", () => { + const userId = "clerk_12345" + const key = generateR2Key(userId, "image/jpeg") + expect(key).not.toContain(userId) + + const expectedHash = crypto.createHash("sha256").update(userId).digest("hex") + expect(key).toContain(expectedHash) }) }) }) diff --git a/convex/lib/r2.ts b/convex/lib/r2.ts index b1d0aa6..8cef1ff 100644 --- a/convex/lib/r2.ts +++ b/convex/lib/r2.ts @@ -8,6 +8,7 @@ */ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3" +import crypto from "crypto" // ============================================================ // Types @@ -30,13 +31,14 @@ export interface R2UploadResult { * * @param userId - Owner's user ID (Clerk subject) * @param contentType - MIME type of the image - * @returns Unique object key in the format: generated/{userId}/{timestamp}-{randomId}.{ext} + * @returns Unique object key in the format: generated/{hash(userId)}/{timestamp}-{uuid}.{ext} */ export function generateR2Key(userId: string, contentType: string): string { const ext = contentType.split("/")[1] || "jpg" const timestamp = Date.now() - const randomId = Math.random().toString(36).substring(2, 10) - return `generated/${userId}/${timestamp}-${randomId}.${ext}` + const randomId = crypto.randomUUID() + const userHash = crypto.createHash("sha256").update(userId).digest("hex") + return `generated/${userHash}/${timestamp}-${randomId}.${ext}` } // ============================================================ diff --git a/convex/rateLimits.test.ts b/convex/rateLimits.test.ts new file mode 100644 index 0000000..cb1f99e --- /dev/null +++ b/convex/rateLimits.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for the Rate Limiting Module + * + * Tests the sliding window rate limiting algorithm and cleanup functionality. + */ +import { describe, expect, it } from "vitest" +import { RATE_LIMIT_CONFIG } from "./rateLimits" + +describe("RATE_LIMIT_CONFIG", () => { + it("should have enhance-prompt configuration with correct limits", () => { + expect(RATE_LIMIT_CONFIG["enhance-prompt"]).toEqual({ + maxRequests: 10, + windowMs: 60 * 1000, // 1 minute + }) + }) + + it("should have suggestions configuration with correct limits", () => { + expect(RATE_LIMIT_CONFIG["suggestions"]).toEqual({ + maxRequests: 20, + windowMs: 60 * 1000, // 1 minute + }) + }) +}) + +/** + * Note: Integration tests for checkRateLimit, getRateLimitStatus, and cleanupExpiredLimits + * require a Convex test environment with database access. + * + * The following tests document expected behavior and can be used as a reference + * for E2E testing or when Convex test utilities are available. + */ +describe("Rate Limiting Behavior (Documentation)", () => { + describe("checkRateLimit", () => { + it("should allow first request and return remaining quota", () => { + // Expected behavior: + // - First request creates a new record with count=1 + // - Returns { allowed: true, remaining: maxRequests - 1, resetAt: now + windowMs } + expect(true).toBe(true) + }) + + it("should increment counter for subsequent requests within window", () => { + // Expected behavior: + // - Each request increments count + // - remaining decreases by 1 each time + // - resetAt stays the same within the window + expect(true).toBe(true) + }) + + it("should deny request when limit is exceeded", () => { + // Expected behavior: + // - When count >= maxRequests, returns { allowed: false, remaining: 0, resetAt } + // - No increment to counter + expect(true).toBe(true) + }) + + it("should reset counter when window expires", () => { + // Expected behavior: + // - After windowMs passes, counter resets to 1 + // - Returns { allowed: true, remaining: maxRequests - 1, resetAt: new window } + expect(true).toBe(true) + }) + + it("should track limits independently per user and endpoint", () => { + // Expected behavior: + // - User A hitting enhance-prompt doesn't affect User B + // - User A hitting enhance-prompt doesn't affect their suggestions limit + expect(true).toBe(true) + }) + }) + + describe("getRateLimitStatus", () => { + it("should return full quota when no record exists", () => { + // Expected behavior: + // - Returns { remaining: maxRequests, resetAt: now + windowMs } + expect(true).toBe(true) + }) + + it("should return current remaining count without incrementing", () => { + // Expected behavior: + // - Returns current remaining without modifying the database + // - Multiple calls return the same value + expect(true).toBe(true) + }) + + it("should return full quota when window has expired", () => { + // Expected behavior: + // - Even if a record exists, expired window = full quota + expect(true).toBe(true) + }) + }) + + describe("cleanupExpiredLimits", () => { + it("should delete records older than 2x the max window", () => { + // Expected behavior: + // - Records with windowStart < now - (maxWindowMs * 2) are deleted + // - Returns { deleted: count } + expect(true).toBe(true) + }) + + it("should not delete recent records", () => { + // Expected behavior: + // - Active records within the buffer are preserved + expect(true).toBe(true) + }) + }) +}) diff --git a/convex/rateLimits.ts b/convex/rateLimits.ts new file mode 100644 index 0000000..d9b0cb0 --- /dev/null +++ b/convex/rateLimits.ts @@ -0,0 +1,185 @@ +/** + * Convex Rate Limiting Module + * + * Implements a sliding window rate limiting algorithm using Convex's + * database for storage. This avoids external dependencies like Redis + * while providing reliable rate limiting. + */ +import { v } from "convex/values" +import { internalMutation, mutation, query } from "./_generated/server" + +/** + * Rate limit configuration for different endpoints. + * Each entry defines the maximum requests allowed within a time window. + */ +export const RATE_LIMIT_CONFIG = { + "enhance-prompt": { + maxRequests: 10, + windowMs: 60 * 1000, // 1 minute + }, + "suggestions": { + maxRequests: 20, + windowMs: 60 * 1000, // 1 minute + }, +} as const + +export type RateLimitEndpoint = keyof typeof RATE_LIMIT_CONFIG + +/** + * Check and consume a rate limit for a given user and endpoint. + * Uses a sliding window algorithm to track usage. + * + * @param userId - The Clerk user ID + * @param endpoint - The endpoint being rate limited + * @returns Object with allowed (boolean), remaining requests, and reset time + */ +export const checkRateLimit = mutation({ + args: { + userId: v.string(), + endpoint: v.union(v.literal("enhance-prompt"), v.literal("suggestions")), + }, + returns: v.object({ + allowed: v.boolean(), + remaining: v.number(), + resetAt: v.number(), + }), + handler: async (ctx, args) => { + const { userId, endpoint } = args + const config = RATE_LIMIT_CONFIG[endpoint] + const key = `${endpoint}:${userId}` + const now = Date.now() + const windowStart = now - config.windowMs + + // Get existing rate limit record + const existing = await ctx.db + .query("rateLimits") + .withIndex("by_key", (q) => q.eq("key", key)) + .unique() + + if (!existing) { + // First request - create new record + await ctx.db.insert("rateLimits", { + key, + count: 1, + windowStart: now, + }) + return { + allowed: true, + remaining: config.maxRequests - 1, + resetAt: now + config.windowMs, + } + } + + // Check if window has expired + if (existing.windowStart < windowStart) { + // Window expired - reset counter + await ctx.db.patch(existing._id, { + count: 1, + windowStart: now, + }) + return { + allowed: true, + remaining: config.maxRequests - 1, + resetAt: now + config.windowMs, + } + } + + // Within current window - check limit + if (existing.count >= config.maxRequests) { + // Rate limit exceeded + const resetAt = existing.windowStart + config.windowMs + return { + allowed: false, + remaining: 0, + resetAt, + } + } + + // Increment counter + const newCount = existing.count + 1 + await ctx.db.patch(existing._id, { + count: newCount, + }) + + return { + allowed: true, + remaining: config.maxRequests - newCount, + resetAt: existing.windowStart + config.windowMs, + } + }, +}) + +/** + * Get current rate limit status without consuming a request. + * Useful for client-side display of remaining requests. + */ +export const getRateLimitStatus = query({ + args: { + userId: v.string(), + endpoint: v.union(v.literal("enhance-prompt"), v.literal("suggestions")), + }, + returns: v.object({ + remaining: v.number(), + resetAt: v.number(), + }), + handler: async (ctx, args) => { + const { userId, endpoint } = args + const config = RATE_LIMIT_CONFIG[endpoint] + const key = `${endpoint}:${userId}` + const now = Date.now() + const windowStart = now - config.windowMs + + const existing = await ctx.db + .query("rateLimits") + .withIndex("by_key", (q) => q.eq("key", key)) + .unique() + + if (!existing || existing.windowStart < windowStart) { + // No record or expired window - full quota available + return { + remaining: config.maxRequests, + resetAt: now + config.windowMs, + } + } + + const remaining = Math.max(0, config.maxRequests - existing.count) + return { + remaining, + resetAt: existing.windowStart + config.windowMs, + } + }, +}) + +/** + * Clean up expired rate limit records. + * Should be called periodically (e.g., via a cron job) to prevent table bloat. + */ +export const cleanupExpiredLimits = internalMutation({ + args: {}, + returns: v.object({ + deleted: v.number(), + }), + handler: async (ctx) => { + // Use the longest window from our config + const maxWindowMs = Math.max( + ...Object.values(RATE_LIMIT_CONFIG).map((c) => c.windowMs) + ) + const cutoff = Date.now() - maxWindowMs * 2 // Keep some buffer + + // Query all records and filter/delete expired ones + // Query records older than cutoff using the specific index + const expiredRecords = await ctx.db + .query("rateLimits") + .withIndex("by_windowStart", (q) => q.lt("windowStart", cutoff)) + .collect() + + let deleted = 0 + + for (const record of expiredRecords) { + await ctx.db.delete(record._id) + deleted++ + } + + return { deleted } + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index 27bf517..0290bf3 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -269,4 +269,18 @@ export default defineSchema({ .index("by_user", ["userId", "createdAt"]) .index("by_prompt", ["promptId"]) .index("by_user_prompt", ["userId", "promptId"]), + + /** + * Rate limits table - tracks API rate limiting per user/endpoint + * Uses a sliding window algorithm to count requests within a time window. + */ + rateLimits: defineTable({ + /** Unique key combining endpoint and user ID, e.g. "enhance-prompt:user_123" */ + key: v.string(), + /** Number of requests made in the current window */ + count: v.number(), + /** Timestamp when the current window started */ + windowStart: v.number(), + }).index("by_key", ["key"]) + .index("by_windowStart", ["windowStart"]), }) diff --git a/convex/singleGenerationProcessor.ts b/convex/singleGenerationProcessor.ts index ec5fbad..e237eda 100644 --- a/convex/singleGenerationProcessor.ts +++ b/convex/singleGenerationProcessor.ts @@ -123,7 +123,10 @@ export const processGeneration = internalAction({ try { const params = generation.generationParams - const seed = params.seed ?? Math.floor(Math.random() * 2147483647) + // Pollinations API only accepts seeds up to int32 max (2147483647) + const INT32_MAX = 2147483647 + const rawSeed = params.seed ?? Math.floor(Math.random() * INT32_MAX) + const seed = Math.min(rawSeed, INT32_MAX) // Build the generation URL const generationUrl = buildPollinationsUrl({ diff --git a/hooks/use-image-lightbox.ts b/hooks/use-image-lightbox.ts index e898683..a0bdd46 100644 --- a/hooks/use-image-lightbox.ts +++ b/hooks/use-image-lightbox.ts @@ -4,7 +4,7 @@ import * as React from "react" export interface LightboxImage { url: string - prompt: string + prompt?: string params?: { model?: string width?: number @@ -31,9 +31,9 @@ export function useImageLightbox({ image, isOpen }: UseImageLightboxProps) { const [isZoomed, setIsZoomed] = React.useState(false) const [naturalSize, setNaturalSize] = React.useState({ width: 0, height: 0 }) const [renderedSize, setRenderedSize] = React.useState({ width: 0, height: 0 }) - + const scrollContainerRef = React.useRef(null) - + // Drag-to-scroll state const [isDragging, setIsDragging] = React.useState(false) const dragStart = React.useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 }) @@ -98,7 +98,7 @@ export function useImageLightbox({ image, isOpen }: UseImageLightboxProps) { // Only allow left click dragging if (e.button !== 0) return if (!isZoomed || !scrollContainerRef.current) return - + setIsDragging(true) hasDragged.current = false dragStart.current = { @@ -111,15 +111,15 @@ export function useImageLightbox({ image, isOpen }: UseImageLightboxProps) { const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging || !scrollContainerRef.current) return - + const dx = e.clientX - dragStart.current.x const dy = e.clientY - dragStart.current.y - + // Mark as dragged if moved more than 5px if (Math.abs(dx) > 5 || Math.abs(dy) > 5) { hasDragged.current = true } - + scrollContainerRef.current.scrollLeft = dragStart.current.scrollLeft - dx scrollContainerRef.current.scrollTop = dragStart.current.scrollTop - dy } diff --git a/hooks/use-random-seed.test.ts b/hooks/use-random-seed.test.ts index fc8a573..ec6c768 100644 --- a/hooks/use-random-seed.test.ts +++ b/hooks/use-random-seed.test.ts @@ -60,7 +60,7 @@ describe("useRandomSeed", () => { expect(result.current.isRandomMode(0)).toBe(false) expect(result.current.isRandomMode(12345)).toBe(false) - expect(result.current.isRandomMode(1844674407370955)).toBe(false) + expect(result.current.isRandomMode(2147483647)).toBe(false) }) it("RANDOM_SEED constant is -1", () => { @@ -75,43 +75,16 @@ describe("useRandomSeed", () => { expect(result.current.MIN_SEED).toBe(0) }) - it("MAX_SEED is 1844674407370955 for zimage", () => { + it("MAX_SEED is 2147483647 (int32 max) for all models", () => { const { result } = renderHook(() => useRandomSeed("zimage")) - expect(result.current.MAX_SEED).toBe(1844674407370955) - }) - }) - - describe("hook with Seedream model (int32 max)", () => { - it("MAX_SEED is 2147483647 for seedream", () => { - const { result } = renderHook(() => useRandomSeed("seedream")) - expect(result.current.MAX_SEED).toBe(2147483647) }) - - it("MAX_SEED is 2147483647 for seedream-pro", () => { - const { result } = renderHook(() => useRandomSeed("seedream-pro")) - - expect(result.current.MAX_SEED).toBe(2147483647) - }) - - it("generateSeed returns values within int32 range for seedream", () => { - const { result } = renderHook(() => useRandomSeed("seedream")) - - // Generate multiple seeds and verify they're all within int32 range - for (let i = 0; i < 20; i++) { - const seed = result.current.generateSeed() - expect(seed).toBeLessThanOrEqual(2147483647) - } - }) }) describe("getMaxSeedForModel utility", () => { - it("returns correct max seed for zimage", () => { - expect(getMaxSeedForModel("zimage")).toBe(1844674407370955) - }) - - it("returns int32 max for seedream", () => { + it("returns int32 max (2147483647) for all models", () => { + expect(getMaxSeedForModel("zimage")).toBe(2147483647) expect(getMaxSeedForModel("seedream")).toBe(2147483647) }) @@ -123,17 +96,9 @@ describe("useRandomSeed", () => { }) describe("standalone utilities", () => { - it("generateRandomSeed returns a valid integer for zimage", () => { + it("generateRandomSeed returns a valid integer within int32 range", () => { const seed = generateRandomSeed("zimage") - expect(Number.isInteger(seed)).toBe(true) - expect(seed).toBeGreaterThanOrEqual(0) - expect(seed).toBeLessThanOrEqual(1844674407370955) - }) - - it("generateRandomSeed returns value within int32 for seedream", () => { - const seed = generateRandomSeed("seedream") - expect(Number.isInteger(seed)).toBe(true) expect(seed).toBeGreaterThanOrEqual(0) expect(seed).toBeLessThanOrEqual(2147483647) diff --git a/hooks/use-studio-ui.ts b/hooks/use-studio-ui.ts index 3d35711..0819172 100644 --- a/hooks/use-studio-ui.ts +++ b/hooks/use-studio-ui.ts @@ -17,7 +17,7 @@ */ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts" -import type { GeneratedImage } from "@/types/pollinations" +import type { LightboxImage } from "@/hooks/use-image-lightbox" import * as React from "react" /** @@ -37,11 +37,11 @@ export interface UseStudioUIReturn { // Fullscreen/Lightbox state isFullscreen: boolean setIsFullscreen: React.Dispatch> - lightboxImage: GeneratedImage | null - setLightboxImage: React.Dispatch> + lightboxImage: LightboxImage | null + setLightboxImage: React.Dispatch> // Open lightbox with specific image - openLightbox: (image: GeneratedImage | null) => void + openLightbox: (image: LightboxImage | null) => void closeLightbox: () => void } @@ -75,7 +75,7 @@ export function useStudioUI(): UseStudioUIReturn { // Fullscreen/Lightbox State // ======================================== const [isFullscreen, setIsFullscreen] = React.useState(false) - const [lightboxImage, setLightboxImage] = React.useState(null) + const [lightboxImage, setLightboxImage] = React.useState(null) // ======================================== // Stable Toggle Callbacks @@ -91,7 +91,7 @@ export function useStudioUI(): UseStudioUIReturn { // ======================================== // Lightbox Handlers // ======================================== - const openLightbox = React.useCallback((image: GeneratedImage | null) => { + const openLightbox = React.useCallback((image: LightboxImage | null) => { setLightboxImage(image) setIsFullscreen(true) }, []) diff --git a/lib/config/api.config.ts b/lib/config/api.config.ts index 9a29a3c..f3ba373 100644 --- a/lib/config/api.config.ts +++ b/lib/config/api.config.ts @@ -18,6 +18,8 @@ const schemaDefaults = ImageGenerationParamsSchema.parse({ prompt: "placeholder" export const API_DEFAULTS = { model: schemaDefaults.model, quality: schemaDefaults.quality, + width: schemaDefaults.width, + height: schemaDefaults.height, enhance: schemaDefaults.enhance, safe: schemaDefaults.safe, private: schemaDefaults.private, @@ -42,7 +44,7 @@ export const API_CONSTRAINTS = { }, seed: { min: 0, - max: 1844674407370955, + max: 2147483647, // int32 max - Pollinations API limit }, guidanceScale: { min: 1, diff --git a/lib/config/models.ts b/lib/config/models.ts index 534c91a..06332d3 100644 --- a/lib/config/models.ts +++ b/lib/config/models.ts @@ -139,7 +139,7 @@ export const MODEL_REGISTRY: Record = { step: 32, defaultDimensions: { width: 1000, height: 1000 }, dimensionsEnabled: true, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: STANDARD_ASPECT_RATIOS, supportsNegativePrompt: false, @@ -160,7 +160,7 @@ export const MODEL_REGISTRY: Record = { step: 32, defaultDimensions: { width: 2048, height: 2048 }, dimensionsEnabled: true, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: ZIMAGE_ASPECT_RATIOS, supportsNegativePrompt: false, @@ -181,7 +181,7 @@ export const MODEL_REGISTRY: Record = { step: 64, defaultDimensions: { width: 768, height: 768 }, dimensionsEnabled: true, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: SDXLTURBO_ASPECT_RATIOS, supportsNegativePrompt: false, @@ -202,7 +202,7 @@ export const MODEL_REGISTRY: Record = { step: 1, defaultDimensions: { width: 1024, height: 1024 }, dimensionsEnabled: false, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: GPTIMAGE_ASPECT_RATIOS, supportsNegativePrompt: false, @@ -223,7 +223,7 @@ export const MODEL_REGISTRY: Record = { step: 1, defaultDimensions: { width: 1024, height: 1024 }, dimensionsEnabled: false, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: GPTIMAGE_LARGE_ASPECT_RATIOS, supportsNegativePrompt: false, @@ -286,7 +286,7 @@ export const MODEL_REGISTRY: Record = { step: 32, defaultDimensions: { width: 1024, height: 1024 }, dimensionsEnabled: true, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: STANDARD_ASPECT_RATIOS, supportsNegativePrompt: false, @@ -307,7 +307,7 @@ export const MODEL_REGISTRY: Record = { step: 32, defaultDimensions: { width: 1024, height: 1024 }, dimensionsEnabled: true, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: STANDARD_ASPECT_RATIOS, supportsNegativePrompt: false, @@ -331,7 +331,7 @@ export const MODEL_REGISTRY: Record = { step: 1, defaultDimensions: { width: 1920, height: 1080 }, dimensionsEnabled: false, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: VIDEO_ASPECT_RATIOS, supportsNegativePrompt: false, @@ -351,7 +351,7 @@ export const MODEL_REGISTRY: Record = { step: 1, defaultDimensions: { width: 1920, height: 1080 }, dimensionsEnabled: false, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: VIDEO_ASPECT_RATIOS, supportsNegativePrompt: false, @@ -372,7 +372,7 @@ export const MODEL_REGISTRY: Record = { step: 1, defaultDimensions: { width: 1920, height: 1080 }, dimensionsEnabled: false, - maxSeed: 1_844_674_407_370_955, + maxSeed: 2_147_483_647, // int32 max - Pollinations API limit }, aspectRatios: VIDEO_ASPECT_RATIOS, supportsNegativePrompt: false, diff --git a/lib/pollinations-api.ts b/lib/pollinations-api.ts index a3ae63c..73eb7c5 100644 --- a/lib/pollinations-api.ts +++ b/lib/pollinations-api.ts @@ -119,6 +119,11 @@ export class PollinationsAPI { return Math.round(clamped / step) * step } + /** + * Generates a random seed for reproducible generations. + * + * @returns A random integer between 0 and MAX_SEED. + */ static generateRandomSeed(): number { // Generate a random integer between 0 and the max seed value (inclusive) return Math.floor(Math.random() * (API_CONSTRAINTS.seed.max + 1)) diff --git a/lib/security/production-key-validation.test.ts b/lib/security/production-key-validation.test.ts new file mode 100644 index 0000000..7f63b10 --- /dev/null +++ b/lib/security/production-key-validation.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + * + * Tests for production key validation security checks + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" + +/** + * These tests verify the validation logic that would run + * in convex-client-provider.tsx for production key checks. + * + * The actual validation is inline in the provider, so we test + * the logic patterns here. + */ +describe("production key validation", () => { + const originalEnv = process.env + + beforeEach(() => { + vi.resetModules() + process.env = { ...originalEnv } + }) + + afterEach(() => { + process.env = originalEnv + }) + + describe("Clerk publishable key validation", () => { + it("detects test key prefix pk_test_", () => { + const key = "pk_test_abc123xyz" + expect(key.startsWith("pk_test_")).toBe(true) + }) + + it("accepts live key prefix pk_live_", () => { + const key = "pk_live_abc123xyz" + expect(key.startsWith("pk_test_")).toBe(false) + expect(key.startsWith("pk_live_")).toBe(true) + }) + + it("handles undefined key gracefully", () => { + const key: string | undefined = undefined + expect(key?.startsWith("pk_test_")).toBeFalsy() + }) + + it("handles empty string key", () => { + const key = "" + expect(key.startsWith("pk_test_")).toBe(false) + }) + }) + + describe("Convex URL validation", () => { + it("detects development instance patterns", () => { + const devUrl = "https://my-app-dev-abc123.convex.cloud" + expect(devUrl.includes(".convex.cloud") && devUrl.includes("-dev-")).toBe(true) + }) + + it("accepts production URL patterns", () => { + const prodUrl = "https://my-app-prod-abc123.convex.cloud" + expect(prodUrl.includes("-dev-")).toBe(false) + }) + + it("accepts URLs without dev indicator", () => { + const url = "https://my-app-abc123.convex.cloud" + expect(url.includes("-dev-")).toBe(false) + }) + }) + + describe("validation logic with Vercel environment detection", () => { + /** + * Simulates the validation logic from convex-client-provider.tsx + */ + function validateProductionKeys(options: { + nodeEnv: string + vercelEnv?: string + clerkKey?: string + convexUrl?: string + }): { errors: string[]; warnings: string[] } { + const errors: string[] = [] + const warnings: string[] = [] + const { nodeEnv, vercelEnv, clerkKey, convexUrl } = options + + const IS_VERCEL_PRODUCTION = vercelEnv === "production" + const IS_NODE_PRODUCTION = nodeEnv === "production" + + if (IS_VERCEL_PRODUCTION) { + // Strict validation for Vercel production + if (clerkKey?.startsWith("pk_test_")) { + errors.push( + "[Security Error] Test Clerk publishable key detected in production deployment." + ) + } + + if (convexUrl?.includes(".convex.cloud") && convexUrl.includes("-dev-")) { + warnings.push( + "[Security Warning] Convex URL appears to be a development instance." + ) + } + } else if (IS_NODE_PRODUCTION && !vercelEnv) { + // Local production build - warning only + if (clerkKey?.startsWith("pk_test_")) { + warnings.push( + "[Security Warning] Test Clerk publishable key detected in production build." + ) + } + } + + return { errors, warnings } + } + + it("throws error for test Clerk key in Vercel production", () => { + const result = validateProductionKeys({ + nodeEnv: "production", + vercelEnv: "production", + clerkKey: "pk_test_abc123", + convexUrl: "https://my-app.convex.cloud", + }) + + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toContain("Security Error") + expect(result.warnings).toHaveLength(0) + }) + + it("warns for dev Convex URL in Vercel production", () => { + const result = validateProductionKeys({ + nodeEnv: "production", + vercelEnv: "production", + clerkKey: "pk_live_abc123", + convexUrl: "https://my-app-dev-abc123.convex.cloud", + }) + + expect(result.errors).toHaveLength(0) + expect(result.warnings).toHaveLength(1) + expect(result.warnings[0]).toContain("Security Warning") + }) + + it("passes validation with valid production keys in Vercel production", () => { + const result = validateProductionKeys({ + nodeEnv: "production", + vercelEnv: "production", + clerkKey: "pk_live_abc123", + convexUrl: "https://my-app-prod-abc123.convex.cloud", + }) + + expect(result.errors).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + + it("allows test keys in Vercel preview environment", () => { + const result = validateProductionKeys({ + nodeEnv: "production", + vercelEnv: "preview", + clerkKey: "pk_test_abc123", + convexUrl: "https://my-app-dev-abc123.convex.cloud", + }) + + // Preview mode should not block or warn + expect(result.errors).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + + it("issues warning for test keys in local production build", () => { + const result = validateProductionKeys({ + nodeEnv: "production", + vercelEnv: undefined, // No VERCEL_ENV = local build + clerkKey: "pk_test_abc123", + convexUrl: "https://my-app.convex.cloud", + }) + + // Should warn but not error for local builds + expect(result.errors).toHaveLength(0) + expect(result.warnings).toHaveLength(1) + expect(result.warnings[0]).toContain("Security Warning") + }) + + it("skips validation in development mode", () => { + const result = validateProductionKeys({ + nodeEnv: "development", + clerkKey: "pk_test_abc123", + convexUrl: "https://my-app-dev-abc123.convex.cloud", + }) + + expect(result.errors).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + + it("handles missing keys in Vercel production", () => { + const result = validateProductionKeys({ + nodeEnv: "production", + vercelEnv: "production", + clerkKey: undefined, + convexUrl: undefined, + }) + + expect(result.errors).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + }) +}) diff --git a/lib/storage/r2-client.ts b/lib/storage/r2-client.ts index 52c4bf5..5d3cc7c 100644 --- a/lib/storage/r2-client.ts +++ b/lib/storage/r2-client.ts @@ -11,6 +11,7 @@ import { HeadObjectCommand, } from "@aws-sdk/client-s3" import { withRetry, isRetryableError } from "./retry" +import crypto from "crypto" // Validate required environment variables function getEnvVar(name: string): string { @@ -132,9 +133,10 @@ export function generateImageKey( ): string { const ext = contentType.split("/")[1] || "jpg" const timestamp = Date.now() - const randomId = Math.random().toString(36).substring(2, 10) + const randomId = crypto.randomUUID() + const userHash = crypto.createHash("sha256").update(userId).digest("hex") - return `${type}/${userId}/${timestamp}-${randomId}.${ext}` + return `${type}/${userHash}/${timestamp}-${randomId}.${ext}` } /** diff --git a/lib/utils.ts b/lib/utils.ts index 79917f4..84f91d8 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,13 @@ import { clsx, type ClassValue } from 'clsx' import { twMerge } from 'tailwind-merge' +/** + * Merges Tailwind CSS classes with clsx. + * This is the standard utility for conditional class merging in shadcn/ui. + * + * @param inputs - Class names or conditional class objects + * @returns Merged class string + */ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } diff --git a/next.config.mjs b/next.config.mjs index 3c01d9f..63da798 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,12 +1,49 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactCompiler: true, - typescript: { - ignoreBuildErrors: true, - }, images: { unoptimized: true, }, + + /** + * Security headers for all routes. + * These headers protect against common web vulnerabilities. + */ + async headers() { + return [ + { + // Apply to all routes + source: '/:path*', + headers: [ + { + // Prevent MIME-type sniffing + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + // Prevent clickjacking attacks by disallowing iframes + key: 'X-Frame-Options', + value: 'DENY', + }, + { + // Control referrer information sent with requests + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + { + // Restrict browser features that are not needed + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + { + // Enforce HTTPS with HSTS (2 years, include subdomains, preload eligible) + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + ], + }, + ]; + }, } export default nextConfig diff --git a/package.json b/package.json index 165eff5..e8e197c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "my-v0-project", "version": "0.1.0", "private": true, - "packageManager": "bun@1.3.3", + "packageManager": "bun@1.3.5", "scripts": { "build": "next build", "dev": "next dev", @@ -50,13 +50,13 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "10.7.7", "@react-three/fiber": "9.4.2", - "@stripe/stripe-js": "^8.6.0", - "@tanstack/react-query": "^5.90.12", - "@tanstack/react-virtual": "^3.13.14", + "@stripe/stripe-js": "^8.6.1", + "@tanstack/react-query": "^5.90.16", + "@tanstack/react-virtual": "^3.13.16", "@types/three": "^0.182.0", "@vercel/analytics": "1.6.1", "@vercel/speed-insights": "^1.3.1", - "ai": "^6.0.3", + "ai": "^6.0.11", "autoprefixer": "^10.4.23", "babel-plugin-react-compiler": "^1.0.0", "class-variance-authority": "^0.7.1", @@ -71,12 +71,12 @@ "expo-asset": "^12.0.12", "expo-file-system": "^19.0.21", "expo-gl": "^16.0.9", - "framer-motion": "^12.23.26", + "framer-motion": "^12.23.28", "input-otp": "^1.4.2", "leva": "0.10.1", "lucide-react": "^0.562.0", "maath": "0.10.8", - "next": "16.1.0", + "next": "16.1.1", "next-themes": "^0.4.6", "r3f-perf": "^7.2.3", "react": "19.2.3", @@ -84,7 +84,7 @@ "react-dom": "19.2.3", "react-hook-form": "^7.69.0", "react-native": "^0.83.1", - "react-resizable-panels": "^4.0.13", + "react-resizable-panels": "^4.0.16", "react-select": "^5.10.2", "react-zoom-pan-pinch": "^3.7.0", "recharts": "2.15.4", diff --git a/proxy.test.ts b/proxy.test.ts new file mode 100644 index 0000000..1604e8f --- /dev/null +++ b/proxy.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + * + * Tests for Proxy route protection configuration + * + * Note: These tests validate the route matching logic, using the actual + * matcher exported from proxy.ts. + */ +import { describe, it, expect } from "vitest" +import { NextRequest } from "next/server" +import { config, isProtectedRoute } from "./proxy" + +/** + * Helper to wrap pathname in a NextRequest for Clerk's matcher + */ +function createRequest(pathname: string) { + return new NextRequest(`https://pixelstream.app${pathname}`) +} + +describe("proxy route protection", () => { + describe("protected routes", () => { + it("protects /studio root path", () => { + expect(isProtectedRoute(createRequest("/studio"))).toBe(true) + }) + + it("protects /studio child paths", () => { + expect(isProtectedRoute(createRequest("/studio/something"))).toBe(true) + expect(isProtectedRoute(createRequest("/studio/deeply/nested/path"))).toBe(true) + }) + + it("protects /settings root path", () => { + expect(isProtectedRoute(createRequest("/settings"))).toBe(true) + }) + + it("protects /settings child paths", () => { + expect(isProtectedRoute(createRequest("/settings/profile"))).toBe(true) + expect(isProtectedRoute(createRequest("/settings/billing"))).toBe(true) + }) + + it("protects /history paths", () => { + expect(isProtectedRoute(createRequest("/history"))).toBe(true) + expect(isProtectedRoute(createRequest("/history/page/2"))).toBe(true) + }) + + it("protects /favorites paths", () => { + expect(isProtectedRoute(createRequest("/favorites"))).toBe(true) + expect(isProtectedRoute(createRequest("/favorites/collection"))).toBe(true) + }) + + it("protects /api/upload paths", () => { + expect(isProtectedRoute(createRequest("/api/upload"))).toBe(true) + expect(isProtectedRoute(createRequest("/api/upload/image"))).toBe(true) + }) + + it("protects /api/images/delete paths", () => { + expect(isProtectedRoute(createRequest("/api/images/delete"))).toBe(true) + expect(isProtectedRoute(createRequest("/api/images/delete/123"))).toBe(true) + }) + }) + + describe("public routes", () => { + it("allows home page", () => { + expect(isProtectedRoute(createRequest("/"))).toBe(false) + }) + + it("allows pricing page", () => { + expect(isProtectedRoute(createRequest("/pricing"))).toBe(false) + }) + + it("allows feed page", () => { + expect(isProtectedRoute(createRequest("/feed"))).toBe(false) + }) + + it("allows sign-in routes", () => { + expect(isProtectedRoute(createRequest("/sign-in"))).toBe(false) + expect(isProtectedRoute(createRequest("/sign-up"))).toBe(false) + }) + + it("allows public API routes", () => { + expect(isProtectedRoute(createRequest("/api/enhance-prompt"))).toBe(false) + expect(isProtectedRoute(createRequest("/api/suggestions"))).toBe(false) + }) + }) +}) + +describe("proxy matcher configuration", () => { + /** + * Check if a path should be processed by the middleware based on config.matcher. + * We use a robust regex construction that accurately reflects Next.js behavior + * by anchoring the patterns. + */ + function shouldProcessPath(pathname: string): boolean { + return config.matcher.some(pattern => { + const regex = new RegExp(`^${pattern}$`) + return regex.test(pathname) + }) + } + + it("processes regular page routes", () => { + expect(shouldProcessPath("/")).toBe(true) + expect(shouldProcessPath("/studio")).toBe(true) + expect(shouldProcessPath("/settings")).toBe(true) + expect(shouldProcessPath("/pricing")).toBe(true) + }) + + it("processes API and TRPC routes", () => { + expect(shouldProcessPath("/api/upload")).toBe(true) + expect(shouldProcessPath("/api/enhance-prompt")).toBe(true) + expect(shouldProcessPath("/trpc/someEndpoint")).toBe(true) + }) + + it("skips Next.js internal routes", () => { + expect(shouldProcessPath("/_next")).toBe(false) + expect(shouldProcessPath("/_next/static/chunks/main.js")).toBe(false) + expect(shouldProcessPath("/_next/data/development/index.json")).toBe(false) + }) + + it("skips static assets with common extensions", () => { + expect(shouldProcessPath("/favicon.ico")).toBe(false) + expect(shouldProcessPath("/styles.css")).toBe(false) + expect(shouldProcessPath("/script.js")).toBe(false) + expect(shouldProcessPath("/image.png")).toBe(false) + expect(shouldProcessPath("/photo.jpg")).toBe(false) + expect(shouldProcessPath("/icon.svg")).toBe(false) + expect(shouldProcessPath("/font.woff2")).toBe(false) + }) + + it("processes files that look like static but aren't in the exclusion list", () => { + // .json is NOT in the exclusion list (it was excluded from the js negative lookahead) + expect(shouldProcessPath("/api/data.json")).toBe(true) + // .txt is NOT in the exclusion list + expect(shouldProcessPath("/robots.txt")).toBe(true) + }) +}) diff --git a/proxy.ts b/proxy.ts index b6b7396..9ee3147 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,11 +1,34 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server" -const isProtectedRoute = createRouteMatcher(["/studio(.*)"]) +/** + * Route matcher for protected routes that require authentication. + * These routes will redirect to sign-in if accessed while unauthenticated. + */ +export const isProtectedRoute = createRouteMatcher([ + '/studio(.*)', + '/settings(.*)', + '/history(.*)', + '/favorites(.*)', + '/api/upload(.*)', + '/api/images/delete(.*)', +]) +/** + * Clerk middleware for authentication enforcement at the edge. + * + * This middleware runs before every request and: + * - Checks if the route is protected + * - Redirects unauthenticated users to sign-in for protected routes + * - Allows all other routes to pass through + */ export default clerkMiddleware(async (auth, req) => { if (isProtectedRoute(req)) await auth.protect() }) +/** + * Middleware configuration object. + * Defines the matcher patterns for routes where the middleware should execute. + */ export const config = { matcher: [ // Skip Next.js internals and all static files, unless found in search params @@ -14,3 +37,4 @@ export const config = { "/(api|trpc)(.*)", ], } +