Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View file

@ -0,0 +1,39 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
/* Presi-specific styles */
.slide-container {
aspect-ratio: 16 / 9;
max-width: 100%;
}
.presentation-fullscreen {
position: fixed;
inset: 0;
z-index: 50;
background-color: hsl(var(--color-background));
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: hsl(var(--color-surface));
}
::-webkit-scrollbar-thumb {
background: hsl(var(--color-border));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--color-muted-foreground));
}

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,270 @@
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 BASE_URL = PUBLIC_BACKEND_URL || 'http://localhost:3008';
const API_URL = `${BASE_URL}/api`;
const AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Storage keys must match @manacore/shared-auth
const STORAGE_KEYS = {
APP_TOKEN: '@auth/appToken',
REFRESH_TOKEN: '@auth/refreshToken',
};
function getToken(): string | null {
if (!browser) return null;
return localStorage.getItem(STORAGE_KEYS.APP_TOKEN);
}
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(STORAGE_KEYS.APP_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
window.location.href = '/login';
}
}
return response;
}
async function refreshToken(): Promise<boolean> {
if (!browser) return false;
const storedRefreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
if (!storedRefreshToken) return false;
try {
const response = await fetch(`${AUTH_URL}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: storedRefreshToken }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem(STORAGE_KEYS.APP_TOKEN, data.accessToken);
if (data.refreshToken) {
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken);
}
return true;
}
} catch (e) {
console.error('Failed to refresh token:', e);
}
return false;
}
// Auth API (legacy - prefer using @manacore/shared-auth via auth store)
export const authApi = {
async login(email: string, password: string) {
const response = await fetch(`${AUTH_URL}/api/v1/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(STORAGE_KEYS.APP_TOKEN, data.accessToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken);
}
return data;
},
async register(email: string, password: string) {
const response = await fetch(`${AUTH_URL}/api/v1/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(STORAGE_KEYS.APP_TOKEN, data.accessToken);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken);
}
return data;
},
logout() {
if (browser) {
localStorage.removeItem(STORAGE_KEYS.APP_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
}
},
isAuthenticated(): boolean {
if (!browser) return false;
return !!localStorage.getItem(STORAGE_KEYS.APP_TOKEN);
},
};
// 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');
},
};
// Share API
export interface ShareLink {
id: string;
deckId: string;
shareCode: string;
expiresAt: string | null;
createdAt: string;
}
export const shareApi = {
// Public - no auth required
async getByCode(code: string): Promise<{ deck: any; slides: any[] }> {
const response = await fetch(`${API_URL}/share/${code}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Shared deck not found or link has expired');
}
throw new Error('Failed to fetch shared deck');
}
return response.json();
},
// Authenticated endpoints
async createShare(deckId: string, expiresAt?: string): Promise<ShareLink> {
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}`, {
method: 'POST',
body: JSON.stringify({ expiresAt }),
});
if (!response.ok) throw new Error('Failed to create share link');
return response.json();
},
async getSharesForDeck(deckId: string): Promise<ShareLink[]> {
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}/links`);
if (!response.ok) throw new Error('Failed to get share links');
return response.json();
},
async deleteShare(shareId: string): Promise<void> {
const response = await fetchWithAuth(`${API_URL}/share/${shareId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete share link');
},
};

View file

@ -0,0 +1,15 @@
/**
* Feedback Service Instance for Presi Web App
*/
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { auth } from '$lib/stores/auth.svelte';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
export const feedbackService = createFeedbackService({
apiUrl: MANA_AUTH_URL,
appId: 'presi',
getAuthToken: async () => auth.getAccessToken(),
});

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { AppSlider } from '@manacore/shared-ui';
import type { AppItem } from '@manacore/shared-ui';
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
// Convert MANA_APPS to AppItem format (English)
const apps: AppItem[] = MANA_APPS.map((app) => ({
name: app.name,
description: app.description.en,
longDescription: app.longDescription.en,
icon: app.icon,
color: app.color,
comingSoon: app.comingSoon,
status: app.status,
}));
const statusLabels = APP_STATUS_LABELS.en;
const labels = APP_SLIDER_LABELS.en;
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<AppSlider
{apps}
title={labels.title}
isDark={false}
{statusLabels}
comingSoonLabel={labels.comingSoon}
openAppLabel={labels.openApp}
onAppClick={handleAppClick}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { PillDropdown } from '@manacore/shared-ui';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLabel = $derived(getCurrentLanguageLabel(currentLocale));
</script>
<PillDropdown items={languageItems} label={currentLabel} direction="down" />

View file

@ -0,0 +1,52 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en', 'it', 'fr', 'es'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
register('it', () => import('./locales/it.json'));
register('fr', () => import('./locales/fr.json'));
register('es', () => import('./locales/es.json'));
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('presi_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('presi_locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,13 @@
{
"app_slider": {
"title": "Weitere Manacore Apps",
"memoro_desc": "KI-gestützte Sprachmemos",
"maerchenzauber_desc": "Magische Gute-Nacht-Geschichten",
"manadeck_desc": "KI Lernkarten",
"picture_desc": "KI Bildgenerierung",
"moodlit_desc": "Dein Stimmungsbegleiter",
"manacore_desc": "KI-Produktivitätssuite",
"coming_soon": "Demnächst",
"download": "Download"
}
}

View file

@ -0,0 +1,13 @@
{
"app_slider": {
"title": "More Manacore Apps",
"memoro_desc": "AI-powered voice memos",
"maerchenzauber_desc": "Magical bedtime stories",
"manadeck_desc": "AI flashcards",
"picture_desc": "AI image generation",
"moodlit_desc": "Your mood companion",
"manacore_desc": "AI productivity suite",
"coming_soon": "Coming soon",
"download": "Download"
}
}

View file

@ -0,0 +1,13 @@
{
"app_slider": {
"title": "Más Apps de Manacore",
"memoro_desc": "Notas de voz con IA",
"maerchenzauber_desc": "Cuentos mágicos para dormir",
"manadeck_desc": "Flashcards con IA",
"picture_desc": "Generación de imágenes con IA",
"moodlit_desc": "Tu compañero de ánimo",
"manacore_desc": "Suite de productividad IA",
"coming_soon": "Próximamente",
"download": "Descargar"
}
}

View file

@ -0,0 +1,13 @@
{
"app_slider": {
"title": "Autres Apps Manacore",
"memoro_desc": "Mémos vocaux avec IA",
"maerchenzauber_desc": "Histoires magiques du soir",
"manadeck_desc": "Flashcards avec IA",
"picture_desc": "Génération d'images avec IA",
"moodlit_desc": "Ton compagnon d'humeur",
"manacore_desc": "Suite de productivité IA",
"coming_soon": "Bientôt disponible",
"download": "Télécharger"
}
}

View file

@ -0,0 +1,13 @@
{
"app_slider": {
"title": "Altre App Manacore",
"memoro_desc": "Memo vocali con IA",
"maerchenzauber_desc": "Storie magiche della buonanotte",
"manadeck_desc": "Flashcard con IA",
"picture_desc": "Generazione immagini con IA",
"moodlit_desc": "Il tuo compagno d'umore",
"manacore_desc": "Suite di produttività IA",
"coming_soon": "Prossimamente",
"download": "Scarica"
}
}

View file

@ -0,0 +1,191 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
// Initialize Mana Core Auth only on the client side
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
// State
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const auth = {
// Getters
get user() {
return user;
},
get isLoading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
/**
* Initialize auth state from stored tokens
*/
async init() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
/**
* Sign in with email and password
* Returns AuthResult compatible format for shared-auth-ui
*/
async login(email: string, password: string): Promise<{ success: boolean; error?: string }> {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
// Get user data from token
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
async register(
email: string,
password: string
): Promise<{ success: boolean; error?: string; needsVerification?: boolean }> {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
// Mana Core Auth requires separate login after signup
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
// Auto sign in after successful signup
const signInResult = await this.login(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
/**
* Sign out
*/
async logout() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
// Clear user even if sign out fails
user = null;
}
},
/**
* Send password reset email
*/
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
/**
* Get access token for API calls
*/
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -0,0 +1,185 @@
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,4 @@
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

View file

@ -0,0 +1,10 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'presi',
defaultVariant: 'stone',
primaryColor: {
light: '220 9% 46%',
dark: '220 9% 56%',
},
});

View file

@ -0,0 +1,19 @@
/**
* User Settings Store for Presi
*
* This store syncs settings with mana-core-auth and provides:
* - Global settings that apply to all apps
* - Per-app overrides for customization
* - localStorage caching for offline support
*/
import { createUserSettingsStore } from '@manacore/shared-theme';
import { auth } from './auth.svelte';
const MANA_AUTH_URL = 'http://localhost:3001';
export const userSettings = createUserSettingsStore({
appId: 'presi',
authUrl: MANA_AUTH_URL,
getAccessToken: () => auth.getAccessToken(),
});

View file

@ -0,0 +1,250 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { auth } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { theme } from '$lib/stores/theme';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
} from '$lib/stores/navigation';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
// App switcher items
const appItems = getPillAppItems('presi');
let { children } = $props();
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(auth.user?.email);
// Navigation items for Presi
const navItems: PillNavItem[] = [
{ href: '/', label: 'Decks', icon: 'document' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Routes where nav should be hidden (present mode, shared view)
const hideNavRoutes = ['/present/', '/shared/'];
function shouldHideNav(pathname: string): boolean {
return hideNavRoutes.some((route) => pathname.startsWith(route));
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('presi-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('presi-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
function handleLogout() {
auth.logout();
goto('/login');
}
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Single key shortcuts (no modifiers)
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) {
if (event.key.toLowerCase() === 't') {
event.preventDefault();
goto('/themes');
}
}
}
onMount(async () => {
// Redirect to login if not authenticated
if (!auth.isAuthenticated) {
goto('/login');
return;
}
// Load user settings
await userSettings.load();
// Redirect to start page if on root and a custom start page is set
const currentPath = window.location.pathname;
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
goto(userSettings.startPage, { replaceState: true });
}
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('presi-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('presi-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if shouldHideNav($page.url.pathname)}
<!-- Present mode and shared view - no nav -->
<main class="min-h-screen bg-background">
{@render children()}
</main>
{:else}
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Floating/Sidebar Pill Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Presi"
homeRoute="/"
onToggleTheme={handleToggleTheme}
isDark={theme.isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition={userSettings.nav.desktopPosition}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
primaryColor="#64748b"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<!-- Main Content with dynamic padding based on nav mode -->
<main
class="main-content"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
{/if}
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
transition: all 300ms ease;
}
/* Floating nav mode - add top padding for fixed nav */
.main-content.floating-mode {
padding-top: 100px;
}
/* Sidebar mode - add left padding for sidebar nav */
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
padding: 2rem 1rem;
}
@media (min-width: 640px) {
.content-wrapper {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding-left: 2rem;
padding-right: 2rem;
}
}
</style>

View file

@ -0,0 +1,236 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import { PageHeader } from '@manacore/shared-ui';
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">
<PageHeader title="My Presentations" description="Create and manage your slide decks" size="lg">
{#snippet actions()}
<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>
{/snippet}
</PageHeader>
{#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,14 @@
<script lang="ts">
import { AppsPage } from '@manacore/shared-ui';
</script>
<div class="apps-page-wrapper">
<AppsPage currentAppId="presi" locale="de" title="Alle Apps" />
</div>
<style>
.apps-page-wrapper {
background-color: hsl(var(--background));
min-height: 100%;
}
</style>

View file

@ -0,0 +1,667 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { decksStore } from '$lib/stores/decks.svelte';
import { shareApi } from '$lib/api/client';
import type { ShareLink } from '$lib/api/client';
import type { Slide, SlideContent } from '@presi/shared';
import {
ArrowLeft,
Play,
Plus,
Trash2,
GripVertical,
ChevronUp,
ChevronDown,
Image,
Type,
List,
Edit3,
X,
Save,
Share2,
Link,
Copy,
Check,
ExternalLink,
} from 'lucide-svelte';
let showSlideModal = $state(false);
let editingSlide = $state<Slide | null>(null);
let showDeleteModal = $state(false);
let slideToDelete = $state<Slide | null>(null);
// Share modal state
let showShareModal = $state(false);
let shareLinks = $state<ShareLink[]>([]);
let isLoadingShares = $state(false);
let isCreatingShare = $state(false);
let copiedLinkId = $state<string | 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 as string;
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;
}
// Share functions
async function openShareModal() {
showShareModal = true;
isLoadingShares = true;
try {
shareLinks = await shareApi.getSharesForDeck(deckId);
} catch (e) {
console.error('Failed to load share links:', e);
} finally {
isLoadingShares = false;
}
}
async function createShareLink() {
isCreatingShare = true;
try {
const newShare = await shareApi.createShare(deckId);
shareLinks = [newShare, ...shareLinks];
} catch (e) {
console.error('Failed to create share link:', e);
} finally {
isCreatingShare = false;
}
}
async function deleteShareLink(shareId: string) {
try {
await shareApi.deleteShare(shareId);
shareLinks = shareLinks.filter((s) => s.id !== shareId);
} catch (e) {
console.error('Failed to delete share link:', e);
}
}
function getShareUrl(shareCode: string): string {
if (!browser) return '';
return `${window.location.origin}/shared/${shareCode}`;
}
async function copyShareLink(share: ShareLink) {
const url = getShareUrl(share.shareCode);
try {
await navigator.clipboard.writeText(url);
copiedLinkId = share.id;
setTimeout(() => {
copiedLinkId = null;
}, 2000);
} catch (e) {
console.error('Failed to copy link:', e);
}
}
</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>
<button
onclick={openShareModal}
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"
>
<Share2 class="w-5 h-5" />
Share
</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}
<!-- Share Modal -->
{#if showShareModal}
<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-lg">
<div
class="p-6 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<Share2 class="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">Share Presentation</h2>
</div>
<button
onclick={() => (showShareModal = 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">
{#if isLoadingShares}
<div class="flex items-center justify-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-4 border-primary-500 border-t-transparent"
></div>
</div>
{:else}
<div class="space-y-4">
<!-- Create new link button -->
<button
onclick={createShareLink}
disabled={isCreatingShare}
class="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg text-slate-600 dark:text-slate-400 hover:border-primary-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors disabled:opacity-50"
>
<Link class="w-5 h-5" />
{isCreatingShare ? 'Creating...' : 'Create Share Link'}
</button>
<!-- Existing links -->
{#if shareLinks.length > 0}
<div class="space-y-3 mt-4">
<h3 class="text-sm font-medium text-slate-700 dark:text-slate-300">Active Links</h3>
{#each shareLinks as share (share.id)}
<div
class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg"
>
<div class="flex-1 min-w-0">
<div
class="flex items-center gap-2 text-sm font-mono text-slate-600 dark:text-slate-400"
>
<Link class="w-4 h-4 flex-shrink-0" />
<span class="truncate">{getShareUrl(share.shareCode)}</span>
</div>
<div class="text-xs text-slate-500 dark:text-slate-500 mt-1">
Created {new Date(share.createdAt).toLocaleDateString()}
{#if share.expiresAt}
· Expires {new Date(share.expiresAt).toLocaleDateString()}
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<a
href="/shared/{share.shareCode}"
target="_blank"
class="p-2 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-lg transition-colors"
title="Open in new tab"
>
<ExternalLink class="w-4 h-4 text-slate-600 dark:text-slate-400" />
</a>
<button
onclick={() => copyShareLink(share)}
class="p-2 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-lg transition-colors"
title="Copy link"
>
{#if copiedLinkId === share.id}
<Check class="w-4 h-4 text-green-600" />
{:else}
<Copy class="w-4 h-4 text-slate-600 dark:text-slate-400" />
{/if}
</button>
<button
onclick={() => deleteShareLink(share.id)}
class="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title="Delete link"
>
<Trash2 class="w-4 h-4 text-red-500" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-center text-sm text-slate-500 dark:text-slate-400 py-4">
No share links yet. Create one to share this presentation.
</p>
{/if}
</div>
{/if}
</div>
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900/50 rounded-b-xl">
<p class="text-xs text-slate-500 dark:text-slate-400 text-center">
Anyone with the link can view this presentation without signing in.
</p>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,7 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { feedbackService } from '$lib/api/feedback';
import { auth } from '$lib/stores/auth.svelte';
</script>
<FeedbackPage {feedbackService} appName="Presi" currentUserId={auth.user?.id} />

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
function handleSubscribe(planId: string) {
console.log('Subscribe to plan:', planId);
alert(`Abo "${planId}" ausgewählt. Bezahlsystem wird noch integriert.`);
}
function handleBuyPackage(packageId: string) {
console.log('Buy package:', packageId);
alert(`Paket "${packageId}" ausgewählt. Bezahlsystem wird noch integriert.`);
}
</script>
<svelte:head>
<title>Mana - Presi</title>
</svelte:head>
<div class="mana-page">
<SubscriptionPage
appName="Presi"
onSubscribe={handleSubscribe}
onBuyPackage={handleBuyPackage}
currentPlanId="free"
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="2 Monate gratis"
/>
</div>
<style>
.mana-page {
min-height: 100%;
width: 100%;
overflow-x: hidden;
background-color: hsl(var(--background));
}
</style>

View file

@ -0,0 +1,310 @@
<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 as string;
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,136 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ProfilePage } from '@manacore/shared-profile-ui';
import type { UserProfile, ProfileActions } from '@manacore/shared-profile-ui';
import { auth } from '$lib/stores/auth.svelte';
import { decksStore } from '$lib/stores/decks.svelte';
import { goto } from '$app/navigation';
import { FolderOpen, Layers, Calendar } from 'lucide-svelte';
let isLoading = $state(true);
// Map auth store user to UserProfile
let userProfile = $derived<UserProfile>({
id: auth.user?.id || '',
email: auth.user?.email || '',
role: auth.user?.role,
});
// Profile actions
const actions: ProfileActions = {
onLogout: async () => {
await auth.logout();
goto('/login');
},
onDeleteAccount: () => {
alert('Konto löschen ist noch nicht implementiert.');
},
};
onMount(async () => {
await decksStore.loadDecks();
isLoading = false;
});
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
}
</script>
<ProfilePage
user={userProfile}
appName="Presi"
{actions}
pageTitle="Profil"
accountInfoTitle="Konto-Informationen"
actionsTitle="Aktionen"
emailLabel="E-Mail"
nameLabel="Name"
memberSinceLabel="Mitglied seit"
lastLoginLabel="Letzter Login"
roleLabel="Rolle"
editProfileLabel="Profil bearbeiten"
changePasswordLabel="Passwort ändern"
logoutLabel="Abmelden"
deleteAccountLabel="Konto löschen"
deleteAccountWarning="Diese Aktion kann nicht rückgängig gemacht werden."
/>
<!-- Stats Section -->
<div class="mx-auto max-w-xl px-4 pb-8">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-4 border-primary border-t-transparent"
></div>
</div>
{:else}
<!-- Stats Card -->
<section class="mb-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Statistiken</h2>
<div
class="p-5 rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm"
>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<FolderOpen class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">
{decksStore.decks.length}
</div>
<div class="text-sm text-muted-foreground">Präsentationen</div>
</div>
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<Layers class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">-</div>
<div class="text-sm text-muted-foreground">Folien</div>
</div>
</div>
</div>
</section>
<!-- Recent Presentations -->
{#if decksStore.decks.length > 0}
<section>
<h2 class="text-lg font-semibold text-foreground mb-4">Letzte Präsentationen</h2>
<div
class="rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm overflow-hidden"
>
<div class="divide-y divide-black/10 dark:divide-white/10">
{#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-black/5 dark:hover:bg-white/5 transition-colors"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<FolderOpen class="w-5 h-5 text-primary" />
</div>
<div>
<h4 class="font-medium text-foreground">{deck.title}</h4>
{#if deck.description}
<p class="text-sm text-muted-foreground truncate max-w-xs">
{deck.description}
</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar class="w-4 h-4" />
{formatDate(deck.updatedAt)}
</div>
</a>
{/each}
</div>
</div>
</section>
{/if}
{/if}
</div>

View file

@ -0,0 +1,194 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
import { userSettings } from '$lib/stores/user-settings.svelte';
import {
SettingsPage,
SettingsSection,
SettingsCard,
SettingsRow,
SettingsToggle,
SettingsDangerZone,
SettingsDangerButton,
GlobalSettingsSection,
} from '@manacore/shared-ui';
function handleLogout() {
auth.logout();
goto('/login');
}
function setThemeMode(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
onMount(async () => {
await userSettings.load();
});
</script>
<svelte:head>
<title>Settings - Presi</title>
</svelte:head>
<SettingsPage title="Settings" subtitle="Manage your account and preferences.">
<!-- Account Section -->
<SettingsSection title="Account">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
{/snippet}
<SettingsCard>
<SettingsRow label="Email" description={auth.user?.email || 'Not available'}>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
{/snippet}
</SettingsRow>
<SettingsRow label="User ID" description={auth.user?.id || 'Not available'} border={false}>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
{/snippet}
<span class="font-mono text-xs text-[hsl(var(--muted-foreground))]"
>{auth.user?.id || '-'}</span
>
</SettingsRow>
</SettingsCard>
</SettingsSection>
<!-- Appearance Section -->
<SettingsSection title="Appearance">
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
{/snippet}
<SettingsCard>
<div class="px-5 py-4">
<p class="font-medium text-[hsl(var(--foreground))] mb-2">Theme</p>
<p class="text-sm text-[hsl(var(--muted-foreground))] mb-4">Choose your preferred theme</p>
<div class="grid grid-cols-3 gap-3">
<button
onclick={() => setThemeMode('light')}
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors
{theme.mode === 'light'
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)]'
: 'border-[hsl(var(--border))]'}"
>
<svg
class="w-6 h-6 text-amber-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<span class="text-sm font-medium text-[hsl(var(--foreground))]">Light</span>
</button>
<button
onclick={() => setThemeMode('dark')}
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors
{theme.mode === 'dark'
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)]'
: 'border-[hsl(var(--border))]'}"
>
<svg
class="w-6 h-6 text-indigo-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
<span class="text-sm font-medium text-[hsl(var(--foreground))]">Dark</span>
</button>
<button
onclick={() => setThemeMode('system')}
class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors
{theme.mode === 'system'
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)]'
: 'border-[hsl(var(--border))]'}"
>
<svg
class="w-6 h-6 text-[hsl(var(--muted-foreground))]"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<span class="text-sm font-medium text-[hsl(var(--foreground))]">System</span>
</button>
</div>
</div>
</SettingsCard>
</SettingsSection>
<!-- Global Settings Section -->
<GlobalSettingsSection {userSettings} />
<!-- Danger Zone -->
<SettingsDangerZone title="Danger Zone">
<SettingsDangerButton
label="Sign out"
description="Sign out of your account on this device"
buttonText="Sign out"
onclick={handleLogout}
border={false}
>
{#snippet icon()}
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
{/snippet}
</SettingsDangerButton>
</SettingsDangerZone>
</SettingsPage>

View file

@ -0,0 +1,314 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { shareApi } from '$lib/api/client';
import type { Slide } from '@presi/shared';
import {
ChevronLeft,
ChevronRight,
Play,
Pause,
Maximize,
Minimize,
Clock,
Presentation,
AlertCircle,
} from 'lucide-svelte';
let deck = $state<any>(null);
let slides = $state<Slide[]>([]);
let isLoading = $state(true);
let error = $state('');
let currentSlideIndex = $state(0);
let isFullscreen = $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 shareCode = $page.params.code as string;
async function loadSharedDeck() {
try {
const data = await shareApi.getByCode(shareCode);
deck = data;
slides = data.slides || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load shared deck';
} finally {
isLoading = false;
}
}
onMount(() => {
loadSharedDeck();
// 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);
};
});
function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowLeft':
case 'a':
prevSlide();
break;
case 'ArrowRight':
case 'd':
case ' ':
nextSlide();
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 < slides.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')}`;
}
const currentSlide = $derived(slides[currentSlideIndex]);
</script>
<svelte:head>
<title>{deck?.title || 'Shared Presentation'} - Presi</title>
</svelte:head>
<div class="fixed inset-0 bg-slate-900 text-white flex flex-col">
{#if 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 error}
<div class="flex-1 flex flex-col items-center justify-center px-4">
<div class="p-4 bg-red-900/30 rounded-full mb-4">
<AlertCircle class="w-12 h-12 text-red-400" />
</div>
<h1 class="text-2xl font-bold mb-2">Unable to load presentation</h1>
<p class="text-slate-400 text-center max-w-md mb-6">{error}</p>
<a
href="/login"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg font-medium transition-colors"
>
Sign in to Presi
</a>
</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">
<div class="flex items-center gap-2 text-primary-400">
<Presentation class="w-5 h-5" />
<span class="text-sm font-medium">Presi</span>
</div>
<h1 class="text-lg font-medium truncate max-w-xs">{deck?.title}</h1>
<span class="text-sm text-slate-400">
Slide {currentSlideIndex + 1} of {slides.length}
</span>
</div>
<a
href="/login"
class="px-4 py-1.5 bg-primary-600 hover:bg-primary-700 rounded-lg text-sm font-medium transition-colors"
>
Sign in
</a>
</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>
<!-- 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 slides 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 === slides.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: Fullscreen -->
<div class="flex items-center gap-2">
<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 flex-col items-center justify-center">
<p class="text-slate-400 mb-4">No slides in this presentation</p>
<a
href="/login"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg font-medium transition-colors"
>
Sign in to Presi
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ThemePage } from '@manacore/shared-theme-ui';
import { theme } from '$lib/stores/theme';
</script>
<svelte:head>
<title>Themes | Presi</title>
</svelte:head>
<ThemePage
currentVariant={theme.variant}
onSelectTheme={(v) => theme.setVariant(v)}
showModeSelector={true}
currentMode={theme.mode}
onModeChange={(m) => theme.setMode(m)}
showBackButton={true}
onBack={() => goto('/')}
/>

View file

@ -0,0 +1,41 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { getForgotPasswordTranslations } from '@manacore/shared-i18n';
import { PresiLogo } from '@manacore/shared-branding';
import { auth } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import '$lib/i18n';
// Get translations based on current locale
const translations = $derived(getForgotPasswordTranslations($locale || 'de'));
async function handleForgotPassword(email: string) {
return auth.forgotPassword(email);
}
</script>
<svelte:head>
<title>{translations.titleForm} | Presi</title>
</svelte:head>
<ForgotPasswordPage
appName="Presi"
logo={PresiLogo}
primaryColor="#f97316"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#fff7ed"
darkBackground="#1c1210"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { locale } from 'svelte-i18n';
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { PresiLogo } from '@manacore/shared-branding';
import { auth } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import '$lib/i18n';
// Get redirect URL from query params
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
// Get translations based on current locale
const translations = $derived(getLoginTranslations($locale || 'de'));
async function handleSignIn(email: string, password: string) {
return auth.login(email, password);
}
</script>
<svelte:head>
<title>{translations.title} | Presi</title>
</svelte:head>
<LoginPage
appName="Presi"
logo={PresiLogo}
primaryColor="#f97316"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#fff7ed"
darkBackground="#1c1210"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { getRegisterTranslations } from '@manacore/shared-i18n';
import { PresiLogo } from '@manacore/shared-branding';
import { auth } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import '$lib/i18n';
// Get translations based on current locale
const translations = $derived(getRegisterTranslations($locale || 'de'));
async function handleSignUp(email: string, password: string) {
return auth.register(email, password);
}
</script>
<svelte:head>
<title>{translations.title} | Presi</title>
</svelte:head>
<RegisterPage
appName="Presi"
logo={PresiLogo}
primaryColor="#f97316"
onSignUp={handleSignUp}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#fff7ed"
darkBackground="#1c1210"
{translations}
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}
</RegisterPage>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
let { children } = $props();
let loading = $state(true);
onMount(async () => {
// Initialize theme
theme.initialize();
// Initialize auth
auth.init();
loading = false;
});
</script>
<svelte:head>
<title>Presi - Presentation Creator</title>
</svelte:head>
{#if loading || auth.isLoading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}