mirror of
https://github.com/xavidop/cardex.git
synced 2026-08-31 18:28:51 +00:00
feat: refactors
This commit is contained in:
+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