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/')}
+
+ {/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)}
+
+ {/each}
+
+ {/if}
+
+
+
+{#if showCreateModal}
+
+{/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}
+
+
+
+
+
+{/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}
+
+
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
+
+
+
+
+
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}
+

+ {: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
+
+
+
+
+
+ {#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
+
+
+
+
+
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() {