feat: integrate presi and voxel-lava into monorepo structure

- Add presi web app and CLAUDE.md documentation
- Restructure voxel-lava to apps/web pattern
- Add voxel-lava scripts to root package.json
- Update generate-env.mjs for presi configuration
- Update .env.development with new project variables

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-27 15:33:28 +01:00
parent 58a342b407
commit 607ca19d4a
89 changed files with 4188 additions and 609 deletions

View file

@ -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

199
apps/presi/CLAUDE.md Normal file
View file

@ -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)

View file

@ -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"
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View file

@ -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));
}

View file

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

View file

@ -0,0 +1,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<Response> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers
};
if (token) {
(headers as Record<string, string>)['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<string, string>)['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<boolean> {
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<Deck[]> {
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<Deck> {
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<Deck> {
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<void> {
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<Slide> {
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<Slide> {
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<void> {
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<void> {
const response = await fetchWithAuth(`${API_URL}/slides/reorder`, {
method: 'PUT',
body: JSON.stringify(dto)
});
if (!response.ok) throw new Error('Failed to reorder slides');
}
};

View file

@ -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<User | null>(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();

View file

@ -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<Deck[]>([]);
let currentDeck = $state<Deck | null>(null);
let currentSlides = $state<Slide[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(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<Deck | null> {
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<boolean> {
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<boolean> {
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<Slide | null> {
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<boolean> {
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<boolean> {
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<boolean> {
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();

View file

@ -0,0 +1,102 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.svelte';
import { Presentation, LogOut, Settings, User, Sun, Moon } from 'lucide-svelte';
import '../app.css';
let isDark = $state(false);
onMount(() => {
auth.init();
isDark = localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
});
function toggleTheme() {
isDark = !isDark;
localStorage.setItem('theme', isDark ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', isDark);
}
function handleLogout() {
auth.logout();
goto('/login');
}
// Public routes that don't require auth
const publicRoutes = ['/login', '/register', '/forgot-password'];
$effect(() => {
if (!auth.isLoading && !auth.isAuthenticated && !publicRoutes.includes($page.url.pathname)) {
goto('/login');
}
});
</script>
<svelte:head>
<title>Presi - Presentation Creator</title>
</svelte:head>
{#if auth.isLoading}
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
</div>
{:else if auth.isAuthenticated || publicRoutes.includes($page.url.pathname)}
<div class="min-h-screen bg-slate-50 dark:bg-slate-900">
{#if auth.isAuthenticated && !$page.url.pathname.startsWith('/present/')}
<header class="bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16 items-center">
<a href="/" class="flex items-center gap-2 text-xl font-bold text-slate-900 dark:text-white">
<Presentation class="w-6 h-6 text-primary-500" />
Presi
</a>
<div class="flex items-center gap-2">
<button
onclick={toggleTheme}
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
aria-label="Toggle theme"
>
{#if isDark}
<Sun class="w-5 h-5 text-slate-600 dark:text-slate-300" />
{:else}
<Moon class="w-5 h-5 text-slate-600 dark:text-slate-300" />
{/if}
</button>
<a
href="/settings"
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
>
<Settings class="w-5 h-5 text-slate-600 dark:text-slate-300" />
</a>
<a
href="/profile"
class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<User class="w-4 h-4 text-slate-600 dark:text-slate-300" />
<span class="text-sm text-slate-700 dark:text-slate-200">{auth.user?.email}</span>
</a>
<button
onclick={handleLogout}
class="p-2 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors group"
aria-label="Logout"
>
<LogOut class="w-5 h-5 text-slate-600 dark:text-slate-300 group-hover:text-red-600" />
</button>
</div>
</div>
</div>
</header>
{/if}
<main>
<slot />
</main>
</div>
{/if}

View file

@ -0,0 +1,215 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import { Plus, Presentation, Trash2, MoreVertical, Clock, Layers } from 'lucide-svelte';
let showCreateModal = $state(false);
let showDeleteModal = $state(false);
let deckToDelete = $state<{ id: string; title: string } | null>(null);
let newDeckTitle = $state('');
let newDeckDescription = $state('');
let isCreating = $state(false);
onMount(() => {
decksStore.loadDecks();
});
async function handleCreateDeck(e: SubmitEvent) {
e.preventDefault();
if (!newDeckTitle.trim()) return;
isCreating = true;
const deck = await decksStore.createDeck({
title: newDeckTitle.trim(),
description: newDeckDescription.trim() || undefined
});
if (deck) {
showCreateModal = false;
newDeckTitle = '';
newDeckDescription = '';
goto(`/deck/${deck.id}`);
}
isCreating = false;
}
function confirmDelete(deck: { id: string; title: string }) {
deckToDelete = deck;
showDeleteModal = true;
}
async function handleDelete() {
if (!deckToDelete) return;
await decksStore.deleteDeck(deckToDelete.id);
showDeleteModal = false;
deckToDelete = null;
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric'
});
}
</script>
<svelte:head>
<title>My Decks - Presi</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">My Presentations</h1>
<p class="text-slate-600 dark:text-slate-400 mt-1">Create and manage your slide decks</p>
</div>
<button
onclick={() => showCreateModal = true}
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<Plus class="w-5 h-5" />
New Deck
</button>
</div>
{#if decksStore.isLoading}
<div class="flex items-center justify-center py-16">
<div class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"></div>
</div>
{:else if decksStore.decks.length === 0}
<div class="text-center py-16">
<div class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4">
<Presentation class="w-8 h-8 text-slate-400" />
</div>
<h2 class="text-lg font-medium text-slate-900 dark:text-white mb-2">No presentations yet</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">Create your first deck to get started</p>
<button
onclick={() => showCreateModal = true}
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<Plus class="w-5 h-5" />
Create Deck
</button>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each decksStore.decks as deck (deck.id)}
<div class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden hover:shadow-md transition-shadow">
<a href="/deck/{deck.id}" class="block">
<div class="aspect-video bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center">
<Presentation class="w-12 h-12 text-white/80" />
</div>
<div class="p-4">
<h3 class="font-semibold text-slate-900 dark:text-white truncate">{deck.title}</h3>
{#if deck.description}
<p class="text-sm text-slate-600 dark:text-slate-400 mt-1 line-clamp-2">{deck.description}</p>
{/if}
<div class="flex items-center gap-4 mt-3 text-xs text-slate-500 dark:text-slate-400">
<span class="flex items-center gap-1">
<Clock class="w-3.5 h-3.5" />
{formatDate(deck.updatedAt)}
</span>
</div>
</div>
</a>
<div class="px-4 pb-4 flex justify-end">
<button
onclick={(e) => { e.preventDefault(); confirmDelete({ id: deck.id, title: deck.title }); }}
class="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
aria-label="Delete deck"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create Deck Modal -->
{#if showCreateModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md">
<form onsubmit={handleCreateDeck}>
<div class="p-6">
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-4">Create New Deck</h2>
<div class="space-y-4">
<div>
<label for="title" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Title
</label>
<input
type="text"
id="title"
bind:value={newDeckTitle}
required
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="My Presentation"
/>
</div>
<div>
<label for="description" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Description (optional)
</label>
<textarea
id="description"
bind:value={newDeckDescription}
rows="3"
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
placeholder="What is this presentation about?"
></textarea>
</div>
</div>
</div>
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-xl">
<button
type="button"
onclick={() => showCreateModal = false}
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isCreating || !newDeckTitle.trim()}
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50"
>
{isCreating ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-2">Delete Deck</h2>
<p class="text-slate-600 dark:text-slate-400 mb-6">
Are you sure you want to delete "{deckToDelete?.title}"? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
onclick={() => { showDeleteModal = false; deckToDelete = null; }}
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={handleDelete}
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,428 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import type { Slide, SlideContent } from '@presi/shared';
import {
ArrowLeft, Play, Plus, Trash2, GripVertical, ChevronUp, ChevronDown,
Image, Type, List, Edit3, X, Save
} from 'lucide-svelte';
let showSlideModal = $state(false);
let editingSlide = $state<Slide | null>(null);
let showDeleteModal = $state(false);
let slideToDelete = $state<Slide | null>(null);
// Slide form state
let slideTitle = $state('');
let slideBody = $state('');
let slideBulletPoints = $state<string[]>(['']);
let slideImageUrl = $state('');
let slideNotes = $state('');
let isSaving = $state(false);
const deckId = $page.params.id;
onMount(() => {
decksStore.loadDeck(deckId);
return () => decksStore.clearCurrent();
});
function openCreateSlide() {
editingSlide = null;
slideTitle = '';
slideBody = '';
slideBulletPoints = [''];
slideImageUrl = '';
slideNotes = '';
showSlideModal = true;
}
function openEditSlide(slide: Slide) {
editingSlide = slide;
slideTitle = slide.content.title || '';
slideBody = slide.content.body || '';
slideBulletPoints = slide.content.bulletPoints?.length ? [...slide.content.bulletPoints] : [''];
slideImageUrl = slide.content.imageUrl || '';
slideNotes = '';
showSlideModal = true;
}
async function handleSaveSlide(e: SubmitEvent) {
e.preventDefault();
isSaving = true;
const content: SlideContent = {
type: slideImageUrl ? 'image' : slideBulletPoints.filter(b => b.trim()).length > 0 ? 'content' : 'title',
title: slideTitle || undefined,
body: slideBody || undefined,
bulletPoints: slideBulletPoints.filter(b => b.trim()),
imageUrl: slideImageUrl || undefined
};
if (editingSlide) {
await decksStore.updateSlide(editingSlide.id, { content });
} else {
await decksStore.createSlide(deckId, { content });
}
isSaving = false;
showSlideModal = false;
}
function confirmDeleteSlide(slide: Slide) {
slideToDelete = slide;
showDeleteModal = true;
}
async function handleDeleteSlide() {
if (!slideToDelete) return;
await decksStore.deleteSlide(slideToDelete.id);
showDeleteModal = false;
slideToDelete = null;
}
async function moveSlide(slide: Slide, direction: 'up' | 'down') {
const slides = decksStore.currentSlides;
const currentIndex = slides.findIndex(s => s.id === slide.id);
if (currentIndex === -1) return;
const newSlides = slides.map((s, i) => ({ id: s.id, order: i + 1 }));
if (direction === 'up' && currentIndex > 0) {
[newSlides[currentIndex], newSlides[currentIndex - 1]] =
[newSlides[currentIndex - 1], newSlides[currentIndex]];
} else if (direction === 'down' && currentIndex < slides.length - 1) {
[newSlides[currentIndex], newSlides[currentIndex + 1]] =
[newSlides[currentIndex + 1], newSlides[currentIndex]];
}
// Update order values
newSlides.forEach((s, i) => s.order = i + 1);
await decksStore.reorderSlides(newSlides);
}
function addBulletPoint() {
slideBulletPoints = [...slideBulletPoints, ''];
}
function removeBulletPoint(index: number) {
slideBulletPoints = slideBulletPoints.filter((_, i) => i !== index);
if (slideBulletPoints.length === 0) {
slideBulletPoints = [''];
}
}
function updateBulletPoint(index: number, value: string) {
slideBulletPoints[index] = value;
}
</script>
<svelte:head>
<title>{decksStore.currentDeck?.title || 'Loading...'} - Presi</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{#if decksStore.isLoading}
<div class="flex items-center justify-center py-16">
<div class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"></div>
</div>
{:else if decksStore.currentDeck}
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<a
href="/"
class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
>
<ArrowLeft class="w-5 h-5 text-slate-600 dark:text-slate-400" />
</a>
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">{decksStore.currentDeck.title}</h1>
{#if decksStore.currentDeck.description}
<p class="text-slate-600 dark:text-slate-400 mt-1">{decksStore.currentDeck.description}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-3">
<button
onclick={openCreateSlide}
class="flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg transition-colors"
>
<Plus class="w-5 h-5" />
Add Slide
</button>
{#if decksStore.currentSlides.length > 0}
<a
href="/present/{deckId}"
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<Play class="w-5 h-5" />
Present
</a>
{/if}
</div>
</div>
<!-- Slides Grid -->
{#if decksStore.currentSlides.length === 0}
<div class="text-center py-16">
<div class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4">
<Type class="w-8 h-8 text-slate-400" />
</div>
<h2 class="text-lg font-medium text-slate-900 dark:text-white mb-2">No slides yet</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">Add your first slide to get started</p>
<button
onclick={openCreateSlide}
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<Plus class="w-5 h-5" />
Add Slide
</button>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each decksStore.currentSlides as slide, index (slide.id)}
<div class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<!-- Slide Preview -->
<button
onclick={() => openEditSlide(slide)}
class="w-full aspect-video bg-slate-100 dark:bg-slate-700 p-4 flex flex-col items-center justify-center text-left"
>
{#if slide.content.imageUrl}
<img
src={slide.content.imageUrl}
alt={slide.content.title || 'Slide image'}
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full flex flex-col items-center justify-center p-4">
{#if slide.content.title}
<h3 class="text-lg font-semibold text-slate-900 dark:text-white text-center line-clamp-2">
{slide.content.title}
</h3>
{/if}
{#if slide.content.bulletPoints?.length}
<ul class="mt-2 text-sm text-slate-600 dark:text-slate-400 space-y-1">
{#each slide.content.bulletPoints.slice(0, 3) as point}
<li class="truncate">{point}</li>
{/each}
{#if slide.content.bulletPoints.length > 3}
<li class="text-slate-400">+{slide.content.bulletPoints.length - 3} more</li>
{/if}
</ul>
{/if}
</div>
{/if}
</button>
<!-- Slide Controls -->
<div class="p-3 flex items-center justify-between border-t border-slate-200 dark:border-slate-700">
<span class="text-sm text-slate-500 dark:text-slate-400">Slide {index + 1}</span>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onclick={() => moveSlide(slide, 'up')}
disabled={index === 0}
class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-30"
aria-label="Move up"
>
<ChevronUp class="w-4 h-4 text-slate-600 dark:text-slate-400" />
</button>
<button
onclick={() => moveSlide(slide, 'down')}
disabled={index === decksStore.currentSlides.length - 1}
class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-30"
aria-label="Move down"
>
<ChevronDown class="w-4 h-4 text-slate-600 dark:text-slate-400" />
</button>
<button
onclick={() => openEditSlide(slide)}
class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded"
aria-label="Edit"
>
<Edit3 class="w-4 h-4 text-slate-600 dark:text-slate-400" />
</button>
<button
onclick={() => confirmDeleteSlide(slide)}
class="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/30 rounded"
aria-label="Delete"
>
<Trash2 class="w-4 h-4 text-red-500" />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<!-- Slide Editor Modal -->
{#if showSlideModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 overflow-y-auto">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-2xl my-8">
<form onsubmit={handleSaveSlide}>
<div class="p-6 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">
{editingSlide ? 'Edit Slide' : 'New Slide'}
</h2>
<button
type="button"
onclick={() => showSlideModal = false}
class="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg"
>
<X class="w-5 h-5 text-slate-600 dark:text-slate-400" />
</button>
</div>
<div class="p-6 space-y-6 max-h-[60vh] overflow-y-auto">
<!-- Title -->
<div>
<label for="slideTitle" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Title
</label>
<input
type="text"
id="slideTitle"
bind:value={slideTitle}
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Slide title"
/>
</div>
<!-- Image URL -->
<div>
<label for="slideImage" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
<span class="flex items-center gap-2">
<Image class="w-4 h-4" />
Image URL (optional)
</span>
</label>
<input
type="url"
id="slideImage"
bind:value={slideImageUrl}
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="https://example.com/image.jpg"
/>
</div>
<!-- Body Text -->
<div>
<label for="slideBody" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Body Text (optional)
</label>
<textarea
id="slideBody"
bind:value={slideBody}
rows="3"
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
placeholder="Main content text..."
></textarea>
</div>
<!-- Bullet Points -->
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
<span class="flex items-center gap-2">
<List class="w-4 h-4" />
Bullet Points
</span>
</label>
<div class="space-y-2">
{#each slideBulletPoints as point, index}
<div class="flex items-center gap-2">
<span class="text-slate-400"></span>
<input
type="text"
value={point}
oninput={(e) => updateBulletPoint(index, (e.target as HTMLInputElement).value)}
class="flex-1 px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Add a point..."
/>
<button
type="button"
onclick={() => removeBulletPoint(index)}
class="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg"
>
<X class="w-4 h-4 text-red-500" />
</button>
</div>
{/each}
<button
type="button"
onclick={addBulletPoint}
class="flex items-center gap-2 px-3 py-2 text-sm text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded-lg"
>
<Plus class="w-4 h-4" />
Add bullet point
</button>
</div>
</div>
<!-- Speaker Notes -->
<div>
<label for="slideNotes" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Speaker Notes (optional)
</label>
<textarea
id="slideNotes"
bind:value={slideNotes}
rows="2"
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
placeholder="Notes only visible to presenter..."
></textarea>
</div>
</div>
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-xl">
<button
type="button"
onclick={() => showSlideModal = false}
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSaving}
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50"
>
<Save class="w-4 h-4" />
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-2">Delete Slide</h2>
<p class="text-slate-600 dark:text-slate-400 mb-6">
Are you sure you want to delete this slide? This action cannot be undone.
</p>
<div class="flex justify-end gap-3">
<button
onclick={() => { showDeleteModal = false; slideToDelete = null; }}
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onclick={handleDeleteSlide}
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
>
Delete
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,122 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Presentation, Mail, ArrowLeft, CheckCircle } from 'lucide-svelte';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
let email = $state('');
let error = $state('');
let isLoading = $state(false);
let resetSent = $state(false);
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
if (!email.trim()) {
error = 'Please enter your email address';
return;
}
isLoading = true;
try {
const response = await fetch(`${AUTH_URL}/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Failed to send reset email');
}
resetSent = true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to send reset email';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Forgot Password - Presi</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="flex justify-center mb-4">
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">
{resetSent ? 'Check your email' : 'Reset password'}
</h1>
<p class="text-slate-600 dark:text-slate-400 mt-1">
{resetSent ? `We've sent reset instructions to ${email}` : 'Enter your email to receive reset instructions'}
</p>
</div>
{#if resetSent}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 text-center">
<div class="flex justify-center mb-4">
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-full">
<CheckCircle class="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
</div>
<p class="text-slate-600 dark:text-slate-400 mb-6">
If an account exists with this email, you'll receive password reset instructions shortly.
</p>
<a
href="/login"
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<ArrowLeft class="w-4 h-4" />
Back to login
</a>
</div>
{:else}
<form onsubmit={handleSubmit} class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4">
{#if error}
<div class="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
{error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Email
</label>
<div class="relative">
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="email"
id="email"
bind:value={email}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="you@example.com"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Sending...' : 'Send reset instructions'}
</button>
<p class="text-center text-sm text-slate-600 dark:text-slate-400">
<a href="/login" class="text-primary-600 hover:text-primary-700 font-medium">Back to login</a>
</p>
</form>
{/if}
</div>
</div>

View file

@ -0,0 +1,104 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.svelte';
import { Presentation, Mail, Lock, AlertCircle } from 'lucide-svelte';
let email = $state('');
let password = $state('');
let error = $state('');
let isLoading = $state(false);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
isLoading = true;
try {
await auth.login(email, password);
goto('/');
} catch (e) {
error = e instanceof Error ? e.message : 'Login failed';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Login - Presi</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="flex justify-center mb-4">
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Welcome back</h1>
<p class="text-slate-600 dark:text-slate-400 mt-1">Sign in to your Presi account</p>
</div>
<form onsubmit={handleSubmit} class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4">
{#if error}
<div class="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
<AlertCircle class="w-4 h-4 flex-shrink-0" />
{error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Email
</label>
<div class="relative">
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="email"
id="email"
bind:value={email}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Password
</label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="password"
id="password"
bind:value={password}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Signing in...' : 'Sign in'}
</button>
<div class="flex items-center justify-between text-sm">
<a href="/forgot-password" class="text-slate-600 dark:text-slate-400 hover:text-primary-600">
Forgot password?
</a>
<p class="text-slate-600 dark:text-slate-400">
No account?
<a href="/register" class="text-primary-600 hover:text-primary-700 font-medium">Sign up</a>
</p>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,296 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import type { Slide } from '@presi/shared';
import {
X, ChevronLeft, ChevronRight, Play, Pause, Eye, EyeOff,
Maximize, Minimize, Clock
} from 'lucide-svelte';
let currentSlideIndex = $state(0);
let isFullscreen = $state(false);
let showNotes = $state(false);
let isTimerRunning = $state(false);
let elapsedSeconds = $state(0);
let showControls = $state(true);
let hideControlsTimeout: ReturnType<typeof setTimeout> | null = null;
let timerInterval: ReturnType<typeof setInterval> | null = null;
const deckId = $page.params.id;
onMount(() => {
decksStore.loadDeck(deckId);
// Keyboard navigation
window.addEventListener('keydown', handleKeydown);
window.addEventListener('mousemove', handleMouseMove);
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('fullscreenchange', handleFullscreenChange);
if (timerInterval) clearInterval(timerInterval);
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
decksStore.clearCurrent();
};
});
function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowLeft':
case 'a':
prevSlide();
break;
case 'ArrowRight':
case 'd':
case ' ':
nextSlide();
break;
case 'Escape':
exitPresentation();
break;
case 'f':
toggleFullscreen();
break;
}
resetHideControlsTimer();
}
function handleMouseMove() {
showControls = true;
resetHideControlsTimer();
}
function resetHideControlsTimer() {
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
hideControlsTimeout = setTimeout(() => {
showControls = false;
}, 3000);
}
function handleFullscreenChange() {
isFullscreen = !!document.fullscreenElement;
}
function prevSlide() {
if (currentSlideIndex > 0) {
currentSlideIndex--;
}
}
function nextSlide() {
if (currentSlideIndex < decksStore.currentSlides.length - 1) {
currentSlideIndex++;
}
}
function goToSlide(index: number) {
currentSlideIndex = index;
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
function toggleTimer() {
isTimerRunning = !isTimerRunning;
if (isTimerRunning) {
timerInterval = setInterval(() => {
elapsedSeconds++;
}, 1000);
} else if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function exitPresentation() {
if (document.fullscreenElement) {
document.exitFullscreen();
}
goto(`/deck/${deckId}`);
}
const currentSlide = $derived(decksStore.currentSlides[currentSlideIndex]);
</script>
<svelte:head>
<title>Presenting: {decksStore.currentDeck?.title || 'Loading...'}</title>
</svelte:head>
<div class="fixed inset-0 bg-slate-900 text-white flex flex-col">
{#if decksStore.isLoading}
<div class="flex-1 flex items-center justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
</div>
{:else if currentSlide}
<!-- Top Bar -->
<div
class="absolute top-0 left-0 right-0 z-10 p-4 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent transition-opacity duration-300"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
>
<div class="flex items-center gap-4">
<h1 class="text-lg font-medium truncate max-w-xs">{decksStore.currentDeck?.title}</h1>
<span class="text-sm text-slate-400">
Slide {currentSlideIndex + 1} of {decksStore.currentSlides.length}
</span>
</div>
<button
onclick={exitPresentation}
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
aria-label="Exit presentation"
>
<X class="w-6 h-6" />
</button>
</div>
<!-- Main Slide Area -->
<div class="flex-1 flex items-center justify-center p-8 pt-20 pb-32">
<div class="w-full max-w-6xl aspect-video bg-slate-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col items-center justify-center p-12">
{#if currentSlide.content.imageUrl}
<img
src={currentSlide.content.imageUrl}
alt={currentSlide.content.title || 'Slide image'}
class="max-w-full max-h-full object-contain"
/>
{:else}
<div class="text-center max-w-4xl">
{#if currentSlide.content.title}
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-8">{currentSlide.content.title}</h2>
{/if}
{#if currentSlide.content.body}
<p class="text-xl md:text-2xl text-slate-300 mb-8">{currentSlide.content.body}</p>
{/if}
{#if currentSlide.content.bulletPoints?.length}
<ul class="text-left text-xl md:text-2xl space-y-4 mx-auto max-w-2xl">
{#each currentSlide.content.bulletPoints as point}
<li class="flex items-start gap-4">
<span class="text-primary-400 mt-1"></span>
<span>{point}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
</div>
<!-- Speaker Notes -->
{#if showNotes && currentSlide.content.subtitle}
<div class="absolute bottom-32 left-1/2 -translate-x-1/2 w-full max-w-2xl px-4">
<div class="bg-slate-800/90 rounded-lg p-4 backdrop-blur-sm">
<h3 class="text-sm font-medium text-slate-400 mb-2">Speaker Notes</h3>
<p class="text-slate-200">{currentSlide.content.subtitle}</p>
</div>
</div>
{/if}
<!-- Bottom Controls -->
<div
class="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/50 to-transparent transition-opacity duration-300"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
>
<div class="max-w-4xl mx-auto flex items-center justify-between">
<!-- Left: Timer -->
<div class="flex items-center gap-4">
<button
onclick={toggleTimer}
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
aria-label={isTimerRunning ? 'Pause timer' : 'Start timer'}
>
{#if isTimerRunning}
<Pause class="w-5 h-5" />
{:else}
<Play class="w-5 h-5" />
{/if}
</button>
<div class="flex items-center gap-2 text-slate-300">
<Clock class="w-4 h-4" />
<span class="font-mono">{formatTime(elapsedSeconds)}</span>
</div>
</div>
<!-- Center: Navigation -->
<div class="flex items-center gap-2">
<button
onclick={prevSlide}
disabled={currentSlideIndex === 0}
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
aria-label="Previous slide"
>
<ChevronLeft class="w-6 h-6" />
</button>
<!-- Slide Dots -->
<div class="flex items-center gap-2 px-4">
{#each decksStore.currentSlides as _, index}
<button
onclick={() => goToSlide(index)}
class="w-2 h-2 rounded-full transition-all"
class:bg-primary-500={index === currentSlideIndex}
class:w-4={index === currentSlideIndex}
class:bg-slate-500={index !== currentSlideIndex}
aria-label="Go to slide {index + 1}"
></button>
{/each}
</div>
<button
onclick={nextSlide}
disabled={currentSlideIndex === decksStore.currentSlides.length - 1}
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
aria-label="Next slide"
>
<ChevronRight class="w-6 h-6" />
</button>
</div>
<!-- Right: Options -->
<div class="flex items-center gap-2">
<button
onclick={() => showNotes = !showNotes}
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
aria-label={showNotes ? 'Hide notes' : 'Show notes'}
>
{#if showNotes}
<EyeOff class="w-5 h-5" />
{:else}
<Eye class="w-5 h-5" />
{/if}
</button>
<button
onclick={toggleFullscreen}
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{#if isFullscreen}
<Minimize class="w-5 h-5" />
{:else}
<Maximize class="w-5 h-5" />
{/if}
</button>
</div>
</div>
</div>
{:else}
<div class="flex-1 flex items-center justify-center">
<p class="text-slate-400">No slides in this deck</p>
</div>
{/if}
</div>

View file

@ -0,0 +1,141 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.svelte';
import { decksStore } from '$lib/stores/decks.svelte';
import { User, FolderOpen, Layers, Calendar, ArrowLeft } from 'lucide-svelte';
let totalSlides = $state(0);
let isLoading = $state(true);
onMount(async () => {
await decksStore.loadDecks();
// Calculate total slides from all decks
let slides = 0;
for (const deck of decksStore.decks) {
// Load each deck to get slide count
// Note: This is a simplified approach - in production you might want an API endpoint for stats
}
// For now, we show deck count - slide count would require loading all decks
isLoading = false;
});
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric'
});
}
</script>
<svelte:head>
<title>Profile - Presi</title>
</svelte:head>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center gap-4 mb-8">
<a
href="/"
class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
>
<ArrowLeft class="w-5 h-5 text-slate-600 dark:text-slate-400" />
</a>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Profile</h1>
</div>
{#if isLoading}
<div class="flex items-center justify-center py-16">
<div class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"></div>
</div>
{:else}
<div class="space-y-6">
<!-- User Info Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="p-8 text-center">
<div class="mx-auto w-20 h-20 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center mb-4">
<User class="w-10 h-10 text-primary-600 dark:text-primary-400" />
</div>
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">
{auth.user?.email || 'User'}
</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1 font-mono">
ID: {auth.user?.id?.slice(0, 8)}...
</p>
</div>
</div>
<!-- Stats Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Statistics</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-2 gap-6">
<!-- Total Decks -->
<div class="text-center p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
<div class="flex justify-center mb-2">
<FolderOpen class="w-8 h-8 text-primary-500" />
</div>
<div class="text-3xl font-bold text-slate-900 dark:text-white">
{decksStore.decks.length}
</div>
<div class="text-sm text-slate-600 dark:text-slate-400 mt-1">
Total Decks
</div>
</div>
<!-- Total Slides -->
<div class="text-center p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
<div class="flex justify-center mb-2">
<Layers class="w-8 h-8 text-primary-500" />
</div>
<div class="text-3xl font-bold text-slate-900 dark:text-white">
-
</div>
<div class="text-sm text-slate-600 dark:text-slate-400 mt-1">
Total Slides
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
{#if decksStore.decks.length > 0}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Recent Presentations</h3>
</div>
<div class="divide-y divide-slate-200 dark:divide-slate-700">
{#each decksStore.decks.slice(0, 5) as deck (deck.id)}
<a
href="/deck/{deck.id}"
class="flex items-center justify-between p-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<FolderOpen class="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<h4 class="font-medium text-slate-900 dark:text-white">{deck.title}</h4>
{#if deck.description}
<p class="text-sm text-slate-500 dark:text-slate-400 truncate max-w-xs">
{deck.description}
</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Calendar class="w-4 h-4" />
{formatDate(deck.updatedAt)}
</div>
</a>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,128 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.svelte';
import { Presentation, Mail, Lock, AlertCircle } from 'lucide-svelte';
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let error = $state('');
let isLoading = $state(false);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
if (password !== confirmPassword) {
error = 'Passwords do not match';
return;
}
if (password.length < 6) {
error = 'Password must be at least 6 characters';
return;
}
isLoading = true;
try {
await auth.register(email, password);
goto('/');
} catch (e) {
error = e instanceof Error ? e.message : 'Registration failed';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Register - Presi</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<div class="flex justify-center mb-4">
<div class="p-3 bg-primary-100 dark:bg-primary-900/30 rounded-xl">
<Presentation class="w-10 h-10 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">Create account</h1>
<p class="text-slate-600 dark:text-slate-400 mt-1">Start creating amazing presentations</p>
</div>
<form onsubmit={handleSubmit} class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 space-y-4">
{#if error}
<div class="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-400 text-sm">
<AlertCircle class="w-4 h-4 flex-shrink-0" />
{error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Email
</label>
<div class="relative">
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="email"
id="email"
bind:value={email}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Password
</label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="password"
id="password"
bind:value={password}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
</div>
<div>
<label for="confirmPassword" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Confirm Password
</label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="password"
id="confirmPassword"
bind:value={confirmPassword}
required
class="w-full pl-10 pr-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
<p class="text-center text-sm text-slate-600 dark:text-slate-400">
Already have an account?
<a href="/login" class="text-primary-600 hover:text-primary-700 font-medium">Sign in</a>
</p>
</form>
</div>
</div>

View file

@ -0,0 +1,148 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.svelte';
import { User, Mail, Shield, LogOut, Sun, Moon, Monitor } from 'lucide-svelte';
import { onMount } from 'svelte';
type ThemeMode = 'light' | 'dark' | 'system';
let themeMode = $state<ThemeMode>('system');
onMount(() => {
const saved = localStorage.getItem('theme') as ThemeMode | null;
if (saved === 'light' || saved === 'dark') {
themeMode = saved;
} else {
themeMode = 'system';
}
});
function setTheme(mode: ThemeMode) {
themeMode = mode;
if (mode === 'system') {
localStorage.removeItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', prefersDark);
} else {
localStorage.setItem('theme', mode);
document.documentElement.classList.toggle('dark', mode === 'dark');
}
}
function handleLogout() {
auth.logout();
goto('/login');
}
</script>
<svelte:head>
<title>Settings - Presi</title>
</svelte:head>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-slate-900 dark:text-white mb-8">Settings</h1>
<div class="space-y-6">
<!-- Account Section -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
<User class="w-5 h-5 text-slate-400" />
Account
</h2>
</div>
<div class="p-4 space-y-4">
<div class="flex items-center justify-between py-2">
<div class="flex items-center gap-3">
<Mail class="w-5 h-5 text-slate-400" />
<div>
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Email</p>
<p class="text-sm text-slate-500 dark:text-slate-400">{auth.user?.email}</p>
</div>
</div>
</div>
<div class="flex items-center justify-between py-2">
<div class="flex items-center gap-3">
<Shield class="w-5 h-5 text-slate-400" />
<div>
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">User ID</p>
<p class="text-sm text-slate-500 dark:text-slate-400 font-mono">{auth.user?.id}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Appearance Section -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="p-4 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
<Sun class="w-5 h-5 text-slate-400" />
Appearance
</h2>
</div>
<div class="p-4">
<p class="text-sm text-slate-600 dark:text-slate-400 mb-4">Choose your preferred theme</p>
<div class="grid grid-cols-3 gap-3">
<button
onclick={() => setTheme('light')}
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors"
class:border-primary-500={themeMode === 'light'}
class:bg-primary-50={themeMode === 'light'}
class:dark:bg-primary-900/30={themeMode === 'light'}
class:border-slate-200={themeMode !== 'light'}
class:dark:border-slate-600={themeMode !== 'light'}
>
<Sun class="w-6 h-6 text-amber-500" />
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Light</span>
</button>
<button
onclick={() => setTheme('dark')}
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors"
class:border-primary-500={themeMode === 'dark'}
class:bg-primary-50={themeMode === 'dark'}
class:dark:bg-primary-900/30={themeMode === 'dark'}
class:border-slate-200={themeMode !== 'dark'}
class:dark:border-slate-600={themeMode !== 'dark'}
>
<Moon class="w-6 h-6 text-indigo-500" />
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Dark</span>
</button>
<button
onclick={() => setTheme('system')}
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors"
class:border-primary-500={themeMode === 'system'}
class:bg-primary-50={themeMode === 'system'}
class:dark:bg-primary-900/30={themeMode === 'system'}
class:border-slate-200={themeMode !== 'system'}
class:dark:border-slate-600={themeMode !== 'system'}
>
<Monitor class="w-6 h-6 text-slate-500" />
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">System</span>
</button>
</div>
</div>
</div>
<!-- Danger Zone -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-red-200 dark:border-red-900/50 overflow-hidden">
<div class="p-4 border-b border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/20">
<h2 class="text-lg font-semibold text-red-700 dark:text-red-400">Danger Zone</h2>
</div>
<div class="p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Sign out</p>
<p class="text-sm text-slate-500 dark:text-slate-400">Sign out of your account on this device</p>
</div>
<button
onclick={handleLogout}
class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
>
<LogOut class="w-4 h-4" />
Sign out
</button>
</div>
</div>
</div>
</div>
</div>

View file

@ -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;

View file

@ -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;

View file

@ -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"
}
}

View file

@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 5178
}
});

View file

@ -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;

View file

@ -0,0 +1,3 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/voxel_lava
MANA_CORE_AUTH_URL=http://localhost:3001
PORT=3010

View file

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

View file

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

View file

@ -0,0 +1,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"
}
}

View file

@ -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 {}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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);

View file

@ -0,0 +1,3 @@
export * from './levels.schema';
export * from './level-likes.schema';
export * from './level-plays.schema';

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View file

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

View file

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

View file

@ -0,0 +1,42 @@
import {
IsString,
IsOptional,
IsBoolean,
IsObject,
IsArray,
} from 'class-validator';
export class CreateLevelDto {
@IsString()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsObject()
voxelData: Record<string, any>;
@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;
}

View file

@ -0,0 +1,11 @@
import { IsBoolean, IsOptional, IsNumber } from 'class-validator';
export class RecordPlayDto {
@IsOptional()
@IsBoolean()
completed?: boolean;
@IsOptional()
@IsNumber()
completionTime?: number;
}

View file

@ -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<string, any>;
@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;
}

View file

@ -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,
);
}
}

View file

@ -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 {}

View file

@ -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<number>`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);
}
}

View file

@ -0,0 +1,36 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for web app
app.enableCors({
origin: [
'http://localhost: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();

View file

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

View file

@ -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"
}
}

View file

@ -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<T>(
endpoint: string,
options: RequestInit = {},
useAuthUrl = false
): Promise<T> {
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<string, string>)['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<any[]>('/api/levels');
},
async getLevel(id: string) {
return apiRequest<any>(`/api/levels/${id}`);
},
async createLevel(data: any) {
return apiRequest<any>('/api/levels', {
method: 'POST',
body: JSON.stringify(data),
});
},
async updateLevel(id: string, data: any) {
return apiRequest<any>(`/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<any>(`/api/levels/${id}/play`, {
method: 'POST',
body: JSON.stringify({ completed, completionTime }),
});
},
async getLeaderboard(id: string, limit = 10) {
return apiRequest<any[]>(`/api/levels/${id}/leaderboard?limit=${limit}`);
},
};
export { API_URL, AUTH_URL };

View file

@ -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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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,7 +125,7 @@ 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;
}
}

View file

@ -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<LevelMetadata> {
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<string | null> {
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<Level | null> {
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<LevelMetadata[]> {
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<LevelMetadata[]> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<any[]> {
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;

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

View file

@ -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"
}
}

View file

@ -0,0 +1,3 @@
packages:
- 'apps/*'
- 'packages/*'

View file

@ -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');
}

View file

@ -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<LevelMetadata> {
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<string | null> {
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<Level | null> {
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<LevelMetadata[]> {
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<LevelMetadata[]> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<any[]> {
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;

View file

@ -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
}
}
}

View file

@ -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",

View file

@ -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() {