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>
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
179
maerchenzauber/apps/web/src/lib/api/client.ts
Normal 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
|
||||
);
|
||||
}
|
||||
452
maerchenzauber/apps/web/src/lib/api/dataService.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
6
maerchenzauber/apps/web/src/lib/api/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* API Index - Re-export all API utilities
|
||||
*/
|
||||
|
||||
export { fetchWithAuth, api, isCreditError } from './client';
|
||||
export { dataService } from './dataService';
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
171
maerchenzauber/apps/web/src/lib/components/layout/Sidebar.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
57
maerchenzauber/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
12
maerchenzauber/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
maerchenzauber/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"back": "Back",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"app_slider": {
|
||||
"title": "Part of the Mana Ecosystem"
|
||||
}
|
||||
}
|
||||
51
maerchenzauber/apps/web/src/lib/types/api.ts
Normal 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;
|
||||
}
|
||||
70
maerchenzauber/apps/web/src/lib/types/character.ts
Normal 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;
|
||||
}
|
||||
15
maerchenzauber/apps/web/src/lib/types/index.ts
Normal 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';
|
||||
75
maerchenzauber/apps/web/src/lib/types/story.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
145
maerchenzauber/apps/web/src/routes/(protected)/+layout.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
7
maerchenzauber/apps/web/src/routes/+layout.ts
Normal 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 {};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 431 KiB |
12
maerchenzauber/apps/web/tailwind.config.js
Normal 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')]
|
||||
};
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
21
manacore/apps/web/src/lib/components/LanguageSelector.svelte
Normal 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"
|
||||
/>
|
||||
57
manacore/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
12
manacore/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
manacore/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"back": "Back",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"app_slider": {
|
||||
"title": "Part of the Mana Ecosystem"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
};
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 263 KiB |
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
21
manadeck/apps/web/src/lib/components/LanguageSelector.svelte
Normal 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"
|
||||
/>
|
||||
57
manadeck/apps/web/src/lib/i18n/index.ts
Normal 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 };
|
||||
12
manadeck/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
12
manadeck/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"back": "Back",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"app_slider": {
|
||||
"title": "Part of the Mana Ecosystem"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
7
manadeck/apps/web/src/routes/+layout.ts
Normal 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 {};
|
||||
};
|
||||
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
|
@ -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')]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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[]>([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
7
memoro/apps/web/src/routes/+layout.ts
Normal 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 {};
|
||||
};
|
||||
|
After Width: | Height: | Size: 263 KiB |
|
|
@ -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: []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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')]
|
||||
};
|
||||
|
|
|
|||
10
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||