diff --git a/.example.env b/.example.env index 22563af..8e7ea56 100644 --- a/.example.env +++ b/.example.env @@ -8,4 +8,7 @@ S3_UPLOAD_REGION= DATABASE_URL= NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -CLERK_SECRET_KEY= \ No newline at end of file +CLERK_SECRET_KEY= + +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= diff --git a/app/api/generate-comic/route.ts b/app/api/generate-comic/route.ts index 45b95bd..008778a 100644 --- a/app/api/generate-comic/route.ts +++ b/app/api/generate-comic/route.ts @@ -7,6 +7,7 @@ import { createPage, getNextPageNumber, } from "@/lib/db-actions"; +import { freeTierRateLimit } from "@/lib/rate-limit"; const NEW_MODEL = false; const IMAGE_MODEL = NEW_MODEL @@ -122,15 +123,47 @@ export async function POST(request: NextRequest) { previousContext = "", } = await request.json(); - console.log("Received request:", { storyId, prompt: prompt?.substring(0, 50), characterImagesCount: characterImages.length, userId }); + console.log("Received request:", { storyId, prompt: prompt?.substring(0, 50), characterImagesCount: characterImages.length, userId, hasApiKey: !!apiKey }); - if (!prompt || !apiKey) { + if (!prompt) { return NextResponse.json( { error: "Missing required fields" }, { status: 400 } ); } + // Determine which API key to use + let finalApiKey = apiKey; + const isUsingFreeTier = !apiKey; + + if (isUsingFreeTier) { + // Using free tier - apply rate limiting + const { success, reset } = await freeTierRateLimit.limit(userId); + + if (!success) { + const resetDate = new Date(reset); + const timeUntilReset = Math.ceil((reset - Date.now()) / (1000 * 60 * 60 * 24)); // days + + return NextResponse.json( + { + error: `Free tier limit reached. You can generate 1 comic per week. Try again in ${timeUntilReset} day(s), or provide your own API key for unlimited access.`, + resetDate: resetDate.toISOString(), + isRateLimited: true, + }, + { status: 429 } + ); + } + + // Use default API key for free tier + finalApiKey = process.env.TOGETHER_API_KEY_DEFAULT; + if (!finalApiKey) { + return NextResponse.json( + { error: "Server configuration error - default API key not available" }, + { status: 500 } + ); + } + } + let page; let story; @@ -235,9 +268,9 @@ COMPOSITION: const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`; - console.log("Generating comic with prompt length:", fullPrompt.length); + console.log("Generating comic with prompt length:", fullPrompt.length, "using tier:", isUsingFreeTier ? "free" : "paid"); - const client = new Together({ apiKey }); + const client = new Together({ apiKey: finalApiKey }); let response; try { diff --git a/clear-db.ts b/clear-db.ts new file mode 100644 index 0000000..e237c68 --- /dev/null +++ b/clear-db.ts @@ -0,0 +1,20 @@ +import { db } from './lib/db'; +import { stories, pages } from './lib/schema'; + +async function clearDatabase() { + try { + console.log('Clearing all pages...'); + await db.delete(pages); + + console.log('Clearing all stories...'); + await db.delete(stories); + + console.log('Database cleared successfully!'); + } catch (error) { + console.error('Error clearing database:', error); + } finally { + process.exit(0); + } +} + +clearDatabase(); \ No newline at end of file diff --git a/components/api-key-modal.tsx b/components/api-key-modal.tsx index 14e1eb3..e4dcc0e 100644 --- a/components/api-key-modal.tsx +++ b/components/api-key-modal.tsx @@ -113,7 +113,7 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {

- Your API key is stored locally and never sent to our servers. + Your API key is stored locally and never stored on our servers.

diff --git a/components/landing/create-button.tsx b/components/landing/create-button.tsx index 5aa1838..29cb65a 100644 --- a/components/landing/create-button.tsx +++ b/components/landing/create-button.tsx @@ -1,44 +1,66 @@ -"use client" +"use client"; -import { useState, useEffect } from "react" -import { useRouter } from "next/navigation" -import { ArrowRight, Loader2 } from "lucide-react" -import { Button } from "@/components/ui/button" -import { useToast } from "@/hooks/use-toast" -import { useS3Upload } from "next-s3-upload" -import { useAuth, SignInButton } from "@clerk/nextjs" +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowRight, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { useS3Upload } from "next-s3-upload"; +import { useAuth, SignInButton } from "@clerk/nextjs"; interface CreateButtonProps { - prompt: string - style: string - characterFiles: File[] + prompt: string; + style: string; + characterFiles: File[]; } -export function CreateButton({ prompt, style, characterFiles }: CreateButtonProps) { - const router = useRouter() - const [isLoading, setIsLoading] = useState(false) - const [loadingStep, setLoadingStep] = useState(0) - const { toast } = useToast() - const { uploadToS3 } = useS3Upload() - const { isSignedIn } = useAuth() +export function CreateButton({ + prompt, + style, + characterFiles, +}: CreateButtonProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [loadingStep, setLoadingStep] = useState(0); + const { toast } = useToast(); + const { uploadToS3 } = useS3Upload(); + const { isSignedIn } = useAuth(); + const [hasApiKey, setHasApiKey] = useState(false); + + // Check if user has their own API key set + useEffect(() => { + const checkApiKey = () => { + const apiKey = localStorage.getItem("together_api_key"); + setHasApiKey(!!apiKey); + }; + + checkApiKey(); + // Listen for storage changes + window.addEventListener("storage", checkApiKey); + return () => window.removeEventListener("storage", checkApiKey); + }, []); useEffect(() => { - if (!isLoading) return + if (!isLoading) return; - const steps = ["Enhancing prompt...", "Generating scenes...", "Creating your comic..."] - let currentStep = 0 + const steps = [ + "Enhancing prompt...", + "Generating scenes...", + "Creating your comic...", + ]; + let currentStep = 0; const interval = setInterval(() => { - currentStep += 1 + currentStep += 1; if (currentStep < steps.length) { - setLoadingStep(currentStep) + setLoadingStep(currentStep); } else { - clearInterval(interval) + clearInterval(interval); } - }, 2500) + }, 2500); - return () => clearInterval(interval) - }, [isLoading]) + return () => clearInterval(interval); + }, [isLoading]); const handleCreate = async () => { if (!prompt.trim()) { @@ -47,16 +69,18 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp description: "Please enter a prompt to generate your comic", variant: "destructive", duration: 3000, - }) - return + }); + return; } - setIsLoading(true) - setLoadingStep(0) + setIsLoading(true); + setLoadingStep(0); try { - const apiKey = localStorage.getItem("together_api_key") - const characterUploads = await Promise.all(characterFiles.map((file) => uploadToS3(file).then(({ url }) => url))) + const apiKey = localStorage.getItem("together_api_key"); + const characterUploads = await Promise.all( + characterFiles.map((file) => uploadToS3(file).then(({ url }) => url)) + ); // Use API to create story and generate first page const response = await fetch("/api/generate-comic", { @@ -70,54 +94,72 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp style, characterImages: characterUploads, }), - }) + }); if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.error || "Failed to create story") + const errorData = await response.json(); + if (response.status === 429 && errorData.isRateLimited) { + throw new Error(errorData.error); + } + throw new Error(errorData.error || "Failed to create story"); } - const result = await response.json() + const result = await response.json(); // Redirect to the story editor using slug - router.push(`/editor/${result.storySlug}`) - + router.push(`/editor/${result.storySlug}`); } catch (error) { - console.error("Error creating comic:", error) + console.error("Error creating comic:", error); toast({ title: "Creation failed", - description: error instanceof Error ? error.message : "Failed to create comic. Please try again.", + description: + error instanceof Error + ? error.message + : "Failed to create comic. Please try again.", variant: "destructive", duration: 4000, - }) - setIsLoading(false) + }); + setIsLoading(false); } - } + }; - - - const loadingSteps = ["Enhancing prompt...", "Generating scenes...", "Creating your comic..."] + const loadingSteps = [ + "Enhancing prompt...", + "Generating scenes...", + "Creating your comic...", + ]; return (
{isSignedIn ? ( - +
+ +
+ {hasApiKey ? ( + <>Using your API key (~$0.01 per comic) + ) : ( + <>1 credit weekly + )} +
+
) : (
- ) + ); } diff --git a/drizzle/0002_romantic_blob.sql b/drizzle/0002_romantic_blob.sql new file mode 100644 index 0000000..0778c56 --- /dev/null +++ b/drizzle/0002_romantic_blob.sql @@ -0,0 +1,2 @@ +ALTER TABLE "stories" ALTER COLUMN "user_id" SET DATA TYPE text;--> statement-breakpoint +ALTER TABLE "stories" ALTER COLUMN "user_id" SET NOT NULL; \ No newline at end of file diff --git a/drizzle/0003_harsh_molecule_man.sql b/drizzle/0003_harsh_molecule_man.sql new file mode 100644 index 0000000..1194449 --- /dev/null +++ b/drizzle/0003_harsh_molecule_man.sql @@ -0,0 +1 @@ +ALTER TABLE "stories" ALTER COLUMN "user_id" DROP NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..477347b --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,164 @@ +{ + "id": "6e2800e4-49f1-4069-aee3-00451ac4eb01", + "prevId": "5ab9181f-d5fe-4f00-8822-d2c4f5be3322", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "story_id": { + "name": "story_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_image_urls": { + "name": "character_image_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "generated_image_url": { + "name": "generated_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pages_story_id_stories_id_fk": { + "name": "pages_story_id_stories_id_fk", + "tableFrom": "pages", + "tableTo": "stories", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stories": { + "name": "stories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stories_slug_unique": { + "name": "stories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..f419cb4 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,164 @@ +{ + "id": "d4fd5445-071f-469e-9bc8-ccb05ead5ef9", + "prevId": "6e2800e4-49f1-4069-aee3-00451ac4eb01", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.pages": { + "name": "pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "story_id": { + "name": "story_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_image_urls": { + "name": "character_image_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "generated_image_url": { + "name": "generated_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pages_story_id_stories_id_fk": { + "name": "pages_story_id_stories_id_fk", + "tableFrom": "pages", + "tableTo": "stories", + "columnsFrom": [ + "story_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stories": { + "name": "stories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stories_slug_unique": { + "name": "stories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 7008510..c464364 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1766492848044, "tag": "0001_windy_ezekiel", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1766674906873, + "tag": "0002_romantic_blob", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1766674926877, + "tag": "0003_harsh_molecule_man", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..dc4bbdd --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,15 @@ +import { Ratelimit } from "@upstash/ratelimit" +import { Redis } from "@upstash/redis" + +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}) + +// Free tier: 1 comic per week (fixed window) +export const freeTierRateLimit = new Ratelimit({ + redis, + limiter: Ratelimit.fixedWindow(1, "7 d"), + analytics: true, + prefix: "ratelimit:free-comics", +}) \ No newline at end of file diff --git a/lib/schema.ts b/lib/schema.ts index 7525db2..090ccce 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -7,7 +7,7 @@ export const stories = pgTable('stories', { title: text('title').notNull(), slug: text('slug').notNull().unique(), description: text('description'), - userId: uuid('user_id').notNull(), // Required Clerk user ID + userId: text('user_id').notNull(), // Required Clerk user ID createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); diff --git a/package.json b/package.json index 4cad12f..378c6d4 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", + "@upstash/ratelimit": "^2.0.7", + "@upstash/redis": "^1.36.0", "@vercel/analytics": "1.3.1", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d740237..bd88410 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,12 @@ importers: '@radix-ui/react-tooltip': specifier: 1.1.6 version: 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@upstash/ratelimit': + specifier: ^2.0.7 + version: 2.0.7(@upstash/redis@1.36.0) + '@upstash/redis': + specifier: ^1.36.0 + version: 1.36.0 '@vercel/analytics': specifier: 1.3.1 version: 1.3.1(next@16.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) @@ -121,7 +127,7 @@ importers: version: 4.1.0 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0) + version: 0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0)(@upstash/redis@1.36.0) embla-carousel-react: specifier: 8.5.1 version: 8.5.1(react@19.2.0) @@ -1994,6 +2000,18 @@ packages: '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + '@upstash/core-analytics@0.0.10': + resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} + engines: {node: '>=16.0.0'} + + '@upstash/ratelimit@2.0.7': + resolution: {integrity: sha512-qNQW4uBPKVk8c4wFGj2S/vfKKQxXx1taSJoSGBN36FeiVBBKHQgsjPbKUijZ9Xu5FyVK+pfiXWKIsQGyoje8Fw==} + peerDependencies: + '@upstash/redis': ^1.34.3 + + '@upstash/redis@1.36.0': + resolution: {integrity: sha512-9zN2UV9QJGPnXfWU3yZBLVQaqqENDh7g+Y4J2vJuSxBCi9FQ0aUOtaXlzuFhnsiZvCqM+eS27ic+tgmkWUsfOg==} + '@vercel/analytics@1.3.1': resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==} peerDependencies: @@ -2709,6 +2727,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -4890,6 +4911,19 @@ snapshots: dependencies: csstype: 3.2.3 + '@upstash/core-analytics@0.0.10': + dependencies: + '@upstash/redis': 1.36.0 + + '@upstash/ratelimit@2.0.7(@upstash/redis@1.36.0)': + dependencies: + '@upstash/core-analytics': 0.0.10 + '@upstash/redis': 1.36.0 + + '@upstash/redis@1.36.0': + dependencies: + uncrypto: 0.1.3 + '@vercel/analytics@1.3.1(next@16.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': dependencies: server-only: 0.0.1 @@ -5027,10 +5061,11 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0): + drizzle-orm@0.45.1(@neondatabase/serverless@1.0.2)(@types/pg@8.16.0)(@upstash/redis@1.36.0): optionalDependencies: '@neondatabase/serverless': 1.0.2 '@types/pg': 8.16.0 + '@upstash/redis': 1.36.0 electron-to-chromium@1.5.267: {} @@ -5512,6 +5547,8 @@ snapshots: typescript@5.9.3: {} + uncrypto@0.1.3: {} + undici-types@6.21.0: {} update-browserslist-db@1.2.3(browserslist@4.28.1):