diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..8ff4af7 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "demo-app" + } +} \ No newline at end of file diff --git a/README.md b/README.md index 4bcdf6c..ecb0ba9 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A modern web application for managing your Pokémon card collection with AI-powe ## Features - 🤖 **AI Card Scanning**: Use Gemini Vision to automatically identify Pokémon cards from photos +- ✨ **AI Card Generation**: Create custom Pokémon cards using Google Imagen4 - 🔐 **Secure Authentication**: Firebase Authentication with email and Google login - 📱 **Responsive Design**: Modern UI built with Next.js and Tailwind CSS - 💾 **Cloud Storage**: Real-time data synchronization with Firestore @@ -76,7 +77,7 @@ src/ - Node.js 18+ - Firebase project with Firestore and Authentication enabled -- Google AI API key for Gemini Vision +- Google AI API key for Gemini Vision and Imagen4 ### Installation @@ -122,6 +123,17 @@ npm run genkit:dev 4. Review and edit the detected information 5. Save the card to your collection +### Card Generation +1. Navigate to the generate page +2. Fill out the card parameters: + - Pokemon name and type + - Card properties (Full Art, Holographic) + - Descriptions for the Pokemon and background + - Language and optional stats +3. Click "Generate Pokemon Card" to create a custom card using AI +4. Preview the generated card +5. Save it to your collection if you like it + ### Collection Management - View all your cards in a responsive grid layout - Edit card details by clicking on any card @@ -143,9 +155,9 @@ Firestore Collection: users/{userId}/pokemon_cards/{cardId} ## AI Integration -The app uses Google's Gemini Vision API through the Genkit framework to: -- Analyze uploaded card images -- Extract card name, set, and rarity information +The app uses Google's AI services through the Genkit framework to: +- **Gemini Vision API**: Analyze uploaded card images and extract card information +- **Imagen4**: Generate custom Pokemon card artwork based on user parameters - Provide structured data for user review and confirmation ## Contributing diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..1edfbc0 --- /dev/null +++ b/firebase.json @@ -0,0 +1,29 @@ +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "emulators": { + "auth": { + "port": 9099, + "host": "0.0.0.0" + }, + "firestore": { + "port": 8080, + "host": "0.0.0.0" + }, + "hub": { + "port": 4400, + "host": "0.0.0.0" + }, + "ui": { + "enabled": true, + "host": "0.0.0.0" + }, + "logging": { + "host": "0.0.0.0" + }, + "singleProjectMode": true + } + } + \ No newline at end of file diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..415027e --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..66b84c8 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,10 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + // Temporarily allow all authenticated users to test auth + match /{document=**} { + allow read, write: if request.auth != null; + } + } +} diff --git a/package-lock.json b/package-lock.json index 311ef1d..69a0e34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@genkit-ai/googleai": "^1.8.0", "@genkit-ai/next": "^1.8.0", + "@google/genai": "^1.10.0", "@hookform/resolvers": "^4.1.3", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", @@ -55,6 +56,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "concurrently": "^9.2.0", "genkit-cli": "^1.8.0", "postcss": "^8", "tailwindcss": "^3.4.1", @@ -1326,6 +1328,27 @@ "node": ">=14.0.0" } }, + "node_modules/@google/genai": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.10.0.tgz", + "integrity": "sha512-PR4tLuiIFMrpAiiCko2Z16ydikFsPF1c5TBfI64hlZcv3xBEApSCceLuDYu1pNMq2SkNh4r66J4AG+ZexBnMLw==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.14.2", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.11.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@google/generative-ai": { "version": "0.24.0", "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.0.tgz", @@ -5154,6 +5177,48 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concurrently": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.0.tgz", + "integrity": "sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/configstore": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", @@ -8987,6 +9052,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shimmer": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", @@ -9591,6 +9669,16 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -10051,6 +10139,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/package.json b/package.json index 02b7537..10ca185 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,15 @@ "build": "next build", "start": "next start", "lint": "next lint", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "emulators": "firebase emulators:start", + "emulators:ui": "firebase emulators:start --only hosting,firestore,auth", + "dev:full": "concurrently \"npm run emulators\" \"npm run dev\"" }, "dependencies": { "@genkit-ai/googleai": "^1.8.0", "@genkit-ai/next": "^1.8.0", + "@google/genai": "^1.10.0", "@hookform/resolvers": "^4.1.3", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-alert-dialog": "^1.1.6", @@ -59,6 +63,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "concurrently": "^9.2.0", "genkit-cli": "^1.8.0", "postcss": "^8", "tailwindcss": "^3.4.1", diff --git a/src/ai/dev.ts b/src/ai/dev.ts index 4e309ce..85d9248 100644 --- a/src/ai/dev.ts +++ b/src/ai/dev.ts @@ -2,4 +2,5 @@ import { config } from 'dotenv'; config(); import '@/ai/flows/summarize-card-information.ts'; -import '@/ai/flows/scan-pokemon-card.ts'; \ No newline at end of file +import '@/ai/flows/scan-pokemon-card.ts'; +import '@/ai/flows/generate-pokemon-card.ts'; \ No newline at end of file diff --git a/src/ai/flows/generate-pokemon-card.ts b/src/ai/flows/generate-pokemon-card.ts new file mode 100644 index 0000000..ac4d20a --- /dev/null +++ b/src/ai/flows/generate-pokemon-card.ts @@ -0,0 +1,134 @@ +// This file is automatically generated - edits will be lost! +'use server'; + +/** + * @fileOverview Uses Google Imagen4 to generate custom Pokemon cards based on user parameters. + * + * - generatePokemonCard - A function that handles the card generation process. + * - GeneratePokemonCardInput - The input type for the generatePokemonCard function. + * - GeneratePokemonCardOutput - The return type for the generatePokemonCard function. + */ + +import { ai } from '@/ai/genkit'; +import { z } from 'genkit'; +import { GoogleGenAI } from '@google/genai'; + +const GeneratePokemonCardInputSchema = z.object({ + pokemonName: z.string().min(1, "Pokemon name is required"), + pokemonType: z.string().min(1, "Pokemon type is required"), + isIllustrationRare: z.boolean(), + isHolo: z.boolean(), + backgroundDescription: z.string().min(1, "Background description is required"), + pokemonDescription: z.string().min(1, "Pokemon description is required"), + language: z.enum(['english', 'japanese', 'chinese', 'korean', 'spanish', 'french', 'german', 'italian']), + hp: z.number().optional(), + attackName1: z.string().optional(), + attackDamage1: z.number().optional(), + attackName2: z.string().optional(), + attackDamage2: z.number().optional(), + weakness: z.string().optional(), + resistance: z.string().optional(), + retreatCost: z.number().optional(), +}); + +export type GeneratePokemonCardInput = z.infer; + +const GeneratePokemonCardOutputSchema = z.object({ + imageBase64: z.string().describe("The generated card image as base64 string").optional(), + prompt: z.string().describe("The prompt used to generate the card").optional(), + error: z.string().describe("Error message if the card could not be generated").optional() +}); + +export type GeneratePokemonCardOutput = z.infer; + +export async function generatePokemonCard(input: GeneratePokemonCardInput): Promise { + return generatePokemonCardFlow(input); +} + +const generatePokemonCardFlow = ai.defineFlow( + { + name: 'generatePokemonCardFlow', + inputSchema: GeneratePokemonCardInputSchema, + outputSchema: GeneratePokemonCardOutputSchema, + }, + async (params: GeneratePokemonCardInput) => { + const apiKey = process.env.GOOGLE_GENAI_API_KEY || process.env.GEMINI_API_KEY; + + if (!apiKey) { + return { error: 'Google AI API key is required. Please set GOOGLE_GENAI_API_KEY or GEMINI_API_KEY environment variable.' }; + } + + const genAI = new GoogleGenAI({ + apiKey: apiKey, + }); + + // Generate the detailed prompt for the Pokemon card + const prompt = generateCardPrompt(params); + console.log("Generated prompt:", prompt); + + try { + const response = await genAI.models.generateImages({ + model: 'models/imagen-4.0-ultra-generate-preview-06-06', + prompt: prompt, + config: { + numberOfImages: 1, + outputMimeType: 'image/jpeg', + aspectRatio: '3:4', // Pokemon cards are roughly 3:4 aspect ratio + }, + }); + + if (!response?.generatedImages || response.generatedImages.length === 0) { + return { error: 'No images generated.' }; + } + + const generatedImage = response.generatedImages[0]; + if (!generatedImage?.image?.imageBytes) { + return { error: 'Generated image data is missing.' }; + } + + return { + imageBase64: generatedImage.image.imageBytes, + prompt: prompt, + }; + } catch (error: any) { + console.error('Error generating Pokemon card:', error); + return { error: `Failed to generate Pokemon card: ${error?.message || 'Unknown error'}` }; + } + } +); + +function generateCardPrompt(params: GeneratePokemonCardInput): string { + const { + pokemonName, + pokemonType, + isIllustrationRare, + isHolo, + backgroundDescription, + pokemonDescription, + language, + hp = 130, + attackName1 = "Quick Attack", + attackDamage1 = 60, + attackName2 = "Special Move", + attackDamage2 = 90, + weakness = "Fighting", + resistance = "Psychic", + retreatCost = 2 + } = params; + + const cardType = isIllustrationRare ? "full art (ART)" : "Regular classic and not full art"; + const holoEffect = isHolo ? "The card's surface has a \"holo\" effect, creating a shimmering, rainbow-like reflection that highlights the Pokémon and key elements." : ""; + + const languageInstruction = language !== 'english' ? `Write all card information in ${language}. The Pokemon name, attacks and description should be translated accordingly.` : ''; + + return `A ${cardType} collectible Pokémon trading card, featuring the Pokémon "${pokemonName}". ${isIllustrationRare ? "The illustration covers the entire card" : "The card has a standard vertical layout with a detailed illustration in the main area."} ${pokemonName}, a ${pokemonType} type, is depicted ${pokemonDescription}. The background of the illustration shows ${backgroundDescription}. ${holoEffect} + +The card layout includes: The top left corner displays "${pokemonName}" in a stylized font, with "HP ${hp}" next to it in red. Below the Pokémon's name, the ${pokemonType} type symbol is clearly visible. In the lower section, the card has two attacks listed. The first attack is ${attackName1} and should be written in ${language}, deals ${attackDamage1} damage. The second attack is ${attackName2} and should be written in ${language}, deals ${attackDamage2} damage. + +Below the attacks, the Weakness is ${weakness} (x2), Resistance is ${resistance} (-30), and Retreat Cost shows ${retreatCost} energy symbols. The bottom edge of the card features a thin line of text indicating the rarity and copyright information. + +${languageInstruction} + +The overall style should match official Pokémon TCG card design with proper fonts, layout, and professional quality artwork. +Make sure the information is displayed clearly.`; +} diff --git a/src/app/api/generate-card/route.ts b/src/app/api/generate-card/route.ts new file mode 100644 index 0000000..326028e --- /dev/null +++ b/src/app/api/generate-card/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { generatePokemonCard } from '@/ai/flows/generate-pokemon-card'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate required fields + const requiredFields = ['pokemonName', 'pokemonType', 'backgroundDescription', 'pokemonDescription']; + const missingFields = requiredFields.filter(field => !body[field]); + + if (missingFields.length > 0) { + return NextResponse.json( + { error: `Missing required fields: ${missingFields.join(', ')}` }, + { status: 400 } + ); + } + + // Call the AI flow + const result = await generatePokemonCard(body); + + if (result.error) { + return NextResponse.json({ error: result.error }, { status: 500 }); + } + + return NextResponse.json(result); + } catch (error) { + console.error('API route error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/dashboard/collection/page.tsx b/src/app/dashboard/collection/page.tsx index ad96cf9..3eafe2f 100644 --- a/src/app/dashboard/collection/page.tsx +++ b/src/app/dashboard/collection/page.tsx @@ -6,7 +6,7 @@ import { getUserCards, deleteCardFromCollection } from '@/lib/firestore'; import type { PokemonCard } from '@/types'; import CardItem from '@/components/cards/CardItem'; import { Button } from '@/components/ui/button'; -import { Loader2, PlusCircle, AlertTriangle } from 'lucide-react'; +import { Loader2, PlusCircle, AlertTriangle, Sparkles } from 'lucide-react'; import Link from 'next/link'; import { useToast } from '@/hooks/use-toast'; @@ -80,23 +80,37 @@ export default function CollectionPage() {

