mirror of
https://github.com/xavidop/cardex.git
synced 2026-08-29 09:18:51 +00:00
feat: refactors
This commit is contained in:
@@ -15,6 +15,13 @@ import { getUserApiKeys } from '@/lib/firestore';
|
||||
import { getGameConfig } from '@/config/tcg-games';
|
||||
import type { TCGGame } from '@/types';
|
||||
import { tcgGameSchema, languageSchema } from '@/constants';
|
||||
import {
|
||||
generatePokemonPrompt,
|
||||
generateOnePiecePrompt,
|
||||
generateLorcanaPrompt,
|
||||
generateMagicPrompt,
|
||||
generateDragonBallPrompt,
|
||||
} from '@/ai/prompts/game-prompts';
|
||||
|
||||
const GenerateFromPhotoInputSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
@@ -147,19 +154,59 @@ function generatePhotoBasedCardPrompt(params: GenerateFromPhotoInput): string {
|
||||
|
||||
switch (game) {
|
||||
case 'pokemon':
|
||||
gameSpecificPrompt = generatePokemonPhotoPrompt(params);
|
||||
gameSpecificPrompt = generatePokemonPrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
hp: params.hp,
|
||||
attackName1: params.attackName1,
|
||||
attackDamage1: params.attackDamage1,
|
||||
attackName2: params.attackName2,
|
||||
attackDamage2: params.attackDamage2,
|
||||
weakness: params.weakness,
|
||||
resistance: params.resistance,
|
||||
retreatCost: params.retreatCost,
|
||||
language: params.language,
|
||||
});
|
||||
break;
|
||||
case 'onepiece':
|
||||
gameSpecificPrompt = generateOnePiecePhotoPrompt(params);
|
||||
gameSpecificPrompt = generateOnePiecePrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
power: params.power,
|
||||
cost: params.cost,
|
||||
counter: params.counter,
|
||||
});
|
||||
break;
|
||||
case 'lorcana':
|
||||
gameSpecificPrompt = generateLorcanaPhotoPrompt(params);
|
||||
gameSpecificPrompt = generateLorcanaPrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
inkCost: params.inkCost,
|
||||
strength: params.strength,
|
||||
willpower: params.willpower,
|
||||
lore: params.lore,
|
||||
inkable: params.inkable,
|
||||
});
|
||||
break;
|
||||
case 'magic':
|
||||
gameSpecificPrompt = generateMagicPhotoPrompt(params);
|
||||
gameSpecificPrompt = generateMagicPrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
manaCost: params.manaCost,
|
||||
cardType: params.cardType,
|
||||
subType: params.subType,
|
||||
powerToughness: params.powerToughness,
|
||||
});
|
||||
break;
|
||||
case 'dragonball':
|
||||
gameSpecificPrompt = generateDragonBallPhotoPrompt(params);
|
||||
gameSpecificPrompt = generateDragonBallPrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
combatPower: params.combatPower,
|
||||
comboCost: params.comboCost,
|
||||
comboEnergy: params.comboEnergy,
|
||||
era: params.era,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -175,43 +222,3 @@ ${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}.`;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ import { getUserApiKeys } from '@/lib/firestore';
|
||||
import type { TCGGame } from '@/types';
|
||||
import { getGameConfig } from '@/config/tcg-games';
|
||||
import { tcgGameSchema, languageSchema, aiModelSchema } from '@/constants';
|
||||
import {
|
||||
generatePokemonPrompt as sharedPokemonPrompt,
|
||||
generateOnePiecePrompt as sharedOnePiecePrompt,
|
||||
generateLorcanaPrompt as sharedLorcanaPrompt,
|
||||
generateMagicPrompt as sharedMagicPrompt,
|
||||
generateDragonBallPrompt as sharedDragonBallPrompt,
|
||||
} from '@/ai/prompts/game-prompts';
|
||||
|
||||
const GenerateTCGCardInputSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
@@ -200,19 +207,60 @@ function generateCardPrompt(params: GenerateTCGCardInput): string {
|
||||
|
||||
switch (game) {
|
||||
case 'pokemon':
|
||||
gameSpecificPrompt = generatePokemonPrompt(params);
|
||||
gameSpecificPrompt = sharedPokemonPrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
hp: params.hp,
|
||||
attackName1: params.attackName1,
|
||||
attackDamage1: params.attackDamage1,
|
||||
attackName2: params.attackName2,
|
||||
attackDamage2: params.attackDamage2,
|
||||
weakness: params.weakness,
|
||||
resistance: params.resistance,
|
||||
retreatCost: params.retreatCost,
|
||||
language: params.language,
|
||||
});
|
||||
break;
|
||||
case 'onepiece':
|
||||
gameSpecificPrompt = generateOnePiecePrompt(params);
|
||||
gameSpecificPrompt = sharedOnePiecePrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
power: params.power,
|
||||
cost: params.cost,
|
||||
counter: params.counter,
|
||||
lifePoints: params.lifePoints,
|
||||
});
|
||||
break;
|
||||
case 'lorcana':
|
||||
gameSpecificPrompt = generateLorcanaPrompt(params);
|
||||
gameSpecificPrompt = sharedLorcanaPrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
inkCost: params.inkCost,
|
||||
strength: params.strength,
|
||||
willpower: params.willpower,
|
||||
lore: params.lore,
|
||||
inkable: params.inkable,
|
||||
});
|
||||
break;
|
||||
case 'magic':
|
||||
gameSpecificPrompt = generateMagicPrompt(params);
|
||||
gameSpecificPrompt = sharedMagicPrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
manaCost: params.manaCost,
|
||||
cardType: params.cardType,
|
||||
subType: params.subType,
|
||||
powerToughness: params.powerToughness,
|
||||
});
|
||||
break;
|
||||
case 'dragonball':
|
||||
gameSpecificPrompt = generateDragonBallPrompt(params);
|
||||
gameSpecificPrompt = sharedDragonBallPrompt({
|
||||
characterName: params.characterName,
|
||||
characterType: params.characterType,
|
||||
combatPower: params.combatPower,
|
||||
comboCost: params.comboCost,
|
||||
comboEnergy: params.comboEnergy,
|
||||
era: params.era,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -226,95 +274,3 @@ The overall style should match official ${gameName} card design with proper font
|
||||
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.`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Shared game-specific prompt generators for TCG card generation.
|
||||
* Used by both standard card generation and photo-based card generation.
|
||||
*/
|
||||
|
||||
interface BaseCardParams {
|
||||
characterName: string;
|
||||
characterType: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
interface PokemonParams extends BaseCardParams {
|
||||
hp?: number;
|
||||
attackName1?: string;
|
||||
attackDamage1?: number;
|
||||
attackName2?: string;
|
||||
attackDamage2?: number;
|
||||
weakness?: string;
|
||||
resistance?: string;
|
||||
retreatCost?: number;
|
||||
}
|
||||
|
||||
interface OnePieceParams extends BaseCardParams {
|
||||
power?: number;
|
||||
cost?: number;
|
||||
counter?: number;
|
||||
lifePoints?: number;
|
||||
}
|
||||
|
||||
interface LorcanaParams extends BaseCardParams {
|
||||
inkCost?: number;
|
||||
strength?: number;
|
||||
willpower?: number;
|
||||
lore?: number;
|
||||
inkable?: boolean;
|
||||
}
|
||||
|
||||
interface MagicParams extends BaseCardParams {
|
||||
manaCost?: string;
|
||||
cardType?: string;
|
||||
subType?: string;
|
||||
powerToughness?: string;
|
||||
}
|
||||
|
||||
interface DragonBallParams extends BaseCardParams {
|
||||
combatPower?: number;
|
||||
comboCost?: number;
|
||||
comboEnergy?: number;
|
||||
era?: string;
|
||||
}
|
||||
|
||||
export function generatePokemonPrompt(params: PokemonParams): 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 `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.`;
|
||||
}
|
||||
|
||||
export function generateOnePiecePrompt(params: OnePieceParams): 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.`;
|
||||
}
|
||||
|
||||
export function generateLorcanaPrompt(params: LorcanaParams): 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.`;
|
||||
}
|
||||
|
||||
export function generateMagicPrompt(params: MagicParams): 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.`;
|
||||
}
|
||||
|
||||
export function generateDragonBallPrompt(params: DragonBallParams): 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.`;
|
||||
}
|
||||
@@ -1,5 +1,22 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ERROR_MESSAGES, CACHE_CONTROL } from '@/constants';
|
||||
|
||||
/**
|
||||
* Validates if a URL is from Firebase Storage
|
||||
* @param url - URL to validate
|
||||
* @returns true if URL is from Firebase Storage (production or emulator)
|
||||
*/
|
||||
function isValidFirebaseStorageUrl(url: string): boolean {
|
||||
return url.includes('firebasestorage.googleapis.com') ||
|
||||
url.includes('localhost:9199') ||
|
||||
url.includes('127.0.0.1:9199');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/download
|
||||
* Proxy endpoint to download files from Firebase Storage to avoid CORS issues
|
||||
* Only allows Firebase Storage URLs for security
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
@@ -11,11 +28,8 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that the URL is from Firebase Storage (production or emulator)
|
||||
const isFirebaseStorage = url.includes('firebasestorage.googleapis.com');
|
||||
const isFirebaseEmulator = url.includes('localhost:9199') || url.includes('127.0.0.1:9199');
|
||||
|
||||
if (!isFirebaseStorage && !isFirebaseEmulator) {
|
||||
// Validate URL is from Firebase Storage
|
||||
if (!isValidFirebaseStorageUrl(url)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only Firebase Storage URLs are allowed' },
|
||||
{ status: 403 }
|
||||
@@ -33,7 +47,6 @@ export async function GET(request: NextRequest) {
|
||||
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Get the content type from the original response
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||||
|
||||
// Stream the response
|
||||
@@ -41,7 +54,7 @@ export async function GET(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Disposition': 'attachment',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Cache-Control': CACHE_CONTROL.ONE_YEAR,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
@@ -49,8 +62,13 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Download proxy error:', error);
|
||||
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: ERROR_MESSAGES.IMAGE_DOWNLOAD_FAILED;
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to download file' },
|
||||
{ error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,23 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { generateTCGCardFromPhoto } from '@/ai/flows/generate-tcg-card-from-photo';
|
||||
import { handleApiRequest } from '@/lib/api-handler';
|
||||
|
||||
/**
|
||||
* Required fields for photo-based card generation
|
||||
*/
|
||||
const REQUIRED_FIELDS = [
|
||||
'userId',
|
||||
'photoDataUri',
|
||||
'characterName',
|
||||
'characterType',
|
||||
'styleDescription',
|
||||
'game'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* POST /api/generate-card-from-photo
|
||||
* Generates a TCG card from a photo using AI
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate required fields - including userId
|
||||
const requiredFields = ['userId', 'photoDataUri', 'characterName', 'characterType', 'styleDescription', 'game'];
|
||||
const missingFields = requiredFields.filter(field => !body[field]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Missing required fields: ${missingFields.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Call the AI flow
|
||||
const result = await generateTCGCardFromPhoto(body);
|
||||
|
||||
if (result.error) {
|
||||
return NextResponse.json({ error: result.error }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('API route error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return handleApiRequest(request, REQUIRED_FIELDS, generateTCGCardFromPhoto);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { generateTCGCard } from '@/ai/flows/generate-tcg-card';
|
||||
import { handleApiRequest } from '@/lib/api-handler';
|
||||
|
||||
/**
|
||||
* Required fields for card generation
|
||||
*/
|
||||
const REQUIRED_FIELDS = [
|
||||
'userId',
|
||||
'game',
|
||||
'characterName',
|
||||
'characterType',
|
||||
'backgroundDescription',
|
||||
'characterDescription'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* POST /api/generate-card
|
||||
* Generates a TCG card using AI based on provided parameters
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// New multi-game flow
|
||||
const requiredFields = ['userId', 'game', 'characterName', 'characterType', 'backgroundDescription', 'characterDescription'];
|
||||
const missingFields = requiredFields.filter(field => !body[field]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Missing required fields: ${missingFields.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return handleApiRequest(request, REQUIRED_FIELDS, generateTCGCard);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { generateVideoForExistingCard } from '@/lib/firestore';
|
||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '@/constants';
|
||||
|
||||
/**
|
||||
* POST /api/generate-video
|
||||
* Starts video generation for an existing card
|
||||
* This is a background process - the video generation happens asynchronously
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId, cardId } = await request.json();
|
||||
|
||||
if (!userId || !cardId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'User ID and Card ID are required' },
|
||||
{ error: `${ERROR_MESSAGES.USER_ID_REQUIRED} and ${ERROR_MESSAGES.CARD_ID_REQUIRED}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -17,13 +23,15 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Video generation started successfully'
|
||||
message: SUCCESS_MESSAGES.VIDEO_GENERATED
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in video generation API:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Internal server error';
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: ERROR_MESSAGES.VIDEO_GENERATION_FAILED;
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
|
||||
@@ -226,7 +226,7 @@ export default function EditCardPage() {
|
||||
isSubmitting={isSubmitting}
|
||||
submitButtonText="Save Changes"
|
||||
formTitle="Update Card Information"
|
||||
formDescription="Modify the details of your Pokémon card."
|
||||
formDescription="Modify the details of your TCG card."
|
||||
onCancel={() => router.push('/dashboard/collection')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -216,7 +216,7 @@ export default function CollectionPage() {
|
||||
<div className="text-center py-10 border-2 border-dashed border-border rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-archive-restore mx-auto mb-4 text-muted-foreground"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-V8"/><path d="M9.5 12.5 12 15l2.5-2.5"/><path d="M12 15V9"/></svg>
|
||||
<h2 className="text-xl font-semibold text-muted-foreground">Your collection is empty.</h2>
|
||||
<p className="text-muted-foreground mt-2">Start by scanning or generating your first Pokémon card!</p>
|
||||
<p className="text-muted-foreground mt-2">Start by scanning or generating your first TCG card!</p>
|
||||
<div className="flex gap-2 justify-center mt-6">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/dashboard/generate">
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ import Navbar from '@/components/layout/Navbar';
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Cardex - Pokémon Card Scanner',
|
||||
description: 'Scan, identify, and manage your Pokémon card collection with Cardex.',
|
||||
title: 'Cardex - TCG Card Scanner',
|
||||
description: 'Scan, identify, and manage your TCG card collection with Cardex.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
+14
-72
@@ -1,80 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import LoginForm from '@/components/auth/LoginForm';
|
||||
import GoogleSignInButton from '@/components/auth/GoogleSignInButton';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import Link from 'next/link';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { AuthPageWrapper } from '@/components/auth/AuthPageWrapper';
|
||||
import { AuthPageLayout } from '@/components/auth/AuthPageLayout';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && user) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, authLoading, router]);
|
||||
|
||||
if (authLoading || (!authLoading && user)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground font-headline">
|
||||
Sign in to Cardex
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Access your Pokémon card collection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<Card className="shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
||||
<CardDescription>Enter your credentials to continue.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LoginForm />
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-card px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<GoogleSignInButton />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" legacyBehavior>
|
||||
<a className="font-medium text-primary hover:text-primary/80">
|
||||
Sign up
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<AuthPageWrapper>
|
||||
<AuthPageLayout
|
||||
title="Sign in to Cardex"
|
||||
subtitle="Access your TCG card collection."
|
||||
cardTitle="Welcome back"
|
||||
cardDescription="Enter your credentials to continue."
|
||||
form={<LoginForm />}
|
||||
footerText="Don't have an account?"
|
||||
footerLinkText="Sign up"
|
||||
footerLinkHref="/signup"
|
||||
/>
|
||||
</AuthPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
+15
-72
@@ -1,80 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import SignupForm from '@/components/auth/SignupForm';
|
||||
import GoogleSignInButton from '@/components/auth/GoogleSignInButton';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import Link from 'next/link';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { AuthPageWrapper } from '@/components/auth/AuthPageWrapper';
|
||||
import { AuthPageLayout } from '@/components/auth/AuthPageLayout';
|
||||
|
||||
export default function SignupPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && user) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, authLoading, router]);
|
||||
|
||||
if (authLoading || (!authLoading && user)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground font-headline">
|
||||
Create your Cardex account
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Start managing your Pokémon card collection today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<Card className="shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Get Started</CardTitle>
|
||||
<CardDescription>It's quick and easy.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SignupForm />
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-card px-2 text-muted-foreground">
|
||||
Or sign up with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<GoogleSignInButton />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Already have an account?{' '}
|
||||
<Link href="/login" legacyBehavior>
|
||||
<a className="font-medium text-primary hover:text-primary/80">
|
||||
Sign in
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<AuthPageWrapper>
|
||||
<AuthPageLayout
|
||||
title="Create your Cardex account"
|
||||
subtitle="Start managing your TCG card collection today."
|
||||
cardTitle="Get Started"
|
||||
cardDescription="It's quick and easy."
|
||||
form={<SignupForm />}
|
||||
footerText="Already have an account?"
|
||||
footerLinkText="Sign in"
|
||||
footerLinkHref="/login"
|
||||
googleButtonText="Or sign up with"
|
||||
/>
|
||||
</AuthPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import GoogleSignInButton from '@/components/auth/GoogleSignInButton';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface AuthPageLayoutProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
cardTitle: string;
|
||||
cardDescription: string;
|
||||
form: React.ReactNode;
|
||||
footerText: string;
|
||||
footerLinkText: string;
|
||||
footerLinkHref: string;
|
||||
googleButtonText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared layout component for authentication pages (login/signup).
|
||||
* Provides consistent structure with header, form, Google sign-in, and footer.
|
||||
*/
|
||||
export function AuthPageLayout({
|
||||
title,
|
||||
subtitle,
|
||||
cardTitle,
|
||||
cardDescription,
|
||||
form,
|
||||
footerText,
|
||||
footerLinkText,
|
||||
footerLinkHref,
|
||||
googleButtonText = 'Or continue with',
|
||||
}: AuthPageLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-foreground font-headline">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<Card className="shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">{cardTitle}</CardTitle>
|
||||
<CardDescription>{cardDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{form}
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-card px-2 text-muted-foreground">
|
||||
{googleButtonText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<GoogleSignInButton />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{footerText}{' '}
|
||||
<Link href={footerLinkHref} legacyBehavior>
|
||||
<a className="font-medium text-primary hover:text-primary/80">
|
||||
{footerLinkText}
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface AuthPageWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component for authentication pages (login/signup).
|
||||
* Handles automatic redirect to dashboard if user is already authenticated.
|
||||
* Shows loading spinner during authentication check.
|
||||
*/
|
||||
export function AuthPageWrapper({ children }: AuthPageWrapperProps) {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && user) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [user, authLoading, router]);
|
||||
|
||||
if (authLoading || (!authLoading && user)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export default function CardForm({
|
||||
isSubmitting,
|
||||
submitButtonText = 'Save Card',
|
||||
formTitle = 'Card Details',
|
||||
formDescription = 'Fill in the details of the Pokémon card.',
|
||||
formDescription = 'Fill in the details of the TCG card.',
|
||||
onCancel,
|
||||
}: CardFormProps) {
|
||||
const {
|
||||
@@ -77,11 +77,11 @@ export default function CardForm({
|
||||
<div className="mb-4 rounded-md overflow-hidden border border-border aspect-[63/88] max-w-xs mx-auto bg-muted">
|
||||
<Image
|
||||
src={currentImageDataUrl}
|
||||
alt={watch('name') || 'Pokémon Card'}
|
||||
alt={watch('name') || 'TCG Card'}
|
||||
width={300}
|
||||
height={420}
|
||||
className="object-contain w-full h-full"
|
||||
data-ai-hint="pokemon card"
|
||||
data-ai-hint="tcg card"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,17 +4,27 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================================================
|
||||
// TCG Game Types
|
||||
// ============================================================================
|
||||
export const TCG_GAME_VALUES = ['pokemon', 'onepiece', 'lorcana', 'magic', 'dragonball'] as const;
|
||||
export type TCGGame = typeof TCG_GAME_VALUES[number];
|
||||
|
||||
/** Default TCG game when not specified */
|
||||
export const DEFAULT_TCG_GAME: TCGGame = 'pokemon';
|
||||
|
||||
// Zod schema for TCG games
|
||||
export const tcgGameSchema = z.enum(TCG_GAME_VALUES);
|
||||
|
||||
// ============================================================================
|
||||
// Language Types
|
||||
// ============================================================================
|
||||
export const LANGUAGE_VALUES = ['english', 'japanese', 'chinese', 'korean', 'spanish', 'french', 'german', 'italian'] as const;
|
||||
export type Language = typeof LANGUAGE_VALUES[number];
|
||||
|
||||
/** Default language for card generation */
|
||||
export const DEFAULT_LANGUAGE: Language = 'english';
|
||||
|
||||
// Zod schema for languages
|
||||
export const languageSchema = z.enum(LANGUAGE_VALUES);
|
||||
|
||||
@@ -30,7 +40,9 @@ export const LANGUAGES = [
|
||||
{ value: 'italian', label: 'Italian' },
|
||||
] as const;
|
||||
|
||||
// ============================================================================
|
||||
// AI Model Types
|
||||
// ============================================================================
|
||||
export const AI_MODEL_VALUES = [
|
||||
'imagen-4.0-ultra-generate-001',
|
||||
'imagen-4.0-generate-001',
|
||||
@@ -40,6 +52,9 @@ export const AI_MODEL_VALUES = [
|
||||
] as const;
|
||||
export type AIModel = typeof AI_MODEL_VALUES[number];
|
||||
|
||||
/** Default AI model for image generation */
|
||||
export const DEFAULT_AI_MODEL: AIModel = 'imagen-4.0-generate-001';
|
||||
|
||||
// Zod schema for AI models
|
||||
export const aiModelSchema = z.enum(AI_MODEL_VALUES);
|
||||
|
||||
@@ -51,3 +66,125 @@ export const AI_MODELS = [
|
||||
{ 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 (Nano Banana Pro)' },
|
||||
] as const;
|
||||
|
||||
// ============================================================================
|
||||
// Video Generation Status
|
||||
// ============================================================================
|
||||
export const VIDEO_STATUS_VALUES = ['pending', 'generating', 'completed', 'failed'] as const;
|
||||
export type VideoGenerationStatus = typeof VIDEO_STATUS_VALUES[number];
|
||||
|
||||
export const videoStatusSchema = z.enum(VIDEO_STATUS_VALUES);
|
||||
|
||||
// ============================================================================
|
||||
// Firestore Collections
|
||||
// ============================================================================
|
||||
export const FIRESTORE_COLLECTIONS = {
|
||||
USERS: 'users',
|
||||
POKEMON_CARDS: 'pokemon_cards',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Firebase Storage Paths
|
||||
// ============================================================================
|
||||
export const STORAGE_PATHS = {
|
||||
USER_CARDS: (userId: string) => `users/${userId}/cards`,
|
||||
USER_VIDEOS: (userId: string) => `users/${userId}/videos`,
|
||||
SHARED_CARDS: 'cards',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// File Size Limits (in bytes)
|
||||
// ============================================================================
|
||||
export const FILE_SIZE_LIMITS = {
|
||||
/** Maximum image size: 10MB */
|
||||
IMAGE_MAX: 10 * 1024 * 1024,
|
||||
/** Maximum video size: 100MB */
|
||||
VIDEO_MAX: 100 * 1024 * 1024,
|
||||
/** Firestore document max: 1MB */
|
||||
FIRESTORE_DOCUMENT_MAX: 1024 * 1024,
|
||||
/** Maximum URL length for Firestore */
|
||||
URL_MAX_LENGTH: 2000,
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Image Processing Constants
|
||||
// ============================================================================
|
||||
export const IMAGE_DEFAULTS = {
|
||||
/** Default max width for image compression */
|
||||
MAX_WIDTH: 512,
|
||||
/** Default max height (Pokemon card aspect ratio) */
|
||||
MAX_HEIGHT: 712,
|
||||
/** Default JPEG quality (0-1) */
|
||||
QUALITY: 0.8,
|
||||
/** Default image format */
|
||||
FORMAT: 'image/png' as const,
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Cache Control
|
||||
// ============================================================================
|
||||
export const CACHE_CONTROL = {
|
||||
/** 1 year cache for static assets */
|
||||
ONE_YEAR: 'public, max-age=31536000',
|
||||
/** No cache for dynamic content */
|
||||
NO_CACHE: 'no-cache, no-store, must-revalidate',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Error Messages
|
||||
// ============================================================================
|
||||
export const ERROR_MESSAGES = {
|
||||
// Authentication
|
||||
AUTH_REQUIRED: 'Authentication required',
|
||||
AUTH_INVALID_CREDENTIALS: 'Invalid email or password',
|
||||
|
||||
// User
|
||||
USER_ID_REQUIRED: 'User ID is required',
|
||||
USER_NOT_FOUND: 'User not found',
|
||||
|
||||
// Card Operations
|
||||
CARD_ADD_FAILED: 'Failed to add card to collection',
|
||||
CARD_UPDATE_FAILED: 'Failed to update card',
|
||||
CARD_DELETE_FAILED: 'Failed to delete card',
|
||||
CARD_FETCH_FAILED: 'Failed to fetch card',
|
||||
CARD_NOT_FOUND: 'Card not found',
|
||||
CARD_ID_REQUIRED: 'Card ID is required',
|
||||
|
||||
// Image Operations
|
||||
IMAGE_UPLOAD_FAILED: 'Failed to upload image',
|
||||
IMAGE_DOWNLOAD_FAILED: 'Failed to download image',
|
||||
IMAGE_NO_DATA: 'No image data available',
|
||||
IMAGE_LOAD_FAILED: 'Failed to load image',
|
||||
|
||||
// Video Operations
|
||||
VIDEO_GENERATION_FAILED: 'Failed to generate video',
|
||||
VIDEO_UPLOAD_FAILED: 'Failed to upload video',
|
||||
VIDEO_DOWNLOAD_FAILED: 'Failed to download video',
|
||||
VIDEO_NO_DATA: 'No video data available',
|
||||
VIDEO_INVALID_URL: 'Invalid video URL format',
|
||||
VIDEO_INVALID_DATA: 'Videos must be stored in Firebase Storage, not Firestore',
|
||||
|
||||
// Storage
|
||||
STORAGE_UPLOAD_FAILED: 'Failed to upload to storage',
|
||||
STORAGE_DELETE_FAILED: 'Failed to delete from storage',
|
||||
|
||||
// API
|
||||
API_KEYS_UPDATE_FAILED: 'Failed to update API keys',
|
||||
API_KEYS_FETCH_FAILED: 'Failed to fetch API keys',
|
||||
MISSING_REQUIRED_FIELDS: 'Missing required fields',
|
||||
INTERNAL_SERVER_ERROR: 'Internal server error',
|
||||
|
||||
// Generic
|
||||
UNEXPECTED_ERROR: 'An unexpected error occurred',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Success Messages
|
||||
// ============================================================================
|
||||
export const SUCCESS_MESSAGES = {
|
||||
CARD_ADDED: 'Card added successfully',
|
||||
CARD_UPDATED: 'Card updated successfully',
|
||||
CARD_DELETED: 'Card deleted successfully',
|
||||
VIDEO_GENERATED: 'Video generated successfully',
|
||||
API_KEYS_UPDATED: 'API keys updated successfully',
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Shared API route handler utilities to eliminate duplication
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ERROR_MESSAGES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Validates request body has all required fields
|
||||
* @param body - Request body to validate
|
||||
* @param requiredFields - Array of required field names
|
||||
* @returns Array of missing field names, empty if all present
|
||||
*/
|
||||
export function validateRequestBody(body: any, requiredFields: readonly string[]): string[] {
|
||||
return requiredFields.filter(field => !body[field]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API handler that wraps common error handling and validation
|
||||
* @param request - Next.js request object
|
||||
* @param requiredFields - Array of required field names to validate
|
||||
* @param handler - Async function that processes the request and returns result
|
||||
* @returns NextResponse with success or error
|
||||
*/
|
||||
export async function handleApiRequest<T>(
|
||||
request: NextRequest,
|
||||
requiredFields: readonly string[],
|
||||
handler: (body: any) => Promise<T>
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
const missingFields = validateRequestBody(body, requiredFields);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `${ERROR_MESSAGES.MISSING_REQUIRED_FIELDS}: ${missingFields.join(', ')}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Call the handler
|
||||
const result = await handler(body);
|
||||
|
||||
// Check if result has error property
|
||||
if (result && typeof result === 'object' && 'error' in result && result.error) {
|
||||
return NextResponse.json(
|
||||
{ error: result.error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('API route error:', error);
|
||||
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: ERROR_MESSAGES.INTERNAL_SERVER_ERROR;
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { ERROR_MESSAGES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Standard API response types
|
||||
*/
|
||||
export interface ApiSuccessResponse<T = any> {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
success: false;
|
||||
error: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
|
||||
/**
|
||||
* Creates a successful API response
|
||||
* @param data - Response data
|
||||
* @param status - HTTP status code (default: 200)
|
||||
* @returns NextResponse with success payload
|
||||
*/
|
||||
export function apiSuccess<T>(data: T, status: number = 200): NextResponse<ApiSuccessResponse<T>> {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
data,
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error API response
|
||||
* @param error - Error message or Error object
|
||||
* @param status - HTTP status code (default: 500)
|
||||
* @param details - Optional additional error details
|
||||
* @returns NextResponse with error payload
|
||||
*/
|
||||
export function apiError(
|
||||
error: string | Error,
|
||||
status: number = 500,
|
||||
details?: any
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
const errorMessage = error instanceof Error ? error.message : error;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
...(details && { details }),
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bad request (400) response
|
||||
* @param message - Error message
|
||||
* @param details - Optional validation details
|
||||
*/
|
||||
export function apiBadRequest(message: string, details?: any): NextResponse<ApiErrorResponse> {
|
||||
return apiError(message, 400, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an unauthorized (401) response
|
||||
*/
|
||||
export function apiUnauthorized(message: string = ERROR_MESSAGES.AUTH_REQUIRED): NextResponse<ApiErrorResponse> {
|
||||
return apiError(message, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a forbidden (403) response
|
||||
*/
|
||||
export function apiForbidden(message: string): NextResponse<ApiErrorResponse> {
|
||||
return apiError(message, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a not found (404) response
|
||||
*/
|
||||
export function apiNotFound(message: string): NextResponse<ApiErrorResponse> {
|
||||
return apiError(message, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an internal server error (500) response
|
||||
*/
|
||||
export function apiInternalError(
|
||||
error?: Error | unknown,
|
||||
message: string = ERROR_MESSAGES.INTERNAL_SERVER_ERROR
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
const errorMessage = error instanceof Error ? error.message : message;
|
||||
return apiError(errorMessage, 500);
|
||||
}
|
||||
+156
-118
@@ -14,33 +14,58 @@ import {
|
||||
orderBy,
|
||||
setDoc,
|
||||
} from 'firebase/firestore';
|
||||
import { db, auth, storage } from './firebase'; // Main Firebase config
|
||||
import { db, auth, storage } from './firebase';
|
||||
import type { PokemonCard, ScannedCardData, UserProfile, UserApiKeys } from '@/types';
|
||||
import { uploadCardImageToStorage, uploadVideoToStorage } from '@/utils/storageUtils';
|
||||
import { ref, deleteObject } from 'firebase/storage';
|
||||
import { filterUndefinedValues } from './utils';
|
||||
import { generateCardVideo } from '@/ai/flows/generate-card-video';
|
||||
|
||||
const CARDS_COLLECTION = 'users'; // Top-level collection for users
|
||||
import {
|
||||
FIRESTORE_COLLECTIONS,
|
||||
FILE_SIZE_LIMITS,
|
||||
ERROR_MESSAGES,
|
||||
DEFAULT_TCG_GAME,
|
||||
type VideoGenerationStatus
|
||||
} from '@/constants';
|
||||
|
||||
// Path: users/{userId}/pokemon_cards/{pokemonCardId}
|
||||
const getPokemonCardsCollectionRef = (userId: string) => {
|
||||
return collection(db, CARDS_COLLECTION, userId, 'pokemon_cards');
|
||||
return collection(db, FIRESTORE_COLLECTIONS.USERS, userId, FIRESTORE_COLLECTIONS.POKEMON_CARDS);
|
||||
};
|
||||
|
||||
// Background function to generate and upload video for a card
|
||||
const generateVideoForCard = async (userId: string, cardId: string, card: PokemonCard) => {
|
||||
/**
|
||||
* Validates that a video URL is valid for Firestore storage
|
||||
* @param videoUrl - The video URL to validate
|
||||
* @throws Error if URL is invalid
|
||||
*/
|
||||
const validateVideoUrl = (videoUrl: string): void => {
|
||||
if (!videoUrl.startsWith('http')) {
|
||||
throw new Error(ERROR_MESSAGES.VIDEO_INVALID_URL);
|
||||
}
|
||||
|
||||
if (videoUrl.length > FILE_SIZE_LIMITS.URL_MAX_LENGTH) {
|
||||
throw new Error(ERROR_MESSAGES.VIDEO_INVALID_DATA);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Background function to generate and upload video for a card
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to generate video for
|
||||
* @param card - The card data
|
||||
*/
|
||||
const generateVideoForCard = async (userId: string, cardId: string, card: PokemonCard): Promise<void> => {
|
||||
try {
|
||||
console.log(`Starting background video generation for card: ${card.name}`);
|
||||
|
||||
// Update status to generating
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'generating'
|
||||
videoGenerationStatus: 'generating' as VideoGenerationStatus
|
||||
});
|
||||
|
||||
// Determine Pokemon type from generation params or default
|
||||
const pokemonType = card.generationParams?.pokemonType ||
|
||||
card.photoGenerationParams?.pokemonType ||
|
||||
const pokemonType = card.generationParams?.characterType ||
|
||||
card.photoGenerationParams?.characterType ||
|
||||
'Normal';
|
||||
|
||||
// Generate video using AI
|
||||
@@ -51,57 +76,33 @@ const generateVideoForCard = async (userId: string, cardId: string, card: Pokemo
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
console.log('Video generation result:', {
|
||||
hasError: !!videoResult.error,
|
||||
hasVideoBase64: !!videoResult.videoBase64,
|
||||
videoBase64Type: typeof videoResult.videoBase64,
|
||||
videoBase64Preview: videoResult.videoBase64?.substring(0, 50) + '...',
|
||||
});
|
||||
|
||||
if (videoResult.error || !videoResult.videoBase64) {
|
||||
console.error('Video generation failed:', videoResult.error);
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
videoGenerationStatus: 'failed' as VideoGenerationStatus
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we have base64 data, not a URL
|
||||
if (videoResult.videoBase64.startsWith('http')) {
|
||||
console.error('Video generation returned a URL instead of base64 data:', videoResult.videoBase64);
|
||||
console.error('Video generation returned a URL instead of base64 data');
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
videoGenerationStatus: 'failed' as VideoGenerationStatus
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload video to storage
|
||||
console.log('Uploading video to Firebase Storage...');
|
||||
const videoUrl = await uploadVideoToStorage(videoResult.videoBase64, userId, card.name);
|
||||
console.log('Video uploaded to Firebase Storage:', videoUrl);
|
||||
|
||||
// Validate video URL before storing
|
||||
validateVideoUrl(videoUrl);
|
||||
|
||||
// Validate that videoUrl is a proper Firebase Storage URL (production or emulator), not raw data
|
||||
if (!videoUrl.startsWith('http')) {
|
||||
console.error('Invalid video URL returned from storage upload:', videoUrl.substring(0, 100));
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional safety check - ensure we're not storing large data in Firestore
|
||||
if (videoUrl.length > 2000) { // URLs should be much shorter than this
|
||||
console.error('Video URL is suspiciously long, might be raw data:', videoUrl.length);
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Update card with video info (only storing the URL, not the video data)
|
||||
// Update card with video info
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoUrl, // This should only be a Firebase Storage URL
|
||||
videoGenerationStatus: 'completed',
|
||||
videoUrl,
|
||||
videoGenerationStatus: 'completed' as VideoGenerationStatus,
|
||||
videoPrompt: videoResult.prompt,
|
||||
});
|
||||
|
||||
@@ -109,72 +110,63 @@ const generateVideoForCard = async (userId: string, cardId: string, card: Pokemo
|
||||
} catch (error) {
|
||||
console.error('Error in background video generation:', error);
|
||||
await updateCardInCollection(userId, cardId, {
|
||||
videoGenerationStatus: 'failed'
|
||||
videoGenerationStatus: 'failed' as VideoGenerationStatus
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new card to the user's collection
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardData - The card data including imageDataUrl
|
||||
* @returns Promise resolving to the new card ID
|
||||
* @throws Error if userId is missing or card add fails
|
||||
*/
|
||||
export const addCardToCollection = async (
|
||||
userId: string,
|
||||
cardData: Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'updatedAt' | 'imageUrl'> & { imageDataUrl: string }
|
||||
): Promise<string> => {
|
||||
if (!userId) {
|
||||
console.error("addCardToCollection: User ID is missing.");
|
||||
throw new Error('User ID is required to add a card.');
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
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
|
||||
console.log("Uploading card image to Firebase Storage...");
|
||||
// Upload the image to Firebase Storage
|
||||
const imageUrl = await uploadCardImageToStorage(cardData.imageDataUrl, userId, cardData.name);
|
||||
|
||||
const collectionRef = getPokemonCardsCollectionRef(userId);
|
||||
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
|
||||
game: cardData.game || DEFAULT_TCG_GAME,
|
||||
imageUrl,
|
||||
userId,
|
||||
// Don't initialize video generation automatically - make it opt-in
|
||||
createdAt: serverTimestamp(),
|
||||
updatedAt: serverTimestamp(),
|
||||
};
|
||||
|
||||
console.log(
|
||||
"Attempting to add card to Firestore. Path:",
|
||||
collectionRef.path,
|
||||
"Payload keys:",
|
||||
Object.keys(docPayload).join(', '),
|
||||
"Image URL:",
|
||||
imageUrl,
|
||||
"Game:",
|
||||
docPayload.game
|
||||
);
|
||||
|
||||
const docRef = await addDoc(collectionRef, docPayload);
|
||||
console.log("Card successfully added to Firestore with ID:", docRef.id);
|
||||
|
||||
// Video generation is now opt-in - users can trigger it manually from the UI
|
||||
|
||||
// Video generation is opt-in - users can trigger it manually from the UI
|
||||
return docRef.id;
|
||||
} catch (error) {
|
||||
console.error('Error adding card to Firestore (full error object):', error);
|
||||
let detailedMessage = 'Failed to add card to collection.';
|
||||
console.error('Error adding card to Firestore:', error);
|
||||
if (error instanceof Error) {
|
||||
detailedMessage = error.message;
|
||||
const firebaseError = error as any;
|
||||
if (firebaseError.code) {
|
||||
detailedMessage = `Error: ${firebaseError.code} - ${error.message}`;
|
||||
throw new Error(`${firebaseError.code}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
throw new Error(detailedMessage);
|
||||
throw new Error(ERROR_MESSAGES.CARD_ADD_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets all cards for a user, ordered by most recently updated
|
||||
* @param userId - The user ID to fetch cards for
|
||||
* @returns Promise resolving to array of cards
|
||||
* @throws Error if fetch fails
|
||||
*/
|
||||
export const getUserCards = async (userId: string): Promise<PokemonCard[]> => {
|
||||
if (!userId) return [];
|
||||
try {
|
||||
@@ -186,14 +178,21 @@ export const getUserCards = async (userId: string): Promise<PokemonCard[]> => {
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching user cards: ', error);
|
||||
throw new Error('Failed to fetch card collection.');
|
||||
throw new Error(ERROR_MESSAGES.CARD_FETCH_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a card by its ID
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to fetch
|
||||
* @returns Promise resolving to the card or null if not found
|
||||
* @throws Error if fetch fails
|
||||
*/
|
||||
export const getCardById = async (userId: string, cardId: string): Promise<PokemonCard | null> => {
|
||||
if (!userId || !cardId) return null;
|
||||
try {
|
||||
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
|
||||
const cardDocRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId, FIRESTORE_COLLECTIONS.POKEMON_CARDS, cardId);
|
||||
const cardSnap = await getDoc(cardDocRef);
|
||||
if (cardSnap.exists()) {
|
||||
return { id: cardSnap.id, ...cardSnap.data() } as PokemonCard;
|
||||
@@ -201,39 +200,45 @@ export const getCardById = async (userId: string, cardId: string): Promise<Pokem
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching card by ID: ', error);
|
||||
throw new Error('Failed to fetch card details.');
|
||||
throw new Error(ERROR_MESSAGES.CARD_FETCH_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a card in the collection
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to update
|
||||
* @param cardData - The partial card data to update
|
||||
* @throws Error if update fails or validation fails
|
||||
*/
|
||||
export const updateCardInCollection = async (
|
||||
userId: string,
|
||||
cardId: string,
|
||||
cardData: Partial<Omit<PokemonCard, 'id' | 'userId' | 'createdAt' | 'imageUrl'>> & { imageDataUrl?: string }
|
||||
): Promise<void> => {
|
||||
if (!userId || !cardId) throw new Error('User ID and Card ID are required to update a card.');
|
||||
if (!userId || !cardId) {
|
||||
throw new Error(`${ERROR_MESSAGES.USER_ID_REQUIRED} and ${ERROR_MESSAGES.CARD_ID_REQUIRED}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
|
||||
const cardDocRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId, FIRESTORE_COLLECTIONS.POKEMON_CARDS, cardId);
|
||||
|
||||
let updateData: any = { ...cardData };
|
||||
|
||||
// Safety check: ensure we're not trying to store large video data in Firestore
|
||||
if (updateData.videoUrl && updateData.videoUrl.length > 2000) {
|
||||
console.error('Attempted to store large video data in Firestore. This is not allowed.');
|
||||
throw new Error('Invalid video data: videos must be stored in Firebase Storage, not Firestore');
|
||||
}
|
||||
|
||||
// Validate video URL format if present - allow both production and emulator URLs
|
||||
if (updateData.videoUrl && !updateData.videoUrl.startsWith('http')) {
|
||||
console.error('Invalid video URL format:', updateData.videoUrl.substring(0, 100));
|
||||
throw new Error('Video URL must be a valid HTTP URL from Firebase Storage');
|
||||
// Validate video URL if present
|
||||
if (updateData.videoUrl) {
|
||||
validateVideoUrl(updateData.videoUrl);
|
||||
}
|
||||
|
||||
// If a new image is provided, upload it to storage first
|
||||
if (cardData.imageDataUrl) {
|
||||
console.log("Uploading updated card image to Firebase Storage...");
|
||||
const imageUrl = await uploadCardImageToStorage(cardData.imageDataUrl, userId, cardData.name || 'updated_card');
|
||||
const imageUrl = await uploadCardImageToStorage(
|
||||
cardData.imageDataUrl,
|
||||
userId,
|
||||
cardData.name || 'updated_card'
|
||||
);
|
||||
updateData.imageUrl = imageUrl;
|
||||
delete updateData.imageDataUrl; // Remove the base64 data from the update
|
||||
delete updateData.imageDataUrl;
|
||||
}
|
||||
|
||||
await updateDoc(cardDocRef, {
|
||||
@@ -242,14 +247,23 @@ export const updateCardInCollection = async (
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating card: ', error);
|
||||
throw new Error('Failed to update card.');
|
||||
throw new Error(ERROR_MESSAGES.CARD_UPDATE_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a card from the collection and its associated files from storage
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to delete
|
||||
* @throws Error if delete fails
|
||||
*/
|
||||
export const deleteCardFromCollection = async (userId: string, cardId: string): Promise<void> => {
|
||||
if (!userId || !cardId) throw new Error('User ID and Card ID are required to delete a card.');
|
||||
if (!userId || !cardId) {
|
||||
throw new Error(`${ERROR_MESSAGES.USER_ID_REQUIRED} and ${ERROR_MESSAGES.CARD_ID_REQUIRED}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const cardDocRef = doc(db, CARDS_COLLECTION, userId, 'pokemon_cards', cardId);
|
||||
const cardDocRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId, FIRESTORE_COLLECTIONS.POKEMON_CARDS, cardId);
|
||||
|
||||
// Get the card data first to delete the associated image from storage
|
||||
const cardDoc = await getDoc(cardDocRef);
|
||||
@@ -294,41 +308,52 @@ export const deleteCardFromCollection = async (userId: string, cardId: string):
|
||||
await deleteDoc(cardDocRef);
|
||||
} catch (error) {
|
||||
console.error('Error deleting card: ', error);
|
||||
throw new Error('Failed to delete card.');
|
||||
throw new Error(ERROR_MESSAGES.CARD_DELETE_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
// Manual function to generate video for an existing card
|
||||
/**
|
||||
* Manually generates video for an existing card
|
||||
* @param userId - The user ID who owns the card
|
||||
* @param cardId - The card ID to generate video for
|
||||
* @throws Error if card not found or generation fails
|
||||
*/
|
||||
export const generateVideoForExistingCard = async (userId: string, cardId: string): Promise<void> => {
|
||||
if (!userId || !cardId) throw new Error('User ID and Card ID are required to generate video.');
|
||||
if (!userId || !cardId) {
|
||||
throw new Error(`${ERROR_MESSAGES.USER_ID_REQUIRED} and ${ERROR_MESSAGES.CARD_ID_REQUIRED}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the card data
|
||||
const card = await getCardById(userId, cardId);
|
||||
if (!card) {
|
||||
throw new Error('Card not found');
|
||||
throw new Error(ERROR_MESSAGES.CARD_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Trigger video generation
|
||||
await generateVideoForCard(userId, cardId, card);
|
||||
} catch (error) {
|
||||
console.error('Error generating video for existing card:', error);
|
||||
throw new Error('Failed to generate video for card.');
|
||||
throw new Error(ERROR_MESSAGES.VIDEO_GENERATION_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// User Profile Management Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates or updates a user profile in Firestore
|
||||
* @param userId - The user ID
|
||||
* @param profileData - Partial user profile data to create/update
|
||||
* @throws Error if userId is missing or operation fails
|
||||
*/
|
||||
export const createOrUpdateUserProfile = async (userId: string, profileData: Partial<UserProfile>): Promise<void> => {
|
||||
if (!userId) throw new Error('User ID is required to create/update profile.');
|
||||
if (!userId) {
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
const userRef = doc(db, 'users', userId);
|
||||
const userRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId);
|
||||
|
||||
// Filter out undefined values to prevent Firestore errors
|
||||
const filteredProfileData = filterUndefinedValues(profileData);
|
||||
|
||||
const updateData: any = {
|
||||
@@ -346,18 +371,23 @@ export const createOrUpdateUserProfile = async (userId: string, profileData: Par
|
||||
await setDoc(userRef, updateData, { merge: true });
|
||||
} catch (error) {
|
||||
console.error('Error creating/updating user profile:', error);
|
||||
throw new Error('Failed to create/update user profile.');
|
||||
throw new Error(ERROR_MESSAGES.USER_NOT_FOUND);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a user profile from Firestore
|
||||
* Gets a user profile from Firestore, creates basic profile if doesn't exist
|
||||
* @param userId - The user ID to fetch profile for
|
||||
* @returns Promise resolving to user profile or null
|
||||
* @throws Error if userId is missing or fetch fails
|
||||
*/
|
||||
export const getUserProfile = async (userId: string): Promise<UserProfile | null> => {
|
||||
if (!userId) throw new Error('User ID is required to get profile.');
|
||||
if (!userId) {
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
const userRef = doc(db, 'users', userId);
|
||||
const userRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId);
|
||||
const userSnap = await getDoc(userRef);
|
||||
|
||||
if (userSnap.exists()) {
|
||||
@@ -365,7 +395,6 @@ export const getUserProfile = async (userId: string): Promise<UserProfile | null
|
||||
}
|
||||
|
||||
// Create a basic user profile if it doesn't exist
|
||||
console.log(`User profile not found for ${userId}, creating basic profile...`);
|
||||
const basicProfile: Partial<UserProfile> = {
|
||||
email: '',
|
||||
displayName: 'Anonymous User',
|
||||
@@ -373,7 +402,6 @@ export const getUserProfile = async (userId: string): Promise<UserProfile | null
|
||||
|
||||
await createOrUpdateUserProfile(userId, basicProfile);
|
||||
|
||||
// Return the newly created profile
|
||||
const newUserSnap = await getDoc(userRef);
|
||||
if (newUserSnap.exists()) {
|
||||
return newUserSnap.data() as UserProfile;
|
||||
@@ -382,39 +410,49 @@ export const getUserProfile = async (userId: string): Promise<UserProfile | null
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error getting user profile:', error);
|
||||
throw new Error('Failed to get user profile.');
|
||||
throw new Error(ERROR_MESSAGES.USER_NOT_FOUND);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates user API keys
|
||||
* @param userId - The user ID
|
||||
* @param apiKeys - The API keys to update
|
||||
* @throws Error if userId is missing or update fails
|
||||
*/
|
||||
export const updateUserApiKeys = async (userId: string, apiKeys: UserApiKeys): Promise<void> => {
|
||||
if (!userId) throw new Error('User ID is required to update API keys.');
|
||||
if (!userId) {
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
const userRef = doc(db, 'users', userId);
|
||||
const userRef = doc(db, FIRESTORE_COLLECTIONS.USERS, userId);
|
||||
await updateDoc(userRef, {
|
||||
apiKeys: apiKeys,
|
||||
updatedAt: serverTimestamp(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user API keys:', error);
|
||||
throw new Error('Failed to update API keys.');
|
||||
throw new Error(ERROR_MESSAGES.API_KEYS_UPDATE_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets user API keys
|
||||
* Gets user API keys from their profile
|
||||
* @param userId - The user ID
|
||||
* @returns Promise resolving to API keys or null
|
||||
* @throws Error if userId is missing or fetch fails
|
||||
*/
|
||||
export const getUserApiKeys = async (userId: string): Promise<UserApiKeys | null> => {
|
||||
if (!userId) throw new Error('User ID is required to get API keys.');
|
||||
if (!userId) {
|
||||
throw new Error(ERROR_MESSAGES.USER_ID_REQUIRED);
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await getUserProfile(userId);
|
||||
return profile?.apiKeys || null;
|
||||
} catch (error) {
|
||||
console.error('Error getting user API keys:', error);
|
||||
throw new Error('Failed to get API keys.');
|
||||
throw new Error(ERROR_MESSAGES.API_KEYS_FETCH_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Centralized logging utility for the application
|
||||
* Provides structured logging with different levels and context
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
interface LogContext {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger class for structured application logging
|
||||
*/
|
||||
class Logger {
|
||||
private isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
/**
|
||||
* Formats log message with context
|
||||
*/
|
||||
private formatMessage(level: LogLevel, message: string, context?: LogContext): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const contextStr = context ? ` | ${JSON.stringify(context)}` : '';
|
||||
return `[${timestamp}] [${level.toUpperCase()}] ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs debug messages (only in development)
|
||||
*/
|
||||
debug(message: string, context?: LogContext): void {
|
||||
if (this.isDevelopment) {
|
||||
console.debug(this.formatMessage('debug', message, context));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs informational messages
|
||||
*/
|
||||
info(message: string, context?: LogContext): void {
|
||||
console.log(this.formatMessage('info', message, context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs warning messages
|
||||
*/
|
||||
warn(message: string, context?: LogContext): void {
|
||||
console.warn(this.formatMessage('warn', message, context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs error messages
|
||||
*/
|
||||
error(message: string, error?: Error | unknown, context?: LogContext): void {
|
||||
const errorContext = error instanceof Error
|
||||
? { ...context, error: error.message, stack: error.stack }
|
||||
: context;
|
||||
|
||||
console.error(this.formatMessage('error', message, errorContext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a scoped logger with default context
|
||||
*/
|
||||
scope(defaultContext: LogContext): ScopedLogger {
|
||||
return new ScopedLogger(this, defaultContext);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped logger that includes default context in all log calls
|
||||
*/
|
||||
class ScopedLogger {
|
||||
constructor(
|
||||
private logger: Logger,
|
||||
private defaultContext: LogContext
|
||||
) {}
|
||||
|
||||
private mergeContext(context?: LogContext): LogContext {
|
||||
return { ...this.defaultContext, ...context };
|
||||
}
|
||||
|
||||
debug(message: string, context?: LogContext): void {
|
||||
this.logger.debug(message, this.mergeContext(context));
|
||||
}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.logger.info(message, this.mergeContext(context));
|
||||
}
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
this.logger.warn(message, this.mergeContext(context));
|
||||
}
|
||||
|
||||
error(message: string, error?: Error | unknown, context?: LogContext): void {
|
||||
this.logger.error(message, error, this.mergeContext(context));
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton logger instance
|
||||
export const logger = new Logger();
|
||||
|
||||
// Export convenience functions
|
||||
export const log = {
|
||||
debug: (message: string, context?: LogContext) => logger.debug(message, context),
|
||||
info: (message: string, context?: LogContext) => logger.info(message, context),
|
||||
warn: (message: string, context?: LogContext) => logger.warn(message, context),
|
||||
error: (message: string, error?: Error | unknown, context?: LogContext) =>
|
||||
logger.error(message, error, context),
|
||||
};
|
||||
+63
-2
@@ -1,14 +1,25 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
/**
|
||||
* Combines class names using clsx and tailwind-merge
|
||||
* Merges Tailwind classes intelligently, removing conflicts
|
||||
* @param inputs - Class values to combine
|
||||
* @returns Merged class name string
|
||||
* @example
|
||||
* cn('px-2 py-1', 'px-4') // returns 'py-1 px-4'
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes undefined values from an object to prevent Firestore errors
|
||||
* @param obj The object to filter
|
||||
* Firestore does not allow undefined values in documents
|
||||
* @param obj - The object to filter
|
||||
* @returns A new object with undefined values removed
|
||||
* @example
|
||||
* filterUndefinedValues({ a: 1, b: undefined, c: 3 }) // returns { a: 1, c: 3 }
|
||||
*/
|
||||
export function filterUndefinedValues<T extends Record<string, any>>(obj: T): Partial<T> {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
@@ -18,3 +29,53 @@ export function filterUndefinedValues<T extends Record<string, any>>(obj: T): Pa
|
||||
return acc;
|
||||
}, {} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes null and undefined values from an object
|
||||
* @param obj - The object to filter
|
||||
* @returns A new object with null and undefined values removed
|
||||
* @example
|
||||
* filterNullishValues({ a: 1, b: null, c: undefined, d: 0 }) // returns { a: 1, d: 0 }
|
||||
*/
|
||||
export function filterNullishValues<T extends Record<string, any>>(obj: T): Partial<T> {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
if (value != null) { // checks for both null and undefined
|
||||
acc[key as keyof T] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Partial<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is not null or undefined
|
||||
* @param value - The value to check
|
||||
* @returns true if value is not null or undefined
|
||||
*/
|
||||
export function isNotNullish<T>(value: T | null | undefined): value is T {
|
||||
return value != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delays execution for a specified time
|
||||
* @param ms - Milliseconds to delay
|
||||
* @returns Promise that resolves after the delay
|
||||
* @example
|
||||
* await delay(1000) // waits 1 second
|
||||
*/
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parses JSON with a fallback value
|
||||
* @param jsonString - The JSON string to parse
|
||||
* @param fallback - Value to return if parsing fails
|
||||
* @returns Parsed object or fallback value
|
||||
*/
|
||||
export function safeJsonParse<T>(jsonString: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(jsonString) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Validation utilities for common data validation tasks
|
||||
*/
|
||||
|
||||
import { ERROR_MESSAGES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Validates that a string is not empty
|
||||
* @param value - String to validate
|
||||
* @param fieldName - Name of the field for error message
|
||||
* @throws Error if string is empty or whitespace only
|
||||
*/
|
||||
export function validateNotEmpty(value: string, fieldName: string): void {
|
||||
if (!value || value.trim().length === 0) {
|
||||
throw new Error(`${fieldName} cannot be empty`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a value is defined
|
||||
* @param value - Value to check
|
||||
* @param fieldName - Name of the field for error message
|
||||
* @throws Error if value is null or undefined
|
||||
*/
|
||||
export function validateDefined<T>(value: T | null | undefined, fieldName: string): asserts value is T {
|
||||
if (value == null) {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an email address format
|
||||
* @param email - Email to validate
|
||||
* @returns true if valid email format
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a URL format
|
||||
* @param url - URL to validate
|
||||
* @returns true if valid URL format
|
||||
*/
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a number is within a range
|
||||
* @param value - Number to validate
|
||||
* @param min - Minimum value (inclusive)
|
||||
* @param max - Maximum value (inclusive)
|
||||
* @param fieldName - Name of the field for error message
|
||||
* @throws Error if value is out of range
|
||||
*/
|
||||
export function validateNumberRange(
|
||||
value: number,
|
||||
min: number,
|
||||
max: number,
|
||||
fieldName: string
|
||||
): void {
|
||||
if (value < min || value > max) {
|
||||
throw new Error(`${fieldName} must be between ${min} and ${max}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an object has all required fields
|
||||
* @param obj - Object to validate
|
||||
* @param requiredFields - Array of required field names
|
||||
* @returns Array of missing field names
|
||||
*/
|
||||
export function getMissingFields<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
requiredFields: readonly (keyof T)[]
|
||||
): string[] {
|
||||
return requiredFields.filter(field => !obj[field]).map(String);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an object has all required fields, throws if not
|
||||
* @param obj - Object to validate
|
||||
* @param requiredFields - Array of required field names
|
||||
* @throws Error if any required fields are missing
|
||||
*/
|
||||
export function validateRequiredFields<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
requiredFields: readonly (keyof T)[]
|
||||
): void {
|
||||
const missing = getMissingFields(obj, requiredFields);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`${ERROR_MESSAGES.MISSING_REQUIRED_FIELDS}: ${missing.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Firebase Storage URL
|
||||
* @param url - URL to validate
|
||||
* @returns true if valid Firebase Storage URL
|
||||
*/
|
||||
export function isFirebaseStorageUrl(url: string): boolean {
|
||||
return url.includes('firebasestorage.googleapis.com') ||
|
||||
url.includes('localhost:9199') ||
|
||||
url.includes('127.0.0.1:9199');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL is an HTTP/HTTPS URL
|
||||
* @param url - URL to check
|
||||
* @returns true if URL starts with http:// or https://
|
||||
*/
|
||||
export function isHttpUrl(url: string): boolean {
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a string is a data URI
|
||||
* @param url - String to check
|
||||
* @returns true if string is a data URI
|
||||
*/
|
||||
export function isDataUri(url: string): boolean {
|
||||
return url.startsWith('data:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a string length is within bounds
|
||||
* @param value - String to validate
|
||||
* @param minLength - Minimum length
|
||||
* @param maxLength - Maximum length
|
||||
* @param fieldName - Name of the field for error message
|
||||
* @throws Error if length is out of bounds
|
||||
*/
|
||||
export function validateStringLength(
|
||||
value: string,
|
||||
minLength: number,
|
||||
maxLength: number,
|
||||
fieldName: string
|
||||
): void {
|
||||
if (value.length < minLength || value.length > maxLength) {
|
||||
throw new Error(
|
||||
`${fieldName} must be between ${minLength} and ${maxLength} characters`
|
||||
);
|
||||
}
|
||||
}
|
||||
+91
-83
@@ -1,68 +1,102 @@
|
||||
import { ERROR_MESSAGES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Sanitizes a filename by replacing non-alphanumeric characters with underscores
|
||||
* @param name - The name to sanitize
|
||||
* @returns Sanitized lowercase string safe for filenames
|
||||
*/
|
||||
const sanitizeFilename = (name: string): string => {
|
||||
return name.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a blob URL from a remote URL using a proxy to avoid CORS issues
|
||||
* @param url - The remote URL to fetch
|
||||
* @returns Promise resolving to the blob URL
|
||||
*/
|
||||
const fetchThroughProxy = async (url: string): Promise<string> => {
|
||||
const proxyUrl = `/api/download?url=${encodeURIComponent(url)}`;
|
||||
const response = await fetch(proxyUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch through proxy');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a blob URL from a remote URL directly (may hit CORS)
|
||||
* @param url - The remote URL to fetch
|
||||
* @returns Promise resolving to the blob URL
|
||||
*/
|
||||
const fetchDirectly = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url, { mode: 'cors' });
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
/**
|
||||
* Triggers a download by creating and clicking a temporary anchor element
|
||||
* @param href - The URL or data URI to download
|
||||
* @param filename - The filename for the download
|
||||
* @param cleanup - Optional cleanup function to run after click
|
||||
*/
|
||||
const triggerDownload = (href: string, filename: string, cleanup?: () => void): void => {
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = filename;
|
||||
|
||||
if (cleanup) {
|
||||
link.addEventListener('click', () => {
|
||||
setTimeout(cleanup, 100);
|
||||
});
|
||||
}
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
/**
|
||||
* Downloads a card image from a base64 data URL, data URI, or Firebase Storage URL
|
||||
* @param imageUrl - The base64 data URL, data URI, or Firebase Storage URL of the image
|
||||
* @param cardName - The name of the card to use as filename
|
||||
* @throws Error if no image is available or download fails
|
||||
*/
|
||||
export async function downloadCardImage(imageUrl: string, cardName: string): Promise<void> {
|
||||
try {
|
||||
if (!imageUrl) {
|
||||
throw new Error('No image available to download');
|
||||
}
|
||||
if (!imageUrl) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NO_DATA);
|
||||
}
|
||||
|
||||
// Create a temporary anchor element
|
||||
const link = document.createElement('a');
|
||||
|
||||
// Set the download attribute with a filename
|
||||
const sanitizedName = cardName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
link.download = `${sanitizedName}_pokemon_card.png`;
|
||||
try {
|
||||
const sanitizedName = sanitizeFilename(cardName);
|
||||
const filename = `${sanitizedName}_pokemon_card.png`;
|
||||
|
||||
// Handle different types of image URLs
|
||||
if (imageUrl.startsWith('data:')) {
|
||||
// Data URL - use directly
|
||||
link.href = imageUrl;
|
||||
triggerDownload(imageUrl, filename);
|
||||
} else if (imageUrl.startsWith('http')) {
|
||||
// Firebase Storage URL or other HTTP URL - use proxy approach to avoid CORS
|
||||
try {
|
||||
const proxyUrl = `/api/download?url=${encodeURIComponent(imageUrl)}`;
|
||||
const response = await fetch(proxyUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch through proxy');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
link.href = blobUrl;
|
||||
|
||||
// Clean up the blob URL after download
|
||||
link.addEventListener('click', () => {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||
});
|
||||
const blobUrl = await fetchThroughProxy(imageUrl);
|
||||
triggerDownload(blobUrl, filename, () => URL.revokeObjectURL(blobUrl));
|
||||
} catch (proxyError) {
|
||||
console.error('Proxy download failed, trying direct fetch:', proxyError);
|
||||
// Fallback to direct fetch (might still hit CORS)
|
||||
const response = await fetch(imageUrl, { mode: 'cors' });
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
link.href = blobUrl;
|
||||
|
||||
link.addEventListener('click', () => {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||
});
|
||||
const blobUrl = await fetchDirectly(imageUrl);
|
||||
triggerDownload(blobUrl, filename, () => URL.revokeObjectURL(blobUrl));
|
||||
}
|
||||
} else {
|
||||
// Assume it's a base64 string without data URL prefix
|
||||
const dataUrl = `data:image/png;base64,${imageUrl}`;
|
||||
link.href = dataUrl;
|
||||
triggerDownload(dataUrl, filename);
|
||||
}
|
||||
|
||||
// Append to body, click, and remove
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error('Error downloading card image:', error);
|
||||
throw new Error('Failed to download card image');
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_DOWNLOAD_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +104,11 @@ export async function downloadCardImage(imageUrl: string, cardName: string): Pro
|
||||
* Downloads a card image from a base64 string
|
||||
* @param base64String - The base64 string of the image
|
||||
* @param cardName - The name of the card to use as filename
|
||||
* @throws Error if no image data is available
|
||||
*/
|
||||
export function downloadCardFromBase64(base64String: string, cardName: string): void {
|
||||
if (!base64String) {
|
||||
throw new Error('No image data available to download');
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NO_DATA);
|
||||
}
|
||||
|
||||
const dataUrl = `data:image/jpeg;base64,${base64String}`;
|
||||
@@ -84,66 +119,39 @@ export function downloadCardFromBase64(base64String: string, cardName: string):
|
||||
* Downloads a card video from a Firebase Storage URL or base64 data
|
||||
* @param videoUrl - The Firebase Storage URL or base64 data of the video
|
||||
* @param cardName - The name of the card to use as filename
|
||||
* @throws Error if no video is available or download fails
|
||||
*/
|
||||
export async function downloadCardVideo(videoUrl: string, cardName: string): Promise<void> {
|
||||
try {
|
||||
if (!videoUrl) {
|
||||
throw new Error('No video available to download');
|
||||
}
|
||||
if (!videoUrl) {
|
||||
throw new Error(ERROR_MESSAGES.VIDEO_NO_DATA);
|
||||
}
|
||||
|
||||
// Create a temporary anchor element
|
||||
const link = document.createElement('a');
|
||||
|
||||
// Set the download attribute with a filename
|
||||
const sanitizedName = cardName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
link.download = `${sanitizedName}_pokemon_card_video.mp4`;
|
||||
try {
|
||||
const sanitizedName = sanitizeFilename(cardName);
|
||||
const filename = `${sanitizedName}_pokemon_card_video.mp4`;
|
||||
|
||||
// Handle different types of video URLs
|
||||
if (videoUrl.startsWith('data:video/')) {
|
||||
// Data URL - use directly
|
||||
link.href = videoUrl;
|
||||
triggerDownload(videoUrl, filename);
|
||||
} else if (videoUrl.startsWith('http')) {
|
||||
// Firebase Storage URL or other HTTP URL - use proxy approach to avoid CORS
|
||||
try {
|
||||
const proxyUrl = `/api/download?url=${encodeURIComponent(videoUrl)}`;
|
||||
const response = await fetch(proxyUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch through proxy');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
link.href = blobUrl;
|
||||
|
||||
// Clean up the blob URL after download
|
||||
link.addEventListener('click', () => {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||
});
|
||||
const blobUrl = await fetchThroughProxy(videoUrl);
|
||||
triggerDownload(blobUrl, filename, () => URL.revokeObjectURL(blobUrl));
|
||||
} catch (proxyError) {
|
||||
console.error('Proxy download failed, trying direct fetch:', proxyError);
|
||||
// Fallback to direct fetch (might still hit CORS)
|
||||
const response = await fetch(videoUrl, { mode: 'cors' });
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
link.href = blobUrl;
|
||||
|
||||
link.addEventListener('click', () => {
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||
});
|
||||
const blobUrl = await fetchDirectly(videoUrl);
|
||||
triggerDownload(blobUrl, filename, () => URL.revokeObjectURL(blobUrl));
|
||||
}
|
||||
} else {
|
||||
// Assume it's a base64 string without data URL prefix
|
||||
const dataUrl = `data:video/mp4;base64,${videoUrl}`;
|
||||
link.href = dataUrl;
|
||||
triggerDownload(dataUrl, filename);
|
||||
}
|
||||
|
||||
// Append to body, click, and remove
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error('Error downloading card video:', error);
|
||||
throw new Error('Failed to download card video');
|
||||
throw new Error(ERROR_MESSAGES.VIDEO_DOWNLOAD_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { CardGenerationParams, PhotoCardGenerationParams } from '@/types';
|
||||
|
||||
export function serializeGenerationParams(params: CardGenerationParams): URLSearchParams {
|
||||
/**
|
||||
* Generic function to serialize any params object to URLSearchParams
|
||||
*/
|
||||
function serializeParams(params: Record<string, any>): URLSearchParams {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
@@ -12,36 +15,66 @@ export function serializeGenerationParams(params: CardGenerationParams): URLSear
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
export function serializeGenerationParams(params: CardGenerationParams): URLSearchParams {
|
||||
return serializeParams(params);
|
||||
}
|
||||
|
||||
export function serializePhotoGenerationParams(params: PhotoCardGenerationParams): URLSearchParams {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
return searchParams;
|
||||
return serializeParams(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to safely parse a number from URLSearchParams
|
||||
*/
|
||||
function parseNumber(searchParams: URLSearchParams, key: string): number | undefined {
|
||||
const value = searchParams.get(key);
|
||||
return value ? parseInt(value) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to safely parse a boolean from URLSearchParams
|
||||
*/
|
||||
function parseBoolean(searchParams: URLSearchParams, key: string): boolean | undefined {
|
||||
const value = searchParams.get(key);
|
||||
return value === 'true' ? true : value === 'false' ? false : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper to safely get a string from URLSearchParams
|
||||
*/
|
||||
function parseString(searchParams: URLSearchParams, key: string): string | undefined {
|
||||
return searchParams.get(key) || undefined;
|
||||
}
|
||||
|
||||
export function parseGenerationParams(searchParams: URLSearchParams): Partial<CardGenerationParams> {
|
||||
const params: Partial<CardGenerationParams> = {};
|
||||
|
||||
if (searchParams.get('pokemonName')) params.pokemonName = searchParams.get('pokemonName')!;
|
||||
if (searchParams.get('pokemonType')) params.pokemonType = searchParams.get('pokemonType')!;
|
||||
if (searchParams.get('isIllustrationRare')) params.isIllustrationRare = searchParams.get('isIllustrationRare') === 'true';
|
||||
if (searchParams.get('isHolo')) params.isHolo = searchParams.get('isHolo') === 'true';
|
||||
if (searchParams.get('backgroundDescription')) params.backgroundDescription = searchParams.get('backgroundDescription')!;
|
||||
if (searchParams.get('pokemonDescription')) params.pokemonDescription = searchParams.get('pokemonDescription')!;
|
||||
if (searchParams.get('language')) params.language = searchParams.get('language') as any;
|
||||
if (searchParams.get('hp')) params.hp = parseInt(searchParams.get('hp')!);
|
||||
if (searchParams.get('attackName1')) params.attackName1 = searchParams.get('attackName1')!;
|
||||
if (searchParams.get('attackDamage1')) params.attackDamage1 = parseInt(searchParams.get('attackDamage1')!);
|
||||
if (searchParams.get('attackName2')) params.attackName2 = searchParams.get('attackName2')!;
|
||||
if (searchParams.get('attackDamage2')) params.attackDamage2 = parseInt(searchParams.get('attackDamage2')!);
|
||||
if (searchParams.get('weakness')) params.weakness = searchParams.get('weakness')!;
|
||||
if (searchParams.get('resistance')) params.resistance = searchParams.get('resistance')!;
|
||||
if (searchParams.get('retreatCost')) params.retreatCost = parseInt(searchParams.get('retreatCost')!);
|
||||
// String fields
|
||||
const stringFields = ['pokemonName', 'pokemonType', 'backgroundDescription', 'pokemonDescription', 'language', 'attackName1', 'attackName2', 'weakness', 'resistance'];
|
||||
stringFields.forEach(field => {
|
||||
const value = parseString(searchParams, field);
|
||||
if (value !== undefined) {
|
||||
(params as any)[field] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Boolean fields
|
||||
const booleanFields = ['isIllustrationRare', 'isHolo'];
|
||||
booleanFields.forEach(field => {
|
||||
const value = parseBoolean(searchParams, field);
|
||||
if (value !== undefined) {
|
||||
(params as any)[field] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Number fields
|
||||
const numberFields = ['hp', 'attackDamage1', 'attackDamage2', 'retreatCost'];
|
||||
numberFields.forEach(field => {
|
||||
const value = parseNumber(searchParams, field);
|
||||
if (value !== undefined) {
|
||||
(params as any)[field] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -49,19 +82,23 @@ export function parseGenerationParams(searchParams: URLSearchParams): Partial<Ca
|
||||
export function parsePhotoGenerationParams(searchParams: URLSearchParams): Partial<PhotoCardGenerationParams> {
|
||||
const params: Partial<PhotoCardGenerationParams> = {};
|
||||
|
||||
if (searchParams.get('photoDataUri')) params.photoDataUri = searchParams.get('photoDataUri')!;
|
||||
if (searchParams.get('pokemonName')) params.pokemonName = searchParams.get('pokemonName')!;
|
||||
if (searchParams.get('pokemonType')) params.pokemonType = searchParams.get('pokemonType')!;
|
||||
if (searchParams.get('styleDescription')) params.styleDescription = searchParams.get('styleDescription')!;
|
||||
if (searchParams.get('language')) params.language = searchParams.get('language') as any;
|
||||
if (searchParams.get('hp')) params.hp = parseInt(searchParams.get('hp')!);
|
||||
if (searchParams.get('attackName1')) params.attackName1 = searchParams.get('attackName1')!;
|
||||
if (searchParams.get('attackDamage1')) params.attackDamage1 = parseInt(searchParams.get('attackDamage1')!);
|
||||
if (searchParams.get('attackName2')) params.attackName2 = searchParams.get('attackName2')!;
|
||||
if (searchParams.get('attackDamage2')) params.attackDamage2 = parseInt(searchParams.get('attackDamage2')!);
|
||||
if (searchParams.get('weakness')) params.weakness = searchParams.get('weakness')!;
|
||||
if (searchParams.get('resistance')) params.resistance = searchParams.get('resistance')!;
|
||||
if (searchParams.get('retreatCost')) params.retreatCost = parseInt(searchParams.get('retreatCost')!);
|
||||
// String fields
|
||||
const stringFields = ['photoDataUri', 'pokemonName', 'pokemonType', 'styleDescription', 'language', 'attackName1', 'attackName2', 'weakness', 'resistance'];
|
||||
stringFields.forEach(field => {
|
||||
const value = parseString(searchParams, field);
|
||||
if (value !== undefined) {
|
||||
(params as any)[field] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Number fields
|
||||
const numberFields = ['hp', 'attackDamage1', 'attackDamage2', 'retreatCost'];
|
||||
numberFields.forEach(field => {
|
||||
const value = parseNumber(searchParams, field);
|
||||
if (value !== undefined) {
|
||||
(params as any)[field] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
+60
-47
@@ -2,78 +2,90 @@
|
||||
* Utility functions for image processing and compression
|
||||
*/
|
||||
|
||||
import { IMAGE_DEFAULTS, ERROR_MESSAGES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Normalizes a base64 image to a data URL format
|
||||
* @param base64Image - Base64 string with or without data URL prefix
|
||||
* @param mimeType - MIME type for the image (default: image/jpeg)
|
||||
* @returns Data URL formatted string
|
||||
*/
|
||||
const normalizeToDataURL = (base64Image: string, mimeType: string = 'image/jpeg'): string => {
|
||||
return base64Image.startsWith('data:')
|
||||
? base64Image
|
||||
: `data:${mimeType};base64,${base64Image}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compress a base64 image to reduce file size
|
||||
* @param base64Image - Base64 encoded image string
|
||||
* @param maxWidth - Maximum width in pixels (default: 512)
|
||||
* @param maxHeight - Maximum height in pixels (default: 712) - Pokemon card aspect ratio
|
||||
* @param quality - JPEG quality 0-1 (default: 0.8)
|
||||
* @returns Promise<string> - Compressed base64 image
|
||||
* @returns Promise<string> - Compressed base64 image as data URL
|
||||
* @throws Error if image fails to load or compression fails
|
||||
*/
|
||||
export function compressBase64Image(
|
||||
base64Image: string,
|
||||
maxWidth: number = 512,
|
||||
maxHeight: number = 712,
|
||||
quality: number = 0.8
|
||||
maxWidth: number = IMAGE_DEFAULTS.MAX_WIDTH,
|
||||
maxHeight: number = IMAGE_DEFAULTS.MAX_HEIGHT,
|
||||
quality: number = IMAGE_DEFAULTS.QUALITY
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create an image element
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
// Create a canvas element
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Could not get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new dimensions while maintaining aspect ratio
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width;
|
||||
width = maxWidth;
|
||||
if (!ctx) {
|
||||
reject(new Error('Could not get canvas context'));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (height > maxHeight) {
|
||||
width = (width * maxHeight) / height;
|
||||
height = maxHeight;
|
||||
|
||||
// Calculate new dimensions while maintaining aspect ratio
|
||||
let { width, height } = img;
|
||||
|
||||
if (width > height) {
|
||||
if (width > maxWidth) {
|
||||
height = (height * maxWidth) / width;
|
||||
width = maxWidth;
|
||||
}
|
||||
} else {
|
||||
if (height > maxHeight) {
|
||||
width = (width * maxHeight) / height;
|
||||
height = maxHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Set canvas dimensions
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw and compress the image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Get compressed base64 string
|
||||
const compressedBase64 = canvas.toDataURL('image/jpeg', quality);
|
||||
resolve(compressedBase64);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
|
||||
// Set canvas dimensions
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw and compress the image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Get compressed base64 string
|
||||
const compressedBase64 = canvas.toDataURL('image/jpeg', quality);
|
||||
resolve(compressedBase64);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error('Failed to load image'));
|
||||
reject(new Error(ERROR_MESSAGES.IMAGE_LOAD_FAILED));
|
||||
};
|
||||
|
||||
// Handle both data URLs and plain base64
|
||||
const imageDataUrl = base64Image.startsWith('data:')
|
||||
? base64Image
|
||||
: `data:image/jpeg;base64,${base64Image}`;
|
||||
|
||||
img.src = imageDataUrl;
|
||||
img.src = normalizeToDataURL(base64Image);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the size of a base64 string in bytes
|
||||
* @param base64String - Base64 encoded string
|
||||
* @returns number - Size in bytes
|
||||
* @returns Size in bytes
|
||||
*/
|
||||
export function getBase64Size(base64String: string): number {
|
||||
// Remove data URL prefix if present
|
||||
@@ -89,14 +101,15 @@ export function getBase64Size(base64String: string): number {
|
||||
/**
|
||||
* Format bytes to human readable format
|
||||
* @param bytes - Number of bytes
|
||||
* @returns string - Formatted string (e.g., "1.2 MB")
|
||||
* @param decimals - Number of decimal places (default: 2)
|
||||
* @returns Formatted string (e.g., "1.2 MB")
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
+63
-74
@@ -7,6 +7,7 @@ export interface ShareOptions {
|
||||
}
|
||||
|
||||
import { uploadImageToStorage } from './storageUtils';
|
||||
import { isHttpUrl, isDataUri, isFirebaseStorageUrl } from '@/lib/validation';
|
||||
|
||||
// Cache for uploaded image URLs to avoid re-uploading the same image
|
||||
const uploadedImageCache = new Map<string, string>();
|
||||
@@ -26,7 +27,7 @@ const getOrUploadImageUrl = async (
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
// If the image URL is already a Firebase Storage URL or HTTP URL, return it directly
|
||||
if (cardImageUrl.startsWith('http')) {
|
||||
if (isHttpUrl(cardImageUrl)) {
|
||||
console.log('Using existing Firebase Storage URL:', cardImageUrl);
|
||||
return cardImageUrl;
|
||||
}
|
||||
@@ -62,9 +63,9 @@ const downloadImageAsBlob = async (imageDataUrl: string): Promise<Blob> => {
|
||||
// Helper function to copy image and text to clipboard
|
||||
export const copyImageAndText = async ({ cardName, cardImageUrl }: ShareOptions) => {
|
||||
try {
|
||||
const text = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
|
||||
const text = `Check out this AI-generated TCG card by Cardex: https://cardex.xavidop.me`;
|
||||
|
||||
if (cardImageUrl && cardImageUrl.startsWith('data:') && navigator.clipboard?.write) {
|
||||
if (cardImageUrl && isDataUri(cardImageUrl) && navigator.clipboard?.write) {
|
||||
// Copy both image and text to clipboard (modern browsers)
|
||||
const imageBlob = await downloadImageAsBlob(cardImageUrl);
|
||||
const clipboardItems = [
|
||||
@@ -86,31 +87,60 @@ export const copyImageAndText = async ({ cardName, cardImageUrl }: ShareOptions)
|
||||
}
|
||||
};
|
||||
|
||||
export const shareOnTwitter = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
|
||||
let shareText = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
|
||||
/**
|
||||
* Shared helper to resolve and upload image URL with fallback to clipboard
|
||||
* @returns Object with imageUrl and shareUrl for social sharing
|
||||
*/
|
||||
const resolveImageUrlForSharing = async (
|
||||
cardName: string,
|
||||
cardImageUrl: string | undefined,
|
||||
publicImageUrl: string | undefined,
|
||||
platform: string,
|
||||
onImageCopied?: (platform: string) => void,
|
||||
onImageUploaded?: (url: string) => void
|
||||
): Promise<{ imageUrl: string | null; shareUrl: string }> => {
|
||||
let imageUrl = publicImageUrl || null;
|
||||
let shareUrl = 'https://cardex.xavidop.me';
|
||||
|
||||
// If we have an image, try to get or upload it to get a public URL
|
||||
let imageUrl = publicImageUrl;
|
||||
if (!imageUrl && cardImageUrl) {
|
||||
if (cardImageUrl.startsWith('http')) {
|
||||
if (isHttpUrl(cardImageUrl)) {
|
||||
// Already a Firebase Storage URL, use directly
|
||||
imageUrl = cardImageUrl;
|
||||
} else if (cardImageUrl.startsWith('data:')) {
|
||||
shareUrl = imageUrl;
|
||||
} else if (isDataUri(cardImageUrl)) {
|
||||
// Base64 data URL, need to upload
|
||||
const uploadedUrl = await getOrUploadImageUrl(cardName, cardImageUrl, onImageUploaded);
|
||||
if (uploadedUrl) {
|
||||
imageUrl = uploadedUrl;
|
||||
shareUrl = imageUrl;
|
||||
} else {
|
||||
// If upload failed, fallback to copying to clipboard
|
||||
try {
|
||||
await copyImageAndText({ cardName, cardImageUrl });
|
||||
onImageCopied?.('Twitter/X');
|
||||
onImageCopied?.(platform);
|
||||
} catch (clipboardError) {
|
||||
console.warn('Failed to copy image to clipboard:', clipboardError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (imageUrl) {
|
||||
shareUrl = imageUrl;
|
||||
}
|
||||
|
||||
return { imageUrl, shareUrl };
|
||||
};
|
||||
|
||||
export const shareOnTwitter = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
|
||||
let shareText = `Check out this AI-generated TCG card by Cardex: https://cardex.xavidop.me`;
|
||||
|
||||
const { imageUrl } = await resolveImageUrlForSharing(
|
||||
cardName,
|
||||
cardImageUrl,
|
||||
publicImageUrl,
|
||||
'Twitter/X',
|
||||
onImageCopied,
|
||||
onImageUploaded
|
||||
);
|
||||
|
||||
// If we have a public image URL, include it in the tweet
|
||||
if (imageUrl) {
|
||||
@@ -122,77 +152,39 @@ export const shareOnTwitter = async ({ cardName, cardImageUrl, publicImageUrl, o
|
||||
};
|
||||
|
||||
export const shareOnFacebook = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
|
||||
let shareText = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
|
||||
let shareUrl = 'https://cardex.xavidop.me';
|
||||
const shareText = `Check out this AI-generated TCG card by Cardex: https://cardex.xavidop.me`;
|
||||
|
||||
// If we have an image, try to get or upload it to get a public URL
|
||||
let imageUrl = publicImageUrl;
|
||||
if (!imageUrl && cardImageUrl) {
|
||||
if (cardImageUrl.startsWith('http')) {
|
||||
// Already a Firebase Storage URL, use directly
|
||||
imageUrl = cardImageUrl;
|
||||
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
|
||||
} else if (cardImageUrl.startsWith('data:')) {
|
||||
// Base64 data URL, need to upload
|
||||
const uploadedUrl = await getOrUploadImageUrl(cardName, cardImageUrl, onImageUploaded);
|
||||
if (uploadedUrl) {
|
||||
imageUrl = uploadedUrl;
|
||||
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
|
||||
} else {
|
||||
// If upload failed, fallback to copying to clipboard
|
||||
try {
|
||||
await copyImageAndText({ cardName, cardImageUrl });
|
||||
onImageCopied?.('Facebook');
|
||||
} catch (clipboardError) {
|
||||
console.warn('Failed to copy image to clipboard:', clipboardError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (imageUrl) {
|
||||
shareUrl = imageUrl;
|
||||
}
|
||||
const { shareUrl } = await resolveImageUrlForSharing(
|
||||
cardName,
|
||||
cardImageUrl,
|
||||
publicImageUrl,
|
||||
'Facebook',
|
||||
onImageCopied,
|
||||
onImageUploaded
|
||||
);
|
||||
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}"e=${encodeURIComponent(shareText)}`;
|
||||
window.open(url, '_blank', 'width=580,height=296');
|
||||
};
|
||||
|
||||
export const shareOnLinkedIn = async ({ cardName, cardImageUrl, publicImageUrl, onImageCopied, onImageUploaded }: ShareOptions) => {
|
||||
let shareText = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
|
||||
let shareUrl = 'https://cardex.xavidop.me';
|
||||
const shareText = `Check out this AI-generated TCG card by Cardex: https://cardex.xavidop.me`;
|
||||
|
||||
// If we have an image, try to get or upload it to get a public URL
|
||||
let imageUrl = publicImageUrl;
|
||||
if (!imageUrl && cardImageUrl) {
|
||||
if (cardImageUrl.startsWith('http')) {
|
||||
// Already a Firebase Storage URL, use directly
|
||||
imageUrl = cardImageUrl;
|
||||
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
|
||||
} else if (cardImageUrl.startsWith('data:')) {
|
||||
// Base64 data URL, need to upload
|
||||
const uploadedUrl = await getOrUploadImageUrl(cardName, cardImageUrl, onImageUploaded);
|
||||
if (uploadedUrl) {
|
||||
imageUrl = uploadedUrl;
|
||||
shareUrl = imageUrl; // Use the image URL as the main share URL for better preview
|
||||
} else {
|
||||
// If upload failed, fallback to copying to clipboard
|
||||
try {
|
||||
await copyImageAndText({ cardName, cardImageUrl });
|
||||
onImageCopied?.('LinkedIn');
|
||||
} catch (clipboardError) {
|
||||
console.warn('Failed to copy image to clipboard:', clipboardError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (imageUrl) {
|
||||
shareUrl = imageUrl;
|
||||
}
|
||||
const { shareUrl } = await resolveImageUrlForSharing(
|
||||
cardName,
|
||||
cardImageUrl,
|
||||
publicImageUrl,
|
||||
'LinkedIn',
|
||||
onImageCopied,
|
||||
onImageUploaded
|
||||
);
|
||||
|
||||
const url = `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(`AI-Generated Pokémon Card: ${cardName}`)}&summary=${encodeURIComponent(shareText)}&source=Cardex`;
|
||||
const url = `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(`AI-Generated TCG Card: ${cardName}`)}&summary=${encodeURIComponent(shareText)}&source=Cardex`;
|
||||
window.open(url, '_blank', 'width=520,height=570');
|
||||
};
|
||||
|
||||
export const copyShareLink = ({ cardName }: ShareOptions) => {
|
||||
const text = `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`;
|
||||
const text = `Check out this AI-generated TCG card by Cardex: https://cardex.xavidop.me`;
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
@@ -201,8 +193,8 @@ export const nativeShare = async ({ cardName, cardImageUrl }: ShareOptions) => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
const shareData: ShareData = {
|
||||
title: `AI-Generated Pokémon Card`,
|
||||
text: `Check out this AI-generated Pokémon card by Cardex: https://cardex.xavidop.me`,
|
||||
title: `AI-Generated TCG Card`,
|
||||
text: `Check out this AI-generated TCG card by Cardex: https://cardex.xavidop.me`,
|
||||
url: 'https://cardex.xavidop.me',
|
||||
};
|
||||
|
||||
@@ -212,10 +204,7 @@ export const nativeShare = async ({ cardName, cardImageUrl }: ShareOptions) => {
|
||||
let fetchUrl = cardImageUrl;
|
||||
|
||||
// If it's a Firebase Storage URL (not a data URL), use the download route
|
||||
if (cardImageUrl.startsWith('http') &&
|
||||
(cardImageUrl.includes('firebasestorage.googleapis.com') ||
|
||||
cardImageUrl.includes('localhost:9199') ||
|
||||
cardImageUrl.includes('127.0.0.1:9199'))) {
|
||||
if (isHttpUrl(cardImageUrl) && isFirebaseStorageUrl(cardImageUrl)) {
|
||||
fetchUrl = `/api/download?url=${encodeURIComponent(cardImageUrl)}`;
|
||||
}
|
||||
|
||||
|
||||
+100
-69
@@ -1,7 +1,12 @@
|
||||
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
||||
import { storage } from '@/lib/firebase';
|
||||
import { STORAGE_PATHS, CACHE_CONTROL, ERROR_MESSAGES } from '@/constants';
|
||||
|
||||
// Convert base64 data URL to blob
|
||||
/**
|
||||
* Converts a data URL to a Blob
|
||||
* @param dataURL - Data URL string
|
||||
* @returns Blob object
|
||||
*/
|
||||
const dataURLToBlob = (dataURL: string): Blob => {
|
||||
const arr = dataURL.split(',');
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png';
|
||||
@@ -14,55 +19,11 @@ const dataURLToBlob = (dataURL: string): Blob => {
|
||||
return new Blob([u8arr], { type: mime });
|
||||
};
|
||||
|
||||
// Upload image to Firebase Storage and return public URL
|
||||
export const uploadImageToStorage = async (
|
||||
imageDataUrl: string,
|
||||
cardName: string,
|
||||
userId?: string
|
||||
): Promise<string> => {
|
||||
try {
|
||||
// Convert data URL to blob
|
||||
const blob = dataURLToBlob(imageDataUrl);
|
||||
|
||||
// Create a unique filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const sanitizedCardName = cardName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
|
||||
|
||||
// Determine the storage path based on whether userId is provided
|
||||
const filename = userId
|
||||
? `users/${userId}/cards/${sanitizedCardName}_${timestamp}.png`
|
||||
: `cards/${sanitizedCardName}_${timestamp}.png`;
|
||||
|
||||
// Create storage reference
|
||||
const storageRef = ref(storage, filename);
|
||||
|
||||
// Upload the blob
|
||||
const snapshot = await uploadBytes(storageRef, blob, {
|
||||
contentType: 'image/png',
|
||||
cacheControl: 'public, max-age=31536000', // 1 year cache
|
||||
});
|
||||
|
||||
// Get the download URL
|
||||
const downloadURL = await getDownloadURL(snapshot.ref);
|
||||
|
||||
console.log('Image uploaded successfully:', downloadURL);
|
||||
return downloadURL;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image to storage:', error);
|
||||
throw new Error('Failed to upload image to storage');
|
||||
}
|
||||
};
|
||||
|
||||
// Upload card image to Firebase Storage in user's collection folder
|
||||
export const uploadCardImageToStorage = async (
|
||||
imageDataUrl: string,
|
||||
userId: string,
|
||||
cardName: string
|
||||
): Promise<string> => {
|
||||
return uploadImageToStorage(imageDataUrl, cardName, userId);
|
||||
};
|
||||
|
||||
// Convert base64 video data to blob
|
||||
/**
|
||||
* Converts base64 video data to a Blob
|
||||
* @param base64Data - Base64 encoded video string
|
||||
* @returns Blob object with video/mp4 MIME type
|
||||
*/
|
||||
const base64ToVideoBlob = (base64Data: string): Blob => {
|
||||
const binaryString = atob(base64Data);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
@@ -72,50 +33,120 @@ const base64ToVideoBlob = (base64Data: string): Blob => {
|
||||
return new Blob([bytes], { type: 'video/mp4' });
|
||||
};
|
||||
|
||||
// Upload video to Firebase Storage and return public URL
|
||||
// This function handles base64 video data only
|
||||
/**
|
||||
* Sanitizes a card name for use in filenames
|
||||
* @param cardName - The card name to sanitize
|
||||
* @returns Sanitized lowercase string
|
||||
*/
|
||||
const sanitizeCardName = (cardName: string): string => {
|
||||
return cardName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload image to Firebase Storage and return public URL
|
||||
* @param imageDataUrl - Data URL of the image
|
||||
* @param cardName - Name of the card for filename
|
||||
* @param userId - Optional user ID for user-specific storage path
|
||||
* @returns Promise resolving to the download URL
|
||||
* @throws Error if upload fails
|
||||
*/
|
||||
export const uploadImageToStorage = async (
|
||||
imageDataUrl: string,
|
||||
cardName: string,
|
||||
userId?: string
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const blob = dataURLToBlob(imageDataUrl);
|
||||
const timestamp = Date.now();
|
||||
const sanitizedCardName = sanitizeCardName(cardName);
|
||||
|
||||
// Determine the storage path based on whether userId is provided
|
||||
const filename = userId
|
||||
? `${STORAGE_PATHS.USER_CARDS(userId)}/${sanitizedCardName}_${timestamp}.png`
|
||||
: `${STORAGE_PATHS.SHARED_CARDS}/${sanitizedCardName}_${timestamp}.png`;
|
||||
|
||||
const storageRef = ref(storage, filename);
|
||||
|
||||
const snapshot = await uploadBytes(storageRef, blob, {
|
||||
contentType: 'image/png',
|
||||
cacheControl: CACHE_CONTROL.ONE_YEAR,
|
||||
});
|
||||
|
||||
const downloadURL = await getDownloadURL(snapshot.ref);
|
||||
console.log('Image uploaded successfully:', downloadURL);
|
||||
|
||||
return downloadURL;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image to storage:', error);
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_UPLOAD_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload card image to Firebase Storage in user's collection folder
|
||||
* @param imageDataUrl - Data URL of the image
|
||||
* @param userId - User ID for storage path
|
||||
* @param cardName - Name of the card for filename
|
||||
* @returns Promise resolving to the download URL
|
||||
*/
|
||||
export const uploadCardImageToStorage = async (
|
||||
imageDataUrl: string,
|
||||
userId: string,
|
||||
cardName: string
|
||||
): Promise<string> => {
|
||||
return uploadImageToStorage(imageDataUrl, cardName, userId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload video to Firebase Storage and return public URL
|
||||
* This function handles base64 video data only
|
||||
* @param videoBase64 - Base64 encoded video string
|
||||
* @param userId - User ID for storage path
|
||||
* @param cardName - Name of the card for filename
|
||||
* @returns Promise resolving to the download URL
|
||||
* @throws Error if input is a URL instead of base64 data, or if upload fails
|
||||
*/
|
||||
export const uploadVideoToStorage = async (
|
||||
videoBase64: string,
|
||||
userId: string,
|
||||
cardName: string
|
||||
): Promise<string> => {
|
||||
try {
|
||||
// Check if this is actually a URL instead of base64 data (this shouldn't happen)
|
||||
// Validate input - should be base64 data, not a URL
|
||||
if (videoBase64.startsWith('http')) {
|
||||
throw new Error('uploadVideoToStorage received a URL instead of base64 data. Video should be downloaded first.');
|
||||
}
|
||||
|
||||
// Convert base64 to blob
|
||||
const blob = base64ToVideoBlob(videoBase64);
|
||||
|
||||
// Create a unique filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const sanitizedCardName = cardName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
|
||||
const filename = `users/${userId}/videos/${sanitizedCardName}_${timestamp}.mp4`;
|
||||
const sanitizedCardName = sanitizeCardName(cardName);
|
||||
const filename = `${STORAGE_PATHS.USER_VIDEOS(userId)}/${sanitizedCardName}_${timestamp}.mp4`;
|
||||
|
||||
// Create storage reference
|
||||
const storageRef = ref(storage, filename);
|
||||
|
||||
// Upload the blob
|
||||
const snapshot = await uploadBytes(storageRef, blob, {
|
||||
contentType: 'video/mp4',
|
||||
cacheControl: 'public, max-age=31536000', // 1 year cache
|
||||
cacheControl: CACHE_CONTROL.ONE_YEAR,
|
||||
});
|
||||
|
||||
// Get the download URL
|
||||
const downloadURL = await getDownloadURL(snapshot.ref);
|
||||
|
||||
console.log('Video uploaded successfully to Firebase Storage:', downloadURL);
|
||||
|
||||
return downloadURL;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload video to storage:', error);
|
||||
throw new Error('Failed to upload video to storage');
|
||||
throw new Error(ERROR_MESSAGES.VIDEO_UPLOAD_FAILED);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up old shared images (optional, can be called periodically)
|
||||
export const cleanupOldSharedImages = async (olderThanDays: number = 7) => {
|
||||
// This would require Firebase Admin SDK or Cloud Functions
|
||||
// For now, we'll rely on Firebase Storage lifecycle rules
|
||||
console.log(`Cleanup of images older than ${olderThanDays} days should be configured in Firebase Storage lifecycle rules`);
|
||||
/**
|
||||
* Clean up old shared images (optional, can be called periodically)
|
||||
* Note: This requires Firebase Admin SDK or Cloud Functions
|
||||
* For now, rely on Firebase Storage lifecycle rules
|
||||
* @param olderThanDays - Number of days after which to clean up images
|
||||
*/
|
||||
export const cleanupOldSharedImages = async (olderThanDays: number = 7): Promise<void> => {
|
||||
console.log(
|
||||
`Cleanup of images older than ${olderThanDays} days should be configured in Firebase Storage lifecycle rules`
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user