diff --git a/CHANGELOG_2025-11-24.md b/docs/CHANGELOG_2025-11-24.md similarity index 100% rename from CHANGELOG_2025-11-24.md rename to docs/CHANGELOG_2025-11-24.md diff --git a/SHARED_PACKAGES_ROADMAP.md b/docs/SHARED_PACKAGES_ROADMAP.md similarity index 70% rename from SHARED_PACKAGES_ROADMAP.md rename to docs/SHARED_PACKAGES_ROADMAP.md index 205ee4c0d..0a6d14ca0 100644 --- a/SHARED_PACKAGES_ROADMAP.md +++ b/docs/SHARED_PACKAGES_ROADMAP.md @@ -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 + + + + +``` + +### `@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 diff --git a/maerchenzauber/apps/mobile/package.json b/maerchenzauber/apps/mobile/package.json index f258ed9f1..d586471dd 100644 --- a/maerchenzauber/apps/mobile/package.json +++ b/maerchenzauber/apps/mobile/package.json @@ -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", diff --git a/maerchenzauber/apps/web/package.json b/maerchenzauber/apps/web/package.json index 2c6fec45a..42091bbbf 100644 --- a/maerchenzauber/apps/web/package.json +++ b/maerchenzauber/apps/web/package.json @@ -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" } } diff --git a/maerchenzauber/apps/web/src/app.css b/maerchenzauber/apps/web/src/app.css index b1adcdc48..f6ad4cf40 100644 --- a/maerchenzauber/apps/web/src/app.css +++ b/maerchenzauber/apps/web/src/app.css @@ -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"; diff --git a/maerchenzauber/apps/web/src/lib/api/client.ts b/maerchenzauber/apps/web/src/lib/api/client.ts new file mode 100644 index 000000000..435990dc1 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/api/client.ts @@ -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): 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 { + 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 { + // 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)['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 = {}; + + 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(endpoint: string): Promise { + const response = await fetchWithAuth(endpoint, { method: 'GET' }); + return response.json(); + }, + + async post(endpoint: string, data?: unknown): Promise { + const response = await fetchWithAuth(endpoint, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + return response.json(); + }, + + async put(endpoint: string, data: unknown): Promise { + const response = await fetchWithAuth(endpoint, { + method: 'PUT', + body: JSON.stringify(data), + }); + return response.json(); + }, + + async delete(endpoint: string): Promise { + const response = await fetchWithAuth(endpoint, { method: 'DELETE' }); + return response.json(); + }, + + async upload(endpoint: string, formData: FormData): Promise { + 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 + ); +} diff --git a/maerchenzauber/apps/web/src/lib/api/dataService.ts b/maerchenzauber/apps/web/src/lib/api/dataService.ts new file mode 100644 index 000000000..f5688b5d2 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/api/dataService.ts @@ -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): Story { + // Extract character image from characters_data + let characterImageUrl: string | undefined; + const charactersData = raw.characters_data as Array> | 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> | 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): 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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; + }, +}; diff --git a/maerchenzauber/apps/web/src/lib/api/index.ts b/maerchenzauber/apps/web/src/lib/api/index.ts new file mode 100644 index 000000000..0f41146af --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/api/index.ts @@ -0,0 +1,6 @@ +/** + * API Index - Re-export all API utilities + */ + +export { fetchWithAuth, api, isCreditError } from './client'; +export { dataService } from './dataService'; diff --git a/maerchenzauber/apps/web/src/lib/components/LanguageSelector.svelte b/maerchenzauber/apps/web/src/lib/components/LanguageSelector.svelte new file mode 100644 index 000000000..55fa05461 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/LanguageSelector.svelte @@ -0,0 +1,21 @@ + + + diff --git a/maerchenzauber/apps/web/src/lib/components/character/CharacterAvatar.svelte b/maerchenzauber/apps/web/src/lib/components/character/CharacterAvatar.svelte new file mode 100644 index 000000000..d967c5fb4 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/character/CharacterAvatar.svelte @@ -0,0 +1,71 @@ + + + + +
+
+ {character.name} +
+ + + {#if isSystem} +
+ + + +
+ {/if} +
+ + + {#if showName} + + {character.name} + + {/if} +
diff --git a/maerchenzauber/apps/web/src/lib/components/common/EmptyState.svelte b/maerchenzauber/apps/web/src/lib/components/common/EmptyState.svelte new file mode 100644 index 000000000..48e710366 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/common/EmptyState.svelte @@ -0,0 +1,58 @@ + + +
+ +
+ + + +
+ + +

+ {title} +

+ + + {#if description} +

+ {description} +

+ {/if} + + + {#if actionLabel && actionHref} + + + + + {actionLabel} + + {/if} +
diff --git a/maerchenzauber/apps/web/src/lib/components/credits/ManaCounter.svelte b/maerchenzauber/apps/web/src/lib/components/credits/ManaCounter.svelte new file mode 100644 index 000000000..31b564a33 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/credits/ManaCounter.svelte @@ -0,0 +1,57 @@ + + + + + + + + + + {#if loading} + + {:else if error} + ! + {:else if balance !== null} + {formatBalance(balance)} + {:else} + - + {/if} + diff --git a/maerchenzauber/apps/web/src/lib/components/layout/Header.svelte b/maerchenzauber/apps/web/src/lib/components/layout/Header.svelte new file mode 100644 index 000000000..ab9740d84 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/layout/Header.svelte @@ -0,0 +1,57 @@ + + +
+ +
+ + + +

+ Willkommen zurück! +

+
+ + +
+ + + + +
+ +
+
+
diff --git a/maerchenzauber/apps/web/src/lib/components/layout/Sidebar.svelte b/maerchenzauber/apps/web/src/lib/components/layout/Sidebar.svelte new file mode 100644 index 000000000..313c146e6 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/layout/Sidebar.svelte @@ -0,0 +1,171 @@ + + + diff --git a/maerchenzauber/apps/web/src/lib/components/story/StoryCard.svelte b/maerchenzauber/apps/web/src/lib/components/story/StoryCard.svelte new file mode 100644 index 000000000..4390c334b --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/story/StoryCard.svelte @@ -0,0 +1,73 @@ + + + + +
+ {story.title + + + {#if story.is_favorite} +
+ + + +
+ {/if} + + +
+
+ + +
+

+ {story.title || 'Unbekannte Geschichte'} +

+
+ {#if story.characterName} + + + + + {story.characterName} + + + {/if} + {formatDate(story.createdAt)} + {#if story.pages?.length} + + {story.pages.length} Seiten + {/if} +
+
+
diff --git a/maerchenzauber/apps/web/src/lib/components/story/StoryEndScreen.svelte b/maerchenzauber/apps/web/src/lib/components/story/StoryEndScreen.svelte new file mode 100644 index 000000000..e027e222f --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/story/StoryEndScreen.svelte @@ -0,0 +1,65 @@ + + +
+ +
+ + + +
+ + +

+ Ende +

+ +

+ Das war's! Was möchtest du als nächstes tun? +

+ + +
+ + + + + + + + {#if onArchive} + + {/if} +
+
diff --git a/maerchenzauber/apps/web/src/lib/components/story/StoryPageView.svelte b/maerchenzauber/apps/web/src/lib/components/story/StoryPageView.svelte new file mode 100644 index 000000000..0088f5225 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/story/StoryPageView.svelte @@ -0,0 +1,36 @@ + + +
+ +
+ + Seite {pageNumber} + + +
+

+ {page.story} +

+
+ + +
+ {pageNumber} / {totalPages} +
+
+
diff --git a/maerchenzauber/apps/web/src/lib/components/story/StoryStartScreen.svelte b/maerchenzauber/apps/web/src/lib/components/story/StoryStartScreen.svelte new file mode 100644 index 000000000..c72d41c37 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/story/StoryStartScreen.svelte @@ -0,0 +1,51 @@ + + +
+ + {#if characterImage} +
+ {characterName +
+ {/if} + + +

+ {title || 'Eine magische Geschichte'} +

+ + + {#if characterName} +

+ mit {characterName} +

+ {/if} + + + + + +

+ Klicke oder drücke die Leertaste zum Fortfahren +

+
diff --git a/maerchenzauber/apps/web/src/lib/components/story/StoryViewer.svelte b/maerchenzauber/apps/web/src/lib/components/story/StoryViewer.svelte new file mode 100644 index 000000000..c910f7f3a --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/components/story/StoryViewer.svelte @@ -0,0 +1,226 @@ + + + + + diff --git a/maerchenzauber/apps/web/src/lib/i18n/index.ts b/maerchenzauber/apps/web/src/lib/i18n/index.ts new file mode 100644 index 000000000..3036ebce3 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/i18n/index.ts @@ -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 }; diff --git a/maerchenzauber/apps/web/src/lib/i18n/locales/de.json b/maerchenzauber/apps/web/src/lib/i18n/locales/de.json new file mode 100644 index 000000000..e242d9c15 --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/i18n/locales/de.json @@ -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" + } +} diff --git a/maerchenzauber/apps/web/src/lib/i18n/locales/en.json b/maerchenzauber/apps/web/src/lib/i18n/locales/en.json new file mode 100644 index 000000000..c68f659ae --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/i18n/locales/en.json @@ -0,0 +1,12 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "back": "Back", + "loading": "Loading..." + }, + "app_slider": { + "title": "Part of the Mana Ecosystem" + } +} diff --git a/maerchenzauber/apps/web/src/lib/types/api.ts b/maerchenzauber/apps/web/src/lib/types/api.ts new file mode 100644 index 000000000..6ee32e5ea --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/types/api.ts @@ -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; +} diff --git a/maerchenzauber/apps/web/src/lib/types/character.ts b/maerchenzauber/apps/web/src/lib/types/character.ts new file mode 100644 index 000000000..ff78481bd --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/types/character.ts @@ -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; +} diff --git a/maerchenzauber/apps/web/src/lib/types/index.ts b/maerchenzauber/apps/web/src/lib/types/index.ts new file mode 100644 index 000000000..ad8c4741b --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/types/index.ts @@ -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'; diff --git a/maerchenzauber/apps/web/src/lib/types/story.ts b/maerchenzauber/apps/web/src/lib/types/story.ts new file mode 100644 index 000000000..19fba54cc --- /dev/null +++ b/maerchenzauber/apps/web/src/lib/types/story.ts @@ -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; + 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; +} diff --git a/maerchenzauber/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/maerchenzauber/apps/web/src/routes/(auth)/forgot-password/+page.svelte new file mode 100644 index 000000000..1537a28b9 --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(auth)/forgot-password/+page.svelte @@ -0,0 +1,26 @@ + + + + {#snippet appSlider()} + + {/snippet} + diff --git a/maerchenzauber/apps/web/src/routes/(auth)/login/+page.svelte b/maerchenzauber/apps/web/src/routes/(auth)/login/+page.svelte index 5213906c2..71a18771d 100644 --- a/maerchenzauber/apps/web/src/routes/(auth)/login/+page.svelte +++ b/maerchenzauber/apps/web/src/routes/(auth)/login/+page.svelte @@ -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); - } + {#snippet headerControls()} + + {/snippet} {#snippet appSlider()} {/snippet} diff --git a/maerchenzauber/apps/web/src/routes/(protected)/+layout.svelte b/maerchenzauber/apps/web/src/routes/(protected)/+layout.svelte new file mode 100644 index 000000000..1be762acc --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/+layout.svelte @@ -0,0 +1,145 @@ + + + + +{#if loading} + +
+
+
+

Laden...

+
+
+{:else} + +
+ + + + + {#if isMobileMenuOpen} +
(isMobileMenuOpen = false)} + onkeydown={(e) => e.key === 'Escape' && (isMobileMenuOpen = false)} + role="button" + tabindex="0" + >
+
+ (isMobileMenuOpen = false)} + onLogout={handleLogout} + isMobile={true} + /> +
+ {/if} + + +
+ +
+ + +
+ {@render children()} +
+
+
+{/if} diff --git a/maerchenzauber/apps/web/src/routes/(protected)/archive/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/archive/+page.svelte new file mode 100644 index 000000000..b04ade9ea --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/archive/+page.svelte @@ -0,0 +1,268 @@ + + + + Archiv | Märchenzauber + + +
+ +
+ + + + + +
+

Archiv

+

+ Archivierte Geschichten und Charaktere +

+
+
+ + +
+ + +
+ + + {#if loading} +
+ {#each Array(6) as _} +
+ {/each} +
+ {:else if error} +
+

{error}

+ +
+ {:else if activeTab === 'stories'} + + {#if archivedStories.length === 0} +
+ + + +

Keine archivierten Geschichten

+

+ Archivierte Geschichten erscheinen hier +

+
+ {:else} +
+ {#each archivedStories as story (story.id)} +
+ +
+ {story.title} +
+ + +
+

+ {story.title || 'Ohne Titel'} +

+

+ Archiviert am {new Date(story.createdAt).toLocaleDateString('de-DE')} +

+ + +
+ + +
+
+
+ {/each} +
+ {/if} + {:else} + + {#if archivedCharacters.length === 0} +
+ + + +

Keine archivierten Charaktere

+

+ Archivierte Charaktere erscheinen hier +

+
+ {:else} +
+ {#each archivedCharacters as character (character.id)} +
+ +
+ {character.name} +
+ + +
+

+ {character.name} +

+

+ Archiviert +

+ + +
+ + +
+
+
+ {/each} +
+ {/if} + {/if} +
diff --git a/maerchenzauber/apps/web/src/routes/(protected)/characters/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/characters/+page.svelte new file mode 100644 index 000000000..7df174ab9 --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/characters/+page.svelte @@ -0,0 +1,131 @@ + + + + Charaktere | Märchenzauber + + +
+ +
+
+

Deine Charaktere

+

+ {filteredCharacters.length} {filteredCharacters.length === 1 ? 'Charakter' : 'Charaktere'} +

+
+ + + + + Neuer Charakter + +
+ + +
+ + + + +
+ + + {#if loading} +
+ {#each Array(12) as _} +
+
+
+
+ {/each} +
+ {:else if error} +
+ {error} +
+ {:else if filteredCharacters.length === 0} + {#if searchQuery} + + {:else} + + {/if} + {:else} +
+ + +
+ + + +
+ Neu +
+ + {#each filteredCharacters as character (character.id)} + + {/each} +
+ {/if} +
diff --git a/maerchenzauber/apps/web/src/routes/(protected)/characters/[id]/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/characters/[id]/+page.svelte new file mode 100644 index 000000000..584d86a80 --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/characters/[id]/+page.svelte @@ -0,0 +1,328 @@ + + + + {character?.name || 'Charakter'} | Märchenzauber + + + +{#if creatingStory} +
+
+
+
+
+ + + +
+
+
+

Geschichte wird erschaffen...

+

Die Magie arbeitet!

+
+
+
+{/if} + +{#if loading} + +
+
+
+
+
+
+
+{:else if error && !character} + +
+
+ + + +

Fehler

+

{error}

+ +
+
+{:else if character} +
+ + + + + + Zurück zu Charakteren + + + + {#if error} +
+ {error} +
+ {/if} + + +
+ +
+
+ {character.name} +
+ {#if isSystem} +
+ + + +
+ {/if} +
+ + + {#if isEditing} +
+ +
+ + +
+
+ {:else} +

+ {character.name} +

+ {/if} + + + {#if character.share_code && !isSystem} +
+ + + + + Geteilt + + +
+ {/if} + + + {#if !isSystem && !isEditing} +
+ + +
+ {/if} +
+ + +
+

+ Neue Geschichte mit {character.name} +

+

+ Beschreibe eine Geschichte und {character.name} wird der Hauptcharakter sein. +

+ + +
+ + + {#if character.originalDescription} +
+

+ Original-Beschreibung +

+

+ {character.originalDescription} +

+
+ {/if} +
+{/if} diff --git a/maerchenzauber/apps/web/src/routes/(protected)/characters/create/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/characters/create/+page.svelte new file mode 100644 index 000000000..012349a57 --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/characters/create/+page.svelte @@ -0,0 +1,453 @@ + + + + Neuer Charakter | Märchenzauber + + + +{#if generating} +
+
+
+
+
+ + + +
+
+
+

+ {mode === 'photo' ? 'Kuscheltier wird analysiert...' : 'Charakter wird generiert...'} +

+

Die Magie arbeitet! Dies kann bis zu einer Minute dauern.

+
+
+
+{/if} + +
+ +
+

+ Neuer Charakter +

+

+ Erstelle einen einzigartigen Charakter für deine Geschichten +

+
+ + + + + + + Zurück zu Charakteren + + + + {#if error} +
+
+ + + + {error} +
+
+ {/if} + + {#if generatedImages.length === 0} + +
+

+ 1. Name deines Charakters +

+ +
+ + +
+

+ 2. Wie möchtest du deinen Charakter erstellen? +

+ + +
+ + +
+ + + {#if mode === 'photo'} +
+

+ Lade ein Foto von einem Kuscheltier hoch, um daraus einen einzigartigen Charakter zu erstellen. +

+ + {#if uploadedPreview} + +
+
+ Hochgeladenes Foto +
+ +
+ {:else} + + + {/if} + + +
+ {/if} + + + {#if mode === 'description'} +
+

+ Beschreibe deinen Charakter mit eigenen Worten. Die KI generiert daraus ein einzigartiges Bild. +

+ + + + +
+ {/if} +
+ {:else} + +
+
+

+ Wähle ein Profilbild für {characterName} +

+

+ Klicke auf das Bild, das dir am besten gefällt +

+
+ + +
+ {#each generatedImages as image (image.imageUrl)} + + {/each} +
+ + +
+ +
+ + + +
+ {/if} + + +

+ Das Erstellen eines Charakters kostet 10 Mana. +

+
diff --git a/maerchenzauber/apps/web/src/routes/(protected)/dashboard/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/dashboard/+page.svelte new file mode 100644 index 000000000..693372542 --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/dashboard/+page.svelte @@ -0,0 +1,243 @@ + + + + Dashboard | Märchenzauber + + +
+ +
+

Willkommen bei Märchenzauber! ✨

+

+ Erstelle magische Geschichten mit deinen eigenen Charakteren. +

+ +
+ + +
+
+

Neueste Geschichten

+ + Alle anzeigen → + +
+ + {#if loadingStories} +
+ {#each Array(4) as _} +
+ {/each} +
+ {:else if errorStories} +
+ {errorStories} +
+ {:else if recentStories.length === 0} + + {:else} +
+ {#each recentStories as story (story.id)} + + {/each} +
+ {/if} +
+ + +
+
+

Deine Charaktere

+ + Alle anzeigen → + +
+ + {#if loadingCharacters} +
+ {#each Array(6) as _} +
+ {/each} +
+ {:else if errorCharacters} +
+ {errorCharacters} +
+ {:else if recentCharacters.length === 0} + + {:else} +
+ + + + + + Neu + + + {#each recentCharacters as character (character.id)} + + {/each} +
+ {/if} +
+ + + {#if favoriteStories.length > 0} +
+
+ + + +

Favoriten

+
+
+ {#each favoriteStories.slice(0, 4) as story (story.id)} + + {/each} +
+
+ {/if} + + +
+
+
+
+ + + +
+
+

{stories.filter(s => !s.archived).length}

+

Geschichten

+
+
+
+ +
+
+
+ + + +
+
+

{characters.filter(c => !c.archived).length}

+

Charaktere

+
+
+
+ +
+
+
+ + + +
+
+

{favoriteStories.length}

+

Favoriten

+
+
+
+
+
diff --git a/maerchenzauber/apps/web/src/routes/(protected)/discover/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/discover/+page.svelte new file mode 100644 index 000000000..9e7da727c --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/discover/+page.svelte @@ -0,0 +1,400 @@ + + + + Entdecken | Märchenzauber + + +
+ +
+
+

Entdecken

+

+ Entdecke Geschichten und Charaktere von anderen Nutzern +

+
+ + +
+ + + + +
+
+ + +
+ + +
+ + +
+ + + +
+ + + {#if activeSection === 'stories'} + + {#if storiesLoading} +
+ {#each Array(8) as _} +
+ {/each} +
+ {:else if storiesError} +
+ + + +

{storiesError}

+ +
+ {:else if filteredStories.length === 0} +
+ + + +

Keine Geschichten gefunden

+

+ {searchQuery ? 'Versuche es mit anderen Suchbegriffen' : 'Schau später nochmal vorbei'} +

+
+ {:else} + + {/if} + {:else} + + {#if charactersLoading} +
+ {#each Array(10) as _} +
+
+
+
+ {/each} +
+ {:else if charactersError} +
+ + + +

{charactersError}

+ +
+ {:else if filteredCharacters.length === 0} +
+ + + +

Keine Charaktere gefunden

+

+ {searchQuery ? 'Versuche es mit anderen Suchbegriffen' : 'Schau später nochmal vorbei'} +

+
+ {:else} +
+ {#each filteredCharacters as character (character.id)} +
+ +
+ + {character.name} + + + + + + + +
+ + + + {character.name} + + + {character.vote_count || 0} Likes + +
+ {/each} +
+ {/if} + {/if} +
diff --git a/maerchenzauber/apps/web/src/routes/(protected)/settings/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/settings/+page.svelte new file mode 100644 index 000000000..4f2509754 --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/settings/+page.svelte @@ -0,0 +1,240 @@ + + + + Einstellungen | Märchenzauber + + +
+ +
+

Einstellungen

+

+ Verwalte dein Konto und deine Einstellungen +

+
+ + +
+

Deine Statistiken

+
+
+
+
+ + + +
+
+

{loadingStats ? '...' : storyCount}

+

Geschichten

+
+
+
+
+
+
+ + + +
+
+

{loadingStats ? '...' : characterCount}

+

Charaktere

+
+
+
+
+
+ + +
+

Einstellungen

+ +
+ +
+
+
+ {#if isDarkMode} + + + + {:else} + + + + {/if} +
+
+

Dunkelmodus

+

{isDarkMode ? 'Aktiviert' : 'Deaktiviert'}

+
+
+ +
+
+
+ + +
+

Konto

+ +
+ +
+
+

E-Mail

+

{authStore.user?.email || '-'}

+
+
+ + +
+
+

Benutzer-ID

+

{authStore.user?.id || '-'}

+
+
+
+
+ + +
+

Abonnement

+ + +
+
+ + + +
+
+

Mana verwalten

+

Abonnement und Guthaben

+
+
+ + + +
+
+ + +
+

Aktionen

+ +
+ + +
+ + + +
+
+

Archiv

+

Archivierte Geschichten und Charaktere

+
+ + + +
+ + + +
+ + + +
+
+

Hilfe

+

FAQ und Support

+
+ + + +
+ + + +
+
+
diff --git a/maerchenzauber/apps/web/src/routes/(protected)/stories/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/stories/+page.svelte new file mode 100644 index 000000000..22f62f6e1 --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/stories/+page.svelte @@ -0,0 +1,159 @@ + + + + Geschichten | Märchenzauber + + +
+ +
+
+

Deine Geschichten

+

+ {filteredStories.length} {filteredStories.length === 1 ? 'Geschichte' : 'Geschichten'} +

+
+ + + + + Neue Geschichte + +
+ + +
+ +
+ + +
+ + +
+ + + + +
+
+ + + {#if loading} +
+ {#each Array(8) as _} +
+ {/each} +
+ {:else if error} +
+ {error} +
+ {:else if filteredStories.length === 0} + {#if searchQuery || filter === 'favorites'} + + {:else} + + {/if} + {:else} +
+ {#each filteredStories as story (story.id)} + + {/each} +
+ {/if} +
diff --git a/maerchenzauber/apps/web/src/routes/(protected)/stories/[id]/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/stories/[id]/+page.svelte new file mode 100644 index 000000000..8992da4ae --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/stories/[id]/+page.svelte @@ -0,0 +1,119 @@ + + + + {story?.title || 'Geschichte laden...'} - Märchenzauber + + +{#if loading} + +
+
+
+

Geschichte wird geladen...

+
+
+{:else if error} + +
+
+
+ + + +
+

Fehler beim Laden

+

{error}

+
+ + +
+
+
+{:else if story} + + +{/if} diff --git a/maerchenzauber/apps/web/src/routes/(protected)/stories/create/+page.svelte b/maerchenzauber/apps/web/src/routes/(protected)/stories/create/+page.svelte new file mode 100644 index 000000000..670e2af30 --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/(protected)/stories/create/+page.svelte @@ -0,0 +1,257 @@ + + + + Geschichte erstellen | Märchenzauber + + + +{#if creating} +
+
+ +
+
+
+ + + +
+
+ +
+

Deine Geschichte wird erschaffen...

+

+ Die Magie arbeitet! Dies kann bis zu 2 Minuten dauern. +

+
+ + +
+
+
+ Geschichte wird geschrieben... +
+
+
+ Illustrationen werden erstellt... +
+
+
+ Alles wird zusammengesetzt... +
+
+
+
+{/if} + +
+ +
+

+ Geschichte erstellen +

+

+ Wähle einen Charakter und beschreibe deine Geschichte +

+
+ + + {#if error} +
+
+ + + + {error} +
+
+ {/if} + + +
+
+

+ 1. Wähle deinen Charakter +

+ + + Neuen Charakter erstellen + +
+ + {#if loading} + +
+ {#each Array(5) as _} +
+
+
+
+ {/each} +
+ {:else if characters.length === 0} + +
+ + + +

Noch keine Charaktere

+

+ Erstelle zuerst einen Charakter für deine Geschichte. +

+ + + + + Charakter erstellen + +
+ {:else} + +
+ {#each characters.filter(c => !c.archived) as character (character.id)} + + {/each} +
+ {/if} +
+ + +
+

+ 2. Beschreibe deine Geschichte +

+ +
+ +

+ Beschreibe den Handlungsablauf, die Stimmung und wichtige Ereignisse. Die KI wird daraus eine passende Geschichte erstellen und illustrieren. +

+
+
+ + +
+ +
+ + +

+ Das Erstellen einer Geschichte kostet 10 Mana. +

+
diff --git a/maerchenzauber/apps/web/src/routes/+layout.ts b/maerchenzauber/apps/web/src/routes/+layout.ts new file mode 100644 index 000000000..8c482a26a --- /dev/null +++ b/maerchenzauber/apps/web/src/routes/+layout.ts @@ -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 {}; +}; diff --git a/maerchenzauber/apps/web/src/routes/+page.svelte b/maerchenzauber/apps/web/src/routes/+page.svelte index cc88df0ea..0397eb1b4 100644 --- a/maerchenzauber/apps/web/src/routes/+page.svelte +++ b/maerchenzauber/apps/web/src/routes/+page.svelte @@ -1,2 +1,28 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + + +
+
+
+

Laden...

+
+
diff --git a/maerchenzauber/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png b/maerchenzauber/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png new file mode 100644 index 000000000..e47ad9138 Binary files /dev/null and b/maerchenzauber/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png differ diff --git a/maerchenzauber/apps/web/static/images/app-icons/manacore-logo-gradient.png b/maerchenzauber/apps/web/static/images/app-icons/manacore-logo-gradient.png new file mode 100644 index 000000000..7bb2798b3 Binary files /dev/null and b/maerchenzauber/apps/web/static/images/app-icons/manacore-logo-gradient.png differ diff --git a/maerchenzauber/apps/web/static/images/app-icons/manadeck-logo-gradient.png b/maerchenzauber/apps/web/static/images/app-icons/manadeck-logo-gradient.png new file mode 100644 index 000000000..7bb2798b3 Binary files /dev/null and b/maerchenzauber/apps/web/static/images/app-icons/manadeck-logo-gradient.png differ diff --git a/maerchenzauber/apps/web/static/images/app-icons/memoro-logo-gradient.png b/maerchenzauber/apps/web/static/images/app-icons/memoro-logo-gradient.png new file mode 100644 index 000000000..f7bbee22d Binary files /dev/null and b/maerchenzauber/apps/web/static/images/app-icons/memoro-logo-gradient.png differ diff --git a/maerchenzauber/apps/web/static/images/app-icons/moodlit-logo-gradient.png b/maerchenzauber/apps/web/static/images/app-icons/moodlit-logo-gradient.png new file mode 100644 index 000000000..69fcd68a1 Binary files /dev/null and b/maerchenzauber/apps/web/static/images/app-icons/moodlit-logo-gradient.png differ diff --git a/maerchenzauber/apps/web/tailwind.config.js b/maerchenzauber/apps/web/tailwind.config.js new file mode 100644 index 000000000..7bcdf5326 --- /dev/null +++ b/maerchenzauber/apps/web/tailwind.config.js @@ -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')] +}; diff --git a/manacore/apps/landing/package.json b/manacore/apps/landing/package.json index 13eff52ba..0f14562b7 100644 --- a/manacore/apps/landing/package.json +++ b/manacore/apps/landing/package.json @@ -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" } } diff --git a/manacore/apps/mobile/package.json b/manacore/apps/mobile/package.json index fe73d4f01..e9325837e 100644 --- a/manacore/apps/mobile/package.json +++ b/manacore/apps/mobile/package.json @@ -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", diff --git a/manacore/apps/web/src/app.css b/manacore/apps/web/src/app.css index 4a6677cf2..c1ecbea8d 100644 --- a/manacore/apps/web/src/app.css +++ b/manacore/apps/web/src/app.css @@ -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 { diff --git a/manacore/apps/web/src/lib/components/LanguageSelector.svelte b/manacore/apps/web/src/lib/components/LanguageSelector.svelte new file mode 100644 index 000000000..01544fd58 --- /dev/null +++ b/manacore/apps/web/src/lib/components/LanguageSelector.svelte @@ -0,0 +1,21 @@ + + + diff --git a/manacore/apps/web/src/lib/i18n/index.ts b/manacore/apps/web/src/lib/i18n/index.ts new file mode 100644 index 000000000..3036ebce3 --- /dev/null +++ b/manacore/apps/web/src/lib/i18n/index.ts @@ -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 }; diff --git a/manacore/apps/web/src/lib/i18n/locales/de.json b/manacore/apps/web/src/lib/i18n/locales/de.json new file mode 100644 index 000000000..e242d9c15 --- /dev/null +++ b/manacore/apps/web/src/lib/i18n/locales/de.json @@ -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" + } +} diff --git a/manacore/apps/web/src/lib/i18n/locales/en.json b/manacore/apps/web/src/lib/i18n/locales/en.json new file mode 100644 index 000000000..c68f659ae --- /dev/null +++ b/manacore/apps/web/src/lib/i18n/locales/en.json @@ -0,0 +1,12 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "back": "Back", + "loading": "Loading..." + }, + "app_slider": { + "title": "Part of the Mana Ecosystem" + } +} diff --git a/manacore/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/manacore/apps/web/src/routes/(auth)/forgot-password/+page.svelte index 5eb9efd79..78bee8865 100644 --- a/manacore/apps/web/src/routes/(auth)/forgot-password/+page.svelte +++ b/manacore/apps/web/src/routes/(auth)/forgot-password/+page.svelte @@ -1,73 +1,26 @@ -
-
-

Forgot Password

-

- Enter your email and we'll send you a link to reset your password -

-
- - -
{ - loading = true; - return async ({ update }) => { - await update(); - loading = false; - }; - }} - > - {#if form?.error} -
- {form.error} -
- {/if} - - {#if form?.success} -
- Password reset email sent! Check your inbox for instructions. -
- {/if} - -
-
- - -
- -
- -
-
-
- -
-

- Remember your password? - - Sign in - -

-
-
-
+ + {#snippet appSlider()} + + {/snippet} + diff --git a/manacore/apps/web/src/routes/(auth)/login/+page.svelte b/manacore/apps/web/src/routes/(auth)/login/+page.svelte index 9b5e03907..d610c669f 100644 --- a/manacore/apps/web/src/routes/(auth)/login/+page.svelte +++ b/manacore/apps/web/src/routes/(auth)/login/+page.svelte @@ -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); - } + {#snippet headerControls()} + + {/snippet} {#snippet appSlider()} {/snippet} diff --git a/manacore/apps/web/src/routes/+layout.ts b/manacore/apps/web/src/routes/+layout.ts index dc0a7c4ad..8c482a26a 100644 --- a/manacore/apps/web/src/routes/+layout.ts +++ b/manacore/apps/web/src/routes/+layout.ts @@ -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 {}; }; diff --git a/manacore/apps/web/static/images/app-icons/manadeck-logo-gradient.png b/manacore/apps/web/static/images/app-icons/manadeck-logo-gradient.png new file mode 100644 index 000000000..7bb2798b3 Binary files /dev/null and b/manacore/apps/web/static/images/app-icons/manadeck-logo-gradient.png differ diff --git a/manacore/apps/web/tailwind.config.js b/manacore/apps/web/tailwind.config.js index 1689d8bf2..db9fa3ef1 100644 --- a/manacore/apps/web/tailwind.config.js +++ b/manacore/apps/web/tailwind.config.js @@ -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: { diff --git a/manadeck/apps/mobile/package.json b/manadeck/apps/mobile/package.json index 52d3e9d5f..85a59d49d 100644 --- a/manadeck/apps/mobile/package.json +++ b/manadeck/apps/mobile/package.json @@ -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", diff --git a/manadeck/apps/web/package.json b/manadeck/apps/web/package.json index 56ccdf1ca..d424af6cf 100644 --- a/manadeck/apps/web/package.json +++ b/manadeck/apps/web/package.json @@ -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" } } diff --git a/manadeck/apps/web/src/app.css b/manadeck/apps/web/src/app.css index b1adcdc48..f6ad4cf40 100644 --- a/manadeck/apps/web/src/app.css +++ b/manadeck/apps/web/src/app.css @@ -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"; diff --git a/manadeck/apps/web/src/lib/components/LanguageSelector.svelte b/manadeck/apps/web/src/lib/components/LanguageSelector.svelte new file mode 100644 index 000000000..f740acefa --- /dev/null +++ b/manadeck/apps/web/src/lib/components/LanguageSelector.svelte @@ -0,0 +1,21 @@ + + + diff --git a/manadeck/apps/web/src/lib/i18n/index.ts b/manadeck/apps/web/src/lib/i18n/index.ts new file mode 100644 index 000000000..3036ebce3 --- /dev/null +++ b/manadeck/apps/web/src/lib/i18n/index.ts @@ -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 }; diff --git a/manadeck/apps/web/src/lib/i18n/locales/de.json b/manadeck/apps/web/src/lib/i18n/locales/de.json new file mode 100644 index 000000000..e242d9c15 --- /dev/null +++ b/manadeck/apps/web/src/lib/i18n/locales/de.json @@ -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" + } +} diff --git a/manadeck/apps/web/src/lib/i18n/locales/en.json b/manadeck/apps/web/src/lib/i18n/locales/en.json new file mode 100644 index 000000000..c68f659ae --- /dev/null +++ b/manadeck/apps/web/src/lib/i18n/locales/en.json @@ -0,0 +1,12 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "back": "Back", + "loading": "Loading..." + }, + "app_slider": { + "title": "Part of the Mana Ecosystem" + } +} diff --git a/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte b/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte index f1610351e..5fbf6e876 100644 --- a/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte +++ b/manadeck/apps/web/src/routes/(app)/decks/[id]/+page.svelte @@ -63,7 +63,7 @@ {#if deck.tags && deck.tags.length > 0}
{#each deck.tags as tag} - {tag} + {tag} {/each}
{/if} @@ -71,9 +71,9 @@
{#if deck.is_public} - Public + Public {/if} -
@@ -140,7 +140,7 @@ - diff --git a/manadeck/apps/web/src/routes/(auth)/forgot-password/+page.svelte b/manadeck/apps/web/src/routes/(auth)/forgot-password/+page.svelte new file mode 100644 index 000000000..8aaabb135 --- /dev/null +++ b/manadeck/apps/web/src/routes/(auth)/forgot-password/+page.svelte @@ -0,0 +1,26 @@ + + + + {#snippet appSlider()} + + {/snippet} + diff --git a/manadeck/apps/web/src/routes/(auth)/login/+page.svelte b/manadeck/apps/web/src/routes/(auth)/login/+page.svelte index ed6af39a3..0f528c240 100644 --- a/manadeck/apps/web/src/routes/(auth)/login/+page.svelte +++ b/manadeck/apps/web/src/routes/(auth)/login/+page.svelte @@ -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); - } + {#snippet headerControls()} + + {/snippet} {#snippet appSlider()} {/snippet} diff --git a/manadeck/apps/web/src/routes/+layout.ts b/manadeck/apps/web/src/routes/+layout.ts new file mode 100644 index 000000000..8c482a26a --- /dev/null +++ b/manadeck/apps/web/src/routes/+layout.ts @@ -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 {}; +}; diff --git a/manadeck/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png b/manadeck/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png new file mode 100644 index 000000000..e47ad9138 Binary files /dev/null and b/manadeck/apps/web/static/images/app-icons/maerchenzauber-logo-gradient.png differ diff --git a/manadeck/apps/web/static/images/app-icons/manacore-logo-gradient.png b/manadeck/apps/web/static/images/app-icons/manacore-logo-gradient.png new file mode 100644 index 000000000..7bb2798b3 Binary files /dev/null and b/manadeck/apps/web/static/images/app-icons/manacore-logo-gradient.png differ diff --git a/manadeck/apps/web/static/images/app-icons/manadeck-logo-gradient.png b/manadeck/apps/web/static/images/app-icons/manadeck-logo-gradient.png new file mode 100644 index 000000000..7bb2798b3 Binary files /dev/null and b/manadeck/apps/web/static/images/app-icons/manadeck-logo-gradient.png differ diff --git a/manadeck/apps/web/static/images/app-icons/memoro-logo-gradient.png b/manadeck/apps/web/static/images/app-icons/memoro-logo-gradient.png new file mode 100644 index 000000000..f7bbee22d Binary files /dev/null and b/manadeck/apps/web/static/images/app-icons/memoro-logo-gradient.png differ diff --git a/manadeck/apps/web/static/images/app-icons/moodlit-logo-gradient.png b/manadeck/apps/web/static/images/app-icons/moodlit-logo-gradient.png new file mode 100644 index 000000000..69fcd68a1 Binary files /dev/null and b/manadeck/apps/web/static/images/app-icons/moodlit-logo-gradient.png differ diff --git a/manadeck/apps/web/tailwind.config.js b/manadeck/apps/web/tailwind.config.js index da92e7c20..7bcdf5326 100644 --- a/manadeck/apps/web/tailwind.config.js +++ b/manadeck/apps/web/tailwind.config.js @@ -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')] }; diff --git a/memoro/apps/mobile/package.json b/memoro/apps/mobile/package.json index 046f77fe2..0a518e0ab 100644 --- a/memoro/apps/mobile/package.json +++ b/memoro/apps/mobile/package.json @@ -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", diff --git a/memoro/apps/web/src/app.css b/memoro/apps/web/src/app.css index 147613253..f6ad4cf40 100644 --- a/memoro/apps/web/src/app.css +++ b/memoro/apps/web/src/app.css @@ -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"; diff --git a/memoro/apps/web/src/lib/components/AppSlider.svelte b/memoro/apps/web/src/lib/components/AppSlider.svelte index c3714bf37..62a7ea7b2 100644 --- a/memoro/apps/web/src/lib/components/AppSlider.svelte +++ b/memoro/apps/web/src/lib/components/AppSlider.svelte @@ -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([ { diff --git a/memoro/apps/web/src/lib/components/LanguageSelector.svelte b/memoro/apps/web/src/lib/components/LanguageSelector.svelte index 6f47e1dde..ed28dff9c 100644 --- a/memoro/apps/web/src/lib/components/LanguageSelector.svelte +++ b/memoro/apps/web/src/lib/components/LanguageSelector.svelte @@ -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 = { - 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" /> diff --git a/memoro/apps/web/src/lib/components/RecordingButton.svelte b/memoro/apps/web/src/lib/components/RecordingButton.svelte index 5cbbed585..5f51c79e0 100644 --- a/memoro/apps/web/src/lib/components/RecordingButton.svelte +++ b/memoro/apps/web/src/lib/components/RecordingButton.svelte @@ -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; diff --git a/memoro/apps/web/src/lib/components/memo/AdditionalRecordings.svelte b/memoro/apps/web/src/lib/components/memo/AdditionalRecordings.svelte index 952017922..740c0e8d4 100644 --- a/memoro/apps/web/src/lib/components/memo/AdditionalRecordings.svelte +++ b/memoro/apps/web/src/lib/components/memo/AdditionalRecordings.svelte @@ -1,6 +1,7 @@ @@ -53,7 +41,7 @@
@@ -65,7 +53,7 @@ />
diff --git a/memoro/apps/web/src/lib/components/statistics/OverviewCard.svelte b/memoro/apps/web/src/lib/components/statistics/OverviewCard.svelte index 7fe4d8047..3538685a2 100644 --- a/memoro/apps/web/src/lib/components/statistics/OverviewCard.svelte +++ b/memoro/apps/web/src/lib/components/statistics/OverviewCard.svelte @@ -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`; - } - } @@ -41,7 +29,7 @@
diff --git a/memoro/apps/web/src/lib/components/statistics/ProductivityCard.svelte b/memoro/apps/web/src/lib/components/statistics/ProductivityCard.svelte index 1eb459522..f5be9dc02 100644 --- a/memoro/apps/web/src/lib/components/statistics/ProductivityCard.svelte +++ b/memoro/apps/web/src/lib/components/statistics/ProductivityCard.svelte @@ -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`; - } - } @@ -63,7 +51,7 @@ /> @@ -86,7 +74,7 @@ /> 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, diff --git a/memoro/apps/web/src/lib/i18n/locales/de.json b/memoro/apps/web/src/lib/i18n/locales/de.json index a3f33df2c..10ed832ab 100644 --- a/memoro/apps/web/src/lib/i18n/locales/de.json +++ b/memoro/apps/web/src/lib/i18n/locales/de.json @@ -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 AGB und der Datenschutzerklärung 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" } } diff --git a/memoro/apps/web/src/lib/i18n/locales/en.json b/memoro/apps/web/src/lib/i18n/locales/en.json index a6150b142..a35785e4e 100644 --- a/memoro/apps/web/src/lib/i18n/locales/en.json +++ b/memoro/apps/web/src/lib/i18n/locales/en.json @@ -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 Terms and Privacy Policy.", - "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" } } diff --git a/memoro/apps/web/src/routes/(protected)/settings/+page.svelte b/memoro/apps/web/src/routes/(protected)/settings/+page.svelte index 93da2a9a3..135f7662f 100644 --- a/memoro/apps/web/src/routes/(protected)/settings/+page.svelte +++ b/memoro/apps/web/src/routes/(protected)/settings/+page.svelte @@ -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(currentTheme.mode); + let activeMode = $state(currentMode); // Update activeMode when theme changes $effect(() => { - activeMode = currentTheme.mode; + activeMode = currentMode; }); // Collapsible sections state diff --git a/memoro/apps/web/src/routes/(public)/forgot-password/+page.svelte b/memoro/apps/web/src/routes/(public)/forgot-password/+page.svelte new file mode 100644 index 000000000..1279c8fee --- /dev/null +++ b/memoro/apps/web/src/routes/(public)/forgot-password/+page.svelte @@ -0,0 +1,26 @@ + + + + {#snippet appSlider()} + + {/snippet} + diff --git a/memoro/apps/web/src/routes/(public)/login/+page.svelte b/memoro/apps/web/src/routes/(public)/login/+page.svelte index 4d16766a6..be5e5cc09 100644 --- a/memoro/apps/web/src/routes/(public)/login/+page.svelte +++ b/memoro/apps/web/src/routes/(public)/login/+page.svelte @@ -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); - } + {#snippet headerControls()} + + {/snippet} {#snippet appSlider()} {/snippet} diff --git a/memoro/apps/web/src/routes/+layout.svelte b/memoro/apps/web/src/routes/+layout.svelte index a7302839a..8f777ff2a 100644 --- a/memoro/apps/web/src/routes/+layout.svelte +++ b/memoro/apps/web/src/routes/+layout.svelte @@ -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(); diff --git a/memoro/apps/web/src/routes/+layout.ts b/memoro/apps/web/src/routes/+layout.ts new file mode 100644 index 000000000..ad4f2b2d7 --- /dev/null +++ b/memoro/apps/web/src/routes/+layout.ts @@ -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 {}; +}; diff --git a/memoro/apps/web/static/images/app-icons/manadeck-logo-gradient.png b/memoro/apps/web/static/images/app-icons/manadeck-logo-gradient.png new file mode 100644 index 000000000..7bb2798b3 Binary files /dev/null and b/memoro/apps/web/static/images/app-icons/manadeck-logo-gradient.png differ diff --git a/memoro/apps/web/svelte.config.js b/memoro/apps/web/svelte.config.js index 18f671315..94c5cf8e0 100644 --- a/memoro/apps/web/svelte.config.js +++ b/memoro/apps/web/svelte.config.js @@ -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: [] } } }; diff --git a/memoro/apps/web/tailwind.config.js b/memoro/apps/web/tailwind.config.js index da92e7c20..7bcdf5326 100644 --- a/memoro/apps/web/tailwind.config.js +++ b/memoro/apps/web/tailwind.config.js @@ -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')] }; diff --git a/package.json b/package.json index 5baae7909..48e7cc0f5 100644 --- a/package.json +++ b/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" + } + } + } } diff --git a/packages/shared-auth-stores/tsconfig.json b/packages/shared-auth-stores/tsconfig.json index 96d56cbb1..dd4b43d8f 100644 --- a/packages/shared-auth-stores/tsconfig.json +++ b/packages/shared-auth-stores/tsconfig.json @@ -10,7 +10,8 @@ "forceConsistentCasingInFileNames": true, "isolatedModules": true, "verbatimModuleSyntax": true, - "noEmit": true + "noEmit": true, + "types": ["svelte"] }, "include": ["src/**/*"], "exclude": ["node_modules"] diff --git a/packages/shared-auth-ui/src/icons/iconPaths.ts b/packages/shared-auth-ui/src/icons/iconPaths.ts index 9d060a982..32c4cd2bc 100644 --- a/packages/shared-auth-ui/src/icons/iconPaths.ts +++ b/packages/shared-auth-ui/src/icons/iconPaths.ts @@ -31,7 +31,11 @@ export const iconPaths = { 'music': '', - 'refresh': '' + 'refresh': '', + + 'check': '', + + 'warning': '' } as const; export type IconName = keyof typeof iconPaths; diff --git a/packages/shared-auth-ui/src/index.ts b/packages/shared-auth-ui/src/index.ts index 426ed291b..07fcbf9c4 100644 --- a/packages/shared-auth-ui/src/index.ts +++ b/packages/shared-auth-ui/src/index.ts @@ -1,6 +1,7 @@ // Pages export { default as LoginPage } from './pages/LoginPage.svelte'; export { default as RegisterPage } from './pages/RegisterPage.svelte'; +export { default as ForgotPasswordPage } from './pages/ForgotPasswordPage.svelte'; // Components export { default as Icon } from './components/Icon.svelte'; diff --git a/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte b/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte new file mode 100644 index 000000000..f519c5ca4 --- /dev/null +++ b/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte @@ -0,0 +1,239 @@ + + + + Forgot Password - {appName} + + +
+ +
+
+ +
+

+ {appName} +

+
+ + +
+
+ +

+ {mode === 'form' ? 'Reset Password' : 'Email Sent'} +

+ + + {#if error} +
+

{error}

+
+ {/if} + + + {#if mode === 'form'} +
{ + e.preventDefault(); + handleForgotPassword(); + }} + class="pb-4" + > +

+ Enter your email address and we'll send you a link to reset your password. +

+ +
+ +
+ + +
+ + +
+ +
+ + + {:else} +
+
+
+ +
+ +

+ We've sent a password reset link to {resetEmail}. Please check your + inbox. +

+
+ +
+ + + +
+
+ {/if} +
+
+ + + {#if appSlider} +
+ {@render appSlider()} +
+ {:else} + +
+ {/if} +
diff --git a/packages/shared-auth-ui/src/pages/LoginPage.svelte b/packages/shared-auth-ui/src/pages/LoginPage.svelte index 4d094ef01..ae34928b8 100644 --- a/packages/shared-auth-ui/src/pages/LoginPage.svelte +++ b/packages/shared-auth-ui/src/pages/LoginPage.svelte @@ -5,8 +5,6 @@ import GoogleSignInButton from '../components/GoogleSignInButton.svelte'; import AppleSignInButton from '../components/AppleSignInButton.svelte'; - type AuthMode = 'initial' | 'login' | 'forgot-password' | 'password-reset-success'; - interface Props { /** App name */ appName: string; @@ -20,8 +18,6 @@ onSignInWithGoogle?: (idToken: string) => Promise; /** Sign in with Apple function */ onSignInWithApple?: (identityToken: string) => Promise; - /** Forgot password function */ - onForgotPassword: (email: string) => Promise; /** Navigation function */ goto: (path: string) => void; /** Enable Google Sign-In */ @@ -32,6 +28,8 @@ successRedirect?: string; /** Register page path */ registerPath?: string; + /** Forgot password page path */ + forgotPasswordPath?: string; /** Light background color */ lightBackground?: string; /** Dark background color */ @@ -49,12 +47,12 @@ onSignIn, onSignInWithGoogle, onSignInWithApple, - onForgotPassword, goto, enableGoogle = false, enableApple = false, successRedirect = '/dashboard', registerPath = '/register', + forgotPasswordPath = '/forgot-password', lightBackground = '#f5f5f5', darkBackground = '#121212', appSlider, @@ -63,11 +61,16 @@ let loading = $state(false); let error = $state(null); + let errorField = $state<'email' | 'password' | 'general' | null>(null); let email = $state(''); let password = $state(''); - let mode = $state('initial'); - let resetEmail = $state(''); let showPassword = $state(false); + let rememberMe = $state(false); + let showSuccess = $state(false); + let shakeError = $state(false); + let emailInput: HTMLInputElement; + let passwordInput: HTMLInputElement; + let successAnnouncement = $state(''); // Check for dark mode let isDark = $state(false); @@ -84,22 +87,67 @@ } }); + // Autofocus email field on mount + $effect(() => { + if (emailInput) { + emailInput.focus(); + } + }); + function getPageBackground() { return isDark ? darkBackground : lightBackground; } + function isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + function triggerErrorShake() { + shakeError = true; + setTimeout(() => { + shakeError = false; + }, 500); + } + + function setError(message: string, field: 'email' | 'password' | 'general' = 'general') { + error = message; + errorField = field; + triggerErrorShake(); + + // Focus the problematic field for better accessibility + setTimeout(() => { + if (field === 'email' && emailInput) { + emailInput.focus(); + } else if (field === 'password' && passwordInput) { + passwordInput.focus(); + } + }, 100); + } + + function clearError() { + error = null; + errorField = null; + } + async function handleLogin() { loading = true; - error = null; + clearError(); if (!email) { - error = 'Email is required'; + setError('Email is required', 'email'); + loading = false; + return; + } + + if (!isValidEmail(email)) { + setError('Please enter a valid email address', 'email'); loading = false; return; } if (!password) { - error = 'Password is required'; + setError('Password is required', 'password'); loading = false; return; } @@ -109,55 +157,41 @@ loading = false; if (result.success) { - goto(successRedirect); + // Show success feedback before redirect + showSuccess = true; + successAnnouncement = 'Successfully signed in. Redirecting...'; + setTimeout(() => { + goto(successRedirect); + }, 600); } else { - error = result.error || 'Sign in failed'; - } - } - - async function handleForgotPassword() { - loading = true; - error = null; - - if (!email) { - error = 'Email is required'; - loading = false; - return; - } - - const result = await onForgotPassword(email); - - loading = false; - - if (result.success) { - resetEmail = email; - resetForm(); - switchMode('password-reset-success'); - } else { - error = result.error || 'Failed to send reset email'; + setError(result.error || 'Sign in failed', 'general'); } } async function handleGoogleSuccess(idToken: string) { if (!onSignInWithGoogle) return; + loading = true; + clearError(); + const result = await onSignInWithGoogle(idToken); + loading = false; + if (result.success) { - goto(successRedirect); + showSuccess = true; + successAnnouncement = 'Successfully signed in with Google. Redirecting...'; + setTimeout(() => { + goto(successRedirect); + }, 600); } else { - error = result.error || 'Google sign in failed'; + setError(result.error || 'Google sign in failed', 'general'); } } - function resetForm() { - email = ''; - password = ''; - error = null; - } - - function switchMode(newMode: AuthMode) { - mode = newMode; - error = null; + function skipToForm() { + if (emailInput) { + emailInput.focus(); + } } @@ -165,6 +199,100 @@ Login - {appName} + + + + + + +
+ {successAnnouncement} +
+
{/if} - -
-
- +
+ +
+ +

+ {appName} +

-

- {appName} -

-
- -
-
- -
-

- {#if mode === 'initial'} - Mana Login - {:else if mode === 'login'} + +
+
+ +
+

Sign In - {:else if mode === 'forgot-password'} - Reset Password - {:else if mode === 'password-reset-success'} - Email Sent - {/if} -

- {#if mode === 'initial'} +

Sign in with your Mana account

+
+ + + {#if error} + {/if} -
- - {#if error} -
-

{error}

-
- {/if} - - - {#if mode === 'initial'} -
- - - -
- - - {:else if mode === 'login'} +
{ e.preventDefault(); handleLogin(); }} class="pb-4" + aria-busy={loading} + aria-describedby={error ? 'form-error' : undefined} > -
+ +
+
-
+ +
+
- + +
+ + +
+ + {#if enableGoogle || enableApple} -
+ -
+
{#if enableGoogle && onSignInWithGoogle} {/if} @@ -332,122 +499,29 @@
{/if} - -
- -
- - - {:else if mode === 'forgot-password'} -
{ - e.preventDefault(); - handleForgotPassword(); - }} - class="pb-4" - > + +

- Enter your email address and we'll send you a link to reset your password. -

- -
- -
- -
- - + Don't have an account? -
- - - - {:else if mode === 'password-reset-success'} -
-
-
- -
- -

- We've sent a password reset link to {resetEmail}. Please check your - inbox. -

-
- -
- - - -
+

- {/if} +
-
+ - - {#if appSlider && mode === 'initial'} + + {#if appSlider}
{@render appSlider()}
diff --git a/packages/shared-auth-ui/src/pages/RegisterPage.svelte b/packages/shared-auth-ui/src/pages/RegisterPage.svelte index 4929cab75..f86bad4ca 100644 --- a/packages/shared-auth-ui/src/pages/RegisterPage.svelte +++ b/packages/shared-auth-ui/src/pages/RegisterPage.svelte @@ -3,6 +3,8 @@ import type { AuthResult } from '../types'; import Icon from '../components/Icon.svelte'; + import type { Snippet } from 'svelte'; + interface Props { /** App name */ appName: string; @@ -22,6 +24,8 @@ lightBackground?: string; /** Dark background color */ darkBackground?: string; + /** App slider snippet */ + appSlider?: Snippet; } let { @@ -33,7 +37,8 @@ successRedirect = '/dashboard', loginPath = '/login', lightBackground = '#f5f5f5', - darkBackground = '#121212' + darkBackground = '#121212', + appSlider }: Props = $props(); let loading = $state(false); @@ -305,6 +310,13 @@
- -
+ + {#if appSlider} +
+ {@render appSlider()} +
+ {:else} + +
+ {/if}
diff --git a/packages/shared-auth-ui/tsconfig.json b/packages/shared-auth-ui/tsconfig.json index 0d1efc56c..e040ebfe2 100644 --- a/packages/shared-auth-ui/tsconfig.json +++ b/packages/shared-auth-ui/tsconfig.json @@ -1,20 +1,18 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, - "declaration": true, - "declarationMap": true, - "skipLibCheck": true, "esModuleInterop": true, - "allowSyntheticDefaultImports": true, + "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, + "verbatimModuleSyntax": true, "noEmit": true, - "verbatimModuleSyntax": true + "types": ["svelte"] }, "include": ["src/**/*"], "exclude": ["node_modules"] diff --git a/packages/shared-auth/tsconfig.json b/packages/shared-auth/tsconfig.json index a0d235f9f..96d56cbb1 100644 --- a/packages/shared-auth/tsconfig.json +++ b/packages/shared-auth/tsconfig.json @@ -1,18 +1,17 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "outDir": "./dist", - "rootDir": "./src" + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules"] } diff --git a/packages/shared-branding/package.json b/packages/shared-branding/package.json index 300dfd416..b7300f6c6 100644 --- a/packages/shared-branding/package.json +++ b/packages/shared-branding/package.json @@ -7,7 +7,11 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": { + "svelte": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + } }, "scripts": { "check": "svelte-check --tsconfig ./tsconfig.json" diff --git a/packages/shared-branding/src/AppLogo.svelte b/packages/shared-branding/src/AppLogo.svelte index 56c54d2fc..40df2c761 100644 --- a/packages/shared-branding/src/AppLogo.svelte +++ b/packages/shared-branding/src/AppLogo.svelte @@ -45,6 +45,8 @@ {/if} diff --git a/packages/shared-branding/tsconfig.json b/packages/shared-branding/tsconfig.json index 58c33b809..eb5beeade 100644 --- a/packages/shared-branding/tsconfig.json +++ b/packages/shared-branding/tsconfig.json @@ -11,7 +11,8 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "verbatimModuleSyntax": true + "verbatimModuleSyntax": true, + "types": ["svelte"] }, "include": ["src/**/*"], "exclude": ["node_modules"] diff --git a/packages/shared-config/package.json b/packages/shared-config/package.json index f4a0c1a52..da0855787 100644 --- a/packages/shared-config/package.json +++ b/packages/shared-config/package.json @@ -18,6 +18,7 @@ "zod": "^3.24.0" }, "devDependencies": { + "@types/node": "^24.10.1", "typescript": "^5.7.3" } } diff --git a/packages/shared-config/tsconfig.json b/packages/shared-config/tsconfig.json index 121a61a7f..36c636e38 100644 --- a/packages/shared-config/tsconfig.json +++ b/packages/shared-config/tsconfig.json @@ -3,15 +3,15 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM"], + "types": ["node"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, "resolveJsonModule": true, "isolatedModules": true, + "verbatimModuleSyntax": true, "noEmit": true }, "include": ["src/**/*"], diff --git a/packages/shared-i18n/tsconfig.json b/packages/shared-i18n/tsconfig.json index 7da622336..e040ebfe2 100644 --- a/packages/shared-i18n/tsconfig.json +++ b/packages/shared-i18n/tsconfig.json @@ -1,17 +1,16 @@ { "compilerOptions": { - "target": "ESNext", + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ESNext", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, "resolveJsonModule": true, "isolatedModules": true, + "verbatimModuleSyntax": true, "noEmit": true, "types": ["svelte"] }, diff --git a/packages/shared-icons/src/iconPaths.ts b/packages/shared-icons/src/iconPaths.ts index db0dbbd0f..4073527a6 100644 --- a/packages/shared-icons/src/iconPaths.ts +++ b/packages/shared-icons/src/iconPaths.ts @@ -119,6 +119,10 @@ export const iconPaths = { gear: '', 'warning': '', + 'alert-triangle': + '', + 'alert-circle': + '', 'question': '', 'house': diff --git a/packages/shared-icons/tsconfig.json b/packages/shared-icons/tsconfig.json index 8f94f3e0e..e040ebfe2 100644 --- a/packages/shared-icons/tsconfig.json +++ b/packages/shared-icons/tsconfig.json @@ -1,16 +1,19 @@ { - "extends": "../../tsconfig.json", "compilerOptions": { - "target": "ESNext", + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true + "verbatimModuleSyntax": true, + "noEmit": true, + "types": ["svelte"] }, - "include": ["src/**/*.ts", "src/**/*.svelte"] + "include": ["src/**/*"], + "exclude": ["node_modules"] } diff --git a/packages/shared-subscription-types/tsconfig.json b/packages/shared-subscription-types/tsconfig.json index 121a61a7f..5b36a0ba0 100644 --- a/packages/shared-subscription-types/tsconfig.json +++ b/packages/shared-subscription-types/tsconfig.json @@ -8,10 +8,8 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "resolveJsonModule": true, "isolatedModules": true, + "verbatimModuleSyntax": true, "noEmit": true }, "include": ["src/**/*"], diff --git a/packages/shared-subscription-ui/package.json b/packages/shared-subscription-ui/package.json index f7313168f..8444ae943 100644 --- a/packages/shared-subscription-ui/package.json +++ b/packages/shared-subscription-ui/package.json @@ -7,14 +7,39 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts", - "./SubscriptionCard.svelte": "./src/SubscriptionCard.svelte", - "./PackageCard.svelte": "./src/PackageCard.svelte", - "./BillingToggle.svelte": "./src/BillingToggle.svelte", - "./UsageCard.svelte": "./src/UsageCard.svelte", - "./CostCard.svelte": "./src/CostCard.svelte", - "./SubscriptionButton.svelte": "./src/SubscriptionButton.svelte", - "./ManaIcon.svelte": "./src/ManaIcon.svelte" + ".": { + "svelte": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./SubscriptionCard.svelte": { + "svelte": "./src/SubscriptionCard.svelte", + "default": "./src/SubscriptionCard.svelte" + }, + "./PackageCard.svelte": { + "svelte": "./src/PackageCard.svelte", + "default": "./src/PackageCard.svelte" + }, + "./BillingToggle.svelte": { + "svelte": "./src/BillingToggle.svelte", + "default": "./src/BillingToggle.svelte" + }, + "./UsageCard.svelte": { + "svelte": "./src/UsageCard.svelte", + "default": "./src/UsageCard.svelte" + }, + "./CostCard.svelte": { + "svelte": "./src/CostCard.svelte", + "default": "./src/CostCard.svelte" + }, + "./SubscriptionButton.svelte": { + "svelte": "./src/SubscriptionButton.svelte", + "default": "./src/SubscriptionButton.svelte" + }, + "./ManaIcon.svelte": { + "svelte": "./src/ManaIcon.svelte", + "default": "./src/ManaIcon.svelte" + } }, "scripts": { "check": "svelte-check --tsconfig ./tsconfig.json" diff --git a/packages/shared-subscription-ui/src/PackageCard.svelte b/packages/shared-subscription-ui/src/PackageCard.svelte index 6d7efc81d..a55dd7cd9 100644 --- a/packages/shared-subscription-ui/src/PackageCard.svelte +++ b/packages/shared-subscription-ui/src/PackageCard.svelte @@ -42,6 +42,7 @@ let isHovered = $state(false); +
+
{ + data: T | null; + error: SupabaseError | null; +} + /** * Common database query helpers */ @@ -44,16 +62,15 @@ export const dbHelpers = { /** * Handle Supabase query result and return standardized response */ - handleQueryResult(result: { data: T | null; error: any }): { - data: T | null; - error: { message: string; code?: string } | null; - } { + handleQueryResult(result: { data: T | null; error: SupabaseError | null }): QueryResult { if (result.error) { return { data: null, error: { message: result.error.message, code: result.error.code, + details: result.error.details, + hint: result.error.hint, }, }; } diff --git a/packages/shared-supabase/tsconfig.json b/packages/shared-supabase/tsconfig.json index 112dcabe1..53d282079 100644 --- a/packages/shared-supabase/tsconfig.json +++ b/packages/shared-supabase/tsconfig.json @@ -1,17 +1,17 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2020"], + "lib": ["ES2022", "DOM"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "outDir": "./dist" + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules"] } diff --git a/packages/shared-tailwind/package.json b/packages/shared-tailwind/package.json index 62a5928f0..8f2b6754f 100644 --- a/packages/shared-tailwind/package.json +++ b/packages/shared-tailwind/package.json @@ -10,7 +10,8 @@ "./colors": "./src/colors.js", "./theme.css": "./src/theme-variables.css", "./themes.css": "./src/themes.css", - "./components.css": "./src/components.css" + "./components.css": "./src/components.css", + "./v4": "./src/tailwind-v4.css" }, "peerDependencies": { "tailwindcss": "^3.0.0 || ^4.0.0" diff --git a/packages/shared-tailwind/src/tailwind-v4.css b/packages/shared-tailwind/src/tailwind-v4.css new file mode 100644 index 000000000..66c63db2a --- /dev/null +++ b/packages/shared-tailwind/src/tailwind-v4.css @@ -0,0 +1,117 @@ +/** + * Tailwind CSS v4 Configuration + * + * This file provides the CSS-first configuration for Tailwind v4. + * Import this file in your app.css instead of using tailwind.config.js + * + * Usage: + * @import "tailwindcss"; + * @import "@manacore/shared-tailwind/v4"; + */ + +/* ===== Theme Configuration ===== */ +@theme { + /* Brand color */ + --color-mana: #4287f5; + + /* Semantic colors using CSS variables */ + --color-background: hsl(var(--color-background)); + --color-foreground: hsl(var(--color-foreground)); + + --color-primary: hsl(var(--color-primary)); + --color-primary-foreground: hsl(var(--color-primary-foreground)); + + --color-secondary: hsl(var(--color-secondary)); + --color-secondary-foreground: hsl(var(--color-secondary-foreground)); + + --color-surface: hsl(var(--color-surface)); + --color-surface-hover: hsl(var(--color-surface-hover)); + --color-surface-elevated: hsl(var(--color-surface-elevated)); + + --color-muted: hsl(var(--color-muted)); + --color-muted-foreground: hsl(var(--color-muted-foreground)); + + --color-border: hsl(var(--color-border)); + --color-border-strong: hsl(var(--color-border-strong)); + + --color-error: hsl(var(--color-error)); + --color-success: hsl(var(--color-success)); + --color-warning: hsl(var(--color-warning)); + + --color-input: hsl(var(--color-input)); + --color-ring: hsl(var(--color-ring)); + + /* Legacy aliases */ + --color-content: hsl(var(--color-surface)); + --color-content-hover: hsl(var(--color-surface-hover)); + --color-content-page: hsl(var(--color-background)); + + --color-menu: hsl(var(--color-muted)); + --color-menu-hover: hsl(var(--color-surface-hover)); + + --color-theme: hsl(var(--color-foreground)); + --color-theme-secondary: hsl(var(--color-muted-foreground)); + + --color-primary-btn: hsl(var(--color-primary)); + --color-primary-btn-text: hsl(var(--color-primary-foreground)); + + /* Border radius */ + --radius-none: 0; + --radius-sm: var(--radius-sm, 0.25rem); + --radius-DEFAULT: var(--radius, 0.375rem); + --radius-md: var(--radius-md, 0.5rem); + --radius-lg: var(--radius-lg, 0.75rem); + --radius-xl: var(--radius-xl, 1rem); + --radius-2xl: var(--radius-2xl, 1.5rem); + --radius-3xl: var(--radius-3xl, 2rem); + --radius-full: 9999px; + + /* Box shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-DEFAULT: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05); + --shadow-none: none; + + /* Font families */ + --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', Menlo, Monaco, Consolas, monospace; + + /* Transition durations */ + --duration-250: 250ms; + --duration-350: 350ms; + --duration-400: 400ms; + + /* Animations */ + --animate-spin-slow: spin 3s linear infinite; + --animate-pulse-slow: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-bounce-slow: bounce 2s infinite; + --animate-fade-in: fadeIn 0.2s ease-out; + --animate-fade-out: fadeOut 0.2s ease-in; + --animate-slide-in: slideIn 0.2s ease-out; + --animate-slide-out: slideOut 0.2s ease-in; +} + +/* ===== Keyframes ===== */ +@keyframes fadeIn { + 0% { opacity: 0; } + 100% { opacity: 1; } +} + +@keyframes fadeOut { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +@keyframes slideIn { + 0% { transform: translateY(-10px); opacity: 0; } + 100% { transform: translateY(0); opacity: 1; } +} + +@keyframes slideOut { + 0% { transform: translateY(0); opacity: 1; } + 100% { transform: translateY(-10px); opacity: 0; } +} diff --git a/packages/shared-tailwind/src/themes.css b/packages/shared-tailwind/src/themes.css index d5c1c356b..3f3ce1d0c 100644 --- a/packages/shared-tailwind/src/themes.css +++ b/packages/shared-tailwind/src/themes.css @@ -5,12 +5,10 @@ * Variables are set by @manacore/shared-theme's createThemeStore() at runtime, * but this file provides sensible defaults for static rendering. * - * Usage in app.css: + * Usage in app.css (Tailwind v4): * ```css - * @import '@manacore/shared-tailwind/themes.css'; - * @tailwind base; - * @tailwind components; - * @tailwind utilities; + * @import "tailwindcss"; + * @import "@manacore/shared-tailwind/themes.css"; * ``` * * Color format: HSL values without hsl() wrapper @@ -18,6 +16,105 @@ * Used as: hsl(var(--color-primary)) */ +/* ===== Tailwind v4 Theme Configuration ===== */ +@theme { + /* Brand color */ + --color-mana: #4287f5; + + /* Semantic colors using CSS variables */ + --color-background: hsl(var(--color-background)); + --color-foreground: hsl(var(--color-foreground)); + + --color-primary: hsl(var(--color-primary)); + --color-primary-foreground: hsl(var(--color-primary-foreground)); + + --color-secondary: hsl(var(--color-secondary)); + --color-secondary-foreground: hsl(var(--color-secondary-foreground)); + + --color-surface: hsl(var(--color-surface)); + --color-surface-hover: hsl(var(--color-surface-hover)); + --color-surface-elevated: hsl(var(--color-surface-elevated)); + + --color-muted: hsl(var(--color-muted)); + --color-muted-foreground: hsl(var(--color-muted-foreground)); + + --color-border: hsl(var(--color-border)); + --color-border-strong: hsl(var(--color-border-strong)); + + --color-error: hsl(var(--color-error)); + --color-success: hsl(var(--color-success)); + --color-warning: hsl(var(--color-warning)); + + --color-input: hsl(var(--color-input)); + --color-ring: hsl(var(--color-ring)); + + /* Legacy aliases */ + --color-content: hsl(var(--color-surface)); + --color-content-hover: hsl(var(--color-surface-hover)); + --color-content-page: hsl(var(--color-background)); + --color-menu: hsl(var(--color-muted)); + --color-menu-hover: hsl(var(--color-surface-hover)); + --color-theme: hsl(var(--color-foreground)); + --color-theme-secondary: hsl(var(--color-muted-foreground)); + --color-primary-btn: hsl(var(--color-primary)); + --color-primary-btn-text: hsl(var(--color-primary-foreground)); + + /* Border radius */ + --radius-none: 0; + --radius-sm: 0.25rem; + --radius-DEFAULT: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-2xl: 1.5rem; + --radius-3xl: 2rem; + --radius-full: 9999px; + + /* Box shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-DEFAULT: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05); + --shadow-none: none; + + /* Font families */ + --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', Menlo, Monaco, Consolas, monospace; + + /* Animations */ + --animate-spin-slow: spin 3s linear infinite; + --animate-pulse-slow: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-bounce-slow: bounce 2s infinite; + --animate-fade-in: fadeIn 0.2s ease-out; + --animate-fade-out: fadeOut 0.2s ease-in; + --animate-slide-in: slideIn 0.2s ease-out; + --animate-slide-out: slideOut 0.2s ease-in; +} + +/* ===== Keyframes ===== */ +@keyframes fadeIn { + 0% { opacity: 0; } + 100% { opacity: 1; } +} + +@keyframes fadeOut { + 0% { opacity: 1; } + 100% { opacity: 0; } +} + +@keyframes slideIn { + 0% { transform: translateY(-10px); opacity: 0; } + 100% { transform: translateY(0); opacity: 1; } +} + +@keyframes slideOut { + 0% { transform: translateY(0); opacity: 1; } + 100% { transform: translateY(-10px); opacity: 0; } +} + /* ===== Default Theme (Lume Light) ===== */ :root { /* Primary brand color */ diff --git a/packages/shared-theme-ui/package.json b/packages/shared-theme-ui/package.json index f7f2e16cd..182a20b02 100644 --- a/packages/shared-theme-ui/package.json +++ b/packages/shared-theme-ui/package.json @@ -3,13 +3,27 @@ "version": "0.1.0", "private": true, "type": "module", + "svelte": "./src/index.ts", "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts", - "./ThemeToggle.svelte": "./src/ThemeToggle.svelte", - "./ThemeSelector.svelte": "./src/ThemeSelector.svelte", - "./ThemeModeSelector.svelte": "./src/ThemeModeSelector.svelte" + ".": { + "svelte": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./ThemeToggle.svelte": { + "svelte": "./src/ThemeToggle.svelte", + "default": "./src/ThemeToggle.svelte" + }, + "./ThemeSelector.svelte": { + "svelte": "./src/ThemeSelector.svelte", + "default": "./src/ThemeSelector.svelte" + }, + "./ThemeModeSelector.svelte": { + "svelte": "./src/ThemeModeSelector.svelte", + "default": "./src/ThemeModeSelector.svelte" + } }, "peerDependencies": { "svelte": "^5.0.0" diff --git a/packages/shared-theme-ui/tsconfig.json b/packages/shared-theme-ui/tsconfig.json index ce024e619..dd4b43d8f 100644 --- a/packages/shared-theme-ui/tsconfig.json +++ b/packages/shared-theme-ui/tsconfig.json @@ -1,15 +1,18 @@ { "compilerOptions": { - "target": "ESNext", + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "declaration": true, - "outDir": "./dist", - "rootDir": "./src" + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "types": ["svelte"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules"] } diff --git a/packages/shared-theme/package.json b/packages/shared-theme/package.json index d6cf062d8..81dd1b16b 100644 --- a/packages/shared-theme/package.json +++ b/packages/shared-theme/package.json @@ -3,14 +3,29 @@ "version": "0.1.0", "private": true, "type": "module", + "svelte": "./src/index.ts", "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts", - "./store": "./src/store.svelte.ts", - "./types": "./src/types.ts", - "./constants": "./src/constants.ts", - "./utils": "./src/utils.ts" + ".": { + "svelte": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./store": { + "svelte": "./src/store.svelte.ts", + "default": "./src/store.svelte.ts" + }, + "./types": { + "types": "./src/types.ts", + "default": "./src/types.ts" + }, + "./constants": { + "default": "./src/constants.ts" + }, + "./utils": { + "default": "./src/utils.ts" + } }, "peerDependencies": { "svelte": "^5.0.0" diff --git a/packages/shared-theme/tsconfig.json b/packages/shared-theme/tsconfig.json index cc6bd8b1d..dd4b43d8f 100644 --- a/packages/shared-theme/tsconfig.json +++ b/packages/shared-theme/tsconfig.json @@ -1,16 +1,18 @@ { "compilerOptions": { - "target": "ESNext", + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, - "declaration": true, - "declarationMap": true, - "outDir": "./dist", - "rootDir": "./src" + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "types": ["svelte"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules"] } diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json index 112dcabe1..5b36a0ba0 100644 --- a/packages/shared-types/tsconfig.json +++ b/packages/shared-types/tsconfig.json @@ -1,17 +1,17 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2020"], + "lib": ["ES2022"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "declaration": true, - "declarationMap": true, - "outDir": "./dist" + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules"] } diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index 064068f82..73581e974 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -7,10 +7,26 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts", - "./atoms": "./src/atoms/index.ts", - "./molecules": "./src/molecules/index.ts", - "./organisms": "./src/organisms/index.ts" + ".": { + "svelte": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./atoms": { + "svelte": "./src/atoms/index.ts", + "types": "./src/atoms/index.ts", + "default": "./src/atoms/index.ts" + }, + "./molecules": { + "svelte": "./src/molecules/index.ts", + "types": "./src/molecules/index.ts", + "default": "./src/molecules/index.ts" + }, + "./organisms": { + "svelte": "./src/organisms/index.ts", + "types": "./src/organisms/index.ts", + "default": "./src/organisms/index.ts" + } }, "peerDependencies": { "svelte": "^5.0.0" diff --git a/packages/shared-ui/src/atoms/Card.svelte b/packages/shared-ui/src/atoms/Card.svelte index 64a8d3f5e..88ba14096 100644 --- a/packages/shared-ui/src/atoms/Card.svelte +++ b/packages/shared-ui/src/atoms/Card.svelte @@ -41,6 +41,7 @@ const isInteractive = $derived(interactive || !!onclick); +
import type { HTMLAttributes } from 'svelte/elements'; + import type { Snippet } from 'svelte'; type TextVariant = 'body' | 'body-secondary' | 'small' | 'large' | 'muted'; type TextAlign = 'left' | 'center' | 'right'; @@ -10,7 +11,7 @@ align?: TextAlign; weight?: TextWeight; class?: string; - children?: any; + children?: Snippet; } let { diff --git a/packages/shared-ui/src/molecules/DataCard.svelte b/packages/shared-ui/src/molecules/DataCard.svelte index 0555951ab..848338603 100644 --- a/packages/shared-ui/src/molecules/DataCard.svelte +++ b/packages/shared-ui/src/molecules/DataCard.svelte @@ -137,6 +137,8 @@ {#if actions} + +
e.stopPropagation()}> {@render actions()}
diff --git a/packages/shared-ui/src/molecules/Input.svelte b/packages/shared-ui/src/molecules/Input.svelte index 38726f9f2..e0c70913a 100644 --- a/packages/shared-ui/src/molecules/Input.svelte +++ b/packages/shared-ui/src/molecules/Input.svelte @@ -13,6 +13,8 @@ required?: boolean; autocomplete?: HTMLInputAttributes['autocomplete']; class?: string; + id?: string; + name?: string; } let { @@ -26,7 +28,9 @@ disabled = false, required = false, autocomplete, - class: className = '' + class: className = '', + id = `input-${Math.random().toString(36).slice(2, 9)}`, + name }: Props = $props(); function handleInput(e: Event) { @@ -43,15 +47,17 @@
{#if label} -