feat: add i18n localization with language switcher to all web apps

- Add svelte-i18n configuration with SSR support to all web apps
- Create LanguageSelector component for each app with brand colors
- Add German and English locale files
- Integrate language switcher into login pages via headerControls snippet
- Fix Tailwind v4 @source directives for shared package scanning
- Update AppSlider styling to match login container design

Apps updated:
- Memoro (gold #f8d62b)
- Märchenzauber (pink #FF6B9D)
- ManaDeck (purple #8b5cf6)
- ManaCore (indigo #6366f1)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-25 01:41:25 +01:00
parent bd869dfe09
commit 926ca231b5
147 changed files with 7090 additions and 2276 deletions

View file

@ -5,17 +5,41 @@ This document outlines the plan to unify common code across all web apps in the
## Current Shared Packages
- [x] `@manacore/shared-icons` - Unified Phosphor Icons for all web apps
- [x] `@manacore/shared-ui` - Unified UI Components (Text, Button, Badge, Toggle, Input, Modal)
- [x] `@manacore/shared-ui` - Unified UI Components (Text, Button, Badge, Toggle, Input, Modal, Card, Navigation, Forms)
- [x] `@manacore/shared-auth` - Unified Auth Logic (Supabase client, token management)
- [x] `@manacore/shared-auth-ui` - Unified Auth UI (LoginPage, RegisterPage, OAuth buttons)
- [x] `@manacore/shared-tailwind` - Unified Tailwind Config (HSL colors, preset, themes.css)
- [x] `@manacore/shared-theme` - **NEW** Unified Theme Store (Svelte 5, 4 variants, light/dark/system)
- [x] `@manacore/shared-theme-ui` - **NEW** Theme UI Components (ThemeToggle, ThemeSelector)
- [x] `@manacore/shared-utils` - Unified Utilities (formatting, validation, async)
- [x] `@manacore/shared-theme` - Unified Theme Store (Svelte 5, 4 variants, light/dark/system)
- [x] `@manacore/shared-theme-ui` - Theme UI Components (ThemeToggle, ThemeSelector)
- [x] `@manacore/shared-utils` - Unified Utilities (formatting, validation, async, date, keyboard)
- [x] `@manacore/shared-types` - Unified TypeScript Types
- [x] `@manacore/shared-supabase` - Unified Supabase Client Factory
- [x] `@manacore/shared-i18n` - Unified i18n (languages, locale detection, translations)
- [x] `@manacore/shared-config` - Unified Config (env validation)
- [x] `@manacore/shared-branding` - **NEW** Unified App Branding (logos, colors, app config)
- [x] `@manacore/shared-subscription-types` - Subscription Type Definitions
- [x] `@manacore/shared-subscription-ui` - Subscription UI Components
---
## Recently Completed (2025-11-24)
### App Migration to Shared Packages
All web apps now use the shared packages consistently:
**Logo Components** - Migrated to `@manacore/shared-branding`:
- `memoro/apps/web/src/lib/components/MemoroLogo.svelte` → uses `AppLogo`
- `manadeck/apps/web/src/lib/components/ManaDeckLogo.svelte` → uses `AppLogo`
- `manacore/apps/web/src/lib/components/ManaCoreLogo.svelte` → uses `AppLogo`
- `maerchenzauber/apps/web/src/lib/components/StorytellerLogo.svelte` → uses `AppLogo`
**Formatter Functions** - Migrated to `@manacore/shared-utils`:
- `memoro/apps/web/src/lib/components/memo/AdditionalRecordings.svelte` → uses `formatDurationFromMs`, `formatFileSize`
- `memoro/apps/web/src/lib/components/RecordingButton.svelte` → uses `formatDuration`
- `memoro/apps/web/src/lib/components/statistics/OverviewCard.svelte` → uses `formatDurationWithUnits`
- `memoro/apps/web/src/lib/components/statistics/InsightsCard.svelte` → uses `formatDurationWithUnits`
- `memoro/apps/web/src/lib/components/statistics/ProductivityCard.svelte` → uses `formatDurationWithUnits`
---
@ -242,6 +266,45 @@ packages/shared-{name}/
---
## Package Details
### `@manacore/shared-branding`
Centralized branding configuration for all Mana ecosystem apps.
**Exports**:
- `AppLogo` - SVG logo component that renders app-specific logo
- `AppLogoWithName` - Logo with app name and tagline
- `ManaIcon` - Generic Mana icon component
- `APP_BRANDING` - Configuration object with colors, names, taglines
- `AppId` type - Union type of all app IDs
**Usage**:
```svelte
<script>
import { AppLogo } from '@manacore/shared-branding';
</script>
<AppLogo app="memoro" size={32} />
<AppLogo app="manacore" size={55} color="#fff" />
```
### `@manacore/shared-utils/format`
Duration and formatting utilities.
**Exports**:
- `formatDuration(seconds)` - Returns `MM:SS` or `HH:MM:SS`
- `formatDurationFromMs(ms)` - Converts milliseconds first
- `formatDurationWithUnits(seconds, locale)` - Returns `2h 30m` style
- `formatDurationHumanReadable(seconds, locale)` - Returns `2 Stunden 30 Minuten`
- `formatFileSize(bytes)` - Returns `1.5 MB`
- `formatNumber(num, locale)` - Locale-aware number formatting
- `formatCurrency(amount, currency, locale)` - Currency formatting
- `formatPercent(value, decimals, locale)` - Percentage formatting
---
## Notes
- Created: 2025-11-24

View file

@ -108,9 +108,9 @@
"@babel/core": "^7.20.0",
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^29.5.12",
"@types/react": "~19.1.17",
"@types/react-dom": "^18.3.0",
"@types/react-test-renderer": "^18.0.7",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"@types/react-test-renderer": "^19.0.0",
"@types/uuid": "^10.0.0",
"jest": "^29.2.1",
"jest-expo": "~54.0.13",

View file

@ -28,6 +28,7 @@
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-config": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
@ -40,6 +41,7 @@
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@supabase/supabase-js": "^2.81.1"
"@supabase/supabase-js": "^2.81.1",
"svelte-i18n": "^4.0.1"
}
}

View file

@ -1,5 +1,9 @@
@import '@manacore/shared-tailwind/themes.css';
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-subscription-ui/src";

View file

@ -0,0 +1,179 @@
/**
* API Client for Märchenzauber Web App
*
* Uses the shared-auth tokenManager for automatic token management.
*/
import { PUBLIC_API_URL } from '$env/static/public';
import { tokenManager } from '$lib/auth';
import type { ApiError } from '$lib/types/api';
const API_BASE_URL = PUBLIC_API_URL || 'http://localhost:3002';
/**
* Check if error indicates insufficient credits
*/
function isInsufficientCreditsError(errorData: Record<string, unknown>): boolean {
return (
errorData.insufficientCredits === true ||
errorData.error === 'INSUFFICIENT_CREDITS' ||
(typeof errorData.message === 'string' &&
errorData.message.toLowerCase().includes('insufficient credits'))
);
}
/**
* Check if error is retryable (rate limit, server errors)
*/
function isRetryableError(status: number): boolean {
return status === 429 || status === 502 || status === 503 || status === 504;
}
/**
* Sleep helper for retry backoff
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Fetch with authentication and automatic token refresh
*/
export async function fetchWithAuth(
endpoint: string,
options: RequestInit = {},
retryCount = 0,
maxRetries = 2
): Promise<Response> {
// Get valid token (handles refresh automatically)
const appToken = await tokenManager.getValidToken();
if (!appToken) {
console.error('[API] No valid token available');
throw new Error('Not authenticated');
}
// Build authenticated request
const headers: HeadersInit = {
...options.headers,
Authorization: `Bearer ${appToken}`,
};
// Set Content-Type for non-FormData requests
if (!(options.body instanceof FormData)) {
(headers as Record<string, string>)['Content-Type'] = 'application/json';
}
const authenticatedOptions: RequestInit = {
...options,
headers,
};
// Build URL (avoid double slashes)
const baseUrl = API_BASE_URL.replace(/\/+$/, '');
const cleanEndpoint = endpoint.replace(/^\/+/, '');
const url = `${baseUrl}/${cleanEndpoint}`;
let response = await fetch(url, authenticatedOptions);
// Handle 401 - token expired, try to refresh
if (response.status === 401) {
try {
response = await tokenManager.handle401Response(url, authenticatedOptions);
} catch {
console.error('[API] Token refresh failed');
throw new Error('Authentication failed. Please login again.');
}
}
// Handle errors
if (!response.ok) {
let errorData: Record<string, unknown> = {};
try {
errorData = await response.clone().json();
} catch {
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
}
// Handle insufficient credits
if (isInsufficientCreditsError(errorData)) {
const error = new Error(String(errorData.message || 'Insufficient credits')) as ApiError;
error.insufficientCredits = true;
error.requiredCredits = errorData.requiredCredits as number;
error.availableCredits = errorData.availableCredits as number;
throw error;
}
// Retry on retryable errors
if (isRetryableError(response.status) && retryCount < maxRetries) {
await sleep(1000 * (retryCount + 1)); // Exponential backoff
return fetchWithAuth(endpoint, options, retryCount + 1, maxRetries);
}
// Build structured error
const apiError = new Error(
String(errorData.message || `API error: ${response.status}`)
) as ApiError;
apiError.error = errorData.error as string;
apiError.messageDE = errorData.messageDE as string;
apiError.messageEN = errorData.messageEN as string;
apiError.retryable = errorData.retryable as boolean;
apiError.technicalMessage = String(errorData.technicalMessage || errorData.message);
throw apiError;
}
return response;
}
/**
* Type-safe API helpers
*/
export const api = {
async get<T>(endpoint: string): Promise<T> {
const response = await fetchWithAuth(endpoint, { method: 'GET' });
return response.json();
},
async post<T>(endpoint: string, data?: unknown): Promise<T> {
const response = await fetchWithAuth(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
return response.json();
},
async put<T>(endpoint: string, data: unknown): Promise<T> {
const response = await fetchWithAuth(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
return response.json();
},
async delete<T>(endpoint: string): Promise<T> {
const response = await fetchWithAuth(endpoint, { method: 'DELETE' });
return response.json();
},
async upload<T>(endpoint: string, formData: FormData): Promise<T> {
const response = await fetchWithAuth(endpoint, {
method: 'POST',
body: formData,
});
return response.json();
},
};
/**
* Check if an error is a credit error
*/
export function isCreditError(
error: unknown
): error is ApiError & { insufficientCredits: true } {
return (
error instanceof Error &&
(error as ApiError).insufficientCredits === true
);
}

View file

@ -0,0 +1,452 @@
/**
* Data Service for Märchenzauber Web App
*
* Centralized API methods for stories, characters, and settings.
*/
import { fetchWithAuth } from './client';
import type {
Story,
StoryPage,
PublicStoriesResponse,
CreateStoryRequest,
} from '$lib/types/story';
import type {
Character,
PublicCharactersResponse,
CharacterCollection,
} from '$lib/types/character';
import type { Creator, UserSettings, CreditBalance } from '$lib/types/api';
/**
* Transform backend story data to frontend format
*/
function transformStory(raw: Record<string, unknown>): Story {
// Extract character image from characters_data
let characterImageUrl: string | undefined;
const charactersData = raw.characters_data as Array<Record<string, unknown>> | undefined;
if (charactersData && Array.isArray(charactersData) && charactersData.length > 0) {
characterImageUrl =
(charactersData[0].image_url as string) || (charactersData[0].imageUrl as string);
}
// Transform pages_data to pages
const pagesData = raw.pages_data as Array<Record<string, unknown>> | undefined;
const pages: StoryPage[] = pagesData
? pagesData.map((page) => ({
image: page.image_url as string,
pageNumber: page.page_number as number,
story: page.story_text as string,
blur_hash: page.blur_hash as string | undefined,
}))
: [];
return {
id: raw.id as string,
characterId: raw.character_id as string,
characterName: raw.character_name as string,
characterImageUrl,
createdAt: raw.created_at as string,
prompt: raw.prompt as string,
pages,
text: raw.text as string,
user_id: raw.user_id as string,
title: raw.title as string,
archived: raw.archived as boolean,
is_favorite: raw.is_favorite as boolean,
visibility: raw.visibility as Story['visibility'],
is_published: raw.is_published as boolean,
published_at: raw.published_at as string,
is_central: raw.is_central as boolean,
vote_count: raw.vote_count as number,
user_vote: raw.user_vote as Story['user_vote'],
metadata: raw.metadata as Story['metadata'],
};
}
/**
* Transform backend character data to frontend format
*/
function transformCharacter(raw: Record<string, unknown>): Character {
return {
id: raw.id as string,
uid: raw.uid as string,
name: raw.name as string,
originalDescription: raw.original_description as string,
characterDescriptionPrompt: raw.character_description_prompt as string,
images: (raw.images as Character['images']) || [],
imageUrl: (raw.image_url as string) || (raw.imageUrl as string),
image_url: raw.image_url as string,
createdAt: raw.created_at as string,
user_id: raw.user_id as string,
archived: raw.archived as boolean,
animalType: raw.animal_type as string,
isAnimal: raw.is_animal as boolean,
blur_hash: raw.blur_hash as string,
share_code: raw.share_code as string,
isFeatured: raw.is_featured as boolean,
vote_count: raw.vote_count as number,
user_vote: raw.user_vote as Character['user_vote'],
};
}
export const dataService = {
// ============ STORIES ============
async getStories(includeArchived = false): Promise<Story[]> {
const url = includeArchived ? '/story?includeArchived=true' : '/story';
const response = await fetchWithAuth(url, { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
const stories = result.data || [];
return stories.map(transformStory);
},
async getStoryById(id: string): Promise<Story> {
const response = await fetchWithAuth(`/story/${id}`, { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
const story = result.data || result;
return transformStory(story);
},
async createStory(request: CreateStoryRequest): Promise<{ storyId: string }> {
const response = await fetchWithAuth('/story/animal', {
method: 'POST',
body: JSON.stringify(request),
});
const result = await response.json();
if (result.error) throw new Error(result.error);
return { storyId: result.storyId || result.id };
},
async updateStory(id: string, data: Partial<Story>): Promise<void> {
const response = await fetchWithAuth(`/story/${id}`, {
method: 'PUT',
body: JSON.stringify({ ...data, updated_at: new Date().toISOString() }),
});
const result = await response.json();
if (result.error) throw new Error(result.error);
},
async deleteStory(id: string): Promise<void> {
const response = await fetchWithAuth(`/story/${id}`, { method: 'DELETE' });
const result = await response.json();
if (result.error) throw new Error(result.error);
},
async toggleFavorite(storyId: string, isFavorite: boolean): Promise<void> {
const response = await fetchWithAuth(`/story/${storyId}/favorite`, {
method: 'POST',
body: JSON.stringify({ isFavorite }),
});
const result = await response.json();
if (result.error) throw new Error(result.error);
},
async publishStory(
storyId: string,
sharingPreference: 'private' | 'link_only' | 'public'
): Promise<void> {
const response = await fetchWithAuth('/story/publish', {
method: 'POST',
body: JSON.stringify({ storyId, sharingPreference }),
});
const result = await response.json();
if (result.error) throw new Error(result.error);
},
async unpublishStory(storyId: string): Promise<void> {
const response = await fetchWithAuth(`/story/unpublish/${storyId}`, {
method: 'POST',
});
const result = await response.json();
if (result.error) throw new Error(result.error);
},
// ============ PUBLIC STORIES ============
async getPublicStories(
filter: 'popular' | 'new' | 'featured' = 'popular',
page = 1,
limit = 20
): Promise<PublicStoriesResponse> {
const params = new URLSearchParams({
filter,
page: page.toString(),
limit: limit.toString(),
});
const response = await fetchWithAuth(`/story/public?${params}`, { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
return {
stories: (result.data || []).map(transformStory),
hasMore: result.hasMore || false,
total: result.total || 0,
};
},
async getCentralStories(): Promise<Story[]> {
const response = await fetchWithAuth('/story/central', { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
return (result.data || []).map(transformStory);
},
async voteForStory(
storyId: string,
voteType: 'like' | 'love' | 'star' = 'like'
): Promise<void> {
const response = await fetchWithAuth(`/story/${storyId}/vote`, {
method: 'POST',
body: JSON.stringify({ voteType }),
});
const result = await response.json();
if (result.error) throw new Error(result.error);
},
async unvoteStory(storyId: string): Promise<void> {
const response = await fetchWithAuth(`/story/${storyId}/vote`, { method: 'DELETE' });
const result = await response.json();
if (result.error) throw new Error(result.error);
},
// ============ CHARACTERS ============
async getCharacters(includeArchived = false): Promise<Character[]> {
const url = includeArchived ? '/character?includeArchived=true' : '/character';
const response = await fetchWithAuth(url, { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
// Backend returns array directly (includes system characters)
const characters = Array.isArray(result) ? result : result.data || [];
return characters.map(transformCharacter);
},
async getCharacterById(id: string): Promise<Character> {
const response = await fetchWithAuth(`/character/${id}`, { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
const data = result.data || result;
return transformCharacter(data);
},
async updateCharacter(id: string, data: Partial<Character>): Promise<void> {
const response = await fetchWithAuth(`/character/${id}`, {
method: 'PUT',
body: JSON.stringify({ ...data, updated_at: new Date().toISOString() }),
});
const result = await response.json();
if (result.error) throw new Error(result.error);
},
async deleteCharacter(id: string): Promise<void> {
const response = await fetchWithAuth(`/character/${id}`, { method: 'DELETE' });
const result = await response.json();
if (result.error) throw new Error(result.error);
},
async generateCharacterImages(
name: string,
description: string,
options?: { isAnimal?: boolean; animalType?: string; photo?: File }
): Promise<{ characterId: string; images: string[] }> {
let response: Response;
if (options?.photo) {
// FormData for photo upload
const formData = new FormData();
formData.append('name', name);
formData.append('description', description);
formData.append('photo', options.photo);
if (options.isAnimal) formData.append('isAnimal', 'true');
if (options.animalType) formData.append('animalType', options.animalType);
response = await fetchWithAuth('/character/generate-images', {
method: 'POST',
body: formData,
});
} else {
// JSON for description-only
response = await fetchWithAuth('/character/generate-images', {
method: 'POST',
body: JSON.stringify({
name,
description,
isAnimal: options?.isAnimal,
animalType: options?.animalType,
}),
});
}
const result = await response.json();
if (result.error) throw new Error(result.error);
return {
characterId: result.characterId || result.id,
images: result.images || [],
};
},
// ============ PUBLIC CHARACTERS ============
async getPublicCharacters(
filter: 'popular' | 'new' | 'featured' = 'popular',
limit = 20,
offset = 0,
collectionId?: string
): Promise<PublicCharactersResponse> {
const params = new URLSearchParams({
filter,
limit: limit.toString(),
offset: offset.toString(),
});
if (collectionId) params.append('collection', collectionId);
const response = await fetchWithAuth(`/characters/public?${params}`, { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
return {
characters: (result.characters || []).map(transformCharacter),
hasMore: result.hasMore || false,
total: result.total || 0,
};
},
async getCharacterCollections(): Promise<CharacterCollection[]> {
const response = await fetchWithAuth('/characters/public/collections', { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
return Array.isArray(result) ? result : [];
},
async voteForCharacter(
characterId: string,
voteType: 'like' | 'love' | 'star'
): Promise<void> {
const response = await fetchWithAuth('/characters/public/vote', {
method: 'POST',
body: JSON.stringify({ characterId, voteType }),
});
const result = await response.json();
if (result.error) throw new Error(result.error);
},
async removeCharacterVote(characterId: string): Promise<void> {
const response = await fetchWithAuth(`/characters/public/vote/${characterId}`, {
method: 'DELETE',
});
const result = await response.json();
if (result.error) throw new Error(result.error);
},
async cloneCharacter(characterId: string): Promise<Character> {
const response = await fetchWithAuth(`/characters/public/clone/${characterId}`, {
method: 'POST',
});
const result = await response.json();
if (result.error) throw new Error(result.error);
return transformCharacter(result);
},
async getCharacterByShareCode(shareCode: string): Promise<Character> {
const response = await fetchWithAuth(`/characters/public/share/${shareCode}`, {
method: 'GET',
});
const result = await response.json();
if (result.error) throw new Error(result.error);
return transformCharacter(result.character);
},
// ============ CREATORS ============
async getCreators(): Promise<Creator[]> {
const response = await fetchWithAuth('/creator', { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
return result.data || [];
},
// ============ SETTINGS ============
async getUserSettings(): Promise<UserSettings> {
const response = await fetchWithAuth('/settings/user', { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
return result.data || result;
},
async updateUserSettings(settings: Partial<UserSettings>): Promise<void> {
const response = await fetchWithAuth('/settings/user', {
method: 'PUT',
body: JSON.stringify(settings),
});
const result = await response.json();
if (result.error) throw new Error(result.error);
},
// ============ CREDITS ============
async getCreditBalance(): Promise<CreditBalance> {
const response = await fetchWithAuth('/credits/balance', { method: 'GET' });
const result = await response.json();
if (result.error) throw new Error(result.error);
return {
balance: result.balance || result.credits || 0,
maxLimit: result.maxLimit || result.max_limit || 100000,
};
},
async checkCredits(requiredCredits: number): Promise<boolean> {
const response = await fetchWithAuth('/credits/check', {
method: 'POST',
body: JSON.stringify({ requiredCredits }),
});
const result = await response.json();
if (result.error) throw new Error(result.error);
return result.hasEnough || false;
},
};

View file

@ -0,0 +1,6 @@
/**
* API Index - Re-export all API utilities
*/
export { fetchWithAuth, api, isCreditError } from './client';
export { dataService } from './dataService';

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { LanguageSelector } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
import { theme } from '$lib/stores/theme';
let isDark = $derived(theme.isDark);
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
</script>
<LanguageSelector
{currentLocale}
{supportedLocales}
onLocaleChange={handleLocaleChange}
{isDark}
primaryColor="#FF6B9D"
/>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import type { Character } from '$lib/types/character';
import { isSystemCharacter } from '$lib/types/character';
interface Props {
character: Character;
size?: 'sm' | 'md' | 'lg';
showName?: boolean;
}
let { character, size = 'md', showName = true }: Props = $props();
// Size classes
const sizeClasses = {
sm: 'h-16 w-16',
md: 'h-24 w-24',
lg: 'h-32 w-32',
};
const textSizeClasses = {
sm: 'text-xs',
md: 'text-xs',
lg: 'text-sm',
};
// Get image URL
let imageUrl = $derived(character.imageUrl || character.image_url || '/images/placeholder-character.png');
// Check if system character
let isSystem = $derived(isSystemCharacter(character));
</script>
<a
href="/characters/{character.id}"
class="group flex flex-shrink-0 flex-col items-center gap-2 text-center"
>
<!-- Avatar -->
<div class="relative">
<div
class="{sizeClasses[size]} overflow-hidden rounded-full border-3 border-white bg-gradient-to-br from-pink-200 to-purple-200 shadow-md transition-all group-hover:shadow-lg group-hover:scale-105 dark:border-gray-700 dark:from-pink-900/30 dark:to-purple-900/30"
>
<img
src={imageUrl}
alt={character.name}
class="h-full w-full object-cover"
loading="lazy"
/>
</div>
<!-- System Character Badge -->
{#if isSystem}
<div
class="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-gradient-to-r from-amber-400 to-yellow-500 text-white shadow-md"
title="System-Charakter"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
</div>
{/if}
</div>
<!-- Name -->
{#if showName}
<span
class="{textSizeClasses[size]} max-w-[80px] truncate font-medium text-gray-700 group-hover:text-pink-600 dark:text-gray-300 dark:group-hover:text-pink-400"
>
{character.name}
</span>
{/if}
</a>

View file

@ -0,0 +1,58 @@
<script lang="ts">
interface Props {
title: string;
description?: string;
actionLabel?: string;
actionHref?: string;
icon?: 'book' | 'users' | 'search' | 'archive';
}
let {
title,
description,
actionLabel,
actionHref,
icon = 'book',
}: Props = $props();
const iconPaths: Record<string, string> = {
book: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253',
users: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
archive: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
};
</script>
<div class="flex flex-col items-center justify-center rounded-2xl bg-gray-50 px-6 py-12 text-center dark:bg-gray-800/50">
<!-- Icon -->
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-pink-100 dark:bg-pink-900/30">
<svg class="h-8 w-8 text-pink-500 dark:text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d={iconPaths[icon]} />
</svg>
</div>
<!-- Title -->
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
{title}
</h3>
<!-- Description -->
{#if description}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
{/if}
<!-- Action Button -->
{#if actionLabel && actionHref}
<a
href={actionHref}
class="mt-4 inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md transition-all hover:from-pink-600 hover:to-purple-700 hover:shadow-lg"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
{actionLabel}
</a>
{/if}
</div>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dataService } from '$lib/api';
let balance = $state<number | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
async function fetchBalance() {
try {
loading = true;
error = null;
const result = await dataService.getCreditBalance();
balance = result.balance;
} catch (e) {
console.error('[ManaCounter] Failed to fetch balance:', e);
error = 'Fehler beim Laden';
} finally {
loading = false;
}
}
onMount(() => {
fetchBalance();
// Refresh every 60 seconds
const interval = setInterval(fetchBalance, 60000);
return () => clearInterval(interval);
});
// Format number with thousands separator
function formatBalance(num: number): string {
return new Intl.NumberFormat('de-DE').format(num);
}
</script>
<a
href="/subscription"
class="flex items-center gap-2 rounded-xl bg-gradient-to-r from-amber-100 to-yellow-100 px-3 py-2 text-sm font-medium text-amber-700 transition-all hover:from-amber-200 hover:to-yellow-200 dark:from-amber-900/30 dark:to-yellow-900/30 dark:text-amber-400 dark:hover:from-amber-900/50 dark:hover:to-yellow-900/50"
title="Mana-Guthaben"
>
<!-- Mana Icon (Sparkle) -->
<svg class="h-5 w-5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z" />
</svg>
<!-- Balance Display -->
{#if loading}
<span class="h-4 w-12 animate-pulse rounded bg-amber-200 dark:bg-amber-800"></span>
{:else if error}
<span class="text-red-500">!</span>
{:else if balance !== null}
<span class="tabular-nums">{formatBalance(balance)}</span>
{:else}
<span>-</span>
{/if}
</a>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import { authStore } from '$lib/stores/authStore.svelte';
import ManaCounter from '$lib/components/credits/ManaCounter.svelte';
interface Props {
onMenuClick?: () => void;
onLogout?: () => void;
}
let { onMenuClick, onLogout }: Props = $props();
// Icon paths
const menuIcon = 'M4 6h16M4 12h16M4 18h16';
const userIcon = 'M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z';
</script>
<header class="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-pink-200/50 bg-white/80 px-4 backdrop-blur-xl lg:px-6 dark:border-gray-700/50 dark:bg-gray-900/80">
<!-- Left: Mobile Menu Button -->
<div class="flex items-center gap-4">
<button
onclick={onMenuClick}
class="rounded-lg p-2 text-gray-500 hover:bg-pink-100 hover:text-pink-600 lg:hidden dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-pink-400"
aria-label="Menü öffnen"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={menuIcon} />
</svg>
</button>
<!-- Page Title (optional - can be passed as prop later) -->
<h1 class="hidden text-lg font-semibold text-gray-800 lg:block dark:text-gray-200">
Willkommen zurück!
</h1>
</div>
<!-- Right: Mana Counter & User Menu -->
<div class="flex items-center gap-3">
<!-- Mana Counter -->
<ManaCounter />
<!-- User Avatar/Menu -->
<div class="relative">
<button
class="flex items-center gap-2 rounded-xl bg-gradient-to-r from-pink-100 to-purple-100 px-3 py-2 text-sm font-medium text-gray-700 transition-all hover:from-pink-200 hover:to-purple-200 dark:from-gray-800 dark:to-gray-700 dark:text-gray-200 dark:hover:from-gray-700 dark:hover:to-gray-600"
>
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-r from-pink-500 to-purple-500 text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={userIcon} />
</svg>
</div>
<span class="hidden max-w-[120px] truncate sm:inline">
{authStore.user?.email?.split('@')[0] || 'Benutzer'}
</span>
</button>
</div>
</div>
</header>

View file

@ -0,0 +1,171 @@
<script lang="ts">
import { StorytellerLogo } from '@manacore/shared-branding';
interface Props {
isCollapsed?: boolean;
currentPath?: string;
onToggle?: () => void;
onLogout?: () => void;
isMobile?: boolean;
}
let {
isCollapsed = false,
currentPath = '/',
onToggle,
onLogout,
isMobile = false,
}: Props = $props();
interface NavItem {
path: string;
label: string;
icon: string;
shortcut?: string;
}
const navItems: NavItem[] = [
{ path: '/dashboard', label: 'Start', icon: 'home', shortcut: '⌘1' },
{ path: '/stories', label: 'Geschichten', icon: 'book', shortcut: '⌘2' },
{ path: '/characters', label: 'Charaktere', icon: 'users', shortcut: '⌘3' },
{ path: '/discover', label: 'Entdecken', icon: 'compass', shortcut: '⌘4' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings', shortcut: '⌘5' },
];
const bottomNavItems: NavItem[] = [
{ path: '/subscription', label: 'Abonnement', icon: 'sparkles' },
{ path: '/help', label: 'Hilfe', icon: 'help-circle' },
];
function isActive(path: string): boolean {
if (path === '/') {
return currentPath === '/';
}
return currentPath.startsWith(path);
}
// Icon paths (simplified SVG paths)
const iconPaths: Record<string, string> = {
home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
book: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253',
users: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z',
compass: 'M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM12 15.75h.008v.008H12v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z',
settings: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
sparkles: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z',
'help-circle': 'M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z',
logout: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75',
'chevron-left': 'M15.75 19.5L8.25 12l7.5-7.5',
'chevron-right': 'M8.25 4.5l7.5 7.5-7.5 7.5',
};
</script>
<aside
class="fixed inset-y-0 left-0 z-30 flex flex-col border-r border-pink-200/50 bg-white/80 backdrop-blur-xl transition-all duration-300 dark:border-gray-700/50 dark:bg-gray-900/80"
class:w-64={!isCollapsed || isMobile}
class:w-20={isCollapsed && !isMobile}
>
<!-- Logo -->
<div class="flex h-16 items-center justify-between border-b border-pink-200/50 px-4 dark:border-gray-700/50">
{#if !isCollapsed || isMobile}
<a href="/" class="flex items-center gap-3">
<StorytellerLogo size={32} />
<span class="text-lg font-bold text-pink-600 dark:text-pink-400">Märchenzauber</span>
</a>
{:else}
<a href="/" class="mx-auto">
<StorytellerLogo size={32} />
</a>
{/if}
<!-- Collapse Toggle (Desktop only) -->
{#if !isMobile}
<button
onclick={onToggle}
class="hidden rounded-lg p-1.5 text-gray-500 hover:bg-pink-100 hover:text-pink-600 lg:block dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-pink-400"
aria-label={isCollapsed ? 'Sidebar erweitern' : 'Sidebar einklappen'}
>
<svg
class="h-5 w-5 transition-transform"
class:rotate-180={isCollapsed}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={iconPaths['chevron-left']} />
</svg>
</button>
{/if}
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto p-3">
<ul class="space-y-1">
{#each navItems as item}
<li>
<a
href={item.path}
class="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all {isActive(item.path) ? 'bg-gradient-to-r from-pink-500 to-purple-500 text-white shadow-lg shadow-pink-500/25' : 'text-gray-700 hover:bg-pink-50 dark:text-gray-300 dark:hover:bg-gray-800'} {isCollapsed && !isMobile ? 'justify-center' : ''}"
title={isCollapsed && !isMobile ? item.label : undefined}
>
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={iconPaths[item.icon]} />
</svg>
{#if !isCollapsed || isMobile}
<span class="flex-1">{item.label}</span>
{#if item.shortcut}
<kbd class="hidden rounded bg-pink-100/50 px-1.5 py-0.5 text-xs text-pink-600 lg:inline dark:bg-gray-800 dark:text-pink-400">
{item.shortcut}
</kbd>
{/if}
{/if}
</a>
</li>
{/each}
</ul>
<!-- Divider -->
<hr class="my-4 border-pink-200/50 dark:border-gray-700/50" />
<!-- Bottom Nav -->
<ul class="space-y-1">
{#each bottomNavItems as item}
<li>
<a
href={item.path}
class="flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-600 transition-all hover:bg-pink-50 dark:text-gray-400 dark:hover:bg-gray-800"
class:bg-pink-50={isActive(item.path)}
class:text-pink-600={isActive(item.path)}
class:dark:bg-gray-800={isActive(item.path)}
class:dark:text-pink-400={isActive(item.path)}
class:justify-center={isCollapsed && !isMobile}
title={isCollapsed && !isMobile ? item.label : undefined}
>
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={iconPaths[item.icon]} />
</svg>
{#if !isCollapsed || isMobile}
<span>{item.label}</span>
{/if}
</a>
</li>
{/each}
</ul>
</nav>
<!-- Logout Button -->
<div class="border-t border-pink-200/50 p-3 dark:border-gray-700/50">
<button
onclick={onLogout}
class="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-gray-600 transition-all hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/20 dark:hover:text-red-400"
class:justify-center={isCollapsed && !isMobile}
title={isCollapsed && !isMobile ? 'Abmelden' : undefined}
>
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={iconPaths.logout} />
</svg>
{#if !isCollapsed || isMobile}
<span>Abmelden</span>
{/if}
</button>
</div>
</aside>

View file

@ -0,0 +1,73 @@
<script lang="ts">
import type { Story } from '$lib/types/story';
interface Props {
story: Story;
}
let { story }: Props = $props();
// Get cover image (first page or character image)
let coverImage = $derived(
story.pages?.[0]?.image || story.characterImageUrl || '/images/placeholder-story.png'
);
// Format date
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
});
}
</script>
<a
href="/stories/{story.id}"
class="group relative overflow-hidden rounded-2xl bg-white shadow-md transition-all hover:shadow-xl dark:bg-gray-800"
>
<!-- Cover Image -->
<div class="aspect-[4/3] overflow-hidden">
<img
src={coverImage}
alt={story.title || 'Geschichte'}
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
<!-- Favorite Badge -->
{#if story.is_favorite}
<div class="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-full bg-white/90 shadow-md">
<svg class="h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
</div>
{/if}
<!-- Gradient Overlay -->
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"></div>
</div>
<!-- Content -->
<div class="absolute bottom-0 left-0 right-0 p-3">
<h3 class="line-clamp-1 text-sm font-semibold text-white">
{story.title || 'Unbekannte Geschichte'}
</h3>
<div class="mt-1 flex items-center gap-2 text-xs text-white/80">
{#if story.characterName}
<span class="flex items-center gap-1">
<svg class="h-3 w-3" 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>
{story.characterName}
</span>
<span></span>
{/if}
<span>{formatDate(story.createdAt)}</span>
{#if story.pages?.length}
<span></span>
<span>{story.pages.length} Seiten</span>
{/if}
</div>
</div>
</a>

View file

@ -0,0 +1,65 @@
<script lang="ts">
interface Props {
onClose: () => void;
onRestart: () => void;
onArchive?: () => void;
}
let { onClose, onRestart, onArchive }: Props = $props();
</script>
<div class="flex h-full w-full flex-col items-center justify-center p-8 text-center">
<!-- Celebration Icon -->
<div class="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-lg shadow-pink-500/30">
<svg class="h-12 w-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
<!-- Title -->
<h2 class="mb-2 text-3xl font-bold text-white sm:text-4xl">
Ende
</h2>
<p class="mb-8 text-lg text-white/70">
Das war's! Was möchtest du als nächstes tun?
</p>
<!-- Actions -->
<div class="flex flex-col gap-3 sm:flex-row">
<!-- Restart -->
<button
onclick={(e) => { e.stopPropagation(); onRestart(); }}
class="flex items-center justify-center gap-2 rounded-xl bg-white/10 px-6 py-3 font-medium text-white backdrop-blur transition-all hover:bg-white/20"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Nochmal lesen
</button>
<!-- Back to Stories -->
<button
onclick={(e) => { e.stopPropagation(); onClose(); }}
class="flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-6 py-3 font-medium text-white shadow-lg transition-all hover:from-pink-600 hover:to-purple-700"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Zur Übersicht
</button>
<!-- Archive -->
{#if onArchive}
<button
onclick={(e) => { e.stopPropagation(); onArchive?.(); }}
class="flex items-center justify-center gap-2 rounded-xl bg-white/10 px-6 py-3 font-medium text-white/70 backdrop-blur transition-all hover:bg-white/20 hover:text-white"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
Archivieren
</button>
{/if}
</div>
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import type { StoryPage } from '$lib/types/story';
interface Props {
page: StoryPage;
pageNumber: number;
totalPages: number;
}
let { page, pageNumber, totalPages }: Props = $props();
</script>
<div class="relative flex h-full w-full flex-col items-center justify-center p-4 sm:p-8">
<!-- Image Container -->
<div class="relative w-full max-w-4xl overflow-hidden rounded-2xl shadow-2xl">
<!-- Story Image -->
<img
src={page.image}
alt="Seite {pageNumber}"
class="aspect-[4/3] w-full object-cover"
loading="eager"
/>
<!-- Text Overlay -->
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/70 to-transparent p-4 sm:p-6">
<p class="text-base leading-relaxed text-white sm:text-lg lg:text-xl">
{page.story}
</p>
</div>
<!-- Page Number Badge -->
<div class="absolute right-3 top-3 rounded-full bg-black/50 px-3 py-1 text-sm font-medium text-white backdrop-blur">
{pageNumber} / {totalPages}
</div>
</div>
</div>

View file

@ -0,0 +1,51 @@
<script lang="ts">
interface Props {
title: string;
characterName?: string;
characterImage?: string;
onStart: () => void;
}
let { title, characterName, characterImage, onStart }: Props = $props();
</script>
<div class="flex h-full w-full flex-col items-center justify-center p-8 text-center">
<!-- Character Image -->
{#if characterImage}
<div class="mb-6 h-32 w-32 overflow-hidden rounded-full border-4 border-pink-500 shadow-lg shadow-pink-500/30 sm:h-40 sm:w-40">
<img
src={characterImage}
alt={characterName || 'Charakter'}
class="h-full w-full object-cover"
/>
</div>
{/if}
<!-- Title -->
<h1 class="mb-4 max-w-2xl text-3xl font-bold text-white sm:text-4xl lg:text-5xl">
{title || 'Eine magische Geschichte'}
</h1>
<!-- Character Name -->
{#if characterName}
<p class="mb-8 text-lg text-pink-300 sm:text-xl">
mit {characterName}
</p>
{/if}
<!-- Start Button -->
<button
onclick={(e) => { e.stopPropagation(); onStart(); }}
class="group flex items-center gap-3 rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 px-8 py-4 text-lg font-semibold text-white shadow-lg transition-all hover:from-pink-600 hover:to-purple-700 hover:shadow-xl hover:scale-105"
>
<span>Geschichte starten</span>
<svg class="h-6 w-6 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</button>
<!-- Hint -->
<p class="mt-8 text-sm text-white/50">
Klicke oder drücke die Leertaste zum Fortfahren
</p>
</div>

View file

@ -0,0 +1,226 @@
<script lang="ts">
import type { Story, StoryPage } from '$lib/types/story';
import StoryPageView from './StoryPageView.svelte';
import StoryStartScreen from './StoryStartScreen.svelte';
import StoryEndScreen from './StoryEndScreen.svelte';
interface Props {
story: Story;
onClose: () => void;
onArchive?: () => void;
onToggleFavorite?: () => void;
}
let { story, onClose, onArchive, onToggleFavorite }: Props = $props();
// Current page index: -1 = start, 0-n = story pages, n+1 = end
let currentPage = $state(-1);
let isFullscreen = $state(false);
// Total pages including start and end screens
let totalPages = $derived(story.pages.length + 2);
// Navigation
function goToPage(index: number) {
if (index >= -1 && index <= story.pages.length) {
currentPage = index;
}
}
function nextPage() {
if (currentPage < story.pages.length) {
currentPage++;
}
}
function prevPage() {
if (currentPage > -1) {
currentPage--;
}
}
function restart() {
currentPage = -1;
}
// Keyboard navigation
function handleKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowRight':
case ' ':
event.preventDefault();
nextPage();
break;
case 'ArrowLeft':
event.preventDefault();
prevPage();
break;
case 'Escape':
event.preventDefault();
if (isFullscreen) {
isFullscreen = false;
} else {
onClose();
}
break;
case 'f':
case 'F':
event.preventDefault();
isFullscreen = !isFullscreen;
break;
case 'Home':
event.preventDefault();
restart();
break;
case 'End':
event.preventDefault();
currentPage = story.pages.length;
break;
}
}
// Click to advance
function handleClick(event: MouseEvent) {
const target = event.target as HTMLElement;
// Don't advance if clicking on buttons
if (target.closest('button') || target.closest('a')) return;
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
// Click on left third = prev, right two-thirds = next
if (x < width / 3) {
prevPage();
} else {
nextPage();
}
}
// Page indicator text
let pageIndicator = $derived.by(() => {
if (currentPage === -1) return 'Titelseite';
if (currentPage === story.pages.length) return 'Ende';
return `Seite ${currentPage + 1} von ${story.pages.length}`;
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 z-50 flex flex-col bg-gray-900"
class:cursor-pointer={currentPage < story.pages.length}
onclick={handleClick}
role="presentation"
>
<!-- Header (hidden in fullscreen) -->
{#if !isFullscreen}
<header class="absolute left-0 right-0 top-0 z-10 flex items-center justify-between bg-gradient-to-b from-black/80 to-transparent p-4">
<!-- Back Button -->
<button
onclick={(e) => { e.stopPropagation(); onClose(); }}
class="flex items-center gap-2 rounded-xl bg-white/10 px-3 py-2 text-sm font-medium text-white backdrop-blur transition-all hover:bg-white/20"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
<span class="hidden sm:inline">Zurück</span>
</button>
<!-- Page Indicator -->
<div class="rounded-xl bg-white/10 px-4 py-2 text-sm font-medium text-white backdrop-blur">
{pageIndicator}
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
{#if onToggleFavorite}
<button
onclick={(e) => { e.stopPropagation(); onToggleFavorite?.(); }}
class="flex h-10 w-10 items-center justify-center rounded-xl bg-white/10 text-white backdrop-blur transition-all hover:bg-white/20"
title={story.is_favorite ? 'Von Favoriten entfernen' : 'Zu Favoriten hinzufügen'}
>
<svg class="h-5 w-5" fill={story.is_favorite ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
{/if}
<button
onclick={(e) => { e.stopPropagation(); isFullscreen = !isFullscreen; }}
class="flex h-10 w-10 items-center justify-center rounded-xl bg-white/10 text-white backdrop-blur transition-all hover:bg-white/20"
title={isFullscreen ? 'Vollbild beenden (F)' : 'Vollbild (F)'}
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if isFullscreen}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
{:else}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
{/if}
</svg>
</button>
</div>
</header>
{/if}
<!-- Content -->
<div class="flex flex-1 items-center justify-center overflow-hidden">
{#if currentPage === -1}
<!-- Start Screen -->
<StoryStartScreen
title={story.title}
characterName={story.characterName}
characterImage={story.characterImageUrl}
onStart={nextPage}
/>
{:else if currentPage === story.pages.length}
<!-- End Screen -->
<StoryEndScreen
{onClose}
onRestart={restart}
{onArchive}
/>
{:else}
<!-- Story Page -->
<StoryPageView
page={story.pages[currentPage]}
pageNumber={currentPage + 1}
totalPages={story.pages.length}
/>
{/if}
</div>
<!-- Navigation Dots (hidden in fullscreen) -->
{#if !isFullscreen}
<footer class="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 to-transparent p-4">
<div class="flex items-center justify-center gap-2">
<!-- Start dot -->
<button
onclick={(e) => { e.stopPropagation(); goToPage(-1); }}
class="h-2 rounded-full transition-all {currentPage === -1 ? 'w-4 bg-white' : 'w-2 bg-white/40'}"
title="Titelseite"
></button>
<!-- Page dots -->
{#each story.pages as _, index}
<button
onclick={(e) => { e.stopPropagation(); goToPage(index); }}
class="h-2 rounded-full transition-all {currentPage === index ? 'w-4 bg-white' : 'w-2 bg-white/40'}"
title="Seite {index + 1}"
></button>
{/each}
<!-- End dot -->
<button
onclick={(e) => { e.stopPropagation(); goToPage(story.pages.length); }}
class="h-2 rounded-full transition-all {currentPage === story.pages.length ? 'w-4 bg-white' : 'w-2 bg-white/40'}"
title="Ende"
></button>
</div>
<!-- Keyboard hints -->
<p class="mt-2 text-center text-xs text-white/50">
← → Navigation • Leertaste Weiter • F Vollbild • ESC Schließen
</p>
</footer>
{/if}
</div>

View file

@ -0,0 +1,57 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
// List of supported locales
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('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()
});
// Also export initI18n for backwards compatibility
export function initI18n() {
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale()
});
}
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,12 @@
{
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"back": "Zurück",
"loading": "Lädt..."
},
"app_slider": {
"title": "Teil des Mana Ökosystems"
}
}

View file

@ -0,0 +1,12 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"back": "Back",
"loading": "Loading..."
},
"app_slider": {
"title": "Part of the Mana Ecosystem"
}
}

View file

@ -0,0 +1,51 @@
/**
* API Types for Märchenzauber Web App
*/
export interface ApiError extends Error {
error?: string;
messageDE?: string;
messageEN?: string;
retryable?: boolean;
technicalMessage?: string;
insufficientCredits?: boolean;
requiredCredits?: number;
availableCredits?: number;
}
export interface CreditBalance {
balance: number;
maxLimit: number;
}
export interface CreditCheckResponse {
hasEnough: boolean;
balance: number;
required: number;
}
export interface Creator {
id: string;
name: string;
type: 'author' | 'illustrator';
description?: string;
style?: string;
image_url?: string;
}
export interface UserSettings {
id: string;
user_id: string;
preferred_author_id?: string;
preferred_illustrator_id?: string;
language: string;
created_at: string;
updated_at: string;
}
export interface UserStats {
storiesCreated: number;
charactersCreated: number;
totalWords: number;
favoriteStories: number;
}

View file

@ -0,0 +1,70 @@
/**
* Character Types for Märchenzauber Web App
*/
export interface CustomImage {
description: string;
imageUrl: string;
}
export interface Character {
id: string;
uid?: string; // Legacy field
name: string;
originalDescription: string;
characterDescriptionPrompt: string;
images: CustomImage[];
imageUrl: string;
image_url?: string; // Backend naming convention
createdAt: string;
user_id: string;
archived?: boolean;
animalType?: string;
isAnimal?: boolean;
blur_hash?: string;
share_code?: string;
isFeatured?: boolean;
// Voting fields for public characters
vote_count?: number;
user_vote?: 'like' | 'love' | 'star' | null;
}
export interface CharactersResponse {
data: Character[];
error?: string;
}
export interface PublicCharactersResponse {
characters: Character[];
hasMore: boolean;
total: number;
}
export interface CharacterCollection {
id: string;
name: string;
description?: string;
image_url?: string;
}
export interface CreateCharacterRequest {
name: string;
description: string;
isAnimal?: boolean;
animalType?: string;
}
export interface GenerateCharacterImagesRequest {
name: string;
description: string;
isAnimal?: boolean;
animalType?: string;
photo?: File;
}
// System user ID for system characters (read-only, visible to all)
export const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
export function isSystemCharacter(character: Character): boolean {
return character.user_id === SYSTEM_USER_ID;
}

View file

@ -0,0 +1,15 @@
/**
* Types Index - Re-export all types
*/
// Auth types
export type { StorytellerUser, AuthState } from './auth';
// Story types
export * from './story';
// Character types
export * from './character';
// API types (includes CreditBalance)
export * from './api';

View file

@ -0,0 +1,75 @@
/**
* Story Types for Märchenzauber Web App
*/
export interface StoryPage {
image: string;
pageNumber: number;
story: string;
blur_hash?: string;
}
export interface Story {
id: string;
characterId: string;
characterName: string;
characterImageUrl?: string;
createdAt: string;
prompt: string;
pages: StoryPage[];
text: string;
user_id: string;
title: string;
archived?: boolean;
is_favorite?: boolean;
visibility?: 'private' | 'public' | 'central' | 'featured';
is_published?: boolean;
published_at?: string;
published_by?: string;
featured_score?: number;
metadata?: {
version?: number;
language?: string;
age_group?: string;
author?: string;
illustrator?: string;
[key: string]: unknown;
};
is_central?: boolean;
collection_position?: number;
// Voting fields for public stories
vote_count?: number;
user_vote?: 'like' | 'love' | 'star' | null;
}
export interface StoryCollection {
id: string;
slug: string;
name: string;
description?: string;
type: 'manual' | 'automatic' | 'contest' | 'seasonal';
is_active: boolean;
sort_order: number;
config?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface StoriesResponse {
data: Story[];
error?: string;
}
export interface PublicStoriesResponse {
stories: Story[];
hasMore: boolean;
total: number;
}
export type StoryFilter = 'all' | 'favorites' | string; // string for character ID filter
export interface CreateStoryRequest {
storyDescription: string;
characters: string[];
language?: string;
}

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { StorytellerLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);
}
</script>
<ForgotPasswordPage
appName="Märchenzauber"
logo={StorytellerLogo}
primaryColor="#FF6B9D"
onForgotPassword={handleForgotPassword}
goto={goto}
loginPath="/login"
lightBackground="#fff5f8"
darkBackground="#1a1218"
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -3,6 +3,7 @@
import { LoginPage } from '@manacore/shared-auth-ui';
import { StorytellerLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
async function handleSignIn(email: string, password: string) {
@ -12,10 +13,6 @@
async function handleSignInWithGoogle(idToken: string) {
return authStore.signInWithGoogle(idToken);
}
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);
}
</script>
<LoginPage
@ -24,15 +21,18 @@
primaryColor="#FF6B9D"
onSignIn={handleSignIn}
onSignInWithGoogle={handleSignInWithGoogle}
onForgotPassword={handleForgotPassword}
goto={goto}
enableGoogle={true}
enableApple={true}
successRedirect="/dashboard"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#fff5f8"
darkBackground="#1a1218"
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}

View file

@ -0,0 +1,145 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/authStore.svelte';
import { onMount } from 'svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import Header from '$lib/components/layout/Header.svelte';
let { children } = $props();
let loading = $state(true);
let isSidebarCollapsed = $state(false);
let isMobileMenuOpen = $state(false);
// Keyboard shortcuts
const navRoutes: Record<string, string> = {
'1': '/dashboard', // Dashboard
'2': '/stories', // Stories
'3': '/characters', // Characters
'4': '/discover', // Discover
'5': '/settings', // Settings
};
function handleKeydown(event: KeyboardEvent) {
// Don't handle if user is typing in an input
const target = event.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
// Ctrl/Cmd + number for navigation
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const route = navRoutes[event.key];
if (route) {
event.preventDefault();
goto(route);
}
}
// ESC to close mobile menu
if (event.key === 'Escape' && isMobileMenuOpen) {
isMobileMenuOpen = false;
}
}
function handleSidebarToggle() {
isSidebarCollapsed = !isSidebarCollapsed;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('maerchenzauber-sidebar-collapsed', String(isSidebarCollapsed));
}
}
function handleMobileMenuToggle() {
isMobileMenuOpen = !isMobileMenuOpen;
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
// Client-side auth guard
onMount(async () => {
await authStore.initialize();
if (!authStore.isAuthenticated) {
goto(`/login?redirectTo=${$page.url.pathname}`);
return;
}
// Restore sidebar state from localStorage
if (typeof localStorage !== 'undefined') {
const savedCollapsed = localStorage.getItem('maerchenzauber-sidebar-collapsed');
if (savedCollapsed === 'true') {
isSidebarCollapsed = true;
}
}
loading = false;
});
</script>
<svelte:window onkeydown={handleKeydown} />
{#if loading}
<!-- Loading State -->
<div class="flex min-h-screen items-center justify-center bg-gradient-to-br from-pink-50 to-purple-50 dark:from-gray-900 dark:to-gray-800">
<div class="text-center">
<div class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-pink-500 border-r-transparent"></div>
<p class="text-gray-600 dark:text-gray-400">Laden...</p>
</div>
</div>
{:else}
<!-- Main Layout -->
<div class="flex min-h-screen bg-gradient-to-br from-pink-50/50 to-purple-50/50 dark:from-gray-900 dark:to-gray-800">
<!-- Sidebar (Desktop) -->
<Sidebar
isCollapsed={isSidebarCollapsed}
currentPath={$page.url.pathname}
onToggle={handleSidebarToggle}
onLogout={handleLogout}
/>
<!-- Mobile Menu Overlay -->
{#if isMobileMenuOpen}
<div
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
onclick={() => (isMobileMenuOpen = false)}
onkeydown={(e) => e.key === 'Escape' && (isMobileMenuOpen = false)}
role="button"
tabindex="0"
></div>
<div class="fixed inset-y-0 left-0 z-50 w-64 lg:hidden">
<Sidebar
isCollapsed={false}
currentPath={$page.url.pathname}
onToggle={() => (isMobileMenuOpen = false)}
onLogout={handleLogout}
isMobile={true}
/>
</div>
{/if}
<!-- Main Content Area -->
<div
class="flex flex-1 flex-col transition-all duration-300"
class:lg:ml-64={!isSidebarCollapsed}
class:lg:ml-20={isSidebarCollapsed}
>
<!-- Header -->
<Header
onMenuClick={handleMobileMenuToggle}
onLogout={handleLogout}
/>
<!-- Page Content -->
<main class="flex-1 overflow-auto p-4 lg:p-6">
{@render children()}
</main>
</div>
</div>
{/if}

View file

@ -0,0 +1,268 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dataService } from '$lib/api';
import type { Story } from '$lib/types/story';
import type { Character } from '$lib/types/character';
// Tab state
let activeTab = $state<'stories' | 'characters'>('stories');
// Data
let archivedStories = $state<Story[]>([]);
let archivedCharacters = $state<Character[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
// Fetch archived items
async function fetchArchived() {
loading = true;
error = null;
try {
const [stories, characters] = await Promise.all([
dataService.getStories(true), // Include archived
dataService.getCharacters(true), // Include archived
]);
archivedStories = stories.filter((s) => s.archived);
archivedCharacters = characters.filter((c) => c.archived);
} catch (err) {
console.error('[Archive] Failed to fetch:', err);
error = 'Archiv konnte nicht geladen werden';
} finally {
loading = false;
}
}
onMount(() => {
fetchArchived();
});
// Restore a story
async function restoreStory(storyId: string) {
try {
await dataService.updateStory(storyId, { archived: false });
archivedStories = archivedStories.filter((s) => s.id !== storyId);
} catch (err) {
console.error('[Archive] Failed to restore story:', err);
}
}
// Restore a character
async function restoreCharacter(characterId: string) {
try {
await dataService.updateCharacter(characterId, { archived: false });
archivedCharacters = archivedCharacters.filter((c) => c.id !== characterId);
} catch (err) {
console.error('[Archive] Failed to restore character:', err);
}
}
// Delete a story permanently
async function deleteStory(storyId: string) {
if (!confirm('Diese Geschichte wird unwiderruflich gelöscht. Fortfahren?')) return;
try {
await dataService.deleteStory(storyId);
archivedStories = archivedStories.filter((s) => s.id !== storyId);
} catch (err) {
console.error('[Archive] Failed to delete story:', err);
}
}
// Delete a character permanently
async function deleteCharacter(characterId: string) {
if (!confirm('Dieser Charakter wird unwiderruflich gelöscht. Fortfahren?')) return;
try {
await dataService.deleteCharacter(characterId);
archivedCharacters = archivedCharacters.filter((c) => c.id !== characterId);
} catch (err) {
console.error('[Archive] Failed to delete character:', err);
}
}
// Get image URLs
function getStoryImage(story: Story): string {
return story.pages?.[0]?.image || '/images/placeholder-story.png';
}
function getCharacterImage(character: Character): string {
return character.imageUrl || character.image_url || '/images/placeholder-character.png';
}
</script>
<svelte:head>
<title>Archiv | Märchenzauber</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center gap-4">
<a
href="/settings"
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</a>
<div>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Archiv</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
Archivierte Geschichten und Charaktere
</p>
</div>
</div>
<!-- Tabs -->
<div class="flex gap-2">
<button
onclick={() => (activeTab = 'stories')}
class="flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all {activeTab === 'stories' ? 'bg-gray-800 text-white dark:bg-white dark:text-gray-800' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Geschichten ({archivedStories.length})
</button>
<button
onclick={() => (activeTab = 'characters')}
class="flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all {activeTab === 'characters' ? 'bg-gray-800 text-white dark:bg-white dark:text-gray-800' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Charaktere ({archivedCharacters.length})
</button>
</div>
<!-- Content -->
{#if loading}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(6) as _}
<div class="h-32 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
{/each}
</div>
{:else if error}
<div class="rounded-2xl bg-red-50 p-6 text-center dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{error}</p>
<button
onclick={fetchArchived}
class="mt-4 rounded-xl bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Erneut versuchen
</button>
</div>
{:else if activeTab === 'stories'}
<!-- Archived Stories -->
{#if archivedStories.length === 0}
<div class="rounded-2xl bg-gray-50 p-8 text-center dark:bg-gray-800/50">
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<h3 class="mt-4 font-medium text-gray-700 dark:text-gray-300">Keine archivierten Geschichten</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Archivierte Geschichten erscheinen hier
</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each archivedStories as story (story.id)}
<div class="group relative overflow-hidden rounded-2xl bg-white shadow-md dark:bg-gray-800">
<!-- Image -->
<div class="aspect-video overflow-hidden">
<img
src={getStoryImage(story)}
alt={story.title}
class="h-full w-full object-cover opacity-60"
loading="lazy"
/>
</div>
<!-- Info -->
<div class="p-4">
<h3 class="font-semibold text-gray-800 dark:text-gray-200 line-clamp-1">
{story.title || 'Ohne Titel'}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Archiviert am {new Date(story.createdAt).toLocaleDateString('de-DE')}
</p>
<!-- Actions -->
<div class="mt-3 flex gap-2">
<button
onclick={() => restoreStory(story.id)}
class="flex-1 rounded-lg bg-green-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-600"
>
Wiederherstellen
</button>
<button
onclick={() => deleteStory(story.id)}
class="rounded-lg bg-red-100 px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
>
Löschen
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
{:else}
<!-- Archived Characters -->
{#if archivedCharacters.length === 0}
<div class="rounded-2xl bg-gray-50 p-8 text-center dark:bg-gray-800/50">
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<h3 class="mt-4 font-medium text-gray-700 dark:text-gray-300">Keine archivierten Charaktere</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Archivierte Charaktere erscheinen hier
</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each archivedCharacters as character (character.id)}
<div class="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-md dark:bg-gray-800">
<!-- Avatar -->
<div class="h-16 w-16 flex-shrink-0 overflow-hidden rounded-full opacity-60">
<img
src={getCharacterImage(character)}
alt={character.name}
class="h-full w-full object-cover"
loading="lazy"
/>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-800 dark:text-gray-200 truncate">
{character.name}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">
Archiviert
</p>
<!-- Actions -->
<div class="mt-2 flex gap-2">
<button
onclick={() => restoreCharacter(character.id)}
class="rounded-lg bg-green-500 px-3 py-1 text-xs font-medium text-white hover:bg-green-600"
>
Wiederherstellen
</button>
<button
onclick={() => deleteCharacter(character.id)}
class="rounded-lg bg-red-100 px-3 py-1 text-xs font-medium text-red-600 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400"
>
Löschen
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,131 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dataService } from '$lib/api';
import type { Character } from '$lib/types/character';
import CharacterAvatar from '$lib/components/character/CharacterAvatar.svelte';
import EmptyState from '$lib/components/common/EmptyState.svelte';
let characters = $state<Character[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let searchQuery = $state('');
let filteredCharacters = $derived.by(() => {
let result = characters.filter((c) => !c.archived);
// Apply search
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter((c) => c.name?.toLowerCase().includes(query));
}
// Sort by date (newest first)
return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
});
async function fetchCharacters() {
try {
loading = true;
error = null;
characters = await dataService.getCharacters();
} catch (e) {
console.error('[Characters] Failed to fetch:', e);
error = 'Charaktere konnten nicht geladen werden';
} finally {
loading = false;
}
}
onMount(() => {
fetchCharacters();
});
</script>
<svelte:head>
<title>Charaktere | Märchenzauber</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Deine Charaktere</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
{filteredCharacters.length} {filteredCharacters.length === 1 ? 'Charakter' : 'Charaktere'}
</p>
</div>
<a
href="/characters/create"
class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md transition-all hover:from-pink-600 hover:to-purple-700 hover:shadow-lg"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Neuer Charakter
</a>
</div>
<!-- Search -->
<div class="relative max-w-xs">
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Charakter suchen..."
bind:value={searchQuery}
class="w-full rounded-xl border border-gray-200 bg-white py-2 pl-10 pr-4 text-sm focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
/>
</div>
<!-- Content -->
{#if loading}
<div class="grid gap-6 grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
{#each Array(12) as _}
<div class="flex flex-col items-center gap-2">
<div class="h-24 w-24 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
{/each}
</div>
{:else if error}
<div class="rounded-2xl bg-red-50 p-4 text-red-600 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
{:else if filteredCharacters.length === 0}
{#if searchQuery}
<EmptyState
title="Keine Ergebnisse"
description="Kein Charakter mit diesem Namen gefunden."
icon="search"
/>
{:else}
<EmptyState
title="Noch keine Charaktere"
description="Erstelle deinen ersten Charakter!"
actionLabel="Charakter erstellen"
actionHref="/characters/create"
icon="users"
/>
{/if}
{:else}
<div class="grid gap-6 grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
<!-- Create Character Button -->
<a
href="/characters/create"
class="flex flex-col items-center gap-2"
>
<div class="flex h-24 w-24 flex-shrink-0 items-center justify-center rounded-full border-3 border-dashed border-pink-300 bg-pink-50 text-pink-500 transition-all hover:border-pink-400 hover:bg-pink-100 dark:border-pink-700 dark:bg-pink-900/20 dark:text-pink-400 dark:hover:border-pink-600 dark:hover:bg-pink-900/30">
<svg class="h-10 w-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<span class="text-xs font-medium text-pink-600 dark:text-pink-400">Neu</span>
</a>
{#each filteredCharacters as character (character.id)}
<CharacterAvatar {character} size="md" />
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,328 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { dataService } from '$lib/api';
import type { Character } from '$lib/types/character';
import { isSystemCharacter } from '$lib/types/character';
let character = $state<Character | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
// Edit state
let isEditing = $state(false);
let editedName = $state('');
let saving = $state(false);
// Story creation state
let storyPrompt = $state('');
let creatingStory = $state(false);
// Get character ID from route params
let characterId = $derived($page.params.id);
let isSystem = $derived(character ? isSystemCharacter(character) : false);
// Fetch character data
async function loadCharacter() {
if (!characterId) return;
loading = true;
error = null;
try {
character = await dataService.getCharacterById(characterId);
editedName = character.name;
} catch (err) {
console.error('[CharacterDetail] Failed to load character:', err);
error = err instanceof Error ? err.message : 'Charakter konnte nicht geladen werden';
} finally {
loading = false;
}
}
onMount(() => {
loadCharacter();
});
// Get image URL
function getImageUrl(char: Character): string {
return char.imageUrl || char.image_url || '/images/placeholder-character.png';
}
// Save name edit
async function handleSaveName() {
if (!character || !editedName.trim()) return;
saving = true;
try {
await dataService.updateCharacter(character.id, { name: editedName.trim() });
character = { ...character, name: editedName.trim() };
isEditing = false;
} catch (err) {
console.error('[CharacterDetail] Failed to update name:', err);
error = 'Name konnte nicht gespeichert werden';
} finally {
saving = false;
}
}
// Cancel editing
function handleCancelEdit() {
if (character) {
editedName = character.name;
}
isEditing = false;
}
// Archive character
async function handleArchive() {
if (!character || !confirm(`Möchtest du "${character.name}" wirklich archivieren?`)) return;
try {
await dataService.updateCharacter(character.id, { archived: true });
goto('/characters');
} catch (err) {
console.error('[CharacterDetail] Failed to archive:', err);
error = 'Charakter konnte nicht archiviert werden';
}
}
// Create story with this character
async function handleCreateStory() {
if (!character || !storyPrompt.trim()) return;
creatingStory = true;
error = null;
try {
const result = await dataService.createStory({
storyDescription: storyPrompt,
characters: [character.id],
});
goto(`/stories/${result.storyId}`);
} catch (err) {
console.error('[CharacterDetail] Failed to create story:', err);
error = err instanceof Error ? err.message : 'Geschichte konnte nicht erstellt werden';
creatingStory = false;
}
}
// Copy share code
function handleCopyShareCode() {
if (character?.share_code) {
navigator.clipboard.writeText(character.share_code);
// Could add a toast notification here
}
}
</script>
<svelte:head>
<title>{character?.name || 'Charakter'} | Märchenzauber</title>
</svelte:head>
<!-- Creating Story Overlay -->
{#if creatingStory}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/90 backdrop-blur">
<div class="flex flex-col items-center gap-6 p-8 text-center">
<div class="relative">
<div class="h-24 w-24 animate-spin rounded-full border-4 border-pink-500/30 border-t-pink-500"></div>
<div class="absolute inset-0 flex items-center justify-center">
<svg class="h-10 w-10 animate-pulse text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
</div>
<div>
<h2 class="mb-2 text-xl font-bold text-white">Geschichte wird erschaffen...</h2>
<p class="text-white/70">Die Magie arbeitet!</p>
</div>
</div>
</div>
{/if}
{#if loading}
<!-- Loading State -->
<div class="mx-auto max-w-2xl animate-pulse space-y-8 p-4">
<div class="flex flex-col items-center gap-4">
<div class="h-48 w-48 rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-8 w-48 rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<div class="h-32 rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
</div>
{:else if error && !character}
<!-- Error State -->
<div class="mx-auto max-w-md p-4 text-center">
<div class="rounded-2xl bg-red-50 p-8 dark:bg-red-900/20">
<svg class="mx-auto h-12 w-12 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<h2 class="mt-4 text-xl font-bold text-red-600 dark:text-red-400">Fehler</h2>
<p class="mt-2 text-red-500 dark:text-red-300">{error}</p>
<button
onclick={() => goto('/characters')}
class="mt-6 rounded-xl bg-red-500 px-6 py-2 font-medium text-white hover:bg-red-600"
>
Zurück zur Übersicht
</button>
</div>
</div>
{:else if character}
<div class="mx-auto max-w-2xl space-y-8">
<!-- Back Link -->
<a
href="/characters"
class="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-600 dark:text-gray-400 dark:hover:text-pink-400"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zu Charakteren
</a>
<!-- Error Alert -->
{#if error}
<div class="rounded-xl bg-red-50 p-4 text-red-600 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
{/if}
<!-- Character Header -->
<div class="flex flex-col items-center text-center">
<!-- Character Image -->
<div class="relative">
<div class="h-48 w-48 overflow-hidden rounded-full border-4 border-pink-500 shadow-xl shadow-pink-500/20">
<img
src={getImageUrl(character)}
alt={character.name}
class="h-full w-full object-cover"
/>
</div>
{#if isSystem}
<div
class="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-r from-amber-400 to-yellow-500 shadow-lg"
title="System-Charakter"
>
<svg class="h-4 w-4 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
</div>
{/if}
</div>
<!-- Character Name -->
{#if isEditing}
<div class="mt-6 w-full max-w-sm space-y-4">
<input
type="text"
bind:value={editedName}
class="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-center text-xl font-bold text-gray-800 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
/>
<div class="flex gap-3">
<button
onclick={handleCancelEdit}
class="flex-1 rounded-xl bg-gray-100 px-4 py-2 font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Abbrechen
</button>
<button
onclick={handleSaveName}
disabled={saving || !editedName.trim()}
class="flex-1 rounded-xl bg-pink-500 px-4 py-2 font-medium text-white hover:bg-pink-600 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{:else}
<h1 class="mt-6 text-3xl font-bold text-gray-800 dark:text-gray-200">
{character.name}
</h1>
{/if}
<!-- Sharing Status Badge -->
{#if character.share_code && !isSystem}
<div class="mt-3 flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
Geteilt
</span>
<button
onclick={handleCopyShareCode}
class="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-pink-600 dark:text-gray-400"
title="Share-Code kopieren"
>
<span class="font-mono">{character.share_code.slice(0, 8)}</span>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
{/if}
<!-- Action Buttons (only for non-system characters) -->
{#if !isSystem && !isEditing}
<div class="mt-6 flex flex-wrap justify-center gap-3">
<button
onclick={() => (isEditing = true)}
class="inline-flex items-center gap-2 rounded-xl bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Bearbeiten
</button>
<button
onclick={handleArchive}
class="inline-flex items-center gap-2 rounded-xl bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-red-100 hover:text-red-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-red-900/30 dark:hover:text-red-400"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
Archivieren
</button>
</div>
{/if}
</div>
<!-- Quick Story Creation -->
<div class="rounded-2xl bg-white/80 p-6 shadow-lg backdrop-blur dark:bg-gray-800/80">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">
Neue Geschichte mit {character.name}
</h2>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">
Beschreibe eine Geschichte und {character.name} wird der Hauptcharakter sein.
</p>
<textarea
bind:value={storyPrompt}
placeholder="z.B. {character.name} erlebt ein spannendes Abenteuer im Zauberwald..."
rows={4}
class="w-full resize-none rounded-xl border border-gray-200 bg-white p-4 text-gray-800 placeholder:text-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200"
></textarea>
<button
onclick={handleCreateStory}
disabled={creatingStory || !storyPrompt.trim()}
class="mt-4 flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-6 py-3 font-semibold text-white shadow-md transition-all hover:from-pink-600 hover:to-purple-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
Geschichte erstellen
</button>
</div>
<!-- Character Info (if available) -->
{#if character.originalDescription}
<div class="rounded-2xl bg-gray-50 p-6 dark:bg-gray-800/50">
<h3 class="mb-3 text-sm font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Original-Beschreibung
</h3>
<p class="text-gray-700 dark:text-gray-300">
{character.originalDescription}
</p>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,453 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fetchWithAuth } from '$lib/api/client';
import { dataService } from '$lib/api';
interface GeneratedImage {
imageUrl: string;
description: string;
}
// Form state
let characterName = $state('');
let characterDescription = $state('');
// Creation mode
let mode = $state<'description' | 'photo'>('description');
// Photo upload state
let uploadedFile = $state<File | null>(null);
let uploadedPreview = $state<string | null>(null);
// Generation state
let generating = $state(false);
let generatedImages = $state<GeneratedImage[]>([]);
let selectedImage = $state<GeneratedImage | null>(null);
let characterId = $state<string | null>(null);
// Final save state
let saving = $state(false);
let error = $state<string | null>(null);
// Handle file selection
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
uploadedFile = file;
// Create preview URL
uploadedPreview = URL.createObjectURL(file);
}
}
// Generate character images from description
async function handleGenerateFromDescription() {
if (!characterName.trim() || !characterDescription.trim()) {
error = 'Bitte gib einen Namen und eine Beschreibung ein.';
return;
}
generating = true;
error = null;
generatedImages = [];
selectedImage = null;
try {
const response = await fetchWithAuth('/character/generate-animal', {
method: 'POST',
body: JSON.stringify({
name: characterName,
description: characterDescription,
}),
});
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
if (!result.data?.images || !Array.isArray(result.data.images)) {
throw new Error('Keine Bilder vom Server erhalten');
}
characterId = result.data.characterId;
generatedImages = result.data.images.filter(
(img: GeneratedImage) => img && typeof img.imageUrl === 'string'
);
} catch (err) {
console.error('[CreateCharacter] Failed to generate:', err);
error = err instanceof Error ? err.message : 'Charakter konnte nicht generiert werden';
} finally {
generating = false;
}
}
// Generate character images from photo
async function handleGenerateFromPhoto() {
if (!characterName.trim() || !uploadedFile) {
error = 'Bitte gib einen Namen ein und lade ein Foto hoch.';
return;
}
generating = true;
error = null;
generatedImages = [];
selectedImage = null;
try {
const formData = new FormData();
formData.append('name', characterName);
formData.append('image', uploadedFile);
const response = await fetchWithAuth('/character/generate-animal-from-image', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.error) {
throw new Error(result.error);
}
if (!result.data?.images || !Array.isArray(result.data.images)) {
throw new Error('Keine Bilder vom Server erhalten');
}
characterId = result.data.characterId;
generatedImages = result.data.images.filter(
(img: GeneratedImage) => img && typeof img.imageUrl === 'string'
);
} catch (err) {
console.error('[CreateCharacter] Failed to generate from photo:', err);
error = err instanceof Error ? err.message : 'Charakter konnte nicht generiert werden';
} finally {
generating = false;
}
}
// Save the character with selected image
async function handleSaveCharacter() {
if (!characterId || !selectedImage) {
error = 'Bitte wähle ein Bild aus.';
return;
}
saving = true;
error = null;
try {
await dataService.updateCharacter(characterId, {
characterDescriptionPrompt: selectedImage.description,
imageUrl: selectedImage.imageUrl,
} as any);
goto(`/characters/${characterId}`);
} catch (err) {
console.error('[CreateCharacter] Failed to save:', err);
error = err instanceof Error ? err.message : 'Charakter konnte nicht gespeichert werden';
saving = false;
}
}
// Select an image
function selectImage(image: GeneratedImage) {
selectedImage = image;
}
// Clear uploaded photo
function clearPhoto() {
uploadedFile = null;
if (uploadedPreview) {
URL.revokeObjectURL(uploadedPreview);
uploadedPreview = null;
}
}
</script>
<svelte:head>
<title>Neuer Charakter | Märchenzauber</title>
</svelte:head>
<!-- Generating Overlay -->
{#if generating}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/90 backdrop-blur">
<div class="flex flex-col items-center gap-6 p-8 text-center">
<div class="relative">
<div class="h-24 w-24 animate-spin rounded-full border-4 border-pink-500/30 border-t-pink-500"></div>
<div class="absolute inset-0 flex items-center justify-center">
<svg class="h-10 w-10 animate-pulse text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
</div>
<div>
<h2 class="mb-2 text-xl font-bold text-white">
{mode === 'photo' ? 'Kuscheltier wird analysiert...' : 'Charakter wird generiert...'}
</h2>
<p class="text-white/70">Die Magie arbeitet! Dies kann bis zu einer Minute dauern.</p>
</div>
</div>
</div>
{/if}
<div class="mx-auto max-w-2xl space-y-8">
<!-- Header -->
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200 sm:text-3xl">
Neuer Charakter
</h1>
<p class="mt-2 text-gray-500 dark:text-gray-400">
Erstelle einen einzigartigen Charakter für deine Geschichten
</p>
</div>
<!-- Back Link -->
<a
href="/characters"
class="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-600 dark:text-gray-400 dark:hover:text-pink-400"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurück zu Charakteren
</a>
<!-- Error Message -->
{#if error}
<div class="rounded-xl bg-red-50 p-4 text-red-600 dark:bg-red-900/20 dark:text-red-400">
<div class="flex items-start gap-3">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{error}</span>
</div>
</div>
{/if}
{#if generatedImages.length === 0}
<!-- Step 1: Name -->
<section class="space-y-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
1. Name deines Charakters
</h2>
<input
type="text"
bind:value={characterName}
placeholder="z.B. Luna die Mondprinzessin, Max der mutige Ritter..."
class="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-gray-800 placeholder:text-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
/>
</section>
<!-- Step 2: Mode Selection -->
<section class="space-y-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
2. Wie möchtest du deinen Charakter erstellen?
</h2>
<!-- Mode Tabs -->
<div class="flex gap-2">
<button
onclick={() => (mode = 'photo')}
class="flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-all"
class:bg-pink-500={mode === 'photo'}
class:text-white={mode === 'photo'}
class:bg-gray-100={mode !== 'photo'}
class:text-gray-600={mode !== 'photo'}
class:dark:bg-gray-700={mode !== 'photo'}
class:dark:text-gray-300={mode !== 'photo'}
>
<div class="flex items-center justify-center gap-2">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Foto hochladen
</div>
</button>
<button
onclick={() => (mode = 'description')}
class="flex-1 rounded-xl px-4 py-3 text-sm font-medium transition-all"
class:bg-pink-500={mode === 'description'}
class:text-white={mode === 'description'}
class:bg-gray-100={mode !== 'description'}
class:text-gray-600={mode !== 'description'}
class:dark:bg-gray-700={mode !== 'description'}
class:dark:text-gray-300={mode !== 'description'}
>
<div class="flex items-center justify-center gap-2">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Beschreibung
</div>
</button>
</div>
<!-- Photo Upload Mode -->
{#if mode === 'photo'}
<div class="space-y-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
Lade ein Foto von einem Kuscheltier hoch, um daraus einen einzigartigen Charakter zu erstellen.
</p>
{#if uploadedPreview}
<!-- Photo Preview -->
<div class="relative mx-auto w-fit">
<div class="h-48 w-48 overflow-hidden rounded-2xl border-4 border-amber-400 shadow-lg">
<img
src={uploadedPreview}
alt="Hochgeladenes Foto"
class="h-full w-full object-cover"
/>
</div>
<button
onclick={clearPhoto}
class="absolute -right-2 -top-2 flex h-8 w-8 items-center justify-center rounded-full bg-red-500 text-white shadow-lg hover:bg-red-600"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{:else}
<!-- Upload Area -->
<label class="flex cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-amber-400 bg-amber-50/50 p-8 transition-colors hover:bg-amber-50 dark:bg-amber-900/10 dark:hover:bg-amber-900/20">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/30">
<svg class="h-8 w-8 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<span class="font-medium text-amber-600 dark:text-amber-400">Foto hochladen</span>
<span class="mt-1 text-sm text-gray-500">Klicken oder Bild hierher ziehen</span>
<input
type="file"
accept="image/*"
onchange={handleFileSelect}
class="hidden"
/>
</label>
{/if}
<button
onclick={handleGenerateFromPhoto}
disabled={generating || !characterName.trim() || !uploadedFile}
class="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 px-6 py-3 font-semibold text-white shadow-md transition-all hover:from-amber-600 hover:to-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
Charakter erstellen
</button>
</div>
{/if}
<!-- Description Mode -->
{#if mode === 'description'}
<div class="space-y-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
Beschreibe deinen Charakter mit eigenen Worten. Die KI generiert daraus ein einzigartiges Bild.
</p>
<textarea
bind:value={characterDescription}
placeholder="z.B. Ein kleiner Drache mit glitzernden blauen Schuppen, der gerne Abenteuer erlebt und immer fröhlich ist. Er trägt einen goldenen Schal und kann kleine Funken spucken..."
rows={5}
class="w-full resize-none rounded-xl border border-gray-200 bg-white p-4 text-gray-800 placeholder:text-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
></textarea>
<button
onclick={handleGenerateFromDescription}
disabled={generating || !characterName.trim() || !characterDescription.trim()}
class="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-6 py-3 font-semibold text-white shadow-md transition-all hover:from-pink-600 hover:to-purple-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
Charakter erstellen
</button>
</div>
{/if}
</section>
{:else}
<!-- Step 3: Select Generated Image -->
<section class="space-y-6">
<div class="text-center">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
Wähle ein Profilbild für {characterName}
</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Klicke auf das Bild, das dir am besten gefällt
</p>
</div>
<!-- Generated Images Grid -->
<div class="flex flex-wrap justify-center gap-4">
{#each generatedImages as image (image.imageUrl)}
<button
onclick={() => selectImage(image)}
class="relative overflow-hidden rounded-full transition-all"
class:ring-4={selectedImage?.imageUrl === image.imageUrl}
class:ring-pink-500={selectedImage?.imageUrl === image.imageUrl}
class:scale-105={selectedImage?.imageUrl === image.imageUrl}
>
<div class="h-32 w-32 overflow-hidden rounded-full border-3 border-white shadow-lg dark:border-gray-700 sm:h-40 sm:w-40">
<img
src={image.imageUrl}
alt="Generiertes Charakterbild"
class="h-full w-full object-cover"
/>
</div>
{#if selectedImage?.imageUrl === image.imageUrl}
<div class="absolute inset-0 flex items-center justify-center rounded-full bg-pink-500/20">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500 text-white">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
{/if}
</button>
{/each}
</div>
<!-- Regenerate Button -->
<div class="flex justify-center">
<button
onclick={mode === 'photo' ? handleGenerateFromPhoto : handleGenerateFromDescription}
disabled={generating}
class="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-600 dark:text-gray-400 dark:hover:text-pink-400"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Neu generieren
</button>
</div>
<!-- Save Button -->
<button
onclick={handleSaveCharacter}
disabled={saving || !selectedImage}
class="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-6 py-4 text-lg font-semibold text-white shadow-lg transition-all hover:from-pink-600 hover:to-purple-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if saving}
<div class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
Speichern...
{:else}
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Charakter erstellen
{/if}
</button>
</section>
{/if}
<!-- Credit Info -->
<p class="text-center text-sm text-gray-500 dark:text-gray-400">
Das Erstellen eines Charakters kostet 10 Mana.
</p>
</div>

View file

@ -0,0 +1,243 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dataService } from '$lib/api';
import type { Story } from '$lib/types/story';
import type { Character } from '$lib/types/character';
import StoryCard from '$lib/components/story/StoryCard.svelte';
import CharacterAvatar from '$lib/components/character/CharacterAvatar.svelte';
import EmptyState from '$lib/components/common/EmptyState.svelte';
let stories = $state<Story[]>([]);
let characters = $state<Character[]>([]);
let loadingStories = $state(true);
let loadingCharacters = $state(true);
let errorStories = $state<string | null>(null);
let errorCharacters = $state<string | null>(null);
// Get recent stories (last 4)
let recentStories = $derived(
stories
.filter((s) => !s.archived)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 4)
);
// Get favorite stories
let favoriteStories = $derived(stories.filter((s) => s.is_favorite && !s.archived));
// Get recent characters (last 6)
let recentCharacters = $derived(
characters
.filter((c) => !c.archived)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 6)
);
async function fetchStories() {
try {
loadingStories = true;
errorStories = null;
stories = await dataService.getStories();
} catch (e) {
console.error('[Dashboard] Failed to fetch stories:', e);
errorStories = 'Geschichten konnten nicht geladen werden';
} finally {
loadingStories = false;
}
}
async function fetchCharacters() {
try {
loadingCharacters = true;
errorCharacters = null;
characters = await dataService.getCharacters();
} catch (e) {
console.error('[Dashboard] Failed to fetch characters:', e);
errorCharacters = 'Charaktere konnten nicht geladen werden';
} finally {
loadingCharacters = false;
}
}
onMount(() => {
fetchStories();
fetchCharacters();
});
</script>
<svelte:head>
<title>Dashboard | Märchenzauber</title>
</svelte:head>
<div class="space-y-8">
<!-- Welcome Section -->
<section class="rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 p-6 text-white shadow-lg lg:p-8">
<h1 class="text-2xl font-bold lg:text-3xl">Willkommen bei Märchenzauber! ✨</h1>
<p class="mt-2 text-pink-100">
Erstelle magische Geschichten mit deinen eigenen Charakteren.
</p>
<div class="mt-4 flex flex-wrap gap-3">
<a
href="/stories/create"
class="inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2.5 text-sm font-semibold text-pink-600 shadow-md transition-all hover:bg-pink-50 hover:shadow-lg"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neue Geschichte
</a>
<a
href="/characters/create"
class="inline-flex items-center gap-2 rounded-xl bg-white/20 px-4 py-2.5 text-sm font-semibold text-white backdrop-blur transition-all hover:bg-white/30"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Neuer Charakter
</a>
</div>
</section>
<!-- Recent Stories Section -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-200">Neueste Geschichten</h2>
<a href="/stories" class="text-sm font-medium text-pink-600 hover:text-pink-700 dark:text-pink-400 dark:hover:text-pink-300">
Alle anzeigen →
</a>
</div>
{#if loadingStories}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each Array(4) as _}
<div class="h-48 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
{/each}
</div>
{:else if errorStories}
<div class="rounded-2xl bg-red-50 p-4 text-red-600 dark:bg-red-900/20 dark:text-red-400">
{errorStories}
</div>
{:else if recentStories.length === 0}
<EmptyState
title="Noch keine Geschichten"
description="Erstelle deine erste magische Geschichte!"
actionLabel="Geschichte erstellen"
actionHref="/stories/create"
/>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each recentStories as story (story.id)}
<StoryCard {story} />
{/each}
</div>
{/if}
</section>
<!-- Characters Section -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-200">Deine Charaktere</h2>
<a href="/characters" class="text-sm font-medium text-pink-600 hover:text-pink-700 dark:text-pink-400 dark:hover:text-pink-300">
Alle anzeigen →
</a>
</div>
{#if loadingCharacters}
<div class="flex gap-4 overflow-x-auto pb-2">
{#each Array(6) as _}
<div class="h-24 w-24 flex-shrink-0 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
{/each}
</div>
{:else if errorCharacters}
<div class="rounded-2xl bg-red-50 p-4 text-red-600 dark:bg-red-900/20 dark:text-red-400">
{errorCharacters}
</div>
{:else if recentCharacters.length === 0}
<EmptyState
title="Noch keine Charaktere"
description="Erstelle deinen ersten Charakter!"
actionLabel="Charakter erstellen"
actionHref="/characters/create"
/>
{:else}
<div class="flex gap-4 overflow-x-auto pb-2">
<!-- Create Character Button -->
<a
href="/characters/create"
class="flex h-24 w-24 flex-shrink-0 flex-col items-center justify-center rounded-full border-2 border-dashed border-pink-300 bg-pink-50 text-pink-500 transition-all hover:border-pink-400 hover:bg-pink-100 dark:border-pink-700 dark:bg-pink-900/20 dark:text-pink-400 dark:hover:border-pink-600 dark:hover:bg-pink-900/30"
>
<svg class="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span class="mt-1 text-xs">Neu</span>
</a>
{#each recentCharacters as character (character.id)}
<CharacterAvatar {character} />
{/each}
</div>
{/if}
</section>
<!-- Favorites Section (if any) -->
{#if favoriteStories.length > 0}
<section>
<div class="mb-4 flex items-center gap-2">
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-200">Favoriten</h2>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{#each favoriteStories.slice(0, 4) as story (story.id)}
<StoryCard {story} />
{/each}
</div>
</section>
{/if}
<!-- Quick Stats -->
<section class="grid gap-4 sm:grid-cols-3">
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-gray-800">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-pink-100 dark:bg-pink-900/30">
<svg class="h-5 w-5 text-pink-600 dark:text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-800 dark:text-gray-200">{stories.filter(s => !s.archived).length}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Geschichten</p>
</div>
</div>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-gray-800">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-100 dark:bg-purple-900/30">
<svg class="h-5 w-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-800 dark:text-gray-200">{characters.filter(c => !c.archived).length}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Charaktere</p>
</div>
</div>
</div>
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-gray-800">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-yellow-100 dark:bg-yellow-900/30">
<svg class="h-5 w-5 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
</div>
<div>
<p class="text-2xl font-bold text-gray-800 dark:text-gray-200">{favoriteStories.length}</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Favoriten</p>
</div>
</div>
</div>
</section>
</div>

View file

@ -0,0 +1,400 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dataService } from '$lib/api';
import type { Story } from '$lib/types/story';
import type { Character } from '$lib/types/character';
// Tab state
let activeSection = $state<'stories' | 'characters'>('stories');
let activeFilter = $state<'popular' | 'new' | 'featured'>('popular');
let searchQuery = $state('');
// Stories state
let stories = $state<Story[]>([]);
let storiesLoading = $state(false);
let storiesError = $state<string | null>(null);
let storiesHasMore = $state(false);
// Characters state
let characters = $state<Character[]>([]);
let charactersLoading = $state(false);
let charactersError = $state<string | null>(null);
let charactersHasMore = $state(false);
// Filtered results
let filteredStories = $derived.by(() => {
if (!searchQuery.trim()) return stories;
const query = searchQuery.toLowerCase();
return stories.filter(
(s) =>
s.title?.toLowerCase().includes(query) ||
s.prompt?.toLowerCase().includes(query) ||
s.characterName?.toLowerCase().includes(query)
);
});
let filteredCharacters = $derived.by(() => {
if (!searchQuery.trim()) return characters;
const query = searchQuery.toLowerCase();
return characters.filter(
(c) =>
c.name?.toLowerCase().includes(query) ||
c.originalDescription?.toLowerCase().includes(query)
);
});
// Fetch public stories
async function fetchStories() {
storiesLoading = true;
storiesError = null;
try {
const result = await dataService.getPublicStories(activeFilter, 1, 20);
stories = result.stories;
storiesHasMore = result.hasMore;
} catch (err) {
console.error('[Discover] Failed to fetch stories:', err);
storiesError = 'Geschichten konnten nicht geladen werden';
} finally {
storiesLoading = false;
}
}
// Fetch public characters
async function fetchCharacters() {
charactersLoading = true;
charactersError = null;
try {
const result = await dataService.getPublicCharacters(activeFilter, 20, 0);
characters = result.characters;
charactersHasMore = result.hasMore;
} catch (err) {
console.error('[Discover] Failed to fetch characters:', err);
charactersError = 'Charaktere konnten nicht geladen werden';
} finally {
charactersLoading = false;
}
}
// Load data when section or filter changes
$effect(() => {
if (activeSection === 'stories') {
fetchStories();
} else {
fetchCharacters();
}
});
// Vote for a story
async function handleVoteStory(storyId: string) {
try {
const story = stories.find((s) => s.id === storyId);
if (story?.user_vote) {
await dataService.unvoteStory(storyId);
stories = stories.map((s) =>
s.id === storyId
? { ...s, user_vote: null, vote_count: (s.vote_count || 1) - 1 }
: s
);
} else {
await dataService.voteForStory(storyId, 'like');
stories = stories.map((s) =>
s.id === storyId
? { ...s, user_vote: 'like', vote_count: (s.vote_count || 0) + 1 }
: s
);
}
} catch (err) {
console.error('[Discover] Vote failed:', err);
}
}
// Vote for a character
async function handleVoteCharacter(characterId: string) {
try {
const character = characters.find((c) => c.id === characterId);
if (character?.user_vote) {
await dataService.removeCharacterVote(characterId);
characters = characters.map((c) =>
c.id === characterId
? { ...c, user_vote: null, vote_count: (c.vote_count || 1) - 1 }
: c
);
} else {
await dataService.voteForCharacter(characterId, 'like');
characters = characters.map((c) =>
c.id === characterId
? { ...c, user_vote: 'like', vote_count: (c.vote_count || 0) + 1 }
: c
);
}
} catch (err) {
console.error('[Discover] Vote failed:', err);
}
}
// Clone a character
async function handleCloneCharacter(characterId: string) {
try {
const cloned = await dataService.cloneCharacter(characterId);
// Navigate to the cloned character
window.location.href = `/characters/${cloned.id}`;
} catch (err) {
console.error('[Discover] Clone failed:', err);
}
}
// Get image URL helper
function getStoryImage(story: Story): string {
return story.pages?.[0]?.image || '/images/placeholder-story.png';
}
function getCharacterImage(character: Character): string {
return character.imageUrl || character.image_url || '/images/placeholder-character.png';
}
</script>
<svelte:head>
<title>Entdecken | Märchenzauber</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Entdecken</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
Entdecke Geschichten und Charaktere von anderen Nutzern
</p>
</div>
<!-- Search -->
<div class="relative w-full sm:max-w-xs">
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Suchen..."
bind:value={searchQuery}
class="w-full rounded-xl border border-gray-200 bg-white py-2 pl-10 pr-4 text-sm focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
/>
</div>
</div>
<!-- Section Tabs -->
<div class="flex gap-2">
<button
onclick={() => (activeSection = 'stories')}
class="flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all {activeSection === 'stories' ? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-lg' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Geschichten
</button>
<button
onclick={() => (activeSection = 'characters')}
class="flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all {activeSection === 'characters' ? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-lg' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Charaktere
</button>
</div>
<!-- Filter Tabs -->
<div class="flex gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
<button
onclick={() => (activeFilter = 'popular')}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-all {activeFilter === 'popular' ? 'bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'}"
>
🔥 Beliebt
</button>
<button
onclick={() => (activeFilter = 'new')}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-all {activeFilter === 'new' ? 'bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'}"
>
✨ Neu
</button>
<button
onclick={() => (activeFilter = 'featured')}
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-all {activeFilter === 'featured' ? 'bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400' : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'}"
>
⭐ Featured
</button>
</div>
<!-- Content -->
{#if activeSection === 'stories'}
<!-- Stories Section -->
{#if storiesLoading}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each Array(8) as _}
<div class="aspect-[4/3] animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
{/each}
</div>
{:else if storiesError}
<div class="rounded-2xl bg-red-50 p-6 text-center dark:bg-red-900/20">
<svg class="mx-auto h-12 w-12 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p class="mt-2 text-red-600 dark:text-red-400">{storiesError}</p>
<button
onclick={fetchStories}
class="mt-4 rounded-xl bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Erneut versuchen
</button>
</div>
{:else if filteredStories.length === 0}
<div class="rounded-2xl bg-gray-50 p-8 text-center dark:bg-gray-800/50">
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<h3 class="mt-4 font-medium text-gray-700 dark:text-gray-300">Keine Geschichten gefunden</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{searchQuery ? 'Versuche es mit anderen Suchbegriffen' : 'Schau später nochmal vorbei'}
</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each filteredStories as story (story.id)}
<a
href="/stories/{story.id}"
class="group relative overflow-hidden rounded-2xl bg-white shadow-md transition-all hover:shadow-xl dark:bg-gray-800"
>
<!-- Image -->
<div class="aspect-[4/3] overflow-hidden">
<img
src={getStoryImage(story)}
alt={story.title}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
<!-- Featured Badge -->
{#if story.visibility === 'featured'}
<div class="absolute left-3 top-3 rounded-full bg-amber-500 px-2 py-0.5 text-xs font-medium text-white shadow">
⭐ Featured
</div>
{/if}
<!-- Vote Count -->
<button
onclick={(e) => { e.preventDefault(); e.stopPropagation(); handleVoteStory(story.id); }}
class="absolute right-3 top-3 flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium shadow backdrop-blur {story.user_vote ? 'bg-pink-500 text-white' : 'bg-white/90 text-gray-700 dark:bg-gray-800/90 dark:text-gray-300'}"
>
<svg class="h-4 w-4" fill={story.user_vote ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{story.vote_count || 0}
</button>
<!-- Info -->
<div class="p-4">
<h3 class="font-semibold text-gray-800 dark:text-gray-200 line-clamp-1">
{story.title || 'Ohne Titel'}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{story.prompt || 'Keine Beschreibung'}
</p>
</div>
</a>
{/each}
</div>
{/if}
{:else}
<!-- Characters Section -->
{#if charactersLoading}
<div class="grid gap-6 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each Array(10) as _}
<div class="flex flex-col items-center gap-3">
<div class="h-24 w-24 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
{/each}
</div>
{:else if charactersError}
<div class="rounded-2xl bg-red-50 p-6 text-center dark:bg-red-900/20">
<svg class="mx-auto h-12 w-12 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p class="mt-2 text-red-600 dark:text-red-400">{charactersError}</p>
<button
onclick={fetchCharacters}
class="mt-4 rounded-xl bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Erneut versuchen
</button>
</div>
{:else if filteredCharacters.length === 0}
<div class="rounded-2xl bg-gray-50 p-8 text-center dark:bg-gray-800/50">
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<h3 class="mt-4 font-medium text-gray-700 dark:text-gray-300">Keine Charaktere gefunden</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{searchQuery ? 'Versuche es mit anderen Suchbegriffen' : 'Schau später nochmal vorbei'}
</p>
</div>
{:else}
<div class="grid gap-6 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each filteredCharacters as character (character.id)}
<div class="group flex flex-col items-center text-center">
<!-- Avatar with actions -->
<div class="relative">
<a
href="/characters/{character.id}"
class="block h-24 w-24 overflow-hidden rounded-full border-3 border-white shadow-lg transition-all group-hover:scale-105 group-hover:shadow-xl dark:border-gray-700"
>
<img
src={getCharacterImage(character)}
alt={character.name}
class="h-full w-full object-cover"
loading="lazy"
/>
</a>
<!-- Vote Button -->
<button
onclick={() => handleVoteCharacter(character.id)}
class="absolute -right-1 -top-1 flex h-7 w-7 items-center justify-center rounded-full shadow transition-all {character.user_vote ? 'bg-pink-500 text-white' : 'bg-white text-gray-500 hover:text-pink-500 dark:bg-gray-800 dark:text-gray-400'}"
title="Gefällt mir"
>
<svg class="h-4 w-4" fill={character.user_vote ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
<!-- Clone Button -->
<button
onclick={() => handleCloneCharacter(character.id)}
class="absolute -bottom-1 -right-1 flex h-7 w-7 items-center justify-center rounded-full bg-purple-500 text-white shadow transition-all hover:bg-purple-600"
title="Charakter übernehmen"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</button>
</div>
<!-- Name & Votes -->
<a
href="/characters/{character.id}"
class="mt-3 font-medium text-gray-700 hover:text-pink-600 dark:text-gray-300 dark:hover:text-pink-400"
>
{character.name}
</a>
<span class="text-xs text-gray-500 dark:text-gray-400">
{character.vote_count || 0} Likes
</span>
</div>
{/each}
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,240 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/authStore.svelte';
import { goto } from '$app/navigation';
import { dataService } from '$lib/api';
// Stats
let storyCount = $state(0);
let characterCount = $state(0);
let loadingStats = $state(true);
// Theme
let isDarkMode = $state(false);
onMount(async () => {
// Load stats
try {
const [stories, characters] = await Promise.all([
dataService.getStories(),
dataService.getCharacters(),
]);
storyCount = stories.filter((s) => !s.archived).length;
characterCount = characters.filter((c) => !c.archived).length;
} catch (err) {
console.error('[Settings] Failed to load stats:', err);
} finally {
loadingStats = false;
}
// Check current theme
isDarkMode = document.documentElement.classList.contains('dark');
});
function toggleTheme() {
isDarkMode = !isDarkMode;
if (isDarkMode) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
</script>
<svelte:head>
<title>Einstellungen | Märchenzauber</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Einstellungen</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
Verwalte dein Konto und deine Einstellungen
</p>
</div>
<!-- Stats Section -->
<section class="rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 p-6 text-white shadow-lg">
<h2 class="mb-4 text-lg font-semibold">Deine Statistiken</h2>
<div class="grid grid-cols-2 gap-4">
<div class="rounded-xl bg-white/20 p-4 backdrop-blur">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-white/20">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div>
<p class="text-2xl font-bold">{loadingStats ? '...' : storyCount}</p>
<p class="text-sm text-white/80">Geschichten</p>
</div>
</div>
</div>
<div class="rounded-xl bg-white/20 p-4 backdrop-blur">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-white/20">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div>
<p class="text-2xl font-bold">{loadingStats ? '...' : characterCount}</p>
<p class="text-sm text-white/80">Charaktere</p>
</div>
</div>
</div>
</div>
</section>
<!-- Preferences Section -->
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Einstellungen</h2>
<div class="space-y-4">
<!-- Dark Mode Toggle -->
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-4 dark:bg-gray-700/50">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-300">
{#if isDarkMode}
<svg class="h-5 w-5" 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>
{:else}
<svg class="h-5 w-5" 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>
{/if}
</div>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Dunkelmodus</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{isDarkMode ? 'Aktiviert' : 'Deaktiviert'}</p>
</div>
</div>
<button
onclick={toggleTheme}
class="relative h-7 w-12 rounded-full transition-colors {isDarkMode ? 'bg-pink-500' : 'bg-gray-300'}"
>
<span
class="absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-all {isDarkMode ? 'left-5.5' : 'left-0.5'}"
style="left: {isDarkMode ? '1.375rem' : '0.125rem'}"
></span>
</button>
</div>
</div>
</section>
<!-- Account Section -->
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Konto</h2>
<div class="space-y-4">
<!-- Email -->
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-4 dark:bg-gray-700/50">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">E-Mail</p>
<p class="text-gray-800 dark:text-gray-200">{authStore.user?.email || '-'}</p>
</div>
</div>
<!-- User ID -->
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-4 dark:bg-gray-700/50">
<div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Benutzer-ID</p>
<p class="font-mono text-xs text-gray-600 dark:text-gray-400">{authStore.user?.id || '-'}</p>
</div>
</div>
</div>
</section>
<!-- Subscription Section -->
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Abonnement</h2>
<a
href="/subscription"
class="flex items-center justify-between rounded-xl bg-gradient-to-r from-amber-100 to-yellow-100 p-4 transition-all hover:from-amber-200 hover:to-yellow-200 dark:from-amber-900/30 dark:to-yellow-900/30 dark:hover:from-amber-900/50 dark:hover:to-yellow-900/50"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-500 text-white">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
</div>
<div>
<p class="font-medium text-gray-800 dark:text-gray-200">Mana verwalten</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Abonnement und Guthaben</p>
</div>
</div>
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
</section>
<!-- Actions Section -->
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Aktionen</h2>
<div class="space-y-2">
<!-- Archive -->
<a
href="/archive"
class="flex items-center gap-3 rounded-xl p-3 transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
</div>
<div class="flex-1">
<p class="font-medium text-gray-800 dark:text-gray-200">Archiv</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Archivierte Geschichten und Charaktere</p>
</div>
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
<!-- Help -->
<a
href="/help"
class="flex items-center gap-3 rounded-xl p-3 transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100 text-blue-500 dark:bg-blue-900/30 dark:text-blue-400">
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="flex-1">
<p class="font-medium text-gray-800 dark:text-gray-200">Hilfe</p>
<p class="text-sm text-gray-500 dark:text-gray-400">FAQ und Support</p>
</div>
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</a>
<!-- Logout -->
<button
onclick={async () => {
await authStore.signOut();
goto('/login');
}}
class="flex w-full items-center gap-3 rounded-xl p-3 text-left transition-all hover:bg-red-50 dark:hover:bg-red-900/20"
>
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-red-100 text-red-500 dark:bg-red-900/30 dark:text-red-400">
<svg class="h-5 w-5" 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>
</div>
<div class="flex-1">
<p class="font-medium text-red-600 dark:text-red-400">Abmelden</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Von deinem Konto abmelden</p>
</div>
</button>
</div>
</section>
</div>

View file

@ -0,0 +1,159 @@
<script lang="ts">
import { onMount } from 'svelte';
import { dataService } from '$lib/api';
import type { Story } from '$lib/types/story';
import StoryCard from '$lib/components/story/StoryCard.svelte';
import EmptyState from '$lib/components/common/EmptyState.svelte';
let stories = $state<Story[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let filter = $state<'all' | 'favorites'>('all');
let searchQuery = $state('');
let filteredStories = $derived.by(() => {
let result = stories.filter((s) => !s.archived);
// Apply filter
if (filter === 'favorites') {
result = result.filter((s) => s.is_favorite);
}
// Apply search
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(s) =>
s.title?.toLowerCase().includes(query) ||
s.prompt?.toLowerCase().includes(query) ||
s.characterName?.toLowerCase().includes(query)
);
}
// Sort by date (newest first)
return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
});
async function fetchStories() {
try {
loading = true;
error = null;
stories = await dataService.getStories();
} catch (e) {
console.error('[Stories] Failed to fetch:', e);
error = 'Geschichten konnten nicht geladen werden';
} finally {
loading = false;
}
}
onMount(() => {
fetchStories();
});
</script>
<svelte:head>
<title>Geschichten | Märchenzauber</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Deine Geschichten</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
{filteredStories.length} {filteredStories.length === 1 ? 'Geschichte' : 'Geschichten'}
</p>
</div>
<a
href="/stories/create"
class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-4 py-2.5 text-sm font-semibold text-white shadow-md transition-all hover:from-pink-600 hover:to-purple-700 hover:shadow-lg"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Neue Geschichte
</a>
</div>
<!-- Filters & Search -->
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<!-- Filter Tabs -->
<div class="flex gap-2">
<button
onclick={() => (filter = 'all')}
class="rounded-xl px-4 py-2 text-sm font-medium transition-all"
class:bg-pink-500={filter === 'all'}
class:text-white={filter === 'all'}
class:bg-gray-100={filter !== 'all'}
class:text-gray-600={filter !== 'all'}
class:dark:bg-gray-700={filter !== 'all'}
class:dark:text-gray-300={filter !== 'all'}
>
Alle
</button>
<button
onclick={() => (filter = 'favorites')}
class="flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium transition-all"
class:bg-pink-500={filter === 'favorites'}
class:text-white={filter === 'favorites'}
class:bg-gray-100={filter !== 'favorites'}
class:text-gray-600={filter !== 'favorites'}
class:dark:bg-gray-700={filter !== 'favorites'}
class:dark:text-gray-300={filter !== 'favorites'}
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
Favoriten
</button>
</div>
<!-- Search -->
<div class="relative flex-1 sm:max-w-xs">
<svg class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
type="text"
placeholder="Suchen..."
bind:value={searchQuery}
class="w-full rounded-xl border border-gray-200 bg-white py-2 pl-10 pr-4 text-sm focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
/>
</div>
</div>
<!-- Content -->
{#if loading}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each Array(8) as _}
<div class="aspect-[4/3] animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
{/each}
</div>
{:else if error}
<div class="rounded-2xl bg-red-50 p-4 text-red-600 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
{:else if filteredStories.length === 0}
{#if searchQuery || filter === 'favorites'}
<EmptyState
title="Keine Ergebnisse"
description="Versuche einen anderen Suchbegriff oder Filter."
icon="search"
/>
{:else}
<EmptyState
title="Noch keine Geschichten"
description="Erstelle deine erste magische Geschichte!"
actionLabel="Geschichte erstellen"
actionHref="/stories/create"
/>
{/if}
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each filteredStories as story (story.id)}
<StoryCard {story} />
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,119 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { dataService } from '$lib/api/dataService';
import type { Story } from '$lib/types/story';
import StoryViewer from '$lib/components/story/StoryViewer.svelte';
let story = $state<Story | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
// Get story ID from route params
let storyId = $derived($page.params.id);
// Fetch story data
async function loadStory() {
if (!storyId) return;
loading = true;
error = null;
try {
story = await dataService.getStoryById(storyId);
} catch (err) {
console.error('[StoryReader] Failed to load story:', err);
error = err instanceof Error ? err.message : 'Geschichte konnte nicht geladen werden';
} finally {
loading = false;
}
}
// Load story when ID changes
$effect(() => {
if (storyId) {
loadStory();
}
});
// Handle close - go back to stories list
function handleClose() {
goto('/stories');
}
// Handle archive
async function handleArchive() {
if (!story) return;
try {
await dataService.updateStory(story.id, { archived: true });
goto('/stories');
} catch (err) {
console.error('[StoryReader] Failed to archive story:', err);
}
}
// Handle favorite toggle
async function handleToggleFavorite() {
if (!story) return;
const newFavoriteState = !story.is_favorite;
try {
await dataService.toggleFavorite(story.id, newFavoriteState);
// Update local state
story = { ...story, is_favorite: newFavoriteState };
} catch (err) {
console.error('[StoryReader] Failed to toggle favorite:', err);
}
}
</script>
<svelte:head>
<title>{story?.title || 'Geschichte laden...'} - Märchenzauber</title>
</svelte:head>
{#if loading}
<!-- Loading State -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900">
<div class="flex flex-col items-center gap-4">
<div class="h-12 w-12 animate-spin rounded-full border-4 border-pink-500 border-t-transparent"></div>
<p class="text-lg text-white">Geschichte wird geladen...</p>
</div>
</div>
{:else if error}
<!-- Error State -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900 p-4">
<div class="max-w-md rounded-2xl bg-white/10 p-8 text-center backdrop-blur">
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-500/20 mx-auto">
<svg class="h-8 w-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h2 class="mb-2 text-xl font-bold text-white">Fehler beim Laden</h2>
<p class="mb-6 text-white/70">{error}</p>
<div class="flex flex-col gap-3 sm:flex-row sm:justify-center">
<button
onclick={loadStory}
class="rounded-xl bg-pink-500 px-6 py-3 font-medium text-white transition-colors hover:bg-pink-600"
>
Erneut versuchen
</button>
<button
onclick={handleClose}
class="rounded-xl bg-white/10 px-6 py-3 font-medium text-white transition-colors hover:bg-white/20"
>
Zurück zur Übersicht
</button>
</div>
</div>
</div>
{:else if story}
<!-- Story Viewer -->
<StoryViewer
{story}
onClose={handleClose}
onArchive={handleArchive}
onToggleFavorite={handleToggleFavorite}
/>
{/if}

View file

@ -0,0 +1,257 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { dataService } from '$lib/api';
import type { Character } from '$lib/types/character';
let characters = $state<Character[]>([]);
let selectedCharacter = $state<Character | null>(null);
let storyPrompt = $state('');
let loading = $state(true);
let creating = $state(false);
let error = $state<string | null>(null);
// Fetch characters on mount
async function fetchCharacters() {
try {
loading = true;
characters = await dataService.getCharacters();
} catch (e) {
console.error('[CreateStory] Failed to fetch characters:', e);
error = 'Charaktere konnten nicht geladen werden';
} finally {
loading = false;
}
}
onMount(() => {
fetchCharacters();
});
// Select a character
function selectCharacter(character: Character) {
selectedCharacter = character;
}
// Create the story
async function handleCreateStory() {
if (!selectedCharacter) {
error = 'Bitte wähle einen Charakter aus.';
return;
}
if (!storyPrompt.trim()) {
error = 'Bitte beschreibe deine Geschichte.';
return;
}
creating = true;
error = null;
try {
const result = await dataService.createStory({
storyDescription: storyPrompt,
characters: [selectedCharacter.id],
});
// Navigate to the newly created story
goto(`/stories/${result.storyId}`);
} catch (e) {
console.error('[CreateStory] Failed to create story:', e);
error = e instanceof Error ? e.message : 'Geschichte konnte nicht erstellt werden';
creating = false;
}
}
// Get image URL for a character
function getImageUrl(character: Character): string {
return character.imageUrl || character.image_url || '/images/placeholder-character.png';
}
</script>
<svelte:head>
<title>Geschichte erstellen | Märchenzauber</title>
</svelte:head>
<!-- Creating Overlay -->
{#if creating}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/90 backdrop-blur">
<div class="flex flex-col items-center gap-6 p-8 text-center">
<!-- Magical Animation -->
<div class="relative">
<div class="h-24 w-24 animate-spin rounded-full border-4 border-pink-500/30 border-t-pink-500"></div>
<div class="absolute inset-0 flex items-center justify-center">
<svg class="h-10 w-10 animate-pulse text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
</div>
<div>
<h2 class="mb-2 text-xl font-bold text-white">Deine Geschichte wird erschaffen...</h2>
<p class="text-white/70">
Die Magie arbeitet! Dies kann bis zu 2 Minuten dauern.
</p>
</div>
<!-- Progress Steps -->
<div class="mt-4 flex flex-col gap-2 text-left text-sm text-white/60">
<div class="flex items-center gap-2">
<div class="h-2 w-2 animate-pulse rounded-full bg-pink-500"></div>
<span>Geschichte wird geschrieben...</span>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-white/30"></div>
<span>Illustrationen werden erstellt...</span>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-white/30"></div>
<span>Alles wird zusammengesetzt...</span>
</div>
</div>
</div>
</div>
{/if}
<div class="mx-auto max-w-4xl space-y-8">
<!-- Header -->
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200 sm:text-3xl">
Geschichte erstellen
</h1>
<p class="mt-2 text-gray-500 dark:text-gray-400">
Wähle einen Charakter und beschreibe deine Geschichte
</p>
</div>
<!-- Error Message -->
{#if error}
<div class="rounded-xl bg-red-50 p-4 text-red-600 dark:bg-red-900/20 dark:text-red-400">
<div class="flex items-start gap-3">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>{error}</span>
</div>
</div>
{/if}
<!-- Step 1: Character Selection -->
<section class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
1. Wähle deinen Charakter
</h2>
<a
href="/characters/create"
class="text-sm font-medium text-pink-600 hover:text-pink-700 dark:text-pink-400 dark:hover:text-pink-300"
>
+ Neuen Charakter erstellen
</a>
</div>
{#if loading}
<!-- Loading Skeleton -->
<div class="flex gap-4 overflow-x-auto pb-2">
{#each Array(5) as _}
<div class="flex flex-shrink-0 flex-col items-center gap-2">
<div class="h-20 w-20 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
{/each}
</div>
{:else if characters.length === 0}
<!-- No Characters -->
<div class="rounded-2xl border-2 border-dashed border-gray-200 bg-gray-50 p-8 text-center dark:border-gray-700 dark:bg-gray-800/50">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<p class="mt-2 font-medium text-gray-700 dark:text-gray-300">Noch keine Charaktere</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Erstelle zuerst einen Charakter für deine Geschichte.
</p>
<a
href="/characters/create"
class="mt-4 inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-4 py-2 text-sm font-semibold text-white"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Charakter erstellen
</a>
</div>
{:else}
<!-- Character Grid -->
<div class="flex flex-wrap gap-4">
{#each characters.filter(c => !c.archived) as character (character.id)}
<button
onclick={() => selectCharacter(character)}
class="flex flex-col items-center gap-2 rounded-2xl p-3 transition-all {selectedCharacter?.id === character.id ? 'bg-pink-100 dark:bg-pink-900/30 ring-2 ring-pink-500' : 'hover:bg-gray-100 dark:hover:bg-gray-800'}"
>
<div class="relative">
<div class="h-20 w-20 overflow-hidden rounded-full border-3 border-white shadow-md dark:border-gray-700">
<img
src={getImageUrl(character)}
alt={character.name}
class="h-full w-full object-cover"
loading="lazy"
/>
</div>
{#if selectedCharacter?.id === character.id}
<div class="absolute -right-1 -top-1 flex h-6 w-6 items-center justify-center rounded-full bg-pink-500 text-white">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
{/if}
</div>
<span class="max-w-[100px] truncate text-sm font-medium text-gray-700 dark:text-gray-300">
{character.name}
</span>
</button>
{/each}
</div>
{/if}
</section>
<!-- Step 2: Story Prompt -->
<section class="space-y-4">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
2. Beschreibe deine Geschichte
</h2>
<div class="space-y-2">
<textarea
bind:value={storyPrompt}
placeholder="z.B. Mein Charakter geht auf eine magische Reise durch einen verzauberten Wald und trifft dort sprechende Tiere..."
rows={6}
class="w-full resize-none rounded-xl border border-gray-200 bg-white p-4 text-gray-800 placeholder:text-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:placeholder:text-gray-500"
></textarea>
<p class="text-sm text-gray-500 dark:text-gray-400">
Beschreibe den Handlungsablauf, die Stimmung und wichtige Ereignisse. Die KI wird daraus eine passende Geschichte erstellen und illustrieren.
</p>
</div>
</section>
<!-- Submit Button -->
<div class="flex justify-center pt-4">
<button
onclick={handleCreateStory}
disabled={creating || !selectedCharacter || !storyPrompt.trim()}
class="flex items-center gap-3 rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 px-8 py-4 text-lg font-semibold text-white shadow-lg transition-all hover:from-pink-600 hover:to-purple-700 hover:shadow-xl disabled:cursor-not-allowed disabled:opacity-50"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
Geschichte erstellen
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</button>
</div>
<!-- Credit Info -->
<p class="text-center text-sm text-gray-500 dark:text-gray-400">
Das Erstellen einer Geschichte kostet 10 Mana.
</p>
</div>

View file

@ -0,0 +1,7 @@
import { waitLocale } from '$lib/i18n';
import '$lib/i18n'; // This triggers the init() call at module scope
export const load = async () => {
await waitLocale();
return {};
};

View file

@ -1,2 +1,28 @@
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { authStore } from '$lib/stores/authStore.svelte';
let loading = $state(true);
onMount(async () => {
// Initialize auth and redirect accordingly
await authStore.initialize();
if (authStore.isAuthenticated) {
// Redirect to dashboard (which is in protected routes)
goto('/dashboard');
} else {
// Redirect to login
goto('/login');
}
});
</script>
<!-- Loading state while checking auth -->
<div class="flex min-h-screen items-center justify-center bg-gradient-to-br from-pink-50 to-purple-50 dark:from-gray-900 dark:to-gray-800">
<div class="text-center">
<div class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-pink-500 border-r-transparent"></div>
<p class="text-gray-600 dark:text-gray-400">Laden...</p>
</div>
</div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

View file

@ -0,0 +1,12 @@
import preset from '@manacore/shared-tailwind/preset';
/** @type {import('tailwindcss').Config} */
export default {
presets: [preset],
content: [
'./src/**/*.{html,js,svelte,ts}',
'../../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
'../../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
],
plugins: [require('@tailwindcss/typography')]
};

View file

@ -21,6 +21,7 @@
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.7",
"tailwindcss": "^3.4.0"
}
}

View file

@ -43,8 +43,8 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.17",
"@types/react-dom": "^18.3.7",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"eslint": "^8.57.0",

View file

@ -1,8 +1,12 @@
@import '@manacore/shared-tailwind/themes.css';
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-subscription-ui/src";
@layer base {
:root {

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { LanguageSelector } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
import { theme } from '$lib/stores/theme';
let isDark = $derived(theme.isDark);
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
</script>
<LanguageSelector
{currentLocale}
{supportedLocales}
onLocaleChange={handleLocaleChange}
{isDark}
primaryColor="#6366f1"
/>

View file

@ -0,0 +1,57 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
// List of supported locales
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('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()
});
// Also export initI18n for backwards compatibility
export function initI18n() {
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale()
});
}
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,12 @@
{
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"back": "Zurück",
"loading": "Lädt..."
},
"app_slider": {
"title": "Teil des Mana Ökosystems"
}
}

View file

@ -0,0 +1,12 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"back": "Back",
"loading": "Loading..."
},
"app_slider": {
"title": "Part of the Mana Ecosystem"
}
}

View file

@ -1,73 +1,26 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button, Input, Card } from '@manacore/shared-ui';
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
let { form } = $props();
let loading = $state(false);
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);
}
</script>
<div>
<div class="text-center">
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Forgot Password</h2>
<p class="text-gray-600 dark:text-gray-400">
Enter your email and we'll send you a link to reset your password
</p>
</div>
<Card class="mt-8">
<form
method="POST"
use:enhance={() => {
loading = true;
return async ({ update }) => {
await update();
loading = false;
};
}}
>
{#if form?.error}
<div class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400">
{form.error}
</div>
{/if}
{#if form?.success}
<div class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-400">
Password reset email sent! Check your inbox for instructions.
</div>
{/if}
<div class="space-y-4">
<div>
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
Email address
</label>
<Input
type="email"
name="email"
id="email"
autocomplete="email"
placeholder="you@example.com"
required
value={form?.email ?? ''}
/>
</div>
<div>
<Button type="submit" {loading} class="w-full">
{loading ? 'Sending...' : 'Send reset link'}
</Button>
</div>
</div>
</form>
<div class="mt-6 text-center">
<p class="text-sm text-gray-600 dark:text-gray-400">
Remember your password?
<a href="/login" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">
Sign in
</a>
</p>
</div>
</Card>
</div>
<ForgotPasswordPage
appName="ManaCore"
logo={ManaCoreLogo}
primaryColor="#6366f1"
onForgotPassword={handleForgotPassword}
goto={goto}
loginPath="/login"
lightBackground="#f3f4f6"
darkBackground="#121212"
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -3,15 +3,12 @@
import { LoginPage } from '@manacore/shared-auth-ui';
import { ManaCoreLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);
}
</script>
<LoginPage
@ -19,15 +16,18 @@
logo={ManaCoreLogo}
primaryColor="#6366f1"
onSignIn={handleSignIn}
onForgotPassword={handleForgotPassword}
goto={goto}
enableGoogle={false}
enableApple={false}
successRedirect="/dashboard"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f3f4f6"
darkBackground="#121212"
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}

View file

@ -1,38 +1,7 @@
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr';
import type { LayoutLoad } from './$types';
import { waitLocale } from '$lib/i18n';
import '$lib/i18n'; // This triggers the init() call at module scope
export const load: LayoutLoad = async ({ data, depends, fetch }) => {
depends('supabase:auth');
const supabase = isBrowser()
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch
}
})
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
global: {
fetch
},
cookies: {
getAll() {
return data.cookies;
}
}
});
// Use getUser() for security - authenticates with Supabase servers
const {
data: { user }
} = await supabase.auth.getUser();
// Only get session after user is verified
let session = null;
if (user) {
const { data: sessionData } = await supabase.auth.getSession();
session = sessionData.session;
}
return { session, supabase, user };
export const load = async () => {
await waitLocale();
return {};
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

View file

@ -5,8 +5,8 @@ export default {
presets: [preset],
content: [
'./src/**/*.{html,js,svelte,ts}',
'../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
'../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
'../../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
'../../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
],
theme: {
extend: {

View file

@ -64,7 +64,8 @@
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.10",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.3",
"ajv": "^8.12.0",
"eslint": "^9.25.1",
"eslint-config-expo": "~10.0.0",

View file

@ -28,6 +28,7 @@
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-config": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
@ -40,6 +41,7 @@
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@supabase/supabase-js": "^2.81.1"
"@supabase/supabase-js": "^2.81.1",
"svelte-i18n": "^4.0.1"
}
}

View file

@ -1,5 +1,9 @@
@import '@manacore/shared-tailwind/themes.css';
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-subscription-ui/src";

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { locale } from 'svelte-i18n';
import { LanguageSelector } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
import { theme } from '$lib/stores/theme';
let isDark = $derived(theme.isDark);
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
</script>
<LanguageSelector
{currentLocale}
{supportedLocales}
onLocaleChange={handleLocaleChange}
{isDark}
primaryColor="#8b5cf6"
/>

View file

@ -0,0 +1,57 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
// List of supported locales
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('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()
});
// Also export initI18n for backwards compatibility
export function initI18n() {
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale()
});
}
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,12 @@
{
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"back": "Zurück",
"loading": "Lädt..."
},
"app_slider": {
"title": "Teil des Mana Ökosystems"
}
}

View file

@ -0,0 +1,12 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"back": "Back",
"loading": "Loading..."
},
"app_slider": {
"title": "Part of the Mana Ecosystem"
}
}

View file

@ -63,7 +63,7 @@
{#if deck.tags && deck.tags.length > 0}
<div class="flex flex-wrap gap-2">
{#each deck.tags as tag}
<Badge variant="secondary">{tag}</Badge>
<Badge variant="default">{tag}</Badge>
{/each}
</div>
{/if}
@ -71,9 +71,9 @@
<div class="flex items-center space-x-2">
{#if deck.is_public}
<Badge variant="outline">Public</Badge>
<Badge variant="info">Public</Badge>
{/if}
<Button variant="destructive" size="sm" onclick={() => (showDeleteConfirm = true)}>
<Button variant="danger" size="sm" onclick={() => (showDeleteConfirm = true)}>
Delete
</Button>
</div>
@ -140,7 +140,7 @@
<Button variant="ghost" onclick={() => (showDeleteConfirm = false)}>
Cancel
</Button>
<Button variant="destructive" loading={deleting} onclick={handleDelete}>
<Button variant="danger" loading={deleting} onclick={handleDelete}>
Delete Deck
</Button>
</div>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { ManaDeckLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);
}
</script>
<ForgotPasswordPage
appName="ManaDeck"
logo={ManaDeckLogo}
primaryColor="#8b5cf6"
onForgotPassword={handleForgotPassword}
goto={goto}
loginPath="/login"
lightBackground="#faf5ff"
darkBackground="#1a1625"
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -3,15 +3,12 @@
import { LoginPage } from '@manacore/shared-auth-ui';
import { ManaDeckLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
async function handleForgotPassword(email: string) {
return authStore.forgotPassword(email);
}
</script>
<LoginPage
@ -19,15 +16,18 @@
logo={ManaDeckLogo}
primaryColor="#8b5cf6"
onSignIn={handleSignIn}
onForgotPassword={handleForgotPassword}
goto={goto}
enableGoogle={false}
enableApple={false}
successRedirect="/decks"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#faf5ff"
darkBackground="#1a1625"
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}

View file

@ -0,0 +1,7 @@
import { waitLocale } from '$lib/i18n';
import '$lib/i18n'; // This triggers the init() call at module scope
export const load = async () => {
await waitLocale();
return {};
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

View file

@ -5,8 +5,8 @@ export default {
presets: [preset],
content: [
'./src/**/*.{html,js,svelte,ts}',
'../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
'../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
'../../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
'../../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
],
plugins: [require('@tailwindcss/typography')]
};

View file

@ -92,7 +92,7 @@
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@types/react": "~19.1.10",
"@types/react": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"eslint": "^9.36.0",

View file

@ -1,7 +1,9 @@
@import '@manacore/shared-tailwind/themes.css';
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@import '@manacore/shared-tailwind/components.css';
/* Scan shared packages for Tailwind classes */
@source "../../../../packages/shared-ui/src";
@source "../../../../packages/shared-auth-ui/src";
@source "../../../../packages/shared-branding/src";
@source "../../../../packages/shared-theme-ui/src";
@source "../../../../packages/shared-subscription-ui/src";

View file

@ -3,8 +3,8 @@
import { theme } from '$lib/stores/theme';
import { t } from 'svelte-i18n';
let currentTheme = $derived($theme);
let isDark = $derived(currentTheme.effectiveMode === 'dark');
// theme is a Svelte 5 runes-based store, access properties directly
let isDark = $derived(theme.isDark);
let apps = $derived<AppItem[]>([
{

View file

@ -2,24 +2,10 @@
import { locale } from 'svelte-i18n';
import { LanguageSelector } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
import { theme, type ThemeVariant } from '$lib/stores/theme';
import { theme } from '$lib/stores/theme';
let currentTheme = $derived(theme);
let isDark = $derived(currentTheme.effectiveMode === 'dark');
let currentLocale = $derived($locale || 'en');
const variantColors: Record<ThemeVariant, { light: string; dark: string }> = {
lume: { light: '#f8d62b', dark: '#f8d62b' },
nature: { light: '#4CAF50', dark: '#4CAF50' },
stone: { light: '#607D8B', dark: '#78909C' },
ocean: { light: '#039BE5', dark: '#039BE5' }
};
function getPrimaryColor(): string {
const variant = currentTheme.variant;
const colors = variantColors[variant];
return isDark ? colors.dark : colors.light;
}
let isDark = $derived(theme.isDark);
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
@ -31,5 +17,5 @@
{supportedLocales}
onLocaleChange={handleLocaleChange}
{isDark}
primaryColor={getPrimaryColor()}
primaryColor="#f8d62b"
/>

View file

@ -2,6 +2,7 @@
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { recording } from '$lib/stores/recording';
import { formatDuration } from '@manacore/shared-utils';
import type { RecordingStatus } from '$lib/stores/recording';
// Props
@ -38,13 +39,6 @@
const borderColor = themeColor;
const backgroundColor = isRecording ? themeColor : 'transparent';
// Format duration MM:SS
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// Animation functions
function startRotationAnimation() {
if (rotationAnimationId) return;

View file

@ -1,6 +1,7 @@
<script lang="ts">
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import { Text } from '@manacore/shared-ui';
import { formatDurationFromMs, formatFileSize } from '@manacore/shared-utils';
import type { AdditionalRecording } from '$lib/types/memo.types';
interface Props {
@ -17,32 +18,6 @@
let editingId = $state<string | null>(null);
let editLabel = $state('');
function formatDuration(millis?: number): string {
if (!millis) return '--:--';
const totalSeconds = Math.floor(millis / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = Math.floor(totalSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
function formatSize(bytes?: number): string {
if (!bytes) return '--';
const mb = bytes / (1024 * 1024);
if (mb > 1) {
return `${mb.toFixed(1)} MB`;
}
const kb = bytes / 1024;
return `${kb.toFixed(1)} KB`;
}
function formatDate(date: string): string {
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
@ -165,7 +140,7 @@
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{formatDuration(recording.duration_millis)}
{recording.duration_millis ? formatDurationFromMs(recording.duration_millis) : '--:--'}
</Text>
<Text variant="muted" class="flex items-center gap-1">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View file

@ -175,7 +175,13 @@
<style>
.kbd {
@apply rounded border px-1.5 py-0.5 font-mono text-xs;
border-radius: 0.25rem;
border-width: 1px;
padding: 0.125rem 0.375rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 0.75rem;
line-height: 1rem;
border-color: var(--color-border);
background-color: var(--color-menu-bg-hover);
}

View file

@ -205,7 +205,13 @@
<style>
.kbd {
@apply rounded border px-1.5 py-0.5 font-mono text-xs;
border-radius: 0.25rem;
border-width: 1px;
padding: 0.125rem 0.375rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 0.75rem;
line-height: 1rem;
border-color: var(--color-border);
background-color: var(--color-menu-bg-hover);
}

View file

@ -74,7 +74,17 @@
<style>
.kbd {
@apply inline-flex items-center gap-1 rounded border px-2 py-1 font-mono text-xs font-semibold;
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 0.25rem;
border-width: 1px;
padding: 0.25rem 0.5rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 0.75rem;
line-height: 1rem;
font-weight: 600;
border-color: var(--color-border);
background-color: var(--color-menu-bg-hover);
color: var(--color-text);

View file

@ -2,6 +2,7 @@
import GlassCard from './GlassCard.svelte';
import StatRow from './StatRow.svelte';
import { Text } from '@manacore/shared-ui';
import { formatDurationWithUnits } from '@manacore/shared-utils';
interface Props {
averageAudioDuration: number;
@ -26,19 +27,6 @@
mostUsedTags,
topLocations
}: Props = $props();
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m`;
} else {
return `${seconds}s`;
}
}
</script>
<GlassCard>
@ -53,7 +41,7 @@
<div class="mb-4 -space-y-px">
<StatRow
title="Ø Aufnahmedauer"
value={formatDuration(averageAudioDuration)}
value={formatDurationWithUnits(averageAudioDuration, 'de')}
subtitle="pro Memo"
icon="time-outline"
/>
@ -65,7 +53,7 @@
/>
<StatRow
title="Längste Aufnahme"
value={formatDuration(longestRecording)}
value={formatDurationWithUnits(longestRecording, 'de')}
icon="timer-outline"
/>
</div>

View file

@ -2,6 +2,7 @@
import GlassCard from './GlassCard.svelte';
import StatRow from './StatRow.svelte';
import { Text } from '@manacore/shared-ui';
import { formatDurationWithUnits } from '@manacore/shared-utils';
interface Props {
memoCount: number;
@ -13,19 +14,6 @@
}
let { memoCount, memoryCount, totalDuration, totalWords, currentStreak, averageWordCount }: Props = $props();
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m`;
} else {
return `${seconds}s`;
}
}
</script>
<GlassCard>
@ -41,7 +29,7 @@
<div class="-space-y-px">
<StatRow
title="Aufnahmedauer"
value={formatDuration(totalDuration)}
value={formatDurationWithUnits(totalDuration, 'de')}
icon="volume-high-outline"
/>
<StatRow title="Wörter" value={totalWords.toLocaleString()} icon="text-outline" />

View file

@ -2,6 +2,7 @@
import GlassCard from './GlassCard.svelte';
import StatRow from './StatRow.svelte';
import { Text } from '@manacore/shared-ui';
import { formatDurationWithUnits } from '@manacore/shared-utils';
interface Props {
todayStats: {
@ -30,19 +31,6 @@
activestWeek,
activestMonth
}: Props = $props();
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m`;
} else {
return `${seconds}s`;
}
}
</script>
<GlassCard>
@ -63,7 +51,7 @@
/>
<StatRow
title="Aufnahmedauer"
value={formatDuration(todayStats.duration)}
value={formatDurationWithUnits(todayStats.duration, 'de')}
icon="volume-high-outline"
/>
<StatRow title="Wörter" value={todayStats.words.toLocaleString()} icon="text-outline" />
@ -86,7 +74,7 @@
/>
<StatRow
title="Aufnahmedauer"
value={formatDuration(last30DaysStats.duration)}
value={formatDurationWithUnits(last30DaysStats.duration, 'de')}
icon="volume-high-outline"
/>
<StatRow

View file

@ -1,19 +1,16 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en'] 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('fr', () => import('./locales/fr.json'));
register('it', () => import('./locales/it.json'));
register('es', () => import('./locales/es.json'));
// List of supported locales
export const supportedLocales = ['de', 'en', 'fr', 'it', 'es'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'en';
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
@ -34,7 +31,13 @@ function getInitialLocale(): SupportedLocale {
return defaultLocale;
}
// Initialize i18n
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale()
});
// Also export initI18n for backwards compatibility
export function initI18n() {
init({
fallbackLocale: defaultLocale,

View file

@ -3,318 +3,24 @@
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"share": "Teilen",
"back": "Zurück",
"next": "Weiter",
"done": "Fertig",
"loading": "Wird geladen...",
"search": "Suchen",
"settings": "Einstellungen",
"yes": "Ja",
"no": "Nein",
"ok": "OK",
"error": "Fehler",
"success": "Erfolg",
"create": "Erstellen",
"confirm": "Bestätigen",
"close": "Schließen",
"or": "ODER"
},
"nav": {
"dashboard": "Dashboard",
"tags": "Tags",
"spaces": "Spaces",
"mana": "Mana",
"blueprints": "Vorlagen",
"statistics": "Statistiken",
"settings": "Einstellungen",
"logout": "Abmelden",
"expand": "Erweitern",
"minimize": "Minimieren",
"shortcuts": "Tastenkombinationen"
},
"auth": {
"welcome": "Willkommen bei Memoro",
"get_started": "Los geht's",
"create_account": "Neues Konto erstellen",
"sign_in": "Anmelden",
"sign_in_with_email": "Mit E-Mail anmelden",
"sign_up_with_email": "Mit E-Mail registrieren",
"email": "E-Mail",
"password": "Passwort",
"confirm_password": "Passwort bestätigen",
"forgot_password": "Passwort vergessen?",
"reset_password": "Passwort zurücksetzen",
"logging_in": "Anmelden...",
"creating_account": "Konto wird erstellt...",
"sending": "Senden...",
"error_email_required": "Bitte gib deine E-Mail-Adresse ein",
"error_password_required": "Bitte gib dein Passwort ein",
"error_confirm_password": "Bitte bestätige dein Passwort",
"error_passwords_not_match": "Passwörter stimmen nicht überein",
"error_password_too_short": "Das Passwort muss mindestens 8 Zeichen lang sein",
"error_password_requirements": "Das Passwort muss mindestens einen Kleinbuchstaben, einen Großbuchstaben, eine Ziffer und ein Sonderzeichen enthalten",
"error_registration_failed": "Registrierung fehlgeschlagen",
"password_requirement": "Passwort muss mindestens 8 Zeichen lang sein und mindestens einen Kleinbuchstaben, einen Großbuchstaben, eine Ziffer und ein Sonderzeichen enthalten.",
"registration_success": "Registrierung erfolgreich! Überprüfe deine E-Mail, um dein Konto zu bestätigen.",
"check_email_confirmation": "Bitte überprüfe deine E-Mail, um dein Konto zu bestätigen.",
"reset_email_sent": "Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen deines Passworts.",
"email_only_title": "Warum nur E-Mail-Authentifizierung?",
"email_only_info": "Wir unterstützen nur E-Mail-Registrierung, um deine Unabhängigkeit und Privatsphäre zu gewährleisten.",
"email_only_learn_more": "Mehr erfahren",
"email_only_intro": "Wir glauben daran, dir die volle Kontrolle über dein Konto und deine Daten zu geben. Mit E-Mail-basierter Authentifizierung stellen wir sicher:",
"email_only_benefit_1_title": "Kein Vendor Lock-in",
"email_only_benefit_1_desc": "Du bist nicht von Drittanbietern wie Google oder Apple abhängig. Dein Konto funktioniert unabhängig.",
"email_only_benefit_2_title": "Verbesserte Privatsphäre",
"email_only_benefit_2_desc": "Wir teilen deine Daten nicht mit Google, Apple oder anderen Drittanbietern zur Authentifizierung.",
"email_only_benefit_3_title": "Konto-Portabilität",
"email_only_benefit_3_desc": "Deine E-Mail-Adresse ist portabel und funktioniert auf allen Geräten und Plattformen.",
"email_only_benefit_4_title": "Direkte Kommunikation",
"email_only_benefit_4_desc": "Wir können dich direkt über wichtige Updates erreichen, ohne auf Drittanbieter-Benachrichtigungssysteme angewiesen zu sein.",
"email_only_modal_footer": "Wir sind verpflichtet, Tools zu entwickeln, die deine Freiheit und Privatsphäre respektieren. E-Mail-Authentifizierung ist Teil dieser Verpflichtung.",
"got_it": "Verstanden",
"already_have_account": "Hast du bereits ein Konto?",
"dont_have_account": "Noch kein Konto?",
"terms_agreement": "Mit der Nutzung von Memoro stimmst du unseren <a href=\"https://manacore.ai/privacy\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"text-decoration: underline;\">AGB</a> und der <a href=\"https://manacore.ai/privacy\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"text-decoration: underline;\">Datenschutzerklärung</a> zu.",
"mana_login": "Mana Login",
"mana_login_description": "Ein Login für alle Mana Apps",
"mana_login_benefit_0": "Mana Subscriptions über alle Apps hinweg nutzen - nur einmal zahlen und alles nutzen",
"back": "Zurück",
"reset_password_description": "Gib deine E-Mail-Adresse ein und wir senden dir einen Link zum Zurücksetzen deines Passworts.",
"reset_password_error": "Passwort zurücksetzen fehlgeschlagen",
"reset_password_success": "E-Mail wurde versendet!",
"reset_password_rate_limit": "Zu viele Versuche. Bitte warte einige Minuten.",
"reset_email_sent_description": "Wir haben eine E-Mail mit Anweisungen zum Zurücksetzen deines Passworts an {email} gesendet. Bitte überprüfe deinen Posteingang und Spam-Ordner.",
"back_to_login": "Zurück zum Login",
"resend_email": "E-Mail erneut senden",
"reset_email_sent_title": "E-Mail wurde versendet!",
"terms_agreement_conjunction": "und der",
"terms_agreement_suffix": "zu.",
"oauth_error_access_denied": "Zugriff verweigert. Die Anmeldung wurde abgebrochen.",
"oauth_error_server_error": "Server-Fehler bei der Authentifizierung. Bitte versuche es erneut.",
"oauth_error_temporarily_unavailable": "Der Authentifizierungsdienst ist vorübergehend nicht verfügbar. Bitte versuche es später erneut.",
"oauth_error_invalid_request": "Ungültige Anfrage. Bitte versuche es erneut.",
"oauth_error_unauthorized_client": "Nicht autorisierter Client. Bitte kontaktiere den Support.",
"oauth_error_unsupported_response_type": "Nicht unterstützter Antworttyp. Bitte kontaktiere den Support.",
"oauth_error_invalid_scope": "Ungültiger Berechtigungsbereich. Bitte kontaktiere den Support.",
"oauth_error_unknown": "Ein unbekannter Fehler ist aufgetreten. Bitte versuche es erneut.",
"password_requirements_title": "Passwort-Anforderungen:",
"password_requirement_length": "Mindestens 8 Zeichen",
"password_requirement_lowercase": "Einen Kleinbuchstaben",
"password_requirement_uppercase": "Einen Großbuchstaben",
"password_requirement_digit": "Eine Ziffer",
"password_requirement_special": "Ein Sonderzeichen"
},
"dashboard": {
"title": "Dashboard",
"recent_memos": "Neueste Memos",
"no_memos": "Keine Memos gefunden",
"create_memo": "Memo erstellen",
"search_placeholder": "Memos durchsuchen..."
},
"memo": {
"title": "Memo",
"unnamed": "Unbenanntes Memo",
"word_count": "{{count}} Wort",
"word_count_plural": "{{count}} Wörter",
"delete_confirmation": "Möchtest du dieses Memo wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_permanently": "Endgültig löschen",
"deleting": "Wird gelöscht...",
"pin": "Anheften",
"unpin": "Loslösen",
"share": "Teilen",
"edit": "Bearbeiten",
"translate": "Übersetzen",
"create_memory": "Memory erstellen",
"ask_question": "Frage stellen",
"copy_transcript": "Transkript kopieren",
"replace_word": "Wort ersetzen",
"reprocess": "Erneut verarbeiten",
"label_speakers": "Sprecher benennen",
"add_photos": "Fotos hinzufügen",
"manage_spaces": "Spaces verwalten",
"tags": "Tags",
"add_tag": "Tag hinzufügen",
"options": "Optionen",
"search": "Suchen",
"copy": "Kopieren",
"speakers": "Sprecher",
"find_replace": "Suchen & Ersetzen",
"shortcuts": "Tastenkürzel",
"no_memo_selected": "Kein Memo ausgewählt",
"select_memo_hint": "Wähle ein Memo aus der Liste oder erstelle eine neue Aufnahme",
"processing_transcript": "Transkription wird verarbeitet...",
"ask_question_placeholder": "Stelle eine Frage zu diesem Memo...",
"open_in_new_tab": "In neuem Tab öffnen",
"edit_title": "Titel bearbeiten",
"export_text": "Als Text exportieren",
"enter_new_title": "Neuen Titel eingeben:",
"no_title": "Ohne Titel",
"no_transcript": "Kein Transkript verfügbar",
"link_copied": "Link in Zwischenablage kopiert!",
"transcript_copied": "Transkript in Zwischenablage kopiert!",
"no_search_results": "Keine Suchergebnisse",
"no_memos_with_search": "Es wurden keine Memos gefunden, die \"{query}\" enthalten.",
"clear_search": "Suche löschen",
"no_memos_with_tag": "Keine Memos mit diesem Tag",
"no_memos_with_tag_hint": "Es gibt noch keine Memos mit diesem Tag.",
"show_all_memos": "Alle Memos anzeigen",
"no_memos_yet": "Noch keine Memos",
"no_memos_hint": "Gehe zur Aufnahme-Seite, um dein erstes Memo aufzunehmen",
"load_more": "Mehr Memos laden",
"search_placeholder": "Memos durchsuchen...",
"delete_memo_title": "Memo löschen",
"delete_memo_confirm": "Möchtest du \"{title}\" wirklich löschen?",
"delete_memo_warning": "Diese Aktion kann nicht rückgängig gemacht werden. Das Memo und alle zugehörigen Daten werden dauerhaft gelöscht.",
"error_user_not_authenticated": "Benutzer nicht authentifiziert",
"error_loading_memos": "Memos konnten nicht geladen werden",
"error_deleting_memo": "Fehler beim Löschen des Memos. Bitte versuche es erneut.",
"error_updating_title": "Fehler beim Aktualisieren des Titels. Bitte versuche es erneut.",
"error_copying_link": "Fehler beim Kopieren des Links. Bitte versuche es erneut.",
"error_pin_status": "Fehler beim Ändern des Pin-Status. Bitte versuche es erneut.",
"error_saving": "Fehler beim Speichern. Bitte versuche es erneut.",
"error_copying_transcript": "Fehler beim Kopieren des Transkripts. Bitte versuche es erneut.",
"error_updating_tags": "Fehler beim Aktualisieren der Tags. Bitte versuche es erneut.",
"error_creating_tag": "Fehler beim Erstellen des Tags. Bitte versuche es erneut.",
"error_asking_question": "Fehler bei der Verarbeitung der Frage. Bitte versuche es erneut.",
"retry": "Erneut versuchen",
"export_title": "Titel",
"export_date": "Datum",
"export_duration": "Dauer",
"export_transcript": "Transkript",
"export_no_transcript": "Kein Transkript verfügbar",
"export_ai_analysis": "KI-Analyse"
},
"tags": {
"title": "Tags",
"create_tag": "Tag erstellen",
"search_placeholder": "Tags durchsuchen...",
"no_tags": "Keine Tags gefunden",
"delete_confirmation": "Möchtest du den Tag \"{{name}}\" wirklich löschen?",
"tag_name": "Tag-Name",
"tag_color": "Tag-Farbe"
},
"spaces": {
"title": "Spaces",
"create_space": "Space erstellen",
"no_spaces": "Keine Spaces gefunden",
"members": "Mitglieder",
"invite": "Einladen"
},
"blueprints": {
"title": "Vorlagen",
"loading": "Lade Vorlagen...",
"no_blueprints": "Keine Vorlagen gefunden",
"search_placeholder": "Vorlagen durchsuchen...",
"all_categories": "Alle",
"standard": "Standard",
"manage": "Blueprints verwalten",
"activate": "Blueprint aktivieren",
"load_error": "Blueprints konnten nicht geladen werden.",
"previous_tip": "Vorheriger Tipp",
"next_tip": "Nächster Tipp",
"go_to_tip": "Gehe zu Tipp {index}"
},
"record": {
"title": "Aufnehmen - Memoro",
"instruction": "Halte gedrückt zum Aufnehmen",
"uploading": "Wird hochgeladen...",
"user_not_authenticated": "Benutzer nicht authentifiziert",
"upload_failed": "Upload fehlgeschlagen",
"network_error": "Netzwerkfehler: Bitte überprüfe deine Verbindung und versuche es erneut.",
"upload_error": "Fehler beim Hochladen der Aufnahme: {error}",
"unexpected_error": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut.",
"cancel_title": "Aufnahme löschen",
"cancel_message": "Möchtest du die aktuelle Aufnahme wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"cancel_confirm": "Löschen",
"cancel_abort": "Nicht löschen",
"pause": "Pause",
"resume": "Fortsetzen",
"cancel": "Aufnahme abbrechen"
},
"statistics": {
"title": "Statistiken",
"today": "Heute",
"last_30_days": "Letzte 30 Tage",
"total": "Gesamt",
"memos": "Memos",
"words": "Wörter",
"recording_duration": "Aufnahmedauer",
"loading": "Lade Statistiken..."
},
"subscription": {
"title": "Mana kaufen",
"current_plan": "Aktueller Tarif",
"your_mana": "Dein Mana",
"buy": "Kaufen",
"popular": "Beliebt",
"legacy_plan": "Alter Tarif",
"monthly": "Monatlich",
"yearly": "Jährlich"
},
"settings": {
"title": "Einstellungen",
"appearance": "Darstellung",
"theme": "Theme",
"system": "System",
"light": "Hell",
"dark": "Dunkel",
"language": "Sprache",
"user_interface": "Benutzeroberfläche",
"show_language_button": "Sprachen-Button anzeigen",
"show_recording_instruction": "Aufnahme-Anleitung anzeigen",
"show_blueprints": "Blueprints anzeigen",
"show_mana_badge": "Mana-Anzeige im Header",
"data_privacy": "Daten & Privatsphäre",
"save_location": "Standort speichern",
"enable_analytics": "Analytics aktivieren",
"support": "Support",
"contact_support": "Support kontaktieren",
"rate_app": "App bewerten",
"account": "Konto",
"email_label": "E-Mail-Adresse",
"sign_out": "Abmelden",
"delete_account": "Konto löschen",
"app_info": "App-Informationen",
"version": "Version",
"platform": "Plattform",
"build": "Build",
"browser": "Browser",
"copyright": "© 2025 Memoro GmbH",
"made_with_love": "Made with ❤️ in Germany"
"loading": "Lädt..."
},
"app_slider": {
"title": "Weitere Manacore Apps",
"memoro_desc": "KI-gestützte Sprachmemos",
"memoro_long_desc": "Verwandle deine Stimme in organisierte, umsetzbare Erkenntnisse mit KI-gestützter Transkription und Analyse. Perfekt zum Festhalten von Ideen unterwegs.",
"maerchenzauber_desc": "Magische Gute-Nacht-Geschichten",
"maerchenzauber_long_desc": "Erschaffe personalisierte Gute-Nacht-Geschichten für deine Kinder mit KI. Entfache die Fantasie und mache jede Nacht magisch mit einzigartigen Erzählungen.",
"moodlit_desc": "Dein Stimmungsbegleiter",
"moodlit_long_desc": "Verfolge und verstehe deine Emotionen mit KI-gestützten Einblicken. Baue emotionales Bewusstsein auf und verbessere dein mentales Wohlbefinden.",
"manacore_desc": "KI-Produktivitätssuite",
"manacore_long_desc": "Die zentrale Anlaufstelle für alle Manacore-Apps. Verwalte deine Abonnements, synchronisiere Daten und greife auf leistungsstarke KI-Tools von einem Ort aus zu.",
"coming_soon": "Demnächst",
"download": "Download",
"get_started": "Los geht's",
"status_published": "Veröffentlicht",
"title": "Teil des Mana Ökosystems",
"memoro_desc": "KI-gestützte Sprachnotizen",
"memoro_long_desc": "Erfasse deine Gedanken durch Sprache und lasse sie von KI in strukturierte Notizen verwandeln.",
"maerchenzauber_desc": "Magische Kindergeschichten",
"maerchenzauber_long_desc": "Erstelle personalisierte Kindergeschichten mit KI-generierten Illustrationen.",
"moodlit_desc": "Stimmungslicht-Steuerung",
"moodlit_long_desc": "Steuere deine smarten Lichter basierend auf deiner Stimmung und Aktivität.",
"manacore_desc": "Zentrale Verwaltung",
"manacore_long_desc": "Verwalte alle deine Mana-Apps und Einstellungen an einem Ort.",
"status_published": "Verfügbar",
"status_beta": "Beta",
"status_development": "In Entwicklung",
"status_planning": "Geplant"
},
"theme": {
"toggle": "Theme wechseln",
"light_mode": "Heller Modus",
"dark_mode": "Dunkler Modus",
"switch_to_light": "Zum hellen Modus wechseln",
"switch_to_dark": "Zum dunklen Modus wechseln"
},
"errors": {
"unexpected": "Ein unerwarteter Fehler ist aufgetreten.",
"network": "Netzwerkfehler. Bitte überprüfe deine Internetverbindung.",
"not_found": "Nicht gefunden.",
"unauthorized": "Nicht autorisiert.",
"forbidden": "Zugriff verweigert.",
"server_error": "Serverfehler. Bitte versuche es später erneut."
"status_planning": "Geplant",
"coming_soon": "Bald verfügbar",
"download": "App öffnen"
}
}

View file

@ -3,318 +3,24 @@
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"share": "Share",
"back": "Back",
"next": "Next",
"done": "Done",
"loading": "Loading...",
"search": "Search",
"settings": "Settings",
"yes": "Yes",
"no": "No",
"ok": "OK",
"error": "Error",
"success": "Success",
"create": "Create",
"confirm": "Confirm",
"close": "Close",
"or": "OR"
},
"nav": {
"dashboard": "Dashboard",
"tags": "Tags",
"spaces": "Spaces",
"mana": "Mana",
"blueprints": "Blueprints",
"statistics": "Statistics",
"settings": "Settings",
"logout": "Logout",
"expand": "Expand",
"minimize": "Minimize",
"shortcuts": "Shortcuts"
},
"auth": {
"welcome": "Welcome to Memoro",
"get_started": "Get Started",
"create_account": "Create Account",
"sign_in": "Sign In",
"sign_in_with_email": "Sign In with Email",
"sign_up_with_email": "Sign Up with Email",
"email": "Email",
"password": "Password",
"confirm_password": "Confirm Password",
"forgot_password": "Forgot Password?",
"reset_password": "Reset Password",
"logging_in": "Signing in...",
"creating_account": "Creating account...",
"sending": "Sending...",
"error_email_required": "Please enter your email address",
"error_password_required": "Please enter your password",
"error_confirm_password": "Please confirm your password",
"error_passwords_not_match": "Passwords do not match",
"error_password_too_short": "Password must be at least 8 characters long",
"error_password_requirements": "Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character",
"error_registration_failed": "Registration failed",
"password_requirement": "Password must be at least 8 characters long and contain at least one lowercase letter, one uppercase letter, one digit, and one special character.",
"registration_success": "Registration successful! Check your email to verify your account.",
"check_email_confirmation": "Please check your email to confirm your account.",
"reset_email_sent": "Enter your email address and we'll send you a link to reset your password.",
"email_only_title": "Why Email-Only Authentication?",
"email_only_info": "We only support email registration to ensure your independence and privacy.",
"email_only_learn_more": "Learn more",
"email_only_intro": "We believe in giving you full control over your account and data. By using email-based authentication, we ensure:",
"email_only_benefit_1_title": "No Vendor Lock-In",
"email_only_benefit_1_desc": "You're not dependent on third-party services like Google or Apple. Your account works independently.",
"email_only_benefit_2_title": "Enhanced Privacy",
"email_only_benefit_2_desc": "We don't share your data with Google, Apple, or any other third party for authentication.",
"email_only_benefit_3_title": "Account Portability",
"email_only_benefit_3_desc": "Your email address is portable and works across all devices and platforms.",
"email_only_benefit_4_title": "Direct Communication",
"email_only_benefit_4_desc": "We can reach you directly for important updates without relying on third-party notification systems.",
"email_only_modal_footer": "We're committed to building tools that respect your freedom and privacy. Email authentication is part of that commitment.",
"got_it": "Got it",
"already_have_account": "Already have an account?",
"dont_have_account": "Don't have an account?",
"terms_agreement": "By using Memoro you agree to our <a href=\"https://manacore.ai/privacy\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"text-decoration: underline;\">Terms</a> and <a href=\"https://manacore.ai/privacy\" target=\"_blank\" rel=\"noopener noreferrer\" style=\"text-decoration: underline;\">Privacy Policy</a>.",
"mana_login": "Mana Login",
"mana_login_description": "One login for all Mana apps",
"mana_login_benefit_0": "Use Mana subscriptions across all apps - pay once and use everything",
"back": "Back",
"reset_password_description": "Enter your email address and we'll send you a link to reset your password.",
"reset_password_error": "Password reset failed",
"reset_password_success": "Email sent!",
"reset_password_rate_limit": "Too many attempts. Please wait a few minutes.",
"reset_email_sent_description": "We've sent an email with instructions to reset your password to {email}. Please check your inbox and spam folder.",
"back_to_login": "Back to login",
"resend_email": "Resend email",
"reset_email_sent_title": "Email sent!",
"terms_agreement_conjunction": "and the",
"terms_agreement_suffix": ".",
"oauth_error_access_denied": "Access denied. The login was cancelled.",
"oauth_error_server_error": "Server error during authentication. Please try again.",
"oauth_error_temporarily_unavailable": "The authentication service is temporarily unavailable. Please try again later.",
"oauth_error_invalid_request": "Invalid request. Please try again.",
"oauth_error_unauthorized_client": "Unauthorized client. Please contact support.",
"oauth_error_unsupported_response_type": "Unsupported response type. Please contact support.",
"oauth_error_invalid_scope": "Invalid scope. Please contact support.",
"oauth_error_unknown": "An unknown error occurred. Please try again.",
"password_requirements_title": "Password requirements:",
"password_requirement_length": "At least 8 characters",
"password_requirement_lowercase": "One lowercase letter",
"password_requirement_uppercase": "One uppercase letter",
"password_requirement_digit": "One digit",
"password_requirement_special": "One special character"
},
"dashboard": {
"title": "Dashboard",
"recent_memos": "Recent Memos",
"no_memos": "No memos found",
"create_memo": "Create Memo",
"search_placeholder": "Search memos..."
},
"memo": {
"title": "Memo",
"unnamed": "Unnamed Memo",
"word_count": "{{count}} word",
"word_count_plural": "{{count}} words",
"delete_confirmation": "Are you sure you want to delete this memo? This action cannot be undone.",
"delete_permanently": "Delete Permanently",
"deleting": "Deleting...",
"pin": "Pin",
"unpin": "Unpin",
"share": "Share",
"edit": "Edit",
"translate": "Translate",
"create_memory": "Create Memory",
"ask_question": "Ask Question",
"copy_transcript": "Copy Transcript",
"replace_word": "Replace Word",
"reprocess": "Reprocess",
"label_speakers": "Label Speakers",
"add_photos": "Add Photos",
"manage_spaces": "Manage Spaces",
"tags": "Tags",
"add_tag": "Add Tag",
"options": "Options",
"search": "Search",
"copy": "Copy",
"speakers": "Speakers",
"find_replace": "Find & Replace",
"shortcuts": "Shortcuts",
"no_memo_selected": "No memo selected",
"select_memo_hint": "Select a memo from the list or create a new recording",
"processing_transcript": "Transcription in progress...",
"ask_question_placeholder": "Ask a question about this memo...",
"open_in_new_tab": "Open in new tab",
"edit_title": "Edit title",
"export_text": "Export as text",
"enter_new_title": "Enter new title:",
"no_title": "Untitled",
"no_transcript": "No transcript available",
"link_copied": "Link copied to clipboard!",
"transcript_copied": "Transcript copied to clipboard!",
"no_search_results": "No search results",
"no_memos_with_search": "No memos found containing \"{query}\".",
"clear_search": "Clear search",
"no_memos_with_tag": "No memos with this tag",
"no_memos_with_tag_hint": "There are no memos with this tag yet.",
"show_all_memos": "Show all memos",
"no_memos_yet": "No memos yet",
"no_memos_hint": "Go to the recording page to create your first memo",
"load_more": "Load more memos",
"search_placeholder": "Search memos...",
"delete_memo_title": "Delete memo",
"delete_memo_confirm": "Are you sure you want to delete \"{title}\"?",
"delete_memo_warning": "This action cannot be undone. The memo and all associated data will be permanently deleted.",
"error_user_not_authenticated": "User not authenticated",
"error_loading_memos": "Could not load memos",
"error_deleting_memo": "Error deleting memo. Please try again.",
"error_updating_title": "Error updating title. Please try again.",
"error_copying_link": "Error copying link. Please try again.",
"error_pin_status": "Error updating pin status. Please try again.",
"error_saving": "Error saving changes. Please try again.",
"error_copying_transcript": "Error copying transcript. Please try again.",
"error_updating_tags": "Error updating tags. Please try again.",
"error_creating_tag": "Error creating tag. Please try again.",
"error_asking_question": "Error processing question. Please try again.",
"retry": "Try again",
"export_title": "Title",
"export_date": "Date",
"export_duration": "Duration",
"export_transcript": "Transcript",
"export_no_transcript": "No transcript available",
"export_ai_analysis": "AI Analysis"
},
"tags": {
"title": "Tags",
"create_tag": "Create Tag",
"search_placeholder": "Search tags...",
"no_tags": "No tags found",
"delete_confirmation": "Are you sure you want to delete the tag \"{{name}}\"?",
"tag_name": "Tag Name",
"tag_color": "Tag Color"
},
"spaces": {
"title": "Spaces",
"create_space": "Create Space",
"no_spaces": "No spaces found",
"members": "Members",
"invite": "Invite"
},
"blueprints": {
"title": "Blueprints",
"loading": "Loading blueprints...",
"no_blueprints": "No blueprints found",
"search_placeholder": "Search blueprints...",
"all_categories": "All",
"standard": "Standard",
"manage": "Manage blueprints",
"activate": "Activate blueprint",
"load_error": "Could not load blueprints.",
"previous_tip": "Previous tip",
"next_tip": "Next tip",
"go_to_tip": "Go to tip {index}"
},
"record": {
"title": "Record - Memoro",
"instruction": "Hold to record",
"uploading": "Uploading...",
"user_not_authenticated": "User not authenticated",
"upload_failed": "Upload failed",
"network_error": "Network error: Please check your connection and try again.",
"upload_error": "Error uploading recording: {error}",
"unexpected_error": "An unexpected error occurred. Please try again.",
"cancel_title": "Delete Recording",
"cancel_message": "Do you really want to delete the current recording? This action cannot be undone.",
"cancel_confirm": "Delete",
"cancel_abort": "Don't delete",
"pause": "Pause",
"resume": "Resume",
"cancel": "Cancel recording"
},
"statistics": {
"title": "Statistics",
"today": "Today",
"last_30_days": "Last 30 days",
"total": "Total",
"memos": "Memos",
"words": "Words",
"recording_duration": "Recording Duration",
"loading": "Loading statistics..."
},
"subscription": {
"title": "Buy Mana",
"current_plan": "Current Plan",
"your_mana": "Your Mana",
"buy": "Buy",
"popular": "Popular",
"legacy_plan": "Legacy Plan",
"monthly": "Monthly",
"yearly": "Yearly"
},
"settings": {
"title": "Settings",
"appearance": "Appearance",
"theme": "Theme",
"system": "System",
"light": "Light",
"dark": "Dark",
"language": "Language",
"user_interface": "User Interface",
"show_language_button": "Show Language Button",
"show_recording_instruction": "Show Recording Instruction",
"show_blueprints": "Show Blueprints",
"show_mana_badge": "Show Mana Badge in Header",
"data_privacy": "Data & Privacy",
"save_location": "Save Location",
"enable_analytics": "Enable Analytics",
"support": "Support",
"contact_support": "Contact Support",
"rate_app": "Rate App",
"account": "Account",
"email_label": "Email Address",
"sign_out": "Sign Out",
"delete_account": "Delete Account",
"app_info": "App Information",
"version": "Version",
"platform": "Platform",
"build": "Build",
"browser": "Browser",
"copyright": "© 2025 Memoro GmbH",
"made_with_love": "Made with ❤️ in Germany"
"loading": "Loading..."
},
"app_slider": {
"title": "More Manacore Apps",
"memoro_desc": "AI-powered voice memos",
"memoro_long_desc": "Transform your voice into organized, actionable insights with AI-powered transcription and analysis. Perfect for capturing ideas on the go.",
"maerchenzauber_desc": "Magical bedtime stories",
"maerchenzauber_long_desc": "Create personalized bedtime stories for your children with AI. Spark imagination and make every night magical with unique tales.",
"moodlit_desc": "Your mood companion",
"moodlit_long_desc": "Track and understand your emotions with AI-powered insights. Build emotional awareness and improve your mental wellbeing.",
"manacore_desc": "AI productivity suite",
"manacore_long_desc": "The central hub for all Manacore apps. Manage your subscriptions, sync data, and access powerful AI tools from one place.",
"coming_soon": "Coming Soon",
"download": "Download",
"get_started": "Get Started",
"status_published": "Published",
"title": "Part of the Mana Ecosystem",
"memoro_desc": "AI-powered voice notes",
"memoro_long_desc": "Capture your thoughts through voice and let AI transform them into structured notes.",
"maerchenzauber_desc": "Magical children's stories",
"maerchenzauber_long_desc": "Create personalized children's stories with AI-generated illustrations.",
"moodlit_desc": "Mood light control",
"moodlit_long_desc": "Control your smart lights based on your mood and activity.",
"manacore_desc": "Central management",
"manacore_long_desc": "Manage all your Mana apps and settings in one place.",
"status_published": "Available",
"status_beta": "Beta",
"status_development": "In Development",
"status_planning": "Planned"
},
"theme": {
"toggle": "Toggle Theme",
"light_mode": "Light Mode",
"dark_mode": "Dark Mode",
"switch_to_light": "Switch to light mode",
"switch_to_dark": "Switch to dark mode"
},
"errors": {
"unexpected": "An unexpected error occurred.",
"network": "Network error. Please check your internet connection.",
"not_found": "Not found.",
"unauthorized": "Unauthorized.",
"forbidden": "Access denied.",
"server_error": "Server error. Please try again later."
"status_planning": "Planned",
"coming_soon": "Coming Soon",
"download": "Open App"
}
}

View file

@ -8,7 +8,8 @@
import SectionHeader from '$lib/components/SectionHeader.svelte';
import { onMount } from 'svelte';
let currentTheme = $derived($theme);
// theme is a Svelte 5 runes-based store, access properties directly
let currentMode = $derived(theme.mode);
let currentUser = $derived($user);
let currentSettings = $derived($settings);
@ -24,11 +25,11 @@
activeMode = mode;
}
let activeMode = $state<ThemeMode>(currentTheme.mode);
let activeMode = $state<ThemeMode>(currentMode);
// Update activeMode when theme changes
$effect(() => {
activeMode = currentTheme.mode;
activeMode = currentMode;
});
// Collapsible sections state

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { MemoroLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import { auth } from '$lib/stores/auth';
async function handleForgotPassword(email: string) {
return auth.forgotPassword(email);
}
</script>
<ForgotPasswordPage
appName="Memoro"
logo={MemoroLogo}
primaryColor="#f8d62b"
onForgotPassword={handleForgotPassword}
goto={goto}
loginPath="/login"
lightBackground="#dddddd"
darkBackground="#101010"
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</ForgotPasswordPage>

View file

@ -3,6 +3,7 @@
import { LoginPage, setGoogleClientId, setAppleConfig } from '@manacore/shared-auth-ui';
import { MemoroLogo } from '@manacore/shared-branding';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { auth } from '$lib/stores/auth';
import { env } from '$lib/config/env';
import { onMount } from 'svelte';
@ -25,9 +26,6 @@
return auth.signInWithGoogle(idToken);
}
async function handleForgotPassword(email: string) {
return auth.forgotPassword(email);
}
</script>
<LoginPage
@ -36,15 +34,18 @@
primaryColor="#f8d62b"
onSignIn={handleSignIn}
onSignInWithGoogle={handleSignInWithGoogle}
onForgotPassword={handleForgotPassword}
goto={goto}
enableGoogle={!!env.oauth.googleClientId}
enableApple={!!env.oauth.appleClientId}
successRedirect="/dashboard"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#dddddd"
darkBackground="#101010"
>
{#snippet headerControls()}
<LanguageSelector />
{/snippet}
{#snippet appSlider()}
<AppSlider />
{/snippet}

View file

@ -2,10 +2,6 @@
import '../app.css';
import { theme } from '$lib/stores/theme';
import { onMount } from 'svelte';
import { initI18n } from '$lib/i18n';
// Initialize i18n
initI18n();
let { children } = $props();

View file

@ -0,0 +1,7 @@
import { waitLocale } from '$lib/i18n';
import '$lib/i18n'; // This triggers the initI18n() call at module scope
export const load = async () => {
await waitLocale();
return {};
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

View file

@ -13,9 +13,10 @@ const config = {
edge: false, // Use Node-based Netlify Functions (better for OAuth)
split: false // Single function for all routes (simpler deployment)
}),
// Disable built-in CSRF check - we'll handle it in hooks.server.ts
// Disable built-in CSRF check - we handle it in hooks.server.ts
// Using empty array allows all origins (equivalent to checkOrigin: false)
csrf: {
checkOrigin: false
trustedOrigins: []
}
}
};

View file

@ -5,8 +5,8 @@ export default {
presets: [preset],
content: [
'./src/**/*.{html,js,svelte,ts}',
'../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
'../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
'../../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
'../../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
],
plugins: [require('@tailwindcss/typography')]
};

View file

@ -28,5 +28,13 @@
"engines": {
"node": ">=18.0.0"
},
"packageManager": "pnpm@9.15.0"
"packageManager": "pnpm@9.15.0",
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {
"@mana-core/nestjs-integration>@nestjs/common": "^11.0.0",
"@mana-core/nestjs-integration>@nestjs/core": "^11.0.0"
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more