mirror of
https://github.com/xavidop/cardex.git
synced 2026-08-28 17:01:07 +00:00
feat: add card generator
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"default": "demo-app"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ A modern web application for managing your Pokémon card collection with AI-powe
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🤖 **AI Card Scanning**: Use Gemini Vision to automatically identify Pokémon cards from photos
|
- 🤖 **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
|
- 🔐 **Secure Authentication**: Firebase Authentication with email and Google login
|
||||||
- 📱 **Responsive Design**: Modern UI built with Next.js and Tailwind CSS
|
- 📱 **Responsive Design**: Modern UI built with Next.js and Tailwind CSS
|
||||||
- 💾 **Cloud Storage**: Real-time data synchronization with Firestore
|
- 💾 **Cloud Storage**: Real-time data synchronization with Firestore
|
||||||
@@ -76,7 +77,7 @@ src/
|
|||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- Firebase project with Firestore and Authentication enabled
|
- Firebase project with Firestore and Authentication enabled
|
||||||
- Google AI API key for Gemini Vision
|
- Google AI API key for Gemini Vision and Imagen4
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -122,6 +123,17 @@ npm run genkit:dev
|
|||||||
4. Review and edit the detected information
|
4. Review and edit the detected information
|
||||||
5. Save the card to your collection
|
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
|
### Collection Management
|
||||||
- View all your cards in a responsive grid layout
|
- View all your cards in a responsive grid layout
|
||||||
- Edit card details by clicking on any card
|
- Edit card details by clicking on any card
|
||||||
@@ -143,9 +155,9 @@ Firestore Collection: users/{userId}/pokemon_cards/{cardId}
|
|||||||
|
|
||||||
## AI Integration
|
## AI Integration
|
||||||
|
|
||||||
The app uses Google's Gemini Vision API through the Genkit framework to:
|
The app uses Google's AI services through the Genkit framework to:
|
||||||
- Analyze uploaded card images
|
- **Gemini Vision API**: Analyze uploaded card images and extract card information
|
||||||
- Extract card name, set, and rarity information
|
- **Imagen4**: Generate custom Pokemon card artwork based on user parameters
|
||||||
- Provide structured data for user review and confirmation
|
- Provide structured data for user review and confirmation
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"indexes": [],
|
||||||
|
"fieldOverrides": []
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+109
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@genkit-ai/googleai": "^1.8.0",
|
"@genkit-ai/googleai": "^1.8.0",
|
||||||
"@genkit-ai/next": "^1.8.0",
|
"@genkit-ai/next": "^1.8.0",
|
||||||
|
"@google/genai": "^1.10.0",
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"concurrently": "^9.2.0",
|
||||||
"genkit-cli": "^1.8.0",
|
"genkit-cli": "^1.8.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
@@ -1326,6 +1328,27 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@google/generative-ai": {
|
||||||
"version": "0.24.0",
|
"version": "0.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
"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": {
|
"node_modules/configstore": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz",
|
||||||
@@ -8987,6 +9052,19 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/shimmer": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
"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": {
|
"node_modules/triple-beam": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
||||||
@@ -10051,6 +10139,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/xdg-basedir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz",
|
||||||
|
|||||||
+6
-1
@@ -9,11 +9,15 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"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": {
|
"dependencies": {
|
||||||
"@genkit-ai/googleai": "^1.8.0",
|
"@genkit-ai/googleai": "^1.8.0",
|
||||||
"@genkit-ai/next": "^1.8.0",
|
"@genkit-ai/next": "^1.8.0",
|
||||||
|
"@google/genai": "^1.10.0",
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
@@ -59,6 +63,7 @@
|
|||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"concurrently": "^9.2.0",
|
||||||
"genkit-cli": "^1.8.0",
|
"genkit-cli": "^1.8.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
|
|||||||
+2
-1
@@ -2,4 +2,5 @@ import { config } from 'dotenv';
|
|||||||
config();
|
config();
|
||||||
|
|
||||||
import '@/ai/flows/summarize-card-information.ts';
|
import '@/ai/flows/summarize-card-information.ts';
|
||||||
import '@/ai/flows/scan-pokemon-card.ts';
|
import '@/ai/flows/scan-pokemon-card.ts';
|
||||||
|
import '@/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<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.`;
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { getUserCards, deleteCardFromCollection } from '@/lib/firestore';
|
|||||||
import type { PokemonCard } from '@/types';
|
import type { PokemonCard } from '@/types';
|
||||||
import CardItem from '@/components/cards/CardItem';
|
import CardItem from '@/components/cards/CardItem';
|
||||||
import { Button } from '@/components/ui/button';
|
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 Link from 'next/link';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
@@ -80,23 +80,37 @@ export default function CollectionPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
<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>
|
<h1 className="text-3xl font-bold font-headline">My Card Collection</h1>
|
||||||
<Button asChild>
|
<div className="flex gap-2">
|
||||||
<Link href="/dashboard/scan">
|
<Button asChild variant="outline">
|
||||||
<PlusCircle className="mr-2 h-5 w-5" /> Add New Card
|
<Link href="/dashboard/generate">
|
||||||
</Link>
|
<Sparkles className="mr-2 h-5 w-5" /> Generate Card
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/scan">
|
||||||
|
<PlusCircle className="mr-2 h-5 w-5" /> Scan Card
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cards.length === 0 ? (
|
{cards.length === 0 ? (
|
||||||
<div className="text-center py-10 border-2 border-dashed border-border rounded-lg">
|
<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>
|
<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>
|
<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>
|
<p className="text-muted-foreground mt-2">Start by scanning or generating your first Pokémon card!</p>
|
||||||
<Button asChild className="mt-6">
|
<div className="flex gap-2 justify-center mt-6">
|
||||||
<Link href="/dashboard/scan">
|
<Button asChild variant="outline">
|
||||||
<PlusCircle className="mr-2 h-5 w-5" /> Scan a Card
|
<Link href="/dashboard/generate">
|
||||||
</Link>
|
<Sparkles className="mr-2 h-5 w-5" /> Generate a Card
|
||||||
</Button>
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/scan">
|
||||||
|
<PlusCircle className="mr-2 h-5 w-5" /> Scan a Card
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||||
|
|||||||
@@ -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
@@ -1,21 +1,81 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useEffect(() => {
|
|
||||||
// For now, redirect to scan page as the primary action
|
|
||||||
router.replace('/dashboard/scan');
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="container mx-auto px-4 py-8">
|
||||||
<h1 className="text-3xl font-bold font-headline">Welcome to your Cardex Dashboard</h1>
|
<div className="text-center mb-8">
|
||||||
<p className="mt-4 text-lg text-muted-foreground">
|
<h1 className="text-3xl font-bold font-headline mb-2">Welcome to your Cardex Dashboard</h1>
|
||||||
Manage your Pokémon card collection with ease.
|
<p className="text-lg text-muted-foreground">
|
||||||
</p>
|
Manage your Pokémon card collection with ease.
|
||||||
<p className="mt-2 text-md text-muted-foreground">Redirecting to card scanner...</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
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 type { PokemonCard } from '@/types';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -28,7 +29,17 @@ export default function CardItem({ card, onDelete, isDeleting }: CardItemProps)
|
|||||||
return (
|
return (
|
||||||
<Card className="flex flex-col overflow-hidden shadow-lg hover:shadow-xl transition-shadow duration-300">
|
<Card className="flex flex-col overflow-hidden shadow-lg hover:shadow-xl transition-shadow duration-300">
|
||||||
<CardHeader className="p-4">
|
<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">
|
<CardDescription className="text-xs text-muted-foreground">
|
||||||
Set: {card.set} | Rarity: {card.rarity}
|
Set: {card.set} | Rarity: {card.rarity}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { useAuth } from '@/hooks/useAuth';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { signOut } from 'firebase/auth';
|
import { signOut } from 'firebase/auth';
|
||||||
@@ -59,6 +59,11 @@ export default function Navbar() {
|
|||||||
<ScanLine className="mr-2 h-4 w-4" /> Scan Card
|
<ScanLine className="mr-2 h-4 w-4" /> Scan Card
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
<Button variant="ghost" asChild>
|
||||||
<Link href="/dashboard/collection">
|
<Link href="/dashboard/collection">
|
||||||
<BookOpen className="mr-2 h-4 w-4" /> My Collection
|
<BookOpen className="mr-2 h-4 w-4" /> My Collection
|
||||||
|
|||||||
+17
-2
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
import { initializeApp, getApps, getApp, type FirebaseApp } from 'firebase/app';
|
import { initializeApp, getApps, getApp, type FirebaseApp } from 'firebase/app';
|
||||||
import { getAuth, type Auth } from 'firebase/auth';
|
import { getAuth, type Auth, connectAuthEmulator } from 'firebase/auth';
|
||||||
import { getFirestore, type Firestore } from 'firebase/firestore';
|
import { getFirestore, type Firestore, connectFirestoreEmulator } from 'firebase/firestore';
|
||||||
import firebaseConfig from '@/config/firebase';
|
import firebaseConfig from '@/config/firebase';
|
||||||
|
|
||||||
// Define which keys from firebaseConfig are considered essential for initialization.
|
// Define which keys from firebaseConfig are considered essential for initialization.
|
||||||
@@ -48,6 +48,9 @@ let app: FirebaseApp;
|
|||||||
let auth: Auth;
|
let auth: Auth;
|
||||||
let db: Firestore;
|
let db: Firestore;
|
||||||
|
|
||||||
|
// Track if emulators have been connected to avoid multiple connections
|
||||||
|
let emulatorsConnected = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!getApps().length) {
|
if (!getApps().length) {
|
||||||
app = initializeApp(firebaseConfig);
|
app = initializeApp(firebaseConfig);
|
||||||
@@ -56,6 +59,18 @@ try {
|
|||||||
}
|
}
|
||||||
auth = getAuth(app);
|
auth = getAuth(app);
|
||||||
db = getFirestore(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) {
|
} 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);
|
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
|
// Log config again, but mask API key, to show what was passed to initializeApp
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
getDoc,
|
getDoc,
|
||||||
orderBy,
|
orderBy,
|
||||||
} from 'firebase/firestore';
|
} from 'firebase/firestore';
|
||||||
import { db } from './firebase'; // Main Firebase config
|
import { db, auth } from './firebase'; // Main Firebase config
|
||||||
import type { PokemonCard, ScannedCardData } from '@/types';
|
import type { PokemonCard, ScannedCardData } from '@/types';
|
||||||
|
|
||||||
const CARDS_COLLECTION = 'users'; // Top-level collection for users
|
const CARDS_COLLECTION = 'users'; // Top-level collection for users
|
||||||
@@ -31,6 +31,11 @@ export const addCardToCollection = async (
|
|||||||
console.error("addCardToCollection: User ID is missing.");
|
console.error("addCardToCollection: User ID is missing.");
|
||||||
throw new Error('User ID is required to add a card.');
|
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 {
|
try {
|
||||||
const collectionRef = getPokemonCardsCollectionRef(userId);
|
const collectionRef = getPokemonCardsCollectionRef(userId);
|
||||||
const docPayload = {
|
const docPayload = {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface PokemonCard {
|
|||||||
imageDataUrl: string; // data URI of the card image
|
imageDataUrl: string; // data URI of the card image
|
||||||
createdAt: Timestamp;
|
createdAt: Timestamp;
|
||||||
updatedAt: 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
|
// For AI Scan result, before saving to Firestore
|
||||||
@@ -17,3 +19,28 @@ export interface ScannedCardData {
|
|||||||
set?: string;
|
set?: string;
|
||||||
rarity?: 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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user