mirror of
https://github.com/xavidop/cardex.git
synced 2026-08-28 17:01:07 +00:00
feat: video generation, image to image generation and a bunch of new features
This commit is contained in:
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
"projects": {
|
"projects": {
|
||||||
"default": "cardex-1mapj"
|
"default": "cardex-1mapj"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Scanning**: Use Gemini Vision to automatically identify Pokémon cards from photos
|
||||||
- ✨ **AI Card Generation**: Create custom Pokémon cards using Google Imagen4
|
- ✨ **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
|
- 🔐 **Secure Authentication**: Firebase Authentication with email and Google login
|
||||||
- 📱 **Responsive Design**: Modern UI built with Next.js and Tailwind CSS
|
- 📱 **Responsive Design**: Modern UI built with Next.js and Tailwind CSS
|
||||||
- 💾 **Cloud Storage**: Real-time data synchronization with Firestore
|
- 💾 **Cloud Storage**: Real-time data synchronization with Firestore
|
||||||
@@ -30,6 +32,8 @@ graph TB
|
|||||||
J[Firestore Database] --> K[Real-time Updates]
|
J[Firestore Database] --> K[Real-time Updates]
|
||||||
L[Gemini AI] --> M[Vision API]
|
L[Gemini AI] --> M[Vision API]
|
||||||
M --> N[Card Recognition]
|
M --> N[Card Recognition]
|
||||||
|
O[Google Veo 2.0] --> P[Video Generation]
|
||||||
|
P --> Q[Card Animation]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph "Development Tools"
|
subgraph "Development Tools"
|
||||||
@@ -41,6 +45,7 @@ graph TB
|
|||||||
A --> H
|
A --> H
|
||||||
A --> J
|
A --> J
|
||||||
A --> L
|
A --> L
|
||||||
|
A --> O
|
||||||
O --> L
|
O --> L
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -50,12 +55,15 @@ graph TB
|
|||||||
src/
|
src/
|
||||||
├── ai/
|
├── ai/
|
||||||
│ └── flows/
|
│ └── 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/
|
├── app/
|
||||||
│ ├── dashboard/
|
│ ├── dashboard/
|
||||||
│ │ ├── collection/ # Card collection pages
|
│ │ ├── collection/ # Card collection pages
|
||||||
│ │ └── scan/ # Card scanning page
|
│ │ └── scan/ # Card scanning page
|
||||||
│ └── page.tsx # Root page with auth routing
|
│ └── page.tsx # Root page with auth routing
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── cards/
|
│ ├── cards/
|
||||||
│ │ ├── CardForm.tsx # Reusable card form
|
│ │ ├── CardForm.tsx # Reusable card form
|
||||||
@@ -77,7 +85,8 @@ src/
|
|||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- Firebase project with Firestore and Authentication enabled
|
- Firebase project with Firestore and Authentication enabled
|
||||||
- Google AI API key for Gemini Vision 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
|
### 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_MESSAGING_SENDER_ID=your_sender_id
|
||||||
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id
|
NEXT_PUBLIC_FIREBASE_APP_ID=your_app_id
|
||||||
GOOGLE_GENAI_API_KEY=your_gemini_api_key
|
GOOGLE_GENAI_API_KEY=your_gemini_api_key
|
||||||
|
OPENAI_API_KEY=your_openai_api_key
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Run the development server:
|
4. Run the development server:
|
||||||
@@ -134,6 +144,31 @@ npm run genkit:dev
|
|||||||
4. Preview the generated card
|
4. Preview the generated card
|
||||||
5. Save it to your collection if you like it
|
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
|
### Collection Management
|
||||||
- View all your cards in a responsive grid layout
|
- View all your cards in a responsive grid layout
|
||||||
- Edit card details by clicking on any card
|
- Edit card details by clicking on any card
|
||||||
@@ -147,17 +182,29 @@ Firestore Collection: users/{userId}/pokemon_cards/{cardId}
|
|||||||
├── name: string
|
├── name: string
|
||||||
├── set: string
|
├── set: string
|
||||||
├── rarity: 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
|
├── userId: string
|
||||||
├── createdAt: timestamp
|
├── createdAt: timestamp
|
||||||
└── updatedAt: timestamp
|
└── updatedAt: timestamp
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Firebase Storage Structure:
|
||||||
|
```
|
||||||
|
/users/{userId}/cards/{sanitized_card_name}_{timestamp}.png
|
||||||
|
/users/{userId}/videos/{sanitized_card_name}_{timestamp}.mp4
|
||||||
|
```
|
||||||
|
|
||||||
## AI Integration
|
## 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
|
- **Gemini Vision API**: Analyze uploaded card images and extract card information
|
||||||
- **Imagen4**: Generate custom Pokemon card artwork based on user parameters
|
- **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
|
- Provide structured data for user review and confirmation
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
@@ -18,7 +18,5 @@ env:
|
|||||||
secret: NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
|
secret: NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
|
||||||
- variable: NEXT_PUBLIC_FIREBASE_APP_ID
|
- variable: NEXT_PUBLIC_FIREBASE_APP_ID
|
||||||
secret: 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
|
- variable: NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID
|
||||||
secret: NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID
|
secret: NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID
|
||||||
|
|||||||
+37
-35
@@ -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": {
|
"firestore": {
|
||||||
"rules": "firestore.rules",
|
"port": 8080,
|
||||||
"indexes": "firestore.indexes.json"
|
"host": "0.0.0.0"
|
||||||
},
|
},
|
||||||
"apphosting": {
|
"hub": {
|
||||||
"backendId": "studio",
|
"port": 4400,
|
||||||
"rootDir": "/",
|
"host": "0.0.0.0"
|
||||||
"ignore": [
|
|
||||||
"node_modules",
|
|
||||||
".git",
|
|
||||||
"firebase-debug.log",
|
|
||||||
"firebase-debug.*.log",
|
|
||||||
"functions"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"emulators": {
|
"ui": {
|
||||||
"auth": {
|
"enabled": true,
|
||||||
"port": 9099,
|
"host": "0.0.0.0"
|
||||||
"host": "0.0.0.0"
|
},
|
||||||
},
|
"logging": {
|
||||||
"firestore": {
|
"host": "0.0.0.0"
|
||||||
"port": 8080,
|
},
|
||||||
"host": "0.0.0.0"
|
"singleProjectMode": true
|
||||||
},
|
},
|
||||||
"hub": {
|
"storage": {
|
||||||
"port": 4400,
|
"rules": "storage.rules"
|
||||||
"host": "0.0.0.0"
|
|
||||||
},
|
|
||||||
"ui": {
|
|
||||||
"enabled": true,
|
|
||||||
"host": "0.0.0.0"
|
|
||||||
},
|
|
||||||
"logging": {
|
|
||||||
"host": "0.0.0.0"
|
|
||||||
},
|
|
||||||
"singleProjectMode": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ const nextConfig: NextConfig = {
|
|||||||
port: '',
|
port: '',
|
||||||
pathname: '/**',
|
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: '/**',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+1008
-992
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -15,8 +15,8 @@
|
|||||||
"dev:full": "concurrently \"npm run emulators\" \"npm run dev\""
|
"dev:full": "concurrently \"npm run emulators\" \"npm run dev\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@genkit-ai/googleai": "^1.8.0",
|
"@genkit-ai/googleai": "^1.15.1",
|
||||||
"@genkit-ai/next": "^1.8.0",
|
"@genkit-ai/next": "^1.15.1",
|
||||||
"@google/genai": "^1.10.0",
|
"@google/genai": "^1.10.0",
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
@@ -45,10 +45,11 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"firebase": "^11.8.1",
|
"firebase": "^11.10.0",
|
||||||
"genkit": "^1.8.0",
|
"genkit": "^1.15.1",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"next": "15.3.3",
|
"next": "15.3.3",
|
||||||
|
"openai": "^5.10.1",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"concurrently": "^9.2.0",
|
"concurrently": "^9.2.0",
|
||||||
"genkit-cli": "^1.8.0",
|
"genkit-cli": "^1.15.1",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
+1
-1
@@ -3,4 +3,4 @@ config();
|
|||||||
|
|
||||||
import '@/ai/flows/summarize-card-information.ts';
|
import '@/ai/flows/summarize-card-information.ts';
|
||||||
import '@/ai/flows/scan-pokemon-card.ts';
|
import '@/ai/flows/scan-pokemon-card.ts';
|
||||||
import '@/ai/flows/generate-pokemon-card.ts';
|
import '@/ai/flows/generate-pokemon-card.ts';
|
||||||
|
|||||||
@@ -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.`;
|
||||||
|
}
|
||||||
@@ -12,8 +12,10 @@
|
|||||||
import { ai } from '@/ai/genkit';
|
import { ai } from '@/ai/genkit';
|
||||||
import { z } from 'genkit';
|
import { z } from 'genkit';
|
||||||
import { GoogleGenAI } from '@google/genai';
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
import { getUserApiKeys } from '@/lib/firestore';
|
||||||
|
|
||||||
const GeneratePokemonCardInputSchema = z.object({
|
const GeneratePokemonCardInputSchema = z.object({
|
||||||
|
userId: z.string().min(1, "User ID is required"),
|
||||||
pokemonName: z.string().min(1, "Pokemon name is required"),
|
pokemonName: z.string().min(1, "Pokemon name is required"),
|
||||||
pokemonType: z.string().min(1, "Pokemon type is required"),
|
pokemonType: z.string().min(1, "Pokemon type is required"),
|
||||||
isIllustrationRare: z.boolean(),
|
isIllustrationRare: z.boolean(),
|
||||||
@@ -52,28 +54,32 @@ const generatePokemonCardFlow = ai.defineFlow(
|
|||||||
outputSchema: GeneratePokemonCardOutputSchema,
|
outputSchema: GeneratePokemonCardOutputSchema,
|
||||||
},
|
},
|
||||||
async (params: GeneratePokemonCardInput) => {
|
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 {
|
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({
|
const response = await genAI.models.generateImages({
|
||||||
model: 'models/imagen-4.0-ultra-generate-preview-06-06',
|
model: 'models/imagen-4.0-ultra-generate-preview-06-06',
|
||||||
prompt: prompt,
|
prompt: prompt,
|
||||||
config: {
|
config: {
|
||||||
numberOfImages: 1,
|
numberOfImages: 1,
|
||||||
outputMimeType: 'image/jpeg',
|
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
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* - ScanPokemonCardOutput - The return type for the scanPokemonCard function.
|
* - ScanPokemonCardOutput - The return type for the scanPokemonCard function.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {ai} from '@/ai/genkit';
|
import {ai, createUserAI} from '@/ai/genkit';
|
||||||
import {z} from 'genkit';
|
import {z} from 'genkit';
|
||||||
|
|
||||||
const ScanPokemonCardInputSchema = z.object({
|
const ScanPokemonCardInputSchema = z.object({
|
||||||
@@ -18,6 +18,7 @@ const ScanPokemonCardInputSchema = z.object({
|
|||||||
.describe(
|
.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>'."
|
"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>;
|
export type ScanPokemonCardInput = z.infer<typeof ScanPokemonCardInputSchema>;
|
||||||
|
|
||||||
@@ -36,40 +37,60 @@ export async function scanPokemonCard(input: ScanPokemonCardInput): Promise<Scan
|
|||||||
return scanPokemonCardFlow(input);
|
return scanPokemonCardFlow(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scanPokemonCardPrompt = ai.definePrompt({
|
async function createScanPromptAndFlow(userAI: any) {
|
||||||
name: 'scanPokemonCardPrompt',
|
const scanPokemonCardPrompt = userAI.definePrompt({
|
||||||
input: {schema: ScanPokemonCardInputSchema},
|
name: 'scanPokemonCardPrompt',
|
||||||
output: {schema: ScanPokemonCardOutputSchema},
|
input: {schema: ScanPokemonCardInputSchema},
|
||||||
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
|
output: {schema: ScanPokemonCardOutputSchema},
|
||||||
Photo: {{media url=photoDataUri}}
|
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
|
||||||
\n
|
Photo: {{media url=photoDataUri}}
|
||||||
Respond using the following format:
|
\n
|
||||||
{
|
Respond using the following format:
|
||||||
"cardDetails": {
|
{
|
||||||
"name": "Pokemon card name",
|
"cardDetails": {
|
||||||
"set": "Pokemon first card set it was introduced in",
|
"name": "Pokemon card name",
|
||||||
"rarity": "Pokemon card rarity"
|
"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:
|
||||||
\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"
|
||||||
"error": "Error message describing why the card could not be identified"
|
}`,
|
||||||
}`,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const scanPokemonCardFlow = ai.defineFlow(
|
const scanPokemonCardFlow = userAI.defineFlow(
|
||||||
{
|
{
|
||||||
name: 'scanPokemonCardFlow',
|
name: 'scanPokemonCardFlow',
|
||||||
inputSchema: ScanPokemonCardInputSchema,
|
inputSchema: ScanPokemonCardInputSchema,
|
||||||
outputSchema: ScanPokemonCardOutputSchema,
|
outputSchema: ScanPokemonCardOutputSchema,
|
||||||
},
|
},
|
||||||
async input => {
|
async (input: ScanPokemonCardInput) => {
|
||||||
try {
|
try {
|
||||||
const {output} = await scanPokemonCardPrompt(input);
|
const {output} = await scanPokemonCardPrompt(input);
|
||||||
return output!;
|
return output!;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error during card scanning:', error);
|
console.error('Error during card scanning:', error);
|
||||||
return {error: 'Failed to scan Pokemon card.'};
|
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
@@ -1,17 +1,40 @@
|
|||||||
import {genkit} from 'genkit';
|
import {genkit} from 'genkit';
|
||||||
import {googleAI} from '@genkit-ai/googleai';
|
import {googleAI} from '@genkit-ai/googleai';
|
||||||
|
import { getUserApiKeys } from '@/lib/firestore';
|
||||||
|
|
||||||
// Validate API key exists
|
// Default genkit instance with environment variables
|
||||||
const apiKey = process.env.GOOGLE_GENAI_API_KEY;
|
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.');
|
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');
|
throw new Error('Google AI API key is required for Genkit configuration');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ai = genkit({
|
export const ai = genkit({
|
||||||
plugins: [
|
plugins: [
|
||||||
googleAI({apiKey})
|
googleAI({apiKey: defaultApiKey})
|
||||||
],
|
],
|
||||||
model: 'googleai/gemini-2.5-pro-preview-05-06',
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields - including userId
|
||||||
const requiredFields = ['pokemonName', 'pokemonType', 'backgroundDescription', 'pokemonDescription'];
|
const requiredFields = ['userId', 'pokemonName', 'pokemonType', 'backgroundDescription', 'pokemonDescription'];
|
||||||
const missingFields = requiredFields.filter(field => !body[field]);
|
const missingFields = requiredFields.filter(field => !body[field]);
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
if (missingFields.length > 0) {
|
||||||
|
|||||||
@@ -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 type { PokemonCard } from '@/types';
|
||||||
import CardForm, { type CardFormInputs } from '@/components/cards/CardForm';
|
import CardForm, { type CardFormInputs } from '@/components/cards/CardForm';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
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() {
|
export default function EditCardPage() {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
@@ -39,6 +43,38 @@ export default function EditCardPage() {
|
|||||||
.then(fetchedCard => {
|
.then(fetchedCard => {
|
||||||
if (fetchedCard) {
|
if (fetchedCard) {
|
||||||
setCard(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 {
|
} else {
|
||||||
setError('Card not found or you do not have permission to edit it.');
|
setError('Card not found or you do not have permission to edit it.');
|
||||||
toast({ title: "Error", description: "Card not found.", variant: "destructive" });
|
toast({ title: "Error", description: "Card not found.", variant: "destructive" });
|
||||||
@@ -58,16 +94,13 @@ export default function EditCardPage() {
|
|||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const updatedData: Partial<PokemonCard> = {
|
const updatedData = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
set: data.set,
|
set: data.set,
|
||||||
rarity: data.rarity,
|
rarity: data.rarity,
|
||||||
// imageDataUrl is part of CardFormInputs but might not change if not re-uploaded
|
// Only include imageDataUrl if it's different from the original imageUrl
|
||||||
// For simplicity, we assume imageDataUrl is handled if it's part of the form
|
// This allows the backend to determine if a new image upload is needed
|
||||||
// and if image upload was part of the edit form (which it isn't currently for simplicity)
|
...(data.imageDataUrl !== card.imageUrl && { imageDataUrl: data.imageDataUrl })
|
||||||
// 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,
|
|
||||||
};
|
};
|
||||||
await updateCardInCollection(user.uid, card.id, updatedData);
|
await updateCardInCollection(user.uid, card.id, updatedData);
|
||||||
toast({ title: 'Card Updated', description: `${data.name} has been updated.` });
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-[calc(100vh-200px)]">
|
<div className="flex justify-center items-center min-h-[calc(100vh-200px)]">
|
||||||
@@ -106,7 +188,38 @@ export default function EditCardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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
|
<CardForm
|
||||||
initialData={card}
|
initialData={card}
|
||||||
onSubmit={handleUpdateCard}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { getUserCards, deleteCardFromCollection } from '@/lib/firestore';
|
|||||||
import type { PokemonCard } from '@/types';
|
import type { PokemonCard } from '@/types';
|
||||||
import CardItem from '@/components/cards/CardItem';
|
import CardItem from '@/components/cards/CardItem';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Loader2, PlusCircle, AlertTriangle, Sparkles } from 'lucide-react';
|
import { Loader2, PlusCircle, AlertTriangle, Sparkles, Camera, RefreshCw } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
@@ -26,12 +26,55 @@ export default function CollectionPage() {
|
|||||||
}
|
}
|
||||||
}, [user]);
|
}, [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 () => {
|
const fetchCards = async () => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const userCards = await getUserCards(user.uid);
|
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);
|
setCards(userCards);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Error fetching cards:", 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-[calc(100vh-200px)]">
|
<div className="flex justify-center items-center min-h-[calc(100vh-200px)]">
|
||||||
@@ -79,7 +163,18 @@ export default function CollectionPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||||
<h1 className="text-3xl font-bold font-headline">My Card Collection</h1>
|
<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">
|
<div className="flex gap-2">
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href="/dashboard/generate">
|
<Link href="/dashboard/generate">
|
||||||
@@ -94,6 +189,29 @@ export default function CollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{cards.length === 0 ? (
|
||||||
<div className="text-center py-10 border-2 border-dashed border-border rounded-lg">
|
<div className="text-center py-10 border-2 border-dashed border-border rounded-lg">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-archive-restore mx-auto mb-4 text-muted-foreground"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-V8"/><path d="M9.5 12.5 12 15l2.5-2.5"/><path d="M12 15V9"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-archive-restore mx-auto mb-4 text-muted-foreground"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-V8"/><path d="M9.5 12.5 12 15l2.5-2.5"/><path d="M12 15V9"/></svg>
|
||||||
@@ -105,6 +223,11 @@ export default function CollectionPage() {
|
|||||||
<Sparkles className="mr-2 h-5 w-5" /> Generate a Card
|
<Sparkles className="mr-2 h-5 w-5" /> Generate a Card
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</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>
|
<Button asChild>
|
||||||
<Link href="/dashboard/scan">
|
<Link href="/dashboard/scan">
|
||||||
<PlusCircle className="mr-2 h-5 w-5" /> Scan a Card
|
<PlusCircle className="mr-2 h-5 w-5" /> Scan a Card
|
||||||
@@ -113,12 +236,13 @@ export default function CollectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
{cards.map(card => (
|
{cards.map(card => (
|
||||||
<CardItem
|
<CardItem
|
||||||
key={card.id}
|
key={card.id}
|
||||||
card={card}
|
card={card}
|
||||||
onDelete={handleDeleteCard}
|
onDelete={handleDeleteCard}
|
||||||
|
onUpdate={handleUpdateCard}
|
||||||
isDeleting={deletingCardId === card.id}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,15 +7,16 @@ import { useAuth } from '@/hooks/useAuth';
|
|||||||
import { addCardToCollection } from '@/lib/firestore';
|
import { addCardToCollection } from '@/lib/firestore';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { compressBase64Image, getBase64Size, formatBytes } from '@/utils/imageUtils';
|
||||||
|
|
||||||
export default function GenerateCardPage() {
|
export default function GenerateCardPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
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 [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
const handleCardGenerated = (imageBase64: string, prompt: string) => {
|
const handleCardGenerated = (imageBase64: string, prompt: string, params: any) => {
|
||||||
setGeneratedCard({ imageBase64, prompt });
|
setGeneratedCard({ imageBase64, prompt, params });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveToCollection = async () => {
|
const handleSaveToCollection = async () => {
|
||||||
@@ -24,7 +25,35 @@ export default function GenerateCardPage() {
|
|||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
// Convert base64 to data URL
|
// 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, {
|
await addCardToCollection(user.uid, {
|
||||||
name: 'Generated Pokemon Card',
|
name: 'Generated Pokemon Card',
|
||||||
@@ -33,6 +62,7 @@ export default function GenerateCardPage() {
|
|||||||
imageDataUrl: imageDataUrl,
|
imageDataUrl: imageDataUrl,
|
||||||
isGenerated: true,
|
isGenerated: true,
|
||||||
prompt: generatedCard.prompt,
|
prompt: generatedCard.prompt,
|
||||||
|
generationParams: generatedCard.params,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -18,7 +18,7 @@ export default function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center">
|
<CardTitle className="flex items-center">
|
||||||
@@ -57,6 +57,25 @@ export default function DashboardPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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">
|
<Card className="hover:shadow-lg transition-shadow">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center">
|
<CardTitle className="flex items-center">
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import CardScanner from '@/components/cards/CardScanner';
|
|||||||
|
|
||||||
export default function ScanCardPage() {
|
export default function ScanCardPage() {
|
||||||
return (
|
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 />
|
<CardScanner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ export default function CardForm({
|
|||||||
name: initialData?.name || '',
|
name: initialData?.name || '',
|
||||||
set: initialData?.set || '',
|
set: initialData?.set || '',
|
||||||
rarity: initialData?.rarity || '',
|
rarity: initialData?.rarity || '',
|
||||||
imageDataUrl: imageDataUrlFromScan || (initialData as PokemonCard)?.imageDataUrl || '',
|
imageDataUrl: imageDataUrlFromScan || (initialData as PokemonCard)?.imageUrl || '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Separator } from '@/components/ui/separator';
|
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 {
|
export interface GeneratePokemonCardInput {
|
||||||
pokemonName: string;
|
pokemonName: string;
|
||||||
@@ -70,10 +73,13 @@ const cardGeneratorSchema = z.object({
|
|||||||
type CardGeneratorForm = z.infer<typeof cardGeneratorSchema>;
|
type CardGeneratorForm = z.infer<typeof cardGeneratorSchema>;
|
||||||
|
|
||||||
interface CardGeneratorProps {
|
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 [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string } | null>(null);
|
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string } | null>(null);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
@@ -87,27 +93,51 @@ export function CardGenerator({ onCardGenerated }: CardGeneratorProps) {
|
|||||||
} = useForm<CardGeneratorForm>({
|
} = useForm<CardGeneratorForm>({
|
||||||
resolver: zodResolver(cardGeneratorSchema),
|
resolver: zodResolver(cardGeneratorSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
pokemonName: '',
|
pokemonName: initialValues?.pokemonName || '',
|
||||||
pokemonType: '',
|
pokemonType: initialValues?.pokemonType || '',
|
||||||
isIllustrationRare: false,
|
isIllustrationRare: initialValues?.isIllustrationRare || false,
|
||||||
isHolo: false,
|
isHolo: initialValues?.isHolo || false,
|
||||||
backgroundDescription: '',
|
backgroundDescription: initialValues?.backgroundDescription || '',
|
||||||
pokemonDescription: '',
|
pokemonDescription: initialValues?.pokemonDescription || '',
|
||||||
language: 'english',
|
language: initialValues?.language || 'english',
|
||||||
hp: 130,
|
hp: initialValues?.hp || 130,
|
||||||
attackName1: 'Quick Attack',
|
attackName1: initialValues?.attackName1 || 'Quick Attack',
|
||||||
attackDamage1: 60,
|
attackDamage1: initialValues?.attackDamage1 || 60,
|
||||||
attackName2: 'Special Move',
|
attackName2: initialValues?.attackName2 || 'Special Move',
|
||||||
attackDamage2: 90,
|
attackDamage2: initialValues?.attackDamage2 || 90,
|
||||||
weakness: 'Fighting',
|
weakness: initialValues?.weakness || 'Fighting',
|
||||||
resistance: 'Psychic',
|
resistance: initialValues?.resistance || 'Psychic',
|
||||||
retreatCost: 2,
|
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 watchedValues = watch();
|
||||||
|
|
||||||
const onSubmit = async (data: CardGeneratorForm) => {
|
const onSubmit = async (data: CardGeneratorForm) => {
|
||||||
|
if (!user) {
|
||||||
|
setError('You must be logged in to generate cards');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
setError('');
|
setError('');
|
||||||
setGeneratedCard(null);
|
setGeneratedCard(null);
|
||||||
@@ -118,7 +148,10 @@ export function CardGenerator({ onCardGenerated }: CardGeneratorProps) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify({
|
||||||
|
...data,
|
||||||
|
userId: user.uid,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -138,7 +171,7 @@ export function CardGenerator({ onCardGenerated }: CardGeneratorProps) {
|
|||||||
imageBase64: result.imageBase64,
|
imageBase64: result.imageBase64,
|
||||||
prompt: result.prompt,
|
prompt: result.prompt,
|
||||||
});
|
});
|
||||||
onCardGenerated?.(result.imageBase64, result.prompt);
|
onCardGenerated?.(result.imageBase64, result.prompt, data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error generating card:', 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"
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadCard}
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download Card
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-64 bg-gray-50 rounded-lg">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,13 @@ import Link from 'next/link';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 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 {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -22,54 +27,380 @@ import {
|
|||||||
interface CardItemProps {
|
interface CardItemProps {
|
||||||
card: PokemonCard;
|
card: PokemonCard;
|
||||||
onDelete: (cardId: string) => void;
|
onDelete: (cardId: string) => void;
|
||||||
|
onUpdate?: (updatedCard: PokemonCard) => void;
|
||||||
isDeleting: boolean;
|
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 (
|
return (
|
||||||
<Card className="flex flex-col overflow-hidden shadow-lg hover:shadow-xl transition-shadow duration-300">
|
<Card className="flex flex-col overflow-hidden shadow-lg hover:shadow-xl transition-shadow duration-300">
|
||||||
<CardHeader className="p-4">
|
<CardHeader className="p-4">
|
||||||
<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}>
|
<CardTitle className="text-lg font-semibold truncate font-headline" title={card.name}>
|
||||||
{card.name}
|
{card.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{card.isGenerated && (
|
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||||
<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">
|
{card.isGenerated && (
|
||||||
<Sparkles className="h-3 w-3" />
|
<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">
|
||||||
AI Generated
|
<Sparkles className="h-3 w-3" />
|
||||||
</Badge>
|
AI Generated
|
||||||
)}
|
</Badge>
|
||||||
|
)}
|
||||||
|
{getVideoStatusBadge()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-xs text-muted-foreground">
|
<CardDescription className="text-sm text-muted-foreground">
|
||||||
Set: {card.set} | Rarity: {card.rarity}
|
Set: {card.set} | Rarity: {card.rarity}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 flex-grow flex justify-center items-center bg-muted/30">
|
<CardContent className="p-4 flex-grow flex justify-center items-center bg-muted/30">
|
||||||
{card.imageDataUrl ? (
|
{card.imageUrl ? (
|
||||||
<Image
|
<div className="relative w-full max-w-[220px] aspect-[63/88]">
|
||||||
src={card.imageDataUrl}
|
{showVideo && card.videoUrl ? (
|
||||||
alt={card.name}
|
<div className="relative w-full h-full">
|
||||||
width={200}
|
{card.videoUrl.includes('firebasestorage.googleapis.com') ||
|
||||||
height={280}
|
card.videoUrl.includes('firebaseapp.com') ||
|
||||||
className="object-contain rounded-md aspect-[63/88] max-w-[150px]"
|
card.videoUrl.includes('googleapis.com/storage') ||
|
||||||
data-ai-hint="pokemon card"
|
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]">
|
<div className="w-full max-w-[220px] aspect-[63/88] bg-gray-200 flex items-center justify-center rounded-lg text-muted-foreground">
|
||||||
No Image
|
<span className="text-sm">No Image</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="p-4 bg-card border-t flex justify-end space-x-2">
|
<CardFooter className="p-3 bg-card border-t">
|
||||||
<Button variant="outline" size="sm" asChild>
|
<div className="flex flex-col gap-2 w-full">
|
||||||
<Link href={`/dashboard/collection/${card.id}/edit`}>
|
{/* Video Generation Status Indicator */}
|
||||||
<Pencil className="h-3 w-3 mr-1" /> Edit
|
{card.videoGenerationStatus === 'generating' && (
|
||||||
</Link>
|
<div className="w-full px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
</Button>
|
<div className="flex items-center gap-2">
|
||||||
<AlertDialog>
|
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||||
<AlertDialogTrigger asChild>
|
<span className="text-sm text-blue-700 font-medium">
|
||||||
<Button variant="destructive" size="sm" disabled={isDeleting}>
|
{getVideoGenerationStatusMessage()}
|
||||||
<Trash2 className="h-3 w-3 mr-1" /> Delete
|
</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>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -87,6 +418,7 @@ export default function CardItem({ card, onDelete, isDeleting }: CardItemProps)
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ export default function CardScanner() {
|
|||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await scanPokemonCard({ photoDataUri: imageDataUrl });
|
const result = await scanPokemonCard({
|
||||||
|
photoDataUri: imageDataUrl,
|
||||||
|
userId: user?.uid
|
||||||
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
toast({ title: 'Scan Failed', description: result.error, variant: 'destructive' });
|
toast({ title: 'Scan Failed', description: result.error, variant: 'destructive' });
|
||||||
@@ -125,79 +128,133 @@ export default function CardScanner() {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-2xl mx-auto">
|
<div className="space-y-6 max-w-4xl mx-auto">
|
||||||
<Card className="shadow-xl">
|
<Card className="border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 transition-colors">
|
||||||
<CardHeader>
|
<CardHeader className="text-center pb-4">
|
||||||
<CardTitle className="text-2xl font-headline">Scan Pokémon Card</CardTitle>
|
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
|
||||||
<CardDescription>Upload an image of your Pokémon card to identify its details.</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<Label htmlFor="card-image" className="text-base">Upload Card Image</Label>
|
<div className="relative">
|
||||||
<Input
|
<input
|
||||||
id="card-image"
|
id="card-image"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={handleImageChange}
|
onChange={handleImageChange}
|
||||||
ref={fileInputRef}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{imagePreview && (
|
{imagePreview && (
|
||||||
<div className="mt-4 p-4 border border-dashed border-border rounded-lg bg-muted/50">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium mb-2 text-center">Image Preview</h3>
|
<div className="bg-gradient-to-br from-muted/50 to-muted/30 rounded-lg p-6 text-center">
|
||||||
<Image
|
<h3 className="text-lg font-semibold mb-4 text-foreground">Preview</h3>
|
||||||
src={imagePreview}
|
<div className="inline-block p-3 bg-background rounded-lg shadow-md">
|
||||||
alt="Card preview"
|
<Image
|
||||||
width={300}
|
src={imagePreview}
|
||||||
height={420}
|
alt="Card preview"
|
||||||
className="rounded-md object-contain mx-auto shadow-md aspect-[63/88] max-w-xs"
|
width={300}
|
||||||
data-ai-hint="pokemon card"
|
height={420}
|
||||||
/>
|
className="rounded-md object-contain mx-auto aspect-[63/88] max-w-[250px] shadow-sm"
|
||||||
</div>
|
data-ai-hint="pokemon card"
|
||||||
)}
|
/>
|
||||||
|
</div>
|
||||||
{imageDataUrl && (
|
</div>
|
||||||
<Button onClick={handleScan} disabled={isScanning || !imageDataUrl} className="w-full mt-4 text-lg py-6">
|
|
||||||
{isScanning ? (
|
{imageDataUrl && (
|
||||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
<Button
|
||||||
) : (
|
onClick={handleScan}
|
||||||
<Upload className="mr-2 h-6 w-6" />
|
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'}
|
</div>
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{error && !showForm && (
|
{error && !showForm && (
|
||||||
<Card className="border-destructive bg-destructive/10 shadow-lg">
|
<Card className="border-destructive/50 bg-destructive/5">
|
||||||
<CardHeader className="flex flex-row items-center space-x-3">
|
<CardContent className="pt-6">
|
||||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
<div className="flex items-start space-x-4">
|
||||||
<CardTitle className="text-destructive">Scan Failed</CardTitle>
|
<div className="flex-shrink-0">
|
||||||
</CardHeader>
|
<div className="w-10 h-10 bg-destructive/10 rounded-full flex items-center justify-center">
|
||||||
<CardContent>
|
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||||
<p className="text-destructive-foreground">{error}</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showForm && scanResult?.cardDetails && imageDataUrl && (
|
{showForm && scanResult?.cardDetails && imageDataUrl && (
|
||||||
<CardForm
|
<div className="space-y-4">
|
||||||
initialData={{
|
<div className="flex items-center justify-center space-x-3 py-4">
|
||||||
name: scanResult.cardDetails.name || '',
|
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/20 rounded-full flex items-center justify-center">
|
||||||
set: scanResult.cardDetails.set || '',
|
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
rarity: scanResult.cardDetails.rarity || '',
|
</div>
|
||||||
}}
|
<div className="text-center">
|
||||||
imageDataUrlFromScan={imageDataUrl}
|
<h3 className="text-lg font-semibold text-green-800 dark:text-green-200">
|
||||||
onSubmit={handleSaveCard}
|
Card Successfully Scanned!
|
||||||
isSubmitting={isSubmitting}
|
</h3>
|
||||||
submitButtonText="Add to Collection"
|
<p className="text-sm text-green-600 dark:text-green-300">
|
||||||
formTitle="Confirm Card Details"
|
Review the details below and add to your collection
|
||||||
formDescription="Review the scanned details and save the card to your collection."
|
</p>
|
||||||
onCancel={handleCancelForm}
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { LogIn, LogOut, UserCircle, ScanLine, BookOpen, Sparkles } from 'lucide-react';
|
import { LogIn, LogOut, UserCircle, ScanLine, BookOpen, Sparkles, Camera, Menu, Settings } from 'lucide-react';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { signOut } from 'firebase/auth';
|
import { signOut } from 'firebase/auth';
|
||||||
@@ -17,6 +17,13 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -43,6 +50,31 @@ export default function Navbar() {
|
|||||||
return names[0].substring(0, 2);
|
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 (
|
return (
|
||||||
<nav className="bg-card border-b border-border shadow-sm">
|
<nav className="bg-card border-b border-border shadow-sm">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
@@ -54,25 +86,59 @@ export default function Navbar() {
|
|||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{!loading && user && (
|
{!loading && user && (
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" asChild>
|
{/* Desktop Navigation - Hidden on mobile and tablet (lg:flex) */}
|
||||||
<Link href="/dashboard/scan">
|
<NavigationItems className="hidden lg:flex" />
|
||||||
<ScanLine className="mr-2 h-4 w-4" /> Scan Card
|
|
||||||
</Link>
|
{/* Mobile/Tablet Navigation - Hidden on desktop (lg:hidden) */}
|
||||||
</Button>
|
<div className="lg:hidden">
|
||||||
<Button variant="ghost" asChild>
|
<Sheet>
|
||||||
<Link href="/dashboard/generate">
|
<SheetTrigger asChild>
|
||||||
<Sparkles className="mr-2 h-4 w-4" /> Generate Card
|
<Button variant="ghost" size="icon">
|
||||||
</Link>
|
<Menu className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" asChild>
|
</SheetTrigger>
|
||||||
<Link href="/dashboard/collection">
|
<SheetContent side="right">
|
||||||
<BookOpen className="mr-2 h-4 w-4" /> My Collection
|
<SheetHeader>
|
||||||
</Link>
|
<SheetTitle>Navigation</SheetTitle>
|
||||||
</Button>
|
</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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||||
@@ -94,19 +160,28 @@ export default function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<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">
|
<DropdownMenuItem onClick={handleSignOut} className="cursor-pointer">
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>Log out</span>
|
<span>Log out</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : (
|
</div>
|
||||||
<Button asChild>
|
)}
|
||||||
<Link href="/login">
|
|
||||||
<LogIn className="mr-2 h-4 w-4" /> Login
|
{/* Login Button */}
|
||||||
</Link>
|
{!loading && !user && (
|
||||||
</Button>
|
<Button asChild>
|
||||||
)
|
<Link href="/login">
|
||||||
|
<LogIn className="mr-2 h-4 w-4" /> Login
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { createContext, useState, useEffect, type ReactNode } from 'react
|
|||||||
import { onAuthStateChanged, type User } from 'firebase/auth';
|
import { onAuthStateChanged, type User } from 'firebase/auth';
|
||||||
import { auth } from '@/lib/firebase';
|
import { auth } from '@/lib/firebase';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { createOrUpdateUserProfile } from '@/lib/firestore';
|
||||||
|
|
||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -17,8 +18,23 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
|
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
|
||||||
setUser(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);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
|
|||||||
@@ -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
@@ -2,6 +2,7 @@
|
|||||||
import { initializeApp, getApps, getApp, type FirebaseApp } from 'firebase/app';
|
import { initializeApp, getApps, getApp, type FirebaseApp } from 'firebase/app';
|
||||||
import { getAuth, type Auth, connectAuthEmulator } from 'firebase/auth';
|
import { getAuth, type Auth, connectAuthEmulator } from 'firebase/auth';
|
||||||
import { getFirestore, type Firestore, connectFirestoreEmulator } from 'firebase/firestore';
|
import { getFirestore, type Firestore, connectFirestoreEmulator } from 'firebase/firestore';
|
||||||
|
import { getStorage, type FirebaseStorage, connectStorageEmulator } from 'firebase/storage';
|
||||||
import firebaseConfig from '@/config/firebase';
|
import firebaseConfig from '@/config/firebase';
|
||||||
|
|
||||||
// Define which keys from firebaseConfig are considered essential for initialization.
|
// Define which keys from firebaseConfig are considered essential for initialization.
|
||||||
@@ -47,6 +48,7 @@ if (missingEnvVars.length > 0) {
|
|||||||
let app: FirebaseApp;
|
let app: FirebaseApp;
|
||||||
let auth: Auth;
|
let auth: Auth;
|
||||||
let db: Firestore;
|
let db: Firestore;
|
||||||
|
let storage: FirebaseStorage;
|
||||||
|
|
||||||
// Track if emulators have been connected to avoid multiple connections
|
// Track if emulators have been connected to avoid multiple connections
|
||||||
let emulatorsConnected = false;
|
let emulatorsConnected = false;
|
||||||
@@ -59,12 +61,14 @@ try {
|
|||||||
}
|
}
|
||||||
auth = getAuth(app);
|
auth = getAuth(app);
|
||||||
db = getFirestore(app);
|
db = getFirestore(app);
|
||||||
|
storage = getStorage(app);
|
||||||
|
|
||||||
// Connect to emulators in development - do this immediately after getting auth/db instances
|
// Connect to emulators in development - do this immediately after getting auth/db instances
|
||||||
if (process.env.NODE_ENV === 'development' && !emulatorsConnected && typeof window !== 'undefined') {
|
if (process.env.NODE_ENV === 'development' && !emulatorsConnected && typeof window !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true });
|
connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true });
|
||||||
connectFirestoreEmulator(db, 'localhost', 8080);
|
connectFirestoreEmulator(db, 'localhost', 8080);
|
||||||
|
connectStorageEmulator(storage, 'localhost', 9199);
|
||||||
emulatorsConnected = true;
|
emulatorsConnected = true;
|
||||||
console.log('Firebase emulators connected successfully');
|
console.log('Firebase emulators connected successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -81,4 +85,4 @@ try {
|
|||||||
throw error; // Re-throw the original Firebase error
|
throw error; // Re-throw the original Firebase error
|
||||||
}
|
}
|
||||||
|
|
||||||
export { app, auth, db };
|
export { app, auth, db, storage };
|
||||||
|
|||||||
+277
-23
@@ -12,9 +12,13 @@ import {
|
|||||||
Timestamp,
|
Timestamp,
|
||||||
getDoc,
|
getDoc,
|
||||||
orderBy,
|
orderBy,
|
||||||
|
setDoc,
|
||||||
} from 'firebase/firestore';
|
} from 'firebase/firestore';
|
||||||
import { db, auth } from './firebase'; // Main Firebase config
|
import { db, auth, storage } from './firebase'; // Main Firebase config
|
||||||
import type { PokemonCard, ScannedCardData } from '@/types';
|
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
|
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');
|
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 (
|
export const addCardToCollection = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
cardData: Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'updatedAt'>
|
cardData: Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'updatedAt' | 'imageUrl'> & { imageDataUrl: string }
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
console.error("addCardToCollection: User ID is missing.");
|
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);
|
console.log("DEBUG: Auth user matches:", auth.currentUser?.uid === userId);
|
||||||
|
|
||||||
try {
|
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 collectionRef = getPokemonCardsCollectionRef(userId);
|
||||||
|
const { imageDataUrl, ...cardDataWithoutImage } = cardData;
|
||||||
const docPayload = {
|
const docPayload = {
|
||||||
...cardData,
|
...cardDataWithoutImage,
|
||||||
|
imageUrl, // Store the Firebase Storage URL instead of base64 data
|
||||||
userId,
|
userId,
|
||||||
|
// Don't initialize video generation automatically - make it opt-in
|
||||||
createdAt: serverTimestamp(),
|
createdAt: serverTimestamp(),
|
||||||
updatedAt: 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(
|
console.log(
|
||||||
"Attempting to add card to Firestore. Path:",
|
"Attempting to add card to Firestore. Path:",
|
||||||
collectionRef.path,
|
collectionRef.path,
|
||||||
"Payload keys:",
|
"Payload keys:",
|
||||||
payloadKeys.join(', '),
|
Object.keys(docPayload).join(', '),
|
||||||
"imageDataUrl length:",
|
"Image URL:",
|
||||||
imageDataUrlLength
|
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);
|
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;
|
return docRef.id;
|
||||||
} catch (error) {
|
} 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.';
|
let detailedMessage = 'Failed to add card to collection.';
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
detailedMessage = error.message; // Start with the basic error message
|
detailedMessage = 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.
|
|
||||||
const firebaseError = error as any;
|
const firebaseError = error as any;
|
||||||
if (firebaseError.code) {
|
if (firebaseError.code) {
|
||||||
detailedMessage = `Error: ${firebaseError.code} - ${error.message}`;
|
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 (
|
export const updateCardInCollection = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
cardId: string,
|
cardId: string,
|
||||||
cardData: Partial<Omit<PokemonCard, 'id' | 'userId' | 'createdAt'>>
|
cardData: Partial<Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'imageUrl'>> & { imageDataUrl?: string }
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!userId || !cardId) throw new Error('User ID and Card ID are required to update a card.');
|
if (!userId || !cardId) throw new Error('User ID and Card ID are required to update a card.');
|
||||||
try {
|
try {
|
||||||
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
|
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, {
|
await updateDoc(cardDocRef, {
|
||||||
...cardData,
|
...updateData,
|
||||||
updatedAt: serverTimestamp(),
|
updatedAt: serverTimestamp(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.');
|
if (!userId || !cardId) throw new Error('User ID and Card ID are required to delete a card.');
|
||||||
try {
|
try {
|
||||||
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
|
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);
|
await deleteDoc(cardDocRef);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting card: ', error);
|
console.error('Error deleting card: ', error);
|
||||||
throw new Error('Failed to delete card.');
|
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
@@ -6,11 +6,17 @@ export interface PokemonCard {
|
|||||||
name: string;
|
name: string;
|
||||||
set: string;
|
set: string;
|
||||||
rarity: 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;
|
createdAt: Timestamp;
|
||||||
updatedAt: Timestamp;
|
updatedAt: Timestamp;
|
||||||
isGenerated?: boolean; // Flag to indicate if this is an AI-generated card
|
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)
|
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
|
// For AI Scan result, before saving to Firestore
|
||||||
@@ -42,5 +48,37 @@ export interface CardGenerationParams {
|
|||||||
export interface GeneratedCard {
|
export interface GeneratedCard {
|
||||||
imageBase64: string;
|
imageBase64: string;
|
||||||
prompt: 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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)}"e=${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;
|
||||||
|
};
|
||||||
@@ -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`);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user