diff --git a/.gitignore b/.gitignore index 29cb75e..4322547 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,7 @@ next-env.d.ts /.clerk/ .DS_Store + IDEAS.md + +.claude/launch.json diff --git a/app/api/generate-comic/route.ts b/app/api/generate-comic/route.ts index f4fd202..f010e4d 100644 --- a/app/api/generate-comic/route.ts +++ b/app/api/generate-comic/route.ts @@ -31,7 +31,7 @@ const FIXED_DIMENSIONS = NEW_MODEL ? { width: 896, height: 1200 } : { width: 864, height: 1184 }; -const TEXT_MODEL = "Qwen/Qwen3-Next-80B-A3B-Instruct"; +const TEXT_MODEL = "Qwen/Qwen3.5-9B"; export async function POST(request: NextRequest) { try { diff --git a/app/api/validate-api-key/route.ts b/app/api/validate-api-key/route.ts new file mode 100644 index 0000000..0c8b72e --- /dev/null +++ b/app/api/validate-api-key/route.ts @@ -0,0 +1,45 @@ +import { type NextRequest, NextResponse } from "next/server"; +import Together from "together-ai"; + +export async function POST(request: NextRequest) { + try { + const { apiKey } = await request.json(); + + if (!apiKey || typeof apiKey !== "string") { + return NextResponse.json( + { valid: false, error: "API key is required" }, + { status: 400 }, + ); + } + + // Dynamically fetch the fastest available model + const routerRes = await fetch("https://whichllm.together.ai/router/fast", { + next: { revalidate: 60 }, + }); + const { model } = await routerRes.json(); + + // Fire a minimal completion — 1 output token to keep cost/latency negligible + const client = new Together({ apiKey }); + await client.chat.completions.create({ + model, + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + }); + + return NextResponse.json({ valid: true }); + } catch (error: unknown) { + const status = + typeof error === "object" && error !== null && "status" in error + ? (error as { status: number }).status + : undefined; + + if (status === 401 || status === 403) { + return NextResponse.json({ valid: false, error: "Invalid API key" }); + } + + return NextResponse.json( + { valid: false, error: "Validation failed" }, + { status: 500 }, + ); + } +} diff --git a/components/api-key-modal.tsx b/components/api-key-modal.tsx index cd3644a..2c51060 100644 --- a/components/api-key-modal.tsx +++ b/components/api-key-modal.tsx @@ -2,7 +2,8 @@ import type React from "react" import { useState, useEffect } from "react" -import { Key, ExternalLink, ArrowRight, X } from "lucide-react" +import { Key, ExternalLink, ArrowRight, X, Check } from "lucide-react" +import { motion, AnimatePresence } from "motion/react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { @@ -24,10 +25,15 @@ interface ApiKeyModalProps { export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) { const [apiKeyInput, setApiKeyInput] = useState("") const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + const [shakeKey, setShakeKey] = useState(0) + const [success, setSuccess] = useState(false) const [existingKey, setApiKey] = useApiKey() useEffect(() => { if (isOpen) { + setSuccess(false) + setError(null) setApiKeyInput((current) => { if (existingKey && current === "") { return existingKey @@ -42,98 +48,179 @@ export function ApiKeyModal({ isOpen, onClose, onSubmit }: ApiKeyModalProps) { if (!apiKeyInput.trim()) return setIsLoading(true) - await new Promise((resolve) => setTimeout(resolve, 500)) + setError(null) + + try { + const res = await fetch("/api/validate-api-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: apiKeyInput.trim() }), + }) + const data = await res.json() + + if (!data.valid) { + setError("Invalid API key. Please check and try again.") + setShakeKey((k) => k + 1) + setIsLoading(false) + return + } + } catch { + setError("Could not validate key. Please try again.") + setShakeKey((k) => k + 1) + setIsLoading(false) + return + } + setIsLoading(false) + setSuccess(true) onSubmit(apiKeyInput.trim()) - setApiKeyInput("") + setTimeout(() => { + setSuccess(false) + setApiKeyInput("") + }, 1400) } const handleDelete = () => { setApiKey(null) setApiKeyInput("") + setError(null) onClose() } return ( - - -
-
- -
+ + + {success && ( + + + + + + API key saved + + + You're all set for unlimited generation + + + )} + + +
+ + + {existingKey ? "Your API key" : "Add your API key"} +
- - + {existingKey - ? "Update your API key" - : "Add your API key to continue"} - - - - {existingKey - ? "Update your Together API key or add a new one. You can also delete your existing key." - : "You've used all your weekly credits! Add your Together API key for unlimited generation."} + ? "Update or remove your Together AI key." + : "You've used all your free credits. Add your key for unlimited use."}
-
+
- setApiKeyInput(e.target.value)} - placeholder={ - existingKey ? "Your current API key" : "Enter your API key..." - } - className="bg-secondary border-border/50 text-white placeholder-muted-foreground py-5 pr-10" - /> - {apiKeyInput && ( - - )} + + {error && ( + + + {error} + + )} + + + 0 ? { x: [0, -8, 8, -6, 6, -3, 3, 0] } : {}} + transition={{ duration: 0.45, ease: "easeInOut" }} + > + { setApiKeyInput(e.target.value); setError(null) }} + placeholder="sk-••••••••••••••••" + className={`bg-secondary border-border/50 text-white placeholder-muted-foreground/40 py-5 pr-10 font-mono text-sm transition-colors duration-200 ${error ? "border-red-500/60 focus-visible:ring-red-500/20" : ""}`} + /> + {apiKeyInput && ( + + )} +
- Get your Together API key - + Get a free Together AI key + -
+
- -
-

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

+ Stored locally · never sent to our servers

-
+
) diff --git a/package.json b/package.json index 436cddc..ff15ca1 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "input-otp": "1.4.2", "jspdf": "^4.2.1", "lucide-react": "^1.20.0", + "motion": "^12.40.0", "next": "16.2.9", "next-plausible": "^4.0.0", "next-s3-upload": "^0.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c623ab..484cc31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: lucide-react: specifier: ^1.20.0 version: 1.20.0(react@19.2.7) + motion: + specifier: ^12.40.0 + version: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next: specifier: 16.2.9 version: 16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -2888,6 +2891,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3028,6 +3045,26 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.40.0: + resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -6481,6 +6518,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + fsevents@2.3.3: optional: true @@ -6589,6 +6635,20 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + framer-motion: 12.40.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + nanoid@3.3.12: {} next-plausible@4.0.0(next@16.2.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7):