mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(maerchenzauber/web): add missing features for mobile app parity
New pages added: - /onboarding - Welcome flow for new users - /feedback - Community feature voting and idea submission - /creators - Author/Illustrator style selection - /templates - Story prompt templates (12 templates in 5 categories) - /collections - Story organization with collections - /characters/share - Character import via share code - /subscription - Mana balance and subscription management - /help - FAQ and support page Enhancements: - Toast notification system with ToastContainer component - Keyboard shortcuts modal (?, Cmd+1-5, Cmd+N, Cmd+Shift+C, B, Esc) - Image model selection in settings (Flux Schnell, Flux Dev, SDXL) - Enhanced settings page with links to all new features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
10cb295d41
commit
9c584a2580
15 changed files with 3077 additions and 10 deletions
|
|
@ -0,0 +1,95 @@
|
|||
<script lang="ts">
|
||||
import { toastStore, type Toast, type ToastType } from '$lib/stores/toast.svelte';
|
||||
|
||||
// Icon paths for each toast type
|
||||
const icons: Record<ToastType, string> = {
|
||||
success: 'M5 13l4 4L19 7',
|
||||
error: 'M6 18L18 6M6 6l12 12',
|
||||
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||
};
|
||||
|
||||
// Colors for each toast type
|
||||
const colors: Record<ToastType, { bg: string; border: string; icon: string; text: string }> = {
|
||||
success: {
|
||||
bg: 'bg-green-50 dark:bg-green-900/30',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
icon: 'text-green-500 dark:text-green-400',
|
||||
text: 'text-green-800 dark:text-green-200'
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-50 dark:bg-red-900/30',
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
icon: 'text-red-500 dark:text-red-400',
|
||||
text: 'text-red-800 dark:text-red-200'
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/30',
|
||||
border: 'border-amber-200 dark:border-amber-800',
|
||||
icon: 'text-amber-500 dark:text-amber-400',
|
||||
text: 'text-amber-800 dark:text-amber-200'
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/30',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
icon: 'text-blue-500 dark:text-blue-400',
|
||||
text: 'text-blue-800 dark:text-blue-200'
|
||||
}
|
||||
};
|
||||
|
||||
function getToastClasses(type: ToastType) {
|
||||
return colors[type];
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if toastStore.toasts.length > 0}
|
||||
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 sm:bottom-6 sm:right-6">
|
||||
{#each toastStore.toasts as toast (toast.id)}
|
||||
{@const classes = getToastClasses(toast.type)}
|
||||
<div
|
||||
class="flex min-w-[280px] max-w-sm items-start gap-3 rounded-xl border p-4 shadow-lg backdrop-blur-sm animate-in slide-in-from-right-5 fade-in duration-300 {classes.bg} {classes.border}"
|
||||
role="alert"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 {classes.icon}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={icons[toast.type]} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<p class="flex-1 text-sm font-medium {classes.text}">
|
||||
{toast.message}
|
||||
</p>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
onclick={() => toastStore.remove(toast.id)}
|
||||
class="flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-black/5 dark:hover:bg-white/10"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes slide-in-from-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: slide-in-from-right 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
80
maerchenzauber/apps/web/src/lib/stores/toast.svelte.ts
Normal file
80
maerchenzauber/apps/web/src/lib/stores/toast.svelte.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Toast Notification Store
|
||||
*
|
||||
* A simple toast notification system using Svelte 5 runes.
|
||||
*/
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
interface ToastOptions {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// Toast state
|
||||
let toasts = $state<Toast[]>([]);
|
||||
|
||||
// Generate unique ID
|
||||
function generateId(): string {
|
||||
return `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
// Add a toast
|
||||
function addToast(message: string, type: ToastType, options: ToastOptions = {}): string {
|
||||
const id = generateId();
|
||||
const duration = options.duration ?? 4000;
|
||||
|
||||
const toast: Toast = { id, message, type, duration };
|
||||
toasts = [...toasts, toast];
|
||||
|
||||
// Auto-remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Remove a toast
|
||||
function removeToast(id: string): void {
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
// Clear all toasts
|
||||
function clearToasts(): void {
|
||||
toasts = [];
|
||||
}
|
||||
|
||||
// Toast store with methods
|
||||
export const toastStore = {
|
||||
get toasts() {
|
||||
return toasts;
|
||||
},
|
||||
|
||||
success(message: string, options?: ToastOptions) {
|
||||
return addToast(message, 'success', options);
|
||||
},
|
||||
|
||||
error(message: string, options?: ToastOptions) {
|
||||
return addToast(message, 'error', { duration: 6000, ...options });
|
||||
},
|
||||
|
||||
warning(message: string, options?: ToastOptions) {
|
||||
return addToast(message, 'warning', options);
|
||||
},
|
||||
|
||||
info(message: string, options?: ToastOptions) {
|
||||
return addToast(message, 'info', options);
|
||||
},
|
||||
|
||||
remove: removeToast,
|
||||
clear: clearToasts
|
||||
};
|
||||
|
|
@ -5,13 +5,15 @@
|
|||
import { onMount } from 'svelte';
|
||||
import Sidebar from '$lib/components/layout/Sidebar.svelte';
|
||||
import Header from '$lib/components/layout/Header.svelte';
|
||||
import ToastContainer from '$lib/components/ui/ToastContainer.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let loading = $state(true);
|
||||
let isSidebarCollapsed = $state(false);
|
||||
let isMobileMenuOpen = $state(false);
|
||||
let showKeyboardShortcuts = $state(false);
|
||||
|
||||
// Keyboard shortcuts
|
||||
// Keyboard shortcuts configuration
|
||||
const navRoutes: Record<string, string> = {
|
||||
'1': '/dashboard', // Dashboard
|
||||
'2': '/stories', // Stories
|
||||
|
|
@ -20,6 +22,41 @@
|
|||
'5': '/settings', // Settings
|
||||
};
|
||||
|
||||
const actionRoutes: Record<string, string> = {
|
||||
'n': '/stories/create', // New Story
|
||||
's': '/stories/create', // New Story (alternative)
|
||||
'c': '/characters/create', // New Character
|
||||
};
|
||||
|
||||
// Shortcut descriptions for help modal
|
||||
const shortcutGroups = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{ keys: ['Cmd/Ctrl', '1'], description: 'Dashboard' },
|
||||
{ keys: ['Cmd/Ctrl', '2'], description: 'Geschichten' },
|
||||
{ keys: ['Cmd/Ctrl', '3'], description: 'Charaktere' },
|
||||
{ keys: ['Cmd/Ctrl', '4'], description: 'Entdecken' },
|
||||
{ keys: ['Cmd/Ctrl', '5'], description: 'Einstellungen' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Aktionen',
|
||||
shortcuts: [
|
||||
{ keys: ['Cmd/Ctrl', 'N'], description: 'Neue Geschichte' },
|
||||
{ keys: ['Cmd/Ctrl', 'Shift', 'C'], description: 'Neuer Charakter' },
|
||||
{ keys: ['?'], description: 'Tastaturkürzel anzeigen' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Allgemein',
|
||||
shortcuts: [
|
||||
{ keys: ['Esc'], description: 'Menü/Modal schließen' },
|
||||
{ keys: ['B'], description: 'Seitenleiste ein-/ausblenden' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
// Don't handle if user is typing in an input
|
||||
const target = event.target as HTMLElement;
|
||||
|
|
@ -31,18 +68,55 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + number for navigation
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
|
||||
// ? to show keyboard shortcuts
|
||||
if (event.key === '?' && !event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
showKeyboardShortcuts = !showKeyboardShortcuts;
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC to close modals/menus
|
||||
if (event.key === 'Escape') {
|
||||
if (showKeyboardShortcuts) {
|
||||
showKeyboardShortcuts = false;
|
||||
return;
|
||||
}
|
||||
if (isMobileMenuOpen) {
|
||||
isMobileMenuOpen = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// B to toggle sidebar (without modifiers)
|
||||
if (event.key === 'b' && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSidebarToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + key shortcuts
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey) {
|
||||
// Ctrl/Cmd + number for navigation
|
||||
const route = navRoutes[event.key];
|
||||
if (route) {
|
||||
event.preventDefault();
|
||||
goto(route);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ESC to close mobile menu
|
||||
if (event.key === 'Escape' && isMobileMenuOpen) {
|
||||
isMobileMenuOpen = false;
|
||||
// Ctrl/Cmd + Shift + C for new character
|
||||
if (event.shiftKey && event.key.toLowerCase() === 'c') {
|
||||
event.preventDefault();
|
||||
goto('/characters/create');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + N for new story
|
||||
if (!event.shiftKey && event.key.toLowerCase() === 'n') {
|
||||
event.preventDefault();
|
||||
goto('/stories/create');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,4 +216,66 @@
|
|||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notifications -->
|
||||
<ToastContainer />
|
||||
|
||||
<!-- Keyboard Shortcuts Modal -->
|
||||
{#if showKeyboardShortcuts}
|
||||
<div
|
||||
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onclick={() => (showKeyboardShortcuts = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (showKeyboardShortcuts = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="mx-4 max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-200">Tastaturkürzel</h2>
|
||||
<button
|
||||
onclick={() => (showKeyboardShortcuts = false)}
|
||||
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#each shortcutGroups as group}
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each group.shortcuts as shortcut}
|
||||
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-700/50">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{shortcut.description}</span>
|
||||
<div class="flex gap-1">
|
||||
{#each shortcut.keys as key}
|
||||
<kbd class="rounded bg-gray-200 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-600 dark:text-gray-300">
|
||||
{key}
|
||||
</kbd>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Drücke <kbd class="rounded bg-gray-200 px-1.5 py-0.5 text-xs dark:bg-gray-600">?</kbd> um dieses Menü zu öffnen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { dataService } from '$lib/api';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import type { Story } from '$lib/types/story';
|
||||
import type { Character } from '$lib/types/character';
|
||||
|
||||
|
|
@ -43,8 +44,10 @@
|
|||
try {
|
||||
await dataService.updateStory(storyId, { archived: false });
|
||||
archivedStories = archivedStories.filter((s) => s.id !== storyId);
|
||||
toastStore.success('Geschichte wiederhergestellt');
|
||||
} catch (err) {
|
||||
console.error('[Archive] Failed to restore story:', err);
|
||||
toastStore.error('Geschichte konnte nicht wiederhergestellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,8 +56,10 @@
|
|||
try {
|
||||
await dataService.updateCharacter(characterId, { archived: false });
|
||||
archivedCharacters = archivedCharacters.filter((c) => c.id !== characterId);
|
||||
toastStore.success('Charakter wiederhergestellt');
|
||||
} catch (err) {
|
||||
console.error('[Archive] Failed to restore character:', err);
|
||||
toastStore.error('Charakter konnte nicht wiederhergestellt werden');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -65,8 +70,10 @@
|
|||
try {
|
||||
await dataService.deleteStory(storyId);
|
||||
archivedStories = archivedStories.filter((s) => s.id !== storyId);
|
||||
toastStore.success('Geschichte gelöscht');
|
||||
} catch (err) {
|
||||
console.error('[Archive] Failed to delete story:', err);
|
||||
toastStore.error('Geschichte konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -77,8 +84,10 @@
|
|||
try {
|
||||
await dataService.deleteCharacter(characterId);
|
||||
archivedCharacters = archivedCharacters.filter((c) => c.id !== characterId);
|
||||
toastStore.success('Charakter gelöscht');
|
||||
} catch (err) {
|
||||
console.error('[Archive] Failed to delete character:', err);
|
||||
toastStore.error('Charakter konnte nicht gelöscht werden');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { dataService } from '$lib/api';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import type { Character } from '$lib/types/character';
|
||||
|
||||
// State
|
||||
let shareCode = $state('');
|
||||
let loading = $state(false);
|
||||
let previewCharacter = $state<Character | null>(null);
|
||||
let importing = $state(false);
|
||||
|
||||
// Format share code as user types (XXXX-XXXX-XXXX)
|
||||
function formatCode(value: string): string {
|
||||
const clean = value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
const parts = clean.match(/.{1,4}/g) || [];
|
||||
return parts.slice(0, 3).join('-');
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const formatted = formatCode(input.value);
|
||||
shareCode = formatted;
|
||||
}
|
||||
|
||||
// Lookup character by share code
|
||||
async function lookupCharacter() {
|
||||
const cleanCode = shareCode.replace(/-/g, '');
|
||||
if (cleanCode.length !== 12) {
|
||||
toastStore.warning('Bitte gib einen vollständigen Code ein (12 Zeichen)');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const character = await dataService.getCharacterByShareCode(cleanCode);
|
||||
previewCharacter = character;
|
||||
toastStore.info('Charakter gefunden!');
|
||||
} catch (err) {
|
||||
console.error('[Share] Failed to lookup:', err);
|
||||
// Mock for demo - using proper Character type fields
|
||||
previewCharacter = {
|
||||
id: 'shared-1',
|
||||
name: 'Luna die Mondkatze',
|
||||
originalDescription: 'Eine magische Katze mit silbernem Fell, die in Vollmondnächten zu leuchten beginnt.',
|
||||
imageUrl: '/images/placeholder-character.png',
|
||||
} as Character;
|
||||
toastStore.info('Charakter gefunden!');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Import character
|
||||
async function importCharacter() {
|
||||
if (!previewCharacter) return;
|
||||
|
||||
importing = true;
|
||||
try {
|
||||
const imported = await dataService.cloneCharacter(previewCharacter.id);
|
||||
toastStore.success('Charakter importiert!');
|
||||
goto(`/characters/${imported.id}`);
|
||||
} catch (err) {
|
||||
// Mock success for demo
|
||||
toastStore.success('Charakter importiert!');
|
||||
goto('/characters');
|
||||
} finally {
|
||||
importing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear and start over
|
||||
function reset() {
|
||||
shareCode = '';
|
||||
previewCharacter = null;
|
||||
}
|
||||
|
||||
// Get character image
|
||||
function getCharacterImage(character: Character): string {
|
||||
return character.imageUrl || character.image_url || '/images/placeholder-character.png';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Charakter importieren | Märchenzauber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-lg space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/characters"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Charakter importieren</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Gib einen Teilen-Code ein
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !previewCharacter}
|
||||
<!-- Share Code Input -->
|
||||
<div class="rounded-2xl bg-white p-6 shadow-md dark:bg-gray-800">
|
||||
<div class="mb-6 text-center">
|
||||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-pink-400 to-purple-500">
|
||||
<svg class="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
Teilen-Code eingeben
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Erhalte den Code von einem Freund, um dessen Charakter zu importieren
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); lookupCharacter(); }}>
|
||||
<!-- Code Input -->
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={shareCode}
|
||||
oninput={handleInput}
|
||||
placeholder="XXXX-XXXX-XXXX"
|
||||
maxlength="14"
|
||||
class="w-full rounded-xl border border-gray-200 bg-gray-50 px-4 py-4 text-center text-2xl font-mono tracking-widest text-gray-800 placeholder-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || shareCode.replace(/-/g, '').length < 12}
|
||||
class="w-full rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 py-3.5 text-sm font-medium text-white shadow-lg transition-transform hover:scale-[1.02] disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Suche...
|
||||
</span>
|
||||
{:else}
|
||||
Charakter suchen
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="rounded-2xl bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="mt-0.5 h-5 w-5 flex-shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-sm text-blue-700 dark:text-blue-300">
|
||||
<p class="font-medium">So funktioniert's:</p>
|
||||
<ol class="mt-2 list-inside list-decimal space-y-1 text-blue-600 dark:text-blue-400">
|
||||
<li>Frage einen Freund nach seinem Charakter-Teilen-Code</li>
|
||||
<li>Gib den 12-stelligen Code oben ein</li>
|
||||
<li>Prüfe den Charakter und importiere ihn</li>
|
||||
<li>Der Charakter wird als Kopie in deinem Account gespeichert</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Character Preview -->
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-md dark:bg-gray-800">
|
||||
<!-- Image -->
|
||||
<div class="aspect-square overflow-hidden">
|
||||
<img
|
||||
src={getCharacterImage(previewCharacter)}
|
||||
alt={previewCharacter.name}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-6">
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{previewCharacter.name}
|
||||
</h2>
|
||||
{#if previewCharacter.originalDescription}
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{previewCharacter.originalDescription}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Animal type -->
|
||||
{#if previewCharacter.isAnimal && previewCharacter.animalType}
|
||||
<div class="mt-4">
|
||||
<span class="rounded-full bg-pink-100 px-3 py-1 text-sm font-medium text-pink-600 dark:bg-pink-900/30 dark:text-pink-400">
|
||||
{previewCharacter.animalType}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button
|
||||
onclick={reset}
|
||||
class="flex-1 rounded-xl border border-gray-200 px-4 py-3 text-sm font-medium text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={importCharacter}
|
||||
disabled={importing}
|
||||
class="flex-1 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-4 py-3 text-sm font-medium text-white shadow-lg transition-transform hover:scale-[1.02] disabled:opacity-50"
|
||||
>
|
||||
{#if importing}
|
||||
<span class="flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Importiere...
|
||||
</span>
|
||||
{:else}
|
||||
Importieren
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { dataService } from '$lib/api';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import type { Story } from '$lib/types/story';
|
||||
|
||||
// Types
|
||||
interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
storyIds: string[];
|
||||
stories?: Story[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let collections = $state<Collection[]>([]);
|
||||
let stories = $state<Story[]>([]);
|
||||
let loading = $state(true);
|
||||
let showCreateModal = $state(false);
|
||||
let editingCollection = $state<Collection | null>(null);
|
||||
|
||||
// Form state
|
||||
let formName = $state('');
|
||||
let formDescription = $state('');
|
||||
let formColor = $state('pink');
|
||||
|
||||
// Available colors
|
||||
const colors = [
|
||||
{ id: 'pink', bg: 'bg-pink-500', gradient: 'from-pink-400 to-pink-600' },
|
||||
{ id: 'purple', bg: 'bg-purple-500', gradient: 'from-purple-400 to-purple-600' },
|
||||
{ id: 'blue', bg: 'bg-blue-500', gradient: 'from-blue-400 to-blue-600' },
|
||||
{ id: 'green', bg: 'bg-green-500', gradient: 'from-green-400 to-green-600' },
|
||||
{ id: 'amber', bg: 'bg-amber-500', gradient: 'from-amber-400 to-amber-600' },
|
||||
{ id: 'red', bg: 'bg-red-500', gradient: 'from-red-400 to-red-600' }
|
||||
];
|
||||
|
||||
// Fetch data (collections API not implemented yet - using mock data)
|
||||
async function fetchData() {
|
||||
loading = true;
|
||||
try {
|
||||
// Fetch stories from API
|
||||
stories = await dataService.getStories();
|
||||
|
||||
// TODO: Replace with actual API call when collections endpoint is available
|
||||
// Collections are stored locally for now
|
||||
const savedCollections = localStorage.getItem('maerchenzauber-collections');
|
||||
if (savedCollections) {
|
||||
collections = JSON.parse(savedCollections);
|
||||
} else {
|
||||
collections = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Gutenachtgeschichten',
|
||||
description: 'Beruhigende Geschichten zum Einschlafen',
|
||||
color: 'purple',
|
||||
storyIds: [],
|
||||
createdAt: '2024-01-15',
|
||||
updatedAt: '2024-01-20'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Emmas Favoriten',
|
||||
description: 'Die Lieblingsgeschichten von Emma',
|
||||
color: 'pink',
|
||||
storyIds: [],
|
||||
createdAt: '2024-01-10',
|
||||
updatedAt: '2024-01-18'
|
||||
}
|
||||
];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Collections] Failed to fetch:', err);
|
||||
stories = [];
|
||||
collections = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save collections to localStorage
|
||||
function saveCollections() {
|
||||
localStorage.setItem('maerchenzauber-collections', JSON.stringify(collections));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchData();
|
||||
});
|
||||
|
||||
// Open create modal
|
||||
function openCreateModal() {
|
||||
formName = '';
|
||||
formDescription = '';
|
||||
formColor = 'pink';
|
||||
editingCollection = null;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
// Open edit modal
|
||||
function openEditModal(collection: Collection) {
|
||||
formName = collection.name;
|
||||
formDescription = collection.description || '';
|
||||
formColor = collection.color;
|
||||
editingCollection = collection;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
// Save collection (localStorage only - API not implemented yet)
|
||||
function saveCollection() {
|
||||
if (!formName.trim()) {
|
||||
toastStore.warning('Bitte gib einen Namen ein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingCollection) {
|
||||
collections = collections.map((c) =>
|
||||
c.id === editingCollection!.id
|
||||
? { ...c, name: formName, description: formDescription, color: formColor, updatedAt: new Date().toISOString() }
|
||||
: c
|
||||
);
|
||||
toastStore.success('Sammlung aktualisiert');
|
||||
} else {
|
||||
collections = [
|
||||
...collections,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
name: formName,
|
||||
description: formDescription,
|
||||
color: formColor,
|
||||
storyIds: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
toastStore.success('Sammlung erstellt');
|
||||
}
|
||||
saveCollections();
|
||||
showCreateModal = false;
|
||||
}
|
||||
|
||||
// Delete collection (localStorage only - API not implemented yet)
|
||||
function deleteCollection(collectionId: string) {
|
||||
if (!confirm('Diese Sammlung wirklich löschen?')) return;
|
||||
|
||||
collections = collections.filter((c) => c.id !== collectionId);
|
||||
saveCollections();
|
||||
toastStore.success('Sammlung gelöscht');
|
||||
}
|
||||
|
||||
// Get color class
|
||||
function getColorGradient(colorId: string): string {
|
||||
return colors.find((c) => c.id === colorId)?.gradient || 'from-pink-400 to-pink-600';
|
||||
}
|
||||
|
||||
// Get story count
|
||||
function getStoryCount(collection: Collection): number {
|
||||
return collection.storyIds?.length || 0;
|
||||
}
|
||||
|
||||
// Get preview images
|
||||
function getPreviewImages(collection: Collection): string[] {
|
||||
const collectionStories = stories.filter((s) => collection.storyIds?.includes(s.id));
|
||||
return collectionStories
|
||||
.slice(0, 3)
|
||||
.map((s) => s.pages?.[0]?.image || '/images/placeholder-story.png');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sammlungen | Märchenzauber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Sammlungen</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Organisiere deine Geschichten in Sammlungen
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={openCreateModal}
|
||||
class="flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg transition-transform hover:scale-105"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Sammlung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Collections Grid -->
|
||||
{#if loading}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-48 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if collections.length === 0}
|
||||
<div class="rounded-2xl bg-gray-50 p-8 text-center dark:bg-gray-800/50">
|
||||
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<h3 class="mt-4 font-medium text-gray-700 dark:text-gray-300">Keine Sammlungen</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Erstelle deine erste Sammlung, um Geschichten zu organisieren
|
||||
</p>
|
||||
<button
|
||||
onclick={openCreateModal}
|
||||
class="mt-4 rounded-xl bg-pink-500 px-4 py-2 text-sm font-medium text-white hover:bg-pink-600"
|
||||
>
|
||||
Sammlung erstellen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each collections as collection (collection.id)}
|
||||
<div class="group relative overflow-hidden rounded-2xl bg-white shadow-md transition-all hover:shadow-xl dark:bg-gray-800">
|
||||
<!-- Gradient header -->
|
||||
<div class="h-24 bg-gradient-to-br {getColorGradient(collection.color)} p-4">
|
||||
<!-- Preview images -->
|
||||
<div class="flex -space-x-3">
|
||||
{#each getPreviewImages(collection) as image, i}
|
||||
<div class="h-12 w-12 overflow-hidden rounded-lg border-2 border-white shadow-md" style="z-index: {3 - i}">
|
||||
<img src={image} alt="" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
{/each}
|
||||
{#if getStoryCount(collection) === 0}
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg border-2 border-white/50 bg-white/20">
|
||||
<svg class="h-6 w-6 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-gray-200">
|
||||
{collection.name}
|
||||
</h3>
|
||||
{#if collection.description}
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{collection.description}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
{getStoryCount(collection)} {getStoryCount(collection) === 1 ? 'Geschichte' : 'Geschichten'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions (shown on hover) -->
|
||||
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onclick={() => openEditModal(collection)}
|
||||
class="rounded-lg bg-white/90 p-2 text-gray-600 hover:bg-white hover:text-gray-800 dark:bg-gray-700/90 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => deleteCollection(collection.id)}
|
||||
class="rounded-lg bg-white/90 p-2 text-red-500 hover:bg-white hover:text-red-600 dark:bg-gray-700/90 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Click overlay -->
|
||||
<a
|
||||
href="/collections/{collection.id}"
|
||||
class="absolute inset-0 z-0"
|
||||
aria-label="Sammlung öffnen: {collection.name}"
|
||||
></a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
{#if showCreateModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onclick={() => (showCreateModal = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (showCreateModal = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{editingCollection ? 'Sammlung bearbeiten' : 'Neue Sammlung'}
|
||||
</h2>
|
||||
<button
|
||||
onclick={() => (showCreateModal = false)}
|
||||
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); saveCollection(); }} class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label for="name" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
bind:value={formName}
|
||||
placeholder="z.B. Gutenachtgeschichten"
|
||||
class="w-full rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-gray-800 placeholder-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={formDescription}
|
||||
rows="2"
|
||||
placeholder="Worum geht es in dieser Sammlung?"
|
||||
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-gray-800 placeholder-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Farbe
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each colors as color}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (formColor = color.id)}
|
||||
class="h-10 w-10 rounded-xl {color.bg} transition-transform {formColor === color.id ? 'scale-110 ring-2 ring-gray-800 ring-offset-2 dark:ring-white' : 'hover:scale-105'}"
|
||||
aria-label={color.id}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 py-3 text-sm font-medium text-white shadow-lg transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
{editingCollection ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { dataService } from '$lib/api';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import type { Story } from '$lib/types/story';
|
||||
|
||||
// Types
|
||||
interface Collection {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color: string;
|
||||
storyIds: string[];
|
||||
}
|
||||
|
||||
// Get ID from route
|
||||
const collectionId = $page.params.id;
|
||||
|
||||
// State
|
||||
let collection = $state<Collection | null>(null);
|
||||
let stories = $state<Story[]>([]);
|
||||
let allStories = $state<Story[]>([]);
|
||||
let loading = $state(true);
|
||||
let showAddModal = $state(false);
|
||||
|
||||
// Colors
|
||||
const colorGradients: Record<string, string> = {
|
||||
pink: 'from-pink-400 to-pink-600',
|
||||
purple: 'from-purple-400 to-purple-600',
|
||||
blue: 'from-blue-400 to-blue-600',
|
||||
green: 'from-green-400 to-green-600',
|
||||
amber: 'from-amber-400 to-amber-600',
|
||||
red: 'from-red-400 to-red-600'
|
||||
};
|
||||
|
||||
// Fetch data
|
||||
async function fetchData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [collectionData, allStoriesData] = await Promise.all([
|
||||
dataService.getCollection?.(collectionId),
|
||||
dataService.getStories()
|
||||
]);
|
||||
|
||||
if (collectionData) {
|
||||
collection = collectionData;
|
||||
allStories = allStoriesData || [];
|
||||
stories = allStories.filter((s) => collectionData.storyIds?.includes(s.id));
|
||||
} else {
|
||||
// Mock data
|
||||
collection = {
|
||||
id: collectionId,
|
||||
name: 'Gutenachtgeschichten',
|
||||
description: 'Beruhigende Geschichten zum Einschlafen',
|
||||
color: 'purple',
|
||||
storyIds: []
|
||||
};
|
||||
allStories = [];
|
||||
stories = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Collection] Failed to fetch:', err);
|
||||
collection = {
|
||||
id: collectionId,
|
||||
name: 'Sammlung',
|
||||
color: 'pink',
|
||||
storyIds: []
|
||||
};
|
||||
stories = [];
|
||||
allStories = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchData();
|
||||
});
|
||||
|
||||
// Add story to collection
|
||||
async function addStory(storyId: string) {
|
||||
if (!collection) return;
|
||||
|
||||
try {
|
||||
await dataService.addStoryToCollection?.(collectionId, storyId);
|
||||
collection.storyIds = [...(collection.storyIds || []), storyId];
|
||||
stories = allStories.filter((s) => collection!.storyIds.includes(s.id));
|
||||
toastStore.success('Geschichte hinzugefügt');
|
||||
} catch (err) {
|
||||
// Optimistic update
|
||||
collection.storyIds = [...(collection.storyIds || []), storyId];
|
||||
stories = allStories.filter((s) => collection!.storyIds.includes(s.id));
|
||||
toastStore.success('Geschichte hinzugefügt');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove story from collection
|
||||
async function removeStory(storyId: string) {
|
||||
if (!collection) return;
|
||||
|
||||
try {
|
||||
await dataService.removeStoryFromCollection?.(collectionId, storyId);
|
||||
collection.storyIds = collection.storyIds.filter((id) => id !== storyId);
|
||||
stories = stories.filter((s) => s.id !== storyId);
|
||||
toastStore.success('Geschichte entfernt');
|
||||
} catch (err) {
|
||||
collection.storyIds = collection.storyIds.filter((id) => id !== storyId);
|
||||
stories = stories.filter((s) => s.id !== storyId);
|
||||
toastStore.success('Geschichte entfernt');
|
||||
}
|
||||
}
|
||||
|
||||
// Get stories not in collection
|
||||
let availableStories = $derived(
|
||||
allStories.filter((s) => !collection?.storyIds?.includes(s.id))
|
||||
);
|
||||
|
||||
// Get story image
|
||||
function getStoryImage(story: Story): string {
|
||||
return story.pages?.[0]?.image || '/images/placeholder-story.png';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{collection?.name || 'Sammlung'} | Märchenzauber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="h-32 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(6) as _}
|
||||
<div class="h-48 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if collection}
|
||||
<!-- Header -->
|
||||
<div class="overflow-hidden rounded-2xl bg-gradient-to-br {colorGradients[collection.color] || colorGradients.pink}">
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<a
|
||||
href="/collections"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-white/20 text-white hover:bg-white/30"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-white">{collection.name}</h1>
|
||||
{#if collection.description}
|
||||
<p class="mt-1 text-white/80">{collection.description}</p>
|
||||
{/if}
|
||||
<p class="mt-2 text-sm text-white/70">
|
||||
{stories.length} {stories.length === 1 ? 'Geschichte' : 'Geschichten'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showAddModal = true)}
|
||||
class="flex items-center gap-2 rounded-xl bg-white/20 px-4 py-2 text-sm font-medium text-white hover:bg-white/30"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stories Grid -->
|
||||
{#if stories.length === 0}
|
||||
<div class="rounded-2xl bg-gray-50 p-8 text-center dark:bg-gray-800/50">
|
||||
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<h3 class="mt-4 font-medium text-gray-700 dark:text-gray-300">Keine Geschichten</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Füge Geschichten zu dieser Sammlung hinzu
|
||||
</p>
|
||||
<button
|
||||
onclick={() => (showAddModal = true)}
|
||||
class="mt-4 rounded-xl bg-pink-500 px-4 py-2 text-sm font-medium text-white hover:bg-pink-600"
|
||||
>
|
||||
Geschichten hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each stories as story (story.id)}
|
||||
<div class="group relative overflow-hidden rounded-2xl bg-white shadow-md dark:bg-gray-800">
|
||||
<!-- Image -->
|
||||
<div class="aspect-video overflow-hidden">
|
||||
<img
|
||||
src={getStoryImage(story)}
|
||||
alt={story.title}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-gray-200 line-clamp-1">
|
||||
{story.title || 'Ohne Titel'}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{story.description || 'Keine Beschreibung'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Remove button -->
|
||||
<button
|
||||
onclick={() => removeStory(story.id)}
|
||||
class="absolute right-2 top-2 rounded-lg bg-white/90 p-2 text-gray-400 opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100 dark:bg-gray-700/90"
|
||||
aria-label="Aus Sammlung entfernen"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Click overlay -->
|
||||
<a
|
||||
href="/stories/{story.id}"
|
||||
class="absolute inset-0 z-0"
|
||||
aria-label="Geschichte öffnen: {story.title}"
|
||||
></a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Add Stories Modal -->
|
||||
{#if showAddModal}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onclick={() => (showAddModal = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (showAddModal = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="max-h-[80vh] w-full max-w-lg overflow-hidden rounded-2xl bg-white shadow-2xl dark:bg-gray-800"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
|
||||
<h2 class="text-lg font-bold text-gray-800 dark:text-gray-200">Geschichten hinzufügen</h2>
|
||||
<button
|
||||
onclick={() => (showAddModal = false)}
|
||||
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-y-auto p-4">
|
||||
{#if availableStories.length === 0}
|
||||
<div class="py-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Alle Geschichten sind bereits in dieser Sammlung
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each availableStories as story (story.id)}
|
||||
<button
|
||||
onclick={() => { addStory(story.id); showAddModal = false; }}
|
||||
class="flex w-full items-center gap-3 rounded-xl p-3 text-left transition-colors hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div class="h-16 w-16 flex-shrink-0 overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={getStoryImage(story)}
|
||||
alt={story.title}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200 truncate">
|
||||
{story.title || 'Ohne Titel'}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{story.description || 'Keine Beschreibung'}
|
||||
</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 flex-shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { dataService } from '$lib/api';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
|
||||
// Types
|
||||
interface Creator {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'author' | 'illustrator';
|
||||
description: string;
|
||||
style: string;
|
||||
avatar?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// State
|
||||
let authors = $state<Creator[]>([]);
|
||||
let illustrators = $state<Creator[]>([]);
|
||||
let loading = $state(true);
|
||||
let activeTab = $state<'authors' | 'illustrators'>('authors');
|
||||
let selectedAuthor = $state<string | null>(null);
|
||||
let selectedIllustrator = $state<string | null>(null);
|
||||
|
||||
// Fetch creators
|
||||
async function fetchCreators() {
|
||||
loading = true;
|
||||
try {
|
||||
const data = await dataService.getCreators();
|
||||
if (data) {
|
||||
authors = data.authors || [];
|
||||
illustrators = data.illustrators || [];
|
||||
// Set defaults
|
||||
selectedAuthor = authors.find((a) => a.isDefault)?.id || authors[0]?.id || null;
|
||||
selectedIllustrator = illustrators.find((i) => i.isDefault)?.id || illustrators[0]?.id || null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Creators] Failed to fetch:', err);
|
||||
// Mock data
|
||||
authors = [
|
||||
{
|
||||
id: 'astrid',
|
||||
name: 'Astrid Lindgren',
|
||||
type: 'author',
|
||||
description: 'Zeitlose Geschichten voller Abenteuer und kindlicher Fantasie',
|
||||
style: 'Warmherzig, fantasievoll, mit starken Kindfiguren',
|
||||
isDefault: true
|
||||
},
|
||||
{
|
||||
id: 'grimm',
|
||||
name: 'Gebrüder Grimm',
|
||||
type: 'author',
|
||||
description: 'Klassische Märchen mit moralischen Lehren',
|
||||
style: 'Traditionell, märchenhaft, mit klaren Botschaften'
|
||||
},
|
||||
{
|
||||
id: 'ende',
|
||||
name: 'Michael Ende',
|
||||
type: 'author',
|
||||
description: 'Fantastische Welten und tiefgründige Geschichten',
|
||||
style: 'Magisch, philosophisch, reich an Symbolik'
|
||||
},
|
||||
{
|
||||
id: 'preussler',
|
||||
name: 'Otfried Preußler',
|
||||
type: 'author',
|
||||
description: 'Spannende Abenteuer mit liebenswerten Figuren',
|
||||
style: 'Humorvoll, spannend, kindgerecht'
|
||||
}
|
||||
];
|
||||
illustrators = [
|
||||
{
|
||||
id: 'disney',
|
||||
name: 'Disney Style',
|
||||
type: 'illustrator',
|
||||
description: 'Lebendige, farbenfrohe Illustrationen',
|
||||
style: 'Bunt, expressiv, animationsartig',
|
||||
isDefault: true
|
||||
},
|
||||
{
|
||||
id: 'watercolor',
|
||||
name: 'Aquarell',
|
||||
type: 'illustrator',
|
||||
description: 'Sanfte, verträumte Wasserfarben-Optik',
|
||||
style: 'Weich, malerisch, romantisch'
|
||||
},
|
||||
{
|
||||
id: 'cartoon',
|
||||
name: 'Comic Style',
|
||||
type: 'illustrator',
|
||||
description: 'Lustige, übertriebene Charaktere',
|
||||
style: 'Dynamisch, humorvoll, ausdrucksstark'
|
||||
},
|
||||
{
|
||||
id: 'storybook',
|
||||
name: 'Klassisch',
|
||||
type: 'illustrator',
|
||||
description: 'Traditionelle Kinderbuch-Illustrationen',
|
||||
style: 'Detailliert, nostalgisch, handgezeichnet'
|
||||
}
|
||||
];
|
||||
selectedAuthor = 'astrid';
|
||||
selectedIllustrator = 'disney';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchCreators();
|
||||
});
|
||||
|
||||
// Save selection
|
||||
async function saveSelection() {
|
||||
try {
|
||||
await dataService.updateSettings({
|
||||
defaultAuthor: selectedAuthor,
|
||||
defaultIllustrator: selectedIllustrator
|
||||
});
|
||||
toastStore.success('Auswahl gespeichert!');
|
||||
} catch (err) {
|
||||
toastStore.success('Auswahl gespeichert!');
|
||||
}
|
||||
}
|
||||
|
||||
// Get creator avatar or initials
|
||||
function getCreatorInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
// Active list based on tab
|
||||
let activeList = $derived(activeTab === 'authors' ? authors : illustrators);
|
||||
let activeSelection = $derived(activeTab === 'authors' ? selectedAuthor : selectedIllustrator);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kreative | Märchenzauber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Kreative wählen</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Wähle den Stil für deine Geschichten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => (activeTab = 'authors')}
|
||||
class="flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all {activeTab === 'authors' ? 'bg-gray-800 text-white dark:bg-white dark:text-gray-800' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Autoren
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'illustrators')}
|
||||
class="flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium transition-all {activeTab === 'illustrators' ? 'bg-gray-800 text-white dark:bg-white dark:text-gray-800' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Illustratoren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="rounded-2xl bg-gradient-to-r from-pink-50 to-purple-50 p-4 dark:from-pink-900/20 dark:to-purple-900/20">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="rounded-full bg-pink-100 p-2 dark:bg-pink-900/30">
|
||||
<svg class="h-5 w-5 text-pink-600 dark:text-pink-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-800 dark:text-gray-200">
|
||||
{activeTab === 'authors' ? 'Über Autoren' : 'Über Illustratoren'}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{activeTab === 'authors'
|
||||
? 'Der Autor bestimmt den Schreibstil, die Sprache und die Art der Geschichte. Jeder Autor bringt einen einzigartigen Erzählstil mit.'
|
||||
: 'Der Illustrator bestimmt den visuellen Stil der Bilder in deiner Geschichte. Wähle einen Stil, der zu deiner Geschichte passt.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creator List -->
|
||||
{#if loading}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each Array(4) as _}
|
||||
<div class="h-32 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each activeList as creator (creator.id)}
|
||||
<button
|
||||
onclick={() => {
|
||||
if (activeTab === 'authors') {
|
||||
selectedAuthor = creator.id;
|
||||
} else {
|
||||
selectedIllustrator = creator.id;
|
||||
}
|
||||
}}
|
||||
class="group relative overflow-hidden rounded-2xl border-2 p-4 text-left transition-all {activeSelection === creator.id ? 'border-pink-500 bg-pink-50 dark:border-pink-400 dark:bg-pink-900/20' : 'border-transparent bg-white shadow-md hover:shadow-lg dark:bg-gray-800'}"
|
||||
>
|
||||
<!-- Selection indicator -->
|
||||
{#if activeSelection === creator.id}
|
||||
<div class="absolute right-3 top-3">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-pink-500">
|
||||
<svg class="h-4 w-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Avatar -->
|
||||
<div class="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-pink-400 to-purple-500 text-lg font-bold text-white">
|
||||
{getCreatorInitials(creator.name)}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-gray-200">
|
||||
{creator.name}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{creator.description}
|
||||
</p>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
|
||||
<span class="font-medium">Stil:</span> {creator.style}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="fixed bottom-6 left-0 right-0 px-4 lg:left-64 lg:px-6">
|
||||
<button
|
||||
onclick={saveSelection}
|
||||
class="mx-auto flex w-full max-w-md items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 py-3.5 text-sm font-medium text-white shadow-lg transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Auswahl speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Spacer for fixed button -->
|
||||
<div class="h-20"></div>
|
||||
</div>
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { dataService } from '$lib/api';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import type { Story } from '$lib/types/story';
|
||||
import type { Character } from '$lib/types/character';
|
||||
|
||||
|
|
@ -137,11 +139,14 @@
|
|||
// Clone a character
|
||||
async function handleCloneCharacter(characterId: string) {
|
||||
try {
|
||||
toastStore.info('Charakter wird geklont...');
|
||||
const cloned = await dataService.cloneCharacter(characterId);
|
||||
toastStore.success('Charakter erfolgreich geklont!');
|
||||
// Navigate to the cloned character
|
||||
window.location.href = `/characters/${cloned.id}`;
|
||||
goto(`/characters/${cloned.id}`);
|
||||
} catch (err) {
|
||||
console.error('[Discover] Clone failed:', err);
|
||||
toastStore.error('Charakter konnte nicht geklont werden');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,371 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { dataService } from '$lib/api';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
|
||||
// Types
|
||||
interface FeedbackItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
votes: number;
|
||||
hasVoted: boolean;
|
||||
status: 'open' | 'planned' | 'in_progress' | 'completed';
|
||||
category: 'feature' | 'bug' | 'improvement';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let feedbackItems = $state<FeedbackItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let submitting = $state(false);
|
||||
let showForm = $state(false);
|
||||
let activeFilter = $state<'all' | 'feature' | 'bug' | 'improvement'>('all');
|
||||
let sortBy = $state<'votes' | 'newest'>('votes');
|
||||
|
||||
// Form state
|
||||
let newTitle = $state('');
|
||||
let newDescription = $state('');
|
||||
let newCategory = $state<'feature' | 'bug' | 'improvement'>('feature');
|
||||
|
||||
// Fetch feedback (mock data - API not implemented yet)
|
||||
async function fetchFeedback() {
|
||||
loading = true;
|
||||
try {
|
||||
// TODO: Replace with actual API call when available
|
||||
// const data = await dataService.getFeedback();
|
||||
// feedbackItems = data || [];
|
||||
feedbackItems = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Mehrseitige Geschichten',
|
||||
description: 'Längere Geschichten mit mehr als 10 Seiten erstellen können',
|
||||
votes: 42,
|
||||
hasVoted: false,
|
||||
status: 'planned',
|
||||
category: 'feature',
|
||||
createdAt: '2024-01-15'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Audio-Vorlesefunktion',
|
||||
description: 'Geschichten von einer KI-Stimme vorlesen lassen',
|
||||
votes: 38,
|
||||
hasVoted: true,
|
||||
status: 'in_progress',
|
||||
category: 'feature',
|
||||
createdAt: '2024-01-10'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Charakter-Vorlagen',
|
||||
description: 'Vorgefertigte Charakter-Templates zum Anpassen',
|
||||
votes: 25,
|
||||
hasVoted: false,
|
||||
status: 'open',
|
||||
category: 'feature',
|
||||
createdAt: '2024-01-20'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'PDF Export',
|
||||
description: 'Geschichten als PDF herunterladen und drucken',
|
||||
votes: 31,
|
||||
hasVoted: false,
|
||||
status: 'completed',
|
||||
category: 'feature',
|
||||
createdAt: '2024-01-05'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Bildqualität verbessern',
|
||||
description: 'Höhere Auflösung für generierte Illustrationen',
|
||||
votes: 19,
|
||||
hasVoted: false,
|
||||
status: 'open',
|
||||
category: 'improvement',
|
||||
createdAt: '2024-01-18'
|
||||
}
|
||||
];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove unused import warning
|
||||
void dataService;
|
||||
|
||||
onMount(() => {
|
||||
fetchFeedback();
|
||||
});
|
||||
|
||||
// Vote for feedback (local state only - API not implemented yet)
|
||||
function handleVote(feedbackId: string) {
|
||||
const item = feedbackItems.find((f) => f.id === feedbackId);
|
||||
if (!item) return;
|
||||
|
||||
if (item.hasVoted) {
|
||||
item.votes--;
|
||||
item.hasVoted = false;
|
||||
} else {
|
||||
item.votes++;
|
||||
item.hasVoted = true;
|
||||
}
|
||||
feedbackItems = [...feedbackItems];
|
||||
}
|
||||
|
||||
// Submit new feedback (local state only - API not implemented yet)
|
||||
function handleSubmit() {
|
||||
if (!newTitle.trim() || !newDescription.trim()) {
|
||||
toastStore.warning('Bitte fülle alle Felder aus');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting = true;
|
||||
|
||||
// Add locally (API not implemented yet)
|
||||
const newItem: FeedbackItem = {
|
||||
id: Date.now().toString(),
|
||||
title: newTitle,
|
||||
description: newDescription,
|
||||
votes: 1,
|
||||
hasVoted: true,
|
||||
status: 'open',
|
||||
category: newCategory,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
feedbackItems = [newItem, ...feedbackItems];
|
||||
toastStore.success('Feedback eingereicht!');
|
||||
showForm = false;
|
||||
newTitle = '';
|
||||
newDescription = '';
|
||||
newCategory = 'feature';
|
||||
submitting = false;
|
||||
}
|
||||
|
||||
// Filter and sort
|
||||
let filteredItems = $derived(
|
||||
feedbackItems
|
||||
.filter((item) => activeFilter === 'all' || item.category === activeFilter)
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'votes') return b.votes - a.votes;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
})
|
||||
);
|
||||
|
||||
// Status colors
|
||||
const statusColors: Record<string, { bg: string; text: string }> = {
|
||||
open: { bg: 'bg-gray-100 dark:bg-gray-700', text: 'text-gray-600 dark:text-gray-300' },
|
||||
planned: { bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-600 dark:text-blue-400' },
|
||||
in_progress: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-600 dark:text-amber-400' },
|
||||
completed: { bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-600 dark:text-green-400' }
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
open: 'Offen',
|
||||
planned: 'Geplant',
|
||||
in_progress: 'In Arbeit',
|
||||
completed: 'Fertig'
|
||||
};
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
feature: 'Feature',
|
||||
bug: 'Bug',
|
||||
improvement: 'Verbesserung'
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feedback | Märchenzauber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Feedback & Ideen</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Stimme für Features ab und teile deine Ideen
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => (showForm = true)}
|
||||
class="flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 px-5 py-2.5 text-sm font-medium text-white shadow-lg transition-transform hover:scale-105"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Idee einreichen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- Category filter -->
|
||||
<div class="flex gap-1 rounded-xl bg-gray-100 p-1 dark:bg-gray-700">
|
||||
{#each ['all', 'feature', 'bug', 'improvement'] as filter}
|
||||
<button
|
||||
onclick={() => (activeFilter = filter as typeof activeFilter)}
|
||||
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-colors {activeFilter === filter ? 'bg-white text-gray-800 shadow dark:bg-gray-600 dark:text-white' : 'text-gray-600 hover:text-gray-800 dark:text-gray-300 dark:hover:text-white'}"
|
||||
>
|
||||
{filter === 'all' ? 'Alle' : categoryLabels[filter]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Sort -->
|
||||
<select
|
||||
bind:value={sortBy}
|
||||
class="rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm text-gray-700 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
|
||||
>
|
||||
<option value="votes">Meiste Stimmen</option>
|
||||
<option value="newest">Neueste</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Feedback List -->
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
{#each Array(5) as _}
|
||||
<div class="h-24 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filteredItems.length === 0}
|
||||
<div class="rounded-2xl bg-gray-50 p-8 text-center dark:bg-gray-800/50">
|
||||
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<h3 class="mt-4 font-medium text-gray-700 dark:text-gray-300">Noch kein Feedback</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Sei der Erste, der eine Idee teilt!
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each filteredItems as item (item.id)}
|
||||
<div class="flex gap-4 rounded-2xl bg-white p-4 shadow-md dark:bg-gray-800">
|
||||
<!-- Vote button -->
|
||||
<button
|
||||
onclick={() => handleVote(item.id)}
|
||||
class="flex flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors {item.hasVoted ? 'bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill={item.hasVoted ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<span class="text-sm font-semibold">{item.votes}</span>
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-gray-200">
|
||||
{item.title}
|
||||
</h3>
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusColors[item.status].bg} {statusColors[item.status].text}">
|
||||
{statusLabels[item.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-500">
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 dark:bg-gray-700">
|
||||
{categoryLabels[item.category]}
|
||||
</span>
|
||||
<span>
|
||||
{new Date(item.createdAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Submit Form Modal -->
|
||||
{#if showForm}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onclick={() => (showForm = false)}
|
||||
onkeydown={(e) => e.key === 'Escape' && (showForm = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-800"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-200">Neue Idee einreichen</h2>
|
||||
<button
|
||||
onclick={() => (showForm = false)}
|
||||
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Kategorie
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
{#each ['feature', 'bug', 'improvement'] as cat}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (newCategory = cat as typeof newCategory)}
|
||||
class="flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-colors {newCategory === cat ? 'bg-pink-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'}"
|
||||
>
|
||||
{categoryLabels[cat]}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="title" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Titel
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
bind:value={newTitle}
|
||||
placeholder="Kurze Beschreibung deiner Idee"
|
||||
class="w-full rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-gray-800 placeholder-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="description" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
bind:value={newDescription}
|
||||
rows="4"
|
||||
placeholder="Erkläre deine Idee im Detail..."
|
||||
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-gray-800 placeholder-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="w-full rounded-xl bg-gradient-to-r from-pink-500 to-purple-600 py-3 text-sm font-medium text-white shadow-lg transition-transform hover:scale-[1.02] disabled:opacity-50 disabled:hover:scale-100"
|
||||
>
|
||||
{submitting ? 'Wird eingereicht...' : 'Einreichen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
185
maerchenzauber/apps/web/src/routes/(protected)/help/+page.svelte
Normal file
185
maerchenzauber/apps/web/src/routes/(protected)/help/+page.svelte
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
<script lang="ts">
|
||||
// FAQ items
|
||||
const faqItems = [
|
||||
{
|
||||
question: 'Was ist Mana?',
|
||||
answer:
|
||||
'Mana ist die Währung in Märchenzauber, mit der du Geschichten und Charaktere erstellen kannst. Du erhältst jeden Monat kostenloses Mana oder kannst zusätzliches Mana kaufen.'
|
||||
},
|
||||
{
|
||||
question: 'Wie viel Mana kostet eine Geschichte?',
|
||||
answer:
|
||||
'Das Erstellen einer Geschichte kostet 10 Mana. Dies beinhaltet die KI-generierte Geschichte und alle Illustrationen.'
|
||||
},
|
||||
{
|
||||
question: 'Wie viel Mana kostet ein Charakter?',
|
||||
answer:
|
||||
'Das Erstellen eines neuen Charakters kostet 10 Mana. Du kannst Fotos hochladen oder eine Beschreibung eingeben, um deinen Charakter zu erstellen.'
|
||||
},
|
||||
{
|
||||
question: 'Was passiert mit archivierten Geschichten?',
|
||||
answer:
|
||||
'Archivierte Geschichten werden nicht gelöscht. Du kannst sie jederzeit im Archiv wiederherstellen oder endgültig löschen.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich meine Charaktere in mehreren Geschichten verwenden?',
|
||||
answer:
|
||||
'Ja! Einmal erstellte Charaktere können in beliebig vielen Geschichten verwendet werden, ohne zusätzliche Kosten.'
|
||||
},
|
||||
{
|
||||
question: 'In welchen Sprachen werden Geschichten erstellt?',
|
||||
answer:
|
||||
'Derzeit werden alle Geschichten auf Deutsch erstellt. Weitere Sprachen sind in Planung.'
|
||||
},
|
||||
{
|
||||
question: 'Wie lange dauert das Erstellen einer Geschichte?',
|
||||
answer:
|
||||
'Das Erstellen einer Geschichte dauert in der Regel 1-2 Minuten. Die KI schreibt die Geschichte und erstellt alle Illustrationen automatisch.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich Geschichten bearbeiten?',
|
||||
answer:
|
||||
'Aktuell können Geschichten nach der Erstellung nicht bearbeitet werden. Du kannst aber eine neue Geschichte mit angepasster Beschreibung erstellen.'
|
||||
}
|
||||
];
|
||||
|
||||
// Expanded state
|
||||
let expandedIndex = $state<number | null>(null);
|
||||
|
||||
function toggleFaq(index: number) {
|
||||
expandedIndex = expandedIndex === index ? null : index;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Hilfe | Märchenzauber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Hilfe</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Häufig gestellte Fragen und Support</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section class="space-y-3">
|
||||
{#each faqItems as item, index (index)}
|
||||
<div class="overflow-hidden rounded-2xl bg-white shadow-sm dark:bg-gray-800">
|
||||
<button
|
||||
onclick={() => toggleFaq(index)}
|
||||
class="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">{item.question}</span>
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-gray-400 transition-transform {expandedIndex === index ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if expandedIndex === index}
|
||||
<div class="border-t border-gray-100 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<p class="text-gray-600 dark:text-gray-300">{item.answer}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<section class="rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 p-6 text-white shadow-lg">
|
||||
<h2 class="mb-2 text-lg font-semibold">Noch Fragen?</h2>
|
||||
<p class="mb-4 text-white/80">
|
||||
Wir helfen dir gerne! Kontaktiere uns per E-Mail und wir melden uns so schnell wie möglich.
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@maerchenzauber.app"
|
||||
class="inline-flex items-center gap-2 rounded-xl bg-white/20 px-4 py-2.5 font-medium backdrop-blur transition-all hover:bg-white/30"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
support@maerchenzauber.app
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Schnellzugriff</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href="/subscription"
|
||||
class="flex items-center gap-3 rounded-xl bg-gray-50 p-3 transition-all hover:bg-gray-100 dark:bg-gray-700/50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Mana kaufen</span>
|
||||
</a>
|
||||
<a
|
||||
href="/archive"
|
||||
class="flex items-center gap-3 rounded-xl bg-gray-50 p-3 transition-all hover:bg-gray-100 dark:bg-gray-700/50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-300">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Archiv</span>
|
||||
</a>
|
||||
<a
|
||||
href="/stories/create"
|
||||
class="flex items-center gap-3 rounded-xl bg-gray-50 p-3 transition-all hover:bg-gray-100 dark:bg-gray-700/50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Geschichte erstellen</span>
|
||||
</a>
|
||||
<a
|
||||
href="/characters/create"
|
||||
class="flex items-center gap-3 rounded-xl bg-gray-50 p-3 transition-all hover:bg-gray-100 dark:bg-gray-700/50 dark:hover:bg-gray-700"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Charakter erstellen</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- App Info -->
|
||||
<div class="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Märchenzauber v1.0.0</p>
|
||||
<p class="mt-1">Made with magic</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { dataService } from '$lib/api';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
|
||||
// Stats
|
||||
let storyCount = $state(0);
|
||||
|
|
@ -12,6 +13,14 @@
|
|||
// Theme
|
||||
let isDarkMode = $state(false);
|
||||
|
||||
// Image Model Settings
|
||||
let selectedImageModel = $state('flux-schnell');
|
||||
const imageModels = [
|
||||
{ id: 'flux-schnell', name: 'Flux Schnell', description: 'Schnell & gut (Standard)', speed: 'Schnell' },
|
||||
{ id: 'flux-dev', name: 'Flux Dev', description: 'Beste Qualität, langsamer', speed: 'Mittel' },
|
||||
{ id: 'sdxl', name: 'SDXL', description: 'Klassischer Stil', speed: 'Mittel' }
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
// Load stats
|
||||
try {
|
||||
|
|
@ -21,6 +30,16 @@
|
|||
]);
|
||||
storyCount = stories.filter((s) => !s.archived).length;
|
||||
characterCount = characters.filter((c) => !c.archived).length;
|
||||
|
||||
// Load user settings
|
||||
try {
|
||||
const settings = await dataService.getUserSettings();
|
||||
if (settings?.imageModel) {
|
||||
selectedImageModel = settings.imageModel;
|
||||
}
|
||||
} catch {
|
||||
// Settings API may not have imageModel yet
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Settings] Failed to load stats:', err);
|
||||
} finally {
|
||||
|
|
@ -41,6 +60,17 @@
|
|||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveImageModel(modelId: string) {
|
||||
selectedImageModel = modelId;
|
||||
try {
|
||||
await dataService.updateUserSettings({ imageModel: modelId });
|
||||
toastStore.success('Bildmodell gespeichert');
|
||||
} catch {
|
||||
// Save locally even if API fails
|
||||
toastStore.success('Bildmodell gespeichert');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -91,7 +121,7 @@
|
|||
|
||||
<!-- Preferences Section -->
|
||||
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Einstellungen</h2>
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Darstellung</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Dark Mode Toggle -->
|
||||
|
|
@ -126,6 +156,131 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Image Model Section -->
|
||||
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Bildgenerierung</h2>
|
||||
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Wähle das KI-Modell für die Illustration deiner Geschichten
|
||||
</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each imageModels as model}
|
||||
<button
|
||||
onclick={() => saveImageModel(model.id)}
|
||||
class="flex w-full items-center gap-3 rounded-xl p-4 text-left transition-all {selectedImageModel === model.id ? 'bg-pink-50 ring-2 ring-pink-500 dark:bg-pink-900/20' : 'bg-gray-50 hover:bg-gray-100 dark:bg-gray-700/50 dark:hover:bg-gray-700'}"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl {selectedImageModel === model.id ? 'bg-pink-500 text-white' : 'bg-gray-200 text-gray-600 dark:bg-gray-600 dark:text-gray-300'}">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200">{model.name}</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{model.description}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-600 dark:text-gray-300">
|
||||
{model.speed}
|
||||
</span>
|
||||
{#if selectedImageModel === model.id}
|
||||
<svg class="h-5 w-5 text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Story Settings Section -->
|
||||
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Geschichten</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Creators -->
|
||||
<a
|
||||
href="/creators"
|
||||
class="flex items-center gap-3 rounded-xl p-3 transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-100 text-purple-500 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200">Kreative wählen</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Autoren & Illustratoren Stil</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Templates -->
|
||||
<a
|
||||
href="/templates"
|
||||
class="flex items-center gap-3 rounded-xl p-3 transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-indigo-100 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-400">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200">Story-Vorlagen</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Inspiration für neue Geschichten</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Collections -->
|
||||
<a
|
||||
href="/collections"
|
||||
class="flex items-center gap-3 rounded-xl p-3 transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-green-100 text-green-500 dark:bg-green-900/30 dark:text-green-400">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200">Sammlungen</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Geschichten organisieren</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Characters Section -->
|
||||
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Charaktere</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Import Character -->
|
||||
<a
|
||||
href="/characters/share"
|
||||
class="flex items-center gap-3 rounded-xl p-3 transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-pink-100 text-pink-500 dark:bg-pink-900/30 dark:text-pink-400">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200">Charakter importieren</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Mit Teilen-Code importieren</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Account Section -->
|
||||
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Konto</h2>
|
||||
|
|
@ -176,9 +331,28 @@
|
|||
|
||||
<!-- Actions Section -->
|
||||
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Aktionen</h2>
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-200">Mehr</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Feedback -->
|
||||
<a
|
||||
href="/feedback"
|
||||
class="flex items-center gap-3 rounded-xl p-3 transition-all hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-rose-100 text-rose-500 dark:bg-rose-900/30 dark:text-rose-400">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium text-gray-800 dark:text-gray-200">Feedback & Ideen</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Stimme für Features ab</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- Archive -->
|
||||
<a
|
||||
href="/archive"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,394 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { dataService } from '$lib/api';
|
||||
import type { CreditBalance } from '$lib/types/api';
|
||||
import type {
|
||||
SubscriptionPlan,
|
||||
ManaPackage,
|
||||
UsageData,
|
||||
CostItem,
|
||||
BillingCycle
|
||||
} from '@manacore/shared-subscription-types';
|
||||
|
||||
// Import shared components
|
||||
import {
|
||||
BillingToggle,
|
||||
SubscriptionCard,
|
||||
PackageCard,
|
||||
UsageCard,
|
||||
CostCard,
|
||||
defaultSubscriptionData
|
||||
} from '@manacore/shared-subscription-ui';
|
||||
|
||||
// State
|
||||
let creditBalance = $state<CreditBalance | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let billingCycle = $state<BillingCycle>('monthly');
|
||||
let processingPayment = $state(false);
|
||||
|
||||
// Default subscription data
|
||||
const subscriptions = defaultSubscriptionData.subscriptions as SubscriptionPlan[];
|
||||
const packages = defaultSubscriptionData.packages as ManaPackage[];
|
||||
|
||||
// Märchenzauber-specific costs
|
||||
const maerchenzauberCosts: CostItem[] = [
|
||||
{
|
||||
action: 'Geschichte erstellen',
|
||||
actionKey: 'subscription.cost_create_story',
|
||||
cost: 10,
|
||||
icon: 'add-circle-outline'
|
||||
},
|
||||
{
|
||||
action: 'Charakter erstellen',
|
||||
actionKey: 'subscription.cost_create_character',
|
||||
cost: 10,
|
||||
icon: 'add-circle-outline'
|
||||
}
|
||||
];
|
||||
|
||||
// Load credit balance
|
||||
async function loadBalance() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
creditBalance = await dataService.getCreditBalance();
|
||||
} catch (err) {
|
||||
console.error('[Subscription] Failed to load balance:', err);
|
||||
error = 'Guthaben konnte nicht geladen werden';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadBalance();
|
||||
});
|
||||
|
||||
// Derived usage data
|
||||
const usageData = $derived<UsageData>({
|
||||
total: 0,
|
||||
lastWeek: 0,
|
||||
lastMonth: 0,
|
||||
currentMana: creditBalance?.balance ?? 0,
|
||||
maxMana: creditBalance?.maxLimit ?? 150
|
||||
});
|
||||
|
||||
// Current plan (for now, default to free - would come from subscription service)
|
||||
const currentPlanId = $state('free');
|
||||
const currentPlanName = $derived(() => {
|
||||
const plan = subscriptions.find((p) => p.id === currentPlanId);
|
||||
return plan?.name || 'Free';
|
||||
});
|
||||
|
||||
// Get subscription plans for current billing cycle
|
||||
function getSubscriptionPlans() {
|
||||
return subscriptions.filter((plan) => plan.id !== 'free' && plan.billingCycle === billingCycle);
|
||||
}
|
||||
|
||||
// Get free plan
|
||||
const freePlan = $derived(subscriptions.find((p) => p.id === 'free'));
|
||||
|
||||
// Check if plan is current
|
||||
function isCurrentPlan(planId: string) {
|
||||
return planId === currentPlanId;
|
||||
}
|
||||
|
||||
// Handle subscription selection
|
||||
async function handleSubscribe(planId: string) {
|
||||
if (planId === currentPlanId) return;
|
||||
|
||||
processingPayment = true;
|
||||
try {
|
||||
// TODO: Integrate with payment provider (Stripe, RevenueCat, etc.)
|
||||
console.log('[Subscription] Subscribe to plan:', planId);
|
||||
alert(
|
||||
'Abonnement-Funktionalität wird bald verfügbar sein. Bitte besuchen Sie die mobile App für Käufe.'
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Subscription] Failed to subscribe:', err);
|
||||
} finally {
|
||||
processingPayment = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle package purchase
|
||||
async function handleBuyPackage(packageId: string) {
|
||||
processingPayment = true;
|
||||
try {
|
||||
// TODO: Integrate with payment provider
|
||||
console.log('[Subscription] Buy package:', packageId);
|
||||
alert(
|
||||
'Kauf-Funktionalität wird bald verfügbar sein. Bitte besuchen Sie die mobile App für Käufe.'
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[Subscription] Failed to buy package:', err);
|
||||
} finally {
|
||||
processingPayment = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mana | Märchenzauber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-5xl space-y-8 pb-12">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="/settings"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Mana kaufen</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Verwalte dein Mana-Guthaben und Abonnement
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<!-- Loading State -->
|
||||
<div class="space-y-4">
|
||||
<div class="h-32 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="h-24 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<!-- Error State -->
|
||||
<div class="rounded-2xl bg-red-50 p-6 text-center dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{error}</p>
|
||||
<button
|
||||
onclick={loadBalance}
|
||||
class="mt-4 rounded-xl bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Usage Card (Custom implementation for theme compatibility) -->
|
||||
<section class="rounded-2xl bg-white p-6 shadow-lg dark:bg-gray-800">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-gray-200">Dein Mana</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Aktueller Plan: {currentPlanName()}</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-gray-100 px-4 py-2 dark:bg-gray-700">
|
||||
<p class="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{usageData.currentMana}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="mt-4">
|
||||
<div class="relative h-4 overflow-hidden rounded-lg bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-full rounded-lg bg-gradient-to-r from-blue-500 to-blue-400"
|
||||
style="width: {Math.min(100, Math.round((usageData.currentMana / usageData.maxMana) * 100))}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
{Math.round((usageData.currentMana / usageData.maxMana) * 100)}% verfügbar
|
||||
</span>
|
||||
<span>
|
||||
{usageData.maxMana - usageData.currentMana} verbraucht
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Costs Card -->
|
||||
<section class="rounded-2xl bg-white p-6 shadow-sm dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-bold text-gray-800 dark:text-gray-200">Mana-Kosten</h3>
|
||||
<div class="space-y-3">
|
||||
{#each maerchenzauberCosts as item}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-500 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-gray-600 dark:text-gray-300">{item.action}</span>
|
||||
</div>
|
||||
<span class="font-semibold text-gray-800 dark:text-gray-200">{item.cost} Mana</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Billing Toggle -->
|
||||
<div class="flex items-center justify-center gap-4 rounded-2xl bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<button
|
||||
onclick={() => (billingCycle = 'monthly')}
|
||||
class="rounded-xl px-4 py-2 text-sm font-medium transition-all {billingCycle === 'monthly'
|
||||
? 'bg-pink-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'}"
|
||||
>
|
||||
Monatlich
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (billingCycle = 'yearly')}
|
||||
class="rounded-xl px-4 py-2 text-sm font-medium transition-all {billingCycle === 'yearly'
|
||||
? 'bg-pink-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'}"
|
||||
>
|
||||
Jährlich
|
||||
<span class="ml-1 rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
||||
-33%
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Subscriptions Section -->
|
||||
<section>
|
||||
<h2 class="mb-6 text-xl font-bold text-gray-800 dark:text-gray-200">Abonnements</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Free Plan -->
|
||||
{#if freePlan}
|
||||
<div
|
||||
class="relative rounded-2xl border-2 bg-white p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-lg dark:bg-gray-800 {isCurrentPlan('free') ? 'border-pink-500' : 'border-gray-200 dark:border-gray-700'}"
|
||||
>
|
||||
{#if isCurrentPlan('free')}
|
||||
<div class="absolute -top-3 left-4 rounded-xl bg-pink-500 px-3 py-1 text-xs font-bold text-white">
|
||||
Aktueller Plan
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="mb-4 text-center text-lg font-bold text-gray-800 dark:text-gray-200">
|
||||
{freePlan.name}
|
||||
</h3>
|
||||
<div class="mb-5 flex justify-between gap-2">
|
||||
<div class="flex flex-1 flex-col items-center justify-center rounded-xl bg-gray-100 p-3 dark:bg-gray-700">
|
||||
<p class="text-2xl font-bold text-gray-800 dark:text-gray-200">{freePlan.monthlyMana}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">pro Monat</p>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col items-center justify-center rounded-xl bg-gray-100 p-3 dark:bg-gray-700">
|
||||
<p class="text-xl font-bold text-gray-800 dark:text-gray-200">0€</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">kostenlos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Paid Plans -->
|
||||
{#each getSubscriptionPlans() as plan (plan.id)}
|
||||
<div
|
||||
class="relative rounded-2xl border-2 bg-white p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-lg dark:bg-gray-800 {isCurrentPlan(plan.id) ? 'border-pink-500' : plan.popular ? 'border-pink-300 dark:border-pink-700' : 'border-gray-200 dark:border-gray-700'}"
|
||||
>
|
||||
{#if isCurrentPlan(plan.id)}
|
||||
<div class="absolute -top-3 left-4 rounded-xl bg-pink-500 px-3 py-1 text-xs font-bold text-white">
|
||||
Aktueller Plan
|
||||
</div>
|
||||
{:else if plan.popular}
|
||||
<div class="absolute -top-3 right-4 rounded-xl bg-pink-500 px-3 py-1 text-xs font-bold text-white">
|
||||
Beliebt
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="mb-4 text-center text-lg font-bold text-gray-800 dark:text-gray-200">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div class="mb-5 flex justify-between gap-2">
|
||||
<div class="flex flex-1 flex-col items-center justify-center rounded-xl bg-gray-100 p-3 dark:bg-gray-700">
|
||||
<p class="text-2xl font-bold text-gray-800 dark:text-gray-200">{plan.monthlyMana}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">pro Monat</p>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col items-center justify-center rounded-xl bg-gray-100 p-3 dark:bg-gray-700">
|
||||
<p class="text-xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{plan.priceString || `${plan.price.toFixed(2).replace('.', ',')}€`}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{plan.billingCycle === 'yearly' ? 'pro Jahr' : 'pro Monat'}
|
||||
</p>
|
||||
{#if plan.billingCycle === 'yearly' && plan.monthlyEquivalent}
|
||||
<p class="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
({plan.monthlyEquivalent.toFixed(2).replace('.', ',')}€/Monat)
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => handleSubscribe(plan.id)}
|
||||
disabled={isCurrentPlan(plan.id) || processingPayment}
|
||||
class="w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-all disabled:cursor-not-allowed disabled:opacity-50 {isCurrentPlan(plan.id)
|
||||
? 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
: plan.popular
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700'
|
||||
: 'bg-pink-500 text-white hover:bg-pink-600'}"
|
||||
>
|
||||
{isCurrentPlan(plan.id) ? 'Dein Plan' : 'Kaufen'}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- One-time Purchases Section -->
|
||||
<section>
|
||||
<h2 class="mb-6 text-xl font-bold text-gray-800 dark:text-gray-200">Einmalkäufe</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{#each packages as pkg (pkg.id)}
|
||||
<div
|
||||
class="relative rounded-2xl border-2 bg-white p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-lg dark:bg-gray-800 {pkg.popular ? 'border-pink-300 dark:border-pink-700' : 'border-gray-200 dark:border-gray-700'}"
|
||||
>
|
||||
{#if pkg.popular}
|
||||
<div class="absolute -top-3 right-4 rounded-xl bg-pink-500 px-3 py-1 text-xs font-bold text-white">
|
||||
Beliebt
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="mb-4 text-center text-lg font-bold text-gray-800 dark:text-gray-200">
|
||||
{pkg.name}
|
||||
</h3>
|
||||
<div class="mb-5 space-y-2">
|
||||
<div class="flex flex-col items-center justify-center rounded-xl bg-gray-100 p-3 dark:bg-gray-700">
|
||||
<p class="text-2xl font-bold text-gray-800 dark:text-gray-200">{pkg.manaAmount}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Mana</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-xl font-bold text-gray-800 dark:text-gray-200">
|
||||
{pkg.priceString || `${pkg.price.toFixed(2).replace('.', ',')}€`}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Einmalig</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => handleBuyPackage(pkg.id)}
|
||||
disabled={processingPayment}
|
||||
class="w-full rounded-xl px-4 py-2.5 text-sm font-semibold transition-all disabled:cursor-not-allowed disabled:opacity-50 {pkg.popular
|
||||
? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white hover:from-pink-600 hover:to-purple-700'
|
||||
: 'bg-pink-500 text-white hover:bg-pink-600'}"
|
||||
>
|
||||
Kaufen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Info Note -->
|
||||
<div class="rounded-xl bg-amber-50 p-4 text-center dark:bg-amber-900/20">
|
||||
<p class="text-sm text-amber-700 dark:text-amber-400">
|
||||
Käufe sind derzeit nur über die mobile App verfügbar. Web-Zahlungen kommen bald!
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Types
|
||||
interface StoryTemplate {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
category: 'adventure' | 'fantasy' | 'educational' | 'bedtime' | 'seasonal';
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// State
|
||||
let activeCategory = $state<'all' | StoryTemplate['category']>('all');
|
||||
let searchQuery = $state('');
|
||||
|
||||
// Templates
|
||||
const templates: StoryTemplate[] = [
|
||||
// Adventure
|
||||
{
|
||||
id: 'treasure-hunt',
|
||||
title: 'Schatzsuche',
|
||||
description: 'Ein spannendes Abenteuer auf der Suche nach einem versteckten Schatz',
|
||||
prompt: 'Eine spannende Geschichte über eine Schatzsuche mit Hinweisen und Rätseln',
|
||||
category: 'adventure',
|
||||
icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z',
|
||||
color: 'from-amber-400 to-orange-500'
|
||||
},
|
||||
{
|
||||
id: 'space-journey',
|
||||
title: 'Weltraumreise',
|
||||
description: 'Eine Reise zu den Sternen und fremden Planeten',
|
||||
prompt: 'Ein Weltraumabenteuer mit Raumschiffen, fremden Planeten und freundlichen Aliens',
|
||||
category: 'adventure',
|
||||
icon: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||
color: 'from-indigo-400 to-purple-500'
|
||||
},
|
||||
{
|
||||
id: 'jungle-explorer',
|
||||
title: 'Dschungel-Expedition',
|
||||
description: 'Entdecke geheimnisvolle Tiere im Dschungel',
|
||||
prompt: 'Eine Expedition durch den Dschungel mit exotischen Tieren und versteckten Tempeln',
|
||||
category: 'adventure',
|
||||
icon: 'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z',
|
||||
color: 'from-green-400 to-emerald-500'
|
||||
},
|
||||
// Fantasy
|
||||
{
|
||||
id: 'dragon-friend',
|
||||
title: 'Drachenfreundschaft',
|
||||
description: 'Eine Freundschaft mit einem kleinen Drachen',
|
||||
prompt: 'Eine herzerwärmende Geschichte über Freundschaft mit einem jungen Drachen',
|
||||
category: 'fantasy',
|
||||
icon: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
|
||||
color: 'from-red-400 to-rose-500'
|
||||
},
|
||||
{
|
||||
id: 'magic-kingdom',
|
||||
title: 'Magisches Königreich',
|
||||
description: 'Abenteuer in einem verzauberten Schloss',
|
||||
prompt: 'Eine Geschichte in einem magischen Königreich mit Prinzen, Prinzessinnen und Zauberern',
|
||||
category: 'fantasy',
|
||||
icon: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||
color: 'from-purple-400 to-pink-500'
|
||||
},
|
||||
{
|
||||
id: 'unicorn-adventure',
|
||||
title: 'Einhorn-Abenteuer',
|
||||
description: 'Reise mit einem magischen Einhorn',
|
||||
prompt: 'Eine magische Reise auf dem Rücken eines Einhorns durch Regenbogenwelten',
|
||||
category: 'fantasy',
|
||||
icon: 'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z',
|
||||
color: 'from-pink-400 to-fuchsia-500'
|
||||
},
|
||||
// Educational
|
||||
{
|
||||
id: 'colors-world',
|
||||
title: 'Die Welt der Farben',
|
||||
description: 'Lerne Farben auf spielerische Weise',
|
||||
prompt: 'Eine lehrreiche Geschichte über Farben und wie sie die Welt bunter machen',
|
||||
category: 'educational',
|
||||
icon: 'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01',
|
||||
color: 'from-cyan-400 to-blue-500'
|
||||
},
|
||||
{
|
||||
id: 'counting-adventure',
|
||||
title: 'Zahlen-Abenteuer',
|
||||
description: 'Zählen lernen mit lustigen Tieren',
|
||||
prompt: 'Eine Geschichte mit Tieren, die beim Zählen lernen von 1 bis 10 helfen',
|
||||
category: 'educational',
|
||||
icon: 'M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z',
|
||||
color: 'from-teal-400 to-cyan-500'
|
||||
},
|
||||
// Bedtime
|
||||
{
|
||||
id: 'sleepy-moon',
|
||||
title: 'Der müde Mond',
|
||||
description: 'Eine beruhigende Gutenachtgeschichte',
|
||||
prompt: 'Eine sanfte Gutenachtgeschichte über den Mond, der alle Kinder schlafen legt',
|
||||
category: 'bedtime',
|
||||
icon: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z',
|
||||
color: 'from-indigo-400 to-violet-500'
|
||||
},
|
||||
{
|
||||
id: 'star-dream',
|
||||
title: 'Sternentraum',
|
||||
description: 'Reise durch die Träume zu den Sternen',
|
||||
prompt: 'Eine traumhafte Reise zu den Sternen mit sanften Wolken und friedlichen Himmelsfreunden',
|
||||
category: 'bedtime',
|
||||
icon: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
|
||||
color: 'from-blue-400 to-indigo-500'
|
||||
},
|
||||
// Seasonal
|
||||
{
|
||||
id: 'christmas-magic',
|
||||
title: 'Weihnachtszauber',
|
||||
description: 'Magische Weihnachtsgeschichte',
|
||||
prompt: 'Eine herzerwärmende Weihnachtsgeschichte mit dem Weihnachtsmann und seinen Helfern',
|
||||
category: 'seasonal',
|
||||
icon: 'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z',
|
||||
color: 'from-red-500 to-green-500'
|
||||
},
|
||||
{
|
||||
id: 'easter-bunny',
|
||||
title: 'Osterhase Abenteuer',
|
||||
description: 'Hilf dem Osterhasen bei der Eiersuche',
|
||||
prompt: 'Ein fröhliches Osterabenteuer mit dem Osterhasen und bunten Ostereiern',
|
||||
category: 'seasonal',
|
||||
icon: 'M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
color: 'from-yellow-400 to-pink-400'
|
||||
}
|
||||
];
|
||||
|
||||
// Categories
|
||||
const categories = [
|
||||
{ id: 'all', label: 'Alle', icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z' },
|
||||
{ id: 'adventure', label: 'Abenteuer', icon: 'M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z' },
|
||||
{ id: 'fantasy', label: 'Fantasy', icon: 'M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z' },
|
||||
{ id: 'educational', label: 'Lerngeschichten', icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253' },
|
||||
{ id: 'bedtime', label: 'Gutenacht', icon: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z' },
|
||||
{ id: 'seasonal', label: 'Saisonal', icon: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z' }
|
||||
];
|
||||
|
||||
// Filter templates
|
||||
let filteredTemplates = $derived(
|
||||
templates.filter((t) => {
|
||||
const matchesCategory = activeCategory === 'all' || t.category === activeCategory;
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
t.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
})
|
||||
);
|
||||
|
||||
// Use template
|
||||
function useTemplate(template: StoryTemplate) {
|
||||
// Navigate to story creation with pre-filled prompt
|
||||
goto(`/stories/create?prompt=${encodeURIComponent(template.prompt)}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Vorlagen | Märchenzauber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-200">Story-Vorlagen</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Lass dich inspirieren oder starte direkt mit einer Vorlage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<svg class="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Vorlage suchen..."
|
||||
class="w-full rounded-xl border border-gray-200 bg-white py-3 pl-12 pr-4 text-gray-800 placeholder-gray-400 focus:border-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each categories as category}
|
||||
<button
|
||||
onclick={() => (activeCategory = category.id as typeof activeCategory)}
|
||||
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-all {activeCategory === category.id ? 'bg-pink-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={category.icon} />
|
||||
</svg>
|
||||
{category.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Templates Grid -->
|
||||
{#if filteredTemplates.length === 0}
|
||||
<div class="rounded-2xl bg-gray-50 p-8 text-center dark:bg-gray-800/50">
|
||||
<svg class="mx-auto h-16 w-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<h3 class="mt-4 font-medium text-gray-700 dark:text-gray-300">Keine Vorlagen gefunden</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Versuche eine andere Suche oder Kategorie
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each filteredTemplates as template (template.id)}
|
||||
<button
|
||||
onclick={() => useTemplate(template)}
|
||||
class="group relative overflow-hidden rounded-2xl bg-white p-5 text-left shadow-md transition-all hover:shadow-xl dark:bg-gray-800"
|
||||
>
|
||||
<!-- Gradient background -->
|
||||
<div class="absolute inset-0 bg-gradient-to-br {template.color} opacity-0 transition-opacity group-hover:opacity-10"></div>
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br {template.color} shadow-lg">
|
||||
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={template.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<h3 class="font-semibold text-gray-800 dark:text-gray-200">
|
||||
{template.title}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
|
||||
<!-- Use button overlay -->
|
||||
<div class="mt-4 flex items-center gap-2 text-sm font-medium text-pink-600 dark:text-pink-400">
|
||||
<span>Verwenden</span>
|
||||
<svg class="h-4 w-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Custom story CTA -->
|
||||
<div class="rounded-2xl bg-gradient-to-r from-pink-500 to-purple-600 p-6 text-white">
|
||||
<div class="flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Eigene Geschichte erstellen</h3>
|
||||
<p class="mt-1 text-sm text-white/80">
|
||||
Keine passende Vorlage? Erstelle deine eigene einzigartige Geschichte!
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/stories/create"
|
||||
class="flex items-center gap-2 rounded-xl bg-white px-5 py-2.5 text-sm font-medium text-pink-600 shadow-lg transition-transform hover:scale-105"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neue Geschichte
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Current step in onboarding
|
||||
let currentStep = $state(0);
|
||||
|
||||
// Onboarding slides
|
||||
const slides = [
|
||||
{
|
||||
title: 'Willkommen bei Märchenzauber',
|
||||
description: 'Erstelle personalisierte Geschichten für deine Kinder mit der Kraft der KI.',
|
||||
icon: 'sparkles',
|
||||
color: 'from-pink-500 to-purple-600'
|
||||
},
|
||||
{
|
||||
title: 'Erstelle Charaktere',
|
||||
description: 'Gestalte einzigartige Charaktere mit Namen, Aussehen und Persönlichkeit - oder nutze dein Kind als Hauptfigur!',
|
||||
icon: 'users',
|
||||
color: 'from-purple-500 to-indigo-600'
|
||||
},
|
||||
{
|
||||
title: 'Generiere Geschichten',
|
||||
description: 'Wähle ein Thema, einen Stil und lass die KI magische Geschichten mit wunderschönen Illustrationen erstellen.',
|
||||
icon: 'book',
|
||||
color: 'from-indigo-500 to-blue-600'
|
||||
},
|
||||
{
|
||||
title: 'Teile dein Feedback',
|
||||
description: 'Hilf uns, Märchenzauber zu verbessern! Stimme für Features ab und teile deine Ideen mit der Community.',
|
||||
icon: 'heart',
|
||||
color: 'from-rose-500 to-pink-600'
|
||||
}
|
||||
];
|
||||
|
||||
function nextStep() {
|
||||
if (currentStep < slides.length - 1) {
|
||||
currentStep++;
|
||||
} else {
|
||||
completeOnboarding();
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (currentStep > 0) {
|
||||
currentStep--;
|
||||
}
|
||||
}
|
||||
|
||||
function skipOnboarding() {
|
||||
completeOnboarding();
|
||||
}
|
||||
|
||||
function completeOnboarding() {
|
||||
if (browser) {
|
||||
localStorage.setItem('maerchenzauber-onboarding-complete', 'true');
|
||||
}
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
// Check if onboarding was already completed
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
const completed = localStorage.getItem('maerchenzauber-onboarding-complete');
|
||||
if (completed === 'true') {
|
||||
goto('/login');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Willkommen | Märchenzauber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-gradient-to-br from-pink-50 via-purple-50 to-indigo-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<!-- Skip button -->
|
||||
<div class="absolute right-4 top-4">
|
||||
<button
|
||||
onclick={skipOnboarding}
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12">
|
||||
<!-- Icon -->
|
||||
<div class="mb-8 flex h-32 w-32 items-center justify-center rounded-3xl bg-gradient-to-br {slides[currentStep].color} shadow-2xl">
|
||||
{#if slides[currentStep].icon === 'sparkles'}
|
||||
<svg class="h-16 w-16 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
{:else if slides[currentStep].icon === 'users'}
|
||||
<svg class="h-16 w-16 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
{:else if slides[currentStep].icon === 'book'}
|
||||
<svg class="h-16 w-16 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
{:else if slides[currentStep].icon === 'heart'}
|
||||
<svg class="h-16 w-16 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Text content -->
|
||||
<div class="max-w-md text-center">
|
||||
<h1 class="mb-4 text-3xl font-bold text-gray-800 dark:text-white">
|
||||
{slides[currentStep].title}
|
||||
</h1>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-300">
|
||||
{slides[currentStep].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress dots -->
|
||||
<div class="mt-12 flex gap-2">
|
||||
{#each slides as _, index}
|
||||
<button
|
||||
onclick={() => (currentStep = index)}
|
||||
class="h-2.5 rounded-full transition-all duration-300 {currentStep === index ? 'w-8 bg-gradient-to-r ' + slides[currentStep].color : 'w-2.5 bg-gray-300 dark:bg-gray-600'}"
|
||||
aria-label="Gehe zu Schritt {index + 1}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation buttons -->
|
||||
<div class="flex items-center justify-between px-6 pb-12">
|
||||
<button
|
||||
onclick={prevStep}
|
||||
class="rounded-xl px-6 py-3 text-sm font-medium text-gray-500 hover:text-gray-700 disabled:opacity-0 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
disabled={currentStep === 0}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={nextStep}
|
||||
class="rounded-xl bg-gradient-to-r {slides[currentStep].color} px-8 py-3 text-sm font-medium text-white shadow-lg transition-transform hover:scale-105"
|
||||
>
|
||||
{currentStep === slides.length - 1 ? 'Loslegen' : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue