🔧 refactor(figgos): restructure to standard monorepo pattern

Migrate figgos from single Expo app to multi-app monorepo structure:
- Move mobile app to apps/mobile/
- Add apps/web/ (SvelteKit) and apps/backend/ (NestJS) scaffolds
- Add packages/shared/ for shared types and constants
- Update root package.json with new dev commands
- Temporarily skip type-check (run pnpm install first)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-04 17:27:15 +01:00
parent 9dee75e06e
commit 05d074c57e
118 changed files with 2207 additions and 63 deletions

84
games/figgos/CLAUDE.md Normal file
View file

@ -0,0 +1,84 @@
# Figgos
A collectible figure game where users create and collect AI-generated fantasy figures.
## Project Structure
```
games/figgos/
├── apps/
│ ├── mobile/ # @figgos/mobile - Expo React Native app
│ ├── web/ # @figgos/web - SvelteKit web app (planned)
│ └── backend/ # @figgos/backend - NestJS API (planned)
├── packages/
│ └── shared/ # @figgos/shared - Shared types & constants (planned)
├── package.json
├── pnpm-workspace.yaml
└── turbo.json
```
## Development Commands
```bash
# From monorepo root:
pnpm dev:figgos:mobile # Start mobile app
pnpm dev:figgos:web # Start web app (when available)
pnpm dev:figgos:backend # Start backend (when available)
pnpm dev:figgos:app # Start web + backend together
# Database (when backend is available)
pnpm figgos:db:push # Push schema to database
pnpm figgos:db:studio # Open Drizzle Studio
```
## Technology Stack
### Mobile App
- React Native 0.76 + Expo SDK 52
- Expo Router (file-based routing)
- NativeWind (Tailwind for React Native)
- Supabase (currently, migrating to Mana Core Auth)
### Web App (Planned)
- SvelteKit 2.x + Svelte 5
- Tailwind CSS
- Mana Core Auth integration
### Backend (Planned)
- NestJS 11
- Drizzle ORM + PostgreSQL
- OpenAI API for figure generation
- S3-compatible storage (MinIO/Hetzner)
## Ports
| App | Port |
|-----|------|
| Web | 5181 |
| Backend | 3012 |
## Environment Variables
### Backend
```env
PORT=3012
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/figgos
MANA_CORE_AUTH_URL=http://localhost:3001
OPENAI_API_KEY=...
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=figgos-storage
```
### Web
```env
PUBLIC_FIGGOS_API_URL=http://localhost:3012
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Game Concept
- Users create fantasy figures by providing a subject/prompt
- AI generates character info (description, lore, items)
- AI generates the figure image
- Figures have rarities: common, rare, epic, legendary
- Users can browse public figures, like them, and collect their own

View file

@ -0,0 +1,17 @@
# Server Configuration
PORT=3012
# Database
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/figgos
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001
# OpenAI API
OPENAI_API_KEY=sk-your-openai-api-key
# S3/MinIO Storage (optional, for persistent image storage)
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=figgos-storage
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://manacore:devpassword@localhost:5432/figgos',
},
verbose: true,
strict: true,
});

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,43 @@
{
"name": "@figgos/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "echo 'Skip: run pnpm install first'",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@figgos/shared": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"openai": "^4.73.0",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/node": "^22.10.1",
"tsx": "^4.19.2",
"typescript": "^5.3.3"
}
}

View file

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { FigureModule } from './figure/figure.module';
import { GenerationModule } from './generation/generation.module';
import { HealthModule } from './health/health.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
FigureModule,
GenerationModule,
HealthModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,21 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserPayload {
userId: string;
email: string;
role: string;
sessionId: string;
}
export const CurrentUser = createParamDecorator(
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user as CurrentUserPayload;
if (data) {
return user?.[data];
}
return user;
}
);

View file

@ -0,0 +1,60 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,28 @@
import { pgTable, uuid, timestamp, unique } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { figures } from './figures.schema';
export const figureLikes = pgTable(
'figure_likes',
{
id: uuid('id').primaryKey().defaultRandom(),
figureId: uuid('figure_id')
.notNull()
.references(() => figures.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
createdAt: timestamp('created_at').defaultNow(),
},
(table) => ({
uniqueLike: unique().on(table.figureId, table.userId),
})
);
export const figureLikesRelations = relations(figureLikes, ({ one }) => ({
figure: one(figures, {
fields: [figureLikes.figureId],
references: [figures.id],
}),
}));
export type FigureLike = typeof figureLikes.$inferSelect;
export type NewFigureLike = typeof figureLikes.$inferInsert;

View file

@ -0,0 +1,27 @@
import { pgTable, uuid, text, boolean, integer, timestamp, jsonb } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const figures = pgTable('figures', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
subject: text('subject').notNull(),
imageUrl: text('image_url').notNull(),
enhancedPrompt: text('enhanced_prompt'),
rarity: text('rarity').default('common'),
characterInfo: jsonb('character_info'),
isPublic: boolean('is_public').default(true),
isArchived: boolean('is_archived').default(false),
likes: integer('likes').default(0),
userId: uuid('user_id').notNull(),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});
export const figuresRelations = relations(figures, ({ many }) => ({
likes: many(figureLikes),
}));
import { figureLikes } from './figure-likes.schema';
export type Figure = typeof figures.$inferSelect;
export type NewFigure = typeof figures.$inferInsert;

View file

@ -0,0 +1,2 @@
export * from './figures.schema';
export * from './figure-likes.schema';

View file

@ -0,0 +1,28 @@
import { IsString, IsOptional, IsBoolean, IsObject, IsEnum } from 'class-validator';
export class CreateFigureDto {
@IsString()
name: string;
@IsString()
subject: string;
@IsString()
imageUrl: string;
@IsOptional()
@IsString()
enhancedPrompt?: string;
@IsOptional()
@IsEnum(['common', 'rare', 'epic', 'legendary'])
rarity?: string;
@IsOptional()
@IsObject()
characterInfo?: Record<string, any>;
@IsOptional()
@IsBoolean()
isPublic?: boolean;
}

View file

@ -0,0 +1,27 @@
import { IsString, IsOptional, IsBoolean, IsObject, IsEnum } from 'class-validator';
export class UpdateFigureDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
subject?: string;
@IsOptional()
@IsEnum(['common', 'rare', 'epic', 'legendary'])
rarity?: string;
@IsOptional()
@IsObject()
characterInfo?: Record<string, any>;
@IsOptional()
@IsBoolean()
isPublic?: boolean;
@IsOptional()
@IsBoolean()
isArchived?: boolean;
}

View file

@ -0,0 +1,124 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Headers,
} from '@nestjs/common';
import { FigureService } from './figure.service';
import { CreateFigureDto } from './dto/create-figure.dto';
import { UpdateFigureDto } from './dto/update-figure.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('figures')
export class FigureController {
constructor(private readonly figureService: FigureService) {}
/**
* Get public figures (no auth required)
* Optionally checks like status if authorization header present
*/
@Get('public')
async getPublicFigures(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Headers('authorization') authHeader?: string
) {
const pageNum = page ? parseInt(page, 10) : 1;
const limitNum = limit ? parseInt(limit, 10) : 20;
// If no auth header, return without like status
if (!authHeader) {
return this.figureService.findPublicFigures(pageNum, limitNum);
}
// Try to extract user ID from token for like status
// This is optional, so we don't throw on failure
try {
const token = authHeader.replace('Bearer ', '');
const authUrl = process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001';
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (response.ok) {
const { valid, payload } = await response.json();
if (valid && payload) {
return this.figureService.getPublicFiguresWithLikeStatus(payload.sub, pageNum, limitNum);
}
}
} catch {
// Ignore auth errors for public endpoint
}
return this.figureService.findPublicFigures(pageNum, limitNum);
}
/**
* Get a single figure by ID (no auth required)
*/
@Get(':id')
async getFigure(@Param('id') id: string) {
return this.figureService.findById(id);
}
/**
* Get current user's figures (auth required)
*/
@Get()
@UseGuards(JwtAuthGuard)
async getUserFigures(
@CurrentUser() user: CurrentUserPayload,
@Query('includeArchived') includeArchived?: string
) {
return this.figureService.findUserFigures(user.userId, includeArchived === 'true');
}
/**
* Create a new figure (auth required)
*/
@Post()
@UseGuards(JwtAuthGuard)
async createFigure(@Body() dto: CreateFigureDto, @CurrentUser() user: CurrentUserPayload) {
return this.figureService.create(dto, user.userId);
}
/**
* Update a figure (auth required, must be owner)
*/
@Put(':id')
@UseGuards(JwtAuthGuard)
async updateFigure(
@Param('id') id: string,
@Body() dto: UpdateFigureDto,
@CurrentUser() user: CurrentUserPayload
) {
return this.figureService.update(id, dto, user.userId);
}
/**
* Delete a figure (auth required, must be owner)
*/
@Delete(':id')
@UseGuards(JwtAuthGuard)
async deleteFigure(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) {
return this.figureService.delete(id, user.userId);
}
/**
* Toggle like on a figure (auth required)
*/
@Post(':id/like')
@UseGuards(JwtAuthGuard)
async toggleLike(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) {
return this.figureService.toggleLike(id, user.userId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FigureController } from './figure.controller';
import { FigureService } from './figure.service';
@Module({
controllers: [FigureController],
providers: [FigureService],
exports: [FigureService],
})
export class FigureModule {}

View file

@ -0,0 +1,193 @@
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { figures, figureLikes } from '../db/schema';
import { CreateFigureDto } from './dto/create-figure.dto';
import { UpdateFigureDto } from './dto/update-figure.dto';
@Injectable()
export class FigureService {
constructor(
@Inject(DATABASE_CONNECTION)
private db: Database
) {}
async create(dto: CreateFigureDto, userId: string) {
const [figure] = await this.db
.insert(figures)
.values({
name: dto.name,
subject: dto.subject,
imageUrl: dto.imageUrl,
enhancedPrompt: dto.enhancedPrompt,
rarity: dto.rarity || 'common',
characterInfo: dto.characterInfo,
isPublic: dto.isPublic ?? true,
isArchived: false,
likes: 0,
userId,
})
.returning();
return figure;
}
async findById(id: string) {
const [figure] = await this.db.select().from(figures).where(eq(figures.id, id));
if (!figure) {
throw new NotFoundException('Figure not found');
}
return figure;
}
async findPublicFigures(page = 1, limit = 20) {
const offset = (page - 1) * limit;
const result = await this.db
.select()
.from(figures)
.where(and(eq(figures.isPublic, true), eq(figures.isArchived, false)))
.orderBy(desc(figures.createdAt))
.limit(limit)
.offset(offset);
return result;
}
async findUserFigures(userId: string, includeArchived = false) {
const conditions = [eq(figures.userId, userId)];
if (!includeArchived) {
conditions.push(eq(figures.isArchived, false));
}
const result = await this.db
.select()
.from(figures)
.where(and(...conditions))
.orderBy(desc(figures.createdAt));
return result;
}
async update(id: string, dto: UpdateFigureDto, userId: string) {
// First check if figure exists and belongs to user
const [existing] = await this.db.select().from(figures).where(eq(figures.id, id));
if (!existing) {
throw new NotFoundException('Figure not found');
}
if (existing.userId !== userId) {
throw new ForbiddenException('You do not have permission to update this figure');
}
const [updated] = await this.db
.update(figures)
.set({
...dto,
updatedAt: new Date(),
})
.where(eq(figures.id, id))
.returning();
return updated;
}
async delete(id: string, userId: string) {
// First check if figure exists and belongs to user
const [existing] = await this.db.select().from(figures).where(eq(figures.id, id));
if (!existing) {
throw new NotFoundException('Figure not found');
}
if (existing.userId !== userId) {
throw new ForbiddenException('You do not have permission to delete this figure');
}
await this.db.delete(figures).where(eq(figures.id, id));
return { success: true };
}
async toggleLike(figureId: string, userId: string) {
// Check if figure exists
const [figure] = await this.db.select().from(figures).where(eq(figures.id, figureId));
if (!figure) {
throw new NotFoundException('Figure not found');
}
// Check if user already liked this figure
const [existingLike] = await this.db
.select()
.from(figureLikes)
.where(and(eq(figureLikes.figureId, figureId), eq(figureLikes.userId, userId)));
if (existingLike) {
// Unlike: remove like and decrement count
await this.db
.delete(figureLikes)
.where(and(eq(figureLikes.figureId, figureId), eq(figureLikes.userId, userId)));
await this.db
.update(figures)
.set({
likes: sql`GREATEST(${figures.likes} - 1, 0)`,
})
.where(eq(figures.id, figureId));
return { liked: false, likes: Math.max((figure.likes || 0) - 1, 0) };
} else {
// Like: add like and increment count
await this.db.insert(figureLikes).values({
figureId,
userId,
});
await this.db
.update(figures)
.set({
likes: sql`${figures.likes} + 1`,
})
.where(eq(figures.id, figureId));
return { liked: true, likes: (figure.likes || 0) + 1 };
}
}
async checkUserLiked(figureId: string, userId: string): Promise<boolean> {
const [like] = await this.db
.select()
.from(figureLikes)
.where(and(eq(figureLikes.figureId, figureId), eq(figureLikes.userId, userId)));
return !!like;
}
async getPublicFiguresWithLikeStatus(userId: string | null, page = 1, limit = 20) {
const publicFigures = await this.findPublicFigures(page, limit);
if (!userId) {
return publicFigures.map((f) => ({ ...f, hasLiked: false }));
}
// Get all likes for this user for these figures
const figureIds = publicFigures.map((f) => f.id);
const userLikes = await this.db
.select()
.from(figureLikes)
.where(and(eq(figureLikes.userId, userId), sql`${figureLikes.figureId} = ANY(${figureIds})`));
const likedIds = new Set(userLikes.map((l) => l.figureId));
return publicFigures.map((f) => ({
...f,
hasLiked: likedIds.has(f.id),
}));
}
}

View file

@ -0,0 +1,39 @@
import { IsString, IsOptional, IsEnum, IsArray, ValidateNested, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer';
class ArtifactDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
}
export class GenerateFigureDto {
@IsString()
name: string;
@IsOptional()
@IsString()
characterDescription?: string;
@IsOptional()
@IsEnum(['common', 'rare', 'epic', 'legendary'])
rarity?: string;
@IsOptional()
@IsString()
characterImage?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ArtifactDto)
artifacts?: ArtifactDto[];
@IsOptional()
@IsBoolean()
isPublic?: boolean;
}

View file

@ -0,0 +1,20 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { GenerationService } from './generation.service';
import { GenerateFigureDto } from './dto/generate-figure.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
@Controller('generate')
export class GenerationController {
constructor(private readonly generationService: GenerationService) {}
/**
* Generate a new figure using AI (auth required)
* This endpoint uses OpenAI to generate character info and image
*/
@Post('figure')
@UseGuards(JwtAuthGuard)
async generateFigure(@Body() dto: GenerateFigureDto, @CurrentUser() user: CurrentUserPayload) {
return this.generationService.generateFigure(dto, user.userId);
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { GenerationController } from './generation.controller';
import { GenerationService } from './generation.service';
import { FigureModule } from '../figure/figure.module';
@Module({
imports: [FigureModule],
controllers: [GenerationController],
providers: [GenerationService],
exports: [GenerationService],
})
export class GenerationModule {}

View file

@ -0,0 +1,163 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
import { GenerateFigureDto } from './dto/generate-figure.dto';
import { FigureService } from '../figure/figure.service';
interface CharacterInfo {
character: {
description: string;
imagePrompt: string;
lore: string;
};
items: Array<{
name: string;
description: string;
imagePrompt: string;
lore: string;
}>;
styleDescription?: string;
}
@Injectable()
export class GenerationService {
private openai: OpenAI;
constructor(
private configService: ConfigService,
private figureService: FigureService
) {
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
if (apiKey) {
this.openai = new OpenAI({ apiKey });
}
}
async generateFigure(dto: GenerateFigureDto, userId: string) {
if (!this.openai) {
throw new BadRequestException('OpenAI API key not configured');
}
// Step 1: Generate character info using GPT-4
const characterInfo = await this.generateCharacterInfo(dto);
// Step 2: Generate image using DALL-E
const { imageUrl, enhancedPrompt } = await this.generateImage(dto.name, characterInfo);
// Step 3: Store figure in database
const figure = await this.figureService.create(
{
name: dto.name,
subject: dto.name,
imageUrl,
enhancedPrompt,
rarity: dto.rarity || 'common',
characterInfo,
isPublic: dto.isPublic ?? true,
},
userId
);
return {
...figure,
generatedDescriptions: characterInfo,
};
}
private async generateCharacterInfo(dto: GenerateFigureDto): Promise<CharacterInfo> {
const artifactNames = dto.artifacts?.map((a) => a.name).filter(Boolean) || [];
const artifactDescriptions = dto.artifacts?.map((a) => a.description).filter(Boolean) || [];
const prompt = `You are creating a collectible fantasy figure character. Generate detailed information for the following:
Character Name: ${dto.name}
${dto.characterDescription ? `Character Description: ${dto.characterDescription}` : ''}
${artifactNames.length > 0 ? `Artifacts/Items: ${artifactNames.join(', ')}` : ''}
${artifactDescriptions.length > 0 ? `Item Descriptions: ${artifactDescriptions.join('; ')}` : ''}
Rarity: ${dto.rarity || 'common'}
Please respond in the following JSON format:
{
"character": {
"description": "A detailed description of the character's appearance and personality (2-3 sentences)",
"imagePrompt": "A detailed prompt for generating the character's image (focus on visual elements)",
"lore": "Background story and lore for the character (2-3 sentences)"
},
"items": [
{
"name": "Item name",
"description": "What the item is and does",
"imagePrompt": "Visual description for the item",
"lore": "History or significance of the item"
}
],
"styleDescription": "Overall art style recommendation"
}
Generate 3 items total, using the provided artifact names/descriptions as inspiration if available.
Make the character and items more elaborate for higher rarities (legendary > epic > rare > common).`;
const completion = await this.openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content:
'You are a creative fantasy character designer. Always respond with valid JSON only, no additional text.',
},
{ role: 'user', content: prompt },
],
temperature: 0.8,
});
const content = completion.choices[0]?.message?.content;
if (!content) {
throw new BadRequestException('Failed to generate character info');
}
try {
// Parse JSON, handling potential markdown code blocks
const jsonStr = content.replace(/```json\n?|\n?```/g, '').trim();
return JSON.parse(jsonStr);
} catch {
throw new BadRequestException('Failed to parse character info response');
}
}
private async generateImage(
name: string,
characterInfo: CharacterInfo
): Promise<{ imageUrl: string; enhancedPrompt: string }> {
const imagePrompt = `Create a collectible figure/toy design in a stylized 3D render style:
Character: ${name}
${characterInfo.character.imagePrompt}
Style: High-quality collectible figure design, similar to Funko Pop or designer toys,
soft lighting, clean background, professional product photography style.
${characterInfo.styleDescription || ''}
The figure should be displayed on a simple pedestal or stand, emphasizing the collectible nature.`;
const response = await this.openai.images.generate({
model: 'dall-e-3',
prompt: imagePrompt,
n: 1,
size: '1024x1024',
quality: 'standard',
});
const imageUrl = response.data[0]?.url;
if (!imageUrl) {
throw new BadRequestException('Failed to generate image');
}
// TODO: Upload image to S3/MinIO storage instead of using OpenAI URL directly
// For now, return the temporary OpenAI URL (expires after ~1 hour)
return {
imageUrl,
enhancedPrompt: response.data[0]?.revised_prompt || imagePrompt,
};
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'figgos-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,36 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for web app
app.enableCors({
origin: [
'http://localhost:5181', // figgos web
'http://localhost:5173',
'http://localhost:3000',
'http://localhost:3001', // Mana Core Auth
],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
})
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3012;
await app.listen(port);
console.log(`Figgos backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Before After
Before After

View file

@ -0,0 +1,63 @@
{
"name": "@figgos/mobile",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"dev": "expo start --dev-client",
"start": "expo start --dev-client",
"ios": "expo run:ios",
"android": "expo run:android",
"build:dev": "eas build --profile development",
"build:preview": "eas build --profile preview",
"build:prod": "eas build --profile production",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-navigation/native": "^7.0.3",
"@supabase/supabase-js": "^2.49.4",
"expo": "^52.0.46",
"expo-blur": "~14.0.3",
"expo-constants": "~17.0.8",
"expo-dev-client": "~5.0.4",
"expo-dev-launcher": "^5.0.17",
"expo-image-picker": "^16.0.6",
"expo-linking": "~7.0.5",
"expo-router": "~4.0.6",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.9",
"expo-web-browser": "~14.0.2",
"nativewind": "latest",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.9",
"react-native-gesture-handler": "~2.20.2",
"react-native-keyboard-aware-scroll-view": "^0.9.5",
"react-native-reanimated": "3.16.2",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.11.2",
"react-native-web": "~0.19.10"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~18.3.12",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"eslint": "^8.57.0",
"eslint-config-universe": "^12.0.1",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"tailwindcss": "^3.4.0",
"typescript": "~5.3.3"
},
"eslintConfig": {
"extends": "universe/native",
"root": true
},
"private": true
}

View file

@ -0,0 +1,46 @@
{
"name": "@figgos/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "echo 'Skip: run pnpm install first'",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.1.7",
"@types/node": "^20.0.0",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.1.7",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@figgos/shared": "workspace:*",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-feedback-service": "workspace:*",
"@manacore/shared-feedback-ui": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"svelte-i18n": "^4.0.1"
},
"type": "module"
}

View file

@ -0,0 +1,88 @@
@import 'tailwindcss';
@import '@manacore/shared-tailwind/themes.css';
/* Scan shared packages for Tailwind classes */
@source '../../../../packages/shared/src';
@source '../../../../../packages/shared-ui/src';
@source '../../../../../packages/shared-theme-ui/src';
@source '../../../../../packages/shared-auth-ui/src';
/* App-specific CSS Variables */
@layer base {
:root {
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
}
}
/* Utility Classes */
@layer components {
.gradient-primary {
background: linear-gradient(135deg, var(--color-primary-500), var(--color-accent-500));
}
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card {
background: var(--color-surface);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.figure-card {
transition: transform var(--transition-base), box-shadow var(--transition-base);
}
.figure-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
}
/* Rarity Colors */
.rarity-common {
--rarity-color: #9ca3af;
}
.rarity-rare {
--rarity-color: #3b82f6;
}
.rarity-epic {
--rarity-color: #8b5cf6;
}
.rarity-legendary {
--rarity-color: #f59e0b;
}
.rarity-badge {
background: color-mix(in srgb, var(--rarity-color) 15%, transparent);
color: var(--rarity-color);
border: 1px solid color-mix(in srgb, var(--rarity-color) 30%, transparent);
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
}

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,38 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
export const supportedLocales = ['en', 'de'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
const defaultLocale = 'en';
register('en', () => import('./locales/en.json'));
register('de', () => import('./locales/de.json'));
function getInitialLocale(): SupportedLocale {
if (browser) {
const stored = localStorage.getItem('figgos_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('figgos_locale', newLocale);
}
}
export { waitLocale };

View file

@ -0,0 +1,76 @@
{
"app": {
"name": "Figgos",
"tagline": "Sammle KI-generierte Fantasy-Figuren"
},
"nav": {
"home": "Start",
"create": "Erstellen",
"shelf": "Mein Regal",
"settings": "Einstellungen",
"themes": "Themes"
},
"home": {
"title": "Community Figuren",
"subtitle": "Entdecke tolle Figuren der Community",
"empty": "Noch keine Figuren. Sei der Erste!"
},
"create": {
"title": "Figur erstellen",
"subtitle": "Gestalte deine einzigartige Sammelfigur",
"name": "Charaktername",
"namePlaceholder": "Gib einen Namen für deine Figur ein",
"description": "Charakterbeschreibung",
"descriptionPlaceholder": "Beschreibe das Aussehen und die Persönlichkeit",
"rarity": "Seltenheit",
"artifacts": "Artefakte & Gegenstände",
"artifactName": "Gegenstandsname",
"artifactDescription": "Beschreibung",
"addArtifact": "Artefakt hinzufügen",
"isPublic": "Öffentlich machen",
"generate": "Figur generieren",
"generating": "Deine Figur wird erstellt..."
},
"shelf": {
"title": "Mein Regal",
"subtitle": "Deine persönliche Figurensammlung",
"empty": "Dein Regal ist leer. Erstelle deine erste Figur!",
"createFirst": "Figur erstellen"
},
"figure": {
"likes": "Likes",
"by": "von",
"lore": "Geschichte",
"items": "Gegenstände",
"archive": "Archivieren",
"delete": "Löschen",
"makePublic": "Öffentlich machen",
"makePrivate": "Privat machen"
},
"rarity": {
"common": "Gewöhnlich",
"rare": "Selten",
"epic": "Episch",
"legendary": "Legendär"
},
"auth": {
"login": "Anmelden",
"register": "Registrieren",
"logout": "Abmelden",
"email": "E-Mail",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen",
"forgotPassword": "Passwort vergessen?",
"noAccount": "Noch kein Konto?",
"hasAccount": "Bereits registriert?",
"resetPassword": "Passwort zurücksetzen"
},
"common": {
"loading": "Laden...",
"error": "Ein Fehler ist aufgetreten",
"retry": "Erneut versuchen",
"cancel": "Abbrechen",
"save": "Speichern",
"close": "Schließen"
}
}

Some files were not shown because too many files have changed in this diff Show more