mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
58a342b407
commit
607ca19d4a
89 changed files with 4188 additions and 609 deletions
|
|
@ -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
199
apps/presi/CLAUDE.md
Normal 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)
|
||||
36
apps/presi/apps/web/package.json
Normal file
36
apps/presi/apps/web/package.json
Normal 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"
|
||||
}
|
||||
6
apps/presi/apps/web/postcss.config.js
Normal file
6
apps/presi/apps/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
61
apps/presi/apps/web/src/app.css
Normal file
61
apps/presi/apps/web/src/app.css
Normal 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));
|
||||
}
|
||||
12
apps/presi/apps/web/src/app.html
Normal file
12
apps/presi/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
209
apps/presi/apps/web/src/lib/api/client.ts
Normal file
209
apps/presi/apps/web/src/lib/api/client.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
69
apps/presi/apps/web/src/lib/stores/auth.svelte.ts
Normal file
69
apps/presi/apps/web/src/lib/stores/auth.svelte.ts
Normal 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();
|
||||
168
apps/presi/apps/web/src/lib/stores/decks.svelte.ts
Normal file
168
apps/presi/apps/web/src/lib/stores/decks.svelte.ts
Normal 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();
|
||||
102
apps/presi/apps/web/src/routes/+layout.svelte
Normal file
102
apps/presi/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||
215
apps/presi/apps/web/src/routes/+page.svelte
Normal file
215
apps/presi/apps/web/src/routes/+page.svelte
Normal 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}
|
||||
428
apps/presi/apps/web/src/routes/deck/[id]/+page.svelte
Normal file
428
apps/presi/apps/web/src/routes/deck/[id]/+page.svelte
Normal 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}
|
||||
122
apps/presi/apps/web/src/routes/forgot-password/+page.svelte
Normal file
122
apps/presi/apps/web/src/routes/forgot-password/+page.svelte
Normal 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>
|
||||
104
apps/presi/apps/web/src/routes/login/+page.svelte
Normal file
104
apps/presi/apps/web/src/routes/login/+page.svelte
Normal 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>
|
||||
296
apps/presi/apps/web/src/routes/present/[id]/+page.svelte
Normal file
296
apps/presi/apps/web/src/routes/present/[id]/+page.svelte
Normal 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>
|
||||
141
apps/presi/apps/web/src/routes/profile/+page.svelte
Normal file
141
apps/presi/apps/web/src/routes/profile/+page.svelte
Normal 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>
|
||||
128
apps/presi/apps/web/src/routes/register/+page.svelte
Normal file
128
apps/presi/apps/web/src/routes/register/+page.svelte
Normal 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>
|
||||
148
apps/presi/apps/web/src/routes/settings/+page.svelte
Normal file
148
apps/presi/apps/web/src/routes/settings/+page.svelte
Normal 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>
|
||||
15
apps/presi/apps/web/svelte.config.js
Normal file
15
apps/presi/apps/web/svelte.config.js
Normal 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;
|
||||
29
apps/presi/apps/web/tailwind.config.ts
Normal file
29
apps/presi/apps/web/tailwind.config.ts
Normal 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;
|
||||
14
apps/presi/apps/web/tsconfig.json
Normal file
14
apps/presi/apps/web/tsconfig.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
apps/presi/apps/web/vite.config.ts
Normal file
9
apps/presi/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 5178
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
3
games/voxel-lava/apps/backend/.env.example
Normal file
3
games/voxel-lava/apps/backend/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/voxel_lava
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
PORT=3010
|
||||
12
games/voxel-lava/apps/backend/drizzle.config.ts
Normal file
12
games/voxel-lava/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema/index.ts',
|
||||
out: './src/db/migrations',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/voxel_lava',
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
8
games/voxel-lava/apps/backend/nest-cli.json
Normal file
8
games/voxel-lava/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
51
games/voxel-lava/apps/backend/package.json
Normal file
51
games/voxel-lava/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
18
games/voxel-lava/apps/backend/src/app.module.ts
Normal file
18
games/voxel-lava/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserPayload {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: keyof CurrentUserPayload | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user as CurrentUserPayload;
|
||||
|
||||
if (data) {
|
||||
return user?.[data];
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
38
games/voxel-lava/apps/backend/src/db/connection.ts
Normal file
38
games/voxel-lava/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
// Use require for postgres to avoid ESM/CommonJS interop issues
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
28
games/voxel-lava/apps/backend/src/db/database.module.ts
Normal file
28
games/voxel-lava/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection, type Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
26
games/voxel-lava/apps/backend/src/db/migrate.ts
Normal file
26
games/voxel-lava/apps/backend/src/db/migrate.ts
Normal 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);
|
||||
3
games/voxel-lava/apps/backend/src/db/schema/index.ts
Normal file
3
games/voxel-lava/apps/backend/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './levels.schema';
|
||||
export * from './level-likes.schema';
|
||||
export * from './level-plays.schema';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
61
games/voxel-lava/apps/backend/src/db/schema/levels.schema.ts
Normal file
61
games/voxel-lava/apps/backend/src/db/schema/levels.schema.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { IsBoolean, IsOptional, IsNumber } from 'class-validator';
|
||||
|
||||
export class RecordPlayDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
completed?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
completionTime?: number;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
113
games/voxel-lava/apps/backend/src/level/level.controller.ts
Normal file
113
games/voxel-lava/apps/backend/src/level/level.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
10
games/voxel-lava/apps/backend/src/level/level.module.ts
Normal file
10
games/voxel-lava/apps/backend/src/level/level.module.ts
Normal 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 {}
|
||||
198
games/voxel-lava/apps/backend/src/level/level.service.ts
Normal file
198
games/voxel-lava/apps/backend/src/level/level.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
games/voxel-lava/apps/backend/src/main.ts
Normal file
36
games/voxel-lava/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for web app
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost: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();
|
||||
25
games/voxel-lava/apps/backend/tsconfig.json
Normal file
25
games/voxel-lava/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
39
games/voxel-lava/apps/web/package.json
Normal file
39
games/voxel-lava/apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
236
games/voxel-lava/apps/web/src/lib/api/client.ts
Normal file
236
games/voxel-lava/apps/web/src/lib/api/client.ts
Normal 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 };
|
||||
|
|
@ -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,10 +125,10 @@ export class AuthService {
|
|||
* @returns Die E-Mail oder null
|
||||
*/
|
||||
static getUserEmail(): string | null {
|
||||
const user = pb.authStore.model;
|
||||
const user = getStoredUser();
|
||||
return user?.email || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Default export für Kompatibilität
|
||||
export default AuthService;
|
||||
export default AuthService;
|
||||
390
games/voxel-lava/apps/web/src/lib/services/LevelService.ts
Normal file
390
games/voxel-lava/apps/web/src/lib/services/LevelService.ts
Normal 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;
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
games/voxel-lava/pnpm-workspace.yaml
Normal file
3
games/voxel-lava/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/*'
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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;
|
||||
24
games/voxel-lava/turbo.json
Normal file
24
games/voxel-lava/turbo.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue