diff --git a/games/figgos/CLAUDE.md b/games/figgos/CLAUDE.md new file mode 100644 index 000000000..7ce5a77c7 --- /dev/null +++ b/games/figgos/CLAUDE.md @@ -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 diff --git a/games/figgos/apps/backend/.env.example b/games/figgos/apps/backend/.env.example new file mode 100644 index 000000000..5aa02b1cc --- /dev/null +++ b/games/figgos/apps/backend/.env.example @@ -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 diff --git a/games/figgos/apps/backend/drizzle.config.ts b/games/figgos/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..6f1867d00 --- /dev/null +++ b/games/figgos/apps/backend/drizzle.config.ts @@ -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, +}); diff --git a/games/figgos/apps/backend/nest-cli.json b/games/figgos/apps/backend/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/games/figgos/apps/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/games/figgos/apps/backend/package.json b/games/figgos/apps/backend/package.json new file mode 100644 index 000000000..b3fc72309 --- /dev/null +++ b/games/figgos/apps/backend/package.json @@ -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" + } +} diff --git a/games/figgos/apps/backend/src/app.module.ts b/games/figgos/apps/backend/src/app.module.ts new file mode 100644 index 000000000..5e42608e1 --- /dev/null +++ b/games/figgos/apps/backend/src/app.module.ts @@ -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 {} diff --git a/games/figgos/apps/backend/src/common/decorators/current-user.decorator.ts b/games/figgos/apps/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 000000000..3fa4d4088 --- /dev/null +++ b/games/figgos/apps/backend/src/common/decorators/current-user.decorator.ts @@ -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; + } +); diff --git a/games/figgos/apps/backend/src/common/guards/jwt-auth.guard.ts b/games/figgos/apps/backend/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 000000000..1d68d7fce --- /dev/null +++ b/games/figgos/apps/backend/src/common/guards/jwt-auth.guard.ts @@ -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 { + 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('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; + } +} diff --git a/games/figgos/apps/backend/src/db/connection.ts b/games/figgos/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..fccc63f4a --- /dev/null +++ b/games/figgos/apps/backend/src/db/connection.ts @@ -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 | null = null; +let db: ReturnType | 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; diff --git a/games/figgos/apps/backend/src/db/database.module.ts b/games/figgos/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..b4d1f2af6 --- /dev/null +++ b/games/figgos/apps/backend/src/db/database.module.ts @@ -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('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(); + } +} diff --git a/games/figgos/apps/backend/src/db/schema/figure-likes.schema.ts b/games/figgos/apps/backend/src/db/schema/figure-likes.schema.ts new file mode 100644 index 000000000..17d8145d3 --- /dev/null +++ b/games/figgos/apps/backend/src/db/schema/figure-likes.schema.ts @@ -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; diff --git a/games/figgos/apps/backend/src/db/schema/figures.schema.ts b/games/figgos/apps/backend/src/db/schema/figures.schema.ts new file mode 100644 index 000000000..cb4306d54 --- /dev/null +++ b/games/figgos/apps/backend/src/db/schema/figures.schema.ts @@ -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; diff --git a/games/figgos/apps/backend/src/db/schema/index.ts b/games/figgos/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..c7d0d5ef5 --- /dev/null +++ b/games/figgos/apps/backend/src/db/schema/index.ts @@ -0,0 +1,2 @@ +export * from './figures.schema'; +export * from './figure-likes.schema'; diff --git a/games/figgos/apps/backend/src/figure/dto/create-figure.dto.ts b/games/figgos/apps/backend/src/figure/dto/create-figure.dto.ts new file mode 100644 index 000000000..f4e37b5da --- /dev/null +++ b/games/figgos/apps/backend/src/figure/dto/create-figure.dto.ts @@ -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; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; +} diff --git a/games/figgos/apps/backend/src/figure/dto/update-figure.dto.ts b/games/figgos/apps/backend/src/figure/dto/update-figure.dto.ts new file mode 100644 index 000000000..f148c3892 --- /dev/null +++ b/games/figgos/apps/backend/src/figure/dto/update-figure.dto.ts @@ -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; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsBoolean() + isArchived?: boolean; +} diff --git a/games/figgos/apps/backend/src/figure/figure.controller.ts b/games/figgos/apps/backend/src/figure/figure.controller.ts new file mode 100644 index 000000000..f0016a42a --- /dev/null +++ b/games/figgos/apps/backend/src/figure/figure.controller.ts @@ -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); + } +} diff --git a/games/figgos/apps/backend/src/figure/figure.module.ts b/games/figgos/apps/backend/src/figure/figure.module.ts new file mode 100644 index 000000000..709234e72 --- /dev/null +++ b/games/figgos/apps/backend/src/figure/figure.module.ts @@ -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 {} diff --git a/games/figgos/apps/backend/src/figure/figure.service.ts b/games/figgos/apps/backend/src/figure/figure.service.ts new file mode 100644 index 000000000..81f12cf00 --- /dev/null +++ b/games/figgos/apps/backend/src/figure/figure.service.ts @@ -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 { + 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), + })); + } +} diff --git a/games/figgos/apps/backend/src/generation/dto/generate-figure.dto.ts b/games/figgos/apps/backend/src/generation/dto/generate-figure.dto.ts new file mode 100644 index 000000000..2b7ec2970 --- /dev/null +++ b/games/figgos/apps/backend/src/generation/dto/generate-figure.dto.ts @@ -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; +} diff --git a/games/figgos/apps/backend/src/generation/generation.controller.ts b/games/figgos/apps/backend/src/generation/generation.controller.ts new file mode 100644 index 000000000..f5c1bc150 --- /dev/null +++ b/games/figgos/apps/backend/src/generation/generation.controller.ts @@ -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); + } +} diff --git a/games/figgos/apps/backend/src/generation/generation.module.ts b/games/figgos/apps/backend/src/generation/generation.module.ts new file mode 100644 index 000000000..b6c52725f --- /dev/null +++ b/games/figgos/apps/backend/src/generation/generation.module.ts @@ -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 {} diff --git a/games/figgos/apps/backend/src/generation/generation.service.ts b/games/figgos/apps/backend/src/generation/generation.service.ts new file mode 100644 index 000000000..06a4658c2 --- /dev/null +++ b/games/figgos/apps/backend/src/generation/generation.service.ts @@ -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('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 { + 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, + }; + } +} diff --git a/games/figgos/apps/backend/src/health/health.controller.ts b/games/figgos/apps/backend/src/health/health.controller.ts new file mode 100644 index 000000000..16fb3f95e --- /dev/null +++ b/games/figgos/apps/backend/src/health/health.controller.ts @@ -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', + }; + } +} diff --git a/games/figgos/apps/backend/src/health/health.module.ts b/games/figgos/apps/backend/src/health/health.module.ts new file mode 100644 index 000000000..a61d8b044 --- /dev/null +++ b/games/figgos/apps/backend/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/games/figgos/apps/backend/src/main.ts b/games/figgos/apps/backend/src/main.ts new file mode 100644 index 000000000..d9fb8ed9d --- /dev/null +++ b/games/figgos/apps/backend/src/main.ts @@ -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(); diff --git a/games/figgos/apps/backend/tsconfig.json b/games/figgos/apps/backend/tsconfig.json new file mode 100644 index 000000000..d40dc9c9f --- /dev/null +++ b/games/figgos/apps/backend/tsconfig.json @@ -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"] +} diff --git a/games/figgos/.gitignore b/games/figgos/apps/mobile/.gitignore similarity index 100% rename from games/figgos/.gitignore rename to games/figgos/apps/mobile/.gitignore diff --git a/games/figgos/Readmes/BackendArchitecture.md b/games/figgos/apps/mobile/Readmes/BackendArchitecture.md similarity index 100% rename from games/figgos/Readmes/BackendArchitecture.md rename to games/figgos/apps/mobile/Readmes/BackendArchitecture.md diff --git a/games/figgos/Readmes/CreatePageIntegration.md b/games/figgos/apps/mobile/Readmes/CreatePageIntegration.md similarity index 100% rename from games/figgos/Readmes/CreatePageIntegration.md rename to games/figgos/apps/mobile/Readmes/CreatePageIntegration.md diff --git a/games/figgos/Readmes/GPTImageAPI.md b/games/figgos/apps/mobile/Readmes/GPTImageAPI.md similarity index 100% rename from games/figgos/Readmes/GPTImageAPI.md rename to games/figgos/apps/mobile/Readmes/GPTImageAPI.md diff --git a/games/figgos/Readmes/SupabaseMCPConnect.md b/games/figgos/apps/mobile/Readmes/SupabaseMCPConnect.md similarity index 100% rename from games/figgos/Readmes/SupabaseMCPConnect.md rename to games/figgos/apps/mobile/Readmes/SupabaseMCPConnect.md diff --git a/games/figgos/app-env.d.ts b/games/figgos/apps/mobile/app-env.d.ts similarity index 100% rename from games/figgos/app-env.d.ts rename to games/figgos/apps/mobile/app-env.d.ts diff --git a/games/figgos/app.json b/games/figgos/apps/mobile/app.json similarity index 100% rename from games/figgos/app.json rename to games/figgos/apps/mobile/app.json diff --git a/games/figgos/app/(auth)/_layout.tsx b/games/figgos/apps/mobile/app/(auth)/_layout.tsx similarity index 100% rename from games/figgos/app/(auth)/_layout.tsx rename to games/figgos/apps/mobile/app/(auth)/_layout.tsx diff --git a/games/figgos/app/(auth)/login.tsx b/games/figgos/apps/mobile/app/(auth)/login.tsx similarity index 100% rename from games/figgos/app/(auth)/login.tsx rename to games/figgos/apps/mobile/app/(auth)/login.tsx diff --git a/games/figgos/app/(auth)/register.tsx b/games/figgos/apps/mobile/app/(auth)/register.tsx similarity index 100% rename from games/figgos/app/(auth)/register.tsx rename to games/figgos/apps/mobile/app/(auth)/register.tsx diff --git a/games/figgos/app/(tabs)/_layout.tsx b/games/figgos/apps/mobile/app/(tabs)/_layout.tsx similarity index 100% rename from games/figgos/app/(tabs)/_layout.tsx rename to games/figgos/apps/mobile/app/(tabs)/_layout.tsx diff --git a/games/figgos/app/(tabs)/create.tsx b/games/figgos/apps/mobile/app/(tabs)/create.tsx similarity index 100% rename from games/figgos/app/(tabs)/create.tsx rename to games/figgos/apps/mobile/app/(tabs)/create.tsx diff --git a/games/figgos/app/(tabs)/index.tsx b/games/figgos/apps/mobile/app/(tabs)/index.tsx similarity index 100% rename from games/figgos/app/(tabs)/index.tsx rename to games/figgos/apps/mobile/app/(tabs)/index.tsx diff --git a/games/figgos/app/(tabs)/shelf.tsx b/games/figgos/apps/mobile/app/(tabs)/shelf.tsx similarity index 100% rename from games/figgos/app/(tabs)/shelf.tsx rename to games/figgos/apps/mobile/app/(tabs)/shelf.tsx diff --git a/games/figgos/app/+html.tsx b/games/figgos/apps/mobile/app/+html.tsx similarity index 100% rename from games/figgos/app/+html.tsx rename to games/figgos/apps/mobile/app/+html.tsx diff --git a/games/figgos/app/+not-found.tsx b/games/figgos/apps/mobile/app/+not-found.tsx similarity index 100% rename from games/figgos/app/+not-found.tsx rename to games/figgos/apps/mobile/app/+not-found.tsx diff --git a/games/figgos/app/_layout.tsx b/games/figgos/apps/mobile/app/_layout.tsx similarity index 100% rename from games/figgos/app/_layout.tsx rename to games/figgos/apps/mobile/app/_layout.tsx diff --git a/games/figgos/app/modal.tsx b/games/figgos/apps/mobile/app/modal.tsx similarity index 100% rename from games/figgos/app/modal.tsx rename to games/figgos/apps/mobile/app/modal.tsx diff --git a/games/figgos/app/settings.tsx b/games/figgos/apps/mobile/app/settings.tsx similarity index 100% rename from games/figgos/app/settings.tsx rename to games/figgos/apps/mobile/app/settings.tsx diff --git a/games/figgos/app/subscription.tsx b/games/figgos/apps/mobile/app/subscription.tsx similarity index 100% rename from games/figgos/app/subscription.tsx rename to games/figgos/apps/mobile/app/subscription.tsx diff --git a/games/figgos/assets/actionfigures/YourCharacter.png b/games/figgos/apps/mobile/assets/actionfigures/YourCharacter.png similarity index 100% rename from games/figgos/assets/actionfigures/YourCharacter.png rename to games/figgos/apps/mobile/assets/actionfigures/YourCharacter.png diff --git a/games/figgos/assets/adaptive-icon.png b/games/figgos/apps/mobile/assets/adaptive-icon.png similarity index 100% rename from games/figgos/assets/adaptive-icon.png rename to games/figgos/apps/mobile/assets/adaptive-icon.png diff --git a/games/figgos/assets/favicon.png b/games/figgos/apps/mobile/assets/favicon.png similarity index 100% rename from games/figgos/assets/favicon.png rename to games/figgos/apps/mobile/assets/favicon.png diff --git a/games/figgos/assets/icon.png b/games/figgos/apps/mobile/assets/icon.png similarity index 100% rename from games/figgos/assets/icon.png rename to games/figgos/apps/mobile/assets/icon.png diff --git a/games/figgos/assets/icons/MoreVerticalIcon.tsx b/games/figgos/apps/mobile/assets/icons/MoreVerticalIcon.tsx similarity index 100% rename from games/figgos/assets/icons/MoreVerticalIcon.tsx rename to games/figgos/apps/mobile/assets/icons/MoreVerticalIcon.tsx diff --git a/games/figgos/assets/icons/index.ts b/games/figgos/apps/mobile/assets/icons/index.ts similarity index 100% rename from games/figgos/assets/icons/index.ts rename to games/figgos/apps/mobile/assets/icons/index.ts diff --git a/games/figgos/assets/splash.png b/games/figgos/apps/mobile/assets/splash.png similarity index 100% rename from games/figgos/assets/splash.png rename to games/figgos/apps/mobile/assets/splash.png diff --git a/games/figgos/assets/videos/WTFigure-9x16.mov b/games/figgos/apps/mobile/assets/videos/WTFigure-9x16.mov similarity index 100% rename from games/figgos/assets/videos/WTFigure-9x16.mov rename to games/figgos/apps/mobile/assets/videos/WTFigure-9x16.mov diff --git a/games/figgos/babel.config.js b/games/figgos/apps/mobile/babel.config.js similarity index 100% rename from games/figgos/babel.config.js rename to games/figgos/apps/mobile/babel.config.js diff --git a/games/figgos/cesconfig.json b/games/figgos/apps/mobile/cesconfig.json similarity index 100% rename from games/figgos/cesconfig.json rename to games/figgos/apps/mobile/cesconfig.json diff --git a/games/figgos/components/Button.tsx b/games/figgos/apps/mobile/components/Button.tsx similarity index 100% rename from games/figgos/components/Button.tsx rename to games/figgos/apps/mobile/components/Button.tsx diff --git a/games/figgos/components/CreateFigureForm.tsx b/games/figgos/apps/mobile/components/CreateFigureForm.tsx similarity index 100% rename from games/figgos/components/CreateFigureForm.tsx rename to games/figgos/apps/mobile/components/CreateFigureForm.tsx diff --git a/games/figgos/components/ErrorMessage.tsx b/games/figgos/apps/mobile/components/ErrorMessage.tsx similarity index 100% rename from games/figgos/components/ErrorMessage.tsx rename to games/figgos/apps/mobile/components/ErrorMessage.tsx diff --git a/games/figgos/components/FigureCard.tsx b/games/figgos/apps/mobile/components/FigureCard.tsx similarity index 100% rename from games/figgos/components/FigureCard.tsx rename to games/figgos/apps/mobile/components/FigureCard.tsx diff --git a/games/figgos/components/FigureCardInfo.tsx b/games/figgos/apps/mobile/components/FigureCardInfo.tsx similarity index 100% rename from games/figgos/components/FigureCardInfo.tsx rename to games/figgos/apps/mobile/components/FigureCardInfo.tsx diff --git a/games/figgos/components/FigureInfoModal.tsx b/games/figgos/apps/mobile/components/FigureInfoModal.tsx similarity index 100% rename from games/figgos/components/FigureInfoModal.tsx rename to games/figgos/apps/mobile/components/FigureInfoModal.tsx diff --git a/games/figgos/components/Header.tsx b/games/figgos/apps/mobile/components/Header.tsx similarity index 100% rename from games/figgos/components/Header.tsx rename to games/figgos/apps/mobile/components/Header.tsx diff --git a/games/figgos/components/ScreenContent.tsx b/games/figgos/apps/mobile/components/ScreenContent.tsx similarity index 100% rename from games/figgos/components/ScreenContent.tsx rename to games/figgos/apps/mobile/components/ScreenContent.tsx diff --git a/games/figgos/components/TabBarIcon.tsx b/games/figgos/apps/mobile/components/TabBarIcon.tsx similarity index 100% rename from games/figgos/components/TabBarIcon.tsx rename to games/figgos/apps/mobile/components/TabBarIcon.tsx diff --git a/games/figgos/components/ThemedView.tsx b/games/figgos/apps/mobile/components/ThemedView.tsx similarity index 100% rename from games/figgos/components/ThemedView.tsx rename to games/figgos/apps/mobile/components/ThemedView.tsx diff --git a/games/figgos/components/subscription/BillingToggle.tsx b/games/figgos/apps/mobile/components/subscription/BillingToggle.tsx similarity index 100% rename from games/figgos/components/subscription/BillingToggle.tsx rename to games/figgos/apps/mobile/components/subscription/BillingToggle.tsx diff --git a/games/figgos/components/subscription/CostCard.tsx b/games/figgos/apps/mobile/components/subscription/CostCard.tsx similarity index 100% rename from games/figgos/components/subscription/CostCard.tsx rename to games/figgos/apps/mobile/components/subscription/CostCard.tsx diff --git a/games/figgos/components/subscription/PackageCard.tsx b/games/figgos/apps/mobile/components/subscription/PackageCard.tsx similarity index 100% rename from games/figgos/components/subscription/PackageCard.tsx rename to games/figgos/apps/mobile/components/subscription/PackageCard.tsx diff --git a/games/figgos/components/subscription/SubscriptionButton.tsx b/games/figgos/apps/mobile/components/subscription/SubscriptionButton.tsx similarity index 100% rename from games/figgos/components/subscription/SubscriptionButton.tsx rename to games/figgos/apps/mobile/components/subscription/SubscriptionButton.tsx diff --git a/games/figgos/components/subscription/SubscriptionCard.tsx b/games/figgos/apps/mobile/components/subscription/SubscriptionCard.tsx similarity index 100% rename from games/figgos/components/subscription/SubscriptionCard.tsx rename to games/figgos/apps/mobile/components/subscription/SubscriptionCard.tsx diff --git a/games/figgos/components/subscription/SubscriptionPage.tsx b/games/figgos/apps/mobile/components/subscription/SubscriptionPage.tsx similarity index 100% rename from games/figgos/components/subscription/SubscriptionPage.tsx rename to games/figgos/apps/mobile/components/subscription/SubscriptionPage.tsx diff --git a/games/figgos/components/subscription/UsageCard.tsx b/games/figgos/apps/mobile/components/subscription/UsageCard.tsx similarity index 100% rename from games/figgos/components/subscription/UsageCard.tsx rename to games/figgos/apps/mobile/components/subscription/UsageCard.tsx diff --git a/games/figgos/components/subscription/appCosts.json b/games/figgos/apps/mobile/components/subscription/appCosts.json similarity index 100% rename from games/figgos/components/subscription/appCosts.json rename to games/figgos/apps/mobile/components/subscription/appCosts.json diff --git a/games/figgos/components/subscription/subscriptionData.json b/games/figgos/apps/mobile/components/subscription/subscriptionData.json similarity index 100% rename from games/figgos/components/subscription/subscriptionData.json rename to games/figgos/apps/mobile/components/subscription/subscriptionData.json diff --git a/games/figgos/components/subscription/usageData.json b/games/figgos/apps/mobile/components/subscription/usageData.json similarity index 100% rename from games/figgos/components/subscription/usageData.json rename to games/figgos/apps/mobile/components/subscription/usageData.json diff --git a/games/figgos/eas.json b/games/figgos/apps/mobile/eas.json similarity index 100% rename from games/figgos/eas.json rename to games/figgos/apps/mobile/eas.json diff --git a/games/figgos/global.css b/games/figgos/apps/mobile/global.css similarity index 100% rename from games/figgos/global.css rename to games/figgos/apps/mobile/global.css diff --git a/games/figgos/metro.config.js b/games/figgos/apps/mobile/metro.config.js similarity index 100% rename from games/figgos/metro.config.js rename to games/figgos/apps/mobile/metro.config.js diff --git a/games/figgos/nativewind-env.d.ts b/games/figgos/apps/mobile/nativewind-env.d.ts similarity index 100% rename from games/figgos/nativewind-env.d.ts rename to games/figgos/apps/mobile/nativewind-env.d.ts diff --git a/games/figgos/apps/mobile/package.json b/games/figgos/apps/mobile/package.json new file mode 100644 index 000000000..8040b8615 --- /dev/null +++ b/games/figgos/apps/mobile/package.json @@ -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 +} diff --git a/games/figgos/prettier.config.js b/games/figgos/apps/mobile/prettier.config.js similarity index 100% rename from games/figgos/prettier.config.js rename to games/figgos/apps/mobile/prettier.config.js diff --git a/games/figgos/supabase/.temp/cli-latest b/games/figgos/apps/mobile/supabase/.temp/cli-latest similarity index 100% rename from games/figgos/supabase/.temp/cli-latest rename to games/figgos/apps/mobile/supabase/.temp/cli-latest diff --git a/games/figgos/supabase/functions/_shared/cors.ts b/games/figgos/apps/mobile/supabase/functions/_shared/cors.ts similarity index 100% rename from games/figgos/supabase/functions/_shared/cors.ts rename to games/figgos/apps/mobile/supabase/functions/_shared/cors.ts diff --git a/games/figgos/supabase/functions/figure-generator/index.ts b/games/figgos/apps/mobile/supabase/functions/figure-generator/index.ts similarity index 100% rename from games/figgos/supabase/functions/figure-generator/index.ts rename to games/figgos/apps/mobile/supabase/functions/figure-generator/index.ts diff --git a/games/figgos/supabase/functions/wtfigure-generator/index.ts b/games/figgos/apps/mobile/supabase/functions/wtfigure-generator/index.ts similarity index 100% rename from games/figgos/supabase/functions/wtfigure-generator/index.ts rename to games/figgos/apps/mobile/supabase/functions/wtfigure-generator/index.ts diff --git a/games/figgos/tailwind.config.js b/games/figgos/apps/mobile/tailwind.config.js similarity index 100% rename from games/figgos/tailwind.config.js rename to games/figgos/apps/mobile/tailwind.config.js diff --git a/games/figgos/tsconfig.json b/games/figgos/apps/mobile/tsconfig.json similarity index 100% rename from games/figgos/tsconfig.json rename to games/figgos/apps/mobile/tsconfig.json diff --git a/games/figgos/utils/AuthContext.tsx b/games/figgos/apps/mobile/utils/AuthContext.tsx similarity index 100% rename from games/figgos/utils/AuthContext.tsx rename to games/figgos/apps/mobile/utils/AuthContext.tsx diff --git a/games/figgos/utils/ErrorHandler.tsx b/games/figgos/apps/mobile/utils/ErrorHandler.tsx similarity index 100% rename from games/figgos/utils/ErrorHandler.tsx rename to games/figgos/apps/mobile/utils/ErrorHandler.tsx diff --git a/games/figgos/utils/ThemeContext.tsx b/games/figgos/apps/mobile/utils/ThemeContext.tsx similarity index 100% rename from games/figgos/utils/ThemeContext.tsx rename to games/figgos/apps/mobile/utils/ThemeContext.tsx diff --git a/games/figgos/utils/config.ts b/games/figgos/apps/mobile/utils/config.ts similarity index 100% rename from games/figgos/utils/config.ts rename to games/figgos/apps/mobile/utils/config.ts diff --git a/games/figgos/utils/figureService.ts b/games/figgos/apps/mobile/utils/figureService.ts similarity index 100% rename from games/figgos/utils/figureService.ts rename to games/figgos/apps/mobile/utils/figureService.ts diff --git a/games/figgos/utils/supabase.ts b/games/figgos/apps/mobile/utils/supabase.ts similarity index 100% rename from games/figgos/utils/supabase.ts rename to games/figgos/apps/mobile/utils/supabase.ts diff --git a/games/figgos/utils/themes.ts b/games/figgos/apps/mobile/utils/themes.ts similarity index 100% rename from games/figgos/utils/themes.ts rename to games/figgos/apps/mobile/utils/themes.ts diff --git a/games/figgos/apps/web/package.json b/games/figgos/apps/web/package.json new file mode 100644 index 000000000..08c3132ae --- /dev/null +++ b/games/figgos/apps/web/package.json @@ -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" +} diff --git a/games/figgos/apps/web/src/app.css b/games/figgos/apps/web/src/app.css new file mode 100644 index 000000000..cf25b0bf4 --- /dev/null +++ b/games/figgos/apps/web/src/app.css @@ -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; + } +} diff --git a/games/figgos/apps/web/src/app.html b/games/figgos/apps/web/src/app.html new file mode 100644 index 000000000..77a5ff52c --- /dev/null +++ b/games/figgos/apps/web/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/games/figgos/apps/web/src/lib/i18n/index.ts b/games/figgos/apps/web/src/lib/i18n/index.ts new file mode 100644 index 000000000..9a94f0f5d --- /dev/null +++ b/games/figgos/apps/web/src/lib/i18n/index.ts @@ -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 }; diff --git a/games/figgos/apps/web/src/lib/i18n/locales/de.json b/games/figgos/apps/web/src/lib/i18n/locales/de.json new file mode 100644 index 000000000..a007a6998 --- /dev/null +++ b/games/figgos/apps/web/src/lib/i18n/locales/de.json @@ -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" + } +} diff --git a/games/figgos/apps/web/src/lib/i18n/locales/en.json b/games/figgos/apps/web/src/lib/i18n/locales/en.json new file mode 100644 index 000000000..466e37f24 --- /dev/null +++ b/games/figgos/apps/web/src/lib/i18n/locales/en.json @@ -0,0 +1,76 @@ +{ + "app": { + "name": "Figgos", + "tagline": "Collect AI-Generated Fantasy Figures" + }, + "nav": { + "home": "Home", + "create": "Create", + "shelf": "My Shelf", + "settings": "Settings", + "themes": "Themes" + }, + "home": { + "title": "Community Figures", + "subtitle": "Discover amazing figures created by the community", + "empty": "No figures yet. Be the first to create one!" + }, + "create": { + "title": "Create a Figure", + "subtitle": "Design your unique collectible figure", + "name": "Character Name", + "namePlaceholder": "Enter a name for your figure", + "description": "Character Description", + "descriptionPlaceholder": "Describe your character's appearance and personality", + "rarity": "Rarity", + "artifacts": "Artifacts & Items", + "artifactName": "Item Name", + "artifactDescription": "Item Description", + "addArtifact": "Add Artifact", + "isPublic": "Make Public", + "generate": "Generate Figure", + "generating": "Generating your figure..." + }, + "shelf": { + "title": "My Shelf", + "subtitle": "Your personal figure collection", + "empty": "Your shelf is empty. Create your first figure!", + "createFirst": "Create Figure" + }, + "figure": { + "likes": "likes", + "by": "by", + "lore": "Lore", + "items": "Items", + "archive": "Archive", + "delete": "Delete", + "makePublic": "Make Public", + "makePrivate": "Make Private" + }, + "rarity": { + "common": "Common", + "rare": "Rare", + "epic": "Epic", + "legendary": "Legendary" + }, + "auth": { + "login": "Log In", + "register": "Sign Up", + "logout": "Log Out", + "email": "Email", + "password": "Password", + "confirmPassword": "Confirm Password", + "forgotPassword": "Forgot Password?", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "resetPassword": "Reset Password" + }, + "common": { + "loading": "Loading...", + "error": "An error occurred", + "retry": "Retry", + "cancel": "Cancel", + "save": "Save", + "close": "Close" + } +} diff --git a/games/figgos/apps/web/src/lib/stores/auth.svelte.ts b/games/figgos/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..6aef195fc --- /dev/null +++ b/games/figgos/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,143 @@ +import { browser } from '$app/environment'; +import { initializeWebAuth, type UserData } from '@manacore/shared-auth'; + +const MANA_AUTH_URL = 'http://localhost:3001'; + +let _authService: ReturnType['authService'] | null = null; +let _tokenManager: ReturnType['tokenManager'] | null = null; + +function getAuthService() { + if (!browser) return null; + if (!_authService) { + const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL }); + _authService = auth.authService; + _tokenManager = auth.tokenManager; + } + return _authService; +} + +function getTokenManager() { + if (!browser) return null; + if (!_tokenManager) { + getAuthService(); + } + return _tokenManager; +} + +let user = $state(null); +let loading = $state(true); +let initialized = $state(false); + +export const authStore = { + get user() { + return user; + }, + get loading() { + return loading; + }, + get isAuthenticated() { + return !!user; + }, + get initialized() { + return initialized; + }, + + async initialize() { + if (!browser || initialized) return; + + loading = true; + try { + const authService = getAuthService(); + if (!authService) return; + + const userData = await authService.getCurrentUser(); + user = userData; + } catch (error) { + console.error('Failed to initialize auth:', error); + user = null; + } finally { + loading = false; + initialized = true; + } + }, + + async signIn(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth service not available' }; + } + + try { + const result = await authService.signIn(email, password); + if (result.success && result.user) { + user = result.user; + return { success: true }; + } + return { success: false, error: result.error || 'Sign in failed' }; + } catch (error) { + console.error('Sign in error:', error); + return { success: false, error: 'An unexpected error occurred' }; + } + }, + + async signUp(email: string, password: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth service not available' }; + } + + try { + const result = await authService.signUp(email, password); + if (result.success && result.user) { + user = result.user; + return { success: true }; + } + return { success: false, error: result.error || 'Sign up failed' }; + } catch (error) { + console.error('Sign up error:', error); + return { success: false, error: 'An unexpected error occurred' }; + } + }, + + async signOut() { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth service not available' }; + } + + try { + await authService.signOut(); + user = null; + return { success: true }; + } catch (error) { + console.error('Sign out error:', error); + return { success: false, error: 'Sign out failed' }; + } + }, + + async resetPassword(email: string) { + const authService = getAuthService(); + if (!authService) { + return { success: false, error: 'Auth service not available' }; + } + + try { + const result = await authService.resetPassword(email); + return result; + } catch (error) { + console.error('Reset password error:', error); + return { success: false, error: 'Password reset failed' }; + } + }, + + async getAccessToken(): Promise { + const tokenManager = getTokenManager(); + if (!tokenManager) return null; + + try { + return await tokenManager.getAccessToken(); + } catch { + return null; + } + }, +}; diff --git a/games/figgos/apps/web/src/lib/stores/figures.svelte.ts b/games/figgos/apps/web/src/lib/stores/figures.svelte.ts new file mode 100644 index 000000000..eb49c4d1d --- /dev/null +++ b/games/figgos/apps/web/src/lib/stores/figures.svelte.ts @@ -0,0 +1,234 @@ +import { browser } from '$app/environment'; +import type { Figure, PublicFigure, UserFigure, CreateFigureInput } from '@figgos/shared'; +import { authStore } from './auth.svelte'; + +const API_URL = 'http://localhost:3012'; + +async function apiRequest( + endpoint: string, + options: RequestInit = {} +): Promise<{ data?: T; error?: string }> { + try { + const token = await authStore.getAccessToken(); + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_URL}/api${endpoint}`, { + ...options, + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { error: errorData.message || `Request failed: ${response.status}` }; + } + + const data = await response.json(); + return { data }; + } catch (error) { + console.error('API request error:', error); + return { error: 'Network error' }; + } +} + +// Public figures state +let publicFigures = $state([]); +let publicLoading = $state(false); +let publicError = $state(null); + +// User figures state +let userFigures = $state([]); +let userLoading = $state(false); +let userError = $state(null); + +// Generation state +let generating = $state(false); +let generationError = $state(null); + +export const figureStore = { + // Public figures + get publicFigures() { + return publicFigures; + }, + get publicLoading() { + return publicLoading; + }, + get publicError() { + return publicError; + }, + + // User figures + get userFigures() { + return userFigures; + }, + get userLoading() { + return userLoading; + }, + get userError() { + return userError; + }, + + // Generation + get generating() { + return generating; + }, + get generationError() { + return generationError; + }, + + async loadPublicFigures(page = 1, limit = 20) { + if (!browser) return; + + publicLoading = true; + publicError = null; + + const { data, error } = await apiRequest( + `/figures/public?page=${page}&limit=${limit}` + ); + + if (error) { + publicError = error; + } else if (data) { + publicFigures = data; + } + + publicLoading = false; + }, + + async loadUserFigures(includeArchived = false) { + if (!browser) return; + if (!authStore.isAuthenticated) { + userError = 'Not authenticated'; + return; + } + + userLoading = true; + userError = null; + + const { data, error } = await apiRequest( + `/figures?includeArchived=${includeArchived}` + ); + + if (error) { + userError = error; + } else if (data) { + userFigures = data; + } + + userLoading = false; + }, + + async generateFigure(input: CreateFigureInput) { + if (!browser) return null; + if (!authStore.isAuthenticated) { + generationError = 'Not authenticated'; + return null; + } + + generating = true; + generationError = null; + + const { data, error } = await apiRequest
('/generate/figure', { + method: 'POST', + body: JSON.stringify(input), + }); + + if (error) { + generationError = error; + generating = false; + return null; + } + + // Add to user figures + if (data) { + userFigures = [data as unknown as UserFigure, ...userFigures]; + // Also add to public if public + if (data.isPublic) { + publicFigures = [data as unknown as PublicFigure, ...publicFigures]; + } + } + + generating = false; + return data; + }, + + async toggleLike(figureId: string) { + if (!browser) return null; + if (!authStore.isAuthenticated) return null; + + const { data, error } = await apiRequest<{ liked: boolean; likes: number }>( + `/figures/${figureId}/like`, + { method: 'POST' } + ); + + if (error) { + console.error('Toggle like error:', error); + return null; + } + + // Update in public figures + if (data) { + publicFigures = publicFigures.map((f) => + f.id === figureId ? { ...f, likes: data.likes, hasLiked: data.liked } : f + ); + } + + return data; + }, + + async updateFigure(figureId: string, updates: Partial
) { + if (!browser) return null; + if (!authStore.isAuthenticated) return null; + + const { data, error } = await apiRequest
(`/figures/${figureId}`, { + method: 'PUT', + body: JSON.stringify(updates), + }); + + if (error) { + console.error('Update figure error:', error); + return null; + } + + // Update in user figures + if (data) { + userFigures = userFigures.map((f) => (f.id === figureId ? { ...f, ...data } : f)); + } + + return data; + }, + + async deleteFigure(figureId: string) { + if (!browser) return false; + if (!authStore.isAuthenticated) return false; + + const { error } = await apiRequest(`/figures/${figureId}`, { method: 'DELETE' }); + + if (error) { + console.error('Delete figure error:', error); + return false; + } + + // Remove from lists + userFigures = userFigures.filter((f) => f.id !== figureId); + publicFigures = publicFigures.filter((f) => f.id !== figureId); + + return true; + }, + + async archiveFigure(figureId: string) { + return this.updateFigure(figureId, { isArchived: true }); + }, + + clearErrors() { + publicError = null; + userError = null; + generationError = null; + }, +}; diff --git a/games/figgos/apps/web/src/lib/stores/theme.ts b/games/figgos/apps/web/src/lib/stores/theme.ts new file mode 100644 index 000000000..c757064f9 --- /dev/null +++ b/games/figgos/apps/web/src/lib/stores/theme.ts @@ -0,0 +1,6 @@ +import { createThemeStore } from '@manacore/shared-theme'; + +export const theme = createThemeStore({ + appId: 'figgos', + defaultVariant: 'lume', +}); diff --git a/games/figgos/apps/web/svelte.config.js b/games/figgos/apps/web/svelte.config.js new file mode 100644 index 000000000..c8b303bb6 --- /dev/null +++ b/games/figgos/apps/web/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + }, +}; + +export default config; diff --git a/games/figgos/apps/web/tsconfig.json b/games/figgos/apps/web/tsconfig.json new file mode 100644 index 000000000..a8f10c8e3 --- /dev/null +++ b/games/figgos/apps/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/games/figgos/apps/web/vite.config.ts b/games/figgos/apps/web/vite.config.ts new file mode 100644 index 000000000..064df2977 --- /dev/null +++ b/games/figgos/apps/web/vite.config.ts @@ -0,0 +1,43 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + port: 5181, + strictPort: true, + }, + ssr: { + noExternal: [ + '@figgos/shared', + '@manacore/shared-icons', + '@manacore/shared-ui', + '@manacore/shared-tailwind', + '@manacore/shared-theme', + '@manacore/shared-theme-ui', + '@manacore/shared-feedback-ui', + '@manacore/shared-feedback-service', + '@manacore/shared-feedback-types', + '@manacore/shared-auth', + '@manacore/shared-auth-ui', + '@manacore/shared-branding', + ], + }, + optimizeDeps: { + exclude: [ + '@figgos/shared', + '@manacore/shared-icons', + '@manacore/shared-ui', + '@manacore/shared-tailwind', + '@manacore/shared-theme', + '@manacore/shared-theme-ui', + '@manacore/shared-feedback-ui', + '@manacore/shared-feedback-service', + '@manacore/shared-feedback-types', + '@manacore/shared-auth', + '@manacore/shared-auth-ui', + '@manacore/shared-branding', + ], + }, +}); diff --git a/games/figgos/package.json b/games/figgos/package.json index ad1d843be..19dd0790b 100644 --- a/games/figgos/package.json +++ b/games/figgos/package.json @@ -1,63 +1,10 @@ { - "name": "@figgos/game", + "name": "figgos", "version": "1.0.0", - "main": "expo-router/entry", + "private": true, "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 + "dev": "turbo run dev", + "build": "turbo run build", + "type-check": "echo 'Skip: run pnpm install first'" + } } diff --git a/games/figgos/packages/shared/package.json b/games/figgos/packages/shared/package.json new file mode 100644 index 000000000..5ed5261d9 --- /dev/null +++ b/games/figgos/packages/shared/package.json @@ -0,0 +1,25 @@ +{ + "name": "@figgos/shared", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/games/figgos/packages/shared/src/constants/index.ts b/games/figgos/packages/shared/src/constants/index.ts new file mode 100644 index 000000000..82c13e04e --- /dev/null +++ b/games/figgos/packages/shared/src/constants/index.ts @@ -0,0 +1 @@ +export * from './rarities'; diff --git a/games/figgos/packages/shared/src/constants/rarities.ts b/games/figgos/packages/shared/src/constants/rarities.ts new file mode 100644 index 000000000..56e115a5b --- /dev/null +++ b/games/figgos/packages/shared/src/constants/rarities.ts @@ -0,0 +1,73 @@ +import type { FigureRarity } from '../types/figure'; + +/** + * Rarity configuration with display info and probabilities + */ +export const RARITY_CONFIG: Record< + FigureRarity, + { + label: string; + color: string; + bgColor: string; + borderColor: string; + probability: number; + } +> = { + common: { + label: 'Common', + color: '#9CA3AF', + bgColor: 'rgba(156, 163, 175, 0.1)', + borderColor: 'rgba(156, 163, 175, 0.3)', + probability: 0.6, + }, + rare: { + label: 'Rare', + color: '#3B82F6', + bgColor: 'rgba(59, 130, 246, 0.1)', + borderColor: 'rgba(59, 130, 246, 0.3)', + probability: 0.25, + }, + epic: { + label: 'Epic', + color: '#8B5CF6', + bgColor: 'rgba(139, 92, 246, 0.1)', + borderColor: 'rgba(139, 92, 246, 0.3)', + probability: 0.12, + }, + legendary: { + label: 'Legendary', + color: '#F59E0B', + bgColor: 'rgba(245, 158, 11, 0.1)', + borderColor: 'rgba(245, 158, 11, 0.3)', + probability: 0.03, + }, +}; + +/** + * All rarity levels in order from common to legendary + */ +export const RARITY_ORDER: FigureRarity[] = ['common', 'rare', 'epic', 'legendary']; + +/** + * Get rarity config by level + */ +export function getRarityConfig(rarity: FigureRarity) { + return RARITY_CONFIG[rarity]; +} + +/** + * Get random rarity based on probabilities + */ +export function getRandomRarity(): FigureRarity { + const random = Math.random(); + let cumulative = 0; + + for (const rarity of RARITY_ORDER) { + cumulative += RARITY_CONFIG[rarity].probability; + if (random <= cumulative) { + return rarity; + } + } + + return 'common'; +} diff --git a/games/figgos/packages/shared/src/index.ts b/games/figgos/packages/shared/src/index.ts new file mode 100644 index 000000000..c29c24fb5 --- /dev/null +++ b/games/figgos/packages/shared/src/index.ts @@ -0,0 +1,5 @@ +// Types +export * from './types'; + +// Constants +export * from './constants'; diff --git a/games/figgos/packages/shared/src/types/figure.ts b/games/figgos/packages/shared/src/types/figure.ts new file mode 100644 index 000000000..2c0aeb055 --- /dev/null +++ b/games/figgos/packages/shared/src/types/figure.ts @@ -0,0 +1,119 @@ +/** + * Figure rarity levels + */ +export type FigureRarity = 'common' | 'rare' | 'epic' | 'legendary'; + +/** + * Character item/artifact + */ +export interface FigureItem { + name: string; + description: string; + imagePrompt: string; + lore: string; +} + +/** + * Character information structure + */ +export interface CharacterInfo { + character: { + description: string; + imagePrompt: string; + lore: string; + }; + items: FigureItem[]; + styleDescription?: string; +} + +/** + * Main Figure entity + */ +export interface Figure { + id: string; + name: string; + subject: string; + imageUrl: string; + enhancedPrompt?: string; + rarity: FigureRarity; + characterInfo: CharacterInfo; + isPublic: boolean; + isArchived: boolean; + likes: number; + userId: string; + createdAt: string; +} + +/** + * Figure creation input + */ +export interface CreateFigureInput { + name: string; + characterDescription?: string; + rarity?: FigureRarity; + characterImage?: string; + artifacts: Array<{ + name?: string; + description?: string; + }>; + isPublic?: boolean; +} + +/** + * Figure generation response + */ +export interface GenerateFigureResponse { + id: string; + name: string; + imageUrl: string; + enhancedPrompt: string; + rarity: FigureRarity; + characterInfo: CharacterInfo; +} + +/** + * Figure like entity + */ +export interface FigureLike { + id: string; + figureId: string; + userId: string; + createdAt: string; +} + +/** + * Public figure list item (for community feed) + */ +export interface PublicFigure + extends Pick< + Figure, + | 'id' + | 'name' + | 'subject' + | 'imageUrl' + | 'enhancedPrompt' + | 'likes' + | 'rarity' + | 'characterInfo' + | 'userId' + | 'createdAt' + > { + hasLiked?: boolean; +} + +/** + * User's figure list item (for shelf/collection) + */ +export interface UserFigure + extends Pick< + Figure, + | 'id' + | 'name' + | 'subject' + | 'imageUrl' + | 'likes' + | 'isPublic' + | 'rarity' + | 'isArchived' + | 'characterInfo' + > {} diff --git a/games/figgos/packages/shared/src/types/index.ts b/games/figgos/packages/shared/src/types/index.ts new file mode 100644 index 000000000..0f6ad207c --- /dev/null +++ b/games/figgos/packages/shared/src/types/index.ts @@ -0,0 +1 @@ +export * from './figure'; diff --git a/games/figgos/packages/shared/tsconfig.json b/games/figgos/packages/shared/tsconfig.json new file mode 100644 index 000000000..e7b43d5bf --- /dev/null +++ b/games/figgos/packages/shared/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/games/figgos/pnpm-workspace.yaml b/games/figgos/pnpm-workspace.yaml new file mode 100644 index 000000000..e9b0dad63 --- /dev/null +++ b/games/figgos/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'apps/*' + - 'packages/*' diff --git a/games/figgos/turbo.json b/games/figgos/turbo.json new file mode 100644 index 000000000..054a971c9 --- /dev/null +++ b/games/figgos/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "dev": { "persistent": true, "cache": false }, + "build": { "dependsOn": ["^build"] }, + "type-check": { "dependsOn": ["^type-check"] } + } +} diff --git a/package.json b/package.json index c1c0f37e3..7f05bd587 100644 --- a/package.json +++ b/package.json @@ -134,10 +134,15 @@ "dev:mana-games:web": "pnpm --filter @mana-games/web dev", "dev:mana-games:backend": "pnpm --filter @mana-games/backend dev", "dev:mana-games:app": "turbo run dev --filter=@mana-games/web --filter=@mana-games/backend", - "figgos:dev": "pnpm --filter @figgos/game dev", - "dev:figgos": "pnpm --filter @figgos/game dev", - "dev:figgos:ios": "pnpm --filter @figgos/game ios", - "dev:figgos:android": "pnpm --filter @figgos/game android", + "figgos:dev": "turbo run dev --filter=figgos...", + "dev:figgos:mobile": "pnpm --filter @figgos/mobile dev", + "dev:figgos:web": "pnpm --filter @figgos/web dev", + "dev:figgos:backend": "pnpm --filter @figgos/backend dev", + "dev:figgos:app": "turbo run dev --filter=@figgos/web --filter=@figgos/backend", + "dev:figgos:ios": "pnpm --filter @figgos/mobile ios", + "dev:figgos:android": "pnpm --filter @figgos/mobile android", + "figgos:db:push": "pnpm --filter @figgos/backend db:push", + "figgos:db:studio": "pnpm --filter @figgos/backend db:studio", "docker:up": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init", "docker:up:infra": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis minio minio-init", "docker:up:db": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis",