feat: video generation, image to image generation and a bunch of new features

This commit is contained in:
xavidop
2025-07-23 16:12:17 +02:00
parent 5a5b279c4a
commit d3bf5498ad
46 changed files with 6606 additions and 1329 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"projects": {
"default": "cardex-1mapj"
}
}
}
+54 -7
View File
@@ -6,6 +6,8 @@ A modern web application for managing your Pokémon card collection with AI-powe
- 🤖 **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
- 🎬 **AI Video Generation**: Bring your cards to life with animated videos using Google Veo 2.0
- 📸 **Photo-to-Card**: Transform your own photos into Pokemon cards using AI image generation
- 🔐 **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
@@ -30,6 +32,8 @@ graph TB
J[Firestore Database] --> K[Real-time Updates]
L[Gemini AI] --> M[Vision API]
M --> N[Card Recognition]
O[Google Veo 2.0] --> P[Video Generation]
P --> Q[Card Animation]
end
subgraph "Development Tools"
@@ -41,6 +45,7 @@ graph TB
A --> H
A --> J
A --> L
A --> O
O --> L
```
@@ -50,12 +55,15 @@ graph TB
src/
├── ai/
│ └── flows/
── scan-pokemon-card.ts # AI card scanning logic
── scan-pokemon-card.ts # AI card scanning logic
│ ├── generate-pokemon-card.ts # AI card generation logic
│ ├── generate-pokemon-card-from-photo.ts # Photo-to-card logic
│ └── generate-card-video.ts # AI video generation logic
├── app/
│ ├── dashboard/
│ │ ├── collection/ # Card collection pages
│ │ └── scan/ # Card scanning page
│ └── page.tsx # Root page with auth routing
│ │ ├── collection/ # Card collection pages
│ │ └── scan/ # Card scanning page
│ └── page.tsx # Root page with auth routing
├── components/
│ ├── cards/
│ │ ├── CardForm.tsx # Reusable card form
@@ -77,7 +85,8 @@ src/
- Node.js 18+
- Firebase project with Firestore and Authentication enabled
- Google AI API key for Gemini Vision and Imagen4
- Google AI API key for Gemini Vision, Imagen4, and Veo 2.0
- OpenAI API key for DALL-E 3 and GPT-4o (for photo-based card generation)
### Installation
@@ -102,6 +111,7 @@ NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your_project.appspot.com
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id
GOOGLE_GENAI_API_KEY=your_gemini_api_key
OPENAI_API_KEY=your_openai_api_key
```
4. Run the development server:
@@ -134,6 +144,31 @@ npm run genkit:dev
4. Preview the generated card
5. Save it to your collection if you like it
### Photo-to-Card Generation
1. Navigate to the "Photo Card" page
2. Upload a reference photo that will inspire the card design
3. Fill out the Pokemon details:
- Pokemon name and type
- Style description explaining how to adapt the photo
- Card properties and optional stats
4. Click "Generate Pokemon Card from Photo" to create a card using your photo as reference
5. Preview the generated card that combines your photo's style with Pokemon card design
6. Save it to your collection
### Video Generation
1. Navigate to any card in your collection
2. Click "Make Card Live" to generate an animated video
3. The AI will create a 5-second video where:
- The Pokemon comes to life and moves within the card frame
- Sparkles, glowing effects, and type-specific elemental effects are added
- The background has subtle movement and atmospheric effects
- The card maintains a gentle holographic shimmer
4. Video generation may take several minutes - the page will refresh automatically
5. Once complete, you can:
- Play the video directly in the card view
- Download the video to your device
- Share the animated card with others
### Collection Management
- View all your cards in a responsive grid layout
- Edit card details by clicking on any card
@@ -147,17 +182,29 @@ Firestore Collection: users/{userId}/pokemon_cards/{cardId}
├── name: string
├── set: string
├── rarity: string
├── imageDataUrl: string (base64 encoded image)
├── imageUrl: string (Firebase Storage URL)
├── videoUrl?: string (Firebase Storage URL for animated video)
├── videoGenerationStatus?: 'generating' | 'completed' | 'failed'
├── videoPrompt?: string (AI prompt used for video generation)
├── userId: string
├── createdAt: timestamp
└── updatedAt: timestamp
```
Firebase Storage Structure:
```
/users/{userId}/cards/{sanitized_card_name}_{timestamp}.png
/users/{userId}/videos/{sanitized_card_name}_{timestamp}.mp4
```
## AI Integration
The app uses Google's AI services through the Genkit framework to:
The app uses multiple AI services through the Genkit framework:
- **Gemini Vision API**: Analyze uploaded card images and extract card information
- **Imagen4**: Generate custom Pokemon card artwork based on user parameters
- **Google Veo 2.0**: Create animated videos of Pokemon cards with magical effects and movements
- **OpenAI DALL-E 3**: Generate Pokemon cards based on reference photos with advanced image analysis
- **GPT-4o**: Analyze reference photos to extract visual elements for enhanced card generation
- Provide structured data for user review and confirmation
## Contributing
-2
View File
@@ -18,7 +18,5 @@ env:
secret: NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
- variable: NEXT_PUBLIC_FIREBASE_APP_ID
secret: NEXT_PUBLIC_FIREBASE_APP_ID
- variable: GOOGLE_GENAI_API_KEY
secret: GOOGLE_GENAI_API_KEY
- variable: NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID
secret: NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID
+37 -35
View File
@@ -1,40 +1,42 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"apphosting": {
"backendId": "studio",
"rootDir": "/",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log",
"functions"
]
},
"emulators": {
"auth": {
"port": 9099,
"host": "0.0.0.0"
},
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
"port": 8080,
"host": "0.0.0.0"
},
"apphosting": {
"backendId": "studio",
"rootDir": "/",
"ignore": [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log",
"functions"
]
"hub": {
"port": 4400,
"host": "0.0.0.0"
},
"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
}
"ui": {
"enabled": true,
"host": "0.0.0.0"
},
"logging": {
"host": "0.0.0.0"
},
"singleProjectMode": true
},
"storage": {
"rules": "storage.rules"
}
}
+21
View File
@@ -16,6 +16,27 @@ const nextConfig: NextConfig = {
port: '',
pathname: '/**',
},
// Firebase Storage production
{
protocol: 'https',
hostname: 'firebasestorage.googleapis.com',
port: '',
pathname: '/**',
},
// Firebase Storage emulator (for development)
{
protocol: 'http',
hostname: 'localhost',
port: '9199',
pathname: '/**',
},
// Firebase Storage emulator alternative hostname
{
protocol: 'http',
hostname: '127.0.0.1',
port: '9199',
pathname: '/**',
},
],
},
};
+1008 -992
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -15,8 +15,8 @@
"dev:full": "concurrently \"npm run emulators\" \"npm run dev\""
},
"dependencies": {
"@genkit-ai/googleai": "^1.8.0",
"@genkit-ai/next": "^1.8.0",
"@genkit-ai/googleai": "^1.15.1",
"@genkit-ai/next": "^1.15.1",
"@google/genai": "^1.10.0",
"@hookform/resolvers": "^4.1.3",
"@radix-ui/react-accordion": "^1.2.3",
@@ -45,10 +45,11 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"dotenv": "^16.5.0",
"firebase": "^11.8.1",
"genkit": "^1.8.0",
"firebase": "^11.10.0",
"genkit": "^1.15.1",
"lucide-react": "^0.475.0",
"next": "15.3.3",
"openai": "^5.10.1",
"patch-package": "^8.0.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
@@ -64,7 +65,7 @@
"@types/react": "^18",
"@types/react-dom": "^18",
"concurrently": "^9.2.0",
"genkit-cli": "^1.8.0",
"genkit-cli": "^1.15.1",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
+1 -1
View File
@@ -3,4 +3,4 @@ config();
import '@/ai/flows/summarize-card-information.ts';
import '@/ai/flows/scan-pokemon-card.ts';
import '@/ai/flows/generate-pokemon-card.ts';
import '@/ai/flows/generate-pokemon-card.ts';
+191
View File
@@ -0,0 +1,191 @@
// This file handles video generation for Pokemon cards using Veo 3 model
'use server';
/**
* @fileOverview Uses Google Veo 3 to generate animated videos of Pokemon cards.
* Note: This is a placeholder implementation until Veo 3 API is fully available
*/
import { GoogleGenAI } from '@google/genai';
import { getUserApiKeys } from '@/lib/firestore';
export interface GenerateCardVideoInput {
cardImageUrl: string;
pokemonName: string;
pokemonType: string;
customPrompt?: string;
userId?: string;
}
export interface GenerateCardVideoOutput {
videoBase64?: string;
prompt?: string;
error?: string;
}
export async function generateCardVideo(input: GenerateCardVideoInput): Promise<GenerateCardVideoOutput> {
try {
console.log('Starting video generation for:', input.pokemonName);
// Get user API keys first if userId is provided
let apiKey: string | undefined;
if (input.userId) {
try {
const userApiKeys = await getUserApiKeys(input.userId);
apiKey = userApiKeys?.geminiApiKey;
} catch (error) {
console.warn('Failed to get user API keys:', error);
}
}
// Fallback to environment variables if no user API key
if (!apiKey) {
apiKey = process.env.GOOGLE_GENAI_API_KEY || process.env.GEMINI_API_KEY;
}
if (!apiKey) {
return { error: 'Google AI API key is required. Please set your Gemini API key in settings or contact support.' };
}
// Create the animation prompt
const animationPrompt = input.customPrompt ||
`Take this existing Pokemon card image of ${input.pokemonName}, a ${input.pokemonType}-type Pokemon, and create a magical animated video where:
- The Pokemon comes to life and moves naturally within the card frame
- Add sparkles, glowing effects, and ${input.pokemonType}-type elemental effects
- The background has subtle movement and atmospheric effects
- The card itself has a gentle holographic shimmer
- Keep the card frame intact and readable
- Make it feel like the Pokemon is truly alive and magical
- Use the provided card image as the base for animation`;
console.log('Generated animation prompt:', animationPrompt);
// Fetch and convert image to base64
console.log('Fetching image from URL:', input.cardImageUrl);
const imageResponse = await fetch(input.cardImageUrl);
if (!imageResponse.ok) {
return { error: `Failed to fetch image: ${imageResponse.statusText}` };
}
const imageBuffer = await imageResponse.arrayBuffer();
const base64Image = Buffer.from(imageBuffer).toString('base64');
const mimeType = imageResponse.headers.get('content-type') || 'image/jpeg';
console.log('Image converted to base64, mime type:', mimeType);
// Initialize Google GenAI client
const ai = new GoogleGenAI({ apiKey });
// Start video generation operation
console.log('Starting Veo video generation...');
let operation = await ai.models.generateVideos({
model: 'veo-2.0-generate-001',
prompt: animationPrompt,
image: {
mimeType: mimeType,
imageBytes: base64Image
},
config: {
numberOfVideos: 1,
aspectRatio: '9:16',
durationSeconds: 5, // Set duration to 5 seconds
}
});
// Poll for completion
console.log('Waiting for video generation to complete...');
let pollCount = 0;
const maxPolls = 60; // Maximum 10 minutes (60 * 10 seconds)
while (!operation.done && pollCount < maxPolls) {
await new Promise(resolve => setTimeout(resolve, 10000)); // Wait 10 seconds
try {
operation = await ai.operations.getVideosOperation({operation: operation});
pollCount++;
console.log(`Video generation status: ${operation.done ? 'completed' : 'in progress'} (poll ${pollCount}/${maxPolls})`);
} catch (pollError) {
console.error('Error polling for video status:', pollError);
break;
}
}
if (!operation.done) {
return {
error: 'Video generation timed out after 10 minutes. The process is taking longer than expected. Please try again later.'
};
}
// Check if video was generated successfully
if (!operation.response?.generatedVideos?.[0]?.video) {
console.error('No video in operation response:', operation.response);
return {
error: 'No video generated by Veo model'
};
}
const generatedVideo = operation.response.generatedVideos[0].video;
console.log('Video generation completed. Video data available:', Object.keys(generatedVideo));
// Handle video data - we need to download it if it's a URI since HTML video can't handle authenticated URLs
if (generatedVideo.uri) {
console.log('Video generated with URI, downloading video data...');
try {
// Download the video using the correct API key parameter format
const downloadUrl = `${generatedVideo.uri}&key=${apiKey}`;
console.log('Downloading from URL:', downloadUrl);
const videoResponse = await fetch(downloadUrl);
if (!videoResponse.ok) {
console.error('Failed to download video:', videoResponse.status, videoResponse.statusText);
return {
error: `Failed to download generated video: ${videoResponse.status} ${videoResponse.statusText}`
};
}
// Convert to base64 for storage/transmission
const videoBuffer = await videoResponse.arrayBuffer();
const videoBase64 = Buffer.from(videoBuffer).toString('base64');
console.log('Video successfully downloaded and converted to base64');
return {
videoBase64: videoBase64,
prompt: animationPrompt,
};
} catch (downloadError) {
console.error('Error downloading video:', downloadError);
return {
error: `Failed to download video: ${downloadError instanceof Error ? downloadError.message : 'Unknown error'}`
};
}
} else if (generatedVideo.videoBytes) {
console.log('Video generated with direct bytes');
return {
videoBase64: generatedVideo.videoBytes,
prompt: animationPrompt,
};
} else {
console.error('No usable video data in response');
return {
error: 'Video was generated but no accessible video data was provided'
};
}
} catch (error) {
console.error('Error in video generation setup:', error);
let errorMessage = 'Failed to set up video generation';
if (error instanceof Error) {
errorMessage = error.message;
}
return {
error: errorMessage,
};
}
}
@@ -0,0 +1,151 @@
// This file is automatically generated - edits will be lost!
'use server';
/**
* @fileOverview Uses Google Imagen4 to generate Pokemon cards based on an uploaded photo reference.
*
* - generatePokemonCardFromPhoto - A function that handles the photo-based card generation process.
* - GenerateFromPhotoInput - The input type for the generatePokemonCardFromPhoto function.
* - GenerateFromPhotoOutput - The return type for the generatePokemonCardFromPhoto function.
*/
import { ai } from '@/ai/genkit';
import { z } from 'genkit';
import OpenAI from 'openai';
import { getUserApiKeys } from '@/lib/firestore';
const GenerateFromPhotoInputSchema = z.object({
userId: z.string().min(1, "User ID is required"),
photoDataUri: z
.string()
.describe(
"A reference photo as a data URI that must include a MIME type and use Base64 encoding. Expected format: 'data:<mimetype>;base64,<encoded_data>'."
),
pokemonName: z.string().min(1, "Pokemon name is required"),
pokemonType: z.string().min(1, "Pokemon type is required"),
styleDescription: z.string().min(10, "Style description is required - describe how to adapt the photo"),
language: z.enum(['english', 'japanese', 'chinese', 'korean', 'spanish', 'french', 'german', 'italian']).default('english'),
hp: z.number().min(10).max(999).optional().default(130),
attackName1: z.string().optional().default("Quick Attack"),
attackDamage1: z.number().min(0).max(999).optional().default(60),
attackName2: z.string().optional().default("Special Move"),
attackDamage2: z.number().min(0).max(999).optional().default(90),
weakness: z.string().optional().default("Fighting"),
resistance: z.string().optional().default("Psychic"),
retreatCost: z.number().min(0).max(5).optional().default(2),
});
export type GenerateFromPhotoInput = z.infer<typeof GenerateFromPhotoInputSchema>;
const GenerateFromPhotoOutputSchema = 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 GenerateFromPhotoOutput = z.infer<typeof GenerateFromPhotoOutputSchema>;
export async function generatePokemonCardFromPhoto(input: GenerateFromPhotoInput): Promise<GenerateFromPhotoOutput> {
return generateFromPhotoFlow(input);
}
const generateFromPhotoFlow = ai.defineFlow(
{
name: 'generateFromPhotoFlow',
inputSchema: GenerateFromPhotoInputSchema,
outputSchema: GenerateFromPhotoOutputSchema,
},
async (params: GenerateFromPhotoInput) => {
try {
// Get user's API keys from Firestore
const userApiKeys = await getUserApiKeys(params.userId);
const apiKey = userApiKeys?.openaiApiKey || process.env.OPENAI_API_KEY;
if (!apiKey) {
return {
error: 'OpenAI API key is required. Please configure your API key in Settings or set OPENAI_API_KEY environment variable.'
};
}
const openai = new OpenAI({
apiKey: apiKey,
});
// Generate the detailed prompt for the Pokemon card based on photo
console.log("Generating Pokemon card using reference photo...");
// Generate the enhanced prompt for the Pokemon card
const enhancedPrompt = generatePhotoBasedCardPrompt(params);
console.log("Generated prompt for image generation:", enhancedPrompt);
// Use OpenAI responses.create with reference image for image generation
const response = await openai.responses.create({
model: "gpt-4.1",
input: [
{
role: "user",
content: [
{ type: "input_text", text: enhancedPrompt },
{
type: "input_image",
image_url: params.photoDataUri,
detail: "high"
},
],
},
],
tools: [{ type: "image_generation" }],
});
const imageData = response.output
.filter((output) => output.type === "image_generation_call")
.map((output) => output.result);
if (imageData.length > 0) {
const imageBase64 = imageData[0];
return {
imageBase64: imageBase64 || "",
prompt: enhancedPrompt,
};
} else {
console.log("No image generated, response output:", response.output);
return { error: 'No image generated from the reference photo.' };
}
} catch (error: any) {
console.error('Error generating Pokemon card from photo:', error);
return { error: `Failed to generate Pokemon card: ${error?.message || 'Unknown error'}` };
}
}
);
function generatePhotoBasedCardPrompt(params: GenerateFromPhotoInput): string {
const {
pokemonName,
pokemonType,
styleDescription,
language,
hp = 130,
attackName1 = "Quick Attack",
attackDamage1 = 60,
attackName2 = "Special Move",
attackDamage2 = 90,
weakness = "Fighting",
resistance = "Psychic",
retreatCost = 2
} = params;
const languageInstruction = language !== 'english' ? `Write all card information in ${language}. The Pokemon name, attacks and description should be translated accordingly.` : '';
return `A regular classic Pokémon trading card (not full art), featuring the Pokémon "${pokemonName}". Use the reference image as inspiration and adapt it to create a Pokemon card illustration. Use the base photo, but in Studio Ghibli style, ${styleDescription}
The card has a standard vertical layout with a detailed illustration in the main area. The Pokemon "${pokemonName}", a ${pokemonType} type, should be depicted as the main subject, incorporating elements and style from the reference photo.
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, while incorporating the visual style and elements from the reference photo.
Make sure the information is displayed clearly and the Pokemon illustration feels natural within the adapted photographic style.`;
}
+21 -15
View File
@@ -12,8 +12,10 @@
import { ai } from '@/ai/genkit';
import { z } from 'genkit';
import { GoogleGenAI } from '@google/genai';
import { getUserApiKeys } from '@/lib/firestore';
const GeneratePokemonCardInputSchema = z.object({
userId: z.string().min(1, "User ID is required"),
pokemonName: z.string().min(1, "Pokemon name is required"),
pokemonType: z.string().min(1, "Pokemon type is required"),
isIllustrationRare: z.boolean(),
@@ -52,28 +54,32 @@ const generatePokemonCardFlow = ai.defineFlow(
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 {
// Get user's API keys from Firestore
const userApiKeys = await getUserApiKeys(params.userId);
const apiKey = userApiKeys?.geminiApiKey || process.env.GOOGLE_GENAI_API_KEY || process.env.GEMINI_API_KEY;
if (!apiKey) {
return {
error: 'Gemini API key is required. Please configure your API key in Settings or set GOOGLE_GENAI_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);
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
aspectRatio: '3:4' // Pokemon cards are roughly 3:4 aspect ratio
},
});
+55 -34
View File
@@ -9,7 +9,7 @@
* - ScanPokemonCardOutput - The return type for the scanPokemonCard function.
*/
import {ai} from '@/ai/genkit';
import {ai, createUserAI} from '@/ai/genkit';
import {z} from 'genkit';
const ScanPokemonCardInputSchema = z.object({
@@ -18,6 +18,7 @@ const ScanPokemonCardInputSchema = z.object({
.describe(
"A photo of a Pokemon card, as a data URI that must include a MIME type and use Base64 encoding. Expected format: 'data:<mimetype>;base64,<encoded_data>'."
),
userId: z.string().describe("The user ID for personalized API key usage.").optional(),
});
export type ScanPokemonCardInput = z.infer<typeof ScanPokemonCardInputSchema>;
@@ -36,40 +37,60 @@ export async function scanPokemonCard(input: ScanPokemonCardInput): Promise<Scan
return scanPokemonCardFlow(input);
}
const scanPokemonCardPrompt = ai.definePrompt({
name: 'scanPokemonCardPrompt',
input: {schema: ScanPokemonCardInputSchema},
output: {schema: ScanPokemonCardOutputSchema},
prompt: `You are an expert Pokemon card appraiser. Use the following image to identify the card's name, first set it was introduced in, and rarity. If you are unable to determine any of these values, leave them blank.\n
Photo: {{media url=photoDataUri}}
\n
Respond using the following format:
{
"cardDetails": {
"name": "Pokemon card name",
"set": "Pokemon first card set it was introduced in",
"rarity": "Pokemon card rarity"
async function createScanPromptAndFlow(userAI: any) {
const scanPokemonCardPrompt = userAI.definePrompt({
name: 'scanPokemonCardPrompt',
input: {schema: ScanPokemonCardInputSchema},
output: {schema: ScanPokemonCardOutputSchema},
prompt: `You are an expert Pokemon card appraiser. Use the following image to identify the card's name, first set it was introduced in, and rarity. If you are unable to determine any of these values, leave them blank.\n
Photo: {{media url=photoDataUri}}
\n
Respond using the following format:
{
"cardDetails": {
"name": "Pokemon card name",
"set": "Pokemon first card set it was introduced in",
"rarity": "Pokemon card rarity"
}
}
}
\nIf you cannot identify the Pokemon card or if the image does not contain a Pokemon card, respond with the following format:
{
"error": "Error message describing why the card could not be identified"
}`,
});
\nIf you cannot identify the Pokemon card or if the image does not contain a Pokemon card, respond with the following format:
{
"error": "Error message describing why the card could not be identified"
}`,
});
const scanPokemonCardFlow = ai.defineFlow(
{
name: 'scanPokemonCardFlow',
inputSchema: ScanPokemonCardInputSchema,
outputSchema: ScanPokemonCardOutputSchema,
},
async input => {
try {
const {output} = await scanPokemonCardPrompt(input);
return output!;
} catch (error: any) {
console.error('Error during card scanning:', error);
return {error: 'Failed to scan Pokemon card.'};
const scanPokemonCardFlow = userAI.defineFlow(
{
name: 'scanPokemonCardFlow',
inputSchema: ScanPokemonCardInputSchema,
outputSchema: ScanPokemonCardOutputSchema,
},
async (input: ScanPokemonCardInput) => {
try {
const {output} = await scanPokemonCardPrompt(input);
return output!;
} catch (error: any) {
console.error('Error during card scanning:', error);
return {error: 'Failed to scan Pokemon card.'};
}
}
);
return scanPokemonCardFlow;
}
async function scanPokemonCardFlow(input: ScanPokemonCardInput): Promise<ScanPokemonCardOutput> {
try {
// Get user-specific AI instance if userId provided
const userAI = input.userId ? await createUserAI(input.userId) : ai;
// Create the flow with the appropriate AI instance
const flow = await createScanPromptAndFlow(userAI);
// Execute the flow
return await flow(input);
} catch (error: any) {
console.error('Error in scan flow setup:', error);
return {error: 'Failed to scan Pokemon card.'};
}
);
}
@@ -1,50 +0,0 @@
// Summarize Card Information Flow
'use server';
/**
* @fileOverview Summarizes the key information of a Pokemon card.
*
* - summarizeCardInformation - A function that summarizes the card information.
* - SummarizeCardInformationInput - The input type for the summarizeCardInformation function.
* - SummarizeCardInformationOutput - The return type for the summarizeCardInformation function.
*/
import {ai} from '@/ai/genkit';
import {z} from 'genkit';
const SummarizeCardInformationInputSchema = z.object({
name: z.string().describe('The name of the Pokemon card.'),
set: z.string().describe('The set the card belongs to.'),
rarity: z.string().describe('The rarity of the card.'),
});
export type SummarizeCardInformationInput = z.infer<typeof SummarizeCardInformationInputSchema>;
const SummarizeCardInformationOutputSchema = z.object({
summary: z.string().describe('A summary of the card information.'),
});
export type SummarizeCardInformationOutput = z.infer<typeof SummarizeCardInformationOutputSchema>;
export async function summarizeCardInformation(
input: SummarizeCardInformationInput
): Promise<SummarizeCardInformationOutput> {
return summarizeCardInformationFlow(input);
}
const summarizeCardInformationPrompt = ai.definePrompt({
name: 'summarizeCardInformationPrompt',
input: {schema: SummarizeCardInformationInputSchema},
output: {schema: SummarizeCardInformationOutputSchema},
prompt: `Summarize the key information of the following Pokemon card, including its name, set, and rarity:\n\nName: {{{name}}}\nSet: {{{set}}}\nRarity: {{{rarity}}}`,
});
const summarizeCardInformationFlow = ai.defineFlow(
{
name: 'summarizeCardInformationFlow',
inputSchema: SummarizeCardInformationInputSchema,
outputSchema: SummarizeCardInformationOutputSchema,
},
async input => {
const {output} = await summarizeCardInformationPrompt(input);
return output!;
}
);
+27 -4
View File
@@ -1,17 +1,40 @@
import {genkit} from 'genkit';
import {googleAI} from '@genkit-ai/googleai';
import { getUserApiKeys } from '@/lib/firestore';
// Validate API key exists
const apiKey = process.env.GOOGLE_GENAI_API_KEY;
// Default genkit instance with environment variables
const defaultApiKey = process.env.GOOGLE_GENAI_API_KEY;
if (!apiKey) {
if (!defaultApiKey) {
console.error('Missing Google AI API key. Please set GOOGLE_GENAI_API_KEY or GEMINI_API_KEY environment variable.');
throw new Error('Google AI API key is required for Genkit configuration');
}
export const ai = genkit({
plugins: [
googleAI({apiKey})
googleAI({apiKey: defaultApiKey})
],
model: 'googleai/gemini-2.5-pro-preview-05-06',
});
// Function to create genkit instance with user API key
export async function createUserAI(userId: string) {
try {
const userApiKeys = await getUserApiKeys(userId);
const userApiKey = userApiKeys?.geminiApiKey;
if (userApiKey) {
return genkit({
plugins: [
googleAI({apiKey: userApiKey})
],
model: 'googleai/gemini-2.5-pro-preview-05-06',
});
}
} catch (error) {
console.warn('Failed to get user API keys, falling back to default:', error);
}
// Fallback to default AI instance
return ai;
}
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server';
import { generatePokemonCardFromPhoto } from '@/ai/flows/generate-pokemon-card-from-photo';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields - including userId
const requiredFields = ['userId', 'photoDataUri', 'pokemonName', 'pokemonType', 'styleDescription'];
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 generatePokemonCardFromPhoto(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 }
);
}
}
+2 -2
View File
@@ -5,8 +5,8 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields
const requiredFields = ['pokemonName', 'pokemonType', 'backgroundDescription', 'pokemonDescription'];
// Validate required fields - including userId
const requiredFields = ['userId', 'pokemonName', 'pokemonType', 'backgroundDescription', 'pokemonDescription'];
const missingFields = requiredFields.filter(field => !body[field]);
if (missingFields.length > 0) {
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateVideoForExistingCard } from '@/lib/firestore';
export async function POST(request: NextRequest) {
try {
const { userId, cardId } = await request.json();
if (!userId || !cardId) {
return NextResponse.json(
{ error: 'User ID and Card ID are required' },
{ status: 400 }
);
}
// Start video generation in the background
await generateVideoForExistingCard(userId, cardId);
return NextResponse.json({
success: true,
message: 'Video generation started successfully'
});
} catch (error) {
console.error('Error in video generation API:', error);
const errorMessage = error instanceof Error ? error.message : 'Internal server error';
return NextResponse.json(
{ error: errorMessage },
{ status: 500 }
);
}
}
@@ -7,7 +7,11 @@ import { getCardById, updateCardInCollection } from '@/lib/firestore';
import type { PokemonCard } from '@/types';
import CardForm, { type CardFormInputs } from '@/components/cards/CardForm';
import { useToast } from '@/hooks/use-toast';
import { Loader2, AlertTriangle } from 'lucide-react';
import { Loader2, AlertTriangle, Download, FileDown } from 'lucide-react';
import { downloadCardImage, downloadCardVideo } from '@/utils/downloadUtils';
import { Button } from '@/components/ui/button';
import ShareButton from '@/components/ui/share-button';
import { serializeGenerationParams, serializePhotoGenerationParams } from '@/utils/generationParamsUtils';
export default function EditCardPage() {
const { user, loading: authLoading } = useAuth();
@@ -39,6 +43,38 @@ export default function EditCardPage() {
.then(fetchedCard => {
if (fetchedCard) {
setCard(fetchedCard);
// Check if this is an AI-generated card and redirect to appropriate generator
if (fetchedCard.isGenerated) {
if (fetchedCard.isPhotoGenerated && fetchedCard.photoGenerationParams) {
// Redirect to photo generator with parameters
const params = serializePhotoGenerationParams(fetchedCard.photoGenerationParams);
// Add original card data
params.append('originalCardId', fetchedCard.id);
params.append('originalCardName', fetchedCard.name);
params.append('originalCardImageUrl', fetchedCard.imageUrl);
router.replace(`/dashboard/generate-from-photo/edit?${params.toString()}`);
return;
} else if (fetchedCard.generationParams) {
// Redirect to regular generator with parameters
const params = serializeGenerationParams(fetchedCard.generationParams);
// Add original card data
params.append('originalCardId', fetchedCard.id);
params.append('originalCardName', fetchedCard.name);
params.append('originalCardImageUrl', fetchedCard.imageUrl);
router.replace(`/dashboard/generate/edit?${params.toString()}`);
return;
} else {
// AI-generated card but no parameters stored - show a message
toast({
title: "Unable to Edit",
description: "This AI-generated card was created before parameter storage was implemented. Please generate a new card.",
variant: "destructive"
});
router.replace('/dashboard/collection');
return;
}
}
} else {
setError('Card not found or you do not have permission to edit it.');
toast({ title: "Error", description: "Card not found.", variant: "destructive" });
@@ -58,16 +94,13 @@ export default function EditCardPage() {
setIsSubmitting(true);
try {
const updatedData: Partial<PokemonCard> = {
const updatedData = {
name: data.name,
set: data.set,
rarity: data.rarity,
// imageDataUrl is part of CardFormInputs but might not change if not re-uploaded
// For simplicity, we assume imageDataUrl is handled if it's part of the form
// and if image upload was part of the edit form (which it isn't currently for simplicity)
// For this schema, imageDataUrl is part of CardFormInputs, so it will be included.
// If image editing is not part of this form, ensure data.imageDataUrl is the original one.
imageDataUrl: data.imageDataUrl,
// Only include imageDataUrl if it's different from the original imageUrl
// This allows the backend to determine if a new image upload is needed
...(data.imageDataUrl !== card.imageUrl && { imageDataUrl: data.imageDataUrl })
};
await updateCardInCollection(user.uid, card.id, updatedData);
toast({ title: 'Card Updated', description: `${data.name} has been updated.` });
@@ -81,6 +114,55 @@ export default function EditCardPage() {
}
};
const handleDownloadCard = async () => {
if (!card) return;
try {
await downloadCardImage(card.imageUrl, card.name);
toast({
title: 'Download Started',
description: `${card.name} is being downloaded.`,
});
} catch (error) {
console.error('Download error:', error);
toast({
title: 'Download Failed',
description: 'Failed to download the card. Please try again.',
variant: 'destructive',
});
}
};
const handleDownloadVideo = async () => {
if (!card?.videoUrl) return;
try {
await downloadCardVideo(card.videoUrl, card.name);
toast({
title: 'Video Download Started',
description: `${card.name} video is being downloaded.`,
});
} catch (error) {
console.error('Video download error:', error);
toast({
title: 'Video Download Failed',
description: 'Failed to download the video. Please try again.',
variant: 'destructive',
});
}
};
// Helper function to check if video is ready for playback
const isVideoReady = () => {
return card?.videoUrl && (
card.videoGenerationStatus === 'completed' ||
card.videoUrl.includes('firebasestorage.googleapis.com') ||
card.videoUrl.includes('firebaseapp.com') ||
card.videoUrl.includes('googleapis.com/storage') ||
card.videoUrl.includes('localhost:9199') // Support emulator URLs
);
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-[calc(100vh-200px)]">
@@ -106,7 +188,38 @@ export default function EditCardPage() {
return (
<div>
<h1 className="text-3xl font-bold mb-8 text-center font-headline">Edit Card: {card.name}</h1>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-center font-headline">Edit Card: {card.name}</h1>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDownloadCard}
disabled={!card.imageUrl}
>
<Download className="mr-2 h-4 w-4" />
Download Card
</Button>
{isVideoReady() && (
<Button
variant="outline"
onClick={handleDownloadVideo}
className="border-green-200 bg-green-50 hover:bg-green-100 text-green-700"
>
<FileDown className="mr-2 h-4 w-4" />
Download Video
</Button>
)}
{card.isGenerated && (
<ShareButton
cardName={card.name}
cardImageUrl={card.imageUrl}
variant="outline"
size="default"
showText={true}
/>
)}
</div>
</div>
<CardForm
initialData={card}
onSubmit={handleUpdateCard}
@@ -0,0 +1,461 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import { useAuth } from '@/hooks/useAuth';
import { getCardById, generateVideoForExistingCard } from '@/lib/firestore';
import type { PokemonCard } from '@/types';
import { useToast } from '@/hooks/use-toast';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Loader2,
AlertTriangle,
Download,
FileDown,
Play,
Pause,
Video,
Clock,
RotateCcw,
Pencil,
ArrowLeft,
Sparkles,
AlertCircle
} from 'lucide-react';
import { downloadCardImage, downloadCardVideo } from '@/utils/downloadUtils';
import ShareButton from '@/components/ui/share-button';
export default function CardDetailsPage() {
const { user, loading: authLoading } = useAuth();
const router = useRouter();
const params = useParams();
const cardId = params.cardId as string;
const [card, setCard] = useState<PokemonCard | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showVideo, setShowVideo] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const { toast } = useToast();
useEffect(() => {
if (authLoading) return;
if (!user) {
router.replace('/login');
return;
}
if (user && cardId) {
setLoading(true);
setError(null);
getCardById(user.uid, cardId)
.then(fetchedCard => {
if (fetchedCard) {
setCard(fetchedCard);
} else {
setError('Card not found or you do not have permission to view it.');
toast({ title: "Error", description: "Card not found.", variant: "destructive" });
}
})
.catch(err => {
console.error("Error fetching card:", err);
setError((err as Error).message || 'Failed to load card details.');
toast({ title: "Error", description: "Failed to load card details.", variant: "destructive" });
})
.finally(() => setLoading(false));
}
}, [user, cardId, router, toast, authLoading]);
// Helper function to check if video is ready for playback
const isVideoReady = () => {
return card?.videoUrl && (
card.videoGenerationStatus === 'completed' ||
card.videoUrl.includes('firebasestorage.googleapis.com') ||
card.videoUrl.includes('firebaseapp.com') ||
card.videoUrl.includes('googleapis.com/storage') ||
card.videoUrl.includes('localhost:9199') // Support emulator URLs
);
};
const handleDownloadCard = async () => {
if (!card) return;
try {
await downloadCardImage(card.imageUrl, card.name);
toast({
title: 'Download Started',
description: `${card.name} is being downloaded.`,
});
} catch (error) {
console.error('Download error:', error);
toast({
title: 'Download Failed',
description: 'Failed to download the card. Please try again.',
variant: 'destructive',
});
}
};
const handleDownloadVideo = async () => {
if (!card?.videoUrl) return;
try {
await downloadCardVideo(card.videoUrl, card.name);
toast({
title: 'Video Download Started',
description: `${card.name} video is being downloaded.`,
});
} catch (error) {
console.error('Video download error:', error);
toast({
title: 'Video Download Failed',
description: 'Failed to download the video. Please try again.',
variant: 'destructive',
});
}
};
const handleGenerateVideo = async () => {
if (!card?.id) return;
setIsGeneratingVideo(true);
// Update the card status locally to show the banner
setCard(prev => prev ? { ...prev, videoGenerationStatus: 'generating' } : null);
try {
await generateVideoForExistingCard(card.userId, card.id);
toast({
title: 'Video Generation Started',
description: `Video generation for ${card.name} has been started. This may take a few minutes.`,
});
} catch (error) {
console.error('Video generation error:', error);
toast({
title: 'Video Generation Failed',
description: 'Failed to start video generation. Please try again.',
variant: 'destructive',
});
// Revert the status if there was an error
setCard(prev => prev ? { ...prev, videoGenerationStatus: 'failed' } : null);
} finally {
setIsGeneratingVideo(false);
}
};
const getVideoStatusBadge = () => {
if (!card?.videoGenerationStatus || card.videoGenerationStatus === 'pending') return null;
const statusConfig = {
generating: { icon: Clock, text: 'Generating Video...', variant: 'secondary' as const },
completed: {
icon: Video,
text: isVideoReady() ? 'Live Video Ready' : 'Video Processing...',
variant: 'default' as const
},
failed: { icon: AlertCircle, text: 'Video Failed', variant: 'destructive' as const },
};
const config = statusConfig[card.videoGenerationStatus];
if (!config) return null;
const { icon: Icon, text, variant } = config;
return (
<Badge variant={variant} className="flex items-center gap-1">
<Icon className="h-3 w-3" />
{text}
</Badge>
);
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-[calc(100vh-200px)]">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
</div>
);
}
if (error) {
return (
<div className="text-center py-10 text-destructive">
<AlertTriangle className="mx-auto h-12 w-12 mb-4" />
<h2 className="text-xl font-semibold mb-2">Error Loading Card</h2>
<p>{error}</p>
<Button onClick={() => router.push('/dashboard/collection')} className="mt-4">
Back to Collection
</Button>
</div>
);
}
if (!card) {
return (
<div className="text-center py-10">
<h2 className="text-xl font-semibold mb-2">Card not found</h2>
<Button onClick={() => router.push('/dashboard/collection')}>
Back to Collection
</Button>
</div>
);
}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="outline" onClick={() => router.push('/dashboard/collection')}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Collection
</Button>
<div>
<h1 className="text-3xl font-bold font-headline">{card.name}</h1>
<p className="text-muted-foreground">Set: {card.set} | Rarity: {card.rarity}</p>
</div>
</div>
<div className="flex items-center gap-2">
{card.isGenerated && (
<Badge variant="secondary" className="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>
)}
{getVideoStatusBadge()}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Card Display */}
<Card className="overflow-hidden">
<CardContent className="p-6">
<div className="relative w-full max-w-[400px] mx-auto aspect-[63/88]">
{showVideo && card.videoUrl ? (
<div className="relative w-full h-full">
{card.videoUrl.includes('firebasestorage.googleapis.com') ||
card.videoUrl.includes('firebaseapp.com') ||
card.videoUrl.includes('googleapis.com/storage') ||
card.videoUrl.includes('localhost:9199') ? (
<video
src={card.videoUrl}
autoPlay
loop
muted
playsInline
className="w-full h-full object-contain rounded-lg shadow-lg"
onError={() => {
console.error('Video playback error for Firebase URL:', card.videoUrl);
setShowVideo(false);
}}
/>
) : card.videoUrl.startsWith('data:video/') || !card.videoUrl.startsWith('http') ? (
<video
src={card.videoUrl.startsWith('data:') ? card.videoUrl : `data:video/mp4;base64,${card.videoUrl}`}
autoPlay
loop
muted
playsInline
className="w-full h-full object-contain rounded-lg shadow-lg"
onError={() => {
console.error('Video playback error for base64 data');
setShowVideo(false);
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-100 rounded-lg">
<div className="text-center text-sm text-gray-600">
<p>Video processing in progress...</p>
<p className="text-xs mt-1">Please refresh in a moment</p>
</div>
</div>
)}
<Button
variant="default"
size="lg"
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white border-2 border-white/50 shadow-2xl backdrop-blur-sm"
onClick={() => setShowVideo(false)}
>
<Pause className="h-6 w-6 mr-2" />
Pause Video
</Button>
</div>
) : (
<>
<Image
src={card.imageUrl}
alt={card.name}
fill
className="object-contain rounded-lg shadow-lg"
data-ai-hint="pokemon card"
sizes="(max-width: 768px) 100vw, 50vw"
/>
{isVideoReady() && (
<Button
variant="default"
size="lg"
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white border-2 border-white/50 shadow-2xl backdrop-blur-sm"
onClick={() => setShowVideo(true)}
>
<Play className="h-6 w-6 mr-2" />
Play Video
</Button>
)}
</>
)}
</div>
</CardContent>
</Card>
{/* Card Actions & Info */}
<div className="space-y-6">
{/* Video Generation Status */}
{card.videoGenerationStatus === 'generating' && (
<Card className="bg-blue-50 border border-blue-200">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<div>
<h3 className="text-sm font-medium text-blue-900">
Generating Video...
</h3>
<p className="text-xs text-blue-600 mt-1">
This may take a few minutes. The page will refresh automatically.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<Card>
<CardHeader>
<CardTitle>Actions</CardTitle>
<CardDescription>
Download, edit, or generate video for your card
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Download Card */}
<Button
variant="outline"
onClick={handleDownloadCard}
disabled={!card.imageUrl}
className="w-full"
>
<Download className="h-4 w-4 mr-2" />
Download Card Image
</Button>
{/* Download Video */}
{isVideoReady() && (
<Button
variant="outline"
onClick={handleDownloadVideo}
className="w-full border-green-200 bg-green-50 hover:bg-green-100 text-green-700"
>
<FileDown className="h-4 w-4 mr-2" />
Download Video
</Button>
)}
{/* Generate Video */}
{!card.videoUrl && card.videoGenerationStatus !== 'generating' && (
<Button
variant="outline"
onClick={handleGenerateVideo}
disabled={isGeneratingVideo}
className="w-full bg-gradient-to-r from-purple-50 to-pink-50 hover:from-purple-100 hover:to-pink-100 border-purple-200 text-purple-700"
>
<Video className="h-4 w-4 mr-2" />
{isGeneratingVideo ? 'Starting...' : 'Make Card Live'}
</Button>
)}
{/* Retry Video Generation */}
{card.videoGenerationStatus === 'failed' && (
<Button
variant="outline"
onClick={handleGenerateVideo}
disabled={isGeneratingVideo}
className="w-full border-orange-200 bg-orange-50 hover:bg-orange-100 text-orange-700"
>
<RotateCcw className="h-4 w-4 mr-2" />
{isGeneratingVideo ? 'Starting...' : 'Retry Video Generation'}
</Button>
)}
{/* Edit Card */}
<Button variant="outline" asChild className="w-full">
<Link href={`/dashboard/collection/${card.id}/edit`}>
<Pencil className="h-4 w-4 mr-2" />
Edit Card
</Link>
</Button>
{/* Share Card */}
{card.isGenerated && (
<ShareButton
cardName={card.name}
cardImageUrl={card.imageUrl}
variant="outline"
size="default"
className="w-full"
showText={true}
/>
)}
</CardContent>
</Card>
{/* Card Information */}
<Card>
<CardHeader>
<CardTitle>Card Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-muted-foreground">Set:</span>
<p>{card.set}</p>
</div>
<div>
<span className="font-medium text-muted-foreground">Rarity:</span>
<p>{card.rarity}</p>
</div>
{card.isGenerated && (
<>
<div>
<span className="font-medium text-muted-foreground">Type:</span>
<p>AI Generated</p>
</div>
{card.isPhotoGenerated && (
<div>
<span className="font-medium text-muted-foreground">Source:</span>
<p>Photo-based</p>
</div>
)}
</>
)}
{card.videoUrl && (
<div className="col-span-2">
<span className="font-medium text-muted-foreground">Video Status:</span>
<p className="flex items-center gap-2 mt-1">
{getVideoStatusBadge()}
</p>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+127 -3
View File
@@ -6,7 +6,7 @@ import { getUserCards, deleteCardFromCollection } from '@/lib/firestore';
import type { PokemonCard } from '@/types';
import CardItem from '@/components/cards/CardItem';
import { Button } from '@/components/ui/button';
import { Loader2, PlusCircle, AlertTriangle, Sparkles } from 'lucide-react';
import { Loader2, PlusCircle, AlertTriangle, Sparkles, Camera, RefreshCw } from 'lucide-react';
import Link from 'next/link';
import { useToast } from '@/hooks/use-toast';
@@ -26,12 +26,55 @@ export default function CollectionPage() {
}
}, [user]);
// Set up periodic refresh for cards that are generating videos
useEffect(() => {
const hasGeneratingVideos = cards.some(card => card.videoGenerationStatus === 'generating');
if (!hasGeneratingVideos) return;
console.log('Setting up automatic refresh for video generation...');
const interval = setInterval(() => {
console.log('Refreshing cards to check for video generation updates...');
fetchCards();
}, 5000); // Check every 15 seconds (more frequent for better UX)
return () => {
console.log('Clearing automatic refresh interval');
clearInterval(interval);
};
}, [cards, user]);
const fetchCards = async () => {
if (!user) return;
setLoading(true);
setError(null);
try {
const userCards = await getUserCards(user.uid);
// Check for video generation status changes (only for background updates)
userCards.forEach(newCard => {
const existingCard = cards.find(c => c.id === newCard.id);
if (existingCard) {
// Only show notifications for status changes from database updates (not manual updates)
if (existingCard.videoGenerationStatus === 'generating' &&
newCard.videoGenerationStatus === 'completed' &&
newCard.videoUrl) {
toast({
title: 'Video Ready!',
description: `Your video for ${newCard.name} is now available.`,
});
}
else if (existingCard.videoGenerationStatus === 'generating' &&
newCard.videoGenerationStatus === 'failed') {
toast({
title: 'Video Generation Failed',
description: `Failed to generate video for ${newCard.name}. You can try again.`,
variant: 'destructive',
});
}
}
});
setCards(userCards);
} catch (e) {
console.error("Error fetching cards:", e);
@@ -57,6 +100,47 @@ export default function CollectionPage() {
}
};
const handleUpdateCard = (updatedCard: PokemonCard) => {
setCards(prevCards => {
const updated = prevCards.map(card => {
if (card.id === updatedCard.id) {
const oldStatus = card.videoGenerationStatus;
const newStatus = updatedCard.videoGenerationStatus;
// Check if video generation status changed to completed
if (oldStatus === 'generating' &&
newStatus === 'completed' &&
updatedCard.videoUrl) {
toast({
title: 'Video Ready!',
description: `Your video for ${updatedCard.name} is now available.`,
});
}
// Check if video generation failed
else if (oldStatus === 'generating' &&
newStatus === 'failed') {
toast({
title: 'Video Generation Failed',
description: `Failed to generate video for ${updatedCard.name}. You can try again.`,
variant: 'destructive',
});
}
// Show immediate feedback when generation starts
else if (!oldStatus && newStatus === 'generating') {
toast({
title: 'Video Generation Started',
description: `Generating video for ${updatedCard.name}. This page will refresh automatically.`,
});
}
return updatedCard;
}
return card;
});
return updated;
});
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-[calc(100vh-200px)]">
@@ -79,7 +163,18 @@ export default function CollectionPage() {
return (
<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>
<div className="flex items-center gap-4">
<h1 className="text-3xl font-bold font-headline">My Card Collection</h1>
<Button
variant="ghost"
size="sm"
onClick={fetchCards}
disabled={loading}
className="opacity-70 hover:opacity-100"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href="/dashboard/generate">
@@ -94,6 +189,29 @@ export default function CollectionPage() {
</div>
</div>
{/* Video Generation Status Banner */}
{(() => {
const generatingVideos = cards.filter(card => card.videoGenerationStatus === 'generating');
if (generatingVideos.length > 0) {
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<div>
<h3 className="text-sm font-medium text-blue-900">
Generating {generatingVideos.length} video{generatingVideos.length > 1 ? 's' : ''}
</h3>
<p className="text-xs text-blue-700">
{generatingVideos.map(card => card.name).join(', ')} - This page will refresh automatically
</p>
</div>
</div>
</div>
);
}
return null;
})()}
{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>
@@ -105,6 +223,11 @@ export default function CollectionPage() {
<Sparkles className="mr-2 h-5 w-5" /> Generate a Card
</Link>
</Button>
<Button asChild variant="outline">
<Link href="/dashboard/generate-from-photo">
<Camera className="mr-2 h-5 w-5" /> Photo Card
</Link>
</Button>
<Button asChild>
<Link href="/dashboard/scan">
<PlusCircle className="mr-2 h-5 w-5" /> Scan a Card
@@ -113,12 +236,13 @@ export default function CollectionPage() {
</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 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{cards.map(card => (
<CardItem
key={card.id}
card={card}
onDelete={handleDeleteCard}
onUpdate={handleUpdateCard}
isDeleting={deletingCardId === card.id}
/>
))}
@@ -0,0 +1,331 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { PhotoCardGeneratorFormOnly } from '@/components/cards/PhotoCardGeneratorFormOnly';
import { addCardToCollection } from '@/lib/firestore';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2, Save } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { compressBase64Image, getBase64Size, formatBytes } from '@/utils/imageUtils';
import { parsePhotoGenerationParams } from '@/utils/generationParamsUtils';
import type { GenerateFromPhotoInput } from '@/components/cards/PhotoCardGeneratorFormOnly';
import Image from 'next/image';
export default function EditGenerateFromPhotoPage() {
const { user } = useAuth();
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string; params?: any } | null>(null);
const [originalCard, setOriginalCard] = useState<{ id: string; name: string; imageUrl: string } | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [initialValues, setInitialValues] = useState<Partial<GenerateFromPhotoInput> | undefined>(undefined);
const [showComparison, setShowComparison] = useState(true);
useEffect(() => {
// Parse parameters from URL search params
const params = parsePhotoGenerationParams(searchParams);
setInitialValues(params);
// Extract original card data
const originalCardId = searchParams.get('originalCardId');
const originalCardName = searchParams.get('originalCardName');
const originalCardImageUrl = searchParams.get('originalCardImageUrl');
if (originalCardId && originalCardName && originalCardImageUrl) {
setOriginalCard({
id: originalCardId,
name: originalCardName,
imageUrl: originalCardImageUrl,
});
}
}, [searchParams]);
const handleCardGenerated = (imageBase64: string, prompt: string, params: any) => {
setGeneratedCard({ imageBase64, prompt, params });
};
const handleSaveToCollection = async () => {
if (!user || !generatedCard) return;
setIsSaving(true);
try {
// Convert base64 to data URL
const originalImageDataUrl = `data:image/jpeg;base64,${generatedCard.imageBase64}`;
// Check original size
const originalSize = getBase64Size(generatedCard.imageBase64);
console.log(`Original image size: ${formatBytes(originalSize)}`);
// Compress the image if it's too large (> 800KB to leave some buffer)
let finalImageDataUrl = originalImageDataUrl;
if (originalSize > 800 * 1024) {
console.log('Compressing image for Firestore storage...');
try {
finalImageDataUrl = await compressBase64Image(originalImageDataUrl, 400, 560, 0.7);
const compressedSize = getBase64Size(finalImageDataUrl.split(',')[1]);
console.log(`Compressed image size: ${formatBytes(compressedSize)}`);
// If still too large, compress more aggressively
if (compressedSize > 800 * 1024) {
console.log('Further compressing image...');
finalImageDataUrl = await compressBase64Image(originalImageDataUrl, 300, 420, 0.5);
const finalSize = getBase64Size(finalImageDataUrl.split(',')[1]);
console.log(`Final compressed size: ${formatBytes(finalSize)}`);
}
} catch (compressionError) {
console.warn('Image compression failed, using original:', compressionError);
// If compression fails, we'll try with the original and let Firestore handle the error
}
}
// Exclude photoDataUri from params when saving to Firestore (too large for document storage)
const { photoDataUri, ...paramsWithoutPhoto } = generatedCard.params;
await addCardToCollection(user.uid, {
name: 'Photo-Generated Pokemon Card',
set: 'AI Photo Generated',
rarity: 'Photo Special',
imageDataUrl: finalImageDataUrl,
isGenerated: true,
isPhotoGenerated: true,
prompt: generatedCard.prompt,
photoGenerationParams: paramsWithoutPhoto,
});
toast({
title: 'Card Saved',
description: 'Your photo-generated Pokemon card has been added to your collection!',
});
// Clear the generated card after saving
setGeneratedCard(null);
router.push('/dashboard/collection');
} 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);
}
};
if (initialValues === undefined) {
return <div className="container mx-auto px-4 py-8">Loading...</div>;
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Edit Pokemon Card Generator from Photo</h1>
<p className="text-muted-foreground">
Modify your photo-based Pokemon card parameters and regenerate the card
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Form Section - Takes up 2 columns */}
<div className="xl:col-span-2">
<PhotoCardGeneratorFormOnly onCardGenerated={handleCardGenerated} initialValues={initialValues} />
</div>
{/* Card Preview Section - Takes up 1 column */}
<div className="xl:col-span-1">
<div className="sticky top-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Card Preview</h2>
{generatedCard && originalCard && (
<div className="flex items-center space-x-2">
<Button
variant={showComparison ? "default" : "outline"}
size="sm"
onClick={() => setShowComparison(true)}
>
Compare
</Button>
<Button
variant={!showComparison ? "default" : "outline"}
size="sm"
onClick={() => setShowComparison(false)}
>
New Only
</Button>
</div>
)}
</div>
{generatedCard && originalCard ? (
showComparison ? (
/* Show comparison when both cards exist and comparison is enabled */
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-center space-x-2">
<span>Comparison</span>
</CardTitle>
<CardDescription className="text-center">
Compare your original card with the newly generated one
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Original Card */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2 text-center">Original Card</h4>
<div className="relative w-full max-w-xs mx-auto">
<Image
src={originalCard.imageUrl}
alt={originalCard.name}
width={200}
height={280}
className="w-full h-auto rounded-lg shadow-md border"
/>
</div>
</div>
{/* Divider */}
<div className="flex items-center justify-center">
<div className="flex-1 border-t border-gray-200"></div>
<span className="px-3 text-sm font-medium text-muted-foreground bg-background">VS</span>
<div className="flex-1 border-t border-gray-200"></div>
</div>
{/* New Generated Card */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2 text-center">New Generated Card</h4>
<div className="relative w-full max-w-xs mx-auto">
<Image
src={`data:image/jpeg;base64,${generatedCard.imageBase64}`}
alt="Generated Pokemon Card"
width={200}
height={280}
className="w-full h-auto rounded-lg shadow-md border"
/>
</div>
</div>
<Button onClick={handleSaveToCollection} disabled={isSaving} className="w-full">
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving to Collection...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save New Card to Collection
</>
)}
</Button>
</CardContent>
</Card>
) : (
/* Show only new card when comparison is disabled */
<Card>
<CardHeader>
<CardTitle>New Generated Card</CardTitle>
<CardDescription>
Your updated photo-inspired Pokemon card is ready!
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative w-full max-w-sm mx-auto">
<Image
src={`data:image/jpeg;base64,${generatedCard.imageBase64}`}
alt="Generated Pokemon Card"
width={300}
height={420}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<Button onClick={handleSaveToCollection} disabled={isSaving} className="w-full">
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving to Collection...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save New Card to Collection
</>
)}
</Button>
</CardContent>
</Card>
)
) : generatedCard ? (
/* Show only new card if no original */
<Card>
<CardHeader>
<CardTitle>New Generated Card</CardTitle>
<CardDescription>
Your updated photo-inspired Pokemon card is ready!
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative w-full max-w-sm mx-auto">
<Image
src={`data:image/jpeg;base64,${generatedCard.imageBase64}`}
alt="Generated Pokemon Card"
width={300}
height={420}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<Button onClick={handleSaveToCollection} disabled={isSaving} className="w-full">
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving to Collection...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save to Collection
</>
)}
</Button>
</CardContent>
</Card>
) : originalCard ? (
/* Show only original card */
<Card>
<CardHeader>
<CardTitle>Original Card</CardTitle>
<CardDescription>
{originalCard.name} - This card will be replaced when you generate a new one
</CardDescription>
</CardHeader>
<CardContent>
<div className="relative w-full max-w-sm mx-auto">
<Image
src={originalCard.imageUrl}
alt={originalCard.name}
width={300}
height={420}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
</CardContent>
</Card>
) : (
/* No cards available */
<Card>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">
<p>Generate a new card to see it here</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
'use client';
import { useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { PhotoCardGenerator } from '@/components/cards/PhotoCardGenerator';
import { addCardToCollection } from '@/lib/firestore';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Loader2, Save } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { compressBase64Image, getBase64Size, formatBytes } from '@/utils/imageUtils';
export default function GenerateFromPhotoPage() {
const { user } = useAuth();
const { toast } = useToast();
const router = useRouter();
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string; params?: any } | null>(null);
const [isSaving, setIsSaving] = useState(false);
const handleCardGenerated = (imageBase64: string, prompt: string, params: any) => {
setGeneratedCard({ imageBase64, prompt, params });
};
const handleSaveToCollection = async () => {
if (!user || !generatedCard) return;
setIsSaving(true);
try {
// Convert base64 to data URL
const originalImageDataUrl = `data:image/jpeg;base64,${generatedCard.imageBase64}`;
// Check original size
const originalSize = getBase64Size(generatedCard.imageBase64);
console.log(`Original image size: ${formatBytes(originalSize)}`);
// Compress the image if it's too large (> 800KB to leave some buffer)
let finalImageDataUrl = originalImageDataUrl;
if (originalSize > 800 * 1024) {
console.log('Compressing image for Firestore storage...');
try {
finalImageDataUrl = await compressBase64Image(originalImageDataUrl, 400, 560, 0.7);
const compressedSize = getBase64Size(finalImageDataUrl.split(',')[1]);
console.log(`Compressed image size: ${formatBytes(compressedSize)}`);
// If still too large, compress more aggressively
if (compressedSize > 800 * 1024) {
console.log('Further compressing image...');
finalImageDataUrl = await compressBase64Image(originalImageDataUrl, 300, 420, 0.5);
const finalSize = getBase64Size(finalImageDataUrl.split(',')[1]);
console.log(`Final compressed size: ${formatBytes(finalSize)}`);
}
} catch (compressionError) {
console.warn('Image compression failed, using original:', compressionError);
// If compression fails, we'll try with the original and let Firestore handle the error
}
}
// Exclude photoDataUri from params when saving to Firestore (too large for document storage)
const { photoDataUri, ...paramsWithoutPhoto } = generatedCard.params;
await addCardToCollection(user.uid, {
name: 'Photo-Generated Pokemon Card',
set: 'AI Photo Generated',
rarity: 'Photo Special',
imageDataUrl: finalImageDataUrl,
isGenerated: true,
isPhotoGenerated: true,
prompt: generatedCard.prompt,
photoGenerationParams: paramsWithoutPhoto,
});
toast({
title: 'Card Saved',
description: 'Your photo-generated Pokemon card has been added to your collection!',
});
// Clear the generated card after saving
setGeneratedCard(null);
router.push('/dashboard/collection');
} 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 from Photo</h1>
<p className="text-muted-foreground">
Upload your own photo and transform it into a custom Pokemon card using AI-powered image generation
</p>
</div>
<PhotoCardGenerator onCardGenerated={handleCardGenerated} />
{generatedCard && (
<Card className="mt-8">
<CardHeader>
<CardTitle>Save Your Card</CardTitle>
<CardDescription>
Your photo-inspired Pokemon card is ready! Save it to your collection.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleSaveToCollection} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving to Collection...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save to Collection
</>
)}
</Button>
</CardContent>
</Card>
)}
</div>
);
}
+307
View File
@@ -0,0 +1,307 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { CardGeneratorFormOnly } from '@/components/cards/CardGeneratorFormOnly';
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, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { compressBase64Image, getBase64Size, formatBytes } from '@/utils/imageUtils';
import { parseGenerationParams } from '@/utils/generationParamsUtils';
import type { GeneratePokemonCardInput } from '@/components/cards/CardGeneratorFormOnly';
import Image from 'next/image';
export default function EditGenerateCardPage() {
const { user } = useAuth();
const { toast } = useToast();
const searchParams = useSearchParams();
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string; params?: any } | null>(null);
const [originalCard, setOriginalCard] = useState<{ id: string; name: string; imageUrl: string } | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [initialValues, setInitialValues] = useState<Partial<GeneratePokemonCardInput> | undefined>(undefined);
const [showComparison, setShowComparison] = useState(true);
useEffect(() => {
// Parse parameters from URL search params
const params = parseGenerationParams(searchParams);
setInitialValues(params);
// Extract original card data
const originalCardId = searchParams.get('originalCardId');
const originalCardName = searchParams.get('originalCardName');
const originalCardImageUrl = searchParams.get('originalCardImageUrl');
if (originalCardId && originalCardName && originalCardImageUrl) {
setOriginalCard({
id: originalCardId,
name: originalCardName,
imageUrl: originalCardImageUrl,
});
}
}, [searchParams]);
const handleCardGenerated = (imageBase64: string, prompt: string, params: any) => {
setGeneratedCard({ imageBase64, prompt, params });
};
const handleSaveToCollection = async () => {
if (!user || !generatedCard) return;
setIsSaving(true);
try {
// Convert base64 to data URL
let imageDataUrl = `data:image/jpeg;base64,${generatedCard.imageBase64}`;
// Check image size and compress if needed
const originalSize = getBase64Size(generatedCard.imageBase64);
console.log(`Original image size: ${formatBytes(originalSize)}`);
// Firestore has a 1MB limit for document fields, so compress if needed
if (originalSize > 800000) { // 800KB threshold to be safe
console.log('Image is too large, compressing...');
try {
// Try first level compression
let compressedBase64 = await compressBase64Image(generatedCard.imageBase64, 400, 560, 0.7);
let compressedSize = getBase64Size(compressedBase64);
console.log(`Compressed image size (first attempt): ${formatBytes(compressedSize)}`);
// If still too large, compress more aggressively
if (compressedSize > 800000) {
console.log('Still too large, applying aggressive compression...');
compressedBase64 = await compressBase64Image(generatedCard.imageBase64, 300, 420, 0.5);
compressedSize = getBase64Size(compressedBase64);
console.log(`Final compressed image size: ${formatBytes(compressedSize)}`);
}
imageDataUrl = `data:image/jpeg;base64,${compressedBase64}`;
} catch (compressionError) {
console.error('Error compressing image:', compressionError);
// Fall back to original image
}
}
await addCardToCollection(user.uid, {
name: 'Generated Pokemon Card',
set: 'AI Generated',
rarity: 'Special',
imageDataUrl: imageDataUrl,
isGenerated: true,
prompt: generatedCard.prompt,
generationParams: generatedCard.params,
});
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);
}
};
if (initialValues === undefined) {
return <div className="container mx-auto px-4 py-8">Loading...</div>;
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Edit Pokemon Card Generator</h1>
<p className="text-muted-foreground">
Modify your Pokemon card parameters and regenerate the card
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Form Section - Takes up 2 columns */}
<div className="xl:col-span-2">
<CardGeneratorFormOnly onCardGenerated={handleCardGenerated} initialValues={initialValues} />
</div>
{/* Card Preview Section - Takes up 1 column */}
<div className="xl:col-span-1">
<div className="sticky top-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Card Preview</h2>
{generatedCard && originalCard && (
<div className="flex items-center space-x-2">
<Button
variant={showComparison ? "default" : "outline"}
size="sm"
onClick={() => setShowComparison(true)}
>
Compare
</Button>
<Button
variant={!showComparison ? "default" : "outline"}
size="sm"
onClick={() => setShowComparison(false)}
>
New Only
</Button>
</div>
)}
</div>
{generatedCard && originalCard ? (
showComparison ? (
/* Show comparison when both cards exist and comparison is enabled */
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-center space-x-2">
<span>Comparison</span>
</CardTitle>
<CardDescription className="text-center">
Compare your original card with the newly generated one
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Original Card */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2 text-center">Original Card</h4>
<div className="relative w-full max-w-xs mx-auto">
<Image
src={originalCard.imageUrl}
alt={originalCard.name}
width={200}
height={280}
className="w-full h-auto rounded-lg shadow-md border"
/>
</div>
</div>
{/* Divider */}
<div className="flex items-center justify-center">
<div className="flex-1 border-t border-gray-200"></div>
<span className="px-3 text-sm font-medium text-muted-foreground bg-background">VS</span>
<div className="flex-1 border-t border-gray-200"></div>
</div>
{/* New Generated Card */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2 text-center">New Generated Card</h4>
<div className="relative w-full max-w-xs mx-auto">
<Image
src={`data:image/jpeg;base64,${generatedCard.imageBase64}`}
alt="Generated Pokemon Card"
width={200}
height={280}
className="w-full h-auto rounded-lg shadow-md border"
/>
</div>
</div>
<Button
onClick={handleSaveToCollection}
disabled={isSaving}
className="w-full"
>
{isSaving ? 'Saving...' : 'Save New Card to Collection'}
</Button>
</CardContent>
</Card>
) : (
/* Show only new card when comparison is disabled */
<Card>
<CardHeader>
<CardTitle>New Generated Card</CardTitle>
<CardDescription>
Your updated Pokemon card is ready!
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative w-full max-w-sm mx-auto">
<Image
src={`data:image/jpeg;base64,${generatedCard.imageBase64}`}
alt="Generated Pokemon Card"
width={300}
height={420}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<Button
onClick={handleSaveToCollection}
disabled={isSaving}
className="w-full"
>
{isSaving ? 'Saving...' : 'Save New Card to Collection'}
</Button>
</CardContent>
</Card>
)
) : generatedCard ? (
/* Show only new card if no original */
<Card>
<CardHeader>
<CardTitle>New Generated Card</CardTitle>
<CardDescription>
Your updated Pokemon card is ready!
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative w-full max-w-sm mx-auto">
<Image
src={`data:image/jpeg;base64,${generatedCard.imageBase64}`}
alt="Generated Pokemon Card"
width={300}
height={420}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<Button
onClick={handleSaveToCollection}
disabled={isSaving}
className="w-full"
>
{isSaving ? 'Saving...' : 'Save to Collection'}
</Button>
</CardContent>
</Card>
) : originalCard ? (
/* Show only original card */
<Card>
<CardHeader>
<CardTitle>Original Card</CardTitle>
<CardDescription>
{originalCard.name} - This card will be replaced when you generate a new one
</CardDescription>
</CardHeader>
<CardContent>
<div className="relative w-full max-w-sm mx-auto">
<Image
src={originalCard.imageUrl}
alt={originalCard.name}
width={300}
height={420}
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
</CardContent>
</Card>
) : (
/* No cards available */
<Card>
<CardContent className="pt-6">
<div className="text-center text-muted-foreground">
<p>Generate a new card to see it here</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}
+34 -4
View File
@@ -7,15 +7,16 @@ 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';
import { compressBase64Image, getBase64Size, formatBytes } from '@/utils/imageUtils';
export default function GenerateCardPage() {
const { user } = useAuth();
const { toast } = useToast();
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string } | null>(null);
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string; params?: any } | null>(null);
const [isSaving, setIsSaving] = useState(false);
const handleCardGenerated = (imageBase64: string, prompt: string) => {
setGeneratedCard({ imageBase64, prompt });
const handleCardGenerated = (imageBase64: string, prompt: string, params: any) => {
setGeneratedCard({ imageBase64, prompt, params });
};
const handleSaveToCollection = async () => {
@@ -24,7 +25,35 @@ export default function GenerateCardPage() {
setIsSaving(true);
try {
// Convert base64 to data URL
const imageDataUrl = `data:image/jpeg;base64,${generatedCard.imageBase64}`;
let imageDataUrl = `data:image/jpeg;base64,${generatedCard.imageBase64}`;
// Check image size and compress if needed
const originalSize = getBase64Size(generatedCard.imageBase64);
console.log(`Original image size: ${formatBytes(originalSize)}`);
// Firestore has a 1MB limit for document fields, so compress if needed
if (originalSize > 800000) { // 800KB threshold to be safe
console.log('Image is too large, compressing...');
try {
// Try first level compression
let compressedBase64 = await compressBase64Image(generatedCard.imageBase64, 400, 560, 0.7);
let compressedSize = getBase64Size(compressedBase64);
console.log(`Compressed image size (first attempt): ${formatBytes(compressedSize)}`);
// If still too large, compress more aggressively
if (compressedSize > 800000) {
console.log('Still too large, applying aggressive compression...');
compressedBase64 = await compressBase64Image(generatedCard.imageBase64, 300, 420, 0.5);
compressedSize = getBase64Size(compressedBase64);
console.log(`Final compressed image size: ${formatBytes(compressedSize)}`);
}
imageDataUrl = `data:image/jpeg;base64,${compressedBase64}`;
} catch (compressionError) {
console.error('Error compressing image:', compressionError);
// Fall back to original image
}
}
await addCardToCollection(user.uid, {
name: 'Generated Pokemon Card',
@@ -33,6 +62,7 @@ export default function GenerateCardPage() {
imageDataUrl: imageDataUrl,
isGenerated: true,
prompt: generatedCard.prompt,
generationParams: generatedCard.params,
});
toast({
+21 -2
View File
@@ -4,7 +4,7 @@ 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';
import { ScanLine, Sparkles, BookOpen, Camera } from 'lucide-react';
export default function DashboardPage() {
const router = useRouter();
@@ -18,7 +18,7 @@ export default function DashboardPage() {
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-6xl mx-auto">
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center">
@@ -57,6 +57,25 @@ export default function DashboardPage() {
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center">
<Camera className="mr-2 h-5 w-5" />
Photo Cards
</CardTitle>
<CardDescription>
Transform your photos into Pokemon cards using AI
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild className="w-full">
<Link href="/dashboard/generate-from-photo">
Create Photo Card
</Link>
</Button>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle className="flex items-center">
+7 -1
View File
@@ -2,7 +2,13 @@ import CardScanner from '@/components/cards/CardScanner';
export default function ScanCardPage() {
return (
<div>
<div className="space-y-6">
<div className="text-center space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Scan Pokémon Card</h1>
<p className="text-muted-foreground text-lg">
Upload an image of your Pokémon card to automatically identify and add it to your collection
</p>
</div>
<CardScanner />
</div>
);
+249
View File
@@ -0,0 +1,249 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { getUserProfile, updateUserApiKeys } from '@/lib/firestore';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, Key, Save, Eye, EyeOff } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import type { UserApiKeys } from '@/types';
export default function SettingsPage() {
const { user } = useAuth();
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [apiKeys, setApiKeys] = useState<UserApiKeys>({
geminiApiKey: '',
openaiApiKey: '',
});
const [showGeminiKey, setShowGeminiKey] = useState(false);
const [showOpenAIKey, setShowOpenAIKey] = useState(false);
useEffect(() => {
if (user) {
loadUserSettings();
}
}, [user]);
const loadUserSettings = async () => {
if (!user) return;
try {
setLoading(true);
const profile = await getUserProfile(user.uid);
if (profile?.apiKeys) {
setApiKeys(profile.apiKeys);
}
} catch (error) {
console.error('Error loading user settings:', error);
toast({
title: 'Error',
description: 'Failed to load your settings',
variant: 'destructive',
});
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!user) return;
// Basic validation
if (!apiKeys.geminiApiKey && !apiKeys.openaiApiKey) {
toast({
title: 'Warning',
description: 'Please provide at least one API key to enable card generation',
variant: 'destructive',
});
return;
}
try {
setSaving(true);
await updateUserApiKeys(user.uid, apiKeys);
toast({
title: 'Success',
description: 'Your API keys have been saved successfully',
});
} catch (error) {
console.error('Error saving API keys:', error);
toast({
title: 'Error',
description: 'Failed to save your API keys',
variant: 'destructive',
});
} finally {
setSaving(false);
}
};
const handleInputChange = (field: keyof UserApiKeys, value: string) => {
setApiKeys(prev => ({
...prev,
[field]: value,
}));
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-2">Settings</h1>
<p className="text-muted-foreground">
Configure your API keys to enable AI-powered card generation
</p>
</div>
<div className="space-y-6">
{/* API Keys Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
API Keys
</CardTitle>
<CardDescription>
Configure your API keys to enable different AI features. Your keys are securely stored and encrypted.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert>
<AlertDescription>
<strong>Security Note:</strong> Your API keys are stored securely in your user profile.
Only you can access them. We recommend using keys with appropriate usage limits.
</AlertDescription>
</Alert>
{/* Gemini API Key */}
<div className="space-y-2">
<Label htmlFor="geminiApiKey">Google Gemini API Key</Label>
<div className="relative">
<Input
id="geminiApiKey"
type={showGeminiKey ? 'text' : 'password'}
placeholder="Enter your Google Gemini API key"
value={apiKeys.geminiApiKey || ''}
onChange={(e) => handleInputChange('geminiApiKey', e.target.value)}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowGeminiKey(!showGeminiKey)}
>
{showGeminiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Used for regular Pokemon card generation and video generation using Google's Imagen and Veo models.
Get your key from the <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Google AI Studio</a>.
</p>
</div>
{/* OpenAI API Key */}
<div className="space-y-2">
<Label htmlFor="openaiApiKey">OpenAI API Key</Label>
<div className="relative">
<Input
id="openaiApiKey"
type={showOpenAIKey ? 'text' : 'password'}
placeholder="Enter your OpenAI API key"
value={apiKeys.openaiApiKey || ''}
onChange={(e) => handleInputChange('openaiApiKey', e.target.value)}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowOpenAIKey(!showOpenAIKey)}
>
{showOpenAIKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Used for photo-based Pokemon card generation using GPT-4o and DALL-E.
Get your key from the <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">OpenAI Platform</a>.
</p>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving} className="min-w-[120px]">
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Keys
</>
)}
</Button>
</div>
</CardContent>
</Card>
{/* Feature Availability */}
<Card>
<CardHeader>
<CardTitle>Feature Availability</CardTitle>
<CardDescription>
Which features are available based on your configured API keys
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="flex items-center space-x-3">
<div className={`w-3 h-3 rounded-full ${apiKeys.geminiApiKey ? 'bg-green-500' : 'bg-gray-300'}`} />
<div>
<p className="font-medium">Regular Card Generation</p>
<p className="text-sm text-muted-foreground">Requires Gemini API key</p>
</div>
</div>
<div className="flex items-center space-x-3">
<div className={`w-3 h-3 rounded-full ${apiKeys.openaiApiKey ? 'bg-green-500' : 'bg-gray-300'}`} />
<div>
<p className="font-medium">Photo-based Generation</p>
<p className="text-sm text-muted-foreground">Requires OpenAI API key</p>
</div>
</div>
<div className="flex items-center space-x-3">
<div className={`w-3 h-3 rounded-full ${apiKeys.geminiApiKey ? 'bg-green-500' : 'bg-gray-300'}`} />
<div>
<p className="font-medium">Video Generation</p>
<p className="text-sm text-muted-foreground">Requires Gemini API key</p>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
+1 -1
View File
@@ -55,7 +55,7 @@ export default function CardForm({
name: initialData?.name || '',
set: initialData?.set || '',
rarity: initialData?.rarity || '',
imageDataUrl: imageDataUrlFromScan || (initialData as PokemonCard)?.imageDataUrl || '',
imageDataUrl: imageDataUrlFromScan || (initialData as PokemonCard)?.imageUrl || '',
},
});
+61 -20
View File
@@ -12,7 +12,10 @@ 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';
import { Loader2, Download } from 'lucide-react';
import { downloadCardFromBase64 } from '@/utils/downloadUtils';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
export interface GeneratePokemonCardInput {
pokemonName: string;
@@ -70,10 +73,13 @@ const cardGeneratorSchema = z.object({
type CardGeneratorForm = z.infer<typeof cardGeneratorSchema>;
interface CardGeneratorProps {
onCardGenerated?: (imageBase64: string, prompt: string) => void;
onCardGenerated?: (imageBase64: string, prompt: string, params: GeneratePokemonCardInput) => void;
initialValues?: Partial<GeneratePokemonCardInput>;
}
export function CardGenerator({ onCardGenerated }: CardGeneratorProps) {
export function CardGenerator({ onCardGenerated, initialValues }: CardGeneratorProps) {
const { user } = useAuth();
const { toast } = useToast();
const [isGenerating, setIsGenerating] = useState(false);
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string } | null>(null);
const [error, setError] = useState<string>('');
@@ -87,27 +93,51 @@ export function CardGenerator({ onCardGenerated }: CardGeneratorProps) {
} = 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,
pokemonName: initialValues?.pokemonName || '',
pokemonType: initialValues?.pokemonType || '',
isIllustrationRare: initialValues?.isIllustrationRare || false,
isHolo: initialValues?.isHolo || false,
backgroundDescription: initialValues?.backgroundDescription || '',
pokemonDescription: initialValues?.pokemonDescription || '',
language: initialValues?.language || 'english',
hp: initialValues?.hp || 130,
attackName1: initialValues?.attackName1 || 'Quick Attack',
attackDamage1: initialValues?.attackDamage1 || 60,
attackName2: initialValues?.attackName2 || 'Special Move',
attackDamage2: initialValues?.attackDamage2 || 90,
weakness: initialValues?.weakness || 'Fighting',
resistance: initialValues?.resistance || 'Psychic',
retreatCost: initialValues?.retreatCost || 2,
},
});
const handleDownloadCard = () => {
if (!generatedCard) return;
try {
downloadCardFromBase64(generatedCard.imageBase64, watchedValues.pokemonName || 'pokemon_card');
toast({
title: 'Download Started',
description: 'Your Pokemon card is being downloaded.',
});
} catch (error) {
console.error('Download error:', error);
toast({
title: 'Download Failed',
description: 'Failed to download the card. Please try again.',
variant: 'destructive',
});
}
};
const watchedValues = watch();
const onSubmit = async (data: CardGeneratorForm) => {
if (!user) {
setError('You must be logged in to generate cards');
return;
}
setIsGenerating(true);
setError('');
setGeneratedCard(null);
@@ -118,7 +148,10 @@ export function CardGenerator({ onCardGenerated }: CardGeneratorProps) {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
body: JSON.stringify({
...data,
userId: user.uid,
}),
});
const result = await response.json();
@@ -138,7 +171,7 @@ export function CardGenerator({ onCardGenerated }: CardGeneratorProps) {
imageBase64: result.imageBase64,
prompt: result.prompt,
});
onCardGenerated?.(result.imageBase64, result.prompt);
onCardGenerated?.(result.imageBase64, result.prompt, data);
}
} catch (err) {
console.error('Error generating card:', err);
@@ -399,6 +432,14 @@ export function CardGenerator({ onCardGenerated }: CardGeneratorProps) {
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<Button
onClick={handleDownloadCard}
className="w-full"
variant="outline"
>
<Download className="mr-2 h-4 w-4" />
Download Card
</Button>
</div>
) : (
<div className="flex items-center justify-center h-64 bg-gray-50 rounded-lg">
@@ -0,0 +1,399 @@
'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';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
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 CardGeneratorFormOnlyProps {
onCardGenerated?: (imageBase64: string, prompt: string, params: GeneratePokemonCardInput) => void;
initialValues?: Partial<GeneratePokemonCardInput>;
}
export function CardGeneratorFormOnly({ onCardGenerated, initialValues }: CardGeneratorFormOnlyProps) {
const { user } = useAuth();
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string>('');
const { toast } = useToast();
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CardGeneratorForm>({
resolver: zodResolver(cardGeneratorSchema),
defaultValues: {
pokemonName: initialValues?.pokemonName || '',
pokemonType: initialValues?.pokemonType || '',
isIllustrationRare: initialValues?.isIllustrationRare || false,
isHolo: initialValues?.isHolo || false,
backgroundDescription: initialValues?.backgroundDescription || '',
pokemonDescription: initialValues?.pokemonDescription || '',
language: initialValues?.language || 'english',
hp: initialValues?.hp || 130,
attackName1: initialValues?.attackName1 || 'Quick Attack',
attackDamage1: initialValues?.attackDamage1 || 60,
attackName2: initialValues?.attackName2 || 'Special Move',
attackDamage2: initialValues?.attackDamage2 || 90,
weakness: initialValues?.weakness || 'Fighting',
resistance: initialValues?.resistance || 'Psychic',
retreatCost: initialValues?.retreatCost || 2,
},
});
const watchedValues = watch();
const onSubmit = async (data: CardGeneratorForm) => {
if (!user) {
setError('You must be logged in to generate cards');
return;
}
setIsGenerating(true);
setError('');
try {
const response = await fetch('/api/generate-card', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...data,
userId: user.uid,
}),
});
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) {
onCardGenerated?.(result.imageBase64, result.prompt, data);
toast({
title: 'Card Generated!',
description: 'Your Pokemon card has been generated successfully.',
});
}
} catch (err) {
console.error('Error generating card:', err);
setError('Failed to generate card. Please try again.');
} finally {
setIsGenerating(false);
}
};
return (
<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 value={watchedValues.pokemonType} 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 value={watchedValues.language} onValueChange={(value: any) => setValue('language', value)}>
<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="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Full Art</Label>
<p className="text-sm text-muted-foreground">Horizontal layout with full illustration</p>
</div>
<Switch
checked={watchedValues.isIllustrationRare}
onCheckedChange={(checked) => setValue('isIllustrationRare', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Holographic Effect</Label>
<p className="text-sm text-muted-foreground">Rainbow shimmer effect</p>
</div>
<Switch
checked={watchedValues.isHolo}
onCheckedChange={(checked) => setValue('isHolo', checked)}
/>
</div>
</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 the Pokemon's appearance, personality, and characteristics..."
className="min-h-[100px]"
/>
{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, environment, or setting..."
className="min-h-[100px]"
/>
{errors.backgroundDescription && (
<p className="text-sm text-red-500 mt-1">{errors.backgroundDescription.message}</p>
)}
</div>
</div>
<Separator />
{/* Game Stats */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Game Stats (Optional)</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="hp">HP</Label>
<Input
id="hp"
type="number"
{...register('hp', { valueAsNumber: true })}
min="10"
max="999"
/>
</div>
<div>
<Label htmlFor="retreatCost">Retreat Cost</Label>
<Input
id="retreatCost"
type="number"
{...register('retreatCost', { valueAsNumber: true })}
min="0"
max="5"
/>
</div>
</div>
<div className="space-y-2">
<Label>Attack 1</Label>
<div className="grid grid-cols-2 gap-4">
<Input
{...register('attackName1')}
placeholder="Attack name"
/>
<Input
type="number"
{...register('attackDamage1', { valueAsNumber: true })}
placeholder="Damage"
min="0"
max="999"
/>
</div>
</div>
<div className="space-y-2">
<Label>Attack 2</Label>
<div className="grid grid-cols-2 gap-4">
<Input
{...register('attackName2')}
placeholder="Attack name"
/>
<Input
type="number"
{...register('attackDamage2', { valueAsNumber: true })}
placeholder="Damage"
min="0"
max="999"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="weakness">Weakness</Label>
<Input
id="weakness"
{...register('weakness')}
placeholder="e.g., Fighting"
/>
</div>
<div>
<Label htmlFor="resistance">Resistance</Label>
<Input
id="resistance"
{...register('resistance')}
placeholder="e.g., Psychic"
/>
</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="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</form>
</CardContent>
</Card>
);
}
+363 -31
View File
@@ -5,8 +5,13 @@ import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Pencil, Trash2, Sparkles } from 'lucide-react';
import { Pencil, Trash2, Sparkles, Download, Play, Pause, Video, Clock, AlertCircle, RotateCcw, FileDown, Eye } from 'lucide-react';
import type { PokemonCard } from '@/types';
import { downloadCardImage, downloadCardVideo } from '@/utils/downloadUtils';
import { useToast } from '@/hooks/use-toast';
import ShareButton from '@/components/ui/share-button';
import { useState } from 'react';
import { generateVideoForExistingCard } from '@/lib/firestore';
import {
AlertDialog,
AlertDialogAction,
@@ -22,54 +27,380 @@ import {
interface CardItemProps {
card: PokemonCard;
onDelete: (cardId: string) => void;
onUpdate?: (updatedCard: PokemonCard) => void;
isDeleting: boolean;
}
export default function CardItem({ card, onDelete, isDeleting }: CardItemProps) {
export default function CardItem({ card, onDelete, onUpdate, isDeleting }: CardItemProps) {
const { toast } = useToast();
const [showVideo, setShowVideo] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
// Helper function to check if video is ready for playback
const isVideoReady = () => {
return card.videoUrl && (
card.videoGenerationStatus === 'completed' ||
card.videoUrl.includes('firebasestorage.googleapis.com') ||
card.videoUrl.includes('firebaseapp.com') ||
card.videoUrl.includes('googleapis.com/storage') ||
card.videoUrl.includes('localhost:9199') // Support emulator URLs
);
};
// Show appropriate status message for video generation
const getVideoGenerationStatusMessage = () => {
switch (card.videoGenerationStatus) {
case 'generating':
return 'Generating video...';
case 'failed':
return 'Video generation failed';
case 'completed':
return 'Video ready!';
default:
return null;
}
};
const handleDownloadCard = async () => {
try {
await downloadCardImage(card.imageUrl, card.name);
toast({
title: 'Download Started',
description: `${card.name} is being downloaded.`,
});
} catch (error) {
console.error('Download error:', error);
toast({
title: 'Download Failed',
description: 'Failed to download the card. Please try again.',
variant: 'destructive',
});
}
};
const handleDownloadVideo = async () => {
if (!card.videoUrl) return;
try {
await downloadCardVideo(card.videoUrl, card.name);
toast({
title: 'Video Download Started',
description: `${card.name} video is being downloaded.`,
});
} catch (error) {
console.error('Video download error:', error);
toast({
title: 'Video Download Failed',
description: 'Failed to download the video. Please try again.',
variant: 'destructive',
});
}
};
const handleGenerateVideo = async () => {
if (!card.id) return;
setIsGeneratingVideo(true);
// Immediately update the card status locally to show the banner
if (onUpdate) {
onUpdate({
...card,
videoGenerationStatus: 'generating'
});
}
try {
await generateVideoForExistingCard(card.userId, card.id);
toast({
title: 'Video Generation Started',
description: `Video generation for ${card.name} has been started. This may take a few minutes.`,
});
// The status is already updated above, so we don't need to do it again here
} catch (error) {
console.error('Video generation error:', error);
toast({
title: 'Video Generation Failed',
description: 'Failed to start video generation. Please try again.',
variant: 'destructive',
});
// Revert the status if there was an error starting the generation
if (onUpdate) {
onUpdate({
...card,
videoGenerationStatus: 'failed'
});
}
} finally {
setIsGeneratingVideo(false);
}
};
const getVideoStatusBadge = () => {
if (!card.videoGenerationStatus || card.videoGenerationStatus === 'pending') return null;
const statusConfig = {
generating: { icon: Clock, text: 'Generating Video...', variant: 'secondary' as const },
completed: {
icon: Video,
text: isVideoReady() ? 'Live Video Ready' : 'Video Processing...',
variant: 'default' as const
},
failed: { icon: AlertCircle, text: 'Video Failed', variant: 'destructive' as const },
};
const config = statusConfig[card.videoGenerationStatus];
if (!config) return null;
const { icon: Icon, text, variant } = config;
return (
<Badge variant={variant} className="flex items-center gap-1 flex-shrink-0">
<Icon className="h-3 w-3" />
{text}
</Badge>
);
};
return (
<Card className="flex flex-col overflow-hidden shadow-lg hover:shadow-xl transition-shadow duration-300">
<CardHeader className="p-4">
<div className="flex justify-between items-start">
<div className="flex justify-between items-start gap-2">
<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 className="flex flex-col gap-1 flex-shrink-0">
{card.isGenerated && (
<Badge variant="secondary" className="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>
)}
{getVideoStatusBadge()}
</div>
</div>
<CardDescription className="text-xs text-muted-foreground">
<CardDescription className="text-sm text-muted-foreground">
Set: {card.set} | Rarity: {card.rarity}
</CardDescription>
</CardHeader>
<CardContent className="p-4 flex-grow flex justify-center items-center bg-muted/30">
{card.imageDataUrl ? (
<Image
src={card.imageDataUrl}
alt={card.name}
width={200}
height={280}
className="object-contain rounded-md aspect-[63/88] max-w-[150px]"
data-ai-hint="pokemon card"
/>
{card.imageUrl ? (
<div className="relative w-full max-w-[220px] aspect-[63/88]">
{showVideo && card.videoUrl ? (
<div className="relative w-full h-full">
{card.videoUrl.includes('firebasestorage.googleapis.com') ||
card.videoUrl.includes('firebaseapp.com') ||
card.videoUrl.includes('googleapis.com/storage') ||
card.videoUrl.includes('localhost:9199') ? (
// Handle Firebase Storage URLs (production and emulator - publicly accessible)
<video
src={card.videoUrl}
autoPlay
loop
muted
playsInline
className="w-full h-full object-contain rounded-lg shadow-sm"
onError={(e) => {
console.error('Video playback error for Firebase URL:', card.videoUrl, e);
setShowVideo(false);
}}
/>
) : card.videoUrl.startsWith('data:video/') || !card.videoUrl.startsWith('http') ? (
// Handle base64 video data
<video
src={card.videoUrl.startsWith('data:') ? card.videoUrl : `data:video/mp4;base64,${card.videoUrl}`}
autoPlay
loop
muted
playsInline
className="w-full h-full object-contain rounded-lg shadow-sm"
onError={(e) => {
console.error('Video playback error for base64 data:', e);
setShowVideo(false);
}}
/>
) : (
// Handle unsupported URLs (like Google API URLs) - show error message
<div className="w-full h-full flex items-center justify-center bg-gray-100 rounded-lg">
<div className="text-center text-sm text-gray-600">
<p>Video processing in progress...</p>
<p className="text-xs mt-1">Please refresh in a moment</p>
</div>
</div>
)}
<Button
variant="default"
size="lg"
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white border-2 border-white/50 shadow-2xl backdrop-blur-sm"
onClick={() => setShowVideo(false)}
>
<Pause className="h-6 w-6 mr-2" />
Pause Video
</Button>
</div>
) : (
<>
<Image
src={card.imageUrl}
alt={card.name}
fill
className="object-contain rounded-lg shadow-sm"
data-ai-hint="pokemon card"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, (max-width: 1280px) 33vw, 25vw"
/>
{isVideoReady() && (
<Button
variant="default"
size="lg"
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white border-2 border-white/50 shadow-2xl backdrop-blur-sm"
onClick={() => setShowVideo(true)}
>
<Play className="h-6 w-6 mr-2" />
Play Video
</Button>
)}
</>
)}
</div>
) : (
<div className="w-[200px] h-[280px] bg-gray-200 flex items-center justify-center rounded-md text-muted-foreground aspect-[63/88]">
No Image
<div className="w-full max-w-[220px] aspect-[63/88] bg-gray-200 flex items-center justify-center rounded-lg text-muted-foreground">
<span className="text-sm">No Image</span>
</div>
)}
</CardContent>
<CardFooter className="p-4 bg-card border-t flex justify-end space-x-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/dashboard/collection/${card.id}/edit`}>
<Pencil className="h-3 w-3 mr-1" /> Edit
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isDeleting}>
<Trash2 className="h-3 w-3 mr-1" /> Delete
<CardFooter className="p-3 bg-card border-t">
<div className="flex flex-col gap-2 w-full">
{/* Video Generation Status Indicator */}
{card.videoGenerationStatus === 'generating' && (
<div className="w-full px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
<span className="text-sm text-blue-700 font-medium">
{getVideoGenerationStatusMessage()}
</span>
</div>
<div className="text-xs text-blue-600 mt-1">
This may take a few minutes. The page will refresh automatically.
</div>
</div>
)}
{/* Video Generation Controls */}
{!card.videoUrl && card.videoGenerationStatus !== 'generating' && (
<Button
variant="outline"
size="sm"
onClick={handleGenerateVideo}
disabled={isGeneratingVideo}
className="w-full bg-gradient-to-r from-purple-50 to-pink-50 hover:from-purple-100 hover:to-pink-100 border-purple-200 text-purple-700"
>
<Video className="h-4 w-4 mr-2" />
{isGeneratingVideo ? 'Starting...' : 'Make Card Live'}
</Button>
)}
{card.videoGenerationStatus === 'failed' && (
<Button
variant="outline"
size="sm"
onClick={handleGenerateVideo}
disabled={isGeneratingVideo}
className="w-full border-orange-200 bg-orange-50 hover:bg-orange-100 text-orange-700"
>
<RotateCcw className="h-4 w-4 mr-2" />
{isGeneratingVideo ? 'Starting...' : 'Retry Video Generation'}
</Button>
)}
{/* Download Video Button - Show when video is ready */}
{isVideoReady() && (
<Button
variant="outline"
size="sm"
onClick={handleDownloadVideo}
className="w-full border-green-200 bg-green-50 hover:bg-green-100 text-green-700"
>
<FileDown className="h-4 w-4 mr-2" />
Download Video
</Button>
)}
{card.isGenerated ? (
<>
{/* For generated cards: Share button gets its own row for prominence */}
<div className="flex">
<ShareButton
cardName={card.name}
cardImageUrl={card.imageUrl}
variant="outline"
size="sm"
className="w-full"
showText={true}
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDownloadCard}
disabled={!card.imageUrl}
className="flex-1"
>
<Download className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Download</span>
<span className="sm:hidden">DL</span>
</Button>
<Button variant="outline" size="sm" asChild className="flex-1">
<Link href={`/dashboard/collection/${card.id}`}>
<Eye className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">View</span>
<span className="sm:hidden">View</span>
</Link>
</Button>
<Button variant="outline" size="sm" asChild className="flex-1">
<Link href={`/dashboard/collection/${card.id}/edit`}>
<Pencil className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Edit</span>
<span className="sm:hidden">Ed</span>
</Link>
</Button>
</div>
</>
) : (
/* For non-generated cards: Just Download, View, and Edit */
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDownloadCard}
disabled={!card.imageUrl}
className="flex-1"
>
<Download className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Download</span>
<span className="sm:hidden">DL</span>
</Button>
<Button variant="outline" size="sm" asChild className="flex-1">
<Link href={`/dashboard/collection/${card.id}`}>
<Eye className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">View</span>
<span className="sm:hidden">View</span>
</Link>
</Button>
<Button variant="outline" size="sm" asChild className="flex-1">
<Link href={`/dashboard/collection/${card.id}/edit`}>
<Pencil className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Edit</span>
<span className="sm:hidden">Ed</span>
</Link>
</Button>
</div>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isDeleting} className="w-full">
<Trash2 className="h-4 w-4 mr-2" /> Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -87,6 +418,7 @@ export default function CardItem({ card, onDelete, isDeleting }: CardItemProps)
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardFooter>
</Card>
);
+115 -58
View File
@@ -59,7 +59,10 @@ export default function CardScanner() {
setShowForm(false);
try {
const result = await scanPokemonCard({ photoDataUri: imageDataUrl });
const result = await scanPokemonCard({
photoDataUri: imageDataUrl,
userId: user?.uid
});
if (result.error) {
setError(result.error);
toast({ title: 'Scan Failed', description: result.error, variant: 'destructive' });
@@ -125,79 +128,133 @@ export default function CardScanner() {
return (
<div className="space-y-8 max-w-2xl mx-auto">
<Card className="shadow-xl">
<CardHeader>
<CardTitle className="text-2xl font-headline">Scan Pokémon Card</CardTitle>
<CardDescription>Upload an image of your Pokémon card to identify its details.</CardDescription>
<div className="space-y-6 max-w-4xl mx-auto">
<Card className="border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 transition-colors">
<CardHeader className="text-center pb-4">
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Upload className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-xl">Upload Card Image</CardTitle>
<CardDescription>
Choose a clear image of your Pokémon card for best scanning results
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<Label htmlFor="card-image" className="text-base">Upload Card Image</Label>
<Input
id="card-image"
type="file"
accept="image/*"
onChange={handleImageChange}
ref={fileInputRef}
className="mt-2 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20"
/>
<div className="space-y-4">
<div className="relative">
<input
id="card-image"
type="file"
accept="image/*"
onChange={handleImageChange}
ref={fileInputRef}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
<div className="w-full h-12 border-2 border-dashed border-muted-foreground/30 rounded-lg bg-muted/20 hover:bg-muted/30 hover:border-muted-foreground/50 transition-all duration-200 flex items-center justify-center">
<div className="flex items-center space-x-2 text-muted-foreground">
<Upload className="h-4 w-4" />
<span className="text-sm font-medium">Choose file or drag and drop</span>
</div>
</div>
</div>
<p className="text-xs text-muted-foreground text-center">
Supported formats: JPG, PNG, WEBP Max size: 10MB
</p>
</div>
{imagePreview && (
<div className="mt-4 p-4 border border-dashed border-border rounded-lg bg-muted/50">
<h3 className="text-lg font-medium mb-2 text-center">Image Preview</h3>
<Image
src={imagePreview}
alt="Card preview"
width={300}
height={420}
className="rounded-md object-contain mx-auto shadow-md aspect-[63/88] max-w-xs"
data-ai-hint="pokemon card"
/>
</div>
)}
{imageDataUrl && (
<Button onClick={handleScan} disabled={isScanning || !imageDataUrl} className="w-full mt-4 text-lg py-6">
{isScanning ? (
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
) : (
<Upload className="mr-2 h-6 w-6" />
<div className="space-y-4">
<div className="bg-gradient-to-br from-muted/50 to-muted/30 rounded-lg p-6 text-center">
<h3 className="text-lg font-semibold mb-4 text-foreground">Preview</h3>
<div className="inline-block p-3 bg-background rounded-lg shadow-md">
<Image
src={imagePreview}
alt="Card preview"
width={300}
height={420}
className="rounded-md object-contain mx-auto aspect-[63/88] max-w-[250px] shadow-sm"
data-ai-hint="pokemon card"
/>
</div>
</div>
{imageDataUrl && (
<Button
onClick={handleScan}
disabled={isScanning || !imageDataUrl}
className="w-full py-6 text-lg font-semibold"
size="lg"
>
{isScanning ? (
<>
<Loader2 className="mr-3 h-5 w-5 animate-spin" />
Analyzing Card...
</>
) : (
<>
<Upload className="mr-3 h-5 w-5" />
Scan Card
</>
)}
</Button>
)}
{isScanning ? 'Scanning...' : 'Scan Card'}
</Button>
</div>
)}
</CardContent>
</Card>
{error && !showForm && (
<Card className="border-destructive bg-destructive/10 shadow-lg">
<CardHeader className="flex flex-row items-center space-x-3">
<AlertCircle className="h-6 w-6 text-destructive" />
<CardTitle className="text-destructive">Scan Failed</CardTitle>
</CardHeader>
<CardContent>
<p className="text-destructive-foreground">{error}</p>
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-destructive/10 rounded-full flex items-center justify-center">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
</div>
<div className="flex-1 space-y-1">
<h3 className="text-sm font-semibold text-destructive">Scan Failed</h3>
<p className="text-sm text-muted-foreground">{error}</p>
<p className="text-xs text-muted-foreground">
Try taking a clearer photo or ensure the card is fully visible.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{showForm && scanResult?.cardDetails && imageDataUrl && (
<CardForm
initialData={{
name: scanResult.cardDetails.name || '',
set: scanResult.cardDetails.set || '',
rarity: scanResult.cardDetails.rarity || '',
}}
imageDataUrlFromScan={imageDataUrl}
onSubmit={handleSaveCard}
isSubmitting={isSubmitting}
submitButtonText="Add to Collection"
formTitle="Confirm Card Details"
formDescription="Review the scanned details and save the card to your collection."
onCancel={handleCancelForm}
/>
<div className="space-y-4">
<div className="flex items-center justify-center space-x-3 py-4">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200">
Card Successfully Scanned!
</h3>
<p className="text-sm text-green-600 dark:text-green-300">
Review the details below and add to your collection
</p>
</div>
</div>
<CardForm
initialData={{
name: scanResult.cardDetails.name || '',
set: scanResult.cardDetails.set || '',
rarity: scanResult.cardDetails.rarity || '',
}}
imageDataUrlFromScan={imageDataUrl}
onSubmit={handleSaveCard}
isSubmitting={isSubmitting}
submitButtonText="Add to Collection"
formTitle="Confirm Card Details"
formDescription="Review the scanned details and save the card to your collection."
onCancel={handleCancelForm}
/>
</div>
)}
</div>
);
+501
View File
@@ -0,0 +1,501 @@
'use client';
import { useState, type ChangeEvent, useRef } 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 { 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, Upload, X, Download } from 'lucide-react';
import Image from 'next/image';
import { useToast } from '@/hooks/use-toast';
import { downloadCardFromBase64 } from '@/utils/downloadUtils';
import { useAuth } from '@/hooks/useAuth';
export interface GenerateFromPhotoInput {
photoDataUri: string;
pokemonName: string;
pokemonType: string;
styleDescription: 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 photoCardGeneratorSchema = z.object({
photoDataUri: z.string().min(1, "Photo is required"),
pokemonName: z.string().min(1, 'Pokemon name is required'),
pokemonType: z.string().min(1, 'Pokemon type is required'),
styleDescription: z.string().min(10, 'Style 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 PhotoCardGeneratorForm = z.infer<typeof photoCardGeneratorSchema>;
interface PhotoCardGeneratorProps {
onCardGenerated?: (imageBase64: string, prompt: string, params: GenerateFromPhotoInput) => void;
initialValues?: Partial<GenerateFromPhotoInput>;
}
export function PhotoCardGenerator({ onCardGenerated, initialValues }: PhotoCardGeneratorProps) {
const { user } = useAuth();
const [isGenerating, setIsGenerating] = useState(false);
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string } | null>(null);
const [error, setError] = useState<string>('');
const [imagePreview, setImagePreview] = useState<string | null>(initialValues?.photoDataUri || null);
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors },
} = useForm<PhotoCardGeneratorForm>({
resolver: zodResolver(photoCardGeneratorSchema),
defaultValues: {
photoDataUri: initialValues?.photoDataUri || '',
pokemonName: initialValues?.pokemonName || '',
pokemonType: initialValues?.pokemonType || '',
styleDescription: initialValues?.styleDescription || '',
language: initialValues?.language || 'english',
hp: initialValues?.hp || 130,
attackName1: initialValues?.attackName1 || 'Quick Attack',
attackDamage1: initialValues?.attackDamage1 || 60,
attackName2: initialValues?.attackName2 || 'Special Move',
attackDamage2: initialValues?.attackDamage2 || 90,
weakness: initialValues?.weakness || 'Fighting',
resistance: initialValues?.resistance || 'Psychic',
retreatCost: initialValues?.retreatCost || 2,
},
});
const handleDownloadCard = () => {
if (!generatedCard) return;
try {
downloadCardFromBase64(generatedCard.imageBase64, watchedValues.pokemonName || 'pokemon_card');
toast({
title: 'Download Started',
description: 'Your Pokemon card is being downloaded.',
});
} catch (error) {
console.error('Download error:', error);
toast({
title: 'Download Failed',
description: 'Failed to download the card. Please try again.',
variant: 'destructive',
});
}
};
const watchedValues = watch();
const handleImageChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
// Reset previous generation state
setGeneratedCard(null);
setError('');
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
setImagePreview(dataUrl);
setValue('photoDataUri', dataUrl);
};
reader.readAsDataURL(file);
}
};
const handleRemoveImage = () => {
setImagePreview(null);
setValue('photoDataUri', '');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
setGeneratedCard(null);
setError('');
};
const onSubmit = async (data: PhotoCardGeneratorForm) => {
if (!user) {
setError('You must be logged in to generate cards');
return;
}
if (!data.photoDataUri) {
toast({
title: 'Photo Required',
description: 'Please upload a reference photo first.',
variant: 'destructive',
});
return;
}
setIsGenerating(true);
setError('');
setGeneratedCard(null);
try {
const response = await fetch('/api/generate-card-from-photo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...data,
userId: user.uid,
}),
});
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, data);
toast({
title: 'Card Generated!',
description: 'Your Pokemon card has been generated based on your photo.',
});
}
} catch (err) {
console.error('Error generating card from photo:', 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>Generate Card from Photo</CardTitle>
<CardDescription>
Upload a photo and create a custom Pokemon TCG card inspired by your image using AI
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Photo Upload */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Reference Photo</h3>
<div>
<Label htmlFor="photo-upload" className="text-base">Upload Reference Image</Label>
<Input
id="photo-upload"
type="file"
accept="image/*"
onChange={handleImageChange}
ref={fileInputRef}
className="cursor-pointer"
/>
{errors.photoDataUri && (
<p className="text-sm text-red-500 mt-1">{errors.photoDataUri.message}</p>
)}
</div>
{imagePreview && (
<div className="relative">
<div className="relative bg-gray-100 rounded-lg p-4">
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-2 right-2 z-10"
onClick={handleRemoveImage}
>
<X className="h-4 w-4" />
</Button>
<Image
src={imagePreview}
alt="Reference photo"
width={400}
height={300}
className="w-full h-auto rounded-lg object-cover max-h-64"
/>
</div>
</div>
)}
</div>
<Separator />
{/* Basic Pokemon Info */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Pokemon 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 />
{/* Style Description */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Photo Adaptation</h3>
<div>
<Label htmlFor="styleDescription">Style Description</Label>
<Textarea
id="styleDescription"
{...register('styleDescription')}
placeholder="Describe how the photo should be adapted for the Pokemon card (e.g., 'Use the lighting and composition, but transform the subject into a Pokemon in a fantasy setting')"
className="min-h-[100px]"
/>
{errors.styleDescription && (
<p className="text-sm text-red-500 mt-1">{errors.styleDescription.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 || !imagePreview}>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating Card from Photo...
</>
) : (
'Generate Pokemon Card from Photo'
)}
</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 photo-inspired 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 from Photo"
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<Button
onClick={handleDownloadCard}
className="w-full"
variant="outline"
>
<Download className="mr-2 h-4 w-4" />
Download Card
</Button>
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 bg-gray-50 rounded-lg">
{!imagePreview && (
<Upload className="h-12 w-12 text-gray-400 mb-4" />
)}
<p className="text-muted-foreground text-center">
{isGenerating
? 'Generating your Pokemon card from photo...'
: !imagePreview
? 'Upload a reference photo and fill out the form to generate a card'
: 'Fill out the form to generate a card based on your photo'
}
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,439 @@
'use client';
import { useState, type ChangeEvent, useRef } 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 { 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, Upload, X } from 'lucide-react';
import Image from 'next/image';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
export interface GenerateFromPhotoInput {
photoDataUri: string;
pokemonName: string;
pokemonType: string;
styleDescription: 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 photoCardGeneratorSchema = z.object({
photoDataUri: z.string().min(1, "Photo is required"),
pokemonName: z.string().min(1, 'Pokemon name is required'),
pokemonType: z.string().min(1, 'Pokemon type is required'),
styleDescription: z.string().min(10, 'Style 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 PhotoCardGeneratorForm = z.infer<typeof photoCardGeneratorSchema>;
interface PhotoCardGeneratorFormOnlyProps {
onCardGenerated?: (imageBase64: string, prompt: string, params: GenerateFromPhotoInput) => void;
initialValues?: Partial<GenerateFromPhotoInput>;
}
export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: PhotoCardGeneratorFormOnlyProps) {
const { user } = useAuth();
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string>('');
const [imagePreview, setImagePreview] = useState<string | null>(initialValues?.photoDataUri || null);
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors },
} = useForm<PhotoCardGeneratorForm>({
resolver: zodResolver(photoCardGeneratorSchema),
defaultValues: {
photoDataUri: initialValues?.photoDataUri || '',
pokemonName: initialValues?.pokemonName || '',
pokemonType: initialValues?.pokemonType || '',
styleDescription: initialValues?.styleDescription || '',
language: initialValues?.language || 'english',
hp: initialValues?.hp || 130,
attackName1: initialValues?.attackName1 || 'Quick Attack',
attackDamage1: initialValues?.attackDamage1 || 60,
attackName2: initialValues?.attackName2 || 'Special Move',
attackDamage2: initialValues?.attackDamage2 || 90,
weakness: initialValues?.weakness || 'Fighting',
resistance: initialValues?.resistance || 'Psychic',
retreatCost: initialValues?.retreatCost || 2,
},
});
const watchedValues = watch();
const handleImageChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (file.size > 5 * 1024 * 1024) { // 5MB limit
toast({
title: 'File too large',
description: 'Please select an image smaller than 5MB.',
variant: 'destructive',
});
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
setImagePreview(dataUrl);
setValue('photoDataUri', dataUrl);
};
reader.readAsDataURL(file);
}
};
const removeImage = () => {
setImagePreview(null);
setValue('photoDataUri', '');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const onSubmit = async (data: PhotoCardGeneratorForm) => {
if (!user) {
setError('You must be logged in to generate cards');
return;
}
setIsGenerating(true);
setError('');
try {
const response = await fetch('/api/generate-card-from-photo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...data,
userId: user.uid,
}),
});
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) {
onCardGenerated?.(result.imageBase64, result.prompt, data);
toast({
title: 'Card Generated!',
description: 'Your Pokemon card has been generated based on your photo.',
});
}
} catch (err) {
console.error('Error generating card:', err);
setError('Failed to generate card. Please try again.');
} finally {
setIsGenerating(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Create Pokemon Card from Photo</CardTitle>
<CardDescription>
Upload your photo and customize the Pokemon card details
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Photo Upload */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Upload Photo</h3>
<div className="space-y-4">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageChange}
className="hidden"
id="photo-upload"
/>
{imagePreview ? (
<div className="relative">
<div className="relative w-full max-w-xs mx-auto">
<Image
src={imagePreview}
alt="Preview"
width={200}
height={200}
className="w-full h-auto rounded-lg border object-cover"
/>
<button
type="button"
onClick={removeImage}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
) : (
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
Upload Photo
</Button>
<p className="text-sm text-gray-500 mt-2">
PNG, JPG up to 5MB
</p>
</div>
)}
{errors.photoDataUri && (
<p className="text-sm text-red-500">{errors.photoDataUri.message}</p>
)}
</div>
</div>
<Separator />
{/* Basic 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 value={watchedValues.pokemonType} 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 value={watchedValues.language} onValueChange={(value: any) => setValue('language', value)}>
<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 />
{/* Style Description */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Style & Description</h3>
<div>
<Label htmlFor="styleDescription">Style Description</Label>
<Textarea
id="styleDescription"
{...register('styleDescription')}
placeholder="Describe how you want the Pokemon card to look, the art style, background, etc..."
className="min-h-[100px]"
/>
{errors.styleDescription && (
<p className="text-sm text-red-500 mt-1">{errors.styleDescription.message}</p>
)}
</div>
</div>
<Separator />
{/* Game Stats */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Game Stats (Optional)</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="hp">HP</Label>
<Input
id="hp"
type="number"
{...register('hp', { valueAsNumber: true })}
min="10"
max="999"
/>
</div>
<div>
<Label htmlFor="retreatCost">Retreat Cost</Label>
<Input
id="retreatCost"
type="number"
{...register('retreatCost', { valueAsNumber: true })}
min="0"
max="5"
/>
</div>
</div>
<div className="space-y-2">
<Label>Attack 1</Label>
<div className="grid grid-cols-2 gap-4">
<Input
{...register('attackName1')}
placeholder="Attack name"
/>
<Input
type="number"
{...register('attackDamage1', { valueAsNumber: true })}
placeholder="Damage"
min="0"
max="999"
/>
</div>
</div>
<div className="space-y-2">
<Label>Attack 2</Label>
<div className="grid grid-cols-2 gap-4">
<Input
{...register('attackName2')}
placeholder="Attack name"
/>
<Input
type="number"
{...register('attackDamage2', { valueAsNumber: true })}
placeholder="Damage"
min="0"
max="999"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="weakness">Weakness</Label>
<Input
id="weakness"
{...register('weakness')}
placeholder="e.g., Fighting"
/>
</div>
<div>
<Label htmlFor="resistance">Resistance</Label>
<Input
id="resistance"
{...register('resistance')}
placeholder="e.g., Psychic"
/>
</div>
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={isGenerating || !imagePreview}
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating Card...
</>
) : (
'Generate Pokemon Card from Photo'
)}
</Button>
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</form>
</CardContent>
</Card>
);
}
+100 -25
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { LogIn, LogOut, UserCircle, ScanLine, BookOpen, Sparkles } from 'lucide-react';
import { LogIn, LogOut, UserCircle, ScanLine, BookOpen, Sparkles, Camera, Menu, Settings } from 'lucide-react';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { signOut } from 'firebase/auth';
@@ -17,6 +17,13 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
export default function Navbar() {
const { user, loading } = useAuth();
@@ -43,6 +50,31 @@ export default function Navbar() {
return names[0].substring(0, 2);
};
const NavigationItems = ({ className = "", mobile = false }: { className?: string; mobile?: boolean }) => (
<div className={`${mobile ? 'flex flex-col space-y-3' : 'flex items-center space-x-4'} ${className}`}>
<Button variant="ghost" asChild className={mobile ? "justify-start" : ""}>
<Link href="/dashboard/scan">
<ScanLine className="mr-2 h-4 w-4" /> Scan Card
</Link>
</Button>
<Button variant="ghost" asChild className={mobile ? "justify-start" : ""}>
<Link href="/dashboard/generate">
<Sparkles className="mr-2 h-4 w-4" /> Generate Card
</Link>
</Button>
<Button variant="ghost" asChild className={mobile ? "justify-start" : ""}>
<Link href="/dashboard/generate-from-photo">
<Camera className="mr-2 h-4 w-4" /> Photo Card
</Link>
</Button>
<Button variant="ghost" asChild className={mobile ? "justify-start" : ""}>
<Link href="/dashboard/collection">
<BookOpen className="mr-2 h-4 w-4" /> My Collection
</Link>
</Button>
</div>
);
return (
<nav className="bg-card border-b border-border shadow-sm">
<div className="container mx-auto px-4">
@@ -54,25 +86,59 @@ export default function Navbar() {
<div className="flex items-center space-x-4">
{!loading && user && (
<>
<Button variant="ghost" asChild>
<Link href="/dashboard/scan">
<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
</Link>
</Button>
{/* Desktop Navigation - Hidden on mobile and tablet (lg:flex) */}
<NavigationItems className="hidden lg:flex" />
{/* Mobile/Tablet Navigation - Hidden on desktop (lg:hidden) */}
<div className="lg:hidden">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right">
<SheetHeader>
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<div className="pt-4">
<NavigationItems mobile={true} />
</div>
<div className="border-t pt-4 mt-6">
<div className="flex items-center space-x-3 mb-4">
<Avatar className="h-8 w-8">
<AvatarImage src={user.photoURL || undefined} alt={user.displayName || user.email || 'User'} />
<AvatarFallback>{getInitials(user.displayName || user.email)}</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<p className="text-sm font-medium">
{user.displayName || 'User'}
</p>
<p className="text-xs text-muted-foreground">
{user.email}
</p>
</div>
</div>
<Button variant="ghost" onClick={handleSignOut} className="w-full justify-start">
<LogOut className="mr-2 h-4 w-4" />
Log out
</Button>
<Button variant="ghost" asChild className="w-full justify-start">
<Link href="/dashboard/settings">
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</Button>
</div>
</SheetContent>
</Sheet>
</div>
</>
)}
{!loading && (
user ? (
{/* User Avatar (Desktop only) - Hidden on mobile and tablet */}
{!loading && user && (
<div className="hidden lg:block">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
@@ -94,19 +160,28 @@ export default function Navbar() {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild className="cursor-pointer">
<Link href="/dashboard/settings">
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer">
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button asChild>
<Link href="/login">
<LogIn className="mr-2 h-4 w-4" /> Login
</Link>
</Button>
)
</div>
)}
{/* Login Button */}
{!loading && !user && (
<Button asChild>
<Link href="/login">
<LogIn className="mr-2 h-4 w-4" /> Login
</Link>
</Button>
)}
</div>
</div>
+17 -1
View File
@@ -4,6 +4,7 @@ import React, { createContext, useState, useEffect, type ReactNode } from 'react
import { onAuthStateChanged, type User } from 'firebase/auth';
import { auth } from '@/lib/firebase';
import { Loader2 } from 'lucide-react';
import { createOrUpdateUserProfile } from '@/lib/firestore';
export interface AuthContextType {
user: User | null;
@@ -17,8 +18,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
setUser(currentUser);
// Create or update user profile when user signs in
if (currentUser) {
try {
await createOrUpdateUserProfile(currentUser.uid, {
email: currentUser.email || '',
displayName: currentUser.displayName || undefined,
photoURL: currentUser.photoURL || undefined,
});
} catch (error) {
console.error('Error creating/updating user profile:', error);
// Don't block the auth flow for profile creation errors
}
}
setLoading(false);
});
return () => unsubscribe();
+195
View File
@@ -0,0 +1,195 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Share2, Twitter, Facebook, Linkedin, Copy, Check } from 'lucide-react';
import { shareOnTwitter, shareOnFacebook, shareOnLinkedIn, copyShareLink, nativeShare, copyImageAndText, ShareOptions } from '@/utils/shareUtils';
import { useToast } from '@/hooks/use-toast';
interface ShareButtonProps {
cardName: string;
cardImageUrl?: string;
variant?: 'default' | 'outline' | 'secondary' | 'ghost' | 'link' | 'destructive';
size?: 'default' | 'sm' | 'lg' | 'icon';
className?: string;
showText?: boolean;
}
export default function ShareButton({
cardName,
cardImageUrl,
variant = 'outline',
size = 'sm',
className = '',
showText = true
}: ShareButtonProps) {
const [copied, setCopied] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const { toast } = useToast();
const shareOptions: ShareOptions = {
cardName,
cardImageUrl,
onImageCopied: (platform: string) => {
toast({
title: 'Image & Text Copied!',
description: `Card image and text copied to clipboard. Paste them in your ${platform} post!`,
});
},
onImageUploaded: (url: string) => {
toast({
title: 'Image Uploaded!',
description: 'Image has been uploaded and will be included in the social media preview.',
});
},
};
const handleCopyLink = async () => {
try {
// Try to copy image + text first, fallback to text only
if (cardImageUrl) {
try {
const copiedImage = await copyImageAndText(shareOptions);
setCopied(true);
toast({
title: copiedImage ? 'Image & Text Copied!' : 'Text Copied!',
description: copiedImage
? 'Card image and share text have been copied to your clipboard.'
: 'Share text copied to clipboard. Image copying not supported on this device.',
});
} catch (error) {
// Fallback to text only
copyShareLink(shareOptions);
setCopied(true);
toast({
title: 'Text Copied!',
description: 'Share text has been copied to your clipboard.',
});
}
} else {
copyShareLink(shareOptions);
setCopied(true);
toast({
title: 'Link Copied!',
description: 'Share text has been copied to your clipboard.',
});
}
setTimeout(() => setCopied(false), 2000);
} catch (error) {
toast({
title: 'Copy Failed',
description: 'Failed to copy to clipboard. Please try again.',
variant: 'destructive',
});
}
};
const handleNativeShare = async () => {
const shared = await nativeShare(shareOptions);
if (!shared) {
// Fallback to copy link if native share is not available
handleCopyLink();
}
};
// Check if native share is available
const hasNativeShare = typeof navigator !== 'undefined' && navigator.share;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant={variant} size={size} className={className}>
<Share2 className="h-4 w-4" />
{showText && (
<span className="ml-1">Share</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{hasNativeShare && (
<DropdownMenuItem onClick={handleNativeShare}>
<Share2 className="mr-2 h-4 w-4" />
Share
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={async () => {
if (isUploading) return;
setIsUploading(true);
try {
await shareOnTwitter(shareOptions);
} catch (error) {
toast({
title: 'Share Failed',
description: 'Failed to prepare Twitter share. Please try again.',
variant: 'destructive',
});
} finally {
setIsUploading(false);
}
}}
disabled={isUploading}
>
<Twitter className="mr-2 h-4 w-4" />
{isUploading ? 'Preparing...' : 'Twitter/X'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
if (isUploading) return;
setIsUploading(true);
try {
await shareOnFacebook(shareOptions);
} catch (error) {
toast({
title: 'Share Failed',
description: 'Failed to prepare Facebook share. Please try again.',
variant: 'destructive',
});
} finally {
setIsUploading(false);
}
}}
disabled={isUploading}
>
<Facebook className="mr-2 h-4 w-4" />
{isUploading ? 'Preparing...' : 'Facebook'}
</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
if (isUploading) return;
setIsUploading(true);
try {
await shareOnLinkedIn(shareOptions);
} catch (error) {
toast({
title: 'Share Failed',
description: 'Failed to prepare LinkedIn share. Please try again.',
variant: 'destructive',
});
} finally {
setIsUploading(false);
}
}}
disabled={isUploading}
>
<Linkedin className="mr-2 h-4 w-4" />
{isUploading ? 'Preparing...' : 'LinkedIn'}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyLink}>
{copied ? (
<Check className="mr-2 h-4 w-4 text-green-600" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
{copied ? 'Copied!' : cardImageUrl ? 'Copy Text' : 'Copy Link'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+5 -1
View File
@@ -2,6 +2,7 @@
import { initializeApp, getApps, getApp, type FirebaseApp } from 'firebase/app';
import { getAuth, type Auth, connectAuthEmulator } from 'firebase/auth';
import { getFirestore, type Firestore, connectFirestoreEmulator } from 'firebase/firestore';
import { getStorage, type FirebaseStorage, connectStorageEmulator } from 'firebase/storage';
import firebaseConfig from '@/config/firebase';
// Define which keys from firebaseConfig are considered essential for initialization.
@@ -47,6 +48,7 @@ if (missingEnvVars.length > 0) {
let app: FirebaseApp;
let auth: Auth;
let db: Firestore;
let storage: FirebaseStorage;
// Track if emulators have been connected to avoid multiple connections
let emulatorsConnected = false;
@@ -59,12 +61,14 @@ try {
}
auth = getAuth(app);
db = getFirestore(app);
storage = getStorage(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);
connectStorageEmulator(storage, 'localhost', 9199);
emulatorsConnected = true;
console.log('Firebase emulators connected successfully');
} catch (error) {
@@ -81,4 +85,4 @@ try {
throw error; // Re-throw the original Firebase error
}
export { app, auth, db };
export { app, auth, db, storage };
+277 -23
View File
@@ -12,9 +12,13 @@ import {
Timestamp,
getDoc,
orderBy,
setDoc,
} from 'firebase/firestore';
import { db, auth } from './firebase'; // Main Firebase config
import type { PokemonCard, ScannedCardData } from '@/types';
import { db, auth, storage } from './firebase'; // Main Firebase config
import type { PokemonCard, ScannedCardData, UserProfile, UserApiKeys } from '@/types';
import { uploadCardImageToStorage, uploadVideoToStorage } from '@/utils/storageUtils';
import { ref, deleteObject } from 'firebase/storage';
import { generateCardVideo } from '@/ai/flows/generate-card-video';
const CARDS_COLLECTION = 'users'; // Top-level collection for users
@@ -23,9 +27,95 @@ const getPokemonCardsCollectionRef = (userId: string) => {
return collection(db, CARDS_COLLECTION, userId, 'pokemon_cards');
};
// Background function to generate and upload video for a card
const generateVideoForCard = async (userId: string, cardId: string, card: PokemonCard) => {
try {
console.log(`Starting background video generation for card: ${card.name}`);
// Update status to generating
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'generating'
});
// Determine Pokemon type from generation params or default
const pokemonType = card.generationParams?.pokemonType ||
card.photoGenerationParams?.pokemonType ||
'Normal';
// Generate video using AI
const videoResult = await generateCardVideo({
cardImageUrl: card.imageUrl,
pokemonName: card.name,
pokemonType: pokemonType,
userId: userId,
});
console.log('Video generation result:', {
hasError: !!videoResult.error,
hasVideoBase64: !!videoResult.videoBase64,
videoBase64Type: typeof videoResult.videoBase64,
videoBase64Preview: videoResult.videoBase64?.substring(0, 50) + '...',
});
if (videoResult.error || !videoResult.videoBase64) {
console.error('Video generation failed:', videoResult.error);
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
return;
}
// Ensure we have base64 data, not a URL
if (videoResult.videoBase64.startsWith('http')) {
console.error('Video generation returned a URL instead of base64 data:', videoResult.videoBase64);
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
return;
}
// Upload video to storage
console.log('Uploading video to Firebase Storage...');
const videoUrl = await uploadVideoToStorage(videoResult.videoBase64, userId, card.name);
console.log('Video uploaded to Firebase Storage:', videoUrl);
// Validate that videoUrl is a proper Firebase Storage URL (production or emulator), not raw data
if (!videoUrl.startsWith('http')) {
console.error('Invalid video URL returned from storage upload:', videoUrl.substring(0, 100));
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
return;
}
// Additional safety check - ensure we're not storing large data in Firestore
if (videoUrl.length > 2000) { // URLs should be much shorter than this
console.error('Video URL is suspiciously long, might be raw data:', videoUrl.length);
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
return;
}
// Update card with video info (only storing the URL, not the video data)
await updateCardInCollection(userId, cardId, {
videoUrl, // This should only be a Firebase Storage URL
videoGenerationStatus: 'completed',
videoPrompt: videoResult.prompt,
});
console.log(`Video generation completed successfully for card: ${card.name}`);
} catch (error) {
console.error('Error in background video generation:', error);
await updateCardInCollection(userId, cardId, {
videoGenerationStatus: 'failed'
});
}
};
export const addCardToCollection = async (
userId: string,
cardData: Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'updatedAt'>
cardData: Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'updatedAt' | 'imageUrl'> & { imageDataUrl: string }
): Promise<string> => {
if (!userId) {
console.error("addCardToCollection: User ID is missing.");
@@ -37,47 +127,46 @@ export const addCardToCollection = async (
console.log("DEBUG: Auth user matches:", auth.currentUser?.uid === userId);
try {
// First, upload the image to Firebase Storage
console.log("Uploading card image to Firebase Storage...");
const imageUrl = await uploadCardImageToStorage(cardData.imageDataUrl, userId, cardData.name);
const collectionRef = getPokemonCardsCollectionRef(userId);
const { imageDataUrl, ...cardDataWithoutImage } = cardData;
const docPayload = {
...cardData,
...cardDataWithoutImage,
imageUrl, // Store the Firebase Storage URL instead of base64 data
userId,
// Don't initialize video generation automatically - make it opt-in
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
};
// Log information for debugging, excluding potentially very large imageDataUrl from general log
const payloadKeys = Object.keys(docPayload);
const imageDataUrlLength = docPayload.imageDataUrl ? docPayload.imageDataUrl.length : 0;
console.log(
"Attempting to add card to Firestore. Path:",
collectionRef.path,
"Payload keys:",
payloadKeys.join(', '),
"imageDataUrl length:",
imageDataUrlLength
Object.keys(docPayload).join(', '),
"Image URL:",
imageUrl
);
if (imageDataUrlLength > 500 * 1024) { // Warn if image data is > 500KB
console.warn(
`imageDataUrl is very large (length: ${imageDataUrlLength} bytes). This might approach or exceed Firestore's 1MiB document size limit and could cause write failures. Consider storing images in Firebase Storage instead.`
);
}
const docRef = await addDoc(collectionRef, docPayload);
console.log("Card successfully added to Firestore with ID:", docRef.id);
// Video generation is now opt-in - users can trigger it manually from the UI
return docRef.id;
} catch (error) {
console.error('Error adding card to Firestore (full error object):', error); // Log the full error object
console.error('Error adding card to Firestore (full error object):', error);
let detailedMessage = 'Failed to add card to collection.';
if (error instanceof Error) {
detailedMessage = error.message; // Start with the basic error message
// Attempt to access Firebase-specific error code
// Firebase errors often have a 'code' property, but to be safe with typing, we check its existence.
detailedMessage = error.message;
const firebaseError = error as any;
if (firebaseError.code) {
detailedMessage = `Error: ${firebaseError.code} - ${error.message}`;
}
}
throw new Error(detailedMessage); // Throw a new error with the potentially more detailed message
throw new Error(detailedMessage);
}
};
@@ -114,13 +203,36 @@ export const getCardById = async (userId: string, cardId: string): Promise<Pokem
export const updateCardInCollection = async (
userId: string,
cardId: string,
cardData: Partial<Omit<PokemonCard, 'id' | 'userId' | 'createdAt'>>
cardData: Partial<Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'imageUrl'>> & { imageDataUrl?: string }
): Promise<void> => {
if (!userId || !cardId) throw new Error('User ID and Card ID are required to update a card.');
try {
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
let updateData: any = { ...cardData };
// Safety check: ensure we're not trying to store large video data in Firestore
if (updateData.videoUrl && updateData.videoUrl.length > 2000) {
console.error('Attempted to store large video data in Firestore. This is not allowed.');
throw new Error('Invalid video data: videos must be stored in Firebase Storage, not Firestore');
}
// Validate video URL format if present - allow both production and emulator URLs
if (updateData.videoUrl && !updateData.videoUrl.startsWith('http')) {
console.error('Invalid video URL format:', updateData.videoUrl.substring(0, 100));
throw new Error('Video URL must be a valid HTTP URL from Firebase Storage');
}
// If a new image is provided, upload it to storage first
if (cardData.imageDataUrl) {
console.log("Uploading updated card image to Firebase Storage...");
const imageUrl = await uploadCardImageToStorage(cardData.imageDataUrl, userId, cardData.name || 'updated_card');
updateData.imageUrl = imageUrl;
delete updateData.imageDataUrl; // Remove the base64 data from the update
}
await updateDoc(cardDocRef, {
...cardData,
...updateData,
updatedAt: serverTimestamp(),
});
} catch (error) {
@@ -133,9 +245,151 @@ export const deleteCardFromCollection = async (userId: string, cardId: string):
if (!userId || !cardId) throw new Error('User ID and Card ID are required to delete a card.');
try {
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
// Get the card data first to delete the associated image from storage
const cardDoc = await getDoc(cardDocRef);
if (cardDoc.exists()) {
const cardData = cardDoc.data() as PokemonCard;
// Delete image from storage (production or emulator)
if (cardData.imageUrl && (cardData.imageUrl.includes('firebase') || cardData.imageUrl.includes('localhost:9199'))) {
try {
// Extract the storage path from the URL and delete the image
const url = new URL(cardData.imageUrl);
const pathMatch = url.pathname.match(/\/o\/(.+)\?/);
if (pathMatch) {
const storagePath = decodeURIComponent(pathMatch[1]);
const imageRef = ref(storage, storagePath);
await deleteObject(imageRef);
console.log('Card image deleted from storage:', storagePath);
}
} catch (storageError) {
console.warn('Failed to delete card image from storage:', storageError);
// Continue with deletion even if storage deletion fails
}
}
// Delete video from storage if it exists (production or emulator)
if (cardData.videoUrl && (cardData.videoUrl.includes('firebase') || cardData.videoUrl.includes('localhost:9199'))) {
try {
const url = new URL(cardData.videoUrl);
const pathMatch = url.pathname.match(/\/o\/(.+)\?/);
if (pathMatch) {
const storagePath = decodeURIComponent(pathMatch[1]);
const videoRef = ref(storage, storagePath);
await deleteObject(videoRef);
console.log('Card video deleted from storage:', storagePath);
}
} catch (storageError) {
console.warn('Failed to delete card video from storage:', storageError);
}
}
}
await deleteDoc(cardDocRef);
} catch (error) {
console.error('Error deleting card: ', error);
throw new Error('Failed to delete card.');
}
};
// Manual function to generate video for an existing card
export const generateVideoForExistingCard = async (userId: string, cardId: string): Promise<void> => {
if (!userId || !cardId) throw new Error('User ID and Card ID are required to generate video.');
try {
// Get the card data
const card = await getCardById(userId, cardId);
if (!card) {
throw new Error('Card not found');
}
// Trigger video generation
await generateVideoForCard(userId, cardId, card);
} catch (error) {
console.error('Error generating video for existing card:', error);
throw new Error('Failed to generate video for card.');
}
};
// User Profile Management Functions
/**
* Creates or updates a user profile in Firestore
*/
export const createOrUpdateUserProfile = async (userId: string, profileData: Partial<UserProfile>): Promise<void> => {
if (!userId) throw new Error('User ID is required to create/update profile.');
try {
const userRef = doc(db, 'users', userId);
const updateData: any = {
...profileData,
id: userId,
updatedAt: serverTimestamp(),
};
// If creating for the first time, add createdAt
const existingDoc = await getDoc(userRef);
if (!existingDoc.exists()) {
updateData.createdAt = serverTimestamp();
}
await setDoc(userRef, updateData, { merge: true });
} catch (error) {
console.error('Error creating/updating user profile:', error);
throw new Error('Failed to create/update user profile.');
}
};
/**
* Gets a user profile from Firestore
*/
export const getUserProfile = async (userId: string): Promise<UserProfile | null> => {
if (!userId) throw new Error('User ID is required to get profile.');
try {
const userRef = doc(db, 'users', userId);
const userSnap = await getDoc(userRef);
if (userSnap.exists()) {
return userSnap.data() as UserProfile;
}
return null;
} catch (error) {
console.error('Error getting user profile:', error);
throw new Error('Failed to get user profile.');
}
};
/**
* Updates user API keys
*/
export const updateUserApiKeys = async (userId: string, apiKeys: UserApiKeys): Promise<void> => {
if (!userId) throw new Error('User ID is required to update API keys.');
try {
const userRef = doc(db, 'users', userId);
await updateDoc(userRef, {
apiKeys: apiKeys,
updatedAt: serverTimestamp(),
});
} catch (error) {
console.error('Error updating user API keys:', error);
throw new Error('Failed to update API keys.');
}
};
/**
* Gets user API keys
*/
export const getUserApiKeys = async (userId: string): Promise<UserApiKeys | null> => {
if (!userId) throw new Error('User ID is required to get API keys.');
try {
const profile = await getUserProfile(userId);
return profile?.apiKeys || null;
} catch (error) {
console.error('Error getting user API keys:', error);
throw new Error('Failed to get API keys.');
}
};
+40 -2
View File
@@ -6,11 +6,17 @@ export interface PokemonCard {
name: string;
set: string;
rarity: string;
imageDataUrl: string; // data URI of the card image
imageUrl: string; // Firebase Storage URL of the card image (NOT raw image data)
createdAt: Timestamp;
updatedAt: Timestamp;
isGenerated?: boolean; // Flag to indicate if this is an AI-generated card
isPhotoGenerated?: boolean; // Flag to indicate if this is generated from a photo
prompt?: string; // The prompt used to generate the card (for generated cards)
generationParams?: CardGenerationParams; // Parameters used for regular AI generation
photoGenerationParams?: PhotoCardGenerationParams; // Parameters used for photo-based generation
videoUrl?: string; // Firebase Storage URL of the generated video (NOT raw video data)
videoGenerationStatus?: 'pending' | 'generating' | 'completed' | 'failed'; // Status of video generation
videoPrompt?: string; // The prompt used to generate the video
}
// For AI Scan result, before saving to Firestore
@@ -42,5 +48,37 @@ export interface CardGenerationParams {
export interface GeneratedCard {
imageBase64: string;
prompt: string;
params: CardGenerationParams;
}
// User profile and settings
export interface UserProfile {
id: string; // Same as Firebase Auth uid
email: string;
displayName?: string;
photoURL?: string;
apiKeys?: UserApiKeys;
createdAt: Timestamp;
updatedAt: Timestamp;
}
export interface UserApiKeys {
geminiApiKey?: string;
openaiApiKey?: string;
}
// For Pokemon card generation from photo parameters
export interface PhotoCardGenerationParams {
photoDataUri?: string; // Optional - not saved to Firestore (too large), only used during generation
pokemonName: string;
pokemonType: string;
styleDescription: 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;
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Downloads a card image from a base64 data URL, data URI, or Firebase Storage URL
* @param imageUrl - The base64 data URL, data URI, or Firebase Storage URL of the image
* @param cardName - The name of the card to use as filename
*/
export async function downloadCardImage(imageUrl: string, cardName: string): Promise<void> {
try {
if (!imageUrl) {
throw new Error('No image available to download');
}
// Create a temporary anchor element
const link = document.createElement('a');
// Set the download attribute with a filename
const sanitizedName = cardName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
link.download = `${sanitizedName}_pokemon_card.png`;
// Handle different types of image URLs
if (imageUrl.startsWith('data:')) {
// Data URL - use directly
link.href = imageUrl;
} else if (imageUrl.startsWith('http')) {
// Firebase Storage URL or other HTTP URL - fetch and convert to blob URL
const response = await fetch(imageUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
link.href = blobUrl;
// Clean up the blob URL after download
link.addEventListener('click', () => {
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
});
} else {
// Assume it's a base64 string without data URL prefix
const dataUrl = `data:image/png;base64,${imageUrl}`;
link.href = dataUrl;
}
// Append to body, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('Error downloading card image:', error);
throw new Error('Failed to download card image');
}
}
/**
* Downloads a card image from a base64 string
* @param base64String - The base64 string of the image
* @param cardName - The name of the card to use as filename
*/
export function downloadCardFromBase64(base64String: string, cardName: string): void {
if (!base64String) {
throw new Error('No image data available to download');
}
const dataUrl = `data:image/jpeg;base64,${base64String}`;
downloadCardImage(dataUrl, cardName);
}
/**
* Downloads a card video from a Firebase Storage URL or base64 data
* @param videoUrl - The Firebase Storage URL or base64 data of the video
* @param cardName - The name of the card to use as filename
*/
export async function downloadCardVideo(videoUrl: string, cardName: string): Promise<void> {
try {
if (!videoUrl) {
throw new Error('No video available to download');
}
// Create a temporary anchor element
const link = document.createElement('a');
// Set the download attribute with a filename
const sanitizedName = cardName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
link.download = `${sanitizedName}_pokemon_card_video.mp4`;
// Handle different types of video URLs
if (videoUrl.startsWith('data:video/')) {
// Data URL - use directly
link.href = videoUrl;
} else if (videoUrl.startsWith('http')) {
// Firebase Storage URL or other HTTP URL - fetch and convert to blob URL
const response = await fetch(videoUrl);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
link.href = blobUrl;
// Clean up the blob URL after download
link.addEventListener('click', () => {
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
});
} else {
// Assume it's a base64 string without data URL prefix
const dataUrl = `data:video/mp4;base64,${videoUrl}`;
link.href = dataUrl;
}
// Append to body, click, and remove
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('Error downloading card video:', error);
throw new Error('Failed to download card video');
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { CardGenerationParams, PhotoCardGenerationParams } from '@/types';
export function serializeGenerationParams(params: CardGenerationParams): URLSearchParams {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
return searchParams;
}
export function serializePhotoGenerationParams(params: PhotoCardGenerationParams): URLSearchParams {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, value.toString());
}
});
return searchParams;
}
export function parseGenerationParams(searchParams: URLSearchParams): Partial<CardGenerationParams> {
const params: Partial<CardGenerationParams> = {};
if (searchParams.get('pokemonName')) params.pokemonName = searchParams.get('pokemonName')!;
if (searchParams.get('pokemonType')) params.pokemonType = searchParams.get('pokemonType')!;
if (searchParams.get('isIllustrationRare')) params.isIllustrationRare = searchParams.get('isIllustrationRare') === 'true';
if (searchParams.get('isHolo')) params.isHolo = searchParams.get('isHolo') === 'true';
if (searchParams.get('backgroundDescription')) params.backgroundDescription = searchParams.get('backgroundDescription')!;
if (searchParams.get('pokemonDescription')) params.pokemonDescription = searchParams.get('pokemonDescription')!;
if (searchParams.get('language')) params.language = searchParams.get('language') as any;
if (searchParams.get('hp')) params.hp = parseInt(searchParams.get('hp')!);
if (searchParams.get('attackName1')) params.attackName1 = searchParams.get('attackName1')!;
if (searchParams.get('attackDamage1')) params.attackDamage1 = parseInt(searchParams.get('attackDamage1')!);
if (searchParams.get('attackName2')) params.attackName2 = searchParams.get('attackName2')!;
if (searchParams.get('attackDamage2')) params.attackDamage2 = parseInt(searchParams.get('attackDamage2')!);
if (searchParams.get('weakness')) params.weakness = searchParams.get('weakness')!;
if (searchParams.get('resistance')) params.resistance = searchParams.get('resistance')!;
if (searchParams.get('retreatCost')) params.retreatCost = parseInt(searchParams.get('retreatCost')!);
return params;
}
export function parsePhotoGenerationParams(searchParams: URLSearchParams): Partial<PhotoCardGenerationParams> {
const params: Partial<PhotoCardGenerationParams> = {};
if (searchParams.get('photoDataUri')) params.photoDataUri = searchParams.get('photoDataUri')!;
if (searchParams.get('pokemonName')) params.pokemonName = searchParams.get('pokemonName')!;
if (searchParams.get('pokemonType')) params.pokemonType = searchParams.get('pokemonType')!;
if (searchParams.get('styleDescription')) params.styleDescription = searchParams.get('styleDescription')!;
if (searchParams.get('language')) params.language = searchParams.get('language') as any;
if (searchParams.get('hp')) params.hp = parseInt(searchParams.get('hp')!);
if (searchParams.get('attackName1')) params.attackName1 = searchParams.get('attackName1')!;
if (searchParams.get('attackDamage1')) params.attackDamage1 = parseInt(searchParams.get('attackDamage1')!);
if (searchParams.get('attackName2')) params.attackName2 = searchParams.get('attackName2')!;
if (searchParams.get('attackDamage2')) params.attackDamage2 = parseInt(searchParams.get('attackDamage2')!);
if (searchParams.get('weakness')) params.weakness = searchParams.get('weakness')!;
if (searchParams.get('resistance')) params.resistance = searchParams.get('resistance')!;
if (searchParams.get('retreatCost')) params.retreatCost = parseInt(searchParams.get('retreatCost')!);
return params;
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Utility functions for image processing and compression
*/
/**
* Compress a base64 image to reduce file size
* @param base64Image - Base64 encoded image string
* @param maxWidth - Maximum width in pixels (default: 512)
* @param maxHeight - Maximum height in pixels (default: 712) - Pokemon card aspect ratio
* @param quality - JPEG quality 0-1 (default: 0.8)
* @returns Promise<string> - Compressed base64 image
*/
export function compressBase64Image(
base64Image: string,
maxWidth: number = 512,
maxHeight: number = 712,
quality: number = 0.8
): Promise<string> {
return new Promise((resolve, reject) => {
// Create an image element
const img = new Image();
img.onload = () => {
// Create a canvas element
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Could not get canvas context'));
return;
}
// Calculate new dimensions while maintaining aspect ratio
let { width, height } = img;
if (width > height) {
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width = (width * maxHeight) / height;
height = maxHeight;
}
}
// Set canvas dimensions
canvas.width = width;
canvas.height = height;
// Draw and compress the image
ctx.drawImage(img, 0, 0, width, height);
// Get compressed base64 string
const compressedBase64 = canvas.toDataURL('image/jpeg', quality);
resolve(compressedBase64);
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
// Handle both data URLs and plain base64
const imageDataUrl = base64Image.startsWith('data:')
? base64Image
: `data:image/jpeg;base64,${base64Image}`;
img.src = imageDataUrl;
});
}
/**
* Get the size of a base64 string in bytes
* @param base64String - Base64 encoded string
* @returns number - Size in bytes
*/
export function getBase64Size(base64String: string): number {
// Remove data URL prefix if present
const base64Data = base64String.includes(',')
? base64String.split(',')[1]
: base64String;
// Calculate size: (base64 length * 3/4) - padding
const padding = base64Data.endsWith('==') ? 2 : base64Data.endsWith('=') ? 1 : 0;
return Math.floor((base64Data.length * 3) / 4) - padding;
}
/**
* Format bytes to human readable format
* @param bytes - Number of bytes
* @returns string - Formatted string (e.g., "1.2 MB")
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
+236
View File
@@ -0,0 +1,236 @@
export interface ShareOptions {
cardName: string;
cardImageUrl?: string;
publicImageUrl?: string; // For social media previews
onImageCopied?: (platform: string) => void;
onImageUploaded?: (url: string) => void;
}
import { uploadImageToStorage } from './storageUtils';
// Cache for uploaded image URLs to avoid re-uploading the same image
const uploadedImageCache = new Map<string, string>();
// Helper function to get a cache key for an image
const getImageCacheKey = (cardName: string, imageDataUrl: string): string => {
// Use first 100 characters of base64 data as a simple hash
const imageHash = imageDataUrl.slice(0, 100);
return `${cardName}_${imageHash}`;
};
// Helper function to upload image and get public URL with caching
const getOrUploadImageUrl = async (
cardName: string,
cardImageUrl: string,
onImageUploaded?: (url: string) => void
): Promise<string | null> => {
try {
// If the image URL is already a Firebase Storage URL or HTTP URL, return it directly
if (cardImageUrl.startsWith('http')) {
console.log('Using existing Firebase Storage URL:', cardImageUrl);
return cardImageUrl;
}
// Check cache first for base64 images
const cacheKey = getImageCacheKey(cardName, cardImageUrl);
const cachedUrl = uploadedImageCache.get(cacheKey);
if (cachedUrl) {
console.log('Using cached image URL:', cachedUrl);
return cachedUrl;
}
// Upload image to storage (for base64 data URLs)
const imageUrl = await uploadImageToStorage(cardImageUrl, cardName);
// Cache the URL
uploadedImageCache.set(cacheKey, imageUrl);
// Notify callback
onImageUploaded?.(imageUrl);
return imageUrl;
} catch (error) {
console.error('Failed to upload image:', error);
return null;
}
};
const downloadImageAsBlob = async (imageDataUrl: string): Promise<Blob> => {
const response = await fetch(imageDataUrl);
return response.blob();
};
// Helper function to copy image and text to clipboard
export const copyImageAndText = async ({ cardName, cardImageUrl }: ShareOptions) => {
try {
const text = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
if (cardImageUrl && cardImageUrl.startsWith('data:') && navigator.clipboard?.write) {
// Copy both image and text to clipboard (modern browsers)
const imageBlob = await downloadImageAsBlob(cardImageUrl);
const clipboardItems = [
new ClipboardItem({
'image/png': imageBlob,
'text/plain': new Blob([text], { type: 'text/plain' })
})
];
await navigator.clipboard.write(clipboardItems);
return true;
} else {
// Fallback to text only
await navigator.clipboard.writeText(text);
return false; // Indicates image wasn't copied
}
} catch (error) {
console.error('Failed to copy to clipboard:', error);
throw error;
}
};
export const shareOnTwitter = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
let shareText = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
// If we have an image, try to get or upload it to get a public URL
let imageUrl = publicImageUrl;
if (!imageUrl && cardImageUrl) {
if (cardImageUrl.startsWith('http')) {
// Already a Firebase Storage URL, use directly
imageUrl = cardImageUrl;
} else if (cardImageUrl.startsWith('data:')) {
// Base64 data URL, need to upload
const uploadedUrl = await getOrUploadImageUrl(cardName, cardImageUrl, onImageUploaded);
if (uploadedUrl) {
imageUrl = uploadedUrl;
} else {
// If upload failed, fallback to copying to clipboard
try {
await copyImageAndText({ cardName, cardImageUrl });
onImageCopied?.('Twitter/X');
} catch (clipboardError) {
console.warn('Failed to copy image to clipboard:', clipboardError);
}
}
}
}
// If we have a public image URL, include it in the tweet
if (imageUrl) {
shareText += ` ${imageUrl}`;
}
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}`;
window.open(url, '_blank', 'width=550,height=420');
};
export const shareOnFacebook = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
let shareText = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
let shareUrl = 'https://cardex.xavidop.me';
// If we have an image, try to get or upload it to get a public URL
let imageUrl = publicImageUrl;
if (!imageUrl && cardImageUrl) {
if (cardImageUrl.startsWith('http')) {
// Already a Firebase Storage URL, use directly
imageUrl = cardImageUrl;
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
} else if (cardImageUrl.startsWith('data:')) {
// Base64 data URL, need to upload
const uploadedUrl = await getOrUploadImageUrl(cardName, cardImageUrl, onImageUploaded);
if (uploadedUrl) {
imageUrl = uploadedUrl;
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
} else {
// If upload failed, fallback to copying to clipboard
try {
await copyImageAndText({ cardName, cardImageUrl });
onImageCopied?.('Facebook');
} catch (clipboardError) {
console.warn('Failed to copy image to clipboard:', clipboardError);
}
}
}
} else if (imageUrl) {
shareUrl = imageUrl;
}
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}&quote=${encodeURIComponent(shareText)}`;
window.open(url, '_blank', 'width=580,height=296');
};
export const shareOnLinkedIn = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
let shareText = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
let shareUrl = 'https://cardex.xavidop.me';
// If we have an image, try to get or upload it to get a public URL
let imageUrl = publicImageUrl;
if (!imageUrl && cardImageUrl) {
if (cardImageUrl.startsWith('http')) {
// Already a Firebase Storage URL, use directly
imageUrl = cardImageUrl;
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
} else if (cardImageUrl.startsWith('data:')) {
// Base64 data URL, need to upload
const uploadedUrl = await getOrUploadImageUrl(cardName, cardImageUrl, onImageUploaded);
if (uploadedUrl) {
imageUrl = uploadedUrl;
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
} else {
// If upload failed, fallback to copying to clipboard
try {
await copyImageAndText({ cardName, cardImageUrl });
onImageCopied?.('LinkedIn');
} catch (clipboardError) {
console.warn('Failed to copy image to clipboard:', clipboardError);
}
}
}
} else if (imageUrl) {
shareUrl = imageUrl;
}
const url = `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(`AI-Generated Pokémon Card: ${cardName}`)}&summary=${encodeURIComponent(shareText)}&source=Cardex`;
window.open(url, '_blank', 'width=520,height=570');
};
export const copyShareLink = ({ cardName }: ShareOptions) => {
const text = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
navigator.clipboard.writeText(text);
};
// Native share API for mobile devices
export const nativeShare = async ({ cardName, cardImageUrl }: ShareOptions) => {
if (navigator.share) {
try {
const shareData: ShareData = {
title: `AI-Generated Pokémon Card`,
text: `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`,
url: 'https://cardex.xavidop.me',
};
// If we have an image and the browser supports file sharing
if (cardImageUrl && navigator.canShare) {
try {
// Convert data URL or Firebase Storage URL to blob
const response = await fetch(cardImageUrl);
const blob = await response.blob();
const file = new File([blob], `${cardName}-card.png`, { type: 'image/png' });
// Check if we can share files
if (navigator.canShare({ files: [file] })) {
shareData.files = [file];
}
} catch (error) {
console.warn('Failed to add image to share:', error);
}
}
await navigator.share(shareData);
return true;
} catch (error) {
if ((error as Error).name !== 'AbortError') {
console.error('Error sharing:', error);
}
return false;
}
}
return false;
};
+121
View File
@@ -0,0 +1,121 @@
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
import { storage } from '@/lib/firebase';
// Convert base64 data URL to blob
const dataURLToBlob = (dataURL: string): Blob => {
const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png';
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
};
// Upload image to Firebase Storage and return public URL
export const uploadImageToStorage = async (
imageDataUrl: string,
cardName: string,
userId?: string
): Promise<string> => {
try {
// Convert data URL to blob
const blob = dataURLToBlob(imageDataUrl);
// Create a unique filename with timestamp
const timestamp = Date.now();
const sanitizedCardName = cardName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
// Determine the storage path based on whether userId is provided
const filename = userId
? `users/${userId}/cards/${sanitizedCardName}_${timestamp}.png`
: `cards/${sanitizedCardName}_${timestamp}.png`;
// Create storage reference
const storageRef = ref(storage, filename);
// Upload the blob
const snapshot = await uploadBytes(storageRef, blob, {
contentType: 'image/png',
cacheControl: 'public, max-age=31536000', // 1 year cache
});
// Get the download URL
const downloadURL = await getDownloadURL(snapshot.ref);
console.log('Image uploaded successfully:', downloadURL);
return downloadURL;
} catch (error) {
console.error('Failed to upload image to storage:', error);
throw new Error('Failed to upload image to storage');
}
};
// Upload card image to Firebase Storage in user's collection folder
export const uploadCardImageToStorage = async (
imageDataUrl: string,
userId: string,
cardName: string
): Promise<string> => {
return uploadImageToStorage(imageDataUrl, cardName, userId);
};
// Convert base64 video data to blob
const base64ToVideoBlob = (base64Data: string): Blob => {
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: 'video/mp4' });
};
// Upload video to Firebase Storage and return public URL
// This function handles base64 video data only
export const uploadVideoToStorage = async (
videoBase64: string,
userId: string,
cardName: string
): Promise<string> => {
try {
// Check if this is actually a URL instead of base64 data (this shouldn't happen)
if (videoBase64.startsWith('http')) {
throw new Error('uploadVideoToStorage received a URL instead of base64 data. Video should be downloaded first.');
}
// Convert base64 to blob
const blob = base64ToVideoBlob(videoBase64);
// Create a unique filename with timestamp
const timestamp = Date.now();
const sanitizedCardName = cardName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
const filename = `users/${userId}/videos/${sanitizedCardName}_${timestamp}.mp4`;
// Create storage reference
const storageRef = ref(storage, filename);
// Upload the blob
const snapshot = await uploadBytes(storageRef, blob, {
contentType: 'video/mp4',
cacheControl: 'public, max-age=31536000', // 1 year cache
});
// Get the download URL
const downloadURL = await getDownloadURL(snapshot.ref);
console.log('Video uploaded successfully to Firebase Storage:', downloadURL);
return downloadURL;
} catch (error) {
console.error('Failed to upload video to storage:', error);
throw new Error('Failed to upload video to storage');
}
};
// Clean up old shared images (optional, can be called periodically)
export const cleanupOldSharedImages = async (olderThanDays: number = 7) => {
// This would require Firebase Admin SDK or Cloud Functions
// For now, we'll rely on Firebase Storage lifecycle rules
console.log(`Cleanup of images older than ${olderThanDays} days should be configured in Firebase Storage lifecycle rules`);
};
+24
View File
@@ -0,0 +1,24 @@
rules_version = '2';
// Craft rules based on data in your Firestore database
// allow write: if firestore.get(
// /databases/(default)/documents/users/$(request.auth.uid)).data.isAdmin;
service firebase.storage {
match /b/{bucket}/o {
// Allow public read access to cards, but require authentication for writing
match /cards/{allPaths=**} {
allow read: if true; // Public read access for sharing
allow write: if request.auth != null; // Authenticated write access
}
// Allow authenticated users to read and write their own user files
match /users/{userId}/{allPaths=**} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
// Deny all other access
match /{allPaths=**} {
allow read, write: if false;
}
}
}