feat: integrate presi and voxel-lava into monorepo structure

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

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

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

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

@ -0,0 +1,199 @@
# Presi Project Guide
## Project Structure
```
apps/presi/
├── apps/
│ ├── backend/ # NestJS API server (@presi/backend)
│ ├── mobile/ # Expo/React Native mobile app (@presi/mobile)
│ ├── web/ # SvelteKit web application (@presi/web)
│ └── landing/ # Astro marketing landing page (@presi/landing) - TODO
├── packages/
│ └── shared/ # Shared types and utils (@presi/shared)
└── package.json
```
## Commands
### Root Level (from monorepo root)
```bash
pnpm presi:dev # Run all presi apps
pnpm dev:presi:mobile # Start mobile app
pnpm dev:presi:web # Start web app (port 5178)
pnpm dev:presi:backend # Start backend server
pnpm dev:presi:app # Start web + backend together
pnpm presi:db:push # Push schema to database
pnpm presi:db:studio # Open Drizzle Studio
pnpm presi:db:seed # Seed database with sample data
```
### Mobile App (apps/presi/apps/mobile)
```bash
pnpm dev # Start Expo dev server
pnpm ios # Run on iOS simulator
pnpm android # Run on Android emulator
```
### Web App (apps/presi/apps/web)
```bash
pnpm dev # Start dev server (port 5178)
pnpm build # Build for production
pnpm preview # Preview production build
pnpm check # Run svelte-check
```
### Backend (apps/presi/apps/backend)
```bash
pnpm dev # Start with hot reload
pnpm build # Build for production
pnpm start:prod # Start production server
pnpm db:push # Push schema to database
pnpm db:studio # Open Drizzle Studio
pnpm db:seed # Seed database
```
## Technology Stack
- **Mobile**: React Native 0.76 + Expo SDK 52, Expo Router, Zustand
- **Web**: SvelteKit 2.x, Svelte 5 (runes mode), Tailwind CSS
- **Backend**: NestJS 10, Drizzle ORM, PostgreSQL
- **Types**: TypeScript 5.x
## Architecture
### Core Features
- Create and manage presentation decks
- Add and edit slides with various content types
- Apply themes to presentations
- Share decks via share codes
- Present slides in full-screen mode
### Backend API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/health` | GET | Health check |
| `/api/decks` | GET | Get user's decks |
| `/api/decks` | POST | Create new deck |
| `/api/decks/:id` | GET | Get deck details |
| `/api/decks/:id` | PUT | Update deck |
| `/api/decks/:id` | DELETE | Delete deck |
| `/api/decks/:id/slides` | GET | Get slides for deck |
| `/api/decks/:id/slides` | POST | Add slide to deck |
| `/api/slides/:id` | PUT | Update slide |
| `/api/slides/:id` | DELETE | Delete slide |
| `/api/slides/reorder` | POST | Reorder slides |
### Data Models
**Deck** - Presentation deck
- `id` (string) - Unique identifier
- `userId` (string) - Owner user ID
- `title` (string) - Deck title
- `description` (string?) - Optional description
- `themeId` (string?) - Theme reference
- `isPublic` (boolean) - Visibility flag
- `createdAt` / `updatedAt` (timestamps)
**Slide** - Individual slide in a deck
- `id` (string) - Unique identifier
- `deckId` (string) - Parent deck reference
- `order` (number) - Position in deck
- `content` (SlideContent) - Slide content
- `createdAt` (timestamp)
**SlideContent** - Content structure
- `type`: 'title' | 'content' | 'image' | 'split'
- `title`, `subtitle`, `body`, `imageUrl`, `bulletPoints`
**Theme** - Visual theme
- `id`, `name`, `colors`, `fonts`, `isDefault`
### Environment Variables
#### Backend (.env)
```
NODE_ENV=development
PORT=3008
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/presi
MANA_CORE_AUTH_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:5173,http://localhost:8081
```
#### Mobile (.env)
```
EXPO_PUBLIC_BACKEND_URL=http://localhost:3008
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
#### Web (.env)
```
PUBLIC_BACKEND_URL=http://localhost:3008
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
```
## Shared Package
### @presi/shared
Located at `packages/shared/`
**Types:**
- `Deck`, `Slide`, `SlideContent`
- `Theme`, `ThemeColors`, `ThemeFonts`
- `SharedDeck` (for sharing feature)
**DTOs:**
- `CreateDeckDto`, `UpdateDeckDto`
- `CreateSlideDto`, `UpdateSlideDto`
- `ReorderSlidesDto`
## Code Style Guidelines
- **TypeScript**: Strict typing with interfaces
- **Mobile**: Functional components with hooks, Zustand for state
- **Web**: Svelte 5 runes mode (`$state`, `$derived`, `$effect`)
- **Backend**: NestJS modules with controllers and services
- **Styling**: Tailwind CSS (Web), NativeWind (Mobile)
- **Formatting**: Prettier with project config
## Web App Features
The SvelteKit web app provides feature parity with the mobile app:
- **Authentication**: Login/Register with Mana Core Auth
- **Deck Management**: Create, edit, delete presentation decks
- **Slide Editor**: Create slides with title, body, bullet points, images
- **Presentation Mode**: Fullscreen presentation with keyboard navigation
- Arrow keys / A/D for navigation
- F for fullscreen toggle
- ESC to exit
- Timer with start/pause
- Speaker notes toggle
- **Settings**: Theme switching (light/dark/system), account info
### Web App Structure
```
src/
├── lib/
│ ├── api/client.ts # API client with auth
│ └── stores/
│ ├── auth.svelte.ts # Auth state (Svelte 5 runes)
│ └── decks.svelte.ts # Decks/slides state
├── routes/
│ ├── +layout.svelte # App layout with header
│ ├── +page.svelte # Deck list (home)
│ ├── login/ # Login page
│ ├── register/ # Register page
│ ├── deck/[id]/ # Deck editor with slides
│ ├── present/[id]/ # Presentation mode
│ └── settings/ # Settings page
└── app.css # Global styles
```
## Important Notes
1. **Authentication**: Uses Mana Core Auth (JWT in Authorization header)
2. **Database**: PostgreSQL with Drizzle ORM
3. **Ports**: Backend=3008, Web=5178
4. **Landing**: Not yet implemented (empty folder)

View file

@ -0,0 +1,36 @@
{
"name": "@presi/web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite dev --port 5178",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^20.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^6.0.0"
},
"dependencies": {
"@presi/shared": "workspace:*",
"lucide-svelte": "^0.460.0"
},
"type": "module"
}

View file

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

View file

@ -0,0 +1,61 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-bg: 250 250 250;
--color-bg-secondary: 255 255 255;
--color-text: 15 23 42;
--color-text-secondary: 100 116 139;
--color-border: 226 232 240;
--color-primary: 14 165 233;
}
.dark {
--color-bg: 15 23 42;
--color-bg-secondary: 30 41 59;
--color-text: 248 250 252;
--color-text-secondary: 148 163 184;
--color-border: 51 65 85;
--color-primary: 56 189 248;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: rgb(var(--color-bg));
color: rgb(var(--color-text));
min-height: 100vh;
}
/* Slide aspect ratio container */
.slide-container {
aspect-ratio: 16 / 9;
max-width: 100%;
}
/* Presentation mode fullscreen */
.presentation-fullscreen {
position: fixed;
inset: 0;
z-index: 50;
background-color: rgb(var(--color-bg));
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgb(var(--color-bg-secondary));
}
::-webkit-scrollbar-thumb {
background: rgb(var(--color-border));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgb(var(--color-text-secondary));
}

View file

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

View file

@ -0,0 +1,209 @@
import { browser } from '$app/environment';
import { PUBLIC_BACKEND_URL, PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
import type { Deck, Slide, CreateDeckDto, UpdateDeckDto, CreateSlideDto, UpdateSlideDto, ReorderSlidesDto } from '@presi/shared';
const API_URL = PUBLIC_BACKEND_URL || 'http://localhost:3008';
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
function getToken(): string | null {
if (!browser) return null;
return localStorage.getItem('accessToken');
}
async function fetchWithAuth(url: string, options: RequestInit = {}): Promise<Response> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, {
...options,
headers
});
if (response.status === 401) {
// Token expired - try to refresh
const refreshed = await refreshToken();
if (refreshed) {
// Retry the request with new token
const newToken = getToken();
if (newToken) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
}
return fetch(url, { ...options, headers });
}
// Clear tokens and redirect to login
if (browser) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
}
}
return response;
}
async function refreshToken(): Promise<boolean> {
if (!browser) return false;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) return false;
try {
const response = await fetch(`${AUTH_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
if (data.refreshToken) {
localStorage.setItem('refreshToken', data.refreshToken);
}
return true;
}
} catch (e) {
console.error('Failed to refresh token:', e);
}
return false;
}
// Auth API
export const authApi = {
async login(email: string, password: string) {
const response = await fetch(`${AUTH_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
const data = await response.json();
if (browser) {
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
}
return data;
},
async register(email: string, password: string) {
const response = await fetch(`${AUTH_URL}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Registration failed');
}
const data = await response.json();
if (browser) {
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
}
return data;
},
logout() {
if (browser) {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
},
isAuthenticated(): boolean {
if (!browser) return false;
return !!localStorage.getItem('accessToken');
}
};
// Decks API
export const decksApi = {
async getAll(): Promise<Deck[]> {
const response = await fetchWithAuth(`${API_URL}/decks`);
if (!response.ok) throw new Error('Failed to fetch decks');
return response.json();
},
async getOne(id: string): Promise<{ deck: Deck; slides: Slide[] }> {
const response = await fetchWithAuth(`${API_URL}/decks/${id}`);
if (!response.ok) throw new Error('Failed to fetch deck');
return response.json();
},
async create(dto: CreateDeckDto): Promise<Deck> {
const response = await fetchWithAuth(`${API_URL}/decks`, {
method: 'POST',
body: JSON.stringify(dto)
});
if (!response.ok) throw new Error('Failed to create deck');
return response.json();
},
async update(id: string, dto: UpdateDeckDto): Promise<Deck> {
const response = await fetchWithAuth(`${API_URL}/decks/${id}`, {
method: 'PUT',
body: JSON.stringify(dto)
});
if (!response.ok) throw new Error('Failed to update deck');
return response.json();
},
async delete(id: string): Promise<void> {
const response = await fetchWithAuth(`${API_URL}/decks/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete deck');
}
};
// Slides API
export const slidesApi = {
async create(deckId: string, dto: CreateSlideDto): Promise<Slide> {
const response = await fetchWithAuth(`${API_URL}/decks/${deckId}/slides`, {
method: 'POST',
body: JSON.stringify(dto)
});
if (!response.ok) throw new Error('Failed to create slide');
return response.json();
},
async update(id: string, dto: UpdateSlideDto): Promise<Slide> {
const response = await fetchWithAuth(`${API_URL}/slides/${id}`, {
method: 'PUT',
body: JSON.stringify(dto)
});
if (!response.ok) throw new Error('Failed to update slide');
return response.json();
},
async delete(id: string): Promise<void> {
const response = await fetchWithAuth(`${API_URL}/slides/${id}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete slide');
},
async reorder(dto: ReorderSlidesDto): Promise<void> {
const response = await fetchWithAuth(`${API_URL}/slides/reorder`, {
method: 'PUT',
body: JSON.stringify(dto)
});
if (!response.ok) throw new Error('Failed to reorder slides');
}
};

View file

@ -0,0 +1,69 @@
import { browser } from '$app/environment';
import { authApi } from '$lib/api/client';
interface User {
id: string;
email: string;
}
function createAuthStore() {
let isAuthenticated = $state(false);
let user = $state<User | null>(null);
let isLoading = $state(true);
function init() {
if (!browser) {
isLoading = false;
return;
}
const token = localStorage.getItem('accessToken');
if (token) {
// Decode JWT to get user info
try {
const payload = JSON.parse(atob(token.split('.')[1]));
user = { id: payload.sub, email: payload.email };
isAuthenticated = true;
} catch (e) {
console.error('Failed to decode token:', e);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
}
isLoading = false;
}
async function login(email: string, password: string) {
const data = await authApi.login(email, password);
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
user = { id: payload.sub, email: payload.email };
isAuthenticated = true;
return data;
}
async function register(email: string, password: string) {
const data = await authApi.register(email, password);
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
user = { id: payload.sub, email: payload.email };
isAuthenticated = true;
return data;
}
function logout() {
authApi.logout();
user = null;
isAuthenticated = false;
}
return {
get isAuthenticated() { return isAuthenticated; },
get user() { return user; },
get isLoading() { return isLoading; },
init,
login,
register,
logout
};
}
export const auth = createAuthStore();

View file

@ -0,0 +1,168 @@
import { decksApi, slidesApi } from '$lib/api/client';
import type { Deck, Slide, CreateDeckDto, UpdateDeckDto, CreateSlideDto, UpdateSlideDto } from '@presi/shared';
function createDecksStore() {
let decks = $state<Deck[]>([]);
let currentDeck = $state<Deck | null>(null);
let currentSlides = $state<Slide[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
async function loadDecks() {
isLoading = true;
error = null;
try {
decks = await decksApi.getAll();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load decks';
console.error('Failed to load decks:', e);
} finally {
isLoading = false;
}
}
async function loadDeck(id: string) {
isLoading = true;
error = null;
try {
const data = await decksApi.getOne(id);
currentDeck = data.deck;
currentSlides = data.slides.sort((a, b) => a.order - b.order);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load deck';
console.error('Failed to load deck:', e);
} finally {
isLoading = false;
}
}
async function createDeck(dto: CreateDeckDto): Promise<Deck | null> {
isLoading = true;
error = null;
try {
const deck = await decksApi.create(dto);
decks = [deck, ...decks];
return deck;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create deck';
console.error('Failed to create deck:', e);
return null;
} finally {
isLoading = false;
}
}
async function updateDeck(id: string, dto: UpdateDeckDto): Promise<boolean> {
error = null;
try {
const updated = await decksApi.update(id, dto);
decks = decks.map(d => d.id === id ? updated : d);
if (currentDeck?.id === id) {
currentDeck = updated;
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update deck';
console.error('Failed to update deck:', e);
return false;
}
}
async function deleteDeck(id: string): Promise<boolean> {
error = null;
try {
await decksApi.delete(id);
decks = decks.filter(d => d.id !== id);
if (currentDeck?.id === id) {
currentDeck = null;
currentSlides = [];
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete deck';
console.error('Failed to delete deck:', e);
return false;
}
}
async function createSlide(deckId: string, dto: CreateSlideDto): Promise<Slide | null> {
error = null;
try {
const slide = await slidesApi.create(deckId, dto);
currentSlides = [...currentSlides, slide].sort((a, b) => a.order - b.order);
return slide;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create slide';
console.error('Failed to create slide:', e);
return null;
}
}
async function updateSlide(id: string, dto: UpdateSlideDto): Promise<boolean> {
error = null;
try {
const updated = await slidesApi.update(id, dto);
currentSlides = currentSlides.map(s => s.id === id ? updated : s);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update slide';
console.error('Failed to update slide:', e);
return false;
}
}
async function deleteSlide(id: string): Promise<boolean> {
error = null;
try {
await slidesApi.delete(id);
currentSlides = currentSlides.filter(s => s.id !== id);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete slide';
console.error('Failed to delete slide:', e);
return false;
}
}
async function reorderSlides(slides: { id: string; order: number }[]): Promise<boolean> {
error = null;
try {
await slidesApi.reorder({ slides });
// Update local state
const orderMap = new Map(slides.map(s => [s.id, s.order]));
currentSlides = currentSlides
.map(s => ({ ...s, order: orderMap.get(s.id) ?? s.order }))
.sort((a, b) => a.order - b.order);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder slides';
console.error('Failed to reorder slides:', e);
return false;
}
}
function clearCurrent() {
currentDeck = null;
currentSlides = [];
}
return {
get decks() { return decks; },
get currentDeck() { return currentDeck; },
get currentSlides() { return currentSlides; },
get isLoading() { return isLoading; },
get error() { return error; },
loadDecks,
loadDeck,
createDeck,
updateDeck,
deleteDeck,
createSlide,
updateSlide,
deleteSlide,
reorderSlides,
clearCurrent
};
}
export const decksStore = createDecksStore();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
$lib: './src/lib'
}
}
};
export default config;

View file

@ -0,0 +1,29 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49'
}
},
aspectRatio: {
'16/9': '16 / 9'
}
}
},
plugins: []
} satisfies Config;

View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

View file

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