From 607ca19d4ab5fad26012420416e77be2ce297278 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:33:28 +0100 Subject: [PATCH] feat: integrate presi and voxel-lava into monorepo structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add presi web app and CLAUDE.md documentation - Restructure voxel-lava to apps/web pattern - Add voxel-lava scripts to root package.json - Update generate-env.mjs for presi configuration - Update .env.development with new project variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .env.development | 7 + apps/presi/CLAUDE.md | 199 +++++++ apps/presi/apps/web/package.json | 36 ++ apps/presi/apps/web/postcss.config.js | 6 + apps/presi/apps/web/src/app.css | 61 +++ apps/presi/apps/web/src/app.html | 12 + apps/presi/apps/web/src/lib/api/client.ts | 209 ++++++++ .../apps/web/src/lib/stores/auth.svelte.ts | 69 +++ .../apps/web/src/lib/stores/decks.svelte.ts | 168 ++++++ apps/presi/apps/web/src/routes/+layout.svelte | 102 ++++ apps/presi/apps/web/src/routes/+page.svelte | 215 ++++++++ .../web/src/routes/deck/[id]/+page.svelte | 428 +++++++++++++++ .../src/routes/forgot-password/+page.svelte | 122 +++++ .../apps/web/src/routes/login/+page.svelte | 104 ++++ .../web/src/routes/present/[id]/+page.svelte | 296 +++++++++++ .../apps/web/src/routes/profile/+page.svelte | 141 +++++ .../apps/web/src/routes/register/+page.svelte | 128 +++++ .../apps/web/src/routes/settings/+page.svelte | 148 ++++++ apps/presi/apps/web/svelte.config.js | 15 + apps/presi/apps/web/tailwind.config.ts | 29 + apps/presi/apps/web/tsconfig.json | 14 + apps/presi/apps/web/vite.config.ts | 9 + docker/init-db/01-create-databases.sql | 4 + games/voxel-lava/apps/backend/.env.example | 3 + .../voxel-lava/apps/backend/drizzle.config.ts | 12 + games/voxel-lava/apps/backend/nest-cli.json | 8 + games/voxel-lava/apps/backend/package.json | 51 ++ .../voxel-lava/apps/backend/src/app.module.ts | 18 + .../decorators/current-user.decorator.ts | 21 + .../src/common/guards/jwt-auth.guard.ts | 66 +++ .../apps/backend/src/db/connection.ts | 38 ++ .../apps/backend/src/db/database.module.ts | 28 + .../voxel-lava/apps/backend/src/db/migrate.ts | 26 + .../apps/backend/src/db/schema/index.ts | 3 + .../src/db/schema/level-likes.schema.ts | 28 + .../src/db/schema/level-plays.schema.ts | 32 ++ .../backend/src/db/schema/levels.schema.ts | 61 +++ .../backend/src/health/health.controller.ts | 13 + .../apps/backend/src/health/health.module.ts | 7 + .../backend/src/level/dto/create-level.dto.ts | 42 ++ .../backend/src/level/dto/record-play.dto.ts | 11 + .../backend/src/level/dto/update-level.dto.ts | 46 ++ .../backend/src/level/level.controller.ts | 113 ++++ .../apps/backend/src/level/level.module.ts | 10 + .../apps/backend/src/level/level.service.ts | 198 +++++++ games/voxel-lava/apps/backend/src/main.ts | 36 ++ games/voxel-lava/apps/backend/tsconfig.json | 25 + games/voxel-lava/{ => apps/web}/.mcp.json | 0 games/voxel-lava/{ => apps/web}/.npmrc | 0 .../voxel-lava/{ => apps/web}/.prettierignore | 0 games/voxel-lava/{ => apps/web}/.prettierrc | 0 .../{ => apps/web}/eslint.config.js | 0 games/voxel-lava/apps/web/package.json | 39 ++ games/voxel-lava/{ => apps/web}/src/app.d.ts | 0 games/voxel-lava/{ => apps/web}/src/app.html | 0 .../{ => apps/web}/src/lib/BlockTypes.ts | 0 .../web}/src/lib/PlayerController.ts | 0 .../voxel-lava/apps/web/src/lib/api/client.ts | 236 +++++++++ .../src/lib/components/BlockButton.svelte | 0 .../web}/src/lib/components/GameCanvas.svelte | 0 .../src/lib/components/auth/AuthButton.svelte | 0 .../src/lib/components/auth/AuthModal.svelte | 0 .../web}/src/lib/components/auth/Login.svelte | 0 .../lib/components/auth/PasswordReset.svelte | 0 .../src/lib/components/auth/Register.svelte | 0 .../lib/components/auth/UserProfile.svelte | 0 .../components/game-ui/CircleButton.svelte | 0 .../components/level/SaveLevelModal.svelte | 0 .../{ => apps/web}/src/lib/game/levels.js | 0 .../{ => apps/web}/src/lib/game/physics.js | 0 .../web}/src/lib/game/playerControls.js | 0 .../{ => apps/web}/src/lib/game/voxelUtils.js | 0 .../{ => apps/web}/src/lib/index.ts | 0 .../web}/src/lib/services/AuthService.ts | 86 +-- .../apps/web/src/lib/services/LevelService.ts | 390 ++++++++++++++ .../{ => apps/web}/src/lib/stores.js | 0 .../web}/src/lib/types/level.types.ts | 0 .../{ => apps/web}/src/routes/+page.svelte | 0 .../{ => apps/web}/static/favicon.png | Bin .../{ => apps/web}/svelte.config.js | 0 games/voxel-lava/{ => apps/web}/tsconfig.json | 0 .../voxel-lava/{ => apps/web}/vite.config.ts | 0 games/voxel-lava/package.json | 52 +- games/voxel-lava/pnpm-workspace.yaml | 3 + games/voxel-lava/src/lib/pocketbase.ts | 14 - .../src/lib/services/LevelService.ts | 498 ------------------ games/voxel-lava/turbo.json | 24 + package.json | 7 + scripts/generate-env.mjs | 30 ++ 89 files changed, 4188 insertions(+), 609 deletions(-) create mode 100644 apps/presi/CLAUDE.md create mode 100644 apps/presi/apps/web/package.json create mode 100644 apps/presi/apps/web/postcss.config.js create mode 100644 apps/presi/apps/web/src/app.css create mode 100644 apps/presi/apps/web/src/app.html create mode 100644 apps/presi/apps/web/src/lib/api/client.ts create mode 100644 apps/presi/apps/web/src/lib/stores/auth.svelte.ts create mode 100644 apps/presi/apps/web/src/lib/stores/decks.svelte.ts create mode 100644 apps/presi/apps/web/src/routes/+layout.svelte create mode 100644 apps/presi/apps/web/src/routes/+page.svelte create mode 100644 apps/presi/apps/web/src/routes/deck/[id]/+page.svelte create mode 100644 apps/presi/apps/web/src/routes/forgot-password/+page.svelte create mode 100644 apps/presi/apps/web/src/routes/login/+page.svelte create mode 100644 apps/presi/apps/web/src/routes/present/[id]/+page.svelte create mode 100644 apps/presi/apps/web/src/routes/profile/+page.svelte create mode 100644 apps/presi/apps/web/src/routes/register/+page.svelte create mode 100644 apps/presi/apps/web/src/routes/settings/+page.svelte create mode 100644 apps/presi/apps/web/svelte.config.js create mode 100644 apps/presi/apps/web/tailwind.config.ts create mode 100644 apps/presi/apps/web/tsconfig.json create mode 100644 apps/presi/apps/web/vite.config.ts create mode 100644 games/voxel-lava/apps/backend/.env.example create mode 100644 games/voxel-lava/apps/backend/drizzle.config.ts create mode 100644 games/voxel-lava/apps/backend/nest-cli.json create mode 100644 games/voxel-lava/apps/backend/package.json create mode 100644 games/voxel-lava/apps/backend/src/app.module.ts create mode 100644 games/voxel-lava/apps/backend/src/common/decorators/current-user.decorator.ts create mode 100644 games/voxel-lava/apps/backend/src/common/guards/jwt-auth.guard.ts create mode 100644 games/voxel-lava/apps/backend/src/db/connection.ts create mode 100644 games/voxel-lava/apps/backend/src/db/database.module.ts create mode 100644 games/voxel-lava/apps/backend/src/db/migrate.ts create mode 100644 games/voxel-lava/apps/backend/src/db/schema/index.ts create mode 100644 games/voxel-lava/apps/backend/src/db/schema/level-likes.schema.ts create mode 100644 games/voxel-lava/apps/backend/src/db/schema/level-plays.schema.ts create mode 100644 games/voxel-lava/apps/backend/src/db/schema/levels.schema.ts create mode 100644 games/voxel-lava/apps/backend/src/health/health.controller.ts create mode 100644 games/voxel-lava/apps/backend/src/health/health.module.ts create mode 100644 games/voxel-lava/apps/backend/src/level/dto/create-level.dto.ts create mode 100644 games/voxel-lava/apps/backend/src/level/dto/record-play.dto.ts create mode 100644 games/voxel-lava/apps/backend/src/level/dto/update-level.dto.ts create mode 100644 games/voxel-lava/apps/backend/src/level/level.controller.ts create mode 100644 games/voxel-lava/apps/backend/src/level/level.module.ts create mode 100644 games/voxel-lava/apps/backend/src/level/level.service.ts create mode 100644 games/voxel-lava/apps/backend/src/main.ts create mode 100644 games/voxel-lava/apps/backend/tsconfig.json rename games/voxel-lava/{ => apps/web}/.mcp.json (100%) rename games/voxel-lava/{ => apps/web}/.npmrc (100%) rename games/voxel-lava/{ => apps/web}/.prettierignore (100%) rename games/voxel-lava/{ => apps/web}/.prettierrc (100%) rename games/voxel-lava/{ => apps/web}/eslint.config.js (100%) create mode 100644 games/voxel-lava/apps/web/package.json rename games/voxel-lava/{ => apps/web}/src/app.d.ts (100%) rename games/voxel-lava/{ => apps/web}/src/app.html (100%) rename games/voxel-lava/{ => apps/web}/src/lib/BlockTypes.ts (100%) rename games/voxel-lava/{ => apps/web}/src/lib/PlayerController.ts (100%) create mode 100644 games/voxel-lava/apps/web/src/lib/api/client.ts rename games/voxel-lava/{ => apps/web}/src/lib/components/BlockButton.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/components/GameCanvas.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/components/auth/AuthButton.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/components/auth/AuthModal.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/components/auth/Login.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/components/auth/PasswordReset.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/components/auth/Register.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/components/auth/UserProfile.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/components/game-ui/CircleButton.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/components/level/SaveLevelModal.svelte (100%) rename games/voxel-lava/{ => apps/web}/src/lib/game/levels.js (100%) rename games/voxel-lava/{ => apps/web}/src/lib/game/physics.js (100%) rename games/voxel-lava/{ => apps/web}/src/lib/game/playerControls.js (100%) rename games/voxel-lava/{ => apps/web}/src/lib/game/voxelUtils.js (100%) rename games/voxel-lava/{ => apps/web}/src/lib/index.ts (100%) rename games/voxel-lava/{ => apps/web}/src/lib/services/AuthService.ts (60%) create mode 100644 games/voxel-lava/apps/web/src/lib/services/LevelService.ts rename games/voxel-lava/{ => apps/web}/src/lib/stores.js (100%) rename games/voxel-lava/{ => apps/web}/src/lib/types/level.types.ts (100%) rename games/voxel-lava/{ => apps/web}/src/routes/+page.svelte (100%) rename games/voxel-lava/{ => apps/web}/static/favicon.png (100%) rename games/voxel-lava/{ => apps/web}/svelte.config.js (100%) rename games/voxel-lava/{ => apps/web}/tsconfig.json (100%) rename games/voxel-lava/{ => apps/web}/vite.config.ts (100%) create mode 100644 games/voxel-lava/pnpm-workspace.yaml delete mode 100644 games/voxel-lava/src/lib/pocketbase.ts delete mode 100644 games/voxel-lava/src/lib/services/LevelService.ts create mode 100644 games/voxel-lava/turbo.json diff --git a/.env.development b/.env.development index 2c4ff352e..ded684506 100644 --- a/.env.development +++ b/.env.development @@ -127,3 +127,10 @@ PICTURE_APPLE_CLIENT_ID= QUOTE_BACKEND_PORT=3007 QUOTE_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/quote + +# ============================================ +# PRESI PROJECT +# ============================================ + +PRESI_BACKEND_PORT=3008 +PRESI_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/presi diff --git a/apps/presi/CLAUDE.md b/apps/presi/CLAUDE.md new file mode 100644 index 000000000..95623e158 --- /dev/null +++ b/apps/presi/CLAUDE.md @@ -0,0 +1,199 @@ +# Presi Project Guide + +## Project Structure + +``` +apps/presi/ +├── apps/ +│ ├── backend/ # NestJS API server (@presi/backend) +│ ├── mobile/ # Expo/React Native mobile app (@presi/mobile) +│ ├── web/ # SvelteKit web application (@presi/web) +│ └── landing/ # Astro marketing landing page (@presi/landing) - TODO +├── packages/ +│ └── shared/ # Shared types and utils (@presi/shared) +└── package.json +``` + +## Commands + +### Root Level (from monorepo root) +```bash +pnpm presi:dev # Run all presi apps +pnpm dev:presi:mobile # Start mobile app +pnpm dev:presi:web # Start web app (port 5178) +pnpm dev:presi:backend # Start backend server +pnpm dev:presi:app # Start web + backend together +pnpm presi:db:push # Push schema to database +pnpm presi:db:studio # Open Drizzle Studio +pnpm presi:db:seed # Seed database with sample data +``` + +### Mobile App (apps/presi/apps/mobile) +```bash +pnpm dev # Start Expo dev server +pnpm ios # Run on iOS simulator +pnpm android # Run on Android emulator +``` + +### Web App (apps/presi/apps/web) +```bash +pnpm dev # Start dev server (port 5178) +pnpm build # Build for production +pnpm preview # Preview production build +pnpm check # Run svelte-check +``` + +### Backend (apps/presi/apps/backend) +```bash +pnpm dev # Start with hot reload +pnpm build # Build for production +pnpm start:prod # Start production server +pnpm db:push # Push schema to database +pnpm db:studio # Open Drizzle Studio +pnpm db:seed # Seed database +``` + +## Technology Stack + +- **Mobile**: React Native 0.76 + Expo SDK 52, Expo Router, Zustand +- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS +- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL +- **Types**: TypeScript 5.x + +## Architecture + +### Core Features +- Create and manage presentation decks +- Add and edit slides with various content types +- Apply themes to presentations +- Share decks via share codes +- Present slides in full-screen mode + +### Backend API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/health` | GET | Health check | +| `/api/decks` | GET | Get user's decks | +| `/api/decks` | POST | Create new deck | +| `/api/decks/:id` | GET | Get deck details | +| `/api/decks/:id` | PUT | Update deck | +| `/api/decks/:id` | DELETE | Delete deck | +| `/api/decks/:id/slides` | GET | Get slides for deck | +| `/api/decks/:id/slides` | POST | Add slide to deck | +| `/api/slides/:id` | PUT | Update slide | +| `/api/slides/:id` | DELETE | Delete slide | +| `/api/slides/reorder` | POST | Reorder slides | + +### Data Models + +**Deck** - Presentation deck +- `id` (string) - Unique identifier +- `userId` (string) - Owner user ID +- `title` (string) - Deck title +- `description` (string?) - Optional description +- `themeId` (string?) - Theme reference +- `isPublic` (boolean) - Visibility flag +- `createdAt` / `updatedAt` (timestamps) + +**Slide** - Individual slide in a deck +- `id` (string) - Unique identifier +- `deckId` (string) - Parent deck reference +- `order` (number) - Position in deck +- `content` (SlideContent) - Slide content +- `createdAt` (timestamp) + +**SlideContent** - Content structure +- `type`: 'title' | 'content' | 'image' | 'split' +- `title`, `subtitle`, `body`, `imageUrl`, `bulletPoints` + +**Theme** - Visual theme +- `id`, `name`, `colors`, `fonts`, `isDefault` + +### Environment Variables + +#### Backend (.env) +``` +NODE_ENV=development +PORT=3008 +DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/presi +MANA_CORE_AUTH_URL=http://localhost:3001 +CORS_ORIGINS=http://localhost:5173,http://localhost:8081 +``` + +#### Mobile (.env) +``` +EXPO_PUBLIC_BACKEND_URL=http://localhost:3008 +EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +#### Web (.env) +``` +PUBLIC_BACKEND_URL=http://localhost:3008 +PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001 +``` + +## Shared Package + +### @presi/shared +Located at `packages/shared/` + +**Types:** +- `Deck`, `Slide`, `SlideContent` +- `Theme`, `ThemeColors`, `ThemeFonts` +- `SharedDeck` (for sharing feature) + +**DTOs:** +- `CreateDeckDto`, `UpdateDeckDto` +- `CreateSlideDto`, `UpdateSlideDto` +- `ReorderSlidesDto` + +## Code Style Guidelines + +- **TypeScript**: Strict typing with interfaces +- **Mobile**: Functional components with hooks, Zustand for state +- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`) +- **Backend**: NestJS modules with controllers and services +- **Styling**: Tailwind CSS (Web), NativeWind (Mobile) +- **Formatting**: Prettier with project config + +## Web App Features + +The SvelteKit web app provides feature parity with the mobile app: + +- **Authentication**: Login/Register with Mana Core Auth +- **Deck Management**: Create, edit, delete presentation decks +- **Slide Editor**: Create slides with title, body, bullet points, images +- **Presentation Mode**: Fullscreen presentation with keyboard navigation + - Arrow keys / A/D for navigation + - F for fullscreen toggle + - ESC to exit + - Timer with start/pause + - Speaker notes toggle +- **Settings**: Theme switching (light/dark/system), account info + +### Web App Structure +``` +src/ +├── lib/ +│ ├── api/client.ts # API client with auth +│ └── stores/ +│ ├── auth.svelte.ts # Auth state (Svelte 5 runes) +│ └── decks.svelte.ts # Decks/slides state +├── routes/ +│ ├── +layout.svelte # App layout with header +│ ├── +page.svelte # Deck list (home) +│ ├── login/ # Login page +│ ├── register/ # Register page +│ ├── deck/[id]/ # Deck editor with slides +│ ├── present/[id]/ # Presentation mode +│ └── settings/ # Settings page +└── app.css # Global styles +``` + +## Important Notes + +1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header) +2. **Database**: PostgreSQL with Drizzle ORM +3. **Ports**: Backend=3008, Web=5178 +4. **Landing**: Not yet implemented (empty folder) diff --git a/apps/presi/apps/web/package.json b/apps/presi/apps/web/package.json new file mode 100644 index 000000000..93054830c --- /dev/null +++ b/apps/presi/apps/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "@presi/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "vite dev --port 5178", + "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": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "lint": "eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/node": "^20.0.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^3.4.0", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^6.0.0" + }, + "dependencies": { + "@presi/shared": "workspace:*", + "lucide-svelte": "^0.460.0" + }, + "type": "module" +} diff --git a/apps/presi/apps/web/postcss.config.js b/apps/presi/apps/web/postcss.config.js new file mode 100644 index 000000000..ba8073047 --- /dev/null +++ b/apps/presi/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/apps/presi/apps/web/src/app.css b/apps/presi/apps/web/src/app.css new file mode 100644 index 000000000..0fa2ac658 --- /dev/null +++ b/apps/presi/apps/web/src/app.css @@ -0,0 +1,61 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --color-bg: 250 250 250; + --color-bg-secondary: 255 255 255; + --color-text: 15 23 42; + --color-text-secondary: 100 116 139; + --color-border: 226 232 240; + --color-primary: 14 165 233; +} + +.dark { + --color-bg: 15 23 42; + --color-bg-secondary: 30 41 59; + --color-text: 248 250 252; + --color-text-secondary: 148 163 184; + --color-border: 51 65 85; + --color-primary: 56 189 248; +} + +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: rgb(var(--color-bg)); + color: rgb(var(--color-text)); + min-height: 100vh; +} + +/* Slide aspect ratio container */ +.slide-container { + aspect-ratio: 16 / 9; + max-width: 100%; +} + +/* Presentation mode fullscreen */ +.presentation-fullscreen { + position: fixed; + inset: 0; + z-index: 50; + background-color: rgb(var(--color-bg)); +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgb(var(--color-bg-secondary)); +} + +::-webkit-scrollbar-thumb { + background: rgb(var(--color-border)); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgb(var(--color-text-secondary)); +} diff --git a/apps/presi/apps/web/src/app.html b/apps/presi/apps/web/src/app.html new file mode 100644 index 000000000..84ffad166 --- /dev/null +++ b/apps/presi/apps/web/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/apps/presi/apps/web/src/lib/api/client.ts b/apps/presi/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..4465132e8 --- /dev/null +++ b/apps/presi/apps/web/src/lib/api/client.ts @@ -0,0 +1,209 @@ +import { browser } from '$app/environment'; +import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public'; +import type { Deck, Slide, CreateDeckDto, UpdateDeckDto, CreateSlideDto, UpdateSlideDto, ReorderSlidesDto } from '@presi/shared'; + +const API_URL = PUBLIC_BACKEND_URL || 'http://localhost:3008'; +const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; + +function getToken(): string | null { + if (!browser) return null; + return localStorage.getItem('accessToken'); +} + +async function fetchWithAuth(url: string, options: RequestInit = {}): Promise { + const token = getToken(); + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url, { + ...options, + headers + }); + + if (response.status === 401) { + // Token expired - try to refresh + const refreshed = await refreshToken(); + if (refreshed) { + // Retry the request with new token + const newToken = getToken(); + if (newToken) { + (headers as Record)['Authorization'] = `Bearer ${newToken}`; + } + return fetch(url, { ...options, headers }); + } + // Clear tokens and redirect to login + if (browser) { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + window.location.href = '/login'; + } + } + + return response; +} + +async function refreshToken(): Promise { + if (!browser) return false; + + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) return false; + + try { + const response = await fetch(`${AUTH_URL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken }) + }); + + if (response.ok) { + const data = await response.json(); + localStorage.setItem('accessToken', data.accessToken); + if (data.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } + return true; + } + } catch (e) { + console.error('Failed to refresh token:', e); + } + + return false; +} + +// Auth API +export const authApi = { + async login(email: string, password: string) { + const response = await fetch(`${AUTH_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Login failed'); + } + + const data = await response.json(); + if (browser) { + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + } + return data; + }, + + async register(email: string, password: string) { + const response = await fetch(`${AUTH_URL}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Registration failed'); + } + + const data = await response.json(); + if (browser) { + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + } + return data; + }, + + logout() { + if (browser) { + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } + }, + + isAuthenticated(): boolean { + if (!browser) return false; + return !!localStorage.getItem('accessToken'); + } +}; + +// Decks API +export const decksApi = { + async getAll(): Promise { + const response = await fetchWithAuth(`${API_URL}/decks`); + if (!response.ok) throw new Error('Failed to fetch decks'); + return response.json(); + }, + + async getOne(id: string): Promise<{ deck: Deck; slides: Slide[] }> { + const response = await fetchWithAuth(`${API_URL}/decks/${id}`); + if (!response.ok) throw new Error('Failed to fetch deck'); + return response.json(); + }, + + async create(dto: CreateDeckDto): Promise { + const response = await fetchWithAuth(`${API_URL}/decks`, { + method: 'POST', + body: JSON.stringify(dto) + }); + if (!response.ok) throw new Error('Failed to create deck'); + return response.json(); + }, + + async update(id: string, dto: UpdateDeckDto): Promise { + const response = await fetchWithAuth(`${API_URL}/decks/${id}`, { + method: 'PUT', + body: JSON.stringify(dto) + }); + if (!response.ok) throw new Error('Failed to update deck'); + return response.json(); + }, + + async delete(id: string): Promise { + const response = await fetchWithAuth(`${API_URL}/decks/${id}`, { + method: 'DELETE' + }); + if (!response.ok) throw new Error('Failed to delete deck'); + } +}; + +// Slides API +export const slidesApi = { + async create(deckId: string, dto: CreateSlideDto): Promise { + const response = await fetchWithAuth(`${API_URL}/decks/${deckId}/slides`, { + method: 'POST', + body: JSON.stringify(dto) + }); + if (!response.ok) throw new Error('Failed to create slide'); + return response.json(); + }, + + async update(id: string, dto: UpdateSlideDto): Promise { + const response = await fetchWithAuth(`${API_URL}/slides/${id}`, { + method: 'PUT', + body: JSON.stringify(dto) + }); + if (!response.ok) throw new Error('Failed to update slide'); + return response.json(); + }, + + async delete(id: string): Promise { + const response = await fetchWithAuth(`${API_URL}/slides/${id}`, { + method: 'DELETE' + }); + if (!response.ok) throw new Error('Failed to delete slide'); + }, + + async reorder(dto: ReorderSlidesDto): Promise { + const response = await fetchWithAuth(`${API_URL}/slides/reorder`, { + method: 'PUT', + body: JSON.stringify(dto) + }); + if (!response.ok) throw new Error('Failed to reorder slides'); + } +}; diff --git a/apps/presi/apps/web/src/lib/stores/auth.svelte.ts b/apps/presi/apps/web/src/lib/stores/auth.svelte.ts new file mode 100644 index 000000000..8a0ee6114 --- /dev/null +++ b/apps/presi/apps/web/src/lib/stores/auth.svelte.ts @@ -0,0 +1,69 @@ +import { browser } from '$app/environment'; +import { authApi } from '$lib/api/client'; + +interface User { + id: string; + email: string; +} + +function createAuthStore() { + let isAuthenticated = $state(false); + let user = $state(null); + let isLoading = $state(true); + + function init() { + if (!browser) { + isLoading = false; + return; + } + + const token = localStorage.getItem('accessToken'); + if (token) { + // Decode JWT to get user info + try { + const payload = JSON.parse(atob(token.split('.')[1])); + user = { id: payload.sub, email: payload.email }; + isAuthenticated = true; + } catch (e) { + console.error('Failed to decode token:', e); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + } + } + isLoading = false; + } + + async function login(email: string, password: string) { + const data = await authApi.login(email, password); + const payload = JSON.parse(atob(data.accessToken.split('.')[1])); + user = { id: payload.sub, email: payload.email }; + isAuthenticated = true; + return data; + } + + async function register(email: string, password: string) { + const data = await authApi.register(email, password); + const payload = JSON.parse(atob(data.accessToken.split('.')[1])); + user = { id: payload.sub, email: payload.email }; + isAuthenticated = true; + return data; + } + + function logout() { + authApi.logout(); + user = null; + isAuthenticated = false; + } + + return { + get isAuthenticated() { return isAuthenticated; }, + get user() { return user; }, + get isLoading() { return isLoading; }, + init, + login, + register, + logout + }; +} + +export const auth = createAuthStore(); diff --git a/apps/presi/apps/web/src/lib/stores/decks.svelte.ts b/apps/presi/apps/web/src/lib/stores/decks.svelte.ts new file mode 100644 index 000000000..046706f8a --- /dev/null +++ b/apps/presi/apps/web/src/lib/stores/decks.svelte.ts @@ -0,0 +1,168 @@ +import { decksApi, slidesApi } from '$lib/api/client'; +import type { Deck, Slide, CreateDeckDto, UpdateDeckDto, CreateSlideDto, UpdateSlideDto } from '@presi/shared'; + +function createDecksStore() { + let decks = $state([]); + let currentDeck = $state(null); + let currentSlides = $state([]); + let isLoading = $state(false); + let error = $state(null); + + async function loadDecks() { + isLoading = true; + error = null; + try { + decks = await decksApi.getAll(); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load decks'; + console.error('Failed to load decks:', e); + } finally { + isLoading = false; + } + } + + async function loadDeck(id: string) { + isLoading = true; + error = null; + try { + const data = await decksApi.getOne(id); + currentDeck = data.deck; + currentSlides = data.slides.sort((a, b) => a.order - b.order); + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to load deck'; + console.error('Failed to load deck:', e); + } finally { + isLoading = false; + } + } + + async function createDeck(dto: CreateDeckDto): Promise { + isLoading = true; + error = null; + try { + const deck = await decksApi.create(dto); + decks = [deck, ...decks]; + return deck; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create deck'; + console.error('Failed to create deck:', e); + return null; + } finally { + isLoading = false; + } + } + + async function updateDeck(id: string, dto: UpdateDeckDto): Promise { + error = null; + try { + const updated = await decksApi.update(id, dto); + decks = decks.map(d => d.id === id ? updated : d); + if (currentDeck?.id === id) { + currentDeck = updated; + } + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update deck'; + console.error('Failed to update deck:', e); + return false; + } + } + + async function deleteDeck(id: string): Promise { + error = null; + try { + await decksApi.delete(id); + decks = decks.filter(d => d.id !== id); + if (currentDeck?.id === id) { + currentDeck = null; + currentSlides = []; + } + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete deck'; + console.error('Failed to delete deck:', e); + return false; + } + } + + async function createSlide(deckId: string, dto: CreateSlideDto): Promise { + error = null; + try { + const slide = await slidesApi.create(deckId, dto); + currentSlides = [...currentSlides, slide].sort((a, b) => a.order - b.order); + return slide; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to create slide'; + console.error('Failed to create slide:', e); + return null; + } + } + + async function updateSlide(id: string, dto: UpdateSlideDto): Promise { + error = null; + try { + const updated = await slidesApi.update(id, dto); + currentSlides = currentSlides.map(s => s.id === id ? updated : s); + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to update slide'; + console.error('Failed to update slide:', e); + return false; + } + } + + async function deleteSlide(id: string): Promise { + error = null; + try { + await slidesApi.delete(id); + currentSlides = currentSlides.filter(s => s.id !== id); + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to delete slide'; + console.error('Failed to delete slide:', e); + return false; + } + } + + async function reorderSlides(slides: { id: string; order: number }[]): Promise { + error = null; + try { + await slidesApi.reorder({ slides }); + // Update local state + const orderMap = new Map(slides.map(s => [s.id, s.order])); + currentSlides = currentSlides + .map(s => ({ ...s, order: orderMap.get(s.id) ?? s.order })) + .sort((a, b) => a.order - b.order); + return true; + } catch (e) { + error = e instanceof Error ? e.message : 'Failed to reorder slides'; + console.error('Failed to reorder slides:', e); + return false; + } + } + + function clearCurrent() { + currentDeck = null; + currentSlides = []; + } + + return { + get decks() { return decks; }, + get currentDeck() { return currentDeck; }, + get currentSlides() { return currentSlides; }, + get isLoading() { return isLoading; }, + get error() { return error; }, + loadDecks, + loadDeck, + createDeck, + updateDeck, + deleteDeck, + createSlide, + updateSlide, + deleteSlide, + reorderSlides, + clearCurrent + }; +} + +export const decksStore = createDecksStore(); diff --git a/apps/presi/apps/web/src/routes/+layout.svelte b/apps/presi/apps/web/src/routes/+layout.svelte new file mode 100644 index 000000000..403b000e2 --- /dev/null +++ b/apps/presi/apps/web/src/routes/+layout.svelte @@ -0,0 +1,102 @@ + + + + Presi - Presentation Creator + + +{#if auth.isLoading} +
+
+
+{:else if auth.isAuthenticated || publicRoutes.includes($page.url.pathname)} +
+ {#if auth.isAuthenticated && !$page.url.pathname.startsWith('/present/')} +
+
+
+ + + Presi + + +
+ + + + + + + + + {auth.user?.email} + + + +
+
+
+
+ {/if} + +
+ +
+
+{/if} diff --git a/apps/presi/apps/web/src/routes/+page.svelte b/apps/presi/apps/web/src/routes/+page.svelte new file mode 100644 index 000000000..dfdd8e811 --- /dev/null +++ b/apps/presi/apps/web/src/routes/+page.svelte @@ -0,0 +1,215 @@ + + + + My Decks - Presi + + +
+
+
+

My Presentations

+

Create and manage your slide decks

+
+ +
+ + {#if decksStore.isLoading} +
+
+
+ {:else if decksStore.decks.length === 0} +
+
+ +
+

No presentations yet

+

Create your first deck to get started

+ +
+ {:else} +
+ {#each decksStore.decks as deck (deck.id)} +
+ +
+ +
+
+

{deck.title}

+ {#if deck.description} +

{deck.description}

+ {/if} +
+ + + {formatDate(deck.updatedAt)} + +
+
+
+
+ +
+
+ {/each} +
+ {/if} +
+ + +{#if showCreateModal} +
+
+
+
+

Create New Deck

+ +
+
+ + +
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+{/if} + + +{#if showDeleteModal} +
+
+

Delete Deck

+

+ Are you sure you want to delete "{deckToDelete?.title}"? This action cannot be undone. +

+
+ + +
+
+
+{/if} diff --git a/apps/presi/apps/web/src/routes/deck/[id]/+page.svelte b/apps/presi/apps/web/src/routes/deck/[id]/+page.svelte new file mode 100644 index 000000000..9046e9dcb --- /dev/null +++ b/apps/presi/apps/web/src/routes/deck/[id]/+page.svelte @@ -0,0 +1,428 @@ + + + + {decksStore.currentDeck?.title || 'Loading...'} - Presi + + +
+ {#if decksStore.isLoading} +
+
+
+ {:else if decksStore.currentDeck} + +
+
+ + + +
+

{decksStore.currentDeck.title}

+ {#if decksStore.currentDeck.description} +

{decksStore.currentDeck.description}

+ {/if} +
+
+ +
+ + {#if decksStore.currentSlides.length > 0} + + + Present + + {/if} +
+
+ + + {#if decksStore.currentSlides.length === 0} +
+
+ +
+

No slides yet

+

Add your first slide to get started

+ +
+ {:else} +
+ {#each decksStore.currentSlides as slide, index (slide.id)} +
+ + + + +
+ Slide {index + 1} +
+ + + + +
+
+
+ {/each} +
+ {/if} + {/if} +
+ + +{#if showSlideModal} +
+
+
+
+

+ {editingSlide ? 'Edit Slide' : 'New Slide'} +

+ +
+ +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ {#each slideBulletPoints as point, index} +
+ • + updateBulletPoint(index, (e.target as HTMLInputElement).value)} + class="flex-1 px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="Add a point..." + /> + +
+ {/each} + +
+
+ + +
+ + +
+
+ +
+ + +
+
+
+
+{/if} + + +{#if showDeleteModal} +
+
+

Delete Slide

+

+ Are you sure you want to delete this slide? This action cannot be undone. +

+
+ + +
+
+
+{/if} diff --git a/apps/presi/apps/web/src/routes/forgot-password/+page.svelte b/apps/presi/apps/web/src/routes/forgot-password/+page.svelte new file mode 100644 index 000000000..f90d3fd12 --- /dev/null +++ b/apps/presi/apps/web/src/routes/forgot-password/+page.svelte @@ -0,0 +1,122 @@ + + + + Forgot Password - Presi + + +
+
+
+
+
+ +
+
+

+ {resetSent ? 'Check your email' : 'Reset password'} +

+

+ {resetSent ? `We've sent reset instructions to ${email}` : 'Enter your email to receive reset instructions'} +

+
+ + {#if resetSent} +
+
+
+ +
+
+

+ If an account exists with this email, you'll receive password reset instructions shortly. +

+ + + Back to login + +
+ {:else} +
+ {#if error} +
+ {error} +
+ {/if} + +
+ +
+ + +
+
+ + + +

+ Back to login +

+
+ {/if} +
+
diff --git a/apps/presi/apps/web/src/routes/login/+page.svelte b/apps/presi/apps/web/src/routes/login/+page.svelte new file mode 100644 index 000000000..32b776501 --- /dev/null +++ b/apps/presi/apps/web/src/routes/login/+page.svelte @@ -0,0 +1,104 @@ + + + + Login - Presi + + +
+
+
+
+
+ +
+
+

Welcome back

+

Sign in to your Presi account

+
+ +
+ {#if error} +
+ + {error} +
+ {/if} + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + + +
+ + Forgot password? + +

+ No account? + Sign up +

+
+
+
+
diff --git a/apps/presi/apps/web/src/routes/present/[id]/+page.svelte b/apps/presi/apps/web/src/routes/present/[id]/+page.svelte new file mode 100644 index 000000000..1054a9bb2 --- /dev/null +++ b/apps/presi/apps/web/src/routes/present/[id]/+page.svelte @@ -0,0 +1,296 @@ + + + + Presenting: {decksStore.currentDeck?.title || 'Loading...'} + + +
+ {#if decksStore.isLoading} +
+
+
+ {:else if currentSlide} + +
+
+

{decksStore.currentDeck?.title}

+ + Slide {currentSlideIndex + 1} of {decksStore.currentSlides.length} + +
+ +
+ + +
+
+ {#if currentSlide.content.imageUrl} + {currentSlide.content.title + {:else} +
+ {#if currentSlide.content.title} +

{currentSlide.content.title}

+ {/if} + {#if currentSlide.content.body} +

{currentSlide.content.body}

+ {/if} + {#if currentSlide.content.bulletPoints?.length} +
    + {#each currentSlide.content.bulletPoints as point} +
  • + • + {point} +
  • + {/each} +
+ {/if} +
+ {/if} +
+
+ + + {#if showNotes && currentSlide.content.subtitle} +
+
+

Speaker Notes

+

{currentSlide.content.subtitle}

+
+
+ {/if} + + +
+
+ +
+ +
+ + {formatTime(elapsedSeconds)} +
+
+ + +
+ + + +
+ {#each decksStore.currentSlides as _, index} + + {/each} +
+ + +
+ + +
+ + +
+
+
+ {:else} +
+

No slides in this deck

+
+ {/if} +
diff --git a/apps/presi/apps/web/src/routes/profile/+page.svelte b/apps/presi/apps/web/src/routes/profile/+page.svelte new file mode 100644 index 000000000..35f54a94b --- /dev/null +++ b/apps/presi/apps/web/src/routes/profile/+page.svelte @@ -0,0 +1,141 @@ + + + + Profile - Presi + + +
+
+ + + +

Profile

+
+ + {#if isLoading} +
+
+
+ {:else} +
+ +
+
+
+ +
+

+ {auth.user?.email || 'User'} +

+

+ ID: {auth.user?.id?.slice(0, 8)}... +

+
+
+ + +
+
+

Statistics

+
+
+
+ +
+
+ +
+
+ {decksStore.decks.length} +
+
+ Total Decks +
+
+ + +
+
+ +
+
+ - +
+
+ Total Slides +
+
+
+
+
+ + + {#if decksStore.decks.length > 0} +
+
+

Recent Presentations

+
+ +
+ {/if} +
+ {/if} +
diff --git a/apps/presi/apps/web/src/routes/register/+page.svelte b/apps/presi/apps/web/src/routes/register/+page.svelte new file mode 100644 index 000000000..10e7ac71b --- /dev/null +++ b/apps/presi/apps/web/src/routes/register/+page.svelte @@ -0,0 +1,128 @@ + + + + Register - Presi + + +
+
+
+
+
+ +
+
+

Create account

+

Start creating amazing presentations

+
+ +
+ {#if error} +
+ + {error} +
+ {/if} + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + + +

+ Already have an account? + Sign in +

+
+
+
diff --git a/apps/presi/apps/web/src/routes/settings/+page.svelte b/apps/presi/apps/web/src/routes/settings/+page.svelte new file mode 100644 index 000000000..dfd0d64e2 --- /dev/null +++ b/apps/presi/apps/web/src/routes/settings/+page.svelte @@ -0,0 +1,148 @@ + + + + Settings - Presi + + +
+

Settings

+ +
+ +
+
+

+ + Account +

+
+
+
+
+ +
+

Email

+

{auth.user?.email}

+
+
+
+
+
+ +
+

User ID

+

{auth.user?.id}

+
+
+
+
+
+ + +
+
+

+ + Appearance +

+
+
+

Choose your preferred theme

+
+ + + +
+
+
+ + +
+
+

Danger Zone

+
+
+
+
+

Sign out

+

Sign out of your account on this device

+
+ +
+
+
+
+
diff --git a/apps/presi/apps/web/svelte.config.js b/apps/presi/apps/web/svelte.config.js new file mode 100644 index 000000000..43c602b21 --- /dev/null +++ b/apps/presi/apps/web/svelte.config.js @@ -0,0 +1,15 @@ +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(), + alias: { + $lib: './src/lib' + } + } +}; + +export default config; diff --git a/apps/presi/apps/web/tailwind.config.ts b/apps/presi/apps/web/tailwind.config.ts new file mode 100644 index 000000000..98f2a91c5 --- /dev/null +++ b/apps/presi/apps/web/tailwind.config.ts @@ -0,0 +1,29 @@ +import type { Config } from 'tailwindcss'; + +export default { + content: ['./src/**/*.{html,js,svelte,ts}'], + darkMode: 'class', + theme: { + extend: { + colors: { + primary: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + 950: '#082f49' + } + }, + aspectRatio: { + '16/9': '16 / 9' + } + } + }, + plugins: [] +} satisfies Config; diff --git a/apps/presi/apps/web/tsconfig.json b/apps/presi/apps/web/tsconfig.json new file mode 100644 index 000000000..43447105a --- /dev/null +++ b/apps/presi/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/apps/presi/apps/web/vite.config.ts b/apps/presi/apps/web/vite.config.ts new file mode 100644 index 000000000..be51373c0 --- /dev/null +++ b/apps/presi/apps/web/vite.config.ts @@ -0,0 +1,9 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + port: 5178 + } +}); diff --git a/docker/init-db/01-create-databases.sql b/docker/init-db/01-create-databases.sql index eea380357..594e11b57 100644 --- a/docker/init-db/01-create-databases.sql +++ b/docker/init-db/01-create-databases.sql @@ -4,6 +4,10 @@ -- Create chat database CREATE DATABASE chat; +-- Create voxel_lava database +CREATE DATABASE voxel_lava; + -- Grant all privileges to the default user GRANT ALL PRIVILEGES ON DATABASE chat TO manacore; +GRANT ALL PRIVILEGES ON DATABASE voxel_lava TO manacore; GRANT ALL PRIVILEGES ON DATABASE manacore TO manacore; diff --git a/games/voxel-lava/apps/backend/.env.example b/games/voxel-lava/apps/backend/.env.example new file mode 100644 index 000000000..4833a0842 --- /dev/null +++ b/games/voxel-lava/apps/backend/.env.example @@ -0,0 +1,3 @@ +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/voxel_lava +MANA_CORE_AUTH_URL=http://localhost:3001 +PORT=3010 diff --git a/games/voxel-lava/apps/backend/drizzle.config.ts b/games/voxel-lava/apps/backend/drizzle.config.ts new file mode 100644 index 000000000..b9d2a3a87 --- /dev/null +++ b/games/voxel-lava/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://postgres:postgres@localhost:5432/voxel_lava', + }, + verbose: true, + strict: true, +}); diff --git a/games/voxel-lava/apps/backend/nest-cli.json b/games/voxel-lava/apps/backend/nest-cli.json new file mode 100644 index 000000000..f9aa683b1 --- /dev/null +++ b/games/voxel-lava/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/voxel-lava/apps/backend/package.json b/games/voxel-lava/apps/backend/package.json new file mode 100644 index 000000000..1bb9928e5 --- /dev/null +++ b/games/voxel-lava/apps/backend/package.json @@ -0,0 +1,51 @@ +{ + "name": "@voxel-lava/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": "tsc --noEmit", + "migration:generate": "drizzle-kit generate", + "migration:run": "tsx src/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@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", + "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/express": "^5.0.0", + "@types/node": "^22.10.2", + "@typescript-eslint/eslint-plugin": "^8.18.1", + "@typescript-eslint/parser": "^8.18.1", + "eslint": "^9.17.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "ts-loader": "^9.5.1", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/games/voxel-lava/apps/backend/src/app.module.ts b/games/voxel-lava/apps/backend/src/app.module.ts new file mode 100644 index 000000000..66dd311d1 --- /dev/null +++ b/games/voxel-lava/apps/backend/src/app.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DatabaseModule } from './db/database.module'; +import { LevelModule } from './level/level.module'; +import { HealthModule } from './health/health.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + DatabaseModule, + LevelModule, + HealthModule, + ], +}) +export class AppModule {} diff --git a/games/voxel-lava/apps/backend/src/common/decorators/current-user.decorator.ts b/games/voxel-lava/apps/backend/src/common/decorators/current-user.decorator.ts new file mode 100644 index 000000000..91c53dfb1 --- /dev/null +++ b/games/voxel-lava/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/voxel-lava/apps/backend/src/common/guards/jwt-auth.guard.ts b/games/voxel-lava/apps/backend/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 000000000..37bf176fe --- /dev/null +++ b/games/voxel-lava/apps/backend/src/common/guards/jwt-auth.guard.ts @@ -0,0 +1,66 @@ +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/voxel-lava/apps/backend/src/db/connection.ts b/games/voxel-lava/apps/backend/src/db/connection.ts new file mode 100644 index 000000000..ad2e67bfe --- /dev/null +++ b/games/voxel-lava/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/voxel-lava/apps/backend/src/db/database.module.ts b/games/voxel-lava/apps/backend/src/db/database.module.ts new file mode 100644 index 000000000..ab834ba6c --- /dev/null +++ b/games/voxel-lava/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/voxel-lava/apps/backend/src/db/migrate.ts b/games/voxel-lava/apps/backend/src/db/migrate.ts new file mode 100644 index 000000000..d6d9c727e --- /dev/null +++ b/games/voxel-lava/apps/backend/src/db/migrate.ts @@ -0,0 +1,26 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import postgres from 'postgres'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +async function runMigrations() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + + console.log('Running migrations...'); + + const connection = postgres(databaseUrl, { max: 1 }); + const db = drizzle(connection); + + await migrate(db, { migrationsFolder: './src/db/migrations' }); + + await connection.end(); + + console.log('Migrations completed!'); +} + +runMigrations().catch(console.error); diff --git a/games/voxel-lava/apps/backend/src/db/schema/index.ts b/games/voxel-lava/apps/backend/src/db/schema/index.ts new file mode 100644 index 000000000..208327879 --- /dev/null +++ b/games/voxel-lava/apps/backend/src/db/schema/index.ts @@ -0,0 +1,3 @@ +export * from './levels.schema'; +export * from './level-likes.schema'; +export * from './level-plays.schema'; diff --git a/games/voxel-lava/apps/backend/src/db/schema/level-likes.schema.ts b/games/voxel-lava/apps/backend/src/db/schema/level-likes.schema.ts new file mode 100644 index 000000000..241847913 --- /dev/null +++ b/games/voxel-lava/apps/backend/src/db/schema/level-likes.schema.ts @@ -0,0 +1,28 @@ +import { pgTable, uuid, timestamp, unique } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { levels } from './levels.schema'; + +export const levelLikes = pgTable( + 'level_likes', + { + id: uuid('id').primaryKey().defaultRandom(), + levelId: uuid('level_id') + .notNull() + .references(() => levels.id, { onDelete: 'cascade' }), + userId: uuid('user_id').notNull(), + createdAt: timestamp('created_at').defaultNow(), + }, + (table) => ({ + uniqueLike: unique().on(table.levelId, table.userId), + }), +); + +export const levelLikesRelations = relations(levelLikes, ({ one }) => ({ + level: one(levels, { + fields: [levelLikes.levelId], + references: [levels.id], + }), +})); + +export type LevelLike = typeof levelLikes.$inferSelect; +export type NewLevelLike = typeof levelLikes.$inferInsert; diff --git a/games/voxel-lava/apps/backend/src/db/schema/level-plays.schema.ts b/games/voxel-lava/apps/backend/src/db/schema/level-plays.schema.ts new file mode 100644 index 000000000..bed6bfe2f --- /dev/null +++ b/games/voxel-lava/apps/backend/src/db/schema/level-plays.schema.ts @@ -0,0 +1,32 @@ +import { + pgTable, + uuid, + timestamp, + real, + integer, + boolean, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; +import { levels } from './levels.schema'; + +export const levelPlays = pgTable('level_plays', { + id: uuid('id').primaryKey().defaultRandom(), + levelId: uuid('level_id') + .notNull() + .references(() => levels.id, { onDelete: 'cascade' }), + userId: uuid('user_id'), + completionTime: real('completion_time'), + attempts: integer('attempts').default(1), + completed: boolean('completed').default(false), + createdAt: timestamp('created_at').defaultNow(), +}); + +export const levelPlaysRelations = relations(levelPlays, ({ one }) => ({ + level: one(levels, { + fields: [levelPlays.levelId], + references: [levels.id], + }), +})); + +export type LevelPlay = typeof levelPlays.$inferSelect; +export type NewLevelPlay = typeof levelPlays.$inferInsert; diff --git a/games/voxel-lava/apps/backend/src/db/schema/levels.schema.ts b/games/voxel-lava/apps/backend/src/db/schema/levels.schema.ts new file mode 100644 index 000000000..ecdc76a7e --- /dev/null +++ b/games/voxel-lava/apps/backend/src/db/schema/levels.schema.ts @@ -0,0 +1,61 @@ +import { + pgTable, + uuid, + text, + boolean, + integer, + timestamp, + jsonb, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +export const levels = pgTable('levels', { + id: uuid('id').primaryKey().defaultRandom(), + name: text('name').notNull(), + description: text('description'), + userId: uuid('user_id').notNull(), + voxelData: jsonb('voxel_data').notNull(), + spawnPoint: jsonb('spawn_point').notNull(), + worldSize: jsonb('world_size').notNull(), + isPublic: boolean('is_public').default(false), + playCount: integer('play_count').default(0), + likesCount: integer('likes_count').default(0), + difficulty: text('difficulty'), + tags: text('tags').array(), + thumbnailUrl: text('thumbnail_url'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow(), +}); + +export const levelsRelations = relations(levels, ({ many }) => ({ + likes: many(levelLikes), + plays: many(levelPlays), +})); + +// Import after definition to avoid circular dependency +import { levelLikes } from './level-likes.schema'; +import { levelPlays } from './level-plays.schema'; + +export type Level = typeof levels.$inferSelect; +export type NewLevel = typeof levels.$inferInsert; + +// Types for JSON fields +export interface VoxelData { + [key: string]: { + type: string; + isSpawnPoint?: boolean; + isGoal?: boolean; + }; +} + +export interface SpawnPoint { + x: number; + y: number; + z: number; +} + +export interface WorldSize { + width: number; + height: number; + depth: number; +} diff --git a/games/voxel-lava/apps/backend/src/health/health.controller.ts b/games/voxel-lava/apps/backend/src/health/health.controller.ts new file mode 100644 index 000000000..cf5b7531c --- /dev/null +++ b/games/voxel-lava/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: 'voxel-lava-backend', + }; + } +} diff --git a/games/voxel-lava/apps/backend/src/health/health.module.ts b/games/voxel-lava/apps/backend/src/health/health.module.ts new file mode 100644 index 000000000..7476abedd --- /dev/null +++ b/games/voxel-lava/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/voxel-lava/apps/backend/src/level/dto/create-level.dto.ts b/games/voxel-lava/apps/backend/src/level/dto/create-level.dto.ts new file mode 100644 index 000000000..970b2e551 --- /dev/null +++ b/games/voxel-lava/apps/backend/src/level/dto/create-level.dto.ts @@ -0,0 +1,42 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsObject, + IsArray, +} from 'class-validator'; + +export class CreateLevelDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsObject() + voxelData: Record; + + @IsObject() + spawnPoint: { x: number; y: number; z: number }; + + @IsObject() + worldSize: { width: number; height: number; depth: number }; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsString() + difficulty?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + thumbnailUrl?: string; +} diff --git a/games/voxel-lava/apps/backend/src/level/dto/record-play.dto.ts b/games/voxel-lava/apps/backend/src/level/dto/record-play.dto.ts new file mode 100644 index 000000000..8a26b7743 --- /dev/null +++ b/games/voxel-lava/apps/backend/src/level/dto/record-play.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsOptional, IsNumber } from 'class-validator'; + +export class RecordPlayDto { + @IsOptional() + @IsBoolean() + completed?: boolean; + + @IsOptional() + @IsNumber() + completionTime?: number; +} diff --git a/games/voxel-lava/apps/backend/src/level/dto/update-level.dto.ts b/games/voxel-lava/apps/backend/src/level/dto/update-level.dto.ts new file mode 100644 index 000000000..7c21ee444 --- /dev/null +++ b/games/voxel-lava/apps/backend/src/level/dto/update-level.dto.ts @@ -0,0 +1,46 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsObject, + IsArray, +} from 'class-validator'; + +export class UpdateLevelDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsObject() + voxelData?: Record; + + @IsOptional() + @IsObject() + spawnPoint?: { x: number; y: number; z: number }; + + @IsOptional() + @IsObject() + worldSize?: { width: number; height: number; depth: number }; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsString() + difficulty?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + thumbnailUrl?: string; +} diff --git a/games/voxel-lava/apps/backend/src/level/level.controller.ts b/games/voxel-lava/apps/backend/src/level/level.controller.ts new file mode 100644 index 000000000..886fdff5d --- /dev/null +++ b/games/voxel-lava/apps/backend/src/level/level.controller.ts @@ -0,0 +1,113 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Query, + UseGuards, +} from '@nestjs/common'; +import { LevelService } from './level.service'; +import { CreateLevelDto } from './dto/create-level.dto'; +import { UpdateLevelDto } from './dto/update-level.dto'; +import { RecordPlayDto } from './dto/record-play.dto'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { + CurrentUser, + CurrentUserPayload, +} from '../common/decorators/current-user.decorator'; + +@Controller('levels') +export class LevelController { + constructor(private readonly levelService: LevelService) {} + + @Get('public') + async getPublicLevels( + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.levelService.findPublicLevels( + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 20, + ); + } + + @Get() + @UseGuards(JwtAuthGuard) + async getUserLevels(@CurrentUser() user: CurrentUserPayload) { + return this.levelService.findUserLevels(user.userId); + } + + @Get(':id') + async getLevel(@Param('id') id: string) { + return this.levelService.findById(id); + } + + @Post() + @UseGuards(JwtAuthGuard) + async createLevel( + @Body() dto: CreateLevelDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.levelService.create(dto, user.userId); + } + + @Put(':id') + @UseGuards(JwtAuthGuard) + async updateLevel( + @Param('id') id: string, + @Body() dto: UpdateLevelDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.levelService.update(id, dto, user.userId); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard) + async deleteLevel( + @Param('id') id: string, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.levelService.delete(id, user.userId); + } + + @Post(':id/like') + @UseGuards(JwtAuthGuard) + async toggleLike( + @Param('id') id: string, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.levelService.toggleLike(id, user.userId); + } + + @Get(':id/liked') + @UseGuards(JwtAuthGuard) + async hasLiked( + @Param('id') id: string, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.levelService.hasLiked(id, user.userId); + } + + @Post(':id/play') + async recordPlay( + @Param('id') id: string, + @Body() dto: RecordPlayDto, + @CurrentUser() user?: CurrentUserPayload, + ) { + return this.levelService.recordPlay(id, dto, user?.userId); + } + + @Get(':id/leaderboard') + async getLeaderboard( + @Param('id') id: string, + @Query('limit') limit?: string, + ) { + return this.levelService.getLeaderboard( + id, + limit ? parseInt(limit, 10) : 10, + ); + } +} diff --git a/games/voxel-lava/apps/backend/src/level/level.module.ts b/games/voxel-lava/apps/backend/src/level/level.module.ts new file mode 100644 index 000000000..1cc5cc544 --- /dev/null +++ b/games/voxel-lava/apps/backend/src/level/level.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LevelController } from './level.controller'; +import { LevelService } from './level.service'; + +@Module({ + controllers: [LevelController], + providers: [LevelService], + exports: [LevelService], +}) +export class LevelModule {} diff --git a/games/voxel-lava/apps/backend/src/level/level.service.ts b/games/voxel-lava/apps/backend/src/level/level.service.ts new file mode 100644 index 000000000..5873406a6 --- /dev/null +++ b/games/voxel-lava/apps/backend/src/level/level.service.ts @@ -0,0 +1,198 @@ +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 { levels, levelLikes, levelPlays } from '../db/schema'; +import { CreateLevelDto } from './dto/create-level.dto'; +import { UpdateLevelDto } from './dto/update-level.dto'; +import { RecordPlayDto } from './dto/record-play.dto'; + +@Injectable() +export class LevelService { + constructor( + @Inject(DATABASE_CONNECTION) + private db: Database, + ) {} + + async create(dto: CreateLevelDto, userId: string) { + const [level] = await this.db + .insert(levels) + .values({ + name: dto.name, + description: dto.description, + userId, + voxelData: dto.voxelData, + spawnPoint: dto.spawnPoint, + worldSize: dto.worldSize, + isPublic: dto.isPublic ?? false, + difficulty: dto.difficulty, + tags: dto.tags, + thumbnailUrl: dto.thumbnailUrl, + }) + .returning(); + + return level; + } + + async findById(id: string) { + const [level] = await this.db + .select() + .from(levels) + .where(eq(levels.id, id)); + + if (!level) { + throw new NotFoundException('Level not found'); + } + + return level; + } + + async findUserLevels(userId: string) { + return this.db + .select() + .from(levels) + .where(eq(levels.userId, userId)) + .orderBy(desc(levels.updatedAt)); + } + + async findPublicLevels(page = 1, limit = 20) { + const offset = (page - 1) * limit; + + const items = await this.db + .select() + .from(levels) + .where(eq(levels.isPublic, true)) + .orderBy(desc(levels.createdAt)) + .limit(limit) + .offset(offset); + + const [{ count }] = await this.db + .select({ count: sql`count(*)` }) + .from(levels) + .where(eq(levels.isPublic, true)); + + return { + items, + total: Number(count), + page, + limit, + totalPages: Math.ceil(Number(count) / limit), + }; + } + + async update(id: string, dto: UpdateLevelDto, userId: string) { + const level = await this.findById(id); + + if (level.userId !== userId) { + throw new ForbiddenException('You can only update your own levels'); + } + + const [updated] = await this.db + .update(levels) + .set({ + ...dto, + updatedAt: new Date(), + }) + .where(eq(levels.id, id)) + .returning(); + + return updated; + } + + async delete(id: string, userId: string) { + const level = await this.findById(id); + + if (level.userId !== userId) { + throw new ForbiddenException('You can only delete your own levels'); + } + + await this.db.delete(levels).where(eq(levels.id, id)); + + return { success: true }; + } + + async toggleLike(levelId: string, userId: string) { + // Check if already liked + const [existingLike] = await this.db + .select() + .from(levelLikes) + .where(and(eq(levelLikes.levelId, levelId), eq(levelLikes.userId, userId))); + + if (existingLike) { + // Unlike + await this.db + .delete(levelLikes) + .where(eq(levelLikes.id, existingLike.id)); + + // Decrement likes count + await this.db + .update(levels) + .set({ likesCount: sql`${levels.likesCount} - 1` }) + .where(eq(levels.id, levelId)); + + return { liked: false }; + } else { + // Like + await this.db.insert(levelLikes).values({ + levelId, + userId, + }); + + // Increment likes count + await this.db + .update(levels) + .set({ likesCount: sql`${levels.likesCount} + 1` }) + .where(eq(levels.id, levelId)); + + return { liked: true }; + } + } + + async hasLiked(levelId: string, userId: string) { + const [like] = await this.db + .select() + .from(levelLikes) + .where(and(eq(levelLikes.levelId, levelId), eq(levelLikes.userId, userId))); + + return { liked: !!like }; + } + + async recordPlay(levelId: string, dto: RecordPlayDto, userId?: string) { + const [play] = await this.db + .insert(levelPlays) + .values({ + levelId, + userId: userId || null, + completed: dto.completed ?? false, + completionTime: dto.completionTime, + }) + .returning(); + + // Increment play count + await this.db + .update(levels) + .set({ playCount: sql`${levels.playCount} + 1` }) + .where(eq(levels.id, levelId)); + + return play; + } + + async getLeaderboard(levelId: string, limit = 10) { + return this.db + .select({ + id: levelPlays.id, + userId: levelPlays.userId, + completionTime: levelPlays.completionTime, + createdAt: levelPlays.createdAt, + }) + .from(levelPlays) + .where( + and( + eq(levelPlays.levelId, levelId), + eq(levelPlays.completed, true), + ), + ) + .orderBy(levelPlays.completionTime) + .limit(limit); + } +} diff --git a/games/voxel-lava/apps/backend/src/main.ts b/games/voxel-lava/apps/backend/src/main.ts new file mode 100644 index 000000000..3be9561df --- /dev/null +++ b/games/voxel-lava/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:5180', // voxel-lava 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 || 3010; + await app.listen(port); + console.log(`Voxel-Lava backend running on http://localhost:${port}`); +} +bootstrap(); diff --git a/games/voxel-lava/apps/backend/tsconfig.json b/games/voxel-lava/apps/backend/tsconfig.json new file mode 100644 index 000000000..c2e2df495 --- /dev/null +++ b/games/voxel-lava/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/voxel-lava/.mcp.json b/games/voxel-lava/apps/web/.mcp.json similarity index 100% rename from games/voxel-lava/.mcp.json rename to games/voxel-lava/apps/web/.mcp.json diff --git a/games/voxel-lava/.npmrc b/games/voxel-lava/apps/web/.npmrc similarity index 100% rename from games/voxel-lava/.npmrc rename to games/voxel-lava/apps/web/.npmrc diff --git a/games/voxel-lava/.prettierignore b/games/voxel-lava/apps/web/.prettierignore similarity index 100% rename from games/voxel-lava/.prettierignore rename to games/voxel-lava/apps/web/.prettierignore diff --git a/games/voxel-lava/.prettierrc b/games/voxel-lava/apps/web/.prettierrc similarity index 100% rename from games/voxel-lava/.prettierrc rename to games/voxel-lava/apps/web/.prettierrc diff --git a/games/voxel-lava/eslint.config.js b/games/voxel-lava/apps/web/eslint.config.js similarity index 100% rename from games/voxel-lava/eslint.config.js rename to games/voxel-lava/apps/web/eslint.config.js diff --git a/games/voxel-lava/apps/web/package.json b/games/voxel-lava/apps/web/package.json new file mode 100644 index 000000000..891290774 --- /dev/null +++ b/games/voxel-lava/apps/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@voxel-lava/web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev --port 5180", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "format": "prettier --write .", + "lint": "prettier --check . && eslint ." + }, + "devDependencies": { + "@eslint/compat": "^1.2.5", + "@eslint/js": "^9.18.0", + "@sveltejs/adapter-auto": "^6.0.0", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/three": "^0.176.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-svelte": "^3.0.0", + "globals": "^16.0.0", + "prettier": "^3.4.2", + "prettier-plugin-svelte": "^3.3.3", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.0.0", + "typescript-eslint": "^8.20.0", + "vite": "^6.2.6" + }, + "dependencies": { + "three": "^0.176.0" + } +} diff --git a/games/voxel-lava/src/app.d.ts b/games/voxel-lava/apps/web/src/app.d.ts similarity index 100% rename from games/voxel-lava/src/app.d.ts rename to games/voxel-lava/apps/web/src/app.d.ts diff --git a/games/voxel-lava/src/app.html b/games/voxel-lava/apps/web/src/app.html similarity index 100% rename from games/voxel-lava/src/app.html rename to games/voxel-lava/apps/web/src/app.html diff --git a/games/voxel-lava/src/lib/BlockTypes.ts b/games/voxel-lava/apps/web/src/lib/BlockTypes.ts similarity index 100% rename from games/voxel-lava/src/lib/BlockTypes.ts rename to games/voxel-lava/apps/web/src/lib/BlockTypes.ts diff --git a/games/voxel-lava/src/lib/PlayerController.ts b/games/voxel-lava/apps/web/src/lib/PlayerController.ts similarity index 100% rename from games/voxel-lava/src/lib/PlayerController.ts rename to games/voxel-lava/apps/web/src/lib/PlayerController.ts diff --git a/games/voxel-lava/apps/web/src/lib/api/client.ts b/games/voxel-lava/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..cf8f1c72a --- /dev/null +++ b/games/voxel-lava/apps/web/src/lib/api/client.ts @@ -0,0 +1,236 @@ +// API Client Configuration +const API_URL = import.meta.env.PUBLIC_VOXEL_LAVA_API_URL || 'http://localhost:3010'; +const AUTH_URL = import.meta.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001'; + +// Token storage +const TOKEN_KEY = 'voxel_lava_token'; +const REFRESH_TOKEN_KEY = 'voxel_lava_refresh_token'; +const USER_KEY = 'voxel_lava_user'; + +export interface User { + userId: string; + email: string; + role: string; + name?: string; +} + +export interface ApiError { + message: string; + statusCode: number; +} + +// Token management +export function getToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + if (typeof window === 'undefined') return; + localStorage.setItem(TOKEN_KEY, token); +} + +export function getRefreshToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem(REFRESH_TOKEN_KEY); +} + +export function setRefreshToken(token: string): void { + if (typeof window === 'undefined') return; + localStorage.setItem(REFRESH_TOKEN_KEY, token); +} + +export function getStoredUser(): User | null { + if (typeof window === 'undefined') return null; + const user = localStorage.getItem(USER_KEY); + return user ? JSON.parse(user) : null; +} + +export function setStoredUser(user: User): void { + if (typeof window === 'undefined') return; + localStorage.setItem(USER_KEY, JSON.stringify(user)); +} + +export function clearAuth(): void { + if (typeof window === 'undefined') return; + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_KEY); +} + +// API request helper +export async function apiRequest( + endpoint: string, + options: RequestInit = {}, + useAuthUrl = false +): Promise { + const baseUrl = useAuthUrl ? AUTH_URL : API_URL; + const url = `${baseUrl}${endpoint}`; + + const token = getToken(); + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + }; + + if (token) { + (headers as Record)['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw { + message: error.message || 'Request failed', + statusCode: response.status, + } as ApiError; + } + + return response.json(); +} + +// Auth API +export const authApi = { + async login(email: string, password: string) { + const response = await apiRequest<{ + accessToken: string; + refreshToken: string; + user: User; + }>('/api/v1/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + }, true); + + setToken(response.accessToken); + setRefreshToken(response.refreshToken); + setStoredUser(response.user); + + return response; + }, + + async register(email: string, password: string, name?: string) { + const response = await apiRequest<{ + accessToken: string; + refreshToken: string; + user: User; + }>('/api/v1/auth/register', { + method: 'POST', + body: JSON.stringify({ email, password, name }), + }, true); + + setToken(response.accessToken); + setRefreshToken(response.refreshToken); + setStoredUser(response.user); + + return response; + }, + + async logout() { + try { + await apiRequest('/api/v1/auth/logout', { method: 'POST' }, true); + } catch { + // Ignore errors during logout + } + clearAuth(); + }, + + async refreshAuth() { + const refreshToken = getRefreshToken(); + if (!refreshToken) throw new Error('No refresh token'); + + const response = await apiRequest<{ + accessToken: string; + refreshToken: string; + }>('/api/v1/auth/refresh', { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }, true); + + setToken(response.accessToken); + setRefreshToken(response.refreshToken); + + return response; + }, + + async resetPassword(email: string) { + return apiRequest('/api/v1/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ email }), + }, true); + }, + + async validate() { + return apiRequest<{ valid: boolean; payload: User }>('/api/v1/auth/validate', { + method: 'POST', + body: JSON.stringify({ token: getToken() }), + }, true); + }, +}; + +// Levels API +export const levelsApi = { + async getPublicLevels(page = 1, limit = 20) { + return apiRequest<{ + items: any[]; + total: number; + page: number; + limit: number; + totalPages: number; + }>(`/api/levels/public?page=${page}&limit=${limit}`); + }, + + async getUserLevels() { + return apiRequest('/api/levels'); + }, + + async getLevel(id: string) { + return apiRequest(`/api/levels/${id}`); + }, + + async createLevel(data: any) { + return apiRequest('/api/levels', { + method: 'POST', + body: JSON.stringify(data), + }); + }, + + async updateLevel(id: string, data: any) { + return apiRequest(`/api/levels/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + }, + + async deleteLevel(id: string) { + return apiRequest<{ success: boolean }>(`/api/levels/${id}`, { + method: 'DELETE', + }); + }, + + async toggleLike(id: string) { + return apiRequest<{ liked: boolean }>(`/api/levels/${id}/like`, { + method: 'POST', + }); + }, + + async hasLiked(id: string) { + return apiRequest<{ liked: boolean }>(`/api/levels/${id}/liked`); + }, + + async recordPlay(id: string, completed: boolean, completionTime?: number) { + return apiRequest(`/api/levels/${id}/play`, { + method: 'POST', + body: JSON.stringify({ completed, completionTime }), + }); + }, + + async getLeaderboard(id: string, limit = 10) { + return apiRequest(`/api/levels/${id}/leaderboard?limit=${limit}`); + }, +}; + +export { API_URL, AUTH_URL }; diff --git a/games/voxel-lava/src/lib/components/BlockButton.svelte b/games/voxel-lava/apps/web/src/lib/components/BlockButton.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/BlockButton.svelte rename to games/voxel-lava/apps/web/src/lib/components/BlockButton.svelte diff --git a/games/voxel-lava/src/lib/components/GameCanvas.svelte b/games/voxel-lava/apps/web/src/lib/components/GameCanvas.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/GameCanvas.svelte rename to games/voxel-lava/apps/web/src/lib/components/GameCanvas.svelte diff --git a/games/voxel-lava/src/lib/components/auth/AuthButton.svelte b/games/voxel-lava/apps/web/src/lib/components/auth/AuthButton.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/auth/AuthButton.svelte rename to games/voxel-lava/apps/web/src/lib/components/auth/AuthButton.svelte diff --git a/games/voxel-lava/src/lib/components/auth/AuthModal.svelte b/games/voxel-lava/apps/web/src/lib/components/auth/AuthModal.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/auth/AuthModal.svelte rename to games/voxel-lava/apps/web/src/lib/components/auth/AuthModal.svelte diff --git a/games/voxel-lava/src/lib/components/auth/Login.svelte b/games/voxel-lava/apps/web/src/lib/components/auth/Login.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/auth/Login.svelte rename to games/voxel-lava/apps/web/src/lib/components/auth/Login.svelte diff --git a/games/voxel-lava/src/lib/components/auth/PasswordReset.svelte b/games/voxel-lava/apps/web/src/lib/components/auth/PasswordReset.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/auth/PasswordReset.svelte rename to games/voxel-lava/apps/web/src/lib/components/auth/PasswordReset.svelte diff --git a/games/voxel-lava/src/lib/components/auth/Register.svelte b/games/voxel-lava/apps/web/src/lib/components/auth/Register.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/auth/Register.svelte rename to games/voxel-lava/apps/web/src/lib/components/auth/Register.svelte diff --git a/games/voxel-lava/src/lib/components/auth/UserProfile.svelte b/games/voxel-lava/apps/web/src/lib/components/auth/UserProfile.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/auth/UserProfile.svelte rename to games/voxel-lava/apps/web/src/lib/components/auth/UserProfile.svelte diff --git a/games/voxel-lava/src/lib/components/game-ui/CircleButton.svelte b/games/voxel-lava/apps/web/src/lib/components/game-ui/CircleButton.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/game-ui/CircleButton.svelte rename to games/voxel-lava/apps/web/src/lib/components/game-ui/CircleButton.svelte diff --git a/games/voxel-lava/src/lib/components/level/SaveLevelModal.svelte b/games/voxel-lava/apps/web/src/lib/components/level/SaveLevelModal.svelte similarity index 100% rename from games/voxel-lava/src/lib/components/level/SaveLevelModal.svelte rename to games/voxel-lava/apps/web/src/lib/components/level/SaveLevelModal.svelte diff --git a/games/voxel-lava/src/lib/game/levels.js b/games/voxel-lava/apps/web/src/lib/game/levels.js similarity index 100% rename from games/voxel-lava/src/lib/game/levels.js rename to games/voxel-lava/apps/web/src/lib/game/levels.js diff --git a/games/voxel-lava/src/lib/game/physics.js b/games/voxel-lava/apps/web/src/lib/game/physics.js similarity index 100% rename from games/voxel-lava/src/lib/game/physics.js rename to games/voxel-lava/apps/web/src/lib/game/physics.js diff --git a/games/voxel-lava/src/lib/game/playerControls.js b/games/voxel-lava/apps/web/src/lib/game/playerControls.js similarity index 100% rename from games/voxel-lava/src/lib/game/playerControls.js rename to games/voxel-lava/apps/web/src/lib/game/playerControls.js diff --git a/games/voxel-lava/src/lib/game/voxelUtils.js b/games/voxel-lava/apps/web/src/lib/game/voxelUtils.js similarity index 100% rename from games/voxel-lava/src/lib/game/voxelUtils.js rename to games/voxel-lava/apps/web/src/lib/game/voxelUtils.js diff --git a/games/voxel-lava/src/lib/index.ts b/games/voxel-lava/apps/web/src/lib/index.ts similarity index 100% rename from games/voxel-lava/src/lib/index.ts rename to games/voxel-lava/apps/web/src/lib/index.ts diff --git a/games/voxel-lava/src/lib/services/AuthService.ts b/games/voxel-lava/apps/web/src/lib/services/AuthService.ts similarity index 60% rename from games/voxel-lava/src/lib/services/AuthService.ts rename to games/voxel-lava/apps/web/src/lib/services/AuthService.ts index 9b917f3b5..3cb742037 100644 --- a/games/voxel-lava/src/lib/services/AuthService.ts +++ b/games/voxel-lava/apps/web/src/lib/services/AuthService.ts @@ -1,7 +1,14 @@ -import { pb } from '../pocketbase'; +import { + authApi, + getToken, + getStoredUser, + clearAuth, + type User, + type ApiError, +} from '../api/client'; /** - * Service zur Verwaltung der Benutzerauthentifizierung mit PocketBase + * Service zur Verwaltung der Benutzerauthentifizierung mit Mana Core Auth */ export class AuthService { /** @@ -13,19 +20,7 @@ export class AuthService { */ static async register(email: string, password: string, name?: string): Promise { try { - const data = { - email, - password, - passwordConfirm: password, - name: name || email.split('@')[0], - emailVisibility: true - }; - - const record = await pb.collection('users').create(data); - - // Automatisch anmelden nach erfolgreicher Registrierung - await pb.collection('users').authWithPassword(email, password); - + await authApi.register(email, password, name || email.split('@')[0]); return true; } catch (error) { console.error('Fehler bei der Registrierung:', error); @@ -41,8 +36,8 @@ export class AuthService { */ static async login(email: string, password: string): Promise { try { - const authData = await pb.collection('users').authWithPassword(email, password); - return !!authData.token; + await authApi.login(email, password); + return true; } catch (error) { console.error('Fehler bei der Anmeldung:', error); return false; @@ -55,11 +50,12 @@ export class AuthService { */ static async logout(): Promise { try { - pb.authStore.clear(); + await authApi.logout(); return true; } catch (error) { console.error('Fehler bei der Abmeldung:', error); - return false; + clearAuth(); + return true; // Always clear local auth even if API fails } } @@ -67,12 +63,11 @@ export class AuthService { * Prüft, ob ein Benutzer angemeldet ist * @returns Der angemeldete Benutzer oder null, wenn kein Benutzer angemeldet ist */ - static getCurrentUser() { + static getCurrentUser(): User | null { try { - if (pb.authStore.isValid) { - return pb.authStore.model; - } - return null; + const token = getToken(); + if (!token) return null; + return getStoredUser(); } catch (error) { console.error('Fehler beim Abrufen des aktuellen Benutzers:', error); return null; @@ -86,7 +81,7 @@ export class AuthService { */ static async resetPassword(email: string): Promise { try { - await pb.collection('users').requestPasswordReset(email); + await authApi.resetPassword(email); return true; } catch (error) { console.error('Fehler beim Zurücksetzen des Passworts:', error); @@ -94,41 +89,14 @@ export class AuthService { } } - /** - * Aktualisiert das Passwort des aktuellen Benutzers - * @param newPassword Das neue Passwort - * @returns true, wenn das Passwort erfolgreich aktualisiert wurde, sonst false - */ - static async updatePassword(newPassword: string): Promise { - try { - const user = pb.authStore.model; - if (!user) { - throw new Error('Kein Benutzer angemeldet'); - } - - await pb.collection('users').update(user.id, { - password: newPassword, - passwordConfirm: newPassword - }); - - return true; - } catch (error) { - console.error('Fehler beim Aktualisieren des Passworts:', error); - return false; - } - } - /** * Aktualisiert automatisch das Auth-Token * @returns true, wenn das Token erfolgreich aktualisiert wurde, sonst false */ static async refreshAuth(): Promise { try { - if (pb.authStore.isValid) { - await pb.collection('users').authRefresh(); - return true; - } - return false; + await authApi.refreshAuth(); + return true; } catch (error) { console.error('Fehler beim Aktualisieren des Auth-Tokens:', error); return false; @@ -140,7 +108,7 @@ export class AuthService { * @returns true, wenn die Sitzung gültig ist, sonst false */ static isAuthenticated(): boolean { - return pb.authStore.isValid; + return !!getToken(); } /** @@ -148,8 +116,8 @@ export class AuthService { * @returns Die User-ID oder null */ static getUserId(): string | null { - const user = pb.authStore.model; - return user?.id || null; + const user = getStoredUser(); + return user?.userId || null; } /** @@ -157,10 +125,10 @@ export class AuthService { * @returns Die E-Mail oder null */ static getUserEmail(): string | null { - const user = pb.authStore.model; + const user = getStoredUser(); return user?.email || null; } } // Default export für Kompatibilität -export default AuthService; \ No newline at end of file +export default AuthService; diff --git a/games/voxel-lava/apps/web/src/lib/services/LevelService.ts b/games/voxel-lava/apps/web/src/lib/services/LevelService.ts new file mode 100644 index 000000000..4ecf61df2 --- /dev/null +++ b/games/voxel-lava/apps/web/src/lib/services/LevelService.ts @@ -0,0 +1,390 @@ +import { levelsApi, getToken } from '../api/client'; + +// Typdefinitionen +interface Block { + x: number; + y: number; + z: number; + type: string; + isSpawnPoint?: boolean; + isGoal?: boolean; +} + +interface WorldSize { + width: number; + height: number; + depth: number; +} + +interface SpawnPoint { + x: number; + y: number; + z: number; +} + +interface LevelMetadata { + id: string; + name: string; + description: string; + userId: string | null; + createdAt: string | null; + updatedAt: string | null; + isPublic?: boolean | null; + playCount: number; + likesCount: number; + difficulty?: string | undefined; + tags?: string[]; + thumbnailUrl?: string | undefined; +} + +interface Level extends Partial { + id?: string; + name: string; + blocks: Block[]; + spawnPoint: SpawnPoint | null; + worldSize: WorldSize; +} + +/** + * Service zur Verwaltung von Levels mit NestJS Backend API + */ +export class LevelService { + /** + * Speichert ein Level in der Datenbank + * @param level Das zu speichernde Level + * @returns Die ID des gespeicherten Levels + */ + static async saveLevel(level: Level): Promise { + try { + if (!getToken()) { + throw new Error('Du musst angemeldet sein, um ein Level zu speichern'); + } + + const levelData = { + name: level.name, + description: level.description || '', + voxelData: this.convertBlocksToVoxelData(level.blocks), + spawnPoint: level.spawnPoint, + worldSize: level.worldSize, + isPublic: level.isPublic || false, + difficulty: level.difficulty || null, + tags: level.tags || [], + thumbnailUrl: level.thumbnailUrl || null, + }; + + if (level.id) { + const updated = await levelsApi.updateLevel(level.id, levelData); + return updated.id; + } else { + const created = await levelsApi.createLevel(levelData); + return created.id; + } + } catch (error) { + console.error('Fehler beim Speichern des Levels:', error); + return null; + } + } + + /** + * Lädt ein Level aus der Datenbank + * @param levelId Die ID des zu ladenden Levels + * @returns Das geladene Level oder null, wenn es nicht gefunden wurde + */ + static async loadLevel(levelId: string): Promise { + try { + const record = await levelsApi.getLevel(levelId); + + if (!record) return null; + + return { + id: record.id, + name: record.name, + description: record.description || '', + blocks: this.convertVoxelDataToBlocks(record.voxelData), + spawnPoint: record.spawnPoint, + worldSize: record.worldSize, + isPublic: record.isPublic || false, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + userId: record.userId, + playCount: record.playCount || 0, + likesCount: record.likesCount || 0, + difficulty: record.difficulty || undefined, + tags: record.tags || [], + thumbnailUrl: record.thumbnailUrl || undefined, + }; + } catch (error) { + console.error('Fehler beim Laden des Levels:', error); + return null; + } + } + + /** + * Lädt alle öffentlichen Levels + * @param page Seitennummer (startet bei 1) + * @param perPage Anzahl der Einträge pro Seite + * @returns Liste der Level-Metadaten + */ + static async getPublicLevels(page = 1, perPage = 20): Promise { + try { + const response = await levelsApi.getPublicLevels(page, perPage); + + return response.items.map((record) => ({ + id: record.id, + name: record.name, + description: record.description || '', + userId: record.userId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + playCount: record.playCount || 0, + likesCount: record.likesCount || 0, + difficulty: record.difficulty || undefined, + tags: record.tags || [], + thumbnailUrl: record.thumbnailUrl || undefined, + })); + } catch (error) { + console.error('Fehler beim Laden der öffentlichen Levels:', error); + return []; + } + } + + /** + * Lädt alle Levels des aktuellen Benutzers + * @returns Liste der Level-Metadaten + */ + static async getUserLevels(): Promise { + try { + if (!getToken()) { + throw new Error('Du musst angemeldet sein, um deine Levels zu sehen'); + } + + const records = await levelsApi.getUserLevels(); + + return records.map((record) => ({ + id: record.id, + name: record.name, + description: record.description || '', + userId: record.userId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + isPublic: record.isPublic, + playCount: record.playCount || 0, + likesCount: record.likesCount || 0, + difficulty: record.difficulty || undefined, + tags: record.tags || [], + thumbnailUrl: record.thumbnailUrl || undefined, + })); + } catch (error) { + console.error('Fehler beim Laden der Benutzer-Levels:', error); + return []; + } + } + + /** + * Löscht ein Level aus der Datenbank + * @param levelId Die ID des zu löschenden Levels + * @returns true, wenn das Level erfolgreich gelöscht wurde, sonst false + */ + static async deleteLevel(levelId: string): Promise { + try { + if (!getToken()) { + throw new Error('Du musst angemeldet sein, um ein Level zu löschen'); + } + + await levelsApi.deleteLevel(levelId); + return true; + } catch (error) { + console.error('Fehler beim Löschen des Levels:', error); + return false; + } + } + + /** + * Setzt einen "Like" für ein Level + * @param levelId Die ID des Levels + * @returns true, wenn der Like hinzugefügt wurde, false wenn entfernt + */ + static async likeLevel(levelId: string): Promise { + try { + if (!getToken()) { + throw new Error('Du musst angemeldet sein, um ein Level zu liken'); + } + + const result = await levelsApi.toggleLike(levelId); + return result.liked; + } catch (error) { + console.error('Fehler beim Liken des Levels:', error); + return false; + } + } + + /** + * Prüft, ob der aktuelle Benutzer ein Level geliked hat + * @param levelId Die ID des Levels + * @returns true, wenn der Benutzer das Level geliked hat, sonst false + */ + static async hasLiked(levelId: string): Promise { + try { + if (!getToken()) return false; + + const result = await levelsApi.hasLiked(levelId); + return result.liked; + } catch (error) { + console.error('Fehler beim Prüfen des Likes:', error); + return false; + } + } + + /** + * Zeichnet einen Spielversuch auf + * @param levelId Die ID des Levels + * @param completed Ob das Level abgeschlossen wurde + * @param completionTime Die Zeit in Sekunden (optional, nur wenn completed = true) + * @returns true, wenn der Versuch erfolgreich aufgezeichnet wurde, sonst false + */ + static async recordPlay( + levelId: string, + completed: boolean, + completionTime?: number + ): Promise { + try { + await levelsApi.recordPlay(levelId, completed, completionTime); + return true; + } catch (error) { + console.error('Fehler beim Aufzeichnen des Spielversuchs:', error); + return false; + } + } + + /** + * Lädt die Bestenliste für ein Level + * @param levelId Die ID des Levels + * @param limit Maximale Anzahl der Einträge + * @returns Liste der besten Completion-Times + */ + static async getLeaderboard(levelId: string, limit = 10): Promise { + try { + const records = await levelsApi.getLeaderboard(levelId, limit); + + return records.map((record) => ({ + userId: record.userId, + userName: 'Spieler', // User name not available without expand + completionTime: record.completionTime, + createdAt: record.createdAt, + })); + } catch (error) { + console.error('Fehler beim Laden der Bestenliste:', error); + return []; + } + } + + /** + * Konvertiert die Blöcke in ein optimiertes JSON-Format für die Datenbank + */ + private static convertBlocksToVoxelData(blocks: Block[]): any { + const validBlocks = blocks.filter( + (block) => + block && + block.x !== undefined && + block.y !== undefined && + block.z !== undefined && + block.type + ); + + if (validBlocks.length === 0) { + return {}; + } + + const voxelData: any = {}; + + validBlocks.forEach((block) => { + const key = `${block.x},${block.y},${block.z}`; + voxelData[key] = { + type: block.type, + isSpawnPoint: block.isSpawnPoint || false, + isGoal: block.isGoal || false, + }; + }); + + return voxelData; + } + + /** + * Konvertiert das JSON-Format aus der Datenbank in Blöcke + */ + private static convertVoxelDataToBlocks(voxelData: any): Block[] { + const blocks: Block[] = []; + + if (!voxelData || typeof voxelData !== 'object') { + return blocks; + } + + // Prüfen, ob es das neue optimierte Format ist + if (voxelData.format === 'v2' && voxelData.types) { + Object.entries(voxelData.types).forEach(([type, positions]: [string, any]) => { + if (Array.isArray(positions)) { + positions.forEach((pos: number[]) => { + if (pos.length >= 3) { + blocks.push({ + x: pos[0], + y: pos[1], + z: pos[2], + type, + isSpawnPoint: false, + isGoal: false, + }); + } + }); + } + }); + + if (voxelData.special) { + if (voxelData.special.spawn) { + const spawn = voxelData.special.spawn; + const spawnBlock = blocks.find( + (b) => b.x === spawn.x && b.y === spawn.y && b.z === spawn.z + ); + if (spawnBlock) { + spawnBlock.isSpawnPoint = true; + } + } + + if (voxelData.special.goals && Array.isArray(voxelData.special.goals)) { + voxelData.special.goals.forEach((goal: any) => { + const goalBlock = blocks.find( + (b) => b.x === goal.x && b.y === goal.y && b.z === goal.z + ); + if (goalBlock) { + goalBlock.isGoal = true; + } + }); + } + } + } else { + // Altes Format: Position als Key + Object.entries(voxelData).forEach(([key, value]: [string, any]) => { + if (key === 'format' || key === 'types' || key === 'special') { + return; + } + + const [x, y, z] = key.split(',').map(Number); + + if (!isNaN(x) && !isNaN(y) && !isNaN(z) && value && value.type) { + blocks.push({ + x, + y, + z, + type: value.type, + isSpawnPoint: value.isSpawnPoint || false, + isGoal: value.isGoal || false, + }); + } + }); + } + + return blocks; + } +} + +// Default export für Kompatibilität +export default LevelService; diff --git a/games/voxel-lava/src/lib/stores.js b/games/voxel-lava/apps/web/src/lib/stores.js similarity index 100% rename from games/voxel-lava/src/lib/stores.js rename to games/voxel-lava/apps/web/src/lib/stores.js diff --git a/games/voxel-lava/src/lib/types/level.types.ts b/games/voxel-lava/apps/web/src/lib/types/level.types.ts similarity index 100% rename from games/voxel-lava/src/lib/types/level.types.ts rename to games/voxel-lava/apps/web/src/lib/types/level.types.ts diff --git a/games/voxel-lava/src/routes/+page.svelte b/games/voxel-lava/apps/web/src/routes/+page.svelte similarity index 100% rename from games/voxel-lava/src/routes/+page.svelte rename to games/voxel-lava/apps/web/src/routes/+page.svelte diff --git a/games/voxel-lava/static/favicon.png b/games/voxel-lava/apps/web/static/favicon.png similarity index 100% rename from games/voxel-lava/static/favicon.png rename to games/voxel-lava/apps/web/static/favicon.png diff --git a/games/voxel-lava/svelte.config.js b/games/voxel-lava/apps/web/svelte.config.js similarity index 100% rename from games/voxel-lava/svelte.config.js rename to games/voxel-lava/apps/web/svelte.config.js diff --git a/games/voxel-lava/tsconfig.json b/games/voxel-lava/apps/web/tsconfig.json similarity index 100% rename from games/voxel-lava/tsconfig.json rename to games/voxel-lava/apps/web/tsconfig.json diff --git a/games/voxel-lava/vite.config.ts b/games/voxel-lava/apps/web/vite.config.ts similarity index 100% rename from games/voxel-lava/vite.config.ts rename to games/voxel-lava/apps/web/vite.config.ts diff --git a/games/voxel-lava/package.json b/games/voxel-lava/package.json index b67836411..a658e2e1b 100644 --- a/games/voxel-lava/package.json +++ b/games/voxel-lava/package.json @@ -1,40 +1,16 @@ { - "name": "voxel-lava", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "format": "prettier --write .", - "lint": "prettier --check . && eslint ." - }, - "devDependencies": { - "@eslint/compat": "^1.2.5", - "@eslint/js": "^9.18.0", - "@sveltejs/adapter-auto": "^6.0.0", - "@sveltejs/kit": "^2.16.0", - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@types/three": "^0.176.0", - "eslint": "^9.18.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-svelte": "^3.0.0", - "globals": "^16.0.0", - "prettier": "^3.4.2", - "prettier-plugin-svelte": "^3.3.3", - "svelte": "^5.0.0", - "svelte-check": "^4.0.0", - "typescript": "^5.0.0", - "typescript-eslint": "^8.20.0", - "vite": "^6.2.6" - }, - "dependencies": { - "axios": "^1.11.0", - "pocketbase": "^0.26.2", - "three": "^0.176.0" - } + "name": "voxel-lava", + "version": "1.0.0", + "private": true, + "description": "3D Voxel Lava Game - Build and play voxel levels", + "scripts": { + "dev": "turbo run dev", + "build": "turbo run build", + "type-check": "turbo run type-check", + "lint": "turbo run lint", + "clean": "turbo run clean" + }, + "devDependencies": { + "turbo": "^2.5.4" + } } diff --git a/games/voxel-lava/pnpm-workspace.yaml b/games/voxel-lava/pnpm-workspace.yaml new file mode 100644 index 000000000..e9b0dad63 --- /dev/null +++ b/games/voxel-lava/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'apps/*' + - 'packages/*' diff --git a/games/voxel-lava/src/lib/pocketbase.ts b/games/voxel-lava/src/lib/pocketbase.ts deleted file mode 100644 index da340f468..000000000 --- a/games/voxel-lava/src/lib/pocketbase.ts +++ /dev/null @@ -1,14 +0,0 @@ -import PocketBase from 'pocketbase'; - -// PocketBase Instanz mit deiner Domain -export const pb = new PocketBase('https://pb.voxelava.com'); - -// Auto-refresh für Auth Token -pb.authStore.onChange(() => { - // Token wird automatisch erneuert -}); - -// Optional: SSR Support für SvelteKit -export function createPocketBase() { - return new PocketBase('https://pb.voxelava.com'); -} \ No newline at end of file diff --git a/games/voxel-lava/src/lib/services/LevelService.ts b/games/voxel-lava/src/lib/services/LevelService.ts deleted file mode 100644 index 2a737b08c..000000000 --- a/games/voxel-lava/src/lib/services/LevelService.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { pb } from '../pocketbase'; - -// Typdefinitionen -interface Block { - x: number; - y: number; - z: number; - type: string; - isSpawnPoint?: boolean; - isGoal?: boolean; -} - -interface WorldSize { - width: number; - height: number; - depth: number; -} - -interface SpawnPoint { - x: number; - y: number; - z: number; -} - -interface LevelMetadata { - id: string; - name: string; - description: string; - user_id: string | null; - created: string | null; - updated: string | null; - is_public?: boolean | null; - play_count: number; - likes_count: number; - difficulty?: string | undefined; - tags?: string[]; - thumbnail_url?: string | undefined; -} - -interface Level extends Partial { - id?: string; - name: string; - blocks: Block[]; - spawnPoint: SpawnPoint | null; - worldSize: WorldSize; -} - -/** - * Service zur Verwaltung von Levels in PocketBase - */ -export class LevelService { - /** - * Speichert ein Level in der Datenbank - * @param level Das zu speichernde Level - * @returns Die ID des gespeicherten Levels - */ - static async saveLevel(level: Level): Promise { - try { - // Prüfen, ob der Benutzer angemeldet ist - const user = pb.authStore.model; - if (!user) { - throw new Error('Du musst angemeldet sein, um ein Level zu speichern'); - } - - // Level-Daten für die Datenbank vorbereiten - const levelData = { - name: level.name, - description: level.description || '', - user_id: user.id, - voxel_data: this.convertBlocksToVoxelData(level.blocks), - spawn_point: level.spawnPoint, - world_size: level.worldSize, - is_public: level.is_public || false, - difficulty: level.difficulty || null, - tags: level.tags || [], - play_count: level.play_count || 0, - likes_count: level.likes_count || 0, - thumbnail_url: level.thumbnail_url || null - }; - - // Prüfen, ob das Level bereits existiert - if (level.id) { - // Level aktualisieren - const record = await pb.collection('levels').update(level.id, levelData); - return record.id; - } else { - // Neues Level erstellen - const record = await pb.collection('levels').create(levelData); - return record.id; - } - } catch (error) { - console.error('Fehler beim Speichern des Levels:', error); - return null; - } - } - - /** - * Lädt ein Level aus der Datenbank - * @param levelId Die ID des zu ladenden Levels - * @returns Das geladene Level oder null, wenn es nicht gefunden wurde - */ - static async loadLevel(levelId: string): Promise { - try { - const record = await pb.collection('levels').getOne(levelId); - - if (!record) return null; - - // Level-Daten konvertieren - return { - id: record.id, - name: record.name, - description: record.description || '', - blocks: this.convertVoxelDataToBlocks(record.voxel_data), - spawnPoint: record.spawn_point, - worldSize: record.world_size, - is_public: record.is_public || false, - created: record.created, - updated: record.updated, - user_id: record.user_id, - play_count: record.play_count || 0, - likes_count: record.likes_count || 0, - difficulty: record.difficulty || undefined, - tags: record.tags || [], - thumbnail_url: record.thumbnail_url || undefined, - }; - } catch (error) { - console.error('Fehler beim Laden des Levels:', error); - return null; - } - } - - /** - * Lädt alle öffentlichen Levels - * @param page Seitennummer (startet bei 1) - * @param perPage Anzahl der Einträge pro Seite - * @returns Liste der Level-Metadaten - */ - static async getPublicLevels(page = 1, perPage = 20): Promise { - try { - const records = await pb.collection('levels').getList(page, perPage, { - filter: 'is_public = true', - sort: '-created', - }); - - return records.items.map(record => ({ - id: record.id, - name: record.name, - description: record.description || '', - user_id: record.user_id, - created: record.created, - updated: record.updated, - play_count: record.play_count || 0, - likes_count: record.likes_count || 0, - difficulty: record.difficulty || undefined, - tags: record.tags || [], - thumbnail_url: record.thumbnail_url || undefined, - })); - } catch (error) { - console.error('Fehler beim Laden der öffentlichen Levels:', error); - return []; - } - } - - /** - * Lädt alle Levels des aktuellen Benutzers - * @returns Liste der Level-Metadaten - */ - static async getUserLevels(): Promise { - try { - // Prüfen, ob der Benutzer angemeldet ist - const user = pb.authStore.model; - if (!user) { - throw new Error('Du musst angemeldet sein, um deine Levels zu sehen'); - } - - const records = await pb.collection('levels').getFullList({ - filter: `user_id = "${user.id}"`, - sort: '-updated', - }); - - return records.map(record => ({ - id: record.id, - name: record.name, - description: record.description || '', - user_id: user.id, - created: record.created, - updated: record.updated, - is_public: record.is_public, - play_count: record.play_count || 0, - likes_count: record.likes_count || 0, - difficulty: record.difficulty || undefined, - tags: record.tags || [], - thumbnail_url: record.thumbnail_url || undefined, - })); - } catch (error) { - console.error('Fehler beim Laden der Benutzer-Levels:', error); - return []; - } - } - - /** - * Löscht ein Level aus der Datenbank - * @param levelId Die ID des zu löschenden Levels - * @returns true, wenn das Level erfolgreich gelöscht wurde, sonst false - */ - static async deleteLevel(levelId: string): Promise { - try { - // Prüfen, ob der Benutzer angemeldet ist - const user = pb.authStore.model; - if (!user) { - throw new Error('Du musst angemeldet sein, um ein Level zu löschen'); - } - - // Erst prüfen, ob das Level dem User gehört - const level = await pb.collection('levels').getOne(levelId); - if (level.user_id !== user.id) { - throw new Error('Du kannst nur deine eigenen Levels löschen'); - } - - await pb.collection('levels').delete(levelId); - return true; - } catch (error) { - console.error('Fehler beim Löschen des Levels:', error); - return false; - } - } - - /** - * Setzt einen "Like" für ein Level - * @param levelId Die ID des Levels - * @returns true, wenn der Like erfolgreich gesetzt wurde, sonst false - */ - static async likeLevel(levelId: string): Promise { - try { - // Prüfen, ob der Benutzer angemeldet ist - const user = pb.authStore.model; - if (!user) { - throw new Error('Du musst angemeldet sein, um ein Level zu liken'); - } - - // Prüfen, ob der Benutzer das Level bereits geliked hat - const existingLikes = await pb.collection('level_likes').getList(1, 1, { - filter: `level_id = "${levelId}" && user_id = "${user.id}"` - }); - - if (existingLikes.items.length > 0) { - // Like entfernen - await pb.collection('level_likes').delete(existingLikes.items[0].id); - - // Likes-Count im Level aktualisieren - const level = await pb.collection('levels').getOne(levelId); - await pb.collection('levels').update(levelId, { - likes_count: Math.max(0, (level.likes_count || 0) - 1) - }); - - return false; // Like wurde entfernt - } else { - // Like hinzufügen - await pb.collection('level_likes').create({ - level_id: levelId, - user_id: user.id - }); - - // Likes-Count im Level aktualisieren - const level = await pb.collection('levels').getOne(levelId); - await pb.collection('levels').update(levelId, { - likes_count: (level.likes_count || 0) + 1 - }); - - return true; // Like wurde hinzugefügt - } - } catch (error) { - console.error('Fehler beim Liken des Levels:', error); - return false; - } - } - - /** - * Prüft, ob der aktuelle Benutzer ein Level geliked hat - * @param levelId Die ID des Levels - * @returns true, wenn der Benutzer das Level geliked hat, sonst false - */ - static async hasLiked(levelId: string): Promise { - try { - // Prüfen, ob der Benutzer angemeldet ist - const user = pb.authStore.model; - if (!user) return false; - - const likes = await pb.collection('level_likes').getList(1, 1, { - filter: `level_id = "${levelId}" && user_id = "${user.id}"` - }); - - return likes.items.length > 0; - } catch (error) { - console.error('Fehler beim Prüfen des Likes:', error); - return false; - } - } - - /** - * Zeichnet einen Spielversuch auf - * @param levelId Die ID des Levels - * @param completed Ob das Level abgeschlossen wurde - * @param completionTime Die Zeit in Sekunden (optional, nur wenn completed = true) - * @returns true, wenn der Versuch erfolgreich aufgezeichnet wurde, sonst false - */ - static async recordPlay(levelId: string, completed: boolean, completionTime?: number): Promise { - try { - // Prüfen, ob der Benutzer angemeldet ist - const user = pb.authStore.model; - - if (!user) { - // Für nicht angemeldete Benutzer nur den Play-Count erhöhen - const level = await pb.collection('levels').getOne(levelId); - await pb.collection('levels').update(levelId, { - play_count: (level.play_count || 0) + 1 - }); - return true; - } - - // Prüfen, ob bereits ein Spielversuch existiert - const existingPlays = await pb.collection('level_plays').getList(1, 1, { - filter: `level_id = "${levelId}" && user_id = "${user.id}"`, - sort: '-created' - }); - - if (existingPlays.items.length > 0) { - // Vorhandenen Spielversuch aktualisieren - const play = existingPlays.items[0]; - await pb.collection('level_plays').update(play.id, { - attempts: (play.attempts || 1) + 1, - completed: completed || play.completed, - completion_time: completed && completionTime ? completionTime : play.completion_time - }); - } else { - // Neuen Spielversuch erstellen - await pb.collection('level_plays').create({ - level_id: levelId, - user_id: user.id, - completed, - completion_time: completed ? completionTime : null, - attempts: 1 - }); - } - - // Play-Count im Level erhöhen - const level = await pb.collection('levels').getOne(levelId); - await pb.collection('levels').update(levelId, { - play_count: (level.play_count || 0) + 1 - }); - - return true; - } catch (error) { - console.error('Fehler beim Aufzeichnen des Spielversuchs:', error); - return false; - } - } - - /** - * Lädt die Bestenliste für ein Level - * @param levelId Die ID des Levels - * @param limit Maximale Anzahl der Einträge - * @returns Liste der besten Completion-Times - */ - static async getLeaderboard(levelId: string, limit = 10): Promise { - try { - const records = await pb.collection('level_plays').getList(1, limit, { - filter: `level_id = "${levelId}" && completed = true && completion_time > 0`, - sort: 'completion_time', - expand: 'user_id' - }); - - return records.items.map(record => ({ - user_id: record.user_id, - user_name: record.expand?.user_id?.name || 'Unbekannt', - completion_time: record.completion_time, - attempts: record.attempts || 1 - })); - } catch (error) { - console.error('Fehler beim Laden der Bestenliste:', error); - return []; - } - } - - /** - * Konvertiert die Blöcke in ein optimiertes JSON-Format für die Datenbank - * @param blocks Die zu konvertierenden Blöcke - * @returns Die konvertierten Blöcke im optimierten JSON-Format - */ - private static convertBlocksToVoxelData(blocks: Block[]): any { - // Filtere ungültige Blöcke heraus - const validBlocks = blocks.filter(block => - block && - block.x !== undefined && - block.y !== undefined && - block.z !== undefined && - block.type - ); - - if (validBlocks.length === 0) { - return {}; - } - - // Einfaches Format: Position als Key, Block-Daten als Value - const voxelData: any = {}; - - validBlocks.forEach(block => { - const key = `${block.x},${block.y},${block.z}`; - voxelData[key] = { - type: block.type, - isSpawnPoint: block.isSpawnPoint || false, - isGoal: block.isGoal || false - }; - }); - - return voxelData; - } - - /** - * Konvertiert das JSON-Format aus der Datenbank in Blöcke - * @param voxelData Die zu konvertierenden Daten im JSON-Format - * @returns Die konvertierten Blöcke - */ - private static convertVoxelDataToBlocks(voxelData: any): Block[] { - const blocks: Block[] = []; - - if (!voxelData || typeof voxelData !== 'object') { - return blocks; - } - - // Prüfen, ob es das neue optimierte Format ist - if (voxelData.format === 'v2' && voxelData.types) { - // Neues Format: Konvertiere zurück zu Blöcken - Object.entries(voxelData.types).forEach(([type, positions]: [string, any]) => { - if (Array.isArray(positions)) { - positions.forEach((pos: number[]) => { - if (pos.length >= 3) { - blocks.push({ - x: pos[0], - y: pos[1], - z: pos[2], - type, - isSpawnPoint: false, - isGoal: false - }); - } - }); - } - }); - - // Spezielle Blöcke hinzufügen - if (voxelData.special) { - if (voxelData.special.spawn) { - const spawn = voxelData.special.spawn; - const spawnBlock = blocks.find(b => b.x === spawn.x && b.y === spawn.y && b.z === spawn.z); - if (spawnBlock) { - spawnBlock.isSpawnPoint = true; - } - } - - if (voxelData.special.goals && Array.isArray(voxelData.special.goals)) { - voxelData.special.goals.forEach((goal: any) => { - const goalBlock = blocks.find(b => b.x === goal.x && b.y === goal.y && b.z === goal.z); - if (goalBlock) { - goalBlock.isGoal = true; - } - }); - } - } - } else { - // Altes Format: Position als Key - Object.entries(voxelData).forEach(([key, value]: [string, any]) => { - // Überspringe Metadaten-Keys - if (key === 'format' || key === 'types' || key === 'special') { - return; - } - - const [x, y, z] = key.split(',').map(Number); - - if (!isNaN(x) && !isNaN(y) && !isNaN(z) && value && value.type) { - blocks.push({ - x, - y, - z, - type: value.type, - isSpawnPoint: value.isSpawnPoint || false, - isGoal: value.isGoal || false - }); - } - }); - } - - return blocks; - } -} - -// Default export für Kompatibilität -export default LevelService; \ No newline at end of file diff --git a/games/voxel-lava/turbo.json b/games/voxel-lava/turbo.json new file mode 100644 index 000000000..71f2e31b2 --- /dev/null +++ b/games/voxel-lava/turbo.json @@ -0,0 +1,24 @@ +{ + "extends": ["//"], + "tasks": { + "dev": { + "cache": false, + "persistent": true + }, + "build": { + "dependsOn": ["^build"], + "outputs": [".svelte-kit/**", "dist/**"] + }, + "type-check": { + "dependsOn": ["^type-check"], + "outputs": [] + }, + "lint": { + "dependsOn": ["^lint"], + "outputs": [] + }, + "clean": { + "cache": false + } + } +} diff --git a/package.json b/package.json index fc5c0cf7d..5562bd0b6 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,13 @@ "reader:dev": "turbo run dev --filter=reader...", "dev:reader:mobile": "pnpm --filter @reader/mobile dev", + "voxel-lava:dev": "turbo run dev --filter=@voxel-lava/web --filter=@voxel-lava/backend", + "dev:voxel-lava:web": "pnpm --filter @voxel-lava/web dev", + "dev:voxel-lava:backend": "pnpm --filter @voxel-lava/backend start:dev", + "dev:voxel-lava:app": "turbo run dev --filter=@voxel-lava/web --filter=@voxel-lava/backend", + "voxel-lava:db:push": "pnpm --filter @voxel-lava/backend db:push", + "voxel-lava:db:studio": "pnpm --filter @voxel-lava/backend db:studio", + "docker:up": "docker compose -f docker-compose.dev.yml --env-file .env.development up -d postgres redis", "docker:up:auth": "docker compose -f docker-compose.dev.yml --env-file .env.development --profile auth up -d", "docker:up:chat": "docker compose -f docker-compose.dev.yml --env-file .env.development --profile chat up -d", diff --git a/scripts/generate-env.mjs b/scripts/generate-env.mjs index 426c89dff..33036ae3b 100644 --- a/scripts/generate-env.mjs +++ b/scripts/generate-env.mjs @@ -278,6 +278,36 @@ const APP_CONFIGS = [ PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, }, }, + + // Presi Backend (NestJS) + { + path: 'apps/presi/apps/backend/.env', + vars: { + NODE_ENV: () => 'development', + PORT: (env) => env.PRESI_BACKEND_PORT || '3008', + DATABASE_URL: (env) => env.PRESI_DATABASE_URL, + MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, + CORS_ORIGINS: (env) => env.CORS_ORIGINS, + }, + }, + + // Presi Mobile (Expo) + { + path: 'apps/presi/apps/mobile/.env', + vars: { + EXPO_PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.PRESI_BACKEND_PORT || '3008'}`, + EXPO_PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, + }, + }, + + // Presi Web (SvelteKit) + { + path: 'apps/presi/apps/web/.env', + vars: { + PUBLIC_BACKEND_URL: (env) => `http://localhost:${env.PRESI_BACKEND_PORT || '3008'}`, + PUBLIC_MANA_CORE_AUTH_URL: (env) => env.MANA_CORE_AUTH_URL, + }, + }, ]; function main() {