🔧 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>
84
games/figgos/CLAUDE.md
Normal 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
|
||||
17
games/figgos/apps/backend/.env.example
Normal 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
|
||||
12
games/figgos/apps/backend/drizzle.config.ts
Normal 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,
|
||||
});
|
||||
8
games/figgos/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
43
games/figgos/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
games/figgos/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
38
games/figgos/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
28
games/figgos/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
27
games/figgos/apps/backend/src/db/schema/figures.schema.ts
Normal 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;
|
||||
2
games/figgos/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './figures.schema';
|
||||
export * from './figure-likes.schema';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
124
games/figgos/apps/backend/src/figure/figure.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
games/figgos/apps/backend/src/figure/figure.module.ts
Normal 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 {}
|
||||
193
games/figgos/apps/backend/src/figure/figure.service.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
163
games/figgos/apps/backend/src/generation/generation.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
13
games/figgos/apps/backend/src/health/health.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
games/figgos/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
36
games/figgos/apps/backend/src/main.ts
Normal 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();
|
||||
25
games/figgos/apps/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
63
games/figgos/apps/mobile/package.json
Normal 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
|
||||
}
|
||||
46
games/figgos/apps/web/package.json
Normal 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"
|
||||
}
|
||||
88
games/figgos/apps/web/src/app.css
Normal 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;
|
||||
}
|
||||
}
|
||||
12
games/figgos/apps/web/src/app.html
Normal 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>
|
||||
38
games/figgos/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
76
games/figgos/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||