mirror of
https://github.com/xavidop/cardex.git
synced 2026-08-28 23:01:22 +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
|
||||
|
||||
- 🤖 **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
|
||||
|
||||
@@ -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": {
|
||||
"@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
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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 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">
|
||||
|
||||
@@ -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';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { 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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user