My Card Collection

- +
+ + +
{cards.length === 0 ? (

Your collection is empty.

-

Start by scanning your first Pokémon card!

- +

Start by scanning or generating your first Pokémon card!

+
+ + +
) : (
diff --git a/src/app/dashboard/generate/page.tsx b/src/app/dashboard/generate/page.tsx new file mode 100644 index 0000000..1d23896 --- /dev/null +++ b/src/app/dashboard/generate/page.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useState } from 'react'; +import { CardGenerator } from '@/components/cards/CardGenerator'; +import { Button } from '@/components/ui/button'; +import { useAuth } from '@/hooks/useAuth'; +import { addCardToCollection } from '@/lib/firestore'; +import { useToast } from '@/hooks/use-toast'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function GenerateCardPage() { + const { user } = useAuth(); + const { toast } = useToast(); + const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string } | null>(null); + const [isSaving, setIsSaving] = useState(false); + + const handleCardGenerated = (imageBase64: string, prompt: string) => { + setGeneratedCard({ imageBase64, prompt }); + }; + + const handleSaveToCollection = async () => { + if (!user || !generatedCard) return; + + setIsSaving(true); + try { + // Convert base64 to data URL + const imageDataUrl = `data:image/jpeg;base64,${generatedCard.imageBase64}`; + + await addCardToCollection(user.uid, { + name: 'Generated Pokemon Card', + set: 'AI Generated', + rarity: 'Special', + imageDataUrl: imageDataUrl, + isGenerated: true, + prompt: generatedCard.prompt, + }); + + toast({ + title: 'Card Saved', + description: 'Your generated Pokemon card has been added to your collection!', + }); + + // Clear the generated card after saving + setGeneratedCard(null); + } catch (error) { + console.error('Error saving card:', error); + toast({ + title: 'Error', + description: 'Failed to save card to collection. Please try again.', + variant: 'destructive', + }); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
+

Pokemon Card Generator

+

+ Create your own custom Pokemon cards using AI-powered image generation +

+
+ + + + {generatedCard && ( + + + Save Your Card + + +

+ Love your generated card? Save it to your collection! +

+ +
+
+ )} +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 5675d02..cb98b82 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,21 +1,81 @@ 'use client'; import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { ScanLine, Sparkles, BookOpen } from 'lucide-react'; export default function DashboardPage() { const router = useRouter(); - useEffect(() => { - // For now, redirect to scan page as the primary action - router.replace('/dashboard/scan'); - }, [router]); return ( -
-

Welcome to your Cardex Dashboard

-

- Manage your Pokémon card collection with ease. -

-

Redirecting to card scanner...

+
+
+

Welcome to your Cardex Dashboard

+

+ Manage your Pokémon card collection with ease. +

+
+ +
+ + + + + Scan Cards + + + Use your camera to scan and identify Pokemon cards + + + + + + + + + + + + Generate Cards + + + Create custom Pokemon cards using AI image generation + + + + + + + + + + + + My Collection + + + View and manage your Pokemon card collection + + + + + + +
); } diff --git a/src/components/cards/CardGenerator.tsx b/src/components/cards/CardGenerator.tsx new file mode 100644 index 0000000..9655266 --- /dev/null +++ b/src/components/cards/CardGenerator.tsx @@ -0,0 +1,414 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Loader2 } from 'lucide-react'; + +export interface GeneratePokemonCardInput { + pokemonName: string; + pokemonType: string; + isIllustrationRare: boolean; + isHolo: boolean; + backgroundDescription: string; + pokemonDescription: string; + language: 'english' | 'japanese' | 'chinese' | 'korean' | 'spanish' | 'french' | 'german' | 'italian'; + hp?: number; + attackName1?: string; + attackDamage1?: number; + attackName2?: string; + attackDamage2?: number; + weakness?: string; + resistance?: string; + retreatCost?: number; +} + +const pokemonTypes = [ + 'Normal', 'Fire', 'Water', 'Electric', 'Grass', 'Ice', 'Fighting', 'Poison', + 'Ground', 'Flying', 'Psychic', 'Bug', 'Rock', 'Ghost', 'Dragon', 'Dark', + 'Steel', 'Fairy' +]; + +const languages = [ + { value: 'english', label: 'English' }, + { value: 'japanese', label: 'Japanese' }, + { value: 'chinese', label: 'Chinese' }, + { value: 'korean', label: 'Korean' }, + { value: 'spanish', label: 'Spanish' }, + { value: 'french', label: 'French' }, + { value: 'german', label: 'German' }, + { value: 'italian', label: 'Italian' }, +] as const; + +const cardGeneratorSchema = z.object({ + pokemonName: z.string().min(1, 'Pokemon name is required'), + pokemonType: z.string().min(1, 'Pokemon type is required'), + isIllustrationRare: z.boolean(), + isHolo: z.boolean(), + backgroundDescription: z.string().min(10, 'Background description must be at least 10 characters'), + pokemonDescription: z.string().min(10, 'Pokemon description must be at least 10 characters'), + language: z.enum(['english', 'japanese', 'chinese', 'korean', 'spanish', 'french', 'german', 'italian']), + hp: z.number().min(10).max(999).optional(), + attackName1: z.string().optional(), + attackDamage1: z.number().min(0).max(999).optional(), + attackName2: z.string().optional(), + attackDamage2: z.number().min(0).max(999).optional(), + weakness: z.string().optional(), + resistance: z.string().optional(), + retreatCost: z.number().min(0).max(5).optional(), +}); + +type CardGeneratorForm = z.infer; + +interface CardGeneratorProps { + onCardGenerated?: (imageBase64: string, prompt: string) => void; +} + +export function CardGenerator({ onCardGenerated }: CardGeneratorProps) { + const [isGenerating, setIsGenerating] = useState(false); + const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string } | null>(null); + const [error, setError] = useState(''); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(cardGeneratorSchema), + defaultValues: { + pokemonName: '', + pokemonType: '', + isIllustrationRare: false, + isHolo: false, + backgroundDescription: '', + pokemonDescription: '', + language: 'english', + hp: 130, + attackName1: 'Quick Attack', + attackDamage1: 60, + attackName2: 'Special Move', + attackDamage2: 90, + weakness: 'Fighting', + resistance: 'Psychic', + retreatCost: 2, + }, + }); + + const watchedValues = watch(); + + const onSubmit = async (data: CardGeneratorForm) => { + setIsGenerating(true); + setError(''); + setGeneratedCard(null); + + try { + const response = await fetch('/api/generate-card', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + const result = await response.json(); + + if (!response.ok) { + setError(result.error || 'Failed to generate card'); + return; + } + + if (result.error) { + setError(result.error); + return; + } + + if (result.imageBase64 && result.prompt) { + setGeneratedCard({ + imageBase64: result.imageBase64, + prompt: result.prompt, + }); + onCardGenerated?.(result.imageBase64, result.prompt); + } + } catch (err) { + console.error('Error generating card:', err); + setError('Failed to generate card. Please try again.'); + } finally { + setIsGenerating(false); + } + }; + + return ( +
+ {/* Form Section */} + + + Create Your Pokemon Card + + Fill in the details to generate a custom Pokemon TCG card using AI + + + +
+ {/* Basic Pokemon Info */} +
+

Basic Information

+ +
+
+ + + {errors.pokemonName && ( +

{errors.pokemonName.message}

+ )} +
+ +
+ + + {errors.pokemonType && ( +

{errors.pokemonType.message}

+ )} +
+
+ +
+ + +
+
+ + + + {/* Card Properties */} +
+

Card Properties

+ +
+
+ +

Horizontal layout with full illustration

+
+ setValue('isIllustrationRare', checked)} + /> +
+ +
+
+ +

Rainbow shimmer effect

+
+ setValue('isHolo', checked)} + /> +
+
+ + + + {/* Descriptions */} +
+

Descriptions

+ +
+ +