mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 23:56:43 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
39
apps-archived/presi/apps/web/src/app.css
Normal file
39
apps-archived/presi/apps/web/src/app.css
Normal 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));
|
||||
}
|
||||
12
apps-archived/presi/apps/web/src/app.html
Normal file
12
apps-archived/presi/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
270
apps-archived/presi/apps/web/src/lib/api/client.ts
Normal file
270
apps-archived/presi/apps/web/src/lib/api/client.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
15
apps-archived/presi/apps/web/src/lib/api/feedback.ts
Normal file
15
apps-archived/presi/apps/web/src/lib/api/feedback.ts
Normal 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(),
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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" />
|
||||
52
apps-archived/presi/apps/web/src/lib/i18n/index.ts
Normal file
52
apps-archived/presi/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
13
apps-archived/presi/apps/web/src/lib/i18n/locales/de.json
Normal file
13
apps-archived/presi/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
apps-archived/presi/apps/web/src/lib/i18n/locales/en.json
Normal file
13
apps-archived/presi/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
apps-archived/presi/apps/web/src/lib/i18n/locales/es.json
Normal file
13
apps-archived/presi/apps/web/src/lib/i18n/locales/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
apps-archived/presi/apps/web/src/lib/i18n/locales/fr.json
Normal file
13
apps-archived/presi/apps/web/src/lib/i18n/locales/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
apps-archived/presi/apps/web/src/lib/i18n/locales/it.json
Normal file
13
apps-archived/presi/apps/web/src/lib/i18n/locales/it.json
Normal 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"
|
||||
}
|
||||
}
|
||||
191
apps-archived/presi/apps/web/src/lib/stores/auth.svelte.ts
Normal file
191
apps-archived/presi/apps/web/src/lib/stores/auth.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
185
apps-archived/presi/apps/web/src/lib/stores/decks.svelte.ts
Normal file
185
apps-archived/presi/apps/web/src/lib/stores/decks.svelte.ts
Normal 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();
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
10
apps-archived/presi/apps/web/src/lib/stores/theme.ts
Normal file
10
apps-archived/presi/apps/web/src/lib/stores/theme.ts
Normal 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%',
|
||||
},
|
||||
});
|
||||
|
|
@ -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(),
|
||||
});
|
||||
250
apps-archived/presi/apps/web/src/routes/(app)/+layout.svelte
Normal file
250
apps-archived/presi/apps/web/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
236
apps-archived/presi/apps/web/src/routes/(app)/+page.svelte
Normal file
236
apps-archived/presi/apps/web/src/routes/(app)/+page.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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('/')}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
39
apps-archived/presi/apps/web/src/routes/+layout.svelte
Normal file
39
apps-archived/presi/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue