mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
✨ feat(figgos): AI generation pipeline + frontend API integration
Backend: - Add Gemini-powered profile generation (text + image) - Add image processing with background removal (sharp) - Add S3 storage service for figure images - Extend figures schema with generatedProfile, language, status columns - Wire up synchronous generation pipeline on POST /api/v1/figures Frontend (Mobile + Web): - Replace all mock data with real API calls - Show generatedProfile data (subtitle, backstory, stats, items, specialAttack) - Display generated images from S3 or name placeholders - Create web API service ($lib/api.ts) - Delete mock cards data files Infrastructure: - Add CORS origin for web dev port (5196) - Add GEMINI_API_KEY + FIGGOS_STORAGE_PUBLIC_URL to env generation - Add figgos-storage bucket to shared-storage factory - Add .gitignore for workbench/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
49c6ecc377
commit
9d189b1331
37 changed files with 2521 additions and 622 deletions
1
apps/figgos/.gitignore
vendored
Normal file
1
apps/figgos/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
workbench/
|
||||||
|
|
@ -18,11 +18,14 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@figgos/shared": "workspace:*",
|
"@figgos/shared": "workspace:*",
|
||||||
|
"@google/genai": "^1.14.0",
|
||||||
|
"@huggingface/transformers": "^3.8.1",
|
||||||
"@manacore/shared-drizzle-config": "workspace:*",
|
"@manacore/shared-drizzle-config": "workspace:*",
|
||||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||||
"@manacore/shared-nestjs-health": "workspace:*",
|
"@manacore/shared-nestjs-health": "workspace:*",
|
||||||
"@manacore/shared-nestjs-metrics": "workspace:*",
|
"@manacore/shared-nestjs-metrics": "workspace:*",
|
||||||
"@manacore/shared-nestjs-setup": "workspace:*",
|
"@manacore/shared-nestjs-setup": "workspace:*",
|
||||||
|
"@manacore/shared-storage": "workspace:*",
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
"@nestjs/core": "^10.4.15",
|
"@nestjs/core": "^10.4.15",
|
||||||
|
|
@ -35,7 +38,8 @@
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"prom-client": "^15.1.0",
|
"prom-client": "^15.1.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.4.9",
|
"@nestjs/cli": "^10.4.9",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ import {
|
||||||
jsonb,
|
jsonb,
|
||||||
index,
|
index,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
import type { FigureRarity, FigureUserInput } from '@figgos/shared';
|
import type {
|
||||||
|
FigureRarity,
|
||||||
|
FigureUserInput,
|
||||||
|
GeneratedProfile,
|
||||||
|
FigureLanguage,
|
||||||
|
FigureStatus,
|
||||||
|
} from '@figgos/shared';
|
||||||
|
|
||||||
export const figures = pgTable(
|
export const figures = pgTable(
|
||||||
'figures',
|
'figures',
|
||||||
|
|
@ -17,8 +23,12 @@ export const figures = pgTable(
|
||||||
userId: text('user_id').notNull(),
|
userId: text('user_id').notNull(),
|
||||||
name: varchar('name', { length: 200 }).notNull(),
|
name: varchar('name', { length: 200 }).notNull(),
|
||||||
userInput: jsonb('user_input').$type<FigureUserInput>().notNull(),
|
userInput: jsonb('user_input').$type<FigureUserInput>().notNull(),
|
||||||
|
generatedProfile: jsonb('generated_profile').$type<GeneratedProfile>(),
|
||||||
imageUrl: text('image_url'),
|
imageUrl: text('image_url'),
|
||||||
rarity: varchar('rarity', { length: 20 }).default('common').notNull().$type<FigureRarity>(),
|
rarity: varchar('rarity', { length: 20 }).default('common').notNull().$type<FigureRarity>(),
|
||||||
|
language: varchar('language', { length: 5 }).default('en').notNull().$type<FigureLanguage>(),
|
||||||
|
status: varchar('status', { length: 20 }).default('pending').notNull().$type<FigureStatus>(),
|
||||||
|
errorMessage: text('error_message'),
|
||||||
isPublic: boolean('is_public').default(false).notNull(),
|
isPublic: boolean('is_public').default(false).notNull(),
|
||||||
isArchived: boolean('is_archived').default(false).notNull(),
|
isArchived: boolean('is_archived').default(false).notNull(),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { IsString, IsNotEmpty, MaxLength, MinLength } from 'class-validator';
|
import { IsString, IsNotEmpty, MaxLength, MinLength, IsOptional, IsIn } from 'class-validator';
|
||||||
|
import type { FigureLanguage } from '@figgos/shared';
|
||||||
|
|
||||||
export class CreateFigureDto {
|
export class CreateFigureDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|
@ -12,4 +13,9 @@ export class CreateFigureDto {
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
@MaxLength(2000)
|
@MaxLength(2000)
|
||||||
description!: string;
|
description!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['en', 'de'])
|
||||||
|
language?: FigureLanguage = 'en';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,12 @@ export class FiguresController {
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFigureDto) {
|
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFigureDto) {
|
||||||
const figure = await this.figuresService.create(user.userId, dto.name, dto.description);
|
const figure = await this.figuresService.create(
|
||||||
|
user.userId,
|
||||||
|
dto.name,
|
||||||
|
dto.description,
|
||||||
|
dto.language || 'en'
|
||||||
|
);
|
||||||
return { figure };
|
return { figure };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { FiguresController } from './figures.controller';
|
import { FiguresController } from './figures.controller';
|
||||||
import { FiguresService } from './figures.service';
|
import { FiguresService } from './figures.service';
|
||||||
|
import { GenerationModule } from '../generation/generation.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [GenerationModule],
|
||||||
controllers: [FiguresController],
|
controllers: [FiguresController],
|
||||||
providers: [FiguresService],
|
providers: [FiguresService],
|
||||||
exports: [FiguresService],
|
exports: [FiguresService],
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,20 @@ import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
import type { Database } from '../db/connection';
|
import type { Database } from '../db/connection';
|
||||||
import { figures } from '../db/schema';
|
import { figures } from '../db/schema';
|
||||||
import type { Figure } from '../db/schema';
|
import type { Figure } from '../db/schema';
|
||||||
import { RARITY_WEIGHTS, type FigureRarity } from '@figgos/shared';
|
import {
|
||||||
|
RARITY_WEIGHTS,
|
||||||
|
getCardStyle,
|
||||||
|
type FigureRarity,
|
||||||
|
type FigureLanguage,
|
||||||
|
} from '@figgos/shared';
|
||||||
|
import { GenerationService } from '../generation/generation.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FiguresService {
|
export class FiguresService {
|
||||||
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
|
constructor(
|
||||||
|
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||||
|
private readonly generationService: GenerationService
|
||||||
|
) {}
|
||||||
|
|
||||||
rollRarity(): FigureRarity {
|
rollRarity(): FigureRarity {
|
||||||
const total = Object.values(RARITY_WEIGHTS).reduce((sum, w) => sum + w, 0);
|
const total = Object.values(RARITY_WEIGHTS).reduce((sum, w) => sum + w, 0);
|
||||||
|
|
@ -20,20 +29,38 @@ export class FiguresService {
|
||||||
return 'common';
|
return 'common';
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(userId: string, name: string, description: string): Promise<Figure> {
|
async create(
|
||||||
|
userId: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
language: FigureLanguage = 'en'
|
||||||
|
): Promise<Figure> {
|
||||||
const rarity = this.rollRarity();
|
const rarity = this.rollRarity();
|
||||||
|
const cardStyle = getCardStyle(rarity);
|
||||||
|
|
||||||
|
// Insert with status pending
|
||||||
const [figure] = await this.db
|
const [figure] = await this.db
|
||||||
.insert(figures)
|
.insert(figures)
|
||||||
.values({
|
.values({
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
userInput: { description },
|
|
||||||
rarity,
|
rarity,
|
||||||
|
language,
|
||||||
|
userInput: { description, language },
|
||||||
|
status: 'pending',
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return figure;
|
// Run full generation pipeline (synchronous)
|
||||||
|
const completed = await this.generationService.generateFigure(figure.id, userId, {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
language,
|
||||||
|
rarity,
|
||||||
|
cardStyle,
|
||||||
|
});
|
||||||
|
|
||||||
|
return completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByUserId(userId: string): Promise<Figure[]> {
|
async findByUserId(userId: string): Promise<Figure[]> {
|
||||||
|
|
|
||||||
138
apps/figgos/apps/backend/src/generation/gemini.service.ts
Normal file
138
apps/figgos/apps/backend/src/generation/gemini.service.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type { CardStyle, FigureLanguage, FigureRarity, GeneratedProfile } from '@figgos/shared';
|
||||||
|
import { STAT_RANGES } from '@figgos/shared';
|
||||||
|
import { GoogleGenAI } from '@google/genai';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
PROFILE_JSON_SCHEMA,
|
||||||
|
PROFILE_SYSTEM_PROMPT,
|
||||||
|
buildImagePrompt,
|
||||||
|
buildProfileUserPrompt,
|
||||||
|
} from './prompts';
|
||||||
|
|
||||||
|
const TEXT_MODEL = 'gemini-3-flash-preview';
|
||||||
|
const IMAGE_MODEL = 'gemini-2.5-flash-image';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GeminiService {
|
||||||
|
private readonly logger = new Logger(GeminiService.name);
|
||||||
|
private readonly client: GoogleGenAI;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
const apiKey = this.config.get<string>('GEMINI_API_KEY');
|
||||||
|
if (!apiKey) {
|
||||||
|
this.logger.warn('GEMINI_API_KEY not set — generation will fail');
|
||||||
|
}
|
||||||
|
this.client = new GoogleGenAI({ apiKey: apiKey || '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateProfile(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
rarity: FigureRarity,
|
||||||
|
language: FigureLanguage
|
||||||
|
): Promise<GeneratedProfile> {
|
||||||
|
const statRange = STAT_RANGES[rarity];
|
||||||
|
const userPrompt = buildProfileUserPrompt(name, description, rarity, statRange, language);
|
||||||
|
|
||||||
|
this.logger.log(`Generating profile for "${name}" (${rarity})...`);
|
||||||
|
|
||||||
|
const response = await this.client.models.generateContent({
|
||||||
|
model: TEXT_MODEL,
|
||||||
|
contents: userPrompt,
|
||||||
|
config: {
|
||||||
|
systemInstruction: PROFILE_SYSTEM_PROMPT,
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
responseSchema: PROFILE_JSON_SCHEMA,
|
||||||
|
temperature: 1.0,
|
||||||
|
thinkingConfig: { thinkingBudget: 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = response.text;
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Gemini returned empty text response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!parsed.subtitle || !parsed.backstory || !parsed.visualDescription) {
|
||||||
|
throw new Error('Profile missing required text fields');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed.items) || parsed.items.length !== 3) {
|
||||||
|
throw new Error(`Expected 3 items, got ${parsed.items?.length}`);
|
||||||
|
}
|
||||||
|
if (!parsed.stats || typeof parsed.stats.attack !== 'number') {
|
||||||
|
throw new Error('Profile has invalid stats');
|
||||||
|
}
|
||||||
|
if (!parsed.specialAttack?.name || !parsed.specialAttack?.description) {
|
||||||
|
throw new Error('Profile missing specialAttack');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp stats to valid range
|
||||||
|
const clamp = (v: number) => Math.max(statRange.min, Math.min(statRange.max, v));
|
||||||
|
parsed.stats.attack = clamp(parsed.stats.attack);
|
||||||
|
parsed.stats.defense = clamp(parsed.stats.defense);
|
||||||
|
parsed.stats.special = clamp(parsed.stats.special);
|
||||||
|
|
||||||
|
const profile = parsed as GeneratedProfile;
|
||||||
|
this.logger.log(`Profile generated: "${profile.subtitle}"`);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateImage(
|
||||||
|
name: string,
|
||||||
|
subtitle: string,
|
||||||
|
visualDescription: string,
|
||||||
|
items: string[],
|
||||||
|
cardStyle: CardStyle,
|
||||||
|
faceImageUrl?: string | null
|
||||||
|
): Promise<Buffer> {
|
||||||
|
const prompt = buildImagePrompt(
|
||||||
|
name,
|
||||||
|
subtitle,
|
||||||
|
visualDescription,
|
||||||
|
items,
|
||||||
|
cardStyle,
|
||||||
|
!!faceImageUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Generating image for "${name}" (${cardStyle})...`);
|
||||||
|
|
||||||
|
// Build contents array — if face image provided, include it
|
||||||
|
const contents: Array<string | { inlineData: { mimeType: string; data: string } }> = [];
|
||||||
|
|
||||||
|
if (faceImageUrl) {
|
||||||
|
// TODO: Download face image from S3, convert to base64, add as inline data
|
||||||
|
// For now, face transfer is not yet supported in the backend
|
||||||
|
this.logger.warn('Face transfer not yet implemented in backend');
|
||||||
|
}
|
||||||
|
|
||||||
|
contents.push(prompt);
|
||||||
|
|
||||||
|
const response = await this.client.models.generateContent({
|
||||||
|
model: IMAGE_MODEL,
|
||||||
|
contents,
|
||||||
|
config: {
|
||||||
|
responseModalities: ['IMAGE', 'TEXT'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract image from response
|
||||||
|
const parts = response.candidates?.[0]?.content?.parts;
|
||||||
|
if (!parts) {
|
||||||
|
throw new Error('Gemini returned no content parts');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.inlineData?.data) {
|
||||||
|
const buffer = Buffer.from(part.inlineData.data, 'base64');
|
||||||
|
this.logger.log(`Image generated: ${(buffer.length / 1024).toFixed(0)} KB`);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Gemini returned no image data');
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/figgos/apps/backend/src/generation/generation.module.ts
Normal file
12
apps/figgos/apps/backend/src/generation/generation.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { GenerationService } from './generation.service';
|
||||||
|
import { GeminiService } from './gemini.service';
|
||||||
|
import { ImageProcessingService } from './image-processing.service';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [StorageModule],
|
||||||
|
providers: [GenerationService, GeminiService, ImageProcessingService],
|
||||||
|
exports: [GenerationService],
|
||||||
|
})
|
||||||
|
export class GenerationModule {}
|
||||||
120
apps/figgos/apps/backend/src/generation/generation.service.ts
Normal file
120
apps/figgos/apps/backend/src/generation/generation.service.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||||
|
import type { Database } from '../db/connection';
|
||||||
|
import { figures } from '../db/schema';
|
||||||
|
import type { Figure } from '../db/schema';
|
||||||
|
import type {
|
||||||
|
FigureLanguage,
|
||||||
|
FigureRarity,
|
||||||
|
FigureStatus,
|
||||||
|
CardStyle,
|
||||||
|
GeneratedProfile,
|
||||||
|
} from '@figgos/shared';
|
||||||
|
import { GeminiService } from './gemini.service';
|
||||||
|
import { ImageProcessingService } from './image-processing.service';
|
||||||
|
import { StorageService } from '../storage/storage.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GenerationService {
|
||||||
|
private readonly logger = new Logger(GenerationService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DATABASE_CONNECTION) private db: Database,
|
||||||
|
private readonly geminiService: GeminiService,
|
||||||
|
private readonly imageProcessingService: ImageProcessingService,
|
||||||
|
private readonly storageService: StorageService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generateFigure(
|
||||||
|
figureId: string,
|
||||||
|
userId: string,
|
||||||
|
input: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
language: FigureLanguage;
|
||||||
|
rarity: FigureRarity;
|
||||||
|
cardStyle: CardStyle;
|
||||||
|
}
|
||||||
|
): Promise<Figure> {
|
||||||
|
try {
|
||||||
|
// Phase 1: Generate profile via LLM
|
||||||
|
await this.updateStatus(figureId, 'generating_profile');
|
||||||
|
const profile = await this.geminiService.generateProfile(
|
||||||
|
input.name,
|
||||||
|
input.description,
|
||||||
|
input.rarity,
|
||||||
|
input.language
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save profile immediately (even if image gen fails, we keep the text)
|
||||||
|
await this.updateProfile(figureId, profile);
|
||||||
|
await this.updateStatus(figureId, 'generating_image');
|
||||||
|
|
||||||
|
// Phase 2: Generate image
|
||||||
|
const itemLabels = profile.items.map((item) => `${item.name} — ${item.description}`);
|
||||||
|
const pngBuffer = await this.geminiService.generateImage(
|
||||||
|
input.name,
|
||||||
|
profile.subtitle,
|
||||||
|
profile.visualDescription,
|
||||||
|
itemLabels,
|
||||||
|
input.cardStyle
|
||||||
|
);
|
||||||
|
|
||||||
|
// Phase 3: Process image (bg removal + WebP)
|
||||||
|
await this.updateStatus(figureId, 'processing');
|
||||||
|
const webpBuffer = await this.imageProcessingService.removeBackground(pngBuffer);
|
||||||
|
|
||||||
|
// Phase 4: Upload to S3
|
||||||
|
const imageUrl = await this.storageService.uploadFigureImage(userId, figureId, webpBuffer);
|
||||||
|
|
||||||
|
// Phase 5: Finalize
|
||||||
|
const [completed] = await this.db
|
||||||
|
.update(figures)
|
||||||
|
.set({
|
||||||
|
imageUrl,
|
||||||
|
status: 'completed',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(figures.id, figureId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
this.logger.log(`Figure "${input.name}" generation completed`);
|
||||||
|
return completed;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
this.logger.error(`Generation failed for "${input.name}": ${message}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [failed] = await this.db
|
||||||
|
.update(figures)
|
||||||
|
.set({
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: message,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(figures.id, figureId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return failed;
|
||||||
|
} catch (dbError) {
|
||||||
|
this.logger.error(`Failed to update error status for figure ${figureId}`, dbError);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateStatus(figureId: string, status: FigureStatus): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(figures)
|
||||||
|
.set({ status, updatedAt: new Date() })
|
||||||
|
.where(eq(figures.id, figureId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateProfile(figureId: string, profile: GeneratedProfile): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.update(figures)
|
||||||
|
.set({ generatedProfile: profile, updatedAt: new Date() })
|
||||||
|
.where(eq(figures.id, figureId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
type BgRemovalMethod = 'feathered' | 'rmbg';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ImageProcessingService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ImageProcessingService.name);
|
||||||
|
private method: BgRemovalMethod;
|
||||||
|
private segmenter: any = null;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
this.method = (this.config.get<string>('BG_REMOVAL_METHOD') || 'feathered') as BgRemovalMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.logger.log(`Background removal method: ${this.method}`);
|
||||||
|
if (this.method === 'rmbg') {
|
||||||
|
this.logger.log('RMBG-1.4 model will be lazy-loaded on first use');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeBackground(pngBuffer: Buffer): Promise<Buffer> {
|
||||||
|
if (this.method === 'rmbg') {
|
||||||
|
return this.removeWithRmbg(pngBuffer);
|
||||||
|
}
|
||||||
|
return this.removeWithThreshold(pngBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feathered threshold background removal (~77ms).
|
||||||
|
* Removes near-white pixels with a soft edge transition.
|
||||||
|
* T=240, feather=10. Output: WebP quality 85.
|
||||||
|
*/
|
||||||
|
private async removeWithThreshold(inputBuffer: Buffer): Promise<Buffer> {
|
||||||
|
const { data, info } = await sharp(inputBuffer)
|
||||||
|
.ensureAlpha()
|
||||||
|
.raw()
|
||||||
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
|
const T = 240;
|
||||||
|
const F = 10;
|
||||||
|
const low = T - F;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const m = Math.min(data[i], data[i + 1], data[i + 2]);
|
||||||
|
if (m > T) {
|
||||||
|
data[i + 3] = 0;
|
||||||
|
} else if (m > low) {
|
||||||
|
data[i + 3] = Math.min(data[i + 3], Math.round((255 * (T - m)) / F));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sharp(data, { raw: { width: info.width, height: info.height, channels: 4 } })
|
||||||
|
.webp({ quality: 85 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RMBG-1.4 AI background removal (~1s).
|
||||||
|
* Uses @huggingface/transformers pipeline. Model is lazy-loaded and cached.
|
||||||
|
*/
|
||||||
|
private async removeWithRmbg(inputBuffer: Buffer): Promise<Buffer> {
|
||||||
|
if (!this.segmenter) {
|
||||||
|
this.logger.log('Loading RMBG-1.4 model (first use)...');
|
||||||
|
const { pipeline } = await import('@huggingface/transformers');
|
||||||
|
this.segmenter = await pipeline('background-removal', 'briaai/RMBG-1.4');
|
||||||
|
this.logger.log('RMBG-1.4 model loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// HF transformers pipeline needs a file path or URL, not a buffer
|
||||||
|
// Write to a temp file, process, then clean up
|
||||||
|
const { writeFile, unlink } = await import('node:fs/promises');
|
||||||
|
const { join } = await import('node:path');
|
||||||
|
const { tmpdir } = await import('node:os');
|
||||||
|
const { randomUUID } = await import('node:crypto');
|
||||||
|
const tmpPath = join(tmpdir(), `figgos-rmbg-${randomUUID()}.png`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(tmpPath, inputBuffer);
|
||||||
|
const result = await this.segmenter(tmpPath);
|
||||||
|
const img = Array.isArray(result) ? result[0] : result;
|
||||||
|
const { data, width, height, channels } = img;
|
||||||
|
const buf = Buffer.from(data);
|
||||||
|
|
||||||
|
return sharp(buf, { raw: { width, height, channels } }).webp({ quality: 85 }).toBuffer();
|
||||||
|
} finally {
|
||||||
|
await unlink(tmpPath).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
228
apps/figgos/apps/backend/src/generation/prompts.ts
Normal file
228
apps/figgos/apps/backend/src/generation/prompts.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import type { CardStyle, FigureLanguage } from '@figgos/shared';
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// Profile Generation — System Prompt
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const PROFILE_SYSTEM_PROMPT = `You are the creative engine behind FIGGOS — a collectible action figure game. Users give you a name and a short description of a character, and you generate a full character profile for their collectible figure card.
|
||||||
|
|
||||||
|
The figure will be rendered as a hyperrealistic miniature sculpture (6 inches tall) inside toy blister packaging. Your job is to flesh out the character with personality, lore, and visual detail.
|
||||||
|
|
||||||
|
IMPORTANT RULES:
|
||||||
|
- The \`visualDescription\` must describe ONLY the figure itself (person, clothing, pose, expression). NOT the packaging, card, or background. It will be inserted into an image prompt after "In the left compartment stands the figure:".
|
||||||
|
- The \`visualDescription\` should be grounded and specific — real fabrics, real colors, real materials. Avoid vague fantasy language. Think "rumpled beige linen trenchcoat" not "magical cloak of mystery".
|
||||||
|
- IMPORTANT: Never use pure white for clothing, accessories, or any part of the figure. Use off-white, cream, ivory, light gray, or eggshell instead. The figure will be photographed on a white background, so pure white areas would blend into the background.
|
||||||
|
- Items must be concrete physical objects that can be shown as miniature accessories in blister compartments. Think action figure accessories: weapons, tools, personal objects. NOT abstract concepts.
|
||||||
|
- The backstory should be dramatic but concise. Written like the back of a trading card.
|
||||||
|
- Stats must be within the provided range for the rarity level.
|
||||||
|
- The \`specialAttack\` is the figure's signature move. It should connect thematically to the character and can reference one of their items (e.g. a chef might hurl their rolling pin). Higher rarity figures should have more dramatic, over-the-top special attacks. Common = practical and grounded. Legendary = cinematic and awe-inspiring.`;
|
||||||
|
|
||||||
|
export function buildProfileUserPrompt(
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
rarity: string,
|
||||||
|
statRange: { min: number; max: number },
|
||||||
|
language: FigureLanguage
|
||||||
|
): string {
|
||||||
|
const langInstruction =
|
||||||
|
language === 'de'
|
||||||
|
? '\n\nIMPORTANT: Generate ALL text content (subtitle, backstory, items, specialAttack) in German. Only the visualDescription should remain in English (it feeds into an image generation prompt).'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `Generate a full character profile for this collectible figure:
|
||||||
|
|
||||||
|
**Name:** ${name}
|
||||||
|
**Description:** ${description}
|
||||||
|
**Rarity:** ${rarity.toUpperCase()}
|
||||||
|
|
||||||
|
**Stats range for ${rarity}:** each stat (attack, defense, special) must be between ${statRange.min} and ${statRange.max}.
|
||||||
|
|
||||||
|
Generate: subtitle, 3 items, backstory, stats, special attack, and a detailed visual description of the figure.${langInstruction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// Gemini JSON Schema for structured output
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export const PROFILE_JSON_SCHEMA = {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
subtitle: {
|
||||||
|
type: 'string' as const,
|
||||||
|
description: 'A short role/title for the figure, 2-6 words. Like a job title or faction.',
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
type: 'array' as const,
|
||||||
|
items: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string' as const,
|
||||||
|
description: 'Short punchy item name, 1-4 words.',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string' as const,
|
||||||
|
description: "One sentence describing the item's appearance.",
|
||||||
|
},
|
||||||
|
lore: {
|
||||||
|
type: 'string' as const,
|
||||||
|
description: 'One sentence of flavor text.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['name', 'description', 'lore'] as const,
|
||||||
|
},
|
||||||
|
description: 'Exactly 3 physical accessories/items the figure carries.',
|
||||||
|
},
|
||||||
|
backstory: {
|
||||||
|
type: 'string' as const,
|
||||||
|
description: '2-3 sentences of character lore.',
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
attack: { type: 'integer' as const },
|
||||||
|
defense: { type: 'integer' as const },
|
||||||
|
special: { type: 'integer' as const },
|
||||||
|
},
|
||||||
|
required: ['attack', 'defense', 'special'] as const,
|
||||||
|
},
|
||||||
|
specialAttack: {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string' as const,
|
||||||
|
description: 'Short punchy attack name, 2-4 words.',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'string' as const,
|
||||||
|
description: '1-2 sentences describing the attack.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['name', 'description'] as const,
|
||||||
|
},
|
||||||
|
visualDescription: {
|
||||||
|
type: 'string' as const,
|
||||||
|
description:
|
||||||
|
'Detailed physical description of the figure as a miniature collectible sculpture. 3-5 sentences.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: [
|
||||||
|
'subtitle',
|
||||||
|
'items',
|
||||||
|
'backstory',
|
||||||
|
'stats',
|
||||||
|
'specialAttack',
|
||||||
|
'visualDescription',
|
||||||
|
] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// Image Generation — Rarity Styles
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface RarityStyle {
|
||||||
|
card: string;
|
||||||
|
textStyle: string;
|
||||||
|
tag: string;
|
||||||
|
vibe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RARITY_STYLES: Record<CardStyle, RarityStyle> = {
|
||||||
|
// -- Common: Everyday packaging, several sub-types --
|
||||||
|
common_kraft: {
|
||||||
|
card: 'Warm tan kraft cardboard backing card with natural paper fiber texture.',
|
||||||
|
textStyle: 'bold black uppercase',
|
||||||
|
tag: 'A small rounded rectangular tag in the top-right corner of the card reading "COMMON" in dark gray text on a light gray background.',
|
||||||
|
vibe: '',
|
||||||
|
},
|
||||||
|
common_white: {
|
||||||
|
card: 'Clean white matte cardstock backing card with a subtle linen texture.',
|
||||||
|
textStyle: 'bold black uppercase',
|
||||||
|
tag: 'A small rounded rectangular tag in the top-right corner of the card reading "COMMON" in dark gray text on a light gray background.',
|
||||||
|
vibe: '',
|
||||||
|
},
|
||||||
|
common_mint: {
|
||||||
|
card: 'Matte mint-green cardstock backing card with a clean modern feel.',
|
||||||
|
textStyle: 'bold dark green uppercase',
|
||||||
|
tag: 'A small rounded rectangular tag in the top-right corner of the card reading "COMMON" in dark green text on a pale green background.',
|
||||||
|
vibe: '',
|
||||||
|
},
|
||||||
|
common_warm: {
|
||||||
|
card: 'Warm terracotta/clay-colored matte cardstock backing card.',
|
||||||
|
textStyle: 'bold dark brown uppercase',
|
||||||
|
tag: 'A small rounded rectangular tag in the top-right corner of the card reading "COMMON" in brown text on a pale peach background.',
|
||||||
|
vibe: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Rare: Blue/Silver premium --
|
||||||
|
rare: {
|
||||||
|
card: 'Dark navy blue matte cardstock backing card with a subtle brushed metal texture.',
|
||||||
|
textStyle: 'silver metallic uppercase',
|
||||||
|
tag: 'A metallic blue tag with silver border in the top-right corner reading "RARE" in silver text. The tag has a subtle shine.',
|
||||||
|
vibe: 'The overall packaging has a premium, curated feel — a step above standard retail.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Epic: Neon/Holographic, high energy --
|
||||||
|
epic: {
|
||||||
|
card: 'Glossy black cardstock backing card covered in a vivid holographic rainbow foil pattern that shifts between electric purple, hot pink, and cyan as light hits it. The entire card surface shimmers and refracts light like an oil slick. Bright neon purple geometric accent lines and circuit-board-style patterns are printed over the holographic surface.',
|
||||||
|
textStyle:
|
||||||
|
'neon purple glowing uppercase text that appears to emit light, with a bright pink-to-cyan gradient and a visible luminous halo around each letter',
|
||||||
|
tag: 'A large holographic tag in the top-right corner reading "EPIC" in bold glowing white text on a shifting purple-pink-cyan iridescent background. The tag is eye-catching and impossible to miss — it pulses with energy.',
|
||||||
|
vibe: 'This packaging is LOUD and electric. It demands attention. The holographic surface throws rainbow light everywhere. The neon accents make it feel like it belongs in an arcade or a cyberpunk display case. Nothing about this is subtle.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// -- Legendary: Ultra-luxury black & gold, museum piece --
|
||||||
|
legendary: {
|
||||||
|
card: 'Ultra-premium heavyweight matte black cardstock with a wide ornate gold foil border featuring intricate filigree scrollwork patterns embossed into the card. Gold foil decorative corner pieces with Art Deco geometric designs. A subtle gold foil crest or emblem is centered above the figure name. The card itself has a soft-touch velvet-like texture.',
|
||||||
|
textStyle:
|
||||||
|
'large gold foil embossed uppercase with deep dimensional relief — the letters are stamped into the card and filled with brilliant reflective gold that catches studio light with a mirror-like gleam',
|
||||||
|
tag: 'An oversized ornate gold foil tag in the top-right corner with decorative Art Deco border reading "LEGENDARY" in bold black serif text on a brilliant gold background. The tag has visible embossed texture and gleams like real jewelry under the light.',
|
||||||
|
vibe: "This is a once-in-a-lifetime collector's grail piece. The gold foil work is exquisite and covers significant portions of the card — borders, corners, crest, lettering. It looks like it belongs in a glass display case in a luxury boutique, not on a shelf. The contrast of deep matte black and brilliant gold is stunning. Every detail communicates exclusivity and extreme rarity. This packaging alone is worth collecting.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// Image Generation — Prompt Builder
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const REALISM_BLOCK = `This is not a plastic toy — it is a perfect miniature real human being, 6 inches tall, frozen in place. The skin has actual pores and subtle color variation. The clothing is real miniaturized fabric with natural drape, wrinkles, and weave texture. The materials are genuine — real leather grain, real fabric thread, real hair strands catching the light.`;
|
||||||
|
|
||||||
|
export function buildImagePrompt(
|
||||||
|
name: string,
|
||||||
|
subtitle: string,
|
||||||
|
visualDescription: string,
|
||||||
|
items: string[],
|
||||||
|
cardStyle: CardStyle,
|
||||||
|
hasFace: boolean = false
|
||||||
|
): string {
|
||||||
|
const style = RARITY_STYLES[cardStyle];
|
||||||
|
const itemsText = items
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => ` - ${item}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const faceInstruction = hasFace
|
||||||
|
? `\n\nCRITICAL — FACE TRANSFER: The provided reference photo shows the person's real face. The miniature figure MUST have this EXACT face — same facial structure, same features, same expression — but rendered in the miniature figure style. Preserve the likeness perfectly while matching the figure's aesthetic.`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `Product photograph of a premium collectible figure in sealed blister packaging on a pure white background. Package fills 95% of frame.
|
||||||
|
|
||||||
|
${style.card} Hanging hole at top center. Clear plastic blister with molded compartments.
|
||||||
|
|
||||||
|
${style.tag}
|
||||||
|
|
||||||
|
Name in ${style.textStyle}: "${name.toUpperCase()}" large at the top. "${subtitle.toUpperCase()}" in smaller text below.
|
||||||
|
|
||||||
|
In the left compartment stands the figure: ${visualDescription}
|
||||||
|
|
||||||
|
${REALISM_BLOCK}
|
||||||
|
${style.vibe}${faceInstruction}
|
||||||
|
|
||||||
|
Three accessories in separate molded blister compartments on the right side, stacked vertically:
|
||||||
|
${itemsText}
|
||||||
|
Each accessory is detailed, clearly visible, and generously sized.
|
||||||
|
|
||||||
|
IMPORTANT: The figure and accessories must NOT contain pure white (#FFFFFF) areas. Use off-white, cream, ivory, or light gray instead of white for any clothing, skin highlights, or materials. Pure white is reserved for the background only.
|
||||||
|
|
||||||
|
Pure white background, soft even studio lighting, product catalog quality. 85mm lens, sharp focus.`;
|
||||||
|
}
|
||||||
|
|
@ -4,5 +4,5 @@ import { AppModule } from './app.module';
|
||||||
bootstrapApp(AppModule, {
|
bootstrapApp(AppModule, {
|
||||||
defaultPort: 3025,
|
defaultPort: 3025,
|
||||||
serviceName: 'Figgos',
|
serviceName: 'Figgos',
|
||||||
additionalCorsOrigins: ['http://localhost:5181'],
|
additionalCorsOrigins: ['http://localhost:5196'],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
8
apps/figgos/apps/backend/src/storage/storage.module.ts
Normal file
8
apps/figgos/apps/backend/src/storage/storage.module.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [StorageService],
|
||||||
|
exports: [StorageService],
|
||||||
|
})
|
||||||
|
export class StorageModule {}
|
||||||
30
apps/figgos/apps/backend/src/storage/storage.service.ts
Normal file
30
apps/figgos/apps/backend/src/storage/storage.service.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { createFiggosStorage, type StorageClient } from '@manacore/shared-storage';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StorageService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(StorageService.name);
|
||||||
|
private storage!: StorageClient;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const publicUrl = this.config.get<string>('FIGGOS_STORAGE_PUBLIC_URL');
|
||||||
|
this.storage = createFiggosStorage(publicUrl);
|
||||||
|
this.logger.log('Storage initialized (bucket: figgos-storage)');
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFigureImage(userId: string, figureId: string, buffer: Buffer): Promise<string> {
|
||||||
|
const key = `${userId}/${figureId}.webp`;
|
||||||
|
|
||||||
|
const result = await this.storage.upload(key, buffer, {
|
||||||
|
contentType: 'image/webp',
|
||||||
|
public: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = result.url || this.storage.getPublicUrl(key);
|
||||||
|
this.logger.log(`Uploaded figure image: ${key}`);
|
||||||
|
return url || key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
import { useRef } from 'react';
|
import { useRef, useState, useCallback } from 'react';
|
||||||
import { View, Text, Image, Pressable, Animated, Dimensions } from 'react-native';
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
import { CARDS } from '../../data/cards';
|
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
||||||
import type { FigureRarity } from '@figgos/shared';
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
const CARD_WIDTH = SCREEN_WIDTH * 0.65;
|
const CARD_WIDTH = SCREEN_WIDTH * 0.65;
|
||||||
|
|
@ -21,6 +29,35 @@ const RARITY_COLORS: Record<FigureRarity, string> = {
|
||||||
export default function CarouselScreen() {
|
export default function CarouselScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const scrollX = useRef(new Animated.Value(0)).current;
|
const scrollX = useRef(new Animated.Value(0)).current;
|
||||||
|
const [figures, setFigures] = useState<FigureResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
api.figures
|
||||||
|
.list()
|
||||||
|
.then(({ figures }) => setFigures(figures))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background items-center justify-center" edges={['top']}>
|
||||||
|
<ActivityIndicator color="rgb(255, 204, 0)" size="large" />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (figures.length === 0) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background items-center justify-center" edges={['top']}>
|
||||||
|
<Text className="text-muted-foreground" style={{ fontSize: 16, fontWeight: '700' }}>
|
||||||
|
No figures yet
|
||||||
|
</Text>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
|
|
@ -35,7 +72,7 @@ export default function CarouselScreen() {
|
||||||
|
|
||||||
<View style={{ flex: 1, justifyContent: 'center' }}>
|
<View style={{ flex: 1, justifyContent: 'center' }}>
|
||||||
<Animated.FlatList
|
<Animated.FlatList
|
||||||
data={CARDS}
|
data={figures}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
|
|
@ -94,11 +131,31 @@ export default function CarouselScreen() {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{item.imageUrl ? (
|
||||||
source={item.image}
|
<Image
|
||||||
style={{ width: '100%', height: '100%' }}
|
source={{ uri: item.imageUrl }}
|
||||||
resizeMode="cover"
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
className="bg-surface items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: RARITY_COLORS[item.rarity],
|
||||||
|
borderRadius: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-foreground"
|
||||||
|
style={{ fontSize: 14, fontWeight: '800' }}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
@ -108,7 +165,7 @@ export default function CarouselScreen() {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex-row items-center justify-center" style={{ paddingBottom: 24, gap: 8 }}>
|
<View className="flex-row items-center justify-center" style={{ paddingBottom: 24, gap: 8 }}>
|
||||||
{CARDS.map((card, i) => {
|
{figures.map((figure, i) => {
|
||||||
const inputRange = [
|
const inputRange = [
|
||||||
(i - 1) * (CARD_WIDTH + SPACING),
|
(i - 1) * (CARD_WIDTH + SPACING),
|
||||||
i * (CARD_WIDTH + SPACING),
|
i * (CARD_WIDTH + SPACING),
|
||||||
|
|
@ -129,12 +186,12 @@ export default function CarouselScreen() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
key={card.id}
|
key={figure.id}
|
||||||
style={{
|
style={{
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
backgroundColor: RARITY_COLORS[card.rarity],
|
backgroundColor: RARITY_COLORS[figure.rarity],
|
||||||
transform: [{ scale: dotScale }],
|
transform: [{ scale: dotScale }],
|
||||||
opacity: dotOpacity,
|
opacity: dotOpacity,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
import { View, Text, Image, Pressable, FlatList, Dimensions } from 'react-native';
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
FlatList,
|
||||||
|
Dimensions,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
import { CARDS, type CardData } from '../../data/cards';
|
import type { FigureResponse } from '@figgos/shared';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
const GAP = 10;
|
const GAP = 10;
|
||||||
|
|
@ -10,12 +20,12 @@ const COLUMNS = 2;
|
||||||
const CARD_WIDTH = (SCREEN_WIDTH - PADDING * 2 - GAP * (COLUMNS - 1)) / COLUMNS;
|
const CARD_WIDTH = (SCREEN_WIDTH - PADDING * 2 - GAP * (COLUMNS - 1)) / COLUMNS;
|
||||||
const CARD_HEIGHT = CARD_WIDTH * 1.45;
|
const CARD_HEIGHT = CARD_WIDTH * 1.45;
|
||||||
|
|
||||||
function CardThumbnail({ card }: { card: CardData }) {
|
function CardThumbnail({ figure }: { figure: FigureResponse }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.push(`/card/v2/${card.id}` as any)}
|
onPress={() => router.push(`/card/v2/${figure.id}` as any)}
|
||||||
style={{ width: CARD_WIDTH }}
|
style={{ width: CARD_WIDTH }}
|
||||||
className="active:opacity-80"
|
className="active:opacity-80"
|
||||||
>
|
>
|
||||||
|
|
@ -27,13 +37,52 @@ function CardThumbnail({ card }: { card: CardData }) {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image source={card.image} style={{ width: '100%', height: '100%' }} resizeMode="cover" />
|
{figure.imageUrl ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: figure.imageUrl }}
|
||||||
|
style={{ width: '100%', height: '100%' }}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
className="bg-surface items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'rgb(50, 50, 80)',
|
||||||
|
borderRadius: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||||
|
{figure.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CollectionScreen() {
|
export default function CollectionScreen() {
|
||||||
|
const [figures, setFigures] = useState<FigureResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api.figures
|
||||||
|
.list()
|
||||||
|
.then(({ figures }) => {
|
||||||
|
setFigures(figures);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
<View style={{ paddingHorizontal: PADDING, paddingTop: 24, paddingBottom: 16 }}>
|
<View style={{ paddingHorizontal: PADDING, paddingTop: 24, paddingBottom: 16 }}>
|
||||||
|
|
@ -44,18 +93,42 @@ export default function CollectionScreen() {
|
||||||
Collection
|
Collection
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-muted-foreground" style={{ fontSize: 13, marginTop: 2 }}>
|
<Text className="text-muted-foreground" style={{ fontSize: 13, marginTop: 2 }}>
|
||||||
{CARDS.length} {CARDS.length === 1 ? 'Figgo' : 'Figgos'}
|
{loading ? '...' : `${figures.length} ${figures.length === 1 ? 'Figgo' : 'Figgos'}`}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<FlatList
|
{loading ? (
|
||||||
data={CARDS}
|
<View className="flex-1 items-center justify-center">
|
||||||
numColumns={COLUMNS}
|
<ActivityIndicator color="rgb(255, 204, 0)" size="large" />
|
||||||
keyExtractor={(item) => item.id}
|
</View>
|
||||||
contentContainerStyle={{ paddingHorizontal: PADDING, paddingBottom: 40 }}
|
) : error ? (
|
||||||
columnWrapperStyle={{ gap: GAP, marginBottom: GAP }}
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
renderItem={({ item }) => <CardThumbnail card={item} />}
|
<Text className="text-destructive text-center" style={{ fontSize: 14 }}>
|
||||||
/>
|
{error}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : figures.length === 0 ? (
|
||||||
|
<View className="flex-1 items-center justify-center px-6">
|
||||||
|
<Text
|
||||||
|
className="text-muted-foreground text-center"
|
||||||
|
style={{ fontSize: 16, fontWeight: '700' }}
|
||||||
|
>
|
||||||
|
No figures yet
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground text-center mt-2" style={{ fontSize: 13 }}>
|
||||||
|
Create your first Figgo!
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<FlatList
|
||||||
|
data={figures}
|
||||||
|
numColumns={COLUMNS}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
contentContainerStyle={{ paddingHorizontal: PADDING, paddingBottom: 40 }}
|
||||||
|
columnWrapperStyle={{ gap: GAP, marginBottom: GAP }}
|
||||||
|
renderItem={({ item }) => <CardThumbnail figure={item} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import {
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Image,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
// ── Rarity ──
|
// ── Rarity ──
|
||||||
|
|
||||||
|
|
@ -58,6 +60,46 @@ function RarityBadge({ rarity }: { rarity: FigureRarity }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Stat Bar ──
|
||||||
|
|
||||||
|
const STAT_COLORS = {
|
||||||
|
attack: 'rgb(255, 51, 102)',
|
||||||
|
defense: 'rgb(0, 210, 170)',
|
||||||
|
special: 'rgb(180, 130, 255)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatBar({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center mb-1.5" style={{ gap: 6 }}>
|
||||||
|
<Text
|
||||||
|
className="text-muted-foreground"
|
||||||
|
style={{ fontSize: 10, fontWeight: '900', width: 26, letterSpacing: 1 }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
className="flex-1 bg-input rounded-full"
|
||||||
|
style={{ height: 8, borderWidth: 1, borderColor: 'rgb(50, 50, 80)' }}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: `${value}%`,
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: color,
|
||||||
|
borderRadius: 999,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className="text-foreground"
|
||||||
|
style={{ fontSize: 10, fontWeight: '800', width: 22, textAlign: 'right' }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Screen ──
|
// ── Screen ──
|
||||||
|
|
||||||
export default function CreateScreen() {
|
export default function CreateScreen() {
|
||||||
|
|
@ -75,28 +117,8 @@ export default function CreateScreen() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
const { figure } = await api.figures.create(name.trim(), description.trim());
|
||||||
const rarities: FigureRarity[] = [
|
setResult(figure);
|
||||||
'common',
|
|
||||||
'common',
|
|
||||||
'common',
|
|
||||||
'rare',
|
|
||||||
'rare',
|
|
||||||
'epic',
|
|
||||||
'legendary',
|
|
||||||
];
|
|
||||||
setResult({
|
|
||||||
id: 'mock-id',
|
|
||||||
userId: 'mock-user',
|
|
||||||
name: name.trim(),
|
|
||||||
userInput: { description: description.trim() },
|
|
||||||
imageUrl: null,
|
|
||||||
rarity: rarities[Math.floor(Math.random() * rarities.length)],
|
|
||||||
isPublic: false,
|
|
||||||
isArchived: false,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message || 'Something went wrong');
|
setError(e.message || 'Something went wrong');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -111,6 +133,8 @@ export default function CreateScreen() {
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const profile = result?.generatedProfile;
|
||||||
|
|
||||||
// ── Result ──
|
// ── Result ──
|
||||||
if (result) {
|
if (result) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -150,19 +174,33 @@ export default function CreateScreen() {
|
||||||
style={{ borderWidth: 3, borderColor: 'rgb(255, 204, 0)', padding: 24 }}
|
style={{ borderWidth: 3, borderColor: 'rgb(255, 204, 0)', padding: 24 }}
|
||||||
>
|
>
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<View
|
{result.imageUrl ? (
|
||||||
className="bg-input rounded-lg self-center items-center justify-center mb-5"
|
<Image
|
||||||
style={{
|
source={{ uri: result.imageUrl }}
|
||||||
width: 200,
|
style={{
|
||||||
height: 200,
|
width: 200,
|
||||||
borderWidth: 2,
|
height: 200,
|
||||||
borderColor: 'rgb(50, 50, 80)',
|
alignSelf: 'center',
|
||||||
}}
|
marginBottom: 20,
|
||||||
>
|
borderRadius: 12,
|
||||||
<Text className="text-muted-foreground" style={{ fontSize: 12 }}>
|
}}
|
||||||
Image coming soon
|
resizeMode="contain"
|
||||||
</Text>
|
/>
|
||||||
</View>
|
) : (
|
||||||
|
<View
|
||||||
|
className="bg-input rounded-lg self-center items-center justify-center mb-5"
|
||||||
|
style={{
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'rgb(50, 50, 80)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground" style={{ fontSize: 12 }}>
|
||||||
|
{result.status === 'failed' ? 'Generation failed' : 'No image'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
className="text-foreground text-center"
|
className="text-foreground text-center"
|
||||||
|
|
@ -170,16 +208,80 @@ export default function CreateScreen() {
|
||||||
>
|
>
|
||||||
{result.name}
|
{result.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
|
||||||
className="text-muted-foreground text-center mt-3"
|
{profile?.subtitle && (
|
||||||
style={{ fontSize: 14, lineHeight: 20 }}
|
<Text
|
||||||
>
|
className="text-muted-foreground text-center mt-1"
|
||||||
{result.userInput.description}
|
style={{
|
||||||
</Text>
|
fontSize: 13,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{profile?.backstory && (
|
||||||
|
<Text
|
||||||
|
className="text-muted-foreground text-center mt-3"
|
||||||
|
style={{ fontSize: 14, lineHeight: 20 }}
|
||||||
|
>
|
||||||
|
{profile.backstory}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{profile?.stats && (
|
||||||
|
<View className="mt-4 w-full">
|
||||||
|
<StatBar label="ATK" value={profile.stats.attack} color={STAT_COLORS.attack} />
|
||||||
|
<StatBar
|
||||||
|
label="DEF"
|
||||||
|
value={profile.stats.defense}
|
||||||
|
color={STAT_COLORS.defense}
|
||||||
|
/>
|
||||||
|
<StatBar
|
||||||
|
label="SPL"
|
||||||
|
value={profile.stats.special}
|
||||||
|
color={STAT_COLORS.special}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Special Attack */}
|
||||||
|
{profile?.specialAttack && (
|
||||||
|
<View className="mt-3 bg-input rounded-lg" style={{ padding: 12 }}>
|
||||||
|
<Text
|
||||||
|
className="text-primary"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '900',
|
||||||
|
letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.specialAttack.name}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-muted-foreground mt-1"
|
||||||
|
style={{ fontSize: 12, lineHeight: 16 }}
|
||||||
|
>
|
||||||
|
{profile.specialAttack.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View className="mt-4">
|
<View className="mt-4">
|
||||||
<RarityBadge rarity={result.rarity} />
|
<RarityBadge rarity={result.rarity} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{result.status === 'failed' && result.errorMessage && (
|
||||||
|
<Text className="text-destructive text-center mt-3" style={{ fontSize: 12 }}>
|
||||||
|
{result.errorMessage}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
@ -385,7 +487,7 @@ export default function CreateScreen() {
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Rolling...
|
Generating...
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import { View, Text, Image, Pressable, ScrollView, Dimensions } from 'react-native';
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Dimensions,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
import { CARDS, type CardData } from '../../data/cards';
|
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
||||||
import type { FigureRarity } from '@figgos/shared';
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
const CARD_WIDTH = SCREEN_WIDTH * 0.32;
|
const CARD_WIDTH = SCREEN_WIDTH * 0.32;
|
||||||
|
|
@ -24,11 +33,11 @@ const RARITY_LABELS: Record<FigureRarity, string> = {
|
||||||
common: 'COMMON',
|
common: 'COMMON',
|
||||||
};
|
};
|
||||||
|
|
||||||
function ShelfRow({ rarity, cards }: { rarity: FigureRarity; cards: CardData[] }) {
|
function ShelfRow({ rarity, figures }: { rarity: FigureRarity; figures: FigureResponse[] }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const color = RARITY_COLORS[rarity];
|
const color = RARITY_COLORS[rarity];
|
||||||
|
|
||||||
if (cards.length === 0) return null;
|
if (figures.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ marginBottom: 28 }}>
|
<View style={{ marginBottom: 28 }}>
|
||||||
|
|
@ -51,12 +60,12 @@ function ShelfRow({ rarity, cards }: { rarity: FigureRarity; cards: CardData[] }
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={{ paddingHorizontal: 20 }}
|
contentContainerStyle={{ paddingHorizontal: 20 }}
|
||||||
>
|
>
|
||||||
{cards.map((card, i) => (
|
{figures.map((figure, i) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={card.id}
|
key={figure.id}
|
||||||
onPress={() => router.push(`/card/v2/${card.id}` as any)}
|
onPress={() => router.push(`/card/v2/${figure.id}` as any)}
|
||||||
className="active:opacity-80"
|
className="active:opacity-80"
|
||||||
style={{ marginRight: i < cards.length - 1 ? OVERLAP : 0 }}
|
style={{ marginRight: i < figures.length - 1 ? OVERLAP : 0 }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -66,11 +75,31 @@ function ShelfRow({ rarity, cards }: { rarity: FigureRarity; cards: CardData[] }
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
{figure.imageUrl ? (
|
||||||
source={card.image}
|
<Image
|
||||||
style={{ width: '100%', height: '100%' }}
|
source={{ uri: figure.imageUrl }}
|
||||||
resizeMode="cover"
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
className="bg-surface items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: color,
|
||||||
|
borderRadius: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-muted-foreground"
|
||||||
|
style={{ fontSize: 9, textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
{figure.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
|
|
@ -91,11 +120,31 @@ function ShelfRow({ rarity, cards }: { rarity: FigureRarity; cards: CardData[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ShelfScreen() {
|
export default function ShelfScreen() {
|
||||||
|
const [figures, setFigures] = useState<FigureResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
api.figures
|
||||||
|
.list()
|
||||||
|
.then(({ figures }) => setFigures(figures))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
const grouped = RARITY_ORDER.map((rarity) => ({
|
const grouped = RARITY_ORDER.map((rarity) => ({
|
||||||
rarity,
|
rarity,
|
||||||
cards: CARDS.filter((c) => c.rarity === rarity),
|
figures: figures.filter((f) => f.rarity === rarity),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background items-center justify-center" edges={['top']}>
|
||||||
|
<ActivityIndicator color="rgb(255, 204, 0)" size="large" />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
|
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
|
||||||
|
|
@ -108,8 +157,8 @@ export default function ShelfScreen() {
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{grouped.map(({ rarity, cards }) => (
|
{grouped.map(({ rarity, figures }) => (
|
||||||
<ShelfRow key={rarity} rarity={rarity} cards={cards} />
|
<ShelfRow key={rarity} rarity={rarity} figures={figures} />
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,18 @@
|
||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback } from 'react';
|
||||||
import { View, Text, Image, Pressable, Animated, Dimensions, PanResponder } from 'react-native';
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Image,
|
||||||
|
Pressable,
|
||||||
|
Animated,
|
||||||
|
Dimensions,
|
||||||
|
PanResponder,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
import { CARDS } from '../../data/cards';
|
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
||||||
import type { FigureRarity } from '@figgos/shared';
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
const CARD_WIDTH = SCREEN_WIDTH * 0.72;
|
const CARD_WIDTH = SCREEN_WIDTH * 0.72;
|
||||||
|
|
@ -21,13 +30,28 @@ const RARITY_COLORS: Record<FigureRarity, string> = {
|
||||||
|
|
||||||
export default function StackScreen() {
|
export default function StackScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [order, setOrder] = useState(() => CARDS.map((_, i) => i));
|
const [figures, setFigures] = useState<FigureResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [order, setOrder] = useState<number[]>([]);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const swipeY = useRef(new Animated.Value(0)).current;
|
const swipeY = useRef(new Animated.Value(0)).current;
|
||||||
const isAnimating = useRef(false);
|
const isAnimating = useRef(false);
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
api.figures
|
||||||
|
.list()
|
||||||
|
.then(({ figures }) => {
|
||||||
|
setFigures(figures);
|
||||||
|
setOrder(figures.map((_, i) => i));
|
||||||
|
setCurrentIndex(0);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
const dismissTop = useCallback(() => {
|
const dismissTop = useCallback(() => {
|
||||||
if (isAnimating.current) return;
|
if (isAnimating.current || figures.length === 0) return;
|
||||||
isAnimating.current = true;
|
isAnimating.current = true;
|
||||||
|
|
||||||
Animated.timing(swipeY, {
|
Animated.timing(swipeY, {
|
||||||
|
|
@ -36,11 +60,11 @@ export default function StackScreen() {
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start(() => {
|
}).start(() => {
|
||||||
setOrder((prev) => [...prev.slice(1), prev[0]]);
|
setOrder((prev) => [...prev.slice(1), prev[0]]);
|
||||||
setCurrentIndex((prev) => (prev + 1) % CARDS.length);
|
setCurrentIndex((prev) => (prev + 1) % figures.length);
|
||||||
swipeY.setValue(0);
|
swipeY.setValue(0);
|
||||||
isAnimating.current = false;
|
isAnimating.current = false;
|
||||||
});
|
});
|
||||||
}, [swipeY]);
|
}, [swipeY, figures.length]);
|
||||||
|
|
||||||
const snapBack = useCallback(() => {
|
const snapBack = useCallback(() => {
|
||||||
Animated.spring(swipeY, {
|
Animated.spring(swipeY, {
|
||||||
|
|
@ -70,7 +94,25 @@ export default function StackScreen() {
|
||||||
})
|
})
|
||||||
).current;
|
).current;
|
||||||
|
|
||||||
const topCard = CARDS[order[0]];
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background items-center justify-center" edges={['top']}>
|
||||||
|
<ActivityIndicator color="rgb(255, 204, 0)" size="large" />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (figures.length === 0) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background items-center justify-center" edges={['top']}>
|
||||||
|
<Text className="text-muted-foreground" style={{ fontSize: 16, fontWeight: '700' }}>
|
||||||
|
No figures yet
|
||||||
|
</Text>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const topFigure = figures[order[0]];
|
||||||
|
|
||||||
const topOpacity = swipeY.interpolate({
|
const topOpacity = swipeY.interpolate({
|
||||||
inputRange: [-200, 0],
|
inputRange: [-200, 0],
|
||||||
|
|
@ -78,7 +120,6 @@ export default function StackScreen() {
|
||||||
extrapolate: 'clamp',
|
extrapolate: 'clamp',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Total height needed: card + stack peek area
|
|
||||||
const stackHeight = CARD_HEIGHT + (VISIBLE_STACK - 1) * STACK_OFFSET;
|
const stackHeight = CARD_HEIGHT + (VISIBLE_STACK - 1) * STACK_OFFSET;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -97,17 +138,17 @@ export default function StackScreen() {
|
||||||
|
|
||||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 20 }}>
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingBottom: 20 }}>
|
||||||
<View style={{ width: CARD_WIDTH, height: stackHeight }}>
|
<View style={{ width: CARD_WIDTH, height: stackHeight }}>
|
||||||
{/* Background cards — each peeks out below the one above */}
|
|
||||||
{order
|
{order
|
||||||
.slice(1, VISIBLE_STACK)
|
.slice(1, VISIBLE_STACK)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((cardIdx, reverseI) => {
|
.map((cardIdx, reverseI) => {
|
||||||
const depth = VISIBLE_STACK - 1 - reverseI; // 3, 2, 1
|
const depth = VISIBLE_STACK - 1 - reverseI;
|
||||||
const card = CARDS[cardIdx];
|
const figure = figures[cardIdx];
|
||||||
const shrink = depth * 8; // each card slightly narrower
|
if (!figure) return null;
|
||||||
|
const shrink = depth * 8;
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={`bg-${depth}-${card.id}`}
|
key={`bg-${depth}-${figure.id}`}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: depth * STACK_OFFSET,
|
top: depth * STACK_OFFSET,
|
||||||
|
|
@ -118,69 +159,93 @@ export default function StackScreen() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{ width: '100%', height: '100%', borderRadius: 14, overflow: 'hidden' }}
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: 14,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Image
|
{figure.imageUrl ? (
|
||||||
source={card.image}
|
<Image
|
||||||
style={{ width: '100%', height: '100%' }}
|
source={{ uri: figure.imageUrl }}
|
||||||
resizeMode="cover"
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
className="bg-surface items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgb(50,50,80)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||||
|
{figure.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Top card */}
|
{topFigure && (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
{...panResponder.panHandlers}
|
{...panResponder.panHandlers}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
width: CARD_WIDTH,
|
width: CARD_WIDTH,
|
||||||
height: CARD_HEIGHT,
|
height: CARD_HEIGHT,
|
||||||
transform: [{ translateY: swipeY }],
|
transform: [{ translateY: swipeY }],
|
||||||
opacity: topOpacity,
|
opacity: topOpacity,
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
onPress={() => router.push(`/card/v2/${topCard.id}` as any)}
|
|
||||||
className="active:opacity-90"
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
>
|
>
|
||||||
<View
|
<Pressable
|
||||||
style={{
|
onPress={() => router.push(`/card/v2/${topFigure.id}` as any)}
|
||||||
width: '100%',
|
className="active:opacity-90"
|
||||||
height: '100%',
|
style={{ width: '100%', height: '100%' }}
|
||||||
borderRadius: 14,
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Image
|
<View
|
||||||
source={topCard.image}
|
style={{ width: '100%', height: '100%', borderRadius: 14, overflow: 'hidden' }}
|
||||||
style={{ width: '100%', height: '100%' }}
|
>
|
||||||
resizeMode="cover"
|
{topFigure.imageUrl ? (
|
||||||
/>
|
<Image
|
||||||
</View>
|
source={{ uri: topFigure.imageUrl }}
|
||||||
</Pressable>
|
style={{ width: '100%', height: '100%' }}
|
||||||
</Animated.View>
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
className="bg-surface items-center justify-center"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 14,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: RARITY_COLORS[topFigure.rarity],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-foreground" style={{ fontSize: 16, fontWeight: '800' }}>
|
||||||
|
{topFigure.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Dot indicator */}
|
|
||||||
<View className="flex-row items-center" style={{ marginTop: 20, gap: 6 }}>
|
<View className="flex-row items-center" style={{ marginTop: 20, gap: 6 }}>
|
||||||
{CARDS.map((card, i) => (
|
{figures.map((figure, i) => (
|
||||||
<View
|
<View
|
||||||
key={card.id}
|
key={figure.id}
|
||||||
style={{
|
style={{
|
||||||
width: i === currentIndex ? 20 : 8,
|
width: i === currentIndex ? 20 : 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
i === currentIndex ? RARITY_COLORS[card.rarity] : 'rgb(50, 50, 80)',
|
i === currentIndex ? RARITY_COLORS[figure.rarity] : 'rgb(50, 50, 80)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { View, Text, Pressable, Image, Dimensions } from 'react-native';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { View, Text, Pressable, Image, Dimensions, ActivityIndicator } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
|
@ -8,8 +9,8 @@ import Animated, {
|
||||||
interpolate,
|
interpolate,
|
||||||
Easing,
|
Easing,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { CARDS } from '../../data/cards';
|
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
||||||
import type { FigureRarity } from '@figgos/shared';
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
const CARD_WIDTH = SCREEN_WIDTH - 48;
|
const CARD_WIDTH = SCREEN_WIDTH - 48;
|
||||||
|
|
@ -31,7 +32,17 @@ const STAT_COLORS = {
|
||||||
export default function CardDetailScreen() {
|
export default function CardDetailScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const card = CARDS.find((c) => c.id === id);
|
const [figure, setFigure] = useState<FigureResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
api.figures
|
||||||
|
.get(id)
|
||||||
|
.then(({ figure }) => setFigure(figure))
|
||||||
|
.catch(() => setFigure(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
const rotation = useSharedValue(0);
|
const rotation = useSharedValue(0);
|
||||||
const isFlipped = useSharedValue(false);
|
const isFlipped = useSharedValue(false);
|
||||||
|
|
@ -61,7 +72,15 @@ export default function CardDetailScreen() {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!card) {
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background items-center justify-center">
|
||||||
|
<ActivityIndicator color="rgb(255, 204, 0)" size="large" />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!figure) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background items-center justify-center">
|
<SafeAreaView className="flex-1 bg-background items-center justify-center">
|
||||||
<Text className="text-foreground" style={{ fontSize: 16 }}>
|
<Text className="text-foreground" style={{ fontSize: 16 }}>
|
||||||
|
|
@ -71,9 +90,10 @@ export default function CardDetailScreen() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profile = figure.generatedProfile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
{/* Back button */}
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.back()}
|
onPress={() => router.back()}
|
||||||
className="active:opacity-70"
|
className="active:opacity-70"
|
||||||
|
|
@ -87,36 +107,37 @@ export default function CardDetailScreen() {
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<Pressable onPress={handleFlip}>
|
<Pressable onPress={handleFlip}>
|
||||||
<View style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}>
|
<View style={{ width: CARD_WIDTH, height: CARD_HEIGHT }}>
|
||||||
{/* ── Front: just the image ── */}
|
{/* Front */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[{ position: 'absolute', width: CARD_WIDTH, height: CARD_HEIGHT }, frontStyle]}
|
||||||
{
|
|
||||||
position: 'absolute',
|
|
||||||
width: CARD_WIDTH,
|
|
||||||
height: CARD_HEIGHT,
|
|
||||||
},
|
|
||||||
frontStyle,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<Image
|
{figure.imageUrl ? (
|
||||||
source={card.image}
|
<Image
|
||||||
style={{ width: '100%', height: '100%' }}
|
source={{ uri: figure.imageUrl }}
|
||||||
resizeMode="contain"
|
style={{ width: '100%', height: '100%' }}
|
||||||
/>
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
className="bg-surface items-center justify-center rounded-2xl"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: RARITY_COLORS[figure.rarity],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-foreground" style={{ fontSize: 18, fontWeight: '800' }}>
|
||||||
|
{figure.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* ── Back ── */}
|
{/* Back */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[{ position: 'absolute', width: CARD_WIDTH, height: CARD_HEIGHT }, backStyle]}
|
||||||
{
|
|
||||||
position: 'absolute',
|
|
||||||
width: CARD_WIDTH,
|
|
||||||
height: CARD_HEIGHT,
|
|
||||||
},
|
|
||||||
backStyle,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
{/* Shadow layer */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -125,22 +146,20 @@ export default function CardDetailScreen() {
|
||||||
right: -6,
|
right: -6,
|
||||||
bottom: -6,
|
bottom: -6,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: RARITY_COLORS[card.rarity],
|
backgroundColor: RARITY_COLORS[figure.rarity],
|
||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Card back */}
|
|
||||||
<View
|
<View
|
||||||
className="bg-surface rounded-2xl"
|
className="bg-surface rounded-2xl"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderWidth: 3,
|
borderWidth: 3,
|
||||||
borderColor: RARITY_COLORS[card.rarity],
|
borderColor: RARITY_COLORS[figure.rarity],
|
||||||
padding: 24,
|
padding: 24,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<View>
|
<View>
|
||||||
<View
|
<View
|
||||||
className="bg-secondary rounded self-start mb-3"
|
className="bg-secondary rounded self-start mb-3"
|
||||||
|
|
@ -166,56 +185,65 @@ export default function CardDetailScreen() {
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
style={{ fontSize: 22, fontWeight: '900', letterSpacing: -0.5 }}
|
style={{ fontSize: 22, fontWeight: '900', letterSpacing: -0.5 }}
|
||||||
>
|
>
|
||||||
{card.name}
|
{figure.name}
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: '800',
|
|
||||||
letterSpacing: 2,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
marginTop: 2,
|
|
||||||
color: RARITY_COLORS[card.rarity],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.subtitle}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{profile?.subtitle && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '800',
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginTop: 2,
|
||||||
|
color: RARITY_COLORS[figure.rarity],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-muted-foreground" style={{ fontSize: 14, lineHeight: 22 }}>
|
<Text className="text-muted-foreground" style={{ fontSize: 14, lineHeight: 22 }}>
|
||||||
{card.description}
|
{profile?.backstory || figure.userInput.description}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Stats */}
|
{profile?.stats && (
|
||||||
<View>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '900',
|
fontWeight: '900',
|
||||||
letterSpacing: 3,
|
letterSpacing: 3,
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
color: RARITY_COLORS[card.rarity],
|
color: RARITY_COLORS[figure.rarity],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Stats
|
Stats
|
||||||
</Text>
|
</Text>
|
||||||
<StatBar label="ATK" value={card.stats.attack} color={STAT_COLORS.attack} />
|
<StatBar label="ATK" value={profile.stats.attack} color={STAT_COLORS.attack} />
|
||||||
<StatBar label="DEF" value={card.stats.defense} color={STAT_COLORS.defense} />
|
<StatBar
|
||||||
<StatBar label="SPL" value={card.stats.special} color={STAT_COLORS.special} />
|
label="DEF"
|
||||||
</View>
|
value={profile.stats.defense}
|
||||||
|
color={STAT_COLORS.defense}
|
||||||
|
/>
|
||||||
|
<StatBar
|
||||||
|
label="SPL"
|
||||||
|
value={profile.stats.special}
|
||||||
|
color={STAT_COLORS.special}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bottom: rarity + ID */}
|
|
||||||
<View className="flex-row items-center justify-between">
|
<View className="flex-row items-center justify-between">
|
||||||
<View
|
<View
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
backgroundColor: RARITY_COLORS[card.rarity],
|
backgroundColor: RARITY_COLORS[figure.rarity],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
|
|
@ -227,14 +255,14 @@ export default function CardDetailScreen() {
|
||||||
color: 'rgb(15, 15, 30)',
|
color: 'rgb(15, 15, 30)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.rarity}
|
{figure.rarity}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
style={{ fontSize: 10, fontWeight: '600', letterSpacing: 1 }}
|
style={{ fontSize: 10, fontWeight: '600', letterSpacing: 1 }}
|
||||||
>
|
>
|
||||||
#{card.id.split('-').pop()?.toUpperCase()}
|
#{figure.id.split('-').pop()?.toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -260,12 +288,7 @@ function StatBar({ label, value, color }: { label: string; value: number; color:
|
||||||
style={{ height: 10, borderWidth: 1, borderColor: 'rgb(50, 50, 80)' }}
|
style={{ height: 10, borderWidth: 1, borderColor: 'rgb(50, 50, 80)' }}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{ width: `${value}%`, height: '100%', backgroundColor: color, borderRadius: 999 }}
|
||||||
width: `${value}%`,
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: color,
|
|
||||||
borderRadius: 999,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { View, Text, Pressable, Image, Dimensions } from 'react-native';
|
import { View, Text, Pressable, Image, Dimensions, ActivityIndicator } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
|
||||||
import { CARDS } from '../../../data/cards';
|
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
||||||
import type { FigureRarity } from '@figgos/shared';
|
import { api } from '../../../services/api';
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
const CONTAINER_WIDTH = SCREEN_WIDTH - 48;
|
const CONTAINER_WIDTH = SCREEN_WIDTH - 48;
|
||||||
|
|
@ -29,17 +29,21 @@ const SPRING_CONFIG = { damping: 20, stiffness: 200, mass: 0.8 };
|
||||||
export default function CardDetailV2Screen() {
|
export default function CardDetailV2Screen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const card = CARDS.find((c) => c.id === id);
|
const [figure, setFigure] = useState<FigureResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
api.figures
|
||||||
|
.get(id)
|
||||||
|
.then(({ figure }) => setFigure(figure))
|
||||||
|
.catch(() => setFigure(null))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
// Track the actual rendered image size
|
// Track the actual rendered image size
|
||||||
const [imageSize, setImageSize] = useState({ width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT });
|
const [imageSize, setImageSize] = useState({ width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT });
|
||||||
|
|
||||||
const handleImageLayout = (e: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
|
||||||
// Image uses contain, so we can read the actual layout
|
|
||||||
// But we need the source dimensions to compute the real rendered area
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use Image.resolveAssetSource to get original dimensions, then compute contain size
|
|
||||||
const computeContainSize = (srcW: number, srcH: number) => {
|
const computeContainSize = (srcW: number, srcH: number) => {
|
||||||
const ratio = Math.min(CONTAINER_WIDTH / srcW, CONTAINER_HEIGHT / srcH);
|
const ratio = Math.min(CONTAINER_WIDTH / srcW, CONTAINER_HEIGHT / srcH);
|
||||||
setImageSize({
|
setImageSize({
|
||||||
|
|
@ -72,7 +76,6 @@ export default function CardDetailV2Screen() {
|
||||||
rotateY.value = withSpring(snapTo, SPRING_CONFIG);
|
rotateY.value = withSpring(snapTo, SPRING_CONFIG);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Double tap to do a full flip
|
|
||||||
const doubleTap = Gesture.Tap()
|
const doubleTap = Gesture.Tap()
|
||||||
.numberOfTaps(2)
|
.numberOfTaps(2)
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
|
|
@ -93,7 +96,15 @@ export default function CardDetailV2Screen() {
|
||||||
backfaceVisibility: 'hidden' as const,
|
backfaceVisibility: 'hidden' as const,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!card) {
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className="flex-1 bg-background items-center justify-center">
|
||||||
|
<ActivityIndicator color="rgb(255, 204, 0)" size="large" />
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!figure) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background items-center justify-center">
|
<SafeAreaView className="flex-1 bg-background items-center justify-center">
|
||||||
<Text className="text-foreground" style={{ fontSize: 16 }}>
|
<Text className="text-foreground" style={{ fontSize: 16 }}>
|
||||||
|
|
@ -103,6 +114,8 @@ export default function CardDetailV2Screen() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profile = figure.generatedProfile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
|
|
@ -116,14 +129,14 @@ export default function CardDetailV2Screen() {
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text className="text-muted-foreground" style={{ fontSize: 12, fontWeight: '600' }}>
|
<Text className="text-muted-foreground" style={{ fontSize: 12, fontWeight: '600' }}>
|
||||||
V2 — Gesture 3D
|
Drag to rotate · Double-tap to flip
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="flex-1 items-center justify-center">
|
<View className="flex-1 items-center justify-center">
|
||||||
<GestureDetector gesture={composed}>
|
<GestureDetector gesture={composed}>
|
||||||
<View style={{ width: imageSize.width, height: imageSize.height }}>
|
<View style={{ width: imageSize.width, height: imageSize.height }}>
|
||||||
{/* ── Front: just the image ── */}
|
{/* ── Front: the image ── */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
|
|
@ -134,15 +147,34 @@ export default function CardDetailV2Screen() {
|
||||||
frontStyle,
|
frontStyle,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Image
|
{figure.imageUrl ? (
|
||||||
source={card.image}
|
<Image
|
||||||
style={{ width: '100%', height: '100%' }}
|
source={{ uri: figure.imageUrl }}
|
||||||
resizeMode="contain"
|
style={{ width: '100%', height: '100%' }}
|
||||||
onLoad={(e) => {
|
resizeMode="contain"
|
||||||
const { width: srcW, height: srcH } = e.nativeEvent.source;
|
onLoad={(e) => {
|
||||||
computeContainSize(srcW, srcH);
|
const { width: srcW, height: srcH } = e.nativeEvent.source;
|
||||||
}}
|
computeContainSize(srcW, srcH);
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
className="bg-surface items-center justify-center rounded-2xl"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: RARITY_COLORS[figure.rarity],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="text-foreground" style={{ fontSize: 18, fontWeight: '800' }}>
|
||||||
|
{figure.name}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-muted-foreground mt-2" style={{ fontSize: 12 }}>
|
||||||
|
No image
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* ── Back ── */}
|
{/* ── Back ── */}
|
||||||
|
|
@ -165,7 +197,7 @@ export default function CardDetailV2Screen() {
|
||||||
right: -5,
|
right: -5,
|
||||||
bottom: -5,
|
bottom: -5,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: RARITY_COLORS[card.rarity],
|
backgroundColor: RARITY_COLORS[figure.rarity],
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -175,7 +207,7 @@ export default function CardDetailV2Screen() {
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
borderWidth: 3,
|
borderWidth: 3,
|
||||||
borderColor: RARITY_COLORS[card.rarity],
|
borderColor: RARITY_COLORS[figure.rarity],
|
||||||
padding: 20,
|
padding: 20,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
}}
|
}}
|
||||||
|
|
@ -206,20 +238,22 @@ export default function CardDetailV2Screen() {
|
||||||
className="text-foreground"
|
className="text-foreground"
|
||||||
style={{ fontSize: 20, fontWeight: '900', letterSpacing: -0.5 }}
|
style={{ fontSize: 20, fontWeight: '900', letterSpacing: -0.5 }}
|
||||||
>
|
>
|
||||||
{card.name}
|
{figure.name}
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: '800',
|
|
||||||
letterSpacing: 2,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
marginTop: 2,
|
|
||||||
color: RARITY_COLORS[card.rarity],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{card.subtitle}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{profile?.subtitle && (
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '800',
|
||||||
|
letterSpacing: 2,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginTop: 2,
|
||||||
|
color: RARITY_COLORS[figure.rarity],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{profile.subtitle}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
|
|
@ -228,27 +262,54 @@ export default function CardDetailV2Screen() {
|
||||||
style={{ fontSize: 13, lineHeight: 20 }}
|
style={{ fontSize: 13, lineHeight: 20 }}
|
||||||
numberOfLines={5}
|
numberOfLines={5}
|
||||||
>
|
>
|
||||||
{card.description}
|
{profile?.backstory || figure.userInput.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<View>
|
{profile?.stats && (
|
||||||
<Text
|
<View>
|
||||||
style={{
|
<Text
|
||||||
fontSize: 10,
|
style={{
|
||||||
fontWeight: '900',
|
fontSize: 10,
|
||||||
letterSpacing: 3,
|
fontWeight: '900',
|
||||||
textTransform: 'uppercase',
|
letterSpacing: 3,
|
||||||
color: RARITY_COLORS[card.rarity],
|
textTransform: 'uppercase',
|
||||||
marginBottom: 6,
|
color: RARITY_COLORS[figure.rarity],
|
||||||
}}
|
marginBottom: 6,
|
||||||
>
|
}}
|
||||||
Stats
|
>
|
||||||
</Text>
|
Stats
|
||||||
<StatBar label="ATK" value={card.stats.attack} color={STAT_COLORS.attack} />
|
</Text>
|
||||||
<StatBar label="DEF" value={card.stats.defense} color={STAT_COLORS.defense} />
|
<StatBar label="ATK" value={profile.stats.attack} color={STAT_COLORS.attack} />
|
||||||
<StatBar label="SPL" value={card.stats.special} color={STAT_COLORS.special} />
|
<StatBar
|
||||||
</View>
|
label="DEF"
|
||||||
|
value={profile.stats.defense}
|
||||||
|
color={STAT_COLORS.defense}
|
||||||
|
/>
|
||||||
|
<StatBar
|
||||||
|
label="SPL"
|
||||||
|
value={profile.stats.special}
|
||||||
|
color={STAT_COLORS.special}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Special Attack */}
|
||||||
|
{profile?.specialAttack && (
|
||||||
|
<View className="bg-input rounded" style={{ padding: 8 }}>
|
||||||
|
<Text
|
||||||
|
className="text-primary"
|
||||||
|
style={{
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: '900',
|
||||||
|
letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚡ {profile.specialAttack.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Bottom: rarity + ID */}
|
{/* Bottom: rarity + ID */}
|
||||||
<View className="flex-row items-center justify-between">
|
<View className="flex-row items-center justify-between">
|
||||||
|
|
@ -257,7 +318,7 @@ export default function CardDetailV2Screen() {
|
||||||
style={{
|
style={{
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: 3,
|
paddingVertical: 3,
|
||||||
backgroundColor: RARITY_COLORS[card.rarity],
|
backgroundColor: RARITY_COLORS[figure.rarity],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
|
|
@ -269,28 +330,20 @@ export default function CardDetailV2Screen() {
|
||||||
color: 'rgb(15, 15, 30)',
|
color: 'rgb(15, 15, 30)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{card.rarity}
|
{figure.rarity}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
className="text-muted-foreground"
|
className="text-muted-foreground"
|
||||||
style={{ fontSize: 9, fontWeight: '600', letterSpacing: 1 }}
|
style={{ fontSize: 9, fontWeight: '600', letterSpacing: 1 }}
|
||||||
>
|
>
|
||||||
#{card.id.split('-').pop()?.toUpperCase()}
|
#{figure.id.split('-').pop()?.toUpperCase()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
</GestureDetector>
|
||||||
|
|
||||||
{/* Hint */}
|
|
||||||
<Text
|
|
||||||
className="text-muted-foreground mt-6"
|
|
||||||
style={{ fontSize: 11, fontWeight: '600', letterSpacing: 1 }}
|
|
||||||
>
|
|
||||||
Drag to rotate · Double-tap to flip
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import type { ImageSourcePropType } from 'react-native';
|
|
||||||
import type { FigureRarity } from '@figgos/shared';
|
|
||||||
|
|
||||||
export interface CardData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
subtitle: string;
|
|
||||||
description: string;
|
|
||||||
rarity: FigureRarity;
|
|
||||||
image: ImageSourcePropType;
|
|
||||||
stats: { attack: number; defense: number; special: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CARDS: CardData[] = [
|
|
||||||
{
|
|
||||||
id: 'cole-epic',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Noir City Homicide Division',
|
|
||||||
description:
|
|
||||||
'A hardboiled detective who has seen it all. Armed with nothing but a trench coat, a sharp mind, and an unhealthy coffee addiction. Solves impossible cases in the rain-soaked streets of Noir City.',
|
|
||||||
rarity: 'epic',
|
|
||||||
image: require('../assets/images/cole-epic.png'),
|
|
||||||
stats: { attack: 42, defense: 68, special: 75 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cole-rare',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Noir City Homicide Division',
|
|
||||||
description:
|
|
||||||
'Fresh off his first big case, Cole is making a name for himself in the precinct. His instincts are sharp, but he still has a lot to learn about the darker side of Noir City.',
|
|
||||||
rarity: 'rare',
|
|
||||||
image: require('../assets/images/cole-rare.png'),
|
|
||||||
stats: { attack: 35, defense: 52, special: 60 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cole-legendary',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Noir City Homicide Division',
|
|
||||||
description:
|
|
||||||
'The legend of Noir City. After decades on the force, Cole has become the detective other detectives tell stories about. His case closure rate is unmatched in the history of the division.',
|
|
||||||
rarity: 'legendary',
|
|
||||||
image: require('../assets/images/cole-legendary.png'),
|
|
||||||
stats: { attack: 78, defense: 85, special: 95 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cole-common',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Noir City Homicide Division',
|
|
||||||
description:
|
|
||||||
'A standard-issue detective doing his best in a tough city. Nothing fancy, but reliable. Shows up every day, drinks too much coffee, and gets the job done.',
|
|
||||||
rarity: 'common',
|
|
||||||
image: require('../assets/images/cole-common.png'),
|
|
||||||
stats: { attack: 22, defense: 30, special: 28 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cole-kraft',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Kraft Edition',
|
|
||||||
description:
|
|
||||||
"Limited kraft paper edition. A collector's item with a vintage feel. The same old Cole, but with that handmade, artisanal charm that cardboard enthusiasts crave.",
|
|
||||||
rarity: 'common',
|
|
||||||
image: require('../assets/images/cole-kraft.png'),
|
|
||||||
stats: { attack: 25, defense: 32, special: 30 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -26,10 +26,10 @@ export const api = {
|
||||||
health: () => fetchApi('/health'),
|
health: () => fetchApi('/health'),
|
||||||
|
|
||||||
figures: {
|
figures: {
|
||||||
create: (name: string, description: string) =>
|
create: (name: string, description: string, language: string = 'en') =>
|
||||||
fetchApi<{ figure: FigureResponse }>('/api/v1/figures', {
|
fetchApi<{ figure: FigureResponse }>('/api/v1/figures', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, description }),
|
body: JSON.stringify({ name, description, language }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
list: () => fetchApi<{ figures: FigureResponse[] }>('/api/v1/figures'),
|
list: () => fetchApi<{ figures: FigureResponse[] }>('/api/v1/figures'),
|
||||||
|
|
|
||||||
29
apps/figgos/apps/web/src/lib/api.ts
Normal file
29
apps/figgos/apps/web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { FigureResponse } from '@figgos/shared';
|
||||||
|
|
||||||
|
const BACKEND_URL = 'http://localhost:3025';
|
||||||
|
|
||||||
|
async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BACKEND_URL}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(text || `API error: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
figures: {
|
||||||
|
create: (name: string, description: string, language = 'en') =>
|
||||||
|
fetchApi<{ figure: FigureResponse }>('/api/v1/figures', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, description, language }),
|
||||||
|
}),
|
||||||
|
list: () => fetchApi<{ figures: FigureResponse[] }>('/api/v1/figures'),
|
||||||
|
get: (id: string) => fetchApi<{ figure: FigureResponse }>(`/api/v1/figures/${id}`),
|
||||||
|
delete: (id: string) =>
|
||||||
|
fetchApi<{ success: boolean }>(`/api/v1/figures/${id}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import type { FigureRarity } from '@figgos/shared';
|
|
||||||
|
|
||||||
export interface CardData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
subtitle: string;
|
|
||||||
description: string;
|
|
||||||
rarity: FigureRarity;
|
|
||||||
image: string;
|
|
||||||
stats: { attack: number; defense: number; special: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CARDS: CardData[] = [
|
|
||||||
{
|
|
||||||
id: 'cole-epic',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Noir City Homicide Division',
|
|
||||||
description:
|
|
||||||
'A hardboiled detective who has seen it all. Armed with nothing but a trench coat, a sharp mind, and an unhealthy coffee addiction. Solves impossible cases in the rain-soaked streets of Noir City.',
|
|
||||||
rarity: 'epic',
|
|
||||||
image: '/images/cole-epic.png',
|
|
||||||
stats: { attack: 42, defense: 68, special: 75 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cole-rare',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Noir City Homicide Division',
|
|
||||||
description:
|
|
||||||
'Fresh off his first big case, Cole is making a name for himself in the precinct. His instincts are sharp, but he still has a lot to learn about the darker side of Noir City.',
|
|
||||||
rarity: 'rare',
|
|
||||||
image: '/images/cole-rare.png',
|
|
||||||
stats: { attack: 35, defense: 52, special: 60 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cole-legendary',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Noir City Homicide Division',
|
|
||||||
description:
|
|
||||||
'The legend of Noir City. After decades on the force, Cole has become the detective other detectives tell stories about. His case closure rate is unmatched in the history of the division.',
|
|
||||||
rarity: 'legendary',
|
|
||||||
image: '/images/cole-legendary.png',
|
|
||||||
stats: { attack: 78, defense: 85, special: 95 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cole-common',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Noir City Homicide Division',
|
|
||||||
description:
|
|
||||||
'A standard-issue detective doing his best in a tough city. Nothing fancy, but reliable. Shows up every day, drinks too much coffee, and gets the job done.',
|
|
||||||
rarity: 'common',
|
|
||||||
image: '/images/cole-common.png',
|
|
||||||
stats: { attack: 22, defense: 30, special: 28 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cole-kraft',
|
|
||||||
name: 'Detective Cole',
|
|
||||||
subtitle: 'Kraft Edition',
|
|
||||||
description:
|
|
||||||
"Limited kraft paper edition. A collector's item with a vintage feel. The same old Cole, but with that handmade, artisanal charm that cardboard enthusiasts crave.",
|
|
||||||
rarity: 'common',
|
|
||||||
image: '/images/cole-kraft.png',
|
|
||||||
stats: { attack: 25, defense: 32, special: 30 },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let description = $state('');
|
let description = $state('');
|
||||||
|
|
@ -14,6 +15,12 @@
|
||||||
legendary: 'rgb(180, 130, 20)',
|
legendary: 'rgb(180, 130, 20)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STAT_COLORS = {
|
||||||
|
attack: 'rgb(255, 51, 102)',
|
||||||
|
defense: 'rgb(0, 210, 170)',
|
||||||
|
special: 'rgb(180, 130, 255)',
|
||||||
|
};
|
||||||
|
|
||||||
async function handleGenerate() {
|
async function handleGenerate() {
|
||||||
if (!name.trim() || !description.trim()) {
|
if (!name.trim() || !description.trim()) {
|
||||||
error = 'Give your figure a name and a story';
|
error = 'Give your figure a name and a story';
|
||||||
|
|
@ -22,20 +29,8 @@
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
const { figure } = await api.figures.create(name.trim(), description.trim());
|
||||||
const rarities: FigureRarity[] = ['common', 'common', 'common', 'rare', 'rare', 'epic', 'legendary'];
|
result = figure;
|
||||||
result = {
|
|
||||||
id: 'mock-id',
|
|
||||||
userId: 'mock-user',
|
|
||||||
name: name.trim(),
|
|
||||||
userInput: { description: description.trim() },
|
|
||||||
imageUrl: null,
|
|
||||||
rarity: rarities[Math.floor(Math.random() * rarities.length)],
|
|
||||||
isPublic: false,
|
|
||||||
isArchived: false,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error = e.message || 'Something went wrong';
|
error = e.message || 'Something went wrong';
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -49,6 +44,8 @@
|
||||||
result = null;
|
result = null;
|
||||||
error = null;
|
error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let profile = $derived(result?.generatedProfile);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if result}
|
{#if result}
|
||||||
|
|
@ -66,29 +63,100 @@
|
||||||
|
|
||||||
<!-- Figure Card -->
|
<!-- Figure Card -->
|
||||||
<div class="brutal-shadow rounded-xl">
|
<div class="brutal-shadow rounded-xl">
|
||||||
<div
|
<div class="rounded-xl border-3 border-border bg-surface p-8">
|
||||||
class="rounded-xl border-3 border-border bg-surface p-8"
|
<!-- Image -->
|
||||||
>
|
{#if result.imageUrl}
|
||||||
<!-- Image placeholder -->
|
<img
|
||||||
<div
|
src={result.imageUrl}
|
||||||
class="mx-auto mb-6 flex h-[260px] w-[260px] items-center justify-center rounded-xl border-2 border-border-muted bg-input"
|
alt={result.name}
|
||||||
>
|
class="mx-auto mb-6 h-[260px] w-[260px] rounded-xl object-contain"
|
||||||
<span class="text-base text-muted-foreground">Image coming soon</span>
|
/>
|
||||||
</div>
|
{:else}
|
||||||
|
<div
|
||||||
|
class="mx-auto mb-6 flex h-[260px] w-[260px] items-center justify-center rounded-xl border-2 border-border-muted bg-input"
|
||||||
|
>
|
||||||
|
<span class="text-base text-muted-foreground">
|
||||||
|
{result.status === 'failed' ? 'Generation failed' : 'No image'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h2 class="text-center text-3xl font-black tracking-tight text-foreground">
|
<h2 class="text-center text-3xl font-black tracking-tight text-foreground">
|
||||||
{result.name}
|
{result.name}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-4 text-center text-lg leading-6 text-muted-foreground">
|
|
||||||
{result.userInput.description}
|
{#if profile?.subtitle}
|
||||||
</p>
|
<p
|
||||||
|
class="mt-1 text-center text-sm font-bold uppercase tracking-wider text-muted-foreground"
|
||||||
|
>
|
||||||
|
{profile.subtitle}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if profile?.backstory}
|
||||||
|
<p class="mt-4 text-center text-lg leading-6 text-muted-foreground">
|
||||||
|
{profile.backstory}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
{#if profile?.stats}
|
||||||
|
<div class="mt-5">
|
||||||
|
{#each [{ label: 'ATK', value: profile.stats.attack, color: STAT_COLORS.attack }, { label: 'DEF', value: profile.stats.defense, color: STAT_COLORS.defense }, { label: 'SPL', value: profile.stats.special, color: STAT_COLORS.special }] as stat (stat.label)}
|
||||||
|
<div class="mb-2.5 flex items-center gap-3">
|
||||||
|
<span class="w-10 text-sm font-black tracking-wider text-muted-foreground">
|
||||||
|
{stat.label}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="h-3 flex-1 overflow-hidden rounded-full border border-border-muted bg-input"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style="width: {stat.value}%; background-color: {stat.color}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-8 text-right text-sm font-extrabold text-foreground">
|
||||||
|
{stat.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Special Attack -->
|
||||||
|
{#if profile?.specialAttack}
|
||||||
|
<div class="mt-4 rounded-lg bg-input p-4">
|
||||||
|
<p class="text-xs font-black uppercase tracking-wider text-primary">
|
||||||
|
⚡ {profile.specialAttack.name}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
{profile.specialAttack.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
{#if profile?.items && profile.items.length > 0}
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
{#each profile.items as item (item.name)}
|
||||||
|
<div class="rounded-lg border border-border-muted bg-input/50 p-3">
|
||||||
|
<p class="text-xs font-black uppercase tracking-wider text-foreground">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Rarity Badge -->
|
<!-- Rarity Badge -->
|
||||||
<div class="mt-6 flex justify-center">
|
<div class="mt-6 flex justify-center">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<div
|
||||||
class="absolute rounded-full"
|
class="absolute rounded-full"
|
||||||
style="top: 3px; left: 2px; right: -2px; bottom: -3px; background-color: {RARITY_SHADOW[result.rarity]}"
|
style="top: 3px; left: 2px; right: -2px; bottom: -3px; background-color: {RARITY_SHADOW[
|
||||||
|
result.rarity
|
||||||
|
]}"
|
||||||
></div>
|
></div>
|
||||||
<span
|
<span
|
||||||
class="relative inline-block rounded-full border-2 border-white/20 px-6 py-2.5 text-sm font-black uppercase tracking-[2px]"
|
class="relative inline-block rounded-full border-2 border-white/20 px-6 py-2.5 text-sm font-black uppercase tracking-[2px]"
|
||||||
|
|
@ -98,6 +166,11 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
{#if result.status === 'failed' && result.errorMessage}
|
||||||
|
<p class="mt-4 text-center text-sm text-destructive">{result.errorMessage}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -198,10 +271,21 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<span class="inline-flex items-center gap-2">
|
<span class="inline-flex items-center gap-2">
|
||||||
<svg class="h-6 w-6 animate-spin" viewBox="0 0 24 24" fill="none">
|
<svg class="h-6 w-6 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
Rolling...
|
Generating...
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
Generate Figgo
|
Generate Figgo
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { CARDS } from '$lib/data/cards';
|
import type { FigureResponse, FigureRarity } from '@figgos/shared';
|
||||||
import type { FigureRarity } from '@figgos/shared';
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
const RARITY_COLORS: Record<FigureRarity, string> = {
|
const RARITY_COLORS: Record<FigureRarity, string> = {
|
||||||
common: 'rgb(136, 136, 170)',
|
common: 'rgb(136, 136, 170)',
|
||||||
|
|
@ -16,7 +16,21 @@
|
||||||
special: 'rgb(180, 130, 255)',
|
special: 'rgb(180, 130, 255)',
|
||||||
};
|
};
|
||||||
|
|
||||||
let card = $derived(CARDS.find((c) => c.id === page.params.id));
|
let figure = $state<FigureResponse | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const id = page.params.id;
|
||||||
|
if (!id) return;
|
||||||
|
loading = true;
|
||||||
|
api.figures
|
||||||
|
.get(id)
|
||||||
|
.then(({ figure: f }) => (figure = f))
|
||||||
|
.catch(() => (figure = null))
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
|
|
||||||
|
let profile = $derived(figure?.generatedProfile);
|
||||||
|
|
||||||
// Measure actual image size to match front/back
|
// Measure actual image size to match front/back
|
||||||
let cardWidth = $state(0);
|
let cardWidth = $state(0);
|
||||||
|
|
@ -27,12 +41,8 @@
|
||||||
const img = e.target as HTMLImageElement;
|
const img = e.target as HTMLImageElement;
|
||||||
const natW = img.naturalWidth;
|
const natW = img.naturalWidth;
|
||||||
const natH = img.naturalHeight;
|
const natH = img.naturalHeight;
|
||||||
|
|
||||||
// Available space
|
|
||||||
const maxW = window.innerWidth * 0.85;
|
const maxW = window.innerWidth * 0.85;
|
||||||
const maxH = window.innerHeight * 0.78;
|
const maxH = window.innerHeight * 0.78;
|
||||||
|
|
||||||
// Fit image into available space (contain)
|
|
||||||
const ratio = Math.min(maxW / natW, maxH / natH);
|
const ratio = Math.min(maxW / natW, maxH / natH);
|
||||||
cardWidth = Math.round(natW * ratio);
|
cardWidth = Math.round(natW * ratio);
|
||||||
cardHeight = Math.round(natH * ratio);
|
cardHeight = Math.round(natH * ratio);
|
||||||
|
|
@ -59,17 +69,11 @@
|
||||||
function handlePointerUp() {
|
function handlePointerUp() {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
|
|
||||||
// Snap to 0 or 180
|
|
||||||
const normalised = ((rotateY % 360) + 360) % 360;
|
const normalised = ((rotateY % 360) + 360) % 360;
|
||||||
let target: number;
|
let target: number;
|
||||||
if (normalised < 90) {
|
if (normalised < 90) target = 0;
|
||||||
target = 0;
|
else if (normalised < 270) target = 180;
|
||||||
} else if (normalised < 270) {
|
else target = 360;
|
||||||
target = 180;
|
|
||||||
} else {
|
|
||||||
target = 360;
|
|
||||||
}
|
|
||||||
const diff = target - normalised;
|
const diff = target - normalised;
|
||||||
const snapTo = rotateY + diff;
|
const snapTo = rotateY + diff;
|
||||||
savedRotateY = snapTo % 360;
|
savedRotateY = snapTo % 360;
|
||||||
|
|
@ -81,9 +85,30 @@
|
||||||
savedRotateY = target % 360;
|
savedRotateY = target % 360;
|
||||||
rotateY = target;
|
rotateY = target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback card size when no image
|
||||||
|
function initFallbackSize() {
|
||||||
|
const maxW = window.innerWidth * 0.85;
|
||||||
|
const maxH = window.innerHeight * 0.78;
|
||||||
|
cardWidth = Math.min(400, maxW);
|
||||||
|
cardHeight = Math.min(600, maxH);
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if card}
|
{#if loading}
|
||||||
|
<div class="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<svg class="h-8 w-8 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else if figure}
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mx-auto flex max-w-lg items-center justify-between px-5 py-3">
|
<div class="mx-auto flex max-w-lg items-center justify-between px-5 py-3">
|
||||||
<a
|
<a
|
||||||
|
|
@ -98,13 +123,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hidden image to measure natural dimensions -->
|
<!-- Hidden image to measure natural dimensions -->
|
||||||
{#if !loaded}
|
{#if figure.imageUrl && !loaded}
|
||||||
<img
|
<img src={figure.imageUrl} alt="" class="invisible absolute" onload={handleImageLoad} />
|
||||||
src={card.image}
|
{/if}
|
||||||
alt=""
|
|
||||||
class="invisible absolute"
|
<!-- Init fallback size if no image -->
|
||||||
onload={handleImageLoad}
|
{#if !figure.imageUrl && !loaded}
|
||||||
/>
|
{@const _ = initFallbackSize()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Card -->
|
<!-- Card -->
|
||||||
|
|
@ -128,17 +153,27 @@
|
||||||
transition: {isDragging ? 'none' : 'transform 0.4s cubic-bezier(0.22, 1, 0.36, 1)'};
|
transition: {isDragging ? 'none' : 'transform 0.4s cubic-bezier(0.22, 1, 0.36, 1)'};
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- Front: only the image -->
|
<!-- Front: the image or placeholder -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 overflow-hidden rounded-xl"
|
class="absolute inset-0 overflow-hidden rounded-xl"
|
||||||
style="backface-visibility: hidden"
|
style="backface-visibility: hidden"
|
||||||
>
|
>
|
||||||
<img
|
{#if figure.imageUrl}
|
||||||
src={card.image}
|
<img
|
||||||
alt={card.name}
|
src={figure.imageUrl}
|
||||||
class="h-full w-full object-cover"
|
alt={figure.name}
|
||||||
draggable="false"
|
class="h-full w-full object-cover"
|
||||||
/>
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full flex-col items-center justify-center rounded-xl border-3 bg-surface"
|
||||||
|
style="border-color: {RARITY_COLORS[figure.rarity]}"
|
||||||
|
>
|
||||||
|
<span class="text-2xl font-black text-foreground">{figure.name}</span>
|
||||||
|
<span class="mt-2 text-sm text-muted-foreground">No image</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Back -->
|
<!-- Back -->
|
||||||
|
|
@ -147,13 +182,13 @@
|
||||||
style="
|
style="
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
transform: rotateY(180deg);
|
transform: rotateY(180deg);
|
||||||
border-color: {RARITY_COLORS[card.rarity]};
|
border-color: {RARITY_COLORS[figure.rarity]};
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- Shadow layer -->
|
<!-- Shadow layer -->
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute -bottom-[5px] -right-[5px] left-[5px] top-[5px] rounded-xl"
|
class="pointer-events-none absolute -bottom-[5px] -right-[5px] left-[5px] top-[5px] rounded-xl"
|
||||||
style="background-color: {RARITY_COLORS[card.rarity]}; opacity: 0.15; z-index: -1"
|
style="background-color: {RARITY_COLORS[figure.rarity]}; opacity: 0.15; z-index: -1"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|
@ -165,66 +200,75 @@
|
||||||
Backstory
|
Backstory
|
||||||
</span>
|
</span>
|
||||||
<h2 class="text-4xl font-black tracking-tight text-foreground">
|
<h2 class="text-4xl font-black tracking-tight text-foreground">
|
||||||
{card.name}
|
{figure.name}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
{#if profile?.subtitle}
|
||||||
class="mt-1.5 text-base font-extrabold uppercase tracking-[2px]"
|
<p
|
||||||
style="color: {RARITY_COLORS[card.rarity]}"
|
class="mt-1.5 text-base font-extrabold uppercase tracking-[2px]"
|
||||||
>
|
style="color: {RARITY_COLORS[figure.rarity]}"
|
||||||
{card.subtitle}
|
>
|
||||||
</p>
|
{profile.subtitle}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<p class="text-lg leading-7 text-muted-foreground">
|
<p class="text-lg leading-7 text-muted-foreground">
|
||||||
{card.description}
|
{profile?.backstory || figure.userInput.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div>
|
{#if profile?.stats}
|
||||||
<p
|
<div>
|
||||||
class="mb-3 text-base font-black uppercase tracking-[3px]"
|
<p
|
||||||
style="color: {RARITY_COLORS[card.rarity]}"
|
class="mb-3 text-base font-black uppercase tracking-[3px]"
|
||||||
>
|
style="color: {RARITY_COLORS[figure.rarity]}"
|
||||||
Stats
|
>
|
||||||
</p>
|
Stats
|
||||||
{#each [
|
</p>
|
||||||
{ label: 'ATK', value: card.stats.attack, color: STAT_COLORS.attack },
|
{#each [{ label: 'ATK', value: profile.stats.attack, color: STAT_COLORS.attack }, { label: 'DEF', value: profile.stats.defense, color: STAT_COLORS.defense }, { label: 'SPL', value: profile.stats.special, color: STAT_COLORS.special }] as stat (stat.label)}
|
||||||
{ label: 'DEF', value: card.stats.defense, color: STAT_COLORS.defense },
|
<div class="mb-3 flex items-center gap-3">
|
||||||
{ label: 'SPL', value: card.stats.special, color: STAT_COLORS.special },
|
<span class="w-12 text-base font-black tracking-wider text-muted-foreground">
|
||||||
] as stat (stat.label)}
|
{stat.label}
|
||||||
<div class="mb-3 flex items-center gap-3">
|
</span>
|
||||||
<span class="w-12 text-base font-black tracking-wider text-muted-foreground">
|
|
||||||
{stat.label}
|
|
||||||
</span>
|
|
||||||
<div
|
|
||||||
class="h-3.5 flex-1 overflow-hidden rounded-full border border-border-muted bg-input"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full"
|
class="h-3.5 flex-1 overflow-hidden rounded-full border border-border-muted bg-input"
|
||||||
style="width: {stat.value}%; background-color: {stat.color}"
|
>
|
||||||
></div>
|
<div
|
||||||
|
class="h-full rounded-full"
|
||||||
|
style="width: {stat.value}%; background-color: {stat.color}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-9 text-right text-base font-extrabold text-foreground">
|
||||||
|
{stat.value}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="w-9 text-right text-base font-extrabold text-foreground">
|
{/each}
|
||||||
{stat.value}
|
</div>
|
||||||
</span>
|
{/if}
|
||||||
</div>
|
|
||||||
{/each}
|
<!-- Special Attack -->
|
||||||
</div>
|
{#if profile?.specialAttack}
|
||||||
|
<div class="rounded-lg bg-input p-3">
|
||||||
|
<p class="text-xs font-black uppercase tracking-wider text-primary">
|
||||||
|
⚡ {profile.specialAttack.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Bottom: rarity + ID -->
|
<!-- Bottom: rarity + ID -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
class="rounded-full px-5 py-2 text-sm font-black uppercase tracking-[2px]"
|
class="rounded-full px-5 py-2 text-sm font-black uppercase tracking-[2px]"
|
||||||
style="
|
style="
|
||||||
background-color: {RARITY_COLORS[card.rarity]};
|
background-color: {RARITY_COLORS[figure.rarity]};
|
||||||
color: rgb(15, 15, 30);
|
color: rgb(15, 15, 30);
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{card.rarity}
|
{figure.rarity}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-semibold tracking-wider text-muted-foreground">
|
<span class="text-sm font-semibold tracking-wider text-muted-foreground">
|
||||||
#{card.id.split('-').pop()?.toUpperCase()}
|
#{figure.id.split('-').pop()?.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,70 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CARDS } from '$lib/data/cards';
|
import type { FigureResponse } from '@figgos/shared';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
let figures = $state<FigureResponse[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api.figures
|
||||||
|
.list()
|
||||||
|
.then(({ figures: f }) => {
|
||||||
|
figures = f;
|
||||||
|
error = null;
|
||||||
|
})
|
||||||
|
.catch((e) => (error = e.message))
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl px-5 pt-8">
|
<div class="mx-auto max-w-2xl px-5 pt-8">
|
||||||
<h1 class="text-4xl font-black tracking-tight text-foreground">Collection</h1>
|
<h1 class="text-4xl font-black tracking-tight text-foreground">Collection</h1>
|
||||||
<p class="mt-1 text-base text-muted-foreground">
|
<p class="mt-1 text-base text-muted-foreground">
|
||||||
{CARDS.length} {CARDS.length === 1 ? 'Figgo' : 'Figgos'}
|
{loading ? '...' : `${figures.length} ${figures.length === 1 ? 'Figgo' : 'Figgos'}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-6 grid grid-cols-2 gap-4">
|
{#if loading}
|
||||||
{#each CARDS as card (card.id)}
|
<div class="mt-20 flex justify-center">
|
||||||
<a href="/card/{card.id}" class="block transition-opacity hover:opacity-80 active:opacity-70">
|
<svg class="h-8 w-8 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
<div class="aspect-[1/1.45] overflow-hidden rounded-xl">
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||||
<img
|
></circle>
|
||||||
src={card.image}
|
<path
|
||||||
alt={card.name}
|
class="opacity-75"
|
||||||
class="h-full w-full object-cover"
|
fill="currentColor"
|
||||||
/>
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
</div>
|
></path>
|
||||||
</a>
|
</svg>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{:else if error}
|
||||||
|
<div class="mt-20 text-center">
|
||||||
|
<p class="text-base text-destructive">{error}</p>
|
||||||
|
</div>
|
||||||
|
{:else if figures.length === 0}
|
||||||
|
<div class="mt-20 text-center">
|
||||||
|
<p class="text-lg font-bold text-muted-foreground">No figures yet</p>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">Create your first Figgo!</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-6 grid grid-cols-2 gap-4">
|
||||||
|
{#each figures as figure (figure.id)}
|
||||||
|
<a
|
||||||
|
href="/card/{figure.id}"
|
||||||
|
class="block transition-opacity hover:opacity-80 active:opacity-70"
|
||||||
|
>
|
||||||
|
<div class="aspect-[1/1.45] overflow-hidden rounded-xl">
|
||||||
|
{#if figure.imageUrl}
|
||||||
|
<img src={figure.imageUrl} alt={figure.name} class="h-full w-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full items-center justify-center rounded-xl border-2 border-border-muted bg-surface"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-bold text-muted-foreground">{figure.name}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CARDS } from '$lib/data/cards';
|
import type { FigureResponse } from '@figgos/shared';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
let figures = $state<FigureResponse[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
api.figures
|
||||||
|
.list()
|
||||||
|
.then(({ figures: f }) => (figures = f))
|
||||||
|
.finally(() => (loading = false));
|
||||||
|
});
|
||||||
|
|
||||||
let activeIndex = $state(0);
|
let activeIndex = $state(0);
|
||||||
let scrollContainer: HTMLDivElement;
|
let scrollContainer: HTMLDivElement;
|
||||||
|
|
@ -31,7 +42,6 @@
|
||||||
let hasDragged = $state(false);
|
let hasDragged = $state(false);
|
||||||
|
|
||||||
function handlePointerDown(e: PointerEvent) {
|
function handlePointerDown(e: PointerEvent) {
|
||||||
// Only for mouse (touch already scrolls natively)
|
|
||||||
if (e.pointerType !== 'mouse') return;
|
if (e.pointerType !== 'mouse') return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
|
|
@ -55,7 +65,6 @@
|
||||||
scrollContainer.releasePointerCapture(e.pointerId);
|
scrollContainer.releasePointerCapture(e.pointerId);
|
||||||
scrollContainer.style.scrollSnapType = '';
|
scrollContainer.style.scrollSnapType = '';
|
||||||
|
|
||||||
// Re-snap to nearest card
|
|
||||||
const children = Array.from(scrollContainer.querySelectorAll('[data-card]'));
|
const children = Array.from(scrollContainer.querySelectorAll('[data-card]'));
|
||||||
const scrollCenter = scrollContainer.scrollLeft + scrollContainer.clientWidth / 2;
|
const scrollCenter = scrollContainer.scrollLeft + scrollContainer.clientWidth / 2;
|
||||||
let closest: HTMLElement | null = null;
|
let closest: HTMLElement | null = null;
|
||||||
|
|
@ -72,7 +81,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (closest) {
|
if (closest) {
|
||||||
const targetScroll = closest.offsetLeft - (scrollContainer.clientWidth - closest.clientWidth) / 2;
|
const targetScroll =
|
||||||
|
closest.offsetLeft - (scrollContainer.clientWidth - closest.clientWidth) / 2;
|
||||||
scrollContainer.scrollTo({ left: targetScroll, behavior: 'smooth' });
|
scrollContainer.scrollTo({ left: targetScroll, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +94,69 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-[calc(100dvh-72px)] items-center justify-center">
|
||||||
|
<svg class="h-8 w-8 animate-spin text-primary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{:else if figures.length === 0}
|
||||||
|
<div class="flex h-[calc(100dvh-72px)] items-center justify-center">
|
||||||
|
<p class="text-lg font-bold text-muted-foreground">No figures yet</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="showcase-scroll h-[calc(100dvh-72px)] w-full"
|
||||||
|
class:cursor-grabbing={isDragging}
|
||||||
|
bind:this={scrollContainer}
|
||||||
|
onscroll={handleScroll}
|
||||||
|
onpointerdown={handlePointerDown}
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerup={handlePointerUp}
|
||||||
|
onpointercancel={handlePointerUp}
|
||||||
|
>
|
||||||
|
{#each figures as figure, i (figure.id)}
|
||||||
|
<a
|
||||||
|
href="/card/{figure.id}"
|
||||||
|
class="showcase-card h-full transition-all duration-300"
|
||||||
|
data-card
|
||||||
|
onclick={handleClick}
|
||||||
|
style="
|
||||||
|
transform: scale({i === activeIndex ? 1 : 0.8}) rotate({i === activeIndex
|
||||||
|
? 0
|
||||||
|
: i < activeIndex
|
||||||
|
? -3
|
||||||
|
: 3}deg);
|
||||||
|
opacity: {i === activeIndex ? 1 : 0.45};
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{#if figure.imageUrl}
|
||||||
|
<img
|
||||||
|
src={figure.imageUrl}
|
||||||
|
alt={figure.name}
|
||||||
|
class="max-h-[85%] w-auto max-w-full object-contain"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex h-[60%] w-[80%] flex-col items-center justify-center rounded-xl border-2 border-border-muted bg-surface"
|
||||||
|
>
|
||||||
|
<span class="text-lg font-black text-foreground">{figure.name}</span>
|
||||||
|
<span class="mt-1 text-sm text-muted-foreground">No image</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.showcase-scroll {
|
.showcase-scroll {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -99,7 +172,6 @@
|
||||||
.showcase-scroll::-webkit-scrollbar {
|
.showcase-scroll::-webkit-scrollbar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
/* Side padding so first/last card can center */
|
|
||||||
.showcase-scroll::before,
|
.showcase-scroll::before,
|
||||||
.showcase-scroll::after {
|
.showcase-scroll::after {
|
||||||
content: '';
|
content: '';
|
||||||
|
|
@ -116,35 +188,3 @@
|
||||||
touch-action: pan-x;
|
touch-action: pan-x;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="showcase-scroll h-[calc(100dvh-72px)] w-full"
|
|
||||||
class:cursor-grabbing={isDragging}
|
|
||||||
bind:this={scrollContainer}
|
|
||||||
onscroll={handleScroll}
|
|
||||||
onpointerdown={handlePointerDown}
|
|
||||||
onpointermove={handlePointerMove}
|
|
||||||
onpointerup={handlePointerUp}
|
|
||||||
onpointercancel={handlePointerUp}
|
|
||||||
>
|
|
||||||
{#each CARDS as card, i (card.id)}
|
|
||||||
<a
|
|
||||||
href="/card/{card.id}"
|
|
||||||
class="showcase-card h-full transition-all duration-300"
|
|
||||||
data-card
|
|
||||||
onclick={handleClick}
|
|
||||||
style="
|
|
||||||
transform: scale({i === activeIndex ? 1 : 0.8}) rotate({i === activeIndex ? 0 : i < activeIndex ? -3 : 3}deg);
|
|
||||||
opacity: {i === activeIndex ? 1 : 0.45};
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={card.image}
|
|
||||||
alt={card.name}
|
|
||||||
class="max-h-[85%] w-auto max-w-full object-contain"
|
|
||||||
draggable="false"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -4,5 +4,9 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo run dev"
|
"dev": "turbo run dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@imgly/background-removal-node": "^1.4.5",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// ── Rarity ──
|
||||||
|
|
||||||
export type FigureRarity = 'common' | 'rare' | 'epic' | 'legendary';
|
export type FigureRarity = 'common' | 'rare' | 'epic' | 'legendary';
|
||||||
|
|
||||||
export const RARITY_WEIGHTS: Record<FigureRarity, number> = {
|
export const RARITY_WEIGHTS: Record<FigureRarity, number> = {
|
||||||
|
|
@ -7,17 +9,96 @@ export const RARITY_WEIGHTS: Record<FigureRarity, number> = {
|
||||||
legendary: 3,
|
legendary: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const STAT_RANGES: Record<FigureRarity, { min: number; max: number }> = {
|
||||||
|
common: { min: 10, max: 45 },
|
||||||
|
rare: { min: 30, max: 65 },
|
||||||
|
epic: { min: 50, max: 85 },
|
||||||
|
legendary: { min: 75, max: 100 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Card Styles (internal — used for image generation, not persisted) ──
|
||||||
|
|
||||||
|
export type CardStyle =
|
||||||
|
| 'common_kraft'
|
||||||
|
| 'common_white'
|
||||||
|
| 'common_mint'
|
||||||
|
| 'common_warm'
|
||||||
|
| 'rare'
|
||||||
|
| 'epic'
|
||||||
|
| 'legendary';
|
||||||
|
|
||||||
|
const COMMON_STYLES: CardStyle[] = ['common_kraft', 'common_white', 'common_mint', 'common_warm'];
|
||||||
|
|
||||||
|
/** Pick a card style for image generation. Common gets a random variant, others match rarity. */
|
||||||
|
export function getCardStyle(rarity: FigureRarity): CardStyle {
|
||||||
|
if (rarity === 'common') {
|
||||||
|
return COMMON_STYLES[Math.floor(Math.random() * COMMON_STYLES.length)];
|
||||||
|
}
|
||||||
|
return rarity as CardStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Language ──
|
||||||
|
|
||||||
|
export type FigureLanguage = 'en' | 'de';
|
||||||
|
|
||||||
|
// ── Generation Status ──
|
||||||
|
|
||||||
|
export type FigureStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'generating_profile'
|
||||||
|
| 'generating_image'
|
||||||
|
| 'processing'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
// ── User Input (what the user provides) ──
|
||||||
|
|
||||||
export interface FigureUserInput {
|
export interface FigureUserInput {
|
||||||
description: string;
|
description: string;
|
||||||
|
faceImageUrl?: string | null;
|
||||||
|
language: FigureLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Generated Profile (what the LLM produces) ──
|
||||||
|
|
||||||
|
export interface FigureItem {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
lore: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FigureStats {
|
||||||
|
attack: number;
|
||||||
|
defense: number;
|
||||||
|
special: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecialAttack {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedProfile {
|
||||||
|
subtitle: string;
|
||||||
|
backstory: string;
|
||||||
|
visualDescription: string;
|
||||||
|
items: FigureItem[];
|
||||||
|
stats: FigureStats;
|
||||||
|
specialAttack: SpecialAttack;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Response ──
|
||||||
|
|
||||||
export interface FigureResponse {
|
export interface FigureResponse {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
name: string;
|
name: string;
|
||||||
userInput: FigureUserInput;
|
userInput: FigureUserInput;
|
||||||
|
generatedProfile: GeneratedProfile | null;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
rarity: FigureRarity;
|
rarity: FigureRarity;
|
||||||
|
language: FigureLanguage;
|
||||||
|
status: FigureStatus;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
|
||||||
|
|
@ -147,3 +147,13 @@ export function createInventoryStorage(publicUrl?: string): StorageClient {
|
||||||
publicUrl: publicUrl ?? process.env.INVENTORY_S3_PUBLIC_URL,
|
publicUrl: publicUrl ?? process.env.INVENTORY_S3_PUBLIC_URL,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a storage client for the Figgos project
|
||||||
|
*/
|
||||||
|
export function createFiggosStorage(publicUrl?: string): StorageClient {
|
||||||
|
return createStorageClient({
|
||||||
|
name: BUCKETS.FIGGOS,
|
||||||
|
publicUrl: publicUrl ?? process.env.FIGGOS_STORAGE_PUBLIC_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export {
|
||||||
createStorageStorage,
|
createStorageStorage,
|
||||||
createMailStorage,
|
createMailStorage,
|
||||||
createInventoryStorage,
|
createInventoryStorage,
|
||||||
|
createFiggosStorage,
|
||||||
} from './factory';
|
} from './factory';
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ export const BUCKETS = {
|
||||||
STORAGE: 'storage-storage',
|
STORAGE: 'storage-storage',
|
||||||
MAIL: 'mail-storage',
|
MAIL: 'mail-storage',
|
||||||
INVENTORY: 'inventory-storage',
|
INVENTORY: 'inventory-storage',
|
||||||
|
FIGGOS: 'figgos-storage',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
|
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
|
||||||
|
|
|
||||||
643
pnpm-lock.yaml
generated
643
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -616,7 +616,9 @@ const APP_CONFIGS = [
|
||||||
S3_ACCESS_KEY: (env) => env.S3_ACCESS_KEY || 'minioadmin',
|
S3_ACCESS_KEY: (env) => env.S3_ACCESS_KEY || 'minioadmin',
|
||||||
S3_SECRET_KEY: (env) => env.S3_SECRET_KEY || 'minioadmin',
|
S3_SECRET_KEY: (env) => env.S3_SECRET_KEY || 'minioadmin',
|
||||||
S3_BUCKET: () => 'figgos-storage',
|
S3_BUCKET: () => 'figgos-storage',
|
||||||
CORS_ORIGINS: () => 'http://localhost:5181,http://localhost:8081',
|
CORS_ORIGINS: () => 'http://localhost:5196,http://localhost:8081',
|
||||||
|
GEMINI_API_KEY: (env) => env.GEMINI_API_KEY,
|
||||||
|
FIGGOS_STORAGE_PUBLIC_URL: () => 'http://localhost:9000/figgos-storage',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue