feat: unify UI components, AppSlider, and login screens across apps

SUMMARY:
Consolidate shared UI components into @manacore/shared-ui and add
AppSlider to all login screens for a consistent Mana ecosystem experience.

CHANGES:

1. UI Components Migration:
   - Added Card.svelte to @manacore/shared-ui with variants (elevated, outlined, ghost)
   - Migrated Manacore (7 files) and Manadeck (7 files) to use shared-ui
   - Removed local ui/ directories from both apps (8 components total)

2. AppSlider Unification:
   - Created shared AppSlider in @manacore/shared-ui with configurable props
   - Props: apps[], title, isDark, statusLabels, comingSoonLabel, openAppLabel, onAppClick
   - Supports both i18n and static text configurations
   - Updated Memoro AppSlider to use shared component with svelte-i18n
   - Updated Manacore AppSlider to use shared component
   - Created new AppSlider for ManaDeck and Märchenzauber

3. Login Page Enhancements:
   - Extended LoginPage in @manacore/shared-auth-ui with new snippets:
     - appSlider: Renders AppSlider at bottom (initial mode only)
     - headerControls: Renders controls (theme toggle, etc.) top-right
   - Updated all app login pages to include AppSlider:
     - ManaCore: indigo theme (#6366f1)
     - ManaDeck: violet theme (#8b5cf6)
     - Märchenzauber: pink theme (#FF6B9D)

4. Subscription Page Consolidation:
   - Created SubscriptionPage component in @manacore/shared-subscription-ui
   - Moved subscription data (plans, packages, costs) to shared package
   - Reduced subscription page code from ~100 to ~18 lines per app

FILES CHANGED:
- packages/shared-ui: Added Card, AppSlider, updated exports
- packages/shared-auth-ui: Extended LoginPage with snippets
- packages/shared-subscription-ui: Added SubscriptionPage, data files
- manacore/web: Migrated 7 files to shared-ui, updated login
- manadeck/web: Migrated 7 files to shared-ui, added AppSlider, updated login
- maerchenzauber/web: Added AppSlider, updated login
- memoro/web: Updated AppSlider to use shared component

DELETED (moved to shared packages):
- manacore/web/src/lib/components/ui/* (3 files)
- manadeck/web/src/lib/components/ui/* (5 files)
- memoro/web/src/lib/data/*.json (3 files)
- Various pnpm-lock.yaml and pnpm-workspace.yaml files

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-24 21:53:44 +01:00
parent 96e0aceb93
commit 22cb7d2c5f
67 changed files with 894 additions and 22131 deletions

View file

@ -1,55 +1,5 @@
@import '@manacore/shared-tailwind/themes.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--surface: 0 0% 98%;
--surface-elevated: 0 0% 100%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--surface: 217.2 32.6% 17.5%;
--surface-elevated: 222.2 47.4% 11.2%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View file

@ -0,0 +1,53 @@
<script lang="ts">
import { AppSlider, type AppItem } from '@manacore/shared-ui';
const apps: AppItem[] = [
{
name: 'Memoro',
description: 'AI Voice Memos',
longDescription: 'Transform your voice recordings into organized, searchable notes with AI-powered transcription and insights.',
icon: '/images/app-icons/memoro-logo-gradient.png',
color: '#f8d62b',
comingSoon: false,
status: 'published'
},
{
name: 'ManaDeck',
description: 'AI Flashcards',
longDescription: 'Create and study smart flashcards with AI-powered spaced repetition for efficient learning.',
icon: '/images/app-icons/manadeck-logo-gradient.png',
color: '#8b5cf6',
comingSoon: false,
status: 'development'
},
{
name: 'Märchenzauber',
description: 'AI Story Creator',
longDescription: 'Create magical personalized stories for children with AI-generated illustrations and consistent characters.',
icon: '/images/app-icons/maerchenzauber-logo-gradient.png',
color: '#FF6B9D',
comingSoon: true,
status: 'beta'
},
{
name: 'Manacore',
description: 'Central Hub',
longDescription: 'Your central hub for managing all Mana applications, subscriptions, and account settings.',
icon: '/images/app-icons/manacore-logo-gradient.png',
color: '#6366f1',
comingSoon: true,
status: 'development'
}
];
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<AppSlider
{apps}
title="Part of the Mana Ecosystem"
isDark={false}
onAppClick={handleAppClick}
/>

View file

@ -1,7 +1,5 @@
<script lang="ts">
import Modal from '$lib/components/ui/Modal.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { Modal, Input, Button } from '@manacore/shared-ui';
import { deckStore } from '$lib/stores/deckStore.svelte';
interface Props {

View file

@ -1,7 +1,6 @@
<script lang="ts">
import type { Deck } from '$lib/types/deck';
import Card from '$lib/components/ui/Card.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import { Card, Badge } from '@manacore/shared-ui';
interface Props {
deck: Deck;

View file

@ -7,6 +7,7 @@
{ label: 'Decks', href: '/decks', icon: '📚' },
{ label: 'Explore', href: '/explore', icon: '🔍' },
{ label: 'Progress', href: '/progress', icon: '📊' },
{ label: 'Mana', href: '/subscription', icon: '⚡' },
{ label: 'Profile', href: '/profile', icon: '👤' }
];

View file

@ -1,28 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
variant?: 'default' | 'secondary' | 'outline' | 'destructive';
children: Snippet;
class?: string;
}
let {
variant = 'default',
children,
class: className = ''
}: Props = $props();
const variantClasses = {
default: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
outline: 'border border-border',
destructive: 'bg-destructive text-destructive-foreground'
};
const classes = `inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors ${variantClasses[variant]} ${className}`;
</script>
<span class={classes}>
{@render children()}
</span>

View file

@ -1,76 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
onclick?: (e: MouseEvent) => void;
children: Snippet;
class?: string;
}
let {
variant = 'primary',
size = 'md',
type = 'button',
disabled = false,
loading = false,
fullWidth = false,
onclick,
children,
class: className = ''
}: Props = $props();
const baseClasses = 'inline-flex items-center justify-center font-medium transition-colors rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none';
const variantClasses = {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
};
const sizeClasses = {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 py-2',
lg: 'h-11 px-8 text-lg'
};
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${fullWidth ? 'w-full' : ''} ${className}`;
</script>
<button
{type}
class={classes}
disabled={disabled || loading}
onclick={onclick}
>
{#if loading}
<svg
class="mr-2 h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
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>
{/if}
{@render children()}
</button>

View file

@ -1,38 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
variant?: 'elevated' | 'outlined' | 'ghost';
padding?: 'none' | 'sm' | 'md' | 'lg';
children: Snippet;
class?: string;
onclick?: (e: MouseEvent) => void;
}
let {
variant = 'elevated',
padding = 'md',
children,
class: className = '',
onclick
}: Props = $props();
const variantClasses = {
elevated: 'bg-surface-elevated shadow-md',
outlined: 'bg-surface border border-border',
ghost: 'bg-transparent'
};
const paddingClasses = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8'
};
const classes = `rounded-lg ${variantClasses[variant]} ${paddingClasses[padding]} ${className}`;
</script>
<div class={classes} {onclick} role={onclick ? 'button' : undefined} tabindex={onclick ? 0 : undefined}>
{@render children()}
</div>

View file

@ -1,71 +0,0 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props {
type?: 'text' | 'email' | 'password' | 'number' | 'search';
value?: string;
placeholder?: string;
label?: string;
error?: string;
disabled?: boolean;
required?: boolean;
autocomplete?: HTMLInputAttributes['autocomplete'];
oninput?: (e: Event & { currentTarget: HTMLInputElement }) => void;
class?: string;
id?: string;
}
let {
type = 'text',
value = $bindable(''),
placeholder = '',
label,
error,
disabled = false,
required = false,
autocomplete,
oninput,
class: className = '',
id
}: Props = $props();
// Generate unique ID if not provided
const inputId = id || `input-${Math.random().toString(36).substring(2, 9)}`;
const inputClasses = `
flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm
ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium
placeholder:text-muted-foreground
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
disabled:cursor-not-allowed disabled:opacity-50
${error ? 'border-destructive' : ''}
${className}
`;
</script>
<div class="space-y-2">
{#if label}
<label for={inputId} class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{label}
{#if required}
<span class="text-destructive">*</span>
{/if}
</label>
{/if}
<input
id={inputId}
{type}
bind:value
{placeholder}
{disabled}
{required}
autocomplete={autocomplete}
{oninput}
class={inputClasses}
/>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
</div>

View file

@ -1,55 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Button from './Button.svelte';
interface Props {
open?: boolean;
title?: string;
onClose?: () => void;
children: Snippet;
}
let { open = $bindable(false), title, onClose, children }: Props = $props();
function handleClose() {
open = false;
onClose?.();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose();
}
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<div class="bg-surface-elevated rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-auto">
<!-- Header -->
{#if title}
<div class="flex items-center justify-between p-6 border-b border-border">
<h2 class="text-xl font-semibold">{title}</h2>
<button
onclick={handleClose}
class="text-muted-foreground hover:text-foreground transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
<!-- Content -->
<div class="p-6">
{@render children()}
</div>
</div>
</div>
{/if}

View file

@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { deckStore } from '$lib/stores/deckStore.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { Button } from '@manacore/shared-ui';
import DeckCard from '$lib/components/deck/DeckCard.svelte';
import CreateDeckModal from '$lib/components/deck/CreateDeckModal.svelte';

View file

@ -3,9 +3,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { deckStore } from '$lib/stores/deckStore.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Badge from '$lib/components/ui/Badge.svelte';
import Card from '$lib/components/ui/Card.svelte';
import { Button, Badge, Card } from '@manacore/shared-ui';
let deckId = $derived($page.params.id);
let showDeleteConfirm = $state(false);

View file

@ -1,5 +1,5 @@
<script lang="ts">
import Card from '$lib/components/ui/Card.svelte';
import { Card } from '@manacore/shared-ui';
</script>
<svelte:head>

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { authStore } from '$lib/stores/authStore.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { Card, Button } from '@manacore/shared-ui';
let credits = $state<number | null>(null);
let loadingCredits = $state(false);

View file

@ -1,5 +1,5 @@
<script lang="ts">
import Card from '$lib/components/ui/Card.svelte';
import { Card } from '@manacore/shared-ui';
</script>
<svelte:head>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
function handleSubscribe(planId: string) {
alert(`Subscribe to plan: ${planId}\n\nThis would trigger RevenueCat purchase flow.`);
}
function handleBuyPackage(packageId: string) {
alert(`Buy package: ${packageId}\n\nThis would trigger RevenueCat purchase flow.`);
}
</script>
<SubscriptionPage
appName="ManaDeck"
onSubscribe={handleSubscribe}
onBuyPackage={handleBuyPackage}
currentPlanId="free"
/>

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import ManaDeckLogo from '$lib/components/ManaDeckLogo.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
async function handleSignIn(email: string, password: string) {
@ -26,4 +27,8 @@
registerPath="/register"
lightBackground="#faf5ff"
darkBackground="#1a1625"
/>
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>