feat: multiple TCG

This commit is contained in:
xavidop
2025-11-21 09:59:33 +01:00
parent 2209d61879
commit abd98f8009
34 changed files with 1763 additions and 3303 deletions
+29 -11
View File
@@ -1,11 +1,12 @@
# Cardex - Pokémon Card Collection Manager
# Cardex - Multi-TCG Card Collection Manager
A modern web application for managing your Pokémon card collection with AI-powered card scanning and identification.
A modern web application for managing your Trading Card Game (TCG) collection with AI-powered card generation and identification. Now supporting **Pokémon, One Piece, Disney Lorcana, Magic: The Gathering, and Dragon Ball Super**!
## Features
- 🎮 **Multi-Game Support**: Generate and collect cards from 5 popular TCG games
- 🤖 **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 cards for any supported TCG 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
@@ -14,6 +15,18 @@ A modern web application for managing your Pokémon card collection with AI-powe
- ✏️ **CRUD Operations**: Create, read, update, and delete cards in your collection
- 🎯 **Type Safety**: Full TypeScript implementation for better development experience
## Supported Trading Card Games
| Game | Types/Colors | Unique Stats |
|------|-------------|--------------|
| 🎮 **Pokémon TCG** | 18 types (Fire, Water, etc.) | HP, Attacks, Weakness, Resistance |
| 🏴‍☠️ **One Piece Card Game** | 6 colors (Red, Blue, etc.) | Power, Cost, Counter, Life Points |
| ✨ **Disney Lorcana** | 6 ink colors (Amber, Ruby, etc.) | Ink Cost, Strength, Willpower, Lore |
| 🔮 **Magic: The Gathering** | 5 colors + Colorless | Mana Cost, Power/Toughness, Card Type |
| 🐉 **Dragon Ball Super** | 4 colors (Red, Blue, etc.) | Combat Power, Combo Cost, Era |
For detailed information about each game's attributes, see [TCG Attributes Reference](docs/TCG_ATTRIBUTES_REFERENCE.md).
## Technology Stack
```mermaid
@@ -135,14 +148,17 @@ npm run genkit:dev
### Card Generation
1. Navigate to the generate page
2. Fill out the card parameters:
- Pokemon name and type
- Card properties (Full Art, Holographic)
- Descriptions for the Pokemon and background
- Language and optional stats
3. Click "Generate Pokemon Card" to create a custom card using AI
4. Preview the generated card
5. Save it to your collection if you like it
2. **Select your TCG game** (Pokémon, One Piece, Lorcana, Magic, or Dragon Ball)
3. Fill out the card parameters:
- Character name and type/color
- Card properties (Special/Full Art, Holographic)
- Descriptions for the character and background
- Language and game-specific stats
4. Click "Generate Card" to create a custom card using AI
5. Preview the generated card
6. Save it to your collection if you like it
**Note:** Each game has unique stat fields that appear dynamically based on your selection!
### Photo-to-Card Generation
1. Navigate to the "Photo Card" page
@@ -182,10 +198,12 @@ Firestore Collection: users/{userId}/pokemon_cards/{cardId}
├── name: string
├── set: string
├── rarity: string
├── game: TCGGame ('pokemon' | 'onepiece' | 'lorcana' | 'magic' | 'dragonball')
├── 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)
├── generationParams?: CardGenerationParams (game-specific stats and settings)
├── userId: string
├── createdAt: timestamp
└── updatedAt: timestamp
+3 -371
View File
@@ -19,7 +19,6 @@
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
@@ -36,7 +35,6 @@
"@tanstack/react-query": "^5.66.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"dotenv": "^16.5.0",
"firebase": "^11.10.0",
"genkit": "^1.24.0",
@@ -45,10 +43,8 @@
"openai": "^5.10.1",
"patch-package": "^8.0.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.1",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2"
@@ -88,15 +84,6 @@
"zod": "^3.20.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.27.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.4.tgz",
"integrity": "sha512-t3yaEOuGu9NlIZ+hIeGbBjFtZT7j2cb2tg0fuaJKeGotchRjjLfrBA9Kwf8quhpP1EUuxModQg04q/mBwyg8uA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@colors/colors": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
@@ -4766,37 +4753,6 @@
}
}
},
"node_modules/@radix-ui/react-menubar": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.6.tgz",
"integrity": "sha512-FHq7+3DlXwh/7FOM4i0G4bC4vPjiq89VEEvNF4VMLchGnaUuUbE5uKXMUCjdKaOghEEMeiKa5XCa2Pk4kteWmg==",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-menu": "2.1.6",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-roving-focus": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.6.tgz",
@@ -5536,60 +5492,6 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz",
"integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
@@ -6863,117 +6765,8 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
@@ -6983,15 +6776,6 @@
"node": ">= 12"
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -7001,11 +6785,6 @@
"ms": "2.0.0"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/deeks": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/deeks/-/deeks-3.1.0.tgz",
@@ -7109,15 +6888,6 @@
"node": ">=16"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dot-prop": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz",
@@ -7388,11 +7158,6 @@
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"node_modules/eventid": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/eventid/-/eventid-2.0.1.tgz",
@@ -7581,14 +7346,6 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
"integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -8806,14 +8563,6 @@
"node": ">=8"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -9320,6 +9069,7 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.camelcase": {
@@ -10468,21 +10218,6 @@
"node": ">= 0.6.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/proto3-json-serializer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
@@ -10646,19 +10381,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-day-picker": {
"version": "8.10.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
"integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"date-fns": "^2.28.0 || ^3.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -10686,11 +10408,6 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/react-remove-scroll": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",
@@ -10736,20 +10453,6 @@
}
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -10771,21 +10474,6 @@
}
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -10820,36 +10508,6 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz",
"integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -11947,11 +11605,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@@ -12225,27 +11878,6 @@
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
-4
View File
@@ -26,7 +26,6 @@
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-menubar": "^1.1.6",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.3",
@@ -43,7 +42,6 @@
"@tanstack/react-query": "^5.66.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"dotenv": "^16.5.0",
"firebase": "^11.10.0",
"genkit": "^1.24.0",
@@ -52,10 +50,8 @@
"openai": "^5.10.1",
"patch-package": "^8.0.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.1",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.2"
-2
View File
@@ -2,5 +2,3 @@ import { config } from 'dotenv';
config();
import '@/ai/flows/summarize-card-information.ts';
import '@/ai/flows/scan-pokemon-card.ts';
import '@/ai/flows/generate-pokemon-card.ts';
+1 -1
View File
@@ -80,7 +80,7 @@ export async function generateCardVideo(input: GenerateCardVideoInput): Promise<
// Start video generation operation
console.log('Starting Veo video generation...');
let operation = await ai.models.generateVideos({
model: 'veo-3.0-generate-preview',
model: 'veo-3.1-generate-preview',
prompt: animationPrompt,
image: {
mimeType: mimeType,
@@ -1,139 +0,0 @@
// 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 { 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> {
try {
// Get user's API keys from Firestore
const userApiKeys = await getUserApiKeys(input.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(input);
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: input.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.`;
}
-183
View File
@@ -1,183 +0,0 @@
// This file is automatically generated - edits will be lost!
'use server';
/**
* @fileOverview Uses Google Imagen4 to generate custom Pokemon cards based on user parameters.
*
* - generatePokemonCard - A function that handles the card generation process.
* - GeneratePokemonCardInput - The input type for the generatePokemonCard function.
* - GeneratePokemonCardOutput - The return type for the generatePokemonCard function.
*/
import { z } from 'genkit';
import { GoogleGenAI } from '@google/genai';
import { getUserApiKeys } from '@/lib/firestore';
const GeneratePokemonCardInputSchema = z.object({
userId: z.string().min(1, "User ID is required"),
pokemonName: z.string().min(1, "Pokemon name is required"),
pokemonType: z.string().min(1, "Pokemon type is required"),
isIllustrationRare: z.boolean(),
isHolo: z.boolean(),
backgroundDescription: z.string().min(1, "Background description is required"),
pokemonDescription: z.string().min(1, "Pokemon description is required"),
language: z.enum(['english', 'japanese', 'chinese', 'korean', 'spanish', 'french', 'german', 'italian']),
model: z.enum(['imagen-4.0-ultra-generate-001', 'imagen-4.0-generate-001', 'imagen-4.0-fast-generate-001', 'gemini-2.5-flash-image', 'gemini-3-pro-image-preview']).default('imagen-4.0-ultra-generate-001'),
hp: z.number().optional(),
attackName1: z.string().optional(),
attackDamage1: z.number().optional(),
attackName2: z.string().optional(),
attackDamage2: z.number().optional(),
weakness: z.string().optional(),
resistance: z.string().optional(),
retreatCost: z.number().optional(),
});
export type GeneratePokemonCardInput = z.infer<typeof GeneratePokemonCardInputSchema>;
const GeneratePokemonCardOutputSchema = z.object({
imageBase64: z.string().describe("The generated card image as base64 string").optional(),
prompt: z.string().describe("The prompt used to generate the card").optional(),
error: z.string().describe("Error message if the card could not be generated").optional()
});
export type GeneratePokemonCardOutput = z.infer<typeof GeneratePokemonCardOutputSchema>;
export async function generatePokemonCard(input: GeneratePokemonCardInput): Promise<GeneratePokemonCardOutput> {
try {
// Get user's API keys from Firestore
const userApiKeys = await getUserApiKeys(input.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(input);
console.log("Generated prompt:", prompt);
// Check if using Gemini model (for generateContentStream) or Imagen model (for generateImages)
const isGeminiModel = input.model.startsWith('gemini-');
if (isGeminiModel) {
// Use generateContentStream for Gemini models
const config = {
responseModalities: ['IMAGE', 'TEXT'],
imageConfig: {
aspectRatio: '3:4',
imageSize: '1K',
},
};
const contents = [
{
role: 'user' as const,
parts: [
{
text: prompt,
},
],
},
];
const response = await genAI.models.generateContentStream({
model: input.model,
config,
contents,
});
let imageBase64: string | undefined;
for await (const chunk of response) {
if (!chunk.candidates || !chunk.candidates[0].content || !chunk.candidates[0].content.parts) {
continue;
}
if (chunk.candidates?.[0]?.content?.parts?.[0]?.inlineData) {
const inlineData = chunk.candidates[0].content.parts[0].inlineData;
imageBase64 = inlineData.data || '';
break; // We only need the first image
}
}
if (!imageBase64) {
return { error: 'No image generated from Gemini model.' };
}
return {
imageBase64,
prompt,
};
} else {
// Use generateImages for Imagen models
const response = await genAI.models.generateImages({
model: input.model,
prompt: prompt,
config: {
numberOfImages: 1,
outputMimeType: 'image/jpeg',
aspectRatio: '3:4' // Pokemon cards are roughly 3:4 aspect ratio
},
});
if (!response?.generatedImages || response.generatedImages.length === 0) {
return { error: 'No images generated.' };
}
const generatedImage = response.generatedImages[0];
if (!generatedImage?.image?.imageBytes) {
return { error: 'Generated image data is missing.' };
}
return {
imageBase64: generatedImage.image.imageBytes,
prompt: prompt,
};
}
} catch (error: any) {
console.error('Error generating Pokemon card:', error);
return { error: `Failed to generate Pokemon card: ${error?.message || 'Unknown error'}` };
}
}
function generateCardPrompt(params: GeneratePokemonCardInput): string {
const {
pokemonName,
pokemonType,
isIllustrationRare,
isHolo,
backgroundDescription,
pokemonDescription,
language,
hp = 130,
attackName1 = "Quick Attack",
attackDamage1 = 60,
attackName2 = "Special Move",
attackDamage2 = 90,
weakness = "Fighting",
resistance = "Psychic",
retreatCost = 2
} = params;
const cardType = isIllustrationRare ? "full art (ART)" : "Regular classic and not full art";
const holoEffect = isHolo ? "The card's surface has a \"holo\" effect, creating a shimmering, rainbow-like reflection that highlights the Pokémon and key elements." : "";
const languageInstruction = language !== 'english' ? `Write all card information in ${language}. The Pokemon name, attacks and description should be translated accordingly.` : '';
return `A ${cardType} collectible Pokémon trading card, featuring the Pokémon "${pokemonName}". ${isIllustrationRare ? "The illustration covers the entire card" : "The card has a standard vertical layout with a detailed illustration in the main area."} ${pokemonName}, a ${pokemonType} type, is depicted ${pokemonDescription}. The background of the illustration shows ${backgroundDescription}. ${holoEffect}
The card layout includes: The top left corner displays "${pokemonName}" in a stylized font, with "HP ${hp}" next to it in red. Below the Pokémon's name, the ${pokemonType} type symbol is clearly visible. In the lower section, the card has two attacks listed. The first attack is ${attackName1} and should be written in ${language}, deals ${attackDamage1} damage. The second attack is ${attackName2} and should be written in ${language}, deals ${attackDamage2} damage.
Below the attacks, the Weakness is ${weakness} (x2), Resistance is ${resistance} (-30), and Retreat Cost shows ${retreatCost} energy symbols. The bottom edge of the card features a thin line of text indicating the rarity and copyright information.
${languageInstruction}
The overall style should match official Pokémon TCG card design with proper fonts, layout, and professional quality artwork.
Make sure the information is displayed clearly.
The output should just be the card.`;
}
@@ -0,0 +1,216 @@
// This file is automatically generated - edits will be lost!
'use server';
/**
* @fileOverview Uses DALL-E to generate TCG cards based on an uploaded photo reference.
*
* - generateTCGCardFromPhoto - A function that handles the photo-based card generation process.
* - GenerateFromPhotoInput - The input type for the generateTCGCardFromPhoto function.
* - GenerateFromPhotoOutput - The return type for the generateTCGCardFromPhoto function.
*/
import { z } from 'genkit';
import OpenAI from 'openai';
import { getUserApiKeys } from '@/lib/firestore';
import { getGameConfig } from '@/config/tcg-games';
import type { TCGGame } from '@/types';
const GenerateFromPhotoInputSchema = z.object({
userId: z.string().min(1, "User ID is required"),
game: z.enum(['pokemon', 'onepiece', 'lorcana', 'magic', 'dragonball']),
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>'."
),
characterName: z.string().min(1, "Character name is required"),
characterType: z.string().min(1, "Character 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'),
// Game-specific stats (all optional)
hp: z.number().optional(),
attackName1: z.string().optional(),
attackDamage1: z.number().optional(),
attackName2: z.string().optional(),
attackDamage2: z.number().optional(),
weakness: z.string().optional(),
resistance: z.string().optional(),
retreatCost: z.number().optional(),
power: z.number().optional(),
cost: z.number().optional(),
counter: z.number().optional(),
lifePoints: z.number().optional(),
inkCost: z.number().optional(),
strength: z.number().optional(),
willpower: z.number().optional(),
lore: z.number().optional(),
inkable: z.boolean().optional(),
manaCost: z.string().optional(),
cardType: z.string().optional(),
subType: z.string().optional(),
powerToughness: z.string().optional(),
combatPower: z.number().optional(),
comboCost: z.number().optional(),
comboEnergy: z.number().optional(),
era: z.string().optional(),
});
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 generateTCGCardFromPhoto(input: GenerateFromPhotoInput): Promise<GenerateFromPhotoOutput> {
try {
// Get user's API keys from Firestore
const userApiKeys = await getUserApiKeys(input.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,
});
const gameConfig = getGameConfig(input.game as TCGGame);
console.log(`Generating ${gameConfig.name} card using reference photo...`);
// Generate the enhanced prompt for the card
const enhancedPrompt = generatePhotoBasedCardPrompt(input);
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-5",
input: [
{
role: "user",
content: [
{ type: "input_text", text: enhancedPrompt },
{
type: "input_image",
image_url: input.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 card from photo:', error);
return { error: `Failed to generate card: ${error?.message || 'Unknown error'}` };
}
}
function generatePhotoBasedCardPrompt(params: GenerateFromPhotoInput): string {
const gameConfig = getGameConfig(params.game as TCGGame);
const gameName = gameConfig.name;
const {
game,
characterName,
characterType,
styleDescription,
language,
} = params;
const languageInstruction = language !== 'english' ? `Write all card information in ${language}.` : '';
let gameSpecificPrompt = '';
switch (game) {
case 'pokemon':
gameSpecificPrompt = generatePokemonPhotoPrompt(params);
break;
case 'onepiece':
gameSpecificPrompt = generateOnePiecePhotoPrompt(params);
break;
case 'lorcana':
gameSpecificPrompt = generateLorcanaPhotoPrompt(params);
break;
case 'magic':
gameSpecificPrompt = generateMagicPhotoPrompt(params);
break;
case 'dragonball':
gameSpecificPrompt = generateDragonBallPhotoPrompt(params);
break;
}
return `Create a professional ${gameName} trading card featuring "${characterName}".
IMPORTANT: Use the reference photo as inspiration and adapt it to create a trading card illustration. Use the base photo, but in Studio Ghibli style, ${styleDescription}
The card should be a ${characterType} type and follow the authentic ${gameName} card design with proper layout, fonts, and formatting.
${gameSpecificPrompt}
${languageInstruction}
The final output must look like an authentic ${gameName} trading card with all text clearly legible and properly formatted. Make sure the information is displayed clearly and the character illustration feels natural within the adapted photographic style.`;
}
function generatePokemonPhotoPrompt(params: GenerateFromPhotoInput): string {
const { characterName, characterType, hp = 130, attackName1 = "Quick Attack", attackDamage1 = 60, attackName2 = "Special Move", attackDamage2 = 90, weakness = "Fighting", resistance = "Psychic", retreatCost = 2, language = 'english' } = params;
const languageNote = language !== 'english' ? `All attack names and descriptions should be written in ${language}.` : '';
return `A regular classic Pokémon trading card (not full art), featuring the Pokémon "${characterName}". The card has a standard vertical layout with a detailed illustration in the main area. The Pokémon "${characterName}", a ${characterType} type, should be depicted as the main subject.
The card layout includes: The top left corner displays "${characterName}" in a stylized font, with "HP ${hp}" next to it in red. Below the Pokémon's name, the ${characterType} type symbol is clearly visible. In the lower section, the card has two attacks listed. The first attack is ${attackName1}, deals ${attackDamage1} damage. The second attack is ${attackName2}, 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.
${languageNote}
The overall style should match official Pokémon TCG card design with proper fonts, layout, and professional quality artwork.`;
}
function generateOnePiecePhotoPrompt(params: GenerateFromPhotoInput): string {
const { characterName, characterType, power = 5000, cost = 4, counter = 1000 } = params;
return `Card layout: One Piece Card Game style with "${characterName}" prominently displayed. ${characterType} color indicator. Cost: ${cost}, Power: ${power}, Counter: ${counter}. Include attribute icons and ability text box.`;
}
function generateLorcanaPhotoPrompt(params: GenerateFromPhotoInput): string {
const { characterName, characterType, inkCost = 3, strength = 2, willpower = 3, lore = 2, inkable = true } = params;
return `Card layout: Disney Lorcana style with "${characterName}" as title. ${characterType} ink color. Ink cost: ${inkCost}, Strength: ${strength}, Willpower: ${willpower}, Lore: ${lore}. ${inkable ? 'Include inkwell symbol.' : 'No inkwell symbol.'}`;
}
function generateMagicPhotoPrompt(params: GenerateFromPhotoInput): string {
const { characterName, manaCost = '{2}{G}', cardType = 'Creature', subType = 'Warrior', powerToughness = '3/3' } = params;
return `Card layout: Magic: The Gathering style. Card name: "${characterName}", Mana cost: ${manaCost} in top right. Type line: "${cardType}${subType}". ${cardType.toLowerCase().includes('creature') ? `Power/Toughness: ${powerToughness}` : ''}`;
}
function generateDragonBallPhotoPrompt(params: GenerateFromPhotoInput): string {
const { characterName, characterType, combatPower = 20000, comboCost = 1, comboEnergy = 5000, era = 'Universe Survival Saga' } = params;
return `Card layout: Dragon Ball Super Card Game style. Character: "${characterName}", ${characterType} color. Combat Power: ${combatPower}, Combo Cost: ${comboCost}, Combo Energy: ${comboEnergy}, Era: ${era}.`;
}
+319
View File
@@ -0,0 +1,319 @@
// This file is automatically generated - edits will be lost!
'use server';
/**
* @fileOverview Uses Google Imagen4 to generate custom TCG cards based on user parameters.
*
* - generateTCGCard - A function that handles the card generation process.
* - GenerateTCGCardInput - The input type for the generateTCGCard function.
* - GenerateTCGCardOutput - The return type for the generateTCGCard function.
*/
import { z } from 'genkit';
import { GoogleGenAI } from '@google/genai';
import { getUserApiKeys } from '@/lib/firestore';
import type { TCGGame } from '@/types';
import { getGameConfig } from '@/config/tcg-games';
const GenerateTCGCardInputSchema = z.object({
userId: z.string().min(1, "User ID is required"),
game: z.enum(['pokemon', 'onepiece', 'lorcana', 'magic', 'dragonball']),
characterName: z.string().min(1, "Character name is required"),
characterType: z.string().min(1, "Character type is required"),
isSpecialArt: z.boolean(),
isHolo: z.boolean(),
backgroundDescription: z.string().min(1, "Background description is required"),
characterDescription: z.string().min(1, "Character description is required"),
language: z.enum(['english', 'japanese', 'chinese', 'korean', 'spanish', 'french', 'german', 'italian']),
model: z.enum(['imagen-4.0-ultra-generate-001', 'imagen-4.0-generate-001', 'imagen-4.0-fast-generate-001', 'gemini-2.5-flash-image', 'gemini-3-pro-image-preview']).default('imagen-4.0-ultra-generate-001'),
// Pokemon-specific
hp: z.number().optional(),
attackName1: z.string().optional(),
attackDamage1: z.number().optional(),
attackName2: z.string().optional(),
attackDamage2: z.number().optional(),
weakness: z.string().optional(),
resistance: z.string().optional(),
retreatCost: z.number().optional(),
// One Piece-specific
power: z.number().optional(),
cost: z.number().optional(),
counter: z.number().optional(),
color: z.string().optional(),
lifePoints: z.number().optional(),
// Lorcana-specific
inkCost: z.number().optional(),
strength: z.number().optional(),
willpower: z.number().optional(),
lore: z.number().optional(),
inkable: z.boolean().optional(),
// Magic-specific
manaCost: z.string().optional(),
cardType: z.string().optional(),
subType: z.string().optional(),
powerToughness: z.string().optional(),
// Dragon Ball-specific
combatPower: z.number().optional(),
comboCost: z.number().optional(),
comboEnergy: z.number().optional(),
era: z.string().optional(),
});
export type GenerateTCGCardInput = z.infer<typeof GenerateTCGCardInputSchema>;
const GenerateTCGCardOutputSchema = 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 GenerateTCGCardOutput = z.infer<typeof GenerateTCGCardOutputSchema>;
export async function generateTCGCard(input: GenerateTCGCardInput): Promise<GenerateTCGCardOutput> {
try {
// Get user's API keys from Firestore
const userApiKeys = await getUserApiKeys(input.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 card
const prompt = generateCardPrompt(input);
console.log("Generated prompt:", prompt);
// Check if using Gemini model (for generateContentStream) or Imagen model (for generateImages)
const isGeminiModel = input.model.startsWith('gemini-');
if (isGeminiModel) {
// Use generateContentStream for Gemini models
const config = {
responseModalities: ['IMAGE', 'TEXT'],
imageConfig: {
aspectRatio: '3:4',
imageSize: '1K',
},
};
const contents = [
{
role: 'user' as const,
parts: [
{
text: prompt,
},
],
},
];
const response = await genAI.models.generateContentStream({
model: input.model,
config,
contents,
});
let imageBase64: string | undefined;
for await (const chunk of response) {
if (!chunk.candidates || !chunk.candidates[0].content || !chunk.candidates[0].content.parts) {
continue;
}
if (chunk.candidates?.[0]?.content?.parts?.[0]?.inlineData) {
const inlineData = chunk.candidates[0].content.parts[0].inlineData;
imageBase64 = inlineData.data || '';
break; // We only need the first image
}
}
if (!imageBase64) {
return { error: 'No image generated from Gemini model.' };
}
return {
imageBase64,
prompt,
};
} else {
// Use generateImages for Imagen models
const response = await genAI.models.generateImages({
model: input.model,
prompt: prompt,
config: {
numberOfImages: 1,
outputMimeType: 'image/jpeg',
aspectRatio: '3:4' // TCG cards are roughly 3:4 aspect ratio
},
});
if (!response?.generatedImages || response.generatedImages.length === 0) {
return { error: 'No images generated.' };
}
const generatedImage = response.generatedImages[0];
if (!generatedImage?.image?.imageBytes) {
return { error: 'Generated image data is missing.' };
}
return {
imageBase64: generatedImage.image.imageBytes,
prompt: prompt,
};
}
} catch (error: any) {
console.error('Error generating TCG card:', error);
return { error: `Failed to generate card: ${error?.message || 'Unknown error'}` };
}
}
function generateCardPrompt(params: GenerateTCGCardInput): string {
const gameConfig = getGameConfig(params.game as TCGGame);
const gameName = gameConfig.name;
const {
game,
characterName,
characterType,
isSpecialArt,
isHolo,
backgroundDescription,
characterDescription,
language,
} = params;
const cardType = isSpecialArt ? "full art special variant" : "standard layout";
const holoEffect = isHolo ? `The card's surface has a "holo" effect, creating a shimmering, rainbow-like reflection that highlights the character and key elements.` : "";
const languageInstruction = language !== 'english' ? `Write all card information in ${language}. The character name, abilities, and text should be translated accordingly.` : '';
let gameSpecificPrompt = '';
switch (game) {
case 'pokemon':
gameSpecificPrompt = generatePokemonPrompt(params);
break;
case 'onepiece':
gameSpecificPrompt = generateOnePiecePrompt(params);
break;
case 'lorcana':
gameSpecificPrompt = generateLorcanaPrompt(params);
break;
case 'magic':
gameSpecificPrompt = generateMagicPrompt(params);
break;
case 'dragonball':
gameSpecificPrompt = generateDragonBallPrompt(params);
break;
}
return `A ${cardType} collectible ${gameName} trading card, featuring "${characterName}". ${isSpecialArt ? "The illustration covers the entire card in a stunning full-art layout" : "The card has a standard vertical layout with a detailed illustration in the main area."} ${characterName}, a ${characterType} type, is depicted ${characterDescription}. The background of the illustration shows ${backgroundDescription}. ${holoEffect}
${gameSpecificPrompt}
${languageInstruction}
The overall style should match official ${gameName} card design with proper fonts, layout, and professional quality artwork.
Make sure all information is displayed clearly and legibly.
The output should just be the card itself.`;
}
function generatePokemonPrompt(params: GenerateTCGCardInput): string {
const {
characterName,
characterType,
hp = 130,
attackName1 = "Quick Attack",
attackDamage1 = 60,
attackName2 = "Special Move",
attackDamage2 = 90,
weakness = "Fighting",
resistance = "Psychic",
retreatCost = 2,
} = params;
return `The card layout includes: The top left corner displays "${characterName}" in a stylized font, with "HP ${hp}" next to it in red. Below the Pokémon's name, the ${characterType} type symbol is clearly visible. In the lower section, the card has two attacks listed. The first attack is ${attackName1}, deals ${attackDamage1} damage. The second attack is ${attackName2}, 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.`;
}
function generateOnePiecePrompt(params: GenerateTCGCardInput): string {
const {
characterName,
characterType,
power = 5000,
cost = 4,
counter = 1000,
lifePoints,
} = params;
const leaderInfo = lifePoints ? `As a Leader card, it displays ${lifePoints} life points in the top corner.` : '';
return `The card layout follows One Piece Card Game design: The character name "${characterName}" appears prominently at the top. The card shows a ${characterType} color indicator. The cost of ${cost} is displayed in the top left corner. The power value of ${power} is shown prominently. The counter value of ${counter} appears at the bottom. ${leaderInfo}
The card includes attribute icons and a text box for abilities. The bottom features the One Piece Card Game logo and card number.`;
}
function generateLorcanaPrompt(params: GenerateTCGCardInput): string {
const {
characterName,
characterType,
inkCost = 3,
strength = 2,
willpower = 3,
lore = 2,
inkable = true,
} = params;
const inkableIndicator = inkable ? 'The card has an inkwell symbol in the bottom left, indicating it is inkable.' : 'No inkwell symbol appears, as this card is not inkable.';
return `The card layout follows Disney Lorcana design: "${characterName}" appears as the character name at the top. The ${characterType} ink color is indicated by the card's color scheme and symbol. The ink cost of ${inkCost} is displayed in a prominent circle in the top left corner.
The card shows strength of ${strength} (shield icon), willpower of ${willpower} (hexagon icon), and lore value of ${lore} (diamond icon) clearly displayed. ${inkableIndicator}
The card includes a text box for character abilities and flavor text in the Disney Lorcana style. The bottom features the Lorcana logo, card number, and rarity symbol.`;
}
function generateMagicPrompt(params: GenerateTCGCardInput): string {
const {
characterName,
characterType,
manaCost = '{2}{G}',
cardType = 'Creature',
subType = 'Elf Warrior',
powerToughness = '3/3',
} = params;
return `The card layout follows Magic: The Gathering design: "${characterName}" appears as the card name at the top. The mana cost ${manaCost} is displayed in the top right corner using standard Magic mana symbols. The ${characterType} color frame is used.
The type line reads "${cardType}${subType}". The main illustration takes up the center portion of the card. Below the illustration is a text box for abilities and flavor text.
${cardType.toLowerCase().includes('creature') ? `The bottom right corner displays the power/toughness of ${powerToughness}.` : ''}
The bottom features the expansion symbol, card number, artist credit, and copyright information in standard Magic: The Gathering layout.`;
}
function generateDragonBallPrompt(params: GenerateTCGCardInput): string {
const {
characterName,
characterType,
combatPower = 20000,
comboCost = 1,
comboEnergy = 5000,
era = 'Universe Survival Saga',
} = params;
return `The card layout follows Dragon Ball Super Card Game design: The character name "${characterName}" appears at the top in the distinctive Dragon Ball font. The card uses a ${characterType} color border and energy symbols.
The combat power of ${combatPower} is prominently displayed in the top right corner. The combo cost shows ${comboCost} and combo energy displays ${comboEnergy}. The era "${era}" is indicated on the card.
The card includes energy cost symbols, special traits, and a text box for card effects. The character illustration is dynamic and action-packed. The bottom features the Dragon Ball Super Card Game logo, card number, and rarity indicator.`;
}
-96
View File
@@ -1,96 +0,0 @@
// This file is automatically generated - edits will be lost!
'use server';
/**
* @fileOverview Uses Gemini Vision to scan and identify a Pokemon card's name, set, and rarity.
*
* - scanPokemonCard - A function that handles the card scanning process.
* - ScanPokemonCardInput - The input type for the scanPokemonCard function.
* - ScanPokemonCardOutput - The return type for the scanPokemonCard function.
*/
import {ai, createUserAI} from '@/ai/genkit';
import {z} from 'genkit';
const ScanPokemonCardInputSchema = z.object({
photoDataUri: z
.string()
.describe(
"A photo of a Pokemon card, as a data URI that must include a MIME type and use Base64 encoding. Expected format: 'data:<mimetype>;base64,<encoded_data>'."
),
userId: z.string().describe("The user ID for personalized API key usage.").optional(),
});
export type ScanPokemonCardInput = z.infer<typeof ScanPokemonCardInputSchema>;
const ScanPokemonCardOutputSchema = z.object({
cardDetails: z.object({
name: z.string().describe("The name of the Pokemon card.").optional(),
set: z.string().describe("The first set the Pokemon card belongs to.").optional(),
rarity: z.string().describe("The rarity of the Pokemon card.").optional(),
}).describe("Details about the Pokemon card.").optional(),
error: z.string().describe("Error message if the card could not be identified.").optional()
});
export type ScanPokemonCardOutput = z.infer<typeof ScanPokemonCardOutputSchema>;
export async function scanPokemonCard(input: ScanPokemonCardInput): Promise<ScanPokemonCardOutput> {
return scanPokemonCardFlow(input);
}
async function createScanPromptAndFlow(userAI: any) {
const scanPokemonCardPrompt = userAI.definePrompt({
name: 'scanPokemonCardPrompt',
input: {schema: ScanPokemonCardInputSchema},
output: {schema: ScanPokemonCardOutputSchema},
prompt: `You are an expert Pokemon card appraiser. Use the following image to identify the card's name, first set it was introduced in, and rarity. If you are unable to determine any of these values, leave them blank.\n
Photo: {{media url=photoDataUri}}
\n
Respond using the following format:
{
"cardDetails": {
"name": "Pokemon card name",
"set": "Pokemon first card set it was introduced in",
"rarity": "Pokemon card rarity"
}
}
\nIf you cannot identify the Pokemon card or if the image does not contain a Pokemon card, respond with the following format:
{
"error": "Error message describing why the card could not be identified"
}`,
});
const scanPokemonCardFlow = userAI.defineFlow(
{
name: 'scanPokemonCardFlow',
inputSchema: ScanPokemonCardInputSchema,
outputSchema: ScanPokemonCardOutputSchema,
},
async (input: ScanPokemonCardInput) => {
try {
const {output} = await scanPokemonCardPrompt(input);
return output!;
} catch (error: any) {
console.error('Error during card scanning:', error);
return {error: 'Failed to scan Pokemon card.'};
}
}
);
return scanPokemonCardFlow;
}
async function scanPokemonCardFlow(input: ScanPokemonCardInput): Promise<ScanPokemonCardOutput> {
try {
// Get user-specific AI instance if userId provided
const userAI = input.userId ? await createUserAI(input.userId) : ai;
// Create the flow with the appropriate AI instance
const flow = await createScanPromptAndFlow(userAI);
// Execute the flow
return await flow(input);
} catch (error: any) {
console.error('Error in scan flow setup:', error);
return {error: 'Failed to scan Pokemon card.'};
}
}
+128
View File
@@ -0,0 +1,128 @@
// This file is automatically generated - edits will be lost!
'use server';
/**
* @fileOverview Uses Gemini Vision to scan and identify a TCG card's name, set, rarity, and game.
*
* - scanTCGCard - A function that handles the card scanning process.
* - ScanTCGCardInput - The input type for the scanTCGCard function.
* - ScanTCGCardOutput - The return type for the scanTCGCard function.
*/
import {ai, createUserAI} from '@/ai/genkit';
import {z} from 'genkit';
import type { TCGGame } from '@/types';
const ScanTCGCardInputSchema = z.object({
photoDataUri: z
.string()
.describe(
"A photo of a trading 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(),
game: z.enum(['pokemon', 'onepiece', 'lorcana', 'magic', 'dragonball']).optional().describe("The TCG game to scan for. If not specified, will auto-detect."),
});
export type ScanTCGCardInput = z.infer<typeof ScanTCGCardInputSchema>;
const ScanTCGCardOutputSchema = z.object({
cardDetails: z.object({
name: z.string().describe("The name of the card.").optional(),
set: z.string().describe("The set the card belongs to.").optional(),
rarity: z.string().describe("The rarity of the card.").optional(),
game: z.enum(['pokemon', 'onepiece', 'lorcana', 'magic', 'dragonball']).describe("The TCG game this card is from.").optional(),
}).describe("Details about the trading card.").optional(),
error: z.string().describe("Error message if the card could not be identified.").optional()
});
export type ScanTCGCardOutput = z.infer<typeof ScanTCGCardOutputSchema>;
export async function scanTCGCard(input: ScanTCGCardInput): Promise<ScanTCGCardOutput> {
return scanTCGCardFlow(input);
}
async function createScanPromptAndFlow(userAI: any) {
const gameHint = (game?: string) => {
if (!game) return "First, identify which trading card game this is from (Pokémon, One Piece, Lorcana, Magic: The Gathering, or Dragon Ball).";
const gameNames = {
pokemon: 'Pokémon TCG',
onepiece: 'One Piece Card Game',
lorcana: 'Disney Lorcana',
magic: 'Magic: The Gathering',
dragonball: 'Dragon Ball Super Card Game'
};
return `This is a ${gameNames[game as keyof typeof gameNames]} card.`;
};
const scanTCGCardPrompt = userAI.definePrompt({
name: 'scanTCGCardPrompt',
input: {schema: ScanTCGCardInputSchema},
output: {schema: ScanTCGCardOutputSchema},
prompt: `You are an expert trading card game appraiser with deep knowledge of Pokémon TCG, One Piece Card Game, Disney Lorcana, Magic: The Gathering, and Dragon Ball Super Card Game.
{{#if game}}
${gameHint('{{game}}')}
{{else}}
${gameHint()}
{{/if}}
Use the following image to identify:
1. The card's name
2. The set it belongs to
3. The rarity
4. The game (if not already specified)
Photo: {{media url=photoDataUri}}
Respond using the following format:
{
"cardDetails": {
"name": "Card name",
"set": "Set name or expansion",
"rarity": "Card rarity",
"game": "pokemon" | "onepiece" | "lorcana" | "magic" | "dragonball"
}
}
If you cannot identify the card or if the image does not contain a trading card, respond with:
{
"error": "Error message describing why the card could not be identified"
}`,
});
const scanTCGCardFlow = userAI.defineFlow(
{
name: 'scanTCGCardFlow',
inputSchema: ScanTCGCardInputSchema,
outputSchema: ScanTCGCardOutputSchema,
},
async (input: ScanTCGCardInput) => {
try {
const {output} = await scanTCGCardPrompt(input);
return output!;
} catch (error: any) {
console.error('Error during card scanning:', error);
return {error: 'Failed to scan trading card.'};
}
}
);
return scanTCGCardFlow;
}
const scanTCGCardFlow = ai.defineFlow(
{
name: 'scanTCGCardFlow',
inputSchema: ScanTCGCardInputSchema,
outputSchema: ScanTCGCardOutputSchema,
},
async (input: ScanTCGCardInput) => {
try {
const userAI = await createUserAI(input.userId || '');
const flow = await createScanPromptAndFlow(userAI);
return await flow(input);
} catch (error: any) {
console.error('Error during card scanning:', error);
return {error: 'Failed to scan trading card.'};
}
}
);
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { generatePokemonCardFromPhoto } from '@/ai/flows/generate-pokemon-card-from-photo';
import { generateTCGCardFromPhoto } from '@/ai/flows/generate-tcg-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 requiredFields = ['userId', 'photoDataUri', 'characterName', 'characterType', 'styleDescription', 'game'];
const missingFields = requiredFields.filter(field => !body[field]);
if (missingFields.length > 0) {
@@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
}
// Call the AI flow
const result = await generatePokemonCardFromPhoto(body);
const result = await generateTCGCardFromPhoto(body);
if (result.error) {
return NextResponse.json({ error: result.error }, { status: 500 });
+7 -5
View File
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import { generatePokemonCard } from '@/ai/flows/generate-pokemon-card';
import { generateTCGCard } from '@/ai/flows/generate-tcg-card';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate required fields - including userId
const requiredFields = ['userId', 'pokemonName', 'pokemonType', 'backgroundDescription', 'pokemonDescription'];
// New multi-game flow
const requiredFields = ['userId', 'game', 'characterName', 'characterType', 'backgroundDescription', 'characterDescription'];
const missingFields = requiredFields.filter(field => !body[field]);
if (missingFields.length > 0) {
@@ -16,14 +16,15 @@ export async function POST(request: NextRequest) {
);
}
// Call the AI flow
const result = await generatePokemonCard(body);
// Call the new AI flow
const result = await generateTCGCard(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(
@@ -32,3 +33,4 @@ export async function POST(request: NextRequest) {
);
}
}
@@ -12,7 +12,9 @@ import { Loader2, Save } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { compressBase64Image, getBase64Size, formatBytes } from '@/utils/imageUtils';
import { parsePhotoGenerationParams } from '@/utils/generationParamsUtils';
import { getGameConfig } from '@/config/tcg-games';
import type { GenerateFromPhotoInput } from '@/components/cards/PhotoCardGeneratorFormOnly';
import type { TCGGame } from '@/types';
import Image from 'next/image';
export default function EditGenerateFromPhotoPage() {
@@ -25,12 +27,18 @@ export default function EditGenerateFromPhotoPage() {
const [isSaving, setIsSaving] = useState(false);
const [initialValues, setInitialValues] = useState<Partial<GenerateFromPhotoInput> | undefined>(undefined);
const [showComparison, setShowComparison] = useState(true);
const [currentGame, setCurrentGame] = useState<TCGGame>('pokemon');
useEffect(() => {
// Parse parameters from URL search params
const params = parsePhotoGenerationParams(searchParams);
setInitialValues(params);
// Set the game from params
if (params.game) {
setCurrentGame(params.game as TCGGame);
}
// Extract original card data
const originalCardId = searchParams.get('originalCardId');
const originalCardName = searchParams.get('originalCardName');
@@ -83,13 +91,17 @@ export default function EditGenerateFromPhotoPage() {
}
}
const game = generatedCard.params?.game || currentGame;
const gameName = getGameConfig(game as TCGGame).name;
// 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',
name: `Photo-Generated ${gameName} Card`,
set: 'AI Photo Generated',
rarity: 'Photo Special',
game: game,
imageDataUrl: finalImageDataUrl,
isGenerated: true,
isPhotoGenerated: true,
@@ -99,7 +111,7 @@ export default function EditGenerateFromPhotoPage() {
toast({
title: 'Card Saved',
description: 'Your photo-generated Pokemon card has been added to your collection!',
description: `Your photo-generated ${gameName} card has been added to your collection!`,
});
// Clear the generated card after saving
@@ -124,9 +136,9 @@ export default function EditGenerateFromPhotoPage() {
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>
<h1 className="text-3xl font-bold mb-2">Edit {getGameConfig(currentGame).name} Card Generator from Photo</h1>
<p className="text-muted-foreground">
Modify your photo-based Pokemon card parameters and regenerate the card
Modify your photo-based {getGameConfig(currentGame).name} card parameters and regenerate the card
</p>
</div>
+15 -6
View File
@@ -3,7 +3,7 @@
import { useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/hooks/use-toast';
import { PhotoCardGenerator } from '@/components/cards/PhotoCardGenerator';
import { PhotoCardGeneratorFormOnly as PhotoCardGenerator } 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';
@@ -11,6 +11,8 @@ import { Loader2, Save } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { compressBase64Image, getBase64Size, formatBytes } from '@/utils/imageUtils';
import ApiKeysWarning from '@/components/cards/ApiKeysWarning';
import { getGameConfig } from '@/config/tcg-games';
import type { TCGGame } from '@/types';
export default function GenerateFromPhotoPage() {
const { user } = useAuth();
@@ -57,13 +59,17 @@ export default function GenerateFromPhotoPage() {
}
}
const game = generatedCard.params?.game || 'pokemon';
const gameName = getGameConfig(game as TCGGame).name;
// 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',
name: `Photo-Generated ${gameName} Card`,
set: 'AI Photo Generated',
rarity: 'Photo Special',
game: game,
imageDataUrl: finalImageDataUrl,
isGenerated: true,
isPhotoGenerated: true,
@@ -73,7 +79,7 @@ export default function GenerateFromPhotoPage() {
toast({
title: 'Card Saved',
description: 'Your photo-generated Pokemon card has been added to your collection!',
description: `Your photo-generated ${gameName} card has been added to your collection!`,
});
// Clear the generated card after saving
@@ -94,9 +100,12 @@ export default function GenerateFromPhotoPage() {
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>
<h1 className="text-3xl font-bold mb-2">TCG 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
Upload your own photo and transform it into a custom trading card using AI-powered image generation
</p>
<p className="text-sm text-muted-foreground mt-2">
Supports Pokémon, One Piece, Lorcana, Magic: The Gathering, and Dragon Ball cards
</p>
</div>
@@ -115,7 +124,7 @@ export default function GenerateFromPhotoPage() {
<CardHeader>
<CardTitle>Save Your Card</CardTitle>
<CardDescription>
Your photo-inspired Pokemon card is ready! Save it to your collection.
Your photo-inspired trading card is ready! Save it to your collection.
</CardDescription>
</CardHeader>
<CardContent>
+19 -8
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { CardGeneratorFormOnly } from '@/components/cards/CardGeneratorFormOnly';
import { TCGCardGenerator } from '@/components/cards/TCGCardGenerator';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/hooks/useAuth';
import { addCardToCollection } from '@/lib/firestore';
@@ -10,7 +10,8 @@ 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 { getGameConfig } from '@/config/tcg-games';
import type { TCGGame, CardGenerationParams } from '@/types';
import Image from 'next/image';
export default function EditGenerateCardPage() {
@@ -20,14 +21,20 @@ export default function EditGenerateCardPage() {
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 [initialValues, setInitialValues] = useState<Partial<CardGenerationParams> | undefined>(undefined);
const [showComparison, setShowComparison] = useState(true);
const [currentGame, setCurrentGame] = useState<TCGGame>('pokemon');
useEffect(() => {
// Parse parameters from URL search params
const params = parseGenerationParams(searchParams);
setInitialValues(params);
// Set the game from params
if (params.game) {
setCurrentGame(params.game as TCGGame);
}
// Extract original card data
const originalCardId = searchParams.get('originalCardId');
const originalCardName = searchParams.get('originalCardName');
@@ -82,10 +89,14 @@ export default function EditGenerateCardPage() {
}
}
const game = generatedCard.params?.game || currentGame;
const gameName = getGameConfig(game as TCGGame).name;
await addCardToCollection(user.uid, {
name: 'Generated Pokemon Card',
name: `Generated ${gameName} Card`,
set: 'AI Generated',
rarity: 'Special',
game: game,
imageDataUrl: imageDataUrl,
isGenerated: true,
prompt: generatedCard.prompt,
@@ -94,7 +105,7 @@ export default function EditGenerateCardPage() {
toast({
title: 'Card Saved',
description: 'Your generated Pokemon card has been added to your collection!',
description: `Your generated ${gameName} card has been added to your collection!`,
});
// Clear the generated card after saving
@@ -118,16 +129,16 @@ export default function EditGenerateCardPage() {
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>
<h1 className="text-3xl font-bold mb-2">Edit {getGameConfig(currentGame).name} Card Generator</h1>
<p className="text-muted-foreground">
Modify your Pokemon card parameters and regenerate the card
Modify your {getGameConfig(currentGame).name} 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} />
<TCGCardGenerator onCardGenerated={handleCardGenerated} initialValues={initialValues} />
</div>
{/* Card Preview Section - Takes up 1 column */}
+12 -6
View File
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { CardGenerator } from '@/components/cards/CardGenerator';
import { TCGCardGenerator } from '@/components/cards/TCGCardGenerator';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/hooks/useAuth';
import { addCardToCollection } from '@/lib/firestore';
@@ -9,6 +9,8 @@ import { useToast } from '@/hooks/use-toast';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { compressBase64Image, getBase64Size, formatBytes } from '@/utils/imageUtils';
import ApiKeysWarning from '@/components/cards/ApiKeysWarning';
import { getGameConfig } from '@/config/tcg-games';
import type { TCGGame } from '@/types';
export default function GenerateCardPage() {
const { user } = useAuth();
@@ -56,10 +58,14 @@ export default function GenerateCardPage() {
}
}
const game = generatedCard.params?.game || 'pokemon';
const gameName = getGameConfig(game as TCGGame).name;
await addCardToCollection(user.uid, {
name: 'Generated Pokemon Card',
name: `Generated ${gameName} Card`,
set: 'AI Generated',
rarity: 'Special',
game: game,
imageDataUrl: imageDataUrl,
isGenerated: true,
prompt: generatedCard.prompt,
@@ -68,7 +74,7 @@ export default function GenerateCardPage() {
toast({
title: 'Card Saved',
description: 'Your generated Pokemon card has been added to your collection!',
description: `Your generated ${gameName} card has been added to your collection!`,
});
// Clear the generated card after saving
@@ -88,9 +94,9 @@ export default function GenerateCardPage() {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">Pokemon Card Generator</h1>
<h1 className="text-3xl font-bold mb-2">TCG Card Generator</h1>
<p className="text-muted-foreground">
Create your own custom Pokemon cards using AI-powered image generation
Create your own custom trading cards for Pokémon, One Piece, Lorcana, Magic, and Dragon Ball using AI
</p>
</div>
@@ -102,7 +108,7 @@ export default function GenerateCardPage() {
/>
</div>
<CardGenerator onCardGenerated={handleCardGenerated} />
<TCGCardGenerator onCardGenerated={handleCardGenerated} />
{generatedCard && (
<Card className="mt-8">
+5 -5
View File
@@ -15,7 +15,7 @@ export default function DashboardPage() {
<div className="text-center mb-8">
<h1 className="text-3xl font-bold font-headline mb-2">Welcome to your Cardex Dashboard</h1>
<p className="text-lg text-muted-foreground">
Manage your Pokémon card collection with ease.
Manage your TCG card collection with ease.
</p>
</div>
@@ -32,7 +32,7 @@ export default function DashboardPage() {
Scan Cards
</CardTitle>
<CardDescription>
Use your camera to scan and identify Pokemon cards
Use your camera to scan and identify TCG cards
</CardDescription>
</CardHeader>
<CardContent>
@@ -51,7 +51,7 @@ export default function DashboardPage() {
Generate Cards
</CardTitle>
<CardDescription>
Create custom Pokemon cards using AI image generation
Create custom TCG cards using AI image generation
</CardDescription>
</CardHeader>
<CardContent>
@@ -70,7 +70,7 @@ export default function DashboardPage() {
Photo Cards
</CardTitle>
<CardDescription>
Transform your photos into Pokemon cards using AI
Transform your photos into TCG cards using AI
</CardDescription>
</CardHeader>
<CardContent>
@@ -89,7 +89,7 @@ export default function DashboardPage() {
My Collection
</CardTitle>
<CardDescription>
View and manage your Pokemon card collection
View and manage your TCG card collection
</CardDescription>
</CardHeader>
<CardContent>
+5 -2
View File
@@ -5,9 +5,12 @@ export default function ScanCardPage() {
return (
<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>
<h1 className="text-3xl font-bold tracking-tight">Scan Trading 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
Upload an image of your trading card to automatically identify and add it to your collection
</p>
<p className="text-sm text-muted-foreground">
Supports Pokémon, One Piece, Lorcana, Magic: The Gathering, and Dragon Ball cards
</p>
</div>
-506
View File
@@ -1,506 +0,0 @@
'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, Download } from 'lucide-react';
import { downloadCardFromBase64 } from '@/utils/downloadUtils';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { useApiKeys } from '@/hooks/useApiKeys';
export interface GeneratePokemonCardInput {
pokemonName: string;
pokemonType: string;
isIllustrationRare: boolean;
isHolo: boolean;
backgroundDescription: string;
pokemonDescription: string;
language: 'english' | 'japanese' | 'chinese' | 'korean' | 'spanish' | 'french' | 'german' | 'italian';
model: 'imagen-4.0-ultra-generate-001' | 'imagen-4.0-generate-001' | 'imagen-4.0-fast-generate-001' | 'gemini-2.5-flash-image' | 'gemini-3-pro-image-preview';
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 models = [
{ value: 'imagen-4.0-ultra-generate-001', label: 'Imagen 4.0 Ultra' },
{ value: 'imagen-4.0-generate-001', label: 'Imagen 4.0 Standard' },
{ value: 'imagen-4.0-fast-generate-001', label: 'Imagen 4.0 Fast' },
{ value: 'gemini-2.5-flash-image', label: 'Gemini 2.5 Flash (Nano Banana)' },
{ value: 'gemini-3-pro-image-preview', label: 'Gemini 3 Pro (Nano Banana Pro)' },
] 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']),
model: z.enum(['imagen-4.0-ultra-generate-001', 'imagen-4.0-generate-001', 'imagen-4.0-fast-generate-001', 'gemini-2.5-flash-image', 'gemini-3-pro-image-preview']),
hp: z.number().min(10).max(999).optional(),
attackName1: z.string().optional(),
attackDamage1: z.number().min(0).max(999).optional(),
attackName2: z.string().optional(),
attackDamage2: z.number().min(0).max(999).optional(),
weakness: z.string().optional(),
resistance: z.string().optional(),
retreatCost: z.number().min(0).max(5).optional(),
});
type CardGeneratorForm = z.infer<typeof cardGeneratorSchema>;
interface CardGeneratorProps {
onCardGenerated?: (imageBase64: string, prompt: string, params: GeneratePokemonCardInput) => void;
initialValues?: Partial<GeneratePokemonCardInput>;
}
export function CardGenerator({ onCardGenerated, initialValues }: CardGeneratorProps) {
const { user } = useAuth();
const { hasGeminiKey } = useApiKeys();
const { toast } = useToast();
const [isGenerating, setIsGenerating] = useState(false);
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string } | null>(null);
const [error, setError] = useState<string>('');
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CardGeneratorForm>({
resolver: zodResolver(cardGeneratorSchema),
defaultValues: {
pokemonName: initialValues?.pokemonName || '',
pokemonType: initialValues?.pokemonType || '',
isIllustrationRare: initialValues?.isIllustrationRare || false,
isHolo: initialValues?.isHolo || false,
backgroundDescription: initialValues?.backgroundDescription || '',
pokemonDescription: initialValues?.pokemonDescription || '',
language: initialValues?.language || 'english',
model: initialValues?.model || 'imagen-4.0-ultra-generate-001',
hp: initialValues?.hp || 130,
attackName1: initialValues?.attackName1 || 'Quick Attack',
attackDamage1: initialValues?.attackDamage1 || 60,
attackName2: initialValues?.attackName2 || 'Special Move',
attackDamage2: initialValues?.attackDamage2 || 90,
weakness: initialValues?.weakness || 'Fighting',
resistance: initialValues?.resistance || 'Psychic',
retreatCost: initialValues?.retreatCost || 2,
},
});
const handleDownloadCard = () => {
if (!generatedCard) return;
try {
downloadCardFromBase64(generatedCard.imageBase64, watchedValues.pokemonName || 'pokemon_card');
toast({
title: 'Download Started',
description: 'Your Pokemon card is being downloaded.',
});
} catch (error) {
console.error('Download error:', error);
toast({
title: 'Download Failed',
description: 'Failed to download the card. Please try again.',
variant: 'destructive',
});
}
};
const watchedValues = watch();
const onSubmit = async (data: CardGeneratorForm) => {
if (!user) {
setError('You must be logged in to generate cards');
return;
}
if (!hasGeminiKey) {
setError('Gemini API key is required to generate cards. Please configure it in Settings.');
return;
}
setIsGenerating(true);
setError('');
setGeneratedCard(null);
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) {
setGeneratedCard({
imageBase64: result.imageBase64,
prompt: result.prompt,
});
onCardGenerated?.(result.imageBase64, result.prompt, data);
}
} catch (err) {
console.error('Error generating card:', err);
setError('Failed to generate card. Please try again.');
} finally {
setIsGenerating(false);
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Form Section */}
<Card>
<CardHeader>
<CardTitle>Create Your Pokemon Card</CardTitle>
<CardDescription>
Fill in the details to generate a custom Pokemon TCG card using AI
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Pokemon Info */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="pokemonName">Pokemon Name</Label>
<Input
id="pokemonName"
{...register('pokemonName')}
placeholder="e.g., Pikachu"
/>
{errors.pokemonName && (
<p className="text-sm text-red-500 mt-1">{errors.pokemonName.message}</p>
)}
</div>
<div>
<Label htmlFor="pokemonType">Pokemon Type</Label>
<Select onValueChange={(value) => setValue('pokemonType', value)}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{pokemonTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.pokemonType && (
<p className="text-sm text-red-500 mt-1">{errors.pokemonType.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="language">Card Language</Label>
<Select onValueChange={(value) => setValue('language', value as any)}>
<SelectTrigger>
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="model">AI Model</Label>
<Select onValueChange={(value) => setValue('model', value as any)}>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.value} value={model.value}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.model && (
<p className="text-sm text-red-500 mt-1">{errors.model.message}</p>
)}
</div>
</div>
<Separator />
{/* Card Properties */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Card Properties</h3>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="isIllustrationRare">Full Art</Label>
<p className="text-sm text-muted-foreground">Horizontal layout with full illustration</p>
</div>
<Switch
id="isIllustrationRare"
checked={watchedValues.isIllustrationRare}
onCheckedChange={(checked) => setValue('isIllustrationRare', checked)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="isHolo">Holographic Effect</Label>
<p className="text-sm text-muted-foreground">Rainbow shimmer effect</p>
</div>
<Switch
id="isHolo"
checked={watchedValues.isHolo}
onCheckedChange={(checked) => setValue('isHolo', checked)}
/>
</div>
</div>
<Separator />
{/* Descriptions */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Descriptions</h3>
<div>
<Label htmlFor="pokemonDescription">Pokemon Description</Label>
<Textarea
id="pokemonDescription"
{...register('pokemonDescription')}
placeholder="Describe how the Pokemon should look and what it's doing..."
className="min-h-[80px]"
/>
{errors.pokemonDescription && (
<p className="text-sm text-red-500 mt-1">{errors.pokemonDescription.message}</p>
)}
</div>
<div>
<Label htmlFor="backgroundDescription">Background Description</Label>
<Textarea
id="backgroundDescription"
{...register('backgroundDescription')}
placeholder="Describe the background scene..."
className="min-h-[80px]"
/>
{errors.backgroundDescription && (
<p className="text-sm text-red-500 mt-1">{errors.backgroundDescription.message}</p>
)}
</div>
</div>
<Separator />
{/* Stats (Optional) */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Card Stats (Optional)</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="hp">HP</Label>
<Input
id="hp"
type="number"
{...register('hp', { valueAsNumber: true })}
placeholder="130"
/>
</div>
<div>
<Label htmlFor="weakness">Weakness</Label>
<Select onValueChange={(value) => setValue('weakness', value)}>
<SelectTrigger>
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{pokemonTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="retreatCost">Retreat Cost</Label>
<Input
id="retreatCost"
type="number"
{...register('retreatCost', { valueAsNumber: true })}
placeholder="2"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="attackName1">First Attack Name</Label>
<Input
id="attackName1"
{...register('attackName1')}
placeholder="Quick Attack"
/>
</div>
<div>
<Label htmlFor="attackDamage1">First Attack Damage</Label>
<Input
id="attackDamage1"
type="number"
{...register('attackDamage1', { valueAsNumber: true })}
placeholder="60"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="attackName2">Second Attack Name</Label>
<Input
id="attackName2"
{...register('attackName2')}
placeholder="Special Move"
/>
</div>
<div>
<Label htmlFor="attackDamage2">Second Attack Damage</Label>
<Input
id="attackDamage2"
type="number"
{...register('attackDamage2', { valueAsNumber: true })}
placeholder="90"
/>
</div>
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={isGenerating || !hasGeminiKey}
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating Card...
</>
) : !hasGeminiKey ? (
'Gemini API Key Required'
) : (
'Generate Pokemon Card'
)}
</Button>
{!hasGeminiKey && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-sm text-yellow-700">
Please configure your Gemini API key in <strong>Settings</strong> to generate cards.
</p>
</div>
)}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</form>
</CardContent>
</Card>
{/* Preview Section */}
<Card>
<CardHeader>
<CardTitle>Generated Card</CardTitle>
<CardDescription>
Your custom Pokemon card will appear here
</CardDescription>
</CardHeader>
<CardContent>
{generatedCard ? (
<div className="space-y-4">
<div className="relative bg-gray-100 rounded-lg p-4">
<img
src={`data:image/jpeg;base64,${generatedCard.imageBase64}`}
alt="Generated Pokemon Card"
className="w-full h-auto rounded-lg shadow-lg"
/>
</div>
<Button
onClick={handleDownloadCard}
className="w-full"
variant="outline"
>
<Download className="mr-2 h-4 w-4" />
Download Card
</Button>
</div>
) : (
<div className="flex items-center justify-center h-64 bg-gray-50 rounded-lg">
<p className="text-muted-foreground">
{isGenerating ? 'Generating your Pokemon card...' : 'Fill out the form to generate a card'}
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,429 +0,0 @@
'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';
model: 'imagen-4.0-ultra-generate-001' | 'imagen-4.0-generate-001' | 'imagen-4.0-fast-generate-001' | 'gemini-2.5-flash-image' | 'gemini-3-pro-image-preview';
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 models = [
{ value: 'imagen-4.0-ultra-generate-001', label: 'Imagen 4.0 Ultra' },
{ value: 'imagen-4.0-generate-001', label: 'Imagen 4.0 Standard' },
{ value: 'imagen-4.0-fast-generate-001', label: 'Imagen 4.0 Fast' },
{ value: 'gemini-2.5-flash-image', label: 'Gemini 2.5 Flash (Nano Banana)' },
{ value: 'gemini-3-pro-image-preview', label: 'Gemini 3 Pro Image Preview' },
] 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']),
model: z.enum(['imagen-4.0-ultra-generate-001', 'imagen-4.0-generate-001', 'imagen-4.0-fast-generate-001', 'gemini-2.5-flash-image', 'gemini-3-pro-image-preview']),
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',
model: initialValues?.model || 'imagen-4.0-ultra-generate-001',
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>
<Label htmlFor="model">AI Model</Label>
<Select value={watchedValues.model} onValueChange={(value: any) => setValue('model', value)}>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.value} value={model.value}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.model && (
<p className="text-sm text-red-500 mt-1">{errors.model.message}</p>
)}
</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>
);
}
+6 -1
View File
@@ -6,13 +6,14 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Pencil, Trash2, Sparkles, Download, Play, Pause, Video, Clock, AlertCircle, RotateCcw, FileDown, Eye, Settings } from 'lucide-react';
import type { PokemonCard } from '@/types';
import type { PokemonCard, TCGGame } 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 { useApiKeys } from '@/hooks/useApiKeys';
import { getGameConfig } from '@/config/tcg-games';
import {
AlertDialog,
AlertDialogAction,
@@ -173,6 +174,10 @@ export default function CardItem({ card, onDelete, onUpdate, isDeleting }: CardI
{card.name}
</CardTitle>
<div className="flex flex-col gap-1 flex-shrink-0">
{/* Game Badge */}
<Badge variant="outline" className="flex items-center gap-1">
{getGameConfig(card.game || 'pokemon' as TCGGame).name}
</Badge>
{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" />
+8 -9
View File
@@ -1,10 +1,8 @@
'use client';
import { useState, type ChangeEvent, useRef } from 'react';
import { scanPokemonCard, type ScanPokemonCardOutput } from '@/ai/flows/scan-pokemon-card';
import { scanTCGCard, type ScanTCGCardOutput } from '@/ai/flows/scan-tcg-card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { Loader2, Upload, AlertCircle, CheckCircle } from 'lucide-react';
import Image from 'next/image';
@@ -18,7 +16,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui
export default function CardScanner() {
const { hasGeminiKey } = useApiKeys();
const [isScanning, setIsScanning] = useState(false);
const [scanResult, setScanResult] = useState<ScanPokemonCardOutput | null>(null);
const [scanResult, setScanResult] = useState<ScanTCGCardOutput | null>(null);
const [error, setError] = useState<string | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageDataUrl, setImageDataUrl] = useState<string | null>(null);
@@ -56,8 +54,8 @@ export default function CardScanner() {
}
if (!hasGeminiKey) {
setError('Gemini API key is required to scan Pokemon cards. Please configure it in Settings.');
toast({ title: 'API Key Required', description: 'Gemini API key is required to scan Pokemon cards. Please configure it in Settings.', variant: 'destructive' });
setError('Gemini API key is required to scan trading cards. Please configure it in Settings.');
toast({ title: 'API Key Required', description: 'Gemini API key is required to scan trading cards. Please configure it in Settings.', variant: 'destructive' });
return;
}
@@ -67,9 +65,9 @@ export default function CardScanner() {
setShowForm(false);
try {
const result = await scanPokemonCard({
const result = await scanTCGCard({
photoDataUri: imageDataUrl,
userId: user?.uid
userId: user?.uid || ''
});
if (result.error) {
setError(result.error);
@@ -108,6 +106,7 @@ export default function CardScanner() {
name: data.name,
set: data.set,
rarity: data.rarity,
game: scanResult?.cardDetails?.game || 'pokemon', // Include detected game
imageDataUrl: data.imageDataUrl, // Use image from form state, which should be original scan
};
await addCardToCollection(user.uid, cardToSave);
@@ -144,7 +143,7 @@ export default function CardScanner() {
</div>
<CardTitle className="text-xl">Upload Card Image</CardTitle>
<CardDescription>
Choose a clear image of your Pokémon card for best scanning results
Choose a clear image of your trading card for best scanning results. Supports Pokémon, One Piece, Lorcana, Magic: The Gathering, and Dragon Ball cards.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
-522
View File
@@ -1,522 +0,0 @@
'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';
import { useApiKeys } from '@/hooks/useApiKeys';
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 { hasOpenAIKey } = useApiKeys();
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 (!hasOpenAIKey) {
setError('OpenAI API key is required to generate cards from photos. Please configure it in Settings.');
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 || !hasOpenAIKey}
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating Card from Photo...
</>
) : !hasOpenAIKey ? (
'OpenAI API Key Required'
) : (
'Generate Pokemon Card from Photo'
)}
</Button>
{!hasOpenAIKey && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-sm text-yellow-700">
Please configure your OpenAI API key in <strong>Settings</strong> to generate cards from photos.
</p>
</div>
)}
{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>
);
}
@@ -11,32 +11,15 @@ 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 { Loader2, Upload, X, Download } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
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'
];
import { useApiKeys } from '@/hooks/useApiKeys';
import { TCG_GAMES, getGameConfig } from '@/config/tcg-games';
import type { TCGGame, PhotoCardGenerationParams } from '@/types';
const languages = [
{ value: 'english', label: 'English' },
@@ -51,22 +34,34 @@ const languages = [
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'),
game: z.enum(['pokemon', 'onepiece', 'lorcana', 'magic', 'dragonball']),
characterName: z.string().min(1, 'Character name is required'),
characterType: z.string().min(1, 'Character 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(),
// All game-specific stats are optional and dynamic - use preprocess to handle empty strings and NaN
hp: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(10).max(9999).optional()),
attack: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(9999).optional()),
defense: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(9999).optional()),
power: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(9999).optional()),
toughness: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(9999).optional()),
loyalty: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(99).optional()),
energy: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(999).optional()),
cost: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(99).optional()),
manaCost: z.string().optional(),
attackName1: z.string().optional(),
attackDamage1: z.number().min(0).max(999).optional(),
attackDamage1: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(999).optional()),
attackName2: z.string().optional(),
attackDamage2: z.number().min(0).max(999).optional(),
attackDamage2: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(999).optional()),
weakness: z.string().optional(),
resistance: z.string().optional(),
retreatCost: z.number().min(0).max(5).optional(),
retreatCost: z.preprocess((val) => (val === '' || val === null || Number.isNaN(val)) ? undefined : val, z.number().min(0).max(5).optional()),
});
type PhotoCardGeneratorForm = z.infer<typeof photoCardGeneratorSchema>;
export type GenerateFromPhotoInput = PhotoCardGenerationParams & { photoDataUri: string };
interface PhotoCardGeneratorFormOnlyProps {
onCardGenerated?: (imageBase64: string, prompt: string, params: GenerateFromPhotoInput) => void;
initialValues?: Partial<GenerateFromPhotoInput>;
@@ -74,7 +69,9 @@ interface PhotoCardGeneratorFormOnlyProps {
export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: PhotoCardGeneratorFormOnlyProps) {
const { user } = useAuth();
const { hasOpenAIKey } = useApiKeys();
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();
@@ -91,22 +88,63 @@ export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: P
resolver: zodResolver(photoCardGeneratorSchema),
defaultValues: {
photoDataUri: initialValues?.photoDataUri || '',
pokemonName: initialValues?.pokemonName || '',
pokemonType: initialValues?.pokemonType || '',
game: initialValues?.game || 'pokemon',
characterName: initialValues?.characterName || '',
characterType: initialValues?.characterType || '',
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,
hp: initialValues?.hp,
attackName1: initialValues?.attackName1 || '',
attackDamage1: initialValues?.attackDamage1,
attackName2: initialValues?.attackName2 || '',
attackDamage2: initialValues?.attackDamage2,
weakness: initialValues?.weakness || '',
resistance: initialValues?.resistance || '',
retreatCost: initialValues?.retreatCost,
},
});
const watchedValues = watch();
const currentGame = watchedValues.game || 'pokemon';
const gameConfig = getGameConfig(currentGame);
const handleGameChange = (game: TCGGame) => {
setValue('game', game, { shouldValidate: true });
const config = getGameConfig(game);
// Reset character type
setValue('characterType', '', { shouldValidate: false });
// Set default stats for the game
const defaultStats = config.defaultStats;
if (defaultStats) {
Object.entries(defaultStats).forEach(([key, value]) => {
setValue(key as any, value as any);
});
}
};
const handleDownloadCard = () => {
if (!generatedCard) return;
try {
const filename = watchedValues.characterName
? `${watchedValues.characterName.toLowerCase().replace(/\s+/g, '_')}_${currentGame}_card`
: `${currentGame}_card`;
downloadCardFromBase64(generatedCard.imageBase64, filename);
toast({
title: 'Download Started',
description: `Your ${gameConfig.name} 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 handleImageChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -139,13 +177,31 @@ export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: P
};
const onSubmit = async (data: PhotoCardGeneratorForm) => {
console.log('Form submitted with data:', data);
console.log('Has user:', !!user, 'Has OpenAI key:', hasOpenAIKey);
if (!user) {
setError('You must be logged in to generate cards');
return;
}
if (!hasOpenAIKey) {
setError('OpenAI API key is required to generate cards from photos. Please configure it in Settings.');
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', {
@@ -172,10 +228,14 @@ export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: P
}
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.',
description: `Your ${gameConfig.name} card has been generated based on your photo.`,
});
}
} catch (err) {
@@ -187,15 +247,32 @@ export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: P
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Form Section */}
<Card>
<CardHeader>
<CardTitle>Create Pokemon Card from Photo</CardTitle>
<CardTitle>Generate Card from Photo</CardTitle>
<CardDescription>
Upload your photo and customize the Pokemon card details
Upload a photo and create a custom TCG card inspired by your image using AI
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<form onSubmit={handleSubmit(onSubmit, (errors) => {
console.log('Form validation errors:', errors);
// Build a detailed error message
const errorFields = Object.entries(errors).map(([field, error]) => {
return `${field}: ${error?.message || 'Invalid value'}`;
}).join(', ');
console.log('Detailed errors:', errorFields);
toast({
title: 'Validation Error',
description: errorFields || 'Please check all required fields and try again.',
variant: 'destructive',
});
})} className="space-y-6">
{/* Photo Upload */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Upload Photo</h3>
@@ -253,46 +330,73 @@ export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: P
<Separator />
{/* Game Selection */}
<div className="space-y-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
Game Selection
<Badge variant="outline">{gameConfig.name}</Badge>
</h3>
<div>
<Label htmlFor="game">Trading Card Game</Label>
<Select value={currentGame} onValueChange={(value) => handleGameChange(value as TCGGame)}>
<SelectTrigger>
<SelectValue placeholder="Select game" />
</SelectTrigger>
<SelectContent>
{Object.entries(TCG_GAMES).map(([key, config]) => (
<SelectItem key={key} value={key}>
{config.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Basic Information</h3>
<h3 className="text-lg font-semibold">Character Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="pokemonName">Pokemon Name</Label>
<Label htmlFor="characterName">Character Name <span className="text-red-500">*</span></Label>
<Input
id="pokemonName"
{...register('pokemonName')}
placeholder="e.g., Pikachu"
id="characterName"
{...register('characterName')}
placeholder={`e.g., ${gameConfig.name} Character`}
className={errors.characterName ? 'border-red-500' : ''}
/>
{errors.pokemonName && (
<p className="text-sm text-red-500 mt-1">{errors.pokemonName.message}</p>
{errors.characterName && (
<p className="text-sm text-red-500 mt-1">{errors.characterName.message}</p>
)}
</div>
<div>
<Label htmlFor="pokemonType">Pokemon Type</Label>
<Select value={watchedValues.pokemonType} onValueChange={(value) => setValue('pokemonType', value)}>
<SelectTrigger>
<Label htmlFor="characterType">Character Type <span className="text-red-500">*</span></Label>
<Select value={watchedValues.characterType || ''} onValueChange={(value) => setValue('characterType', value, { shouldValidate: true })}>
<SelectTrigger className={errors.characterType ? 'border-red-500' : ''}>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{pokemonTypes.map((type) => (
{gameConfig.types.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>
{errors.characterType && (
<p className="text-sm text-red-500 mt-1">{errors.characterType.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="language">Card Language</Label>
<Select value={watchedValues.language} onValueChange={(value: any) => setValue('language', value)}>
<Select value={watchedValues.language || 'english'} onValueChange={(value: any) => setValue('language', value)}>
<SelectTrigger>
<SelectValue placeholder="Select language" />
</SelectTrigger>
@@ -314,12 +418,12 @@ export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: P
<h3 className="text-lg font-semibold">Style & Description</h3>
<div>
<Label htmlFor="styleDescription">Style Description</Label>
<Label htmlFor="styleDescription">Style Description <span className="text-red-500">*</span></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]"
placeholder={`Describe how the photo should be adapted for the ${gameConfig.name} card (e.g., 'Use the lighting and composition, but transform the subject into a character in a fantasy setting')`}
className={`min-h-[100px] ${errors.styleDescription ? 'border-red-500' : ''}`}
/>
{errors.styleDescription && (
<p className="text-sm text-red-500 mt-1">{errors.styleDescription.message}</p>
@@ -329,104 +433,58 @@ export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: P
<Separator />
{/* Game Stats */}
{/* Dynamic Game Stats */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Game Stats (Optional)</h3>
<h3 className="text-lg font-semibold">Card Stats (Optional)</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="hp">HP</Label>
{gameConfig.statFields.map((field) => (
<div key={field.name}>
<Label htmlFor={field.name}>{field.label}</Label>
{field.type === 'string' ? (
<Input
id="hp"
id={field.name}
{...register(field.name as any)}
placeholder={field.placeholder || field.label}
/>
) : (
<Input
id={field.name}
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"
{...register(field.name as any, { valueAsNumber: true })}
placeholder={field.placeholder || field.label}
/>
)}
</div>
))}
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={isGenerating || !imagePreview}
disabled={isGenerating || !imagePreview || !hasOpenAIKey}
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating Card...
Generating Card from Photo...
</>
) : !hasOpenAIKey ? (
'OpenAI API Key Required'
) : (
'Generate Pokemon Card from Photo'
`Generate ${gameConfig.name} Card from Photo`
)}
</Button>
{!hasOpenAIKey && (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-sm text-yellow-700">
Please configure your OpenAI API key in <strong>Settings</strong> to generate cards from photos.
</p>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4">
<p className="text-sm text-red-600">{error}</p>
@@ -435,5 +493,51 @@ export function PhotoCardGeneratorFormOnly({ onCardGenerated, initialValues }: P
</form>
</CardContent>
</Card>
{/* Preview Section */}
<Card>
<CardHeader>
<CardTitle>Generated Card</CardTitle>
<CardDescription>
Your photo-inspired {gameConfig.name} 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 ${gameConfig.name} 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 ${gameConfig.name} 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>
);
}
+512
View File
@@ -0,0 +1,512 @@
'use client';
import { useState, useMemo } 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 { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Loader2, Download, Upload } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { downloadCardFromBase64 } from '@/utils/downloadUtils';
import { useAuth } from '@/hooks/useAuth';
import type { TCGGame, CardGenerationParams } from '@/types';
import { TCG_GAMES, getGameConfig, getDefaultStats } from '@/config/tcg-games';
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 models = [
{ value: 'imagen-4.0-ultra-generate-001', label: 'Imagen 4.0 Ultra' },
{ value: 'imagen-4.0-generate-001', label: 'Imagen 4.0 Standard' },
{ value: 'imagen-4.0-fast-generate-001', label: 'Imagen 4.0 Fast' },
{ value: 'gemini-2.5-flash-image', label: 'Gemini 2.5 Flash (Nano Banana)' },
{ value: 'gemini-3-pro-image-preview', label: 'Gemini 3 Pro Image Preview' },
] as const;
const cardGeneratorSchema = z.object({
game: z.enum(['pokemon', 'onepiece', 'lorcana', 'magic', 'dragonball']),
characterName: z.string().min(1, 'Character name is required'),
characterType: z.string().min(1, 'Type is required'),
isSpecialArt: z.boolean(),
isHolo: z.boolean(),
backgroundDescription: z.string().min(10, 'Background description must be at least 10 characters'),
characterDescription: z.string().min(10, 'Character description must be at least 10 characters'),
language: z.enum(['english', 'japanese', 'chinese', 'korean', 'spanish', 'french', 'german', 'italian']),
model: z.enum(['imagen-4.0-ultra-generate-001', 'imagen-4.0-generate-001', 'imagen-4.0-fast-generate-001', 'gemini-2.5-flash-image', 'gemini-3-pro-image-preview']),
// All possible game-specific stats (will be filtered based on selected game)
hp: z.number().optional(),
attackName1: z.string().optional(),
attackDamage1: z.number().optional(),
attackName2: z.string().optional(),
attackDamage2: z.number().optional(),
weakness: z.string().optional(),
resistance: z.string().optional(),
retreatCost: z.number().optional(),
power: z.number().optional(),
cost: z.number().optional(),
counter: z.number().optional(),
lifePoints: z.number().optional(),
inkCost: z.number().optional(),
strength: z.number().optional(),
willpower: z.number().optional(),
lore: z.number().optional(),
inkable: z.boolean().optional(),
manaCost: z.string().optional(),
cardType: z.string().optional(),
subType: z.string().optional(),
powerToughness: z.string().optional(),
combatPower: z.number().optional(),
comboCost: z.number().optional(),
comboEnergy: z.number().optional(),
era: z.string().optional(),
});
type CardGeneratorForm = z.infer<typeof cardGeneratorSchema>;
interface TCGCardGeneratorProps {
onCardGenerated?: (imageBase64: string, prompt: string, params: CardGenerationParams) => void;
initialValues?: Partial<CardGenerationParams>;
}
export function TCGCardGenerator({ onCardGenerated, initialValues }: TCGCardGeneratorProps) {
const { user } = useAuth();
const [isGenerating, setIsGenerating] = useState(false);
const [generatedCard, setGeneratedCard] = useState<{ imageBase64: string; prompt: string } | null>(null);
const [error, setError] = useState<string>('');
const { toast } = useToast();
const defaultGame = initialValues?.game || 'pokemon';
const defaultStats = getDefaultStats(defaultGame);
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CardGeneratorForm>({
resolver: zodResolver(cardGeneratorSchema),
defaultValues: {
game: defaultGame,
characterName: initialValues?.characterName || '',
characterType: initialValues?.characterType || '',
isSpecialArt: initialValues?.isSpecialArt || false,
isHolo: initialValues?.isHolo || false,
backgroundDescription: initialValues?.backgroundDescription || '',
characterDescription: initialValues?.characterDescription || '',
language: initialValues?.language || 'english',
model: initialValues?.model || 'imagen-4.0-ultra-generate-001',
...defaultStats,
...initialValues,
},
});
const watchedValues = watch();
const selectedGame = watch('game');
const gameConfig = useMemo(() => getGameConfig(selectedGame as TCGGame), [selectedGame]);
// Update default stats when game changes
const handleGameChange = (game: TCGGame) => {
setValue('game', game);
setValue('characterType', '');
const defaults = getDefaultStats(game);
Object.entries(defaults).forEach(([key, value]) => {
setValue(key as any, value);
});
};
const handleDownloadCard = () => {
if (!generatedCard) return;
try {
const filename = watchedValues.characterName
? `${watchedValues.characterName.toLowerCase().replace(/\s+/g, '_')}_${selectedGame}_card`
: `${selectedGame}_card`;
downloadCardFromBase64(generatedCard.imageBase64, filename);
toast({
title: 'Download Started',
description: `Your ${gameConfig.name} 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 onSubmit = async (data: CardGeneratorForm) => {
if (!user) {
setError('You must be logged in to generate cards');
return;
}
setIsGenerating(true);
setError('');
setGeneratedCard(null);
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) {
setGeneratedCard({
imageBase64: result.imageBase64,
prompt: result.prompt,
});
onCardGenerated?.(result.imageBase64, result.prompt, data as CardGenerationParams);
toast({
title: 'Card Generated!',
description: `Your ${gameConfig.name} card has been generated successfully.`,
});
}
} catch (err) {
console.error('Error generating card:', err);
setError('Failed to generate card. Please try again.');
} finally {
setIsGenerating(false);
}
};
const renderGameSpecificStats = () => {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Game Stats (Optional)</h3>
<div className="grid grid-cols-2 gap-4">
{gameConfig.statFields.map((field) => {
if (field.type === 'boolean') {
return (
<div key={field.name} className="flex items-center justify-between col-span-2">
<div className="space-y-0.5">
<Label>{field.label}</Label>
</div>
<Switch
checked={watchedValues[field.name as keyof CardGeneratorForm] as boolean}
onCheckedChange={(checked) => setValue(field.name as any, checked)}
/>
</div>
);
}
return (
<div key={field.name}>
<Label htmlFor={field.name}>{field.label}</Label>
<Input
id={field.name}
type={field.type}
{...register(field.name as any, {
valueAsNumber: field.type === 'number'
})}
placeholder={field.placeholder}
min={field.min}
max={field.max}
/>
</div>
);
})}
</div>
</div>
);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Form Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Create Your Trading Card
<Badge variant="outline">{gameConfig.name}</Badge>
</CardTitle>
<CardDescription>
Generate a custom TCG card using AI
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Game Selection */}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Select Game</h3>
<div>
<Label htmlFor="game">Trading Card Game</Label>
<Select
value={selectedGame || 'pokemon'}
onValueChange={(value) => handleGameChange(value as TCGGame)}
>
<SelectTrigger>
<SelectValue placeholder="Select game" />
</SelectTrigger>
<SelectContent>
{Object.values(TCG_GAMES).map((game) => (
<SelectItem key={game.id} value={game.id}>
{game.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.game && (
<p className="text-sm text-red-500 mt-1">{errors.game.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="characterName">Character Name</Label>
<Input
id="characterName"
{...register('characterName')}
placeholder={`e.g., ${selectedGame === 'pokemon' ? 'Pikachu' : selectedGame === 'onepiece' ? 'Luffy' : selectedGame === 'lorcana' ? 'Mickey Mouse' : selectedGame === 'magic' ? 'Jace Beleren' : 'Goku'}`}
/>
{errors.characterName && (
<p className="text-sm text-red-500 mt-1">{errors.characterName.message}</p>
)}
</div>
<div>
<Label htmlFor="characterType">Type/Color</Label>
<Select
value={watchedValues.characterType || ''}
onValueChange={(value) => setValue('characterType', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{gameConfig.types.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.characterType && (
<p className="text-sm text-red-500 mt-1">{errors.characterType.message}</p>
)}
</div>
</div>
<div>
<Label htmlFor="language">Card Language</Label>
<Select
value={watchedValues.language || 'english'}
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>
<Label htmlFor="model">AI Model</Label>
<Select
value={watchedValues.model || 'imagen-4.0-ultra-generate-001'}
onValueChange={(value: any) => setValue('model', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.value} value={model.value}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.model && (
<p className="text-sm text-red-500 mt-1">{errors.model.message}</p>
)}
</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>Special/Full Art</Label>
<p className="text-sm text-muted-foreground">Premium full-art layout</p>
</div>
<Switch
checked={watchedValues.isSpecialArt}
onCheckedChange={(checked) => setValue('isSpecialArt', 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="characterDescription">Character Description</Label>
<Textarea
id="characterDescription"
{...register('characterDescription')}
placeholder="Describe the character's appearance, pose, and action..."
className="min-h-[100px]"
/>
{errors.characterDescription && (
<p className="text-sm text-red-500 mt-1">{errors.characterDescription.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-specific stats */}
{renderGameSpecificStats()}
<Button
type="submit"
className="w-full"
disabled={isGenerating}
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating Card...
</>
) : (
`Generate ${gameConfig.name} 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>
{/* Preview Section */}
<Card>
<CardHeader>
<CardTitle>Generated Card Preview</CardTitle>
<CardDescription>
Your {gameConfig.name} 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 ${gameConfig.name} Card`}
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">
<Upload className="h-12 w-12 text-gray-400 mb-4" />
<p className="text-muted-foreground text-center">
{isGenerating
? `Generating your ${gameConfig.name} card...`
: 'Fill out the form and click generate to create your card'
}
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }
-365
View File
@@ -1,365 +0,0 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
-256
View File
@@ -1,256 +0,0 @@
"use client"
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup {...props} />
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}
-28
View File
@@ -1,28 +0,0 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
-117
View File
@@ -1,117 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+141
View File
@@ -0,0 +1,141 @@
import type { TCGGame } from '@/types';
export interface TCGGameConfig {
id: TCGGame;
name: string;
types: string[];
rarities: string[];
defaultStats: Record<string, any>;
statFields: {
name: string;
label: string;
type: 'number' | 'string' | 'boolean';
min?: number;
max?: number;
placeholder?: string;
required?: boolean;
}[];
}
export const TCG_GAMES: Record<TCGGame, TCGGameConfig> = {
pokemon: {
id: 'pokemon',
name: 'Pokémon TCG',
types: [
'Normal', 'Fire', 'Water', 'Electric', 'Grass', 'Ice', 'Fighting', 'Poison',
'Ground', 'Flying', 'Psychic', 'Bug', 'Rock', 'Ghost', 'Dragon', 'Dark',
'Steel', 'Fairy'
],
rarities: ['Common', 'Uncommon', 'Rare', 'Holo Rare', 'Ultra Rare', 'Secret Rare'],
defaultStats: {
hp: 130,
attackName1: 'Quick Attack',
attackDamage1: 60,
attackName2: 'Special Move',
attackDamage2: 90,
weakness: 'Fighting',
resistance: 'Psychic',
retreatCost: 2,
},
statFields: [
{ name: 'hp', label: 'HP', type: 'number', min: 10, max: 999, placeholder: '130', required: false },
{ name: 'attackName1', label: 'Attack 1 Name', type: 'string', placeholder: 'Quick Attack', required: false },
{ name: 'attackDamage1', label: 'Attack 1 Damage', type: 'number', min: 0, max: 999, placeholder: '60', required: false },
{ name: 'attackName2', label: 'Attack 2 Name', type: 'string', placeholder: 'Special Move', required: false },
{ name: 'attackDamage2', label: 'Attack 2 Damage', type: 'number', min: 0, max: 999, placeholder: '90', required: false },
{ name: 'weakness', label: 'Weakness', type: 'string', placeholder: 'Fighting', required: false },
{ name: 'resistance', label: 'Resistance', type: 'string', placeholder: 'Psychic', required: false },
{ name: 'retreatCost', label: 'Retreat Cost', type: 'number', min: 0, max: 5, placeholder: '2', required: false },
]
},
onepiece: {
id: 'onepiece',
name: 'One Piece Card Game',
types: ['Red', 'Green', 'Blue', 'Purple', 'Black', 'Yellow'],
rarities: ['Common', 'Uncommon', 'Rare', 'Super Rare', 'Secret Rare', 'Leader'],
defaultStats: {
power: 5000,
cost: 4,
counter: 1000,
color: 'Red',
},
statFields: [
{ name: 'power', label: 'Power', type: 'number', min: 0, max: 12000, placeholder: '5000', required: false },
{ name: 'cost', label: 'Cost', type: 'number', min: 0, max: 10, placeholder: '4', required: false },
{ name: 'counter', label: 'Counter', type: 'number', min: 0, max: 2000, placeholder: '1000', required: false },
{ name: 'lifePoints', label: 'Life Points (Leader)', type: 'number', min: 0, max: 5, placeholder: '4', required: false },
]
},
lorcana: {
id: 'lorcana',
name: 'Disney Lorcana',
types: ['Amber', 'Amethyst', 'Emerald', 'Ruby', 'Sapphire', 'Steel'],
rarities: ['Common', 'Uncommon', 'Rare', 'Super Rare', 'Legendary', 'Enchanted'],
defaultStats: {
inkCost: 3,
strength: 2,
willpower: 3,
lore: 2,
inkable: true,
},
statFields: [
{ name: 'inkCost', label: 'Ink Cost', type: 'number', min: 0, max: 10, placeholder: '3', required: false },
{ name: 'strength', label: 'Strength', type: 'number', min: 0, max: 10, placeholder: '2', required: false },
{ name: 'willpower', label: 'Willpower', type: 'number', min: 0, max: 10, placeholder: '3', required: false },
{ name: 'lore', label: 'Lore', type: 'number', min: 0, max: 5, placeholder: '2', required: false },
{ name: 'inkable', label: 'Inkable', type: 'boolean', required: false },
]
},
magic: {
id: 'magic',
name: 'Magic: The Gathering',
types: ['White', 'Blue', 'Black', 'Red', 'Green', 'Colorless', 'Multicolor'],
rarities: ['Common', 'Uncommon', 'Rare', 'Mythic Rare'],
defaultStats: {
manaCost: '{2}{G}',
cardType: 'Creature',
subType: 'Elf Warrior',
powerToughness: '3/3',
},
statFields: [
{ name: 'manaCost', label: 'Mana Cost', type: 'string', placeholder: '{2}{G}', required: false },
{ name: 'cardType', label: 'Card Type', type: 'string', placeholder: 'Creature', required: false },
{ name: 'subType', label: 'Subtype', type: 'string', placeholder: 'Elf Warrior', required: false },
{ name: 'powerToughness', label: 'Power/Toughness', type: 'string', placeholder: '3/3', required: false },
]
},
dragonball: {
id: 'dragonball',
name: 'Dragon Ball Super Card Game',
types: ['Red', 'Blue', 'Green', 'Yellow'],
rarities: ['Common', 'Uncommon', 'Rare', 'Super Rare', 'Special Rare', 'Secret Rare'],
defaultStats: {
combatPower: 20000,
comboCost: 1,
comboEnergy: 5000,
era: 'Universe Survival Saga',
},
statFields: [
{ name: 'combatPower', label: 'Combat Power', type: 'number', min: 0, max: 50000, placeholder: '20000', required: false },
{ name: 'comboCost', label: 'Combo Cost', type: 'number', min: 0, max: 5, placeholder: '1', required: false },
{ name: 'comboEnergy', label: 'Combo Energy', type: 'number', min: 0, max: 10000, placeholder: '5000', required: false },
{ name: 'era', label: 'Era', type: 'string', placeholder: 'Universe Survival Saga', required: false },
]
},
};
export const getGameConfig = (game: TCGGame): TCGGameConfig => {
return TCG_GAMES[game];
};
export const getGameTypes = (game: TCGGame): string[] => {
return TCG_GAMES[game].types;
};
export const getGameRarities = (game: TCGGame): string[] => {
return TCG_GAMES[game].rarities;
};
export const getDefaultStats = (game: TCGGame): Record<string, any> => {
return TCG_GAMES[game].defaultStats;
};
+5 -1
View File
@@ -126,6 +126,7 @@ export const addCardToCollection = async (
console.log("DEBUG: Attempting to add card with userId:", userId);
console.log("DEBUG: Current auth user:", auth.currentUser?.uid);
console.log("DEBUG: Auth user matches:", auth.currentUser?.uid === userId);
console.log("DEBUG: Card game:", cardData.game || 'pokemon'); // Default to pokemon for backward compatibility
try {
// First, upload the image to Firebase Storage
@@ -136,6 +137,7 @@ export const addCardToCollection = async (
const { imageDataUrl, ...cardDataWithoutImage } = cardData;
const docPayload = {
...cardDataWithoutImage,
game: cardData.game || 'pokemon', // Default to pokemon for backward compatibility
imageUrl, // Store the Firebase Storage URL instead of base64 data
userId,
// Don't initialize video generation automatically - make it opt-in
@@ -149,7 +151,9 @@ export const addCardToCollection = async (
"Payload keys:",
Object.keys(docPayload).join(', '),
"Image URL:",
imageUrl
imageUrl,
"Game:",
docPayload.game
);
const docRef = await addDoc(collectionRef, docPayload);
+63 -7
View File
@@ -1,5 +1,8 @@
import type { Timestamp } from 'firebase/firestore';
// Supported TCG games
export type TCGGame = 'pokemon' | 'onepiece' | 'lorcana' | 'magic' | 'dragonball';
export interface PokemonCard {
id: string; // Firestore document ID
userId: string;
@@ -9,6 +12,7 @@ export interface PokemonCard {
imageUrl: string; // Firebase Storage URL of the card image (NOT raw image data)
createdAt: Timestamp;
updatedAt: Timestamp;
game: TCGGame; // The TCG game this card belongs to
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)
@@ -28,14 +32,19 @@ export interface ScannedCardData {
// For Pokemon card generation parameters
export interface CardGenerationParams {
pokemonName: string;
pokemonType: string;
isIllustrationRare: boolean;
isHolo: boolean;
game: TCGGame;
// Common fields across all games
characterName: string; // Name of the character/creature
characterType: string; // Type/Element/Color of the card
isSpecialArt: boolean; // Special/Full Art variant
isHolo: boolean; // Holographic effect
backgroundDescription: string;
pokemonDescription: string;
characterDescription: string;
language: 'english' | 'japanese' | 'chinese' | 'korean' | 'spanish' | 'french' | 'german' | 'italian';
model: 'imagen-4.0-ultra-generate-001' | 'imagen-4.0-generate-001' | 'imagen-4.0-fast-generate-001' | 'gemini-2.5-flash-image' | 'gemini-3-pro-image-preview';
// Game-specific fields (optional, used based on game type)
// Pokemon
hp?: number;
attackName1?: string;
attackDamage1?: number;
@@ -44,6 +53,32 @@ export interface CardGenerationParams {
weakness?: string;
resistance?: string;
retreatCost?: number;
// One Piece
power?: number;
cost?: number;
counter?: number;
color?: string;
lifePoints?: number;
// Lorcana
inkCost?: number;
strength?: number;
willpower?: number;
lore?: number;
inkable?: boolean;
// Magic: The Gathering
manaCost?: string;
cardType?: string; // Creature, Instant, Sorcery, etc.
subType?: string;
powerToughness?: string; // e.g., "3/3"
// Dragon Ball
combatPower?: number;
comboCost?: number;
comboEnergy?: number;
era?: string;
}
export interface GeneratedCard {
@@ -69,11 +104,14 @@ export interface UserApiKeys {
// For Pokemon card generation from photo parameters
export interface PhotoCardGenerationParams {
game: TCGGame;
photoDataUri?: string; // Optional - not saved to Firestore (too large), only used during generation
pokemonName: string;
pokemonType: string;
characterName: string;
characterType: string;
styleDescription: string;
language: 'english' | 'japanese' | 'chinese' | 'korean' | 'spanish' | 'french' | 'german' | 'italian';
// Game-specific optional stats (similar to CardGenerationParams)
hp?: number;
attackName1?: string;
attackDamage1?: number;
@@ -82,4 +120,22 @@ export interface PhotoCardGenerationParams {
weakness?: string;
resistance?: string;
retreatCost?: number;
power?: number;
cost?: number;
counter?: number;
color?: string;
lifePoints?: number;
inkCost?: number;
strength?: number;
willpower?: number;
lore?: number;
inkable?: boolean;
manaCost?: string;
cardType?: string;
subType?: string;
powerToughness?: string;
combatPower?: number;
comboCost?: number;
comboEnergy?: number;
era?: string;
}