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

@ -20,6 +20,7 @@
"@sveltejs/adapter-netlify": "^5.2.4",
"@sveltejs/kit": "^2.15.7",
"@sveltejs/vite-plugin-svelte": "^5.0.4",
"@tailwindcss/postcss": "^4.1.17",
"@types/node": "^22.10.5",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.20",
@ -41,10 +42,12 @@
"@manacore/shared-config": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-supabase": "workspace:*",
"@manacore/shared-subscription-types": "workspace:*",
"@manacore/shared-subscription-ui": "workspace:*",
"@manacore/shared-supabase": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
'@tailwindcss/postcss': {}
}
};

View file

@ -1,3 +1,5 @@
@import '@manacore/shared-tailwind/themes.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@ -7,35 +9,39 @@
color-scheme: light dark;
}
* {
@apply border-gray-200 dark:border-gray-700;
}
body {
@apply bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100;
}
h1 {
@apply text-4xl font-bold;
font-size: 2.25rem;
line-height: 2.5rem;
font-weight: 700;
}
h2 {
@apply text-3xl font-bold;
font-size: 1.875rem;
line-height: 2.25rem;
font-weight: 700;
}
h3 {
@apply text-2xl font-semibold;
font-size: 1.5rem;
line-height: 2rem;
font-weight: 600;
}
h4 {
@apply text-xl font-semibold;
font-size: 1.25rem;
line-height: 1.75rem;
font-weight: 600;
}
h5 {
@apply text-lg font-medium;
font-size: 1.125rem;
line-height: 1.75rem;
font-weight: 500;
}
h6 {
@apply text-base font-medium;
font-size: 1rem;
line-height: 1.5rem;
font-weight: 500;
}
}

View file

@ -1,20 +1,7 @@
<script lang="ts">
interface App {
name: string;
description: string;
longDescription: string;
icon: string;
color: string;
comingSoon?: boolean;
status: 'published' | 'beta' | 'development' | 'planning';
}
import { AppSlider, type AppItem } from '@manacore/shared-ui';
let selectedApp = $state<number | null>(null);
let hoveredApp = $state<number | null>(null);
let cardRotations = $state<{ [key: number]: { rotateX: number; rotateY: number } }>({});
let modalScrollContainer = $state<HTMLDivElement | null>(null);
const apps: App[] = [
const apps: AppItem[] = [
{
name: 'Memoro',
description: 'AI Voice Memos',
@ -53,212 +40,14 @@
}
];
function getStatusColor(status: App['status']) {
const colors = {
published: '#4CAF50',
beta: '#FFD700',
development: '#FF9800',
planning: '#F44336'
};
return colors[status];
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
function getStatusLabel(status: App['status']) {
const labels = {
published: 'Live',
beta: 'Beta',
development: 'In Development',
planning: 'Planned'
};
return labels[status];
}
function openModal(index: number) {
selectedApp = index;
}
function closeModal() {
selectedApp = null;
}
function handleCardMouseMove(e: MouseEvent, index: number, cardElement: HTMLElement) {
const rect = cardElement.getBoundingClientRect();
const cardCenterX = rect.left + rect.width / 2;
const cardCenterY = rect.top + rect.height / 2;
const mouseXRelative = e.clientX - cardCenterX;
const mouseYRelative = e.clientY - cardCenterY;
const maxRotation = 3;
const rotateY = (mouseXRelative / (rect.width / 2)) * maxRotation;
const rotateX = -(mouseYRelative / (rect.height / 2)) * maxRotation;
cardRotations[index] = { rotateX, rotateY };
}
function handleCardMouseLeave(index: number) {
cardRotations[index] = { rotateX: 0, rotateY: 0 };
}
$effect(() => {
if (selectedApp !== null && modalScrollContainer) {
const appIndex = selectedApp;
setTimeout(() => {
const cardWidth = 360 + 24;
const scrollPosition = appIndex * cardWidth;
modalScrollContainer?.scrollTo({
left: scrollPosition,
behavior: 'smooth'
});
}, 50);
}
});
</script>
<div class="w-full">
<h3 class="mb-4 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
Part of the Mana Ecosystem
</h3>
<div class="relative">
<div class="flex gap-4 justify-center overflow-x-auto pb-6 scrollbar-hide snap-x snap-mandatory scroll-smooth px-4 py-4" style="perspective: 1000px;">
{#each apps as app, index}
<button
class="group relative flex-shrink-0 rounded-2xl p-5 cursor-pointer snap-center border transition-all"
class:bg-gray-100={hoveredApp !== index}
class:dark:bg-gray-800={hoveredApp !== index}
class:bg-gray-200={hoveredApp === index}
class:dark:bg-gray-700={hoveredApp === index}
style="width: 160px; border-color: rgba(0, 0, 0, 0.1); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); transform: perspective(1000px) rotateX({cardRotations[index]?.rotateX || 0}deg) rotateY({cardRotations[index]?.rotateY || 0}deg); transform-style: preserve-3d; transition: transform 0.1s ease-out, background-color 0.2s ease-out;"
onmouseenter={() => hoveredApp = index}
onmousemove={(e) => handleCardMouseMove(e, index, e.currentTarget)}
onmouseleave={() => { handleCardMouseLeave(index); hoveredApp = null; }}
onclick={() => openModal(index)}
>
<div
class="absolute top-3 right-3 w-3 h-3 rounded-full status-indicator"
style="background-color: {getStatusColor(app.status)}; box-shadow: 0 0 8px {getStatusColor(app.status)};"
></div>
<div class="mb-2 flex h-20 w-20 mx-auto items-center justify-center rounded-xl transition-transform group-hover:scale-110">
<img src={app.icon} alt={app.name} class="w-16 h-16 object-contain" />
</div>
<h4 class="text-base font-semibold text-center text-gray-900 dark:text-white">
{app.name}
</h4>
</button>
{/each}
</div>
</div>
</div>
{#if selectedApp !== null}
<div
class="fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.85);"
onclick={closeModal}
onkeydown={(e) => e.key === 'Escape' && closeModal()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<button
onclick={closeModal}
class="absolute top-6 right-6 rounded-full p-2 transition-all hover:bg-white/10 z-10"
aria-label="Close modal"
>
<svg class="h-8 w-8 text-white" 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
bind:this={modalScrollContainer}
class="absolute inset-0 flex items-center overflow-x-auto scrollbar-hide snap-x snap-mandatory scroll-smooth"
>
<div class="flex gap-6 px-8 py-8 mx-auto" style="perspective: 1000px;">
{#each apps as app, index}
<div
class="flex-shrink-0 rounded-3xl p-8 snap-center shadow-2xl relative"
class:bg-gray-100={hoveredApp !== index}
class:dark:bg-gray-800={hoveredApp !== index}
class:bg-gray-200={hoveredApp === index}
class:dark:bg-gray-700={hoveredApp === index}
style="min-width: 360px; max-width: 360px; border: 3px solid {app.color}40; transform: perspective(1000px) rotateX({cardRotations[index]?.rotateX || 0}deg) rotateY({cardRotations[index]?.rotateY || 0}deg); transform-style: preserve-3d; transition: transform 0.1s ease-out, background-color 0.2s ease-out;"
onclick={(e) => { e.stopPropagation(); selectedApp = index; }}
onmouseenter={() => hoveredApp = index}
onmousemove={(e) => handleCardMouseMove(e, index, e.currentTarget)}
onmouseleave={() => { handleCardMouseLeave(index); hoveredApp = null; }}
onkeydown={() => {}}
role="button"
tabindex="0"
>
<div class="absolute top-4 right-4 flex items-center gap-2">
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">
{getStatusLabel(app.status)}
</span>
<div
class="w-4 h-4 rounded-full status-indicator"
style="background-color: {getStatusColor(app.status)}; box-shadow: 0 0 12px {getStatusColor(app.status)};"
></div>
</div>
<img src={app.icon} alt={app.name} class="w-28 h-28 object-contain mb-3 mx-auto" />
<h3 class="text-2xl font-bold mb-2 text-center text-gray-900 dark:text-white">
{app.name}
</h3>
<p class="text-sm mb-4 text-center font-medium" style="color: {app.color};">
{app.description}
</p>
<p class="text-sm leading-relaxed mb-6 text-center text-gray-600 dark:text-gray-300">
{app.longDescription}
</p>
<div class="text-center">
{#if app.comingSoon}
<div class="inline-block rounded-full px-5 py-2.5 text-sm font-medium bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400">
Coming Soon
</div>
{:else}
<button
class="rounded-xl px-8 py-3 text-sm font-semibold transition-all hover:opacity-80 border-2 text-white"
style="background-color: {app.color}60; border-color: {app.color};"
>
Open App
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.status-indicator {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
</style>
<AppSlider
{apps}
title="Part of the Mana Ecosystem"
isDark={false}
onAppClick={handleAppClick}
/>

View file

@ -1,57 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit' | 'reset';
class?: string;
onclick?: () => void;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
type = 'button',
class: className = '',
onclick,
children
}: Props = $props();
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
ghost: 'text-gray-700 hover:bg-gray-100 focus:ring-gray-500 dark:text-gray-300 dark:hover:bg-gray-800'
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
};
const classes = `${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`;
</script>
<button
{type}
class={classes}
disabled={disabled || loading}
onclick={onclick}
>
{#if loading}
<svg class="mr-2 h-4 w-4 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>
{/if}
{@render children()}
</button>

View file

@ -1,14 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class="rounded-lg border bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800 {className}">
{@render children()}
</div>

View file

@ -1,46 +0,0 @@
<script lang="ts">
interface Props {
type?: string;
name?: string;
id?: string;
placeholder?: string;
value?: string;
required?: boolean;
disabled?: boolean;
class?: string;
autocomplete?: 'email' | 'current-password' | 'new-password' | 'username' | 'off' | string;
oninput?: (event: Event) => void;
minlength?: number;
maxlength?: number;
}
let {
type = 'text',
name,
id,
placeholder,
value = $bindable(''),
required = false,
disabled = false,
class: className = '',
autocomplete,
oninput,
minlength,
maxlength
}: Props = $props();
</script>
<input
{type}
{name}
{id}
{placeholder}
{required}
{disabled}
{minlength}
{maxlength}
autocomplete={autocomplete as any}
bind:value
oninput={oninput}
class="block w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-gray-900 placeholder-gray-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-400 {className}"
/>

View file

@ -16,6 +16,7 @@
{ name: 'Dashboard', href: '/dashboard' },
{ name: 'Organizations', href: '/organizations' },
{ name: 'Teams', href: '/teams' },
{ name: 'Subscription', href: '/subscription' },
{ name: 'Settings', href: '/settings' }
];

View file

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

View file

@ -1,6 +1,5 @@
<script lang="ts">
import Card from '$lib/components/ui/Card.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { Card, Button } from '@manacore/shared-ui';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();

View file

@ -1,7 +1,5 @@
<script lang="ts">
import Card from '$lib/components/ui/Card.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import { Card, Button, Input } from '@manacore/shared-ui';
import { enhance } from '$app/forms';
let { data, form } = $props();

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="ManaCore"
onSubscribe={handleSubscribe}
onBuyPackage={handleBuyPackage}
currentPlanId="free"
/>

View file

@ -1,6 +1,5 @@
<script lang="ts">
import Card from '$lib/components/ui/Card.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { Card, Button } from '@manacore/shared-ui';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();

View file

@ -1,8 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Card from '$lib/components/ui/Card.svelte';
import { Button, Input, Card } from '@manacore/shared-ui';
let { form } = $props();
let loading = $state(false);

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import ManaCoreLogo from '$lib/components/ManaCoreLogo.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="#f3f4f6"
darkBackground="#121212"
/>
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

View file

@ -3,9 +3,7 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Card from '$lib/components/ui/Card.svelte';
import { Button, Input, Card } from '@manacore/shared-ui';
let { form } = $props();
let loading = $state(false);

View file

@ -3,9 +3,7 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/Button.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Card from '$lib/components/ui/Card.svelte';
import { Button, Input, Card } from '@manacore/shared-ui';
let { form, data } = $props();
let loading = $state(false);