feat: add card generator

This commit is contained in:
xavidop
2025-07-21 13:14:24 +02:00
parent 7695d8c6a7
commit 2bae46ad8d
19 changed files with 1017 additions and 34 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"projects": {
"default": "demo-app"
}
}
+16 -4
View File
@@ -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
+29
View File
@@ -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
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"indexes": [],
"fieldOverrides": []
}
+10
View File
@@ -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;
}
}
}
+109
View File
@@ -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",
+6 -1
View File
@@ -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",
+1
View File
@@ -3,3 +3,4 @@ config();
import '@/ai/flows/summarize-card-information.ts';
import '@/ai/flows/scan-pokemon-card.ts';
import '@/ai/flows/generate-pokemon-card.ts';
+134
View File
@@ -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<typeof GeneratePokemonCardInputSchema>;
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<typeof GeneratePokemonCardOutputSchema>;
export async function generatePokemonCard(input: GeneratePokemonCardInput): Promise<GeneratePokemonCardOutput> {
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.`;
}
+34
View File
@@ -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 }
);
}
}
+26 -12
View File
@@ -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() {
<div className="space-y-8">
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
<h1 className="text-3xl font-bold font-headline">My Card Collection</h1>
<Button asChild>
<Link href="/dashboard/scan">
<PlusCircle className="mr-2 h-5 w-5" /> Add New Card
</Link>
</Button>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href="/dashboard/generate">
<Sparkles className="mr-2 h-5 w-5" /> Generate Card
</Link>
</Button>
<Button asChild>
<Link href="/dashboard/scan">
<PlusCircle className="mr-2 h-5 w-5" /> Scan Card
</Link>
</Button>
</div>
</div>
{cards.length === 0 ? (
<div className="text-center py-10 border-2 border-dashed border-border rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-archive-restore mx-auto mb-4 text-muted-foreground"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-V8"/><path d="M9.5 12.5 12 15l2.5-2.5"/><path d="M12 15V9"/></svg>
<h2 className="text-xl font-semibold text-muted-foreground">Your collection is empty.</h2>
<p className="text-muted-foreground mt-2">Start by scanning your first Pokémon card!</p>
<Button asChild className="mt-6">
<Link href="/dashboard/scan">
<PlusCircle className="mr-2 h-5 w-5" /> Scan a Card
</Link>
</Button>
<p className="text-muted-foreground mt-2">Start by scanning or generating your first Pokémon card!</p>
<div className="flex gap-2 justify-center mt-6">
<Button asChild variant="outline">
<Link href="/dashboard/generate">
<Sparkles className="mr-2 h-5 w-5" /> Generate a Card
</Link>
</Button>
<Button asChild>
<Link href="/dashboard/scan">
<PlusCircle className="mr-2 h-5 w-5" /> Scan a Card
</Link>
</Button>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
+89
View File
@@ -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 (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Pokemon Card Generator</h1>
<p className="text-muted-foreground">
Create your own custom Pokemon cards using AI-powered image generation
</p>
</div>
<CardGenerator onCardGenerated={handleCardGenerated} />
{generatedCard && (
<Card className="mt-8">
<CardHeader>
<CardTitle>Save Your Card</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-4">
Love your generated card? Save it to your collection!
</p>
<Button
onClick={handleSaveToCollection}
disabled={isSaving}
className="w-full sm:w-auto"
>
{isSaving ? 'Saving...' : 'Save to Collection'}
</Button>
</CardContent>
</Card>
)}
</div>
);
}
+70 -10
View File
@@ -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 (
<div className="text-center">
<h1 className="text-3xl font-bold font-headline">Welcome to your Cardex Dashboard</h1>
<p className="mt-4 text-lg text-muted-foreground">
Manage your Pokémon card collection with ease.
</p>
<p className="mt-2 text-md text-muted-foreground">Redirecting to card scanner...</p>
<div className="container mx-auto px-4 py-8">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold font-headline mb-2">Welcome to your Cardex Dashboard</h1>
<p className="text-lg text-muted-foreground">
Manage your Pokémon card collection with ease.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center">
<ScanLine className="mr-2 h-5 w-5" />
Scan Cards
</CardTitle>
<CardDescription>
Use your camera to scan and identify Pokemon cards
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link href="/dashboard/scan">
Start Scanning
</Link>
</Button>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center">
<Sparkles className="mr-2 h-5 w-5" />
Generate Cards
</CardTitle>
<CardDescription>
Create custom Pokemon cards using AI image generation
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link href="/dashboard/generate">
Generate Card
</Link>
</Button>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center">
<BookOpen className="mr-2 h-5 w-5" />
My Collection
</CardTitle>
<CardDescription>
View and manage your Pokemon card collection
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full" variant="outline">
<Link href="/dashboard/collection">
View Collection
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
+414
View File
@@ -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<typeof cardGeneratorSchema>;
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<string>('');
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CardGeneratorForm>({
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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Form Section */}
<Card>
<CardHeader>
<CardTitle>Create Your Pokemon Card</CardTitle>
<CardDescription>
Fill in the details to generate a custom Pokemon TCG card using AI
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Pokemon Info */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="pokemonName">Pokemon Name</Label>
<Input
id="pokemonName"
{...register('pokemonName')}
placeholder="e.g., Pikachu"
/>
{errors.pokemonName && (
<p className="text-sm text-red-500 mt-1">{errors.pokemonName.message}</p>
)}
</div>
<div>
<Label htmlFor="pokemonType">Pokemon Type</Label>
<Select onValueChange={(value) => setValue('pokemonType', value)}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{pokemonTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.pokemonType && (
<p className="text-sm text-red-500 mt-1">{errors.pokemonType.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="language">Card Language</Label>
<Select onValueChange={(value) => setValue('language', value as any)}>
<SelectTrigger>
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* Card Properties */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Card Properties</h3>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="isIllustrationRare">Full Art</Label>
<p className="text-sm text-muted-foreground">Horizontal layout with full illustration</p>
</div>
<Switch
id="isIllustrationRare"
checked={watchedValues.isIllustrationRare}
onCheckedChange={(checked) => setValue('isIllustrationRare', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="isHolo">Holographic Effect</Label>
<p className="text-sm text-muted-foreground">Rainbow shimmer effect</p>
</div>
<Switch
id="isHolo"
checked={watchedValues.isHolo}
onCheckedChange={(checked) => setValue('isHolo', checked)}
/>
</div>
</div>
<Separator />
{/* Descriptions */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Descriptions</h3>
<div>
<Label htmlFor="pokemonDescription">Pokemon Description</Label>
<Textarea
id="pokemonDescription"
{...register('pokemonDescription')}
placeholder="Describe how the Pokemon should look and what it's doing..."
className="min-h-[80px]"
/>
{errors.pokemonDescription && (
<p className="text-sm text-red-500 mt-1">{errors.pokemonDescription.message}</p>
)}
</div>
<div>
<Label htmlFor="backgroundDescription">Background Description</Label>
<Textarea
id="backgroundDescription"
{...register('backgroundDescription')}
placeholder="Describe the background scene..."
className="min-h-[80px]"
/>
{errors.backgroundDescription && (
<p className="text-sm text-red-500 mt-1">{errors.backgroundDescription.message}</p>
)}
</div>
</div>
<Separator />
{/* Stats (Optional) */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Card Stats (Optional)</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="hp">HP</Label>
<Input
id="hp"
type="number"
{...register('hp', { valueAsNumber: true })}
placeholder="130"
/>
</div>
<div>
<Label htmlFor="weakness">Weakness</Label>
<Select onValueChange={(value) => setValue('weakness', value)}>
<SelectTrigger>
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{pokemonTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="retreatCost">Retreat Cost</Label>
<Input
id="retreatCost"
type="number"
{...register('retreatCost', { valueAsNumber: true })}
placeholder="2"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="attackName1">First Attack Name</Label>
<Input
id="attackName1"
{...register('attackName1')}
placeholder="Quick Attack"
/>
</div>
<div>
<Label htmlFor="attackDamage1">First Attack Damage</Label>
<Input
id="attackDamage1"
type="number"
{...register('attackDamage1', { valueAsNumber: true })}
placeholder="60"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="attackName2">Second Attack Name</Label>
<Input
id="attackName2"
{...register('attackName2')}
placeholder="Special Move"
/>
</div>
<div>
<Label htmlFor="attackDamage2">Second Attack Damage</Label>
<Input
id="attackDamage2"
type="number"
{...register('attackDamage2', { valueAsNumber: true })}
placeholder="90"
/>
</div>
</div>
</div>
<Button type="submit" className="w-full" disabled={isGenerating}>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating Card...
</>
) : (
'Generate Pokemon Card'
)}
</Button>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</form>
</CardContent>
</Card>
{/* Preview Section */}
<Card>
<CardHeader>
<CardTitle>Generated Card</CardTitle>
<CardDescription>
Your custom Pokemon card will appear here
</CardDescription>
</CardHeader>
<CardContent>
{generatedCard ? (
<div className="space-y-4">
<div className="relative bg-gray-100 rounded-lg p-4">
<img
src={`data:image/jpeg;base64,${generatedCard.imageBase64}`}
alt="Generated Pokemon Card"
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
</div>
) : (
<div className="flex items-center justify-center h-64 bg-gray-50 rounded-lg">
<p className="text-muted-foreground">
{isGenerating ? 'Generating your Pokemon card...' : 'Fill out the form to generate a card'}
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+13 -2
View File
@@ -4,7 +4,8 @@ import Image from 'next/image';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Pencil, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Pencil, Trash2, Sparkles } from 'lucide-react';
import type { PokemonCard } from '@/types';
import {
AlertDialog,
@@ -28,7 +29,17 @@ export default function CardItem({ card, onDelete, isDeleting }: CardItemProps)
return (
<Card className="flex flex-col overflow-hidden shadow-lg hover:shadow-xl transition-shadow duration-300">
<CardHeader className="p-4">
<CardTitle className="text-lg font-semibold truncate font-headline" title={card.name}>{card.name}</CardTitle>
<div className="flex justify-between items-start">
<CardTitle className="text-lg font-semibold truncate font-headline" title={card.name}>
{card.name}
</CardTitle>
{card.isGenerated && (
<Badge variant="secondary" className="ml-2 flex items-center gap-1 bg-gradient-to-r from-purple-100 to-pink-100 text-purple-800 border border-purple-200">
<Sparkles className="h-3 w-3" />
AI Generated
</Badge>
)}
</div>
<CardDescription className="text-xs text-muted-foreground">
Set: {card.set} | Rarity: {card.rarity}
</CardDescription>
+6 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { LogIn, LogOut, UserCircle, ScanLine, BookOpen } from 'lucide-react';
import { LogIn, LogOut, UserCircle, ScanLine, BookOpen, Sparkles } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { signOut } from 'firebase/auth';
@@ -59,6 +59,11 @@ export default function Navbar() {
<ScanLine className="mr-2 h-4 w-4" /> Scan Card
</Link>
</Button>
<Button variant="ghost" asChild>
<Link href="/dashboard/generate">
<Sparkles className="mr-2 h-4 w-4" /> Generate Card
</Link>
</Button>
<Button variant="ghost" asChild>
<Link href="/dashboard/collection">
<BookOpen className="mr-2 h-4 w-4" /> My Collection
+17 -2
View File
@@ -1,7 +1,7 @@
import { initializeApp, getApps, getApp, type FirebaseApp } from 'firebase/app';
import { getAuth, type Auth } from 'firebase/auth';
import { getFirestore, type Firestore } from 'firebase/firestore';
import { getAuth, type Auth, connectAuthEmulator } from 'firebase/auth';
import { getFirestore, type Firestore, connectFirestoreEmulator } from 'firebase/firestore';
import firebaseConfig from '@/config/firebase';
// Define which keys from firebaseConfig are considered essential for initialization.
@@ -48,6 +48,9 @@ let app: FirebaseApp;
let auth: Auth;
let db: Firestore;
// Track if emulators have been connected to avoid multiple connections
let emulatorsConnected = false;
try {
if (!getApps().length) {
app = initializeApp(firebaseConfig);
@@ -56,6 +59,18 @@ try {
}
auth = getAuth(app);
db = getFirestore(app);
// Connect to emulators in development - do this immediately after getting auth/db instances
if (process.env.NODE_ENV === 'development' && !emulatorsConnected && typeof window !== 'undefined') {
try {
connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true });
connectFirestoreEmulator(db, 'localhost', 8080);
emulatorsConnected = true;
console.log('Firebase emulators connected successfully');
} catch (error) {
console.log('Emulator connection attempt (might already be connected):', error);
}
}
} catch (error) {
console.error("Firebase SDK Initialization Error (this occurred *after* the initial config check passed, possibly an issue with the values themselves being invalid for Firebase):", error);
// Log config again, but mask API key, to show what was passed to initializeApp
+6 -1
View File
@@ -13,7 +13,7 @@ import {
getDoc,
orderBy,
} from 'firebase/firestore';
import { db } from './firebase'; // Main Firebase config
import { db, auth } from './firebase'; // Main Firebase config
import type { PokemonCard, ScannedCardData } from '@/types';
const CARDS_COLLECTION = 'users'; // Top-level collection for users
@@ -31,6 +31,11 @@ export const addCardToCollection = async (
console.error("addCardToCollection: User ID is missing.");
throw new Error('User ID is required to add a card.');
}
console.log("DEBUG: Attempting to add card with userId:", userId);
console.log("DEBUG: Current auth user:", auth.currentUser?.uid);
console.log("DEBUG: Auth user matches:", auth.currentUser?.uid === userId);
try {
const collectionRef = getPokemonCardsCollectionRef(userId);
const docPayload = {
+27
View File
@@ -9,6 +9,8 @@ export interface PokemonCard {
imageDataUrl: string; // data URI of the card image
createdAt: Timestamp;
updatedAt: Timestamp;
isGenerated?: boolean; // Flag to indicate if this is an AI-generated card
prompt?: string; // The prompt used to generate the card (for generated cards)
}
// For AI Scan result, before saving to Firestore
@@ -17,3 +19,28 @@ export interface ScannedCardData {
set?: string;
rarity?: string;
}
// For Pokemon card generation parameters
export interface CardGenerationParams {
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;
}
export interface GeneratedCard {
imageBase64: string;
prompt: string;
params: CardGenerationParams;
}