Add free tier rate limiting with Upstash Redis and API key handling
This commit is contained in:
@@ -9,3 +9,6 @@ DATABASE_URL=
|
|||||||
|
|
||||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||||
CLERK_SECRET_KEY=
|
CLERK_SECRET_KEY=
|
||||||
|
|
||||||
|
UPSTASH_REDIS_REST_URL=
|
||||||
|
UPSTASH_REDIS_REST_TOKEN=
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createPage,
|
createPage,
|
||||||
getNextPageNumber,
|
getNextPageNumber,
|
||||||
} from "@/lib/db-actions";
|
} from "@/lib/db-actions";
|
||||||
|
import { freeTierRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const NEW_MODEL = false;
|
const NEW_MODEL = false;
|
||||||
const IMAGE_MODEL = NEW_MODEL
|
const IMAGE_MODEL = NEW_MODEL
|
||||||
@@ -122,15 +123,47 @@ export async function POST(request: NextRequest) {
|
|||||||
previousContext = "",
|
previousContext = "",
|
||||||
} = await request.json();
|
} = 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(
|
return NextResponse.json(
|
||||||
{ error: "Missing required fields" },
|
{ error: "Missing required fields" },
|
||||||
{ status: 400 }
|
{ 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 page;
|
||||||
let story;
|
let story;
|
||||||
|
|
||||||
@@ -235,9 +268,9 @@ COMPOSITION:
|
|||||||
|
|
||||||
const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`;
|
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;
|
let response;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+20
@@ -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();
|
||||||
@@ -113,7 +113,7 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) {
|
|||||||
|
|
||||||
<div className="mt-4 p-3 glass-panel rounded-lg">
|
<div className="mt-4 p-3 glass-panel rounded-lg">
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
Your API key is stored locally and never sent to our servers.
|
Your API key is stored locally and never stored on our servers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,44 +1,66 @@
|
|||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowRight, Loader2 } from "lucide-react"
|
import { ArrowRight, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { useS3Upload } from "next-s3-upload"
|
import { useS3Upload } from "next-s3-upload";
|
||||||
import { useAuth, SignInButton } from "@clerk/nextjs"
|
import { useAuth, SignInButton } from "@clerk/nextjs";
|
||||||
|
|
||||||
interface CreateButtonProps {
|
interface CreateButtonProps {
|
||||||
prompt: string
|
prompt: string;
|
||||||
style: string
|
style: string;
|
||||||
characterFiles: File[]
|
characterFiles: File[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateButton({ prompt, style, characterFiles }: CreateButtonProps) {
|
export function CreateButton({
|
||||||
const router = useRouter()
|
prompt,
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
style,
|
||||||
const [loadingStep, setLoadingStep] = useState(0)
|
characterFiles,
|
||||||
const { toast } = useToast()
|
}: CreateButtonProps) {
|
||||||
const { uploadToS3 } = useS3Upload()
|
const router = useRouter();
|
||||||
const { isSignedIn } = useAuth()
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isLoading) return
|
if (!isLoading) return;
|
||||||
|
|
||||||
const steps = ["Enhancing prompt...", "Generating scenes...", "Creating your comic..."]
|
const steps = [
|
||||||
let currentStep = 0
|
"Enhancing prompt...",
|
||||||
|
"Generating scenes...",
|
||||||
|
"Creating your comic...",
|
||||||
|
];
|
||||||
|
let currentStep = 0;
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
currentStep += 1
|
currentStep += 1;
|
||||||
if (currentStep < steps.length) {
|
if (currentStep < steps.length) {
|
||||||
setLoadingStep(currentStep)
|
setLoadingStep(currentStep);
|
||||||
} else {
|
} else {
|
||||||
clearInterval(interval)
|
clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, 2500)
|
}, 2500);
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval);
|
||||||
}, [isLoading])
|
}, [isLoading]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!prompt.trim()) {
|
if (!prompt.trim()) {
|
||||||
@@ -47,16 +69,18 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp
|
|||||||
description: "Please enter a prompt to generate your comic",
|
description: "Please enter a prompt to generate your comic",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
})
|
});
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true)
|
setIsLoading(true);
|
||||||
setLoadingStep(0)
|
setLoadingStep(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiKey = localStorage.getItem("together_api_key")
|
const apiKey = localStorage.getItem("together_api_key");
|
||||||
const characterUploads = await Promise.all(characterFiles.map((file) => uploadToS3(file).then(({ url }) => url)))
|
const characterUploads = await Promise.all(
|
||||||
|
characterFiles.map((file) => uploadToS3(file).then(({ url }) => url))
|
||||||
|
);
|
||||||
|
|
||||||
// Use API to create story and generate first page
|
// Use API to create story and generate first page
|
||||||
const response = await fetch("/api/generate-comic", {
|
const response = await fetch("/api/generate-comic", {
|
||||||
@@ -70,46 +94,56 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp
|
|||||||
style,
|
style,
|
||||||
characterImages: characterUploads,
|
characterImages: characterUploads,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.error || "Failed to create story")
|
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
|
// Redirect to the story editor using slug
|
||||||
router.push(`/editor/${result.storySlug}`)
|
router.push(`/editor/${result.storySlug}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating comic:", error)
|
console.error("Error creating comic:", error);
|
||||||
toast({
|
toast({
|
||||||
title: "Creation failed",
|
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",
|
variant: "destructive",
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
})
|
});
|
||||||
setIsLoading(false)
|
setIsLoading(false);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadingSteps = [
|
||||||
|
"Enhancing prompt...",
|
||||||
const loadingSteps = ["Enhancing prompt...", "Generating scenes...", "Creating your comic..."]
|
"Generating scenes...",
|
||||||
|
"Creating your comic...",
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
{isSignedIn ? (
|
{isSignedIn ? (
|
||||||
|
<div className="flex items-center justify-between gap-3 w-full">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={isLoading || !prompt.trim()}
|
disabled={isLoading || !prompt.trim()}
|
||||||
className="w-full sm:w-auto sm:min-w-40 bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight"
|
className="bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
<span className="text-sm font-medium tracking-tight">{loadingSteps[loadingStep]}</span>
|
<span className="text-sm font-medium tracking-tight">
|
||||||
|
{loadingSteps[loadingStep]}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -118,6 +152,14 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{hasApiKey ? (
|
||||||
|
<>Using your API key (~$0.01 per comic)</>
|
||||||
|
) : (
|
||||||
|
<>1 credit weekly</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SignInButton mode="modal">
|
<SignInButton mode="modal">
|
||||||
<Button className="w-full sm:w-auto sm:min-w-40 bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight">
|
<Button className="w-full sm:w-auto sm:min-w-40 bg-white hover:bg-neutral-200 text-black px-8 py-2 rounded-md text-sm font-medium transition-colors flex items-center justify-center gap-3 tracking-tight">
|
||||||
@@ -127,5 +169,5 @@ export function CreateButton({ prompt, style, characterFiles }: CreateButtonProp
|
|||||||
</SignInButton>
|
</SignInButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "stories" ALTER COLUMN "user_id" DROP NOT NULL;
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,20 @@
|
|||||||
"when": 1766492848044,
|
"when": 1766492848044,
|
||||||
"tag": "0001_windy_ezekiel",
|
"tag": "0001_windy_ezekiel",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
+1
-1
@@ -7,7 +7,7 @@ export const stories = pgTable('stories', {
|
|||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
slug: text('slug').notNull().unique(),
|
slug: text('slug').notNull().unique(),
|
||||||
description: text('description'),
|
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(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,8 @@
|
|||||||
"@radix-ui/react-toggle": "1.1.1",
|
"@radix-ui/react-toggle": "1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
|
"@upstash/ratelimit": "^2.0.7",
|
||||||
|
"@upstash/redis": "^1.36.0",
|
||||||
"@vercel/analytics": "1.3.1",
|
"@vercel/analytics": "1.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
Generated
+39
-2
@@ -101,6 +101,12 @@ importers:
|
|||||||
'@radix-ui/react-tooltip':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: 1.1.6
|
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)
|
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':
|
'@vercel/analytics':
|
||||||
specifier: 1.3.1
|
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)
|
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
|
version: 4.1.0
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.1
|
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:
|
embla-carousel-react:
|
||||||
specifier: 8.5.1
|
specifier: 8.5.1
|
||||||
version: 8.5.1(react@19.2.0)
|
version: 8.5.1(react@19.2.0)
|
||||||
@@ -1994,6 +2000,18 @@ packages:
|
|||||||
'@types/react@19.2.7':
|
'@types/react@19.2.7':
|
||||||
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
|
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':
|
'@vercel/analytics@1.3.1':
|
||||||
resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==}
|
resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2709,6 +2727,9 @@ packages:
|
|||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
uncrypto@0.1.3:
|
||||||
|
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
|
||||||
|
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
@@ -4890,6 +4911,19 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
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)':
|
'@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:
|
dependencies:
|
||||||
server-only: 0.0.1
|
server-only: 0.0.1
|
||||||
@@ -5027,10 +5061,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
optionalDependencies:
|
||||||
'@neondatabase/serverless': 1.0.2
|
'@neondatabase/serverless': 1.0.2
|
||||||
'@types/pg': 8.16.0
|
'@types/pg': 8.16.0
|
||||||
|
'@upstash/redis': 1.36.0
|
||||||
|
|
||||||
electron-to-chromium@1.5.267: {}
|
electron-to-chromium@1.5.267: {}
|
||||||
|
|
||||||
@@ -5512,6 +5547,8 @@ snapshots:
|
|||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
uncrypto@0.1.3: {}
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||||
|
|||||||
Reference in New Issue
Block a user