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

109
docs/DEVELOPMENT_SCRIPTS.md Normal file
View file

@ -0,0 +1,109 @@
# Development Scripts
Diese Dokumentation beschreibt die verfügbaren Entwicklungsbefehle im Manacore Monorepo.
## Übersicht
Das Monorepo nutzt [Turborepo](https://turbo.build/) für parallele Builds und intelligentes Caching. Alle Befehle werden über `pnpm` ausgeführt.
## Globale Befehle
| Befehl | Beschreibung |
|--------|--------------|
| `pnpm dev` | Startet alle Apps (Web, Mobile, Landing, Backend) |
| `pnpm build` | Baut alle Packages und Apps |
| `pnpm test` | Führt alle Tests aus |
| `pnpm lint` | Führt Linting für alle Packages aus |
| `pnpm type-check` | TypeScript-Typprüfung für alle Packages |
| `pnpm clean` | Bereinigt Build-Artefakte |
| `pnpm format` | Formatiert alle Dateien mit Prettier |
| `pnpm format:check` | Prüft Formatierung ohne Änderungen |
## App-Typ Befehle
Diese Befehle starten alle Apps eines bestimmten Typs gleichzeitig:
| Befehl | Beschreibung | Apps |
|--------|--------------|------|
| `pnpm dev:web` | Startet alle Web-Apps | maerchenzauber, manacore, manadeck, memoro |
| `pnpm dev:landing` | Startet alle Landing Pages | maerchenzauber, manacore, manadeck, memoro |
| `pnpm dev:mobile` | Startet alle Mobile-Apps | maerchenzauber, manacore, manadeck, memoro |
### Beispiel
```bash
# Alle Web-Apps starten (SvelteKit)
pnpm dev:web
# Alle Landing Pages starten (Astro)
pnpm dev:landing
# Alle Mobile-Apps starten (Expo)
pnpm dev:mobile
```
## Projekt-spezifische Befehle
Diese Befehle starten ein komplettes Projekt mit allen zugehörigen Apps und Dependencies:
| Befehl | Beschreibung |
|--------|--------------|
| `pnpm maerchenzauber:dev` | Startet Maerchenzauber (Backend, Web, Mobile, Landing) |
| `pnpm manacore:dev` | Startet Manacore (Web, Mobile, Landing) |
| `pnpm manadeck:dev` | Startet Manadeck (Web, Mobile, Landing) |
| `pnpm memoro:dev` | Startet Memoro (Web, Mobile, Landing) |
## Turbo Filter
Für erweiterte Kontrolle kannst du Turbo-Filter direkt verwenden:
```bash
# Einzelne App starten
pnpm turbo run dev --filter=@storyteller/web
# Mehrere Apps kombinieren
pnpm turbo run dev --filter=manacore-web --filter=memoro-web
```
### Package-Namen Referenz
Da die Package-Namen im Monorepo unterschiedlich sind, hier eine Übersicht:
| Projekt | Web | Landing | Mobile | Backend |
|---------|-----|---------|--------|---------|
| maerchenzauber | `@storyteller/web` | `@storyteller/landing` | `@storyteller/mobile` | `@storyteller/backend` |
| manacore | `manacore-web` | `manacore-landing` | `manacore` | - |
| manadeck | `web` | `landing` | `manadeck` | - |
| memoro | `memoro-web` | `memoro-landing` | `memoro` | - |
### Filter-Syntax
| Pattern | Beschreibung |
|---------|--------------|
| `--filter=name` | Exakte Package-Übereinstimmung |
| `--filter=name...` | Package und alle Dependencies |
| `--filter='@scope/*'` | Alle Packages im Scope |
## Port-Zuweisungen
Wenn mehrere Apps gleichzeitig laufen, verwenden sie unterschiedliche Ports:
| App-Typ | Projekt | Standard-Port |
|---------|---------|---------------|
| Web | maerchenzauber | 5173 |
| Web | manacore | 5174 |
| Web | manadeck | 5175 |
| Web | memoro | 5176 |
| Landing | maerchenzauber | 4321 |
| Landing | manacore | 4322 |
| Landing | manadeck | 4323 |
| Landing | memoro | 4324 |
| Backend | maerchenzauber | 3000 |
*Hinweis: Die tatsächlichen Ports können je nach Konfiguration variieren.*
## Tipps
1. **Schnelleres Starten**: Nutze `dev:web` statt `dev` wenn du nur an Web-Apps arbeitest
2. **Parallele Entwicklung**: Turbo führt alle Tasks parallel aus und nutzt Caching
3. **Selektives Bauen**: Nutze Filter um nur relevante Packages zu bauen

View file

@ -15,6 +15,7 @@
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/postcss": "^4.1.17",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
"svelte": "^5.41.0",
@ -29,10 +30,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:*",

View file

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

View file

@ -1 +1,5 @@
@import "tailwindcss";
@import '@manacore/shared-tailwind/themes.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

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: '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: false,
status: 'beta'
},
{
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: true,
status: 'development'
},
{
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

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { LoginPage } from '@manacore/shared-auth-ui';
import StorytellerLogo from '$lib/components/StorytellerLogo.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import { authStore } from '$lib/stores/authStore.svelte';
async function handleSignIn(email: string, password: string) {
@ -18,9 +19,9 @@
</script>
<LoginPage
appName="Storyteller"
appName="Märchenzauber"
logo={StorytellerLogo}
primaryColor="#6366f1"
primaryColor="#FF6B9D"
onSignIn={handleSignIn}
onSignInWithGoogle={handleSignInWithGoogle}
onForgotPassword={handleForgotPassword}
@ -29,6 +30,10 @@
enableApple={true}
successRedirect="/dashboard"
registerPath="/register"
lightBackground="#f5f5f5"
darkBackground="#121212"
/>
lightBackground="#fff5f8"
darkBackground="#1a1218"
>
{#snippet appSlider()}
<AppSlider />
{/snippet}
</LoginPage>

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
packages:
- 'apps/*'
- 'packages/*'

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);

View file

@ -15,6 +15,7 @@
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.22",
"postcss": "^8.5.6",
@ -30,10 +31,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,2 +0,0 @@
onlyBuiltDependencies:
- esbuild

View file

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

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>

View file

@ -1,43 +1,12 @@
import { themeColors } from '@manacore/shared-tailwind/colors';
import preset from '@manacore/shared-tailwind/preset';
/** @type {import('tailwindcss').Config} */
export default {
presets: [preset],
content: [
'./src/**/*.{html,js,svelte,ts}',
'../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
'../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
],
darkMode: 'class',
theme: {
extend: {
colors: {
// Shared theme colors
...themeColors,
// ManaDeck specific HSL-based colors
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
surface: 'hsl(var(--surface))',
'surface-elevated': 'hsl(var(--surface-elevated))',
muted: 'hsl(var(--muted))',
'muted-foreground': 'hsl(var(--muted-foreground))',
primary: 'hsl(var(--primary))',
'primary-foreground': 'hsl(var(--primary-foreground))',
secondary: 'hsl(var(--secondary))',
'secondary-foreground': 'hsl(var(--secondary-foreground))',
accent: 'hsl(var(--accent))',
'accent-foreground': 'hsl(var(--accent-foreground))',
destructive: 'hsl(var(--destructive))',
'destructive-foreground': 'hsl(var(--destructive-foreground))',
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))'
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require('@tailwindcss/typography')]
};

View file

@ -16,6 +16,7 @@
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/postcss": "^4.1.17",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"svelte": "^5.39.5",
@ -30,10 +31,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:*",

View file

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

View file

@ -1,4 +1,4 @@
@import '@manacore/shared-tailwind/theme.css';
@import '@manacore/shared-tailwind/themes.css';
@tailwind base;
@tailwind components;

View file

@ -1,26 +1,12 @@
<script lang="ts">
import { AppSlider, type AppItem } from '@manacore/shared-ui';
import { theme } from '$lib/stores/theme';
import MemoroLogo from './MemoroLogo.svelte';
import { t } from 'svelte-i18n';
interface App {
name: string;
description: string;
longDescription: string;
icon?: string;
color: string;
comingSoon?: boolean;
status: 'published' | 'beta' | 'development' | 'planning';
}
let currentTheme = $derived($theme);
let isDark = $derived(currentTheme.effectiveMode === 'dark');
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);
let apps = $derived<App[]>([
let apps = $derived<AppItem[]>([
{
name: 'Memoro',
description: $t('app_slider.memoro_desc'),
@ -59,262 +45,24 @@
}
]);
function getPrimaryColor() {
const variant = currentTheme.variant;
if (isDark) {
const colors = {
lume: '#f8d62b',
nature: '#4CAF50',
stone: '#78909C',
ocean: '#039BE5'
};
return colors[variant];
} else {
const colors = {
lume: '#f8d62b',
nature: '#4CAF50',
stone: '#607D8B',
ocean: '#039BE5'
};
return colors[variant];
}
}
function getStatusColor(status: App['status']) {
const colors = {
published: '#4CAF50', // Green
beta: '#FFD700', // Yellow/Gold
development: '#FF9800', // Orange
planning: '#F44336' // Red (not used)
};
return colors[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;
// Calculate mouse position relative to card center
const mouseXRelative = e.clientX - cardCenterX;
const mouseYRelative = e.clientY - cardCenterY;
// Calculate rotation (max 3 degrees)
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 };
}
// Scroll to selected app when modal opens
$effect(() => {
if (selectedApp !== null && modalScrollContainer) {
setTimeout(() => {
if (selectedApp === null) return;
const cardWidth = 360 + 24; // card width + gap
const scrollPosition = selectedApp * cardWidth;
modalScrollContainer?.scrollTo({
left: scrollPosition,
behavior: 'smooth'
});
}, 50);
}
let statusLabels = $derived({
published: $t('app_slider.status_published'),
beta: $t('app_slider.status_beta'),
development: $t('app_slider.status_development'),
planning: $t('app_slider.status_planning')
});
function handleAppClick(app: AppItem, index: number) {
console.log('Opening app:', app.name);
}
</script>
<div class="w-full">
<!-- Title -->
<h3
class="mb-4 text-center text-sm font-medium"
style="color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};"
>
{$t('app_slider.title')}
</h3>
<!-- Slider Container with horizontal scroll -->
<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"
style="width: 160px; background-color: {hoveredApp === index
? isDark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.08)'
: isDark
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.05)'}; border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}; box-shadow: 0 4px 20px rgba(0, 0, 0, {isDark ? '0.3' : '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)}
>
<!-- Status Indicator -->
<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>
<!-- App Icon -->
<div
class="mb-2 flex h-20 w-20 mx-auto items-center justify-center rounded-xl transition-transform group-hover:scale-110"
>
{#if app.icon}
<img src={app.icon} alt={app.name} class="w-16 h-16 object-contain" />
{:else}
<div
class="flex h-10 w-10 items-center justify-center rounded font-bold text-lg"
style="color: {app.color};"
>
{app.name.charAt(0)}
</div>
{/if}
</div>
<!-- App Name -->
<h4
class="text-base font-semibold text-center"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
{app.name}
</h4>
</button>
{/each}
</div>
</div>
</div>
<!-- Modal for App Details -->
{#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}
>
<!-- Close Button -->
<button
onclick={closeModal}
class="absolute top-6 right-6 rounded-full p-2 transition-all hover:bg-white/10 z-10"
>
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="color: #ffffff;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Full-screen Scrollable Container -->
<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 card-3d relative"
style="min-width: 360px; max-width: 360px; background-color: {hoveredApp === index ? (isDark ? '#2A2A2A' : '#F5F5F5') : (isDark ? '#1E1E1E' : '#ffffff')}; 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; opacity: 1;"
onclick={(e) => { e.stopPropagation(); selectedApp = index; }}
onmouseenter={() => hoveredApp = index}
onmousemove={(e) => handleCardMouseMove(e, index, e.currentTarget)}
onmouseleave={() => { handleCardMouseLeave(index); hoveredApp = null; }}
>
<!-- Status Indicator with Label -->
<div class="absolute top-4 right-4 flex items-center gap-2">
<span class="text-xs font-medium" style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};">
{$t(`app_slider.status_${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>
<!-- App Icon Large -->
{#if app.icon}
<img src={app.icon} alt={app.name} class="w-28 h-28 object-contain mb-3 mx-auto" />
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded font-bold text-3xl mb-3 mx-auto"
style="color: {app.color};"
>
{app.name.charAt(0)}
</div>
{/if}
<!-- App Name -->
<h3 class="text-2xl font-bold mb-2 text-center" style="color: {isDark ? '#ffffff' : '#000000'};">
{app.name}
</h3>
<!-- Short Description -->
<p class="text-sm mb-4 text-center font-medium" style="color: {app.color};">
{app.description}
</p>
<!-- Long Description -->
<p class="text-sm leading-relaxed mb-6 text-center" style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};">
{app.longDescription}
</p>
<!-- Coming Soon Badge or Action Button -->
<div class="text-center">
{#if app.comingSoon}
<div
class="inline-block rounded-full px-5 py-2.5 text-sm font-medium"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};"
>
{$t('app_slider.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};"
>
{$t('app_slider.download')}
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<style>
/* Hide scrollbar for Chrome, Safari and Opera */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Status indicator pulse animation */
.status-indicator {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
</style>
<AppSlider
{apps}
title={$t('app_slider.title')}
{isDark}
{statusLabels}
comingSoonLabel={$t('app_slider.coming_soon')}
openAppLabel={$t('app_slider.download')}
onAppClick={handleAppClick}
/>

View file

@ -1,18 +0,0 @@
{
"usage": {
"total": 50,
"lastWeek": 350,
"lastMonth": 1200,
"currentMana": 785,
"maxMana": 1000,
"history": [
{ "date": "2025-04-25", "amount": 50 },
{ "date": "2025-04-26", "amount": 70 },
{ "date": "2025-04-27", "amount": 30 },
{ "date": "2025-04-28", "amount": 45 },
{ "date": "2025-04-29", "amount": 80 },
{ "date": "2025-04-30", "amount": 55 },
{ "date": "2025-05-01", "amount": 20 }
]
}
}

View file

@ -1,43 +1,6 @@
<script lang="ts">
import {
BillingToggle,
SubscriptionCard,
PackageCard,
UsageCard,
CostCard,
type BillingCycle,
type SubscriptionPlan,
type ManaPackage,
type UsageData,
type CostItem
} from '@manacore/shared-subscription-ui';
import { SubscriptionPage } from '@manacore/shared-subscription-ui';
import subscriptionData from '$lib/data/subscriptionData.json';
import appCostsData from '$lib/data/appCosts.json';
import usageData from '$lib/data/usageData.json';
// State
let billingCycle = $state<BillingCycle>('monthly');
// Data from JSON files
const subscriptionOptions = subscriptionData.subscriptions as SubscriptionPlan[];
const manaPackages = subscriptionData.packages as ManaPackage[];
const appCosts = appCostsData.costs as CostItem[];
const usage = usageData.usage as UsageData;
// Get all subscription plans for current billing cycle
function getAllSubscriptionPlans() {
return subscriptionOptions.filter(
(plan) => plan.id !== 'free' && plan.billingCycle === billingCycle
);
}
// Get all mana packages
function getAllManaPackages() {
return manaPackages;
}
// Handlers
function handleSubscribe(planId: string) {
alert(`Subscribe to plan: ${planId}\n\nThis would trigger RevenueCat purchase flow.`);
}
@ -47,60 +10,9 @@
}
</script>
<svelte:head>
<title>Mana - Memoro</title>
</svelte:head>
<div class="flex h-full flex-col">
<!-- Content Area -->
<div class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-5xl pb-12">
<h1 class="mb-8 text-3xl font-bold text-theme">Mana kaufen</h1>
<!-- Active Section (Usage & Costs) -->
<section class="mb-8">
<div class="mb-4">
<UsageCard usageData={usage} currentPlan="Free" />
</div>
<div class="mb-4">
<CostCard costs={appCosts} />
</div>
</section>
<!-- Billing Toggle -->
<BillingToggle {billingCycle} onChange={(cycle) => (billingCycle = cycle)} yearlyDiscount="33%" />
<!-- Subscriptions Section -->
<section class="mb-12 pt-2">
<h2 class="mb-6 text-2xl font-bold text-theme">Abonnements</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<!-- Free Tier -->
<SubscriptionCard
plan={subscriptionOptions.find((plan) => plan.id === 'free')!}
onSelect={handleSubscribe}
isCurrentPlan={true}
/>
<!-- All Paid Subscriptions -->
{#each getAllSubscriptionPlans() as plan}
<SubscriptionCard {plan} onSelect={handleSubscribe} />
{/each}
</div>
</section>
<!-- One-time Purchases Section -->
<section class="mb-12">
<h2 class="mb-6 text-2xl font-bold text-theme">Einmalkäufe</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- All Mana Packages -->
{#each getAllManaPackages() as pkg}
<PackageCard package={pkg} onSelect={handleBuyPackage} />
{/each}
</div>
</section>
</div>
</div>
</div>
<SubscriptionPage
appName="Memoro"
onSubscribe={handleSubscribe}
onBuyPackage={handleBuyPackage}
currentPlanId="free"
/>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Component } from 'svelte';
import type { Component, Snippet } from 'svelte';
import type { AuthResult } from '../types';
import Icon from '../components/Icon.svelte';
import GoogleSignInButton from '../components/GoogleSignInButton.svelte';
@ -36,6 +36,10 @@
lightBackground?: string;
/** Dark background color */
darkBackground?: string;
/** AppSlider snippet to render at the bottom (optional) */
appSlider?: Snippet;
/** Header snippet for controls like theme toggle and language selector */
headerControls?: Snippet;
}
let {
@ -52,7 +56,9 @@
successRedirect = '/dashboard',
registerPath = '/register',
lightBackground = '#f5f5f5',
darkBackground = '#121212'
darkBackground = '#121212',
appSlider,
headerControls
}: Props = $props();
let loading = $state(false);
@ -163,6 +169,13 @@
class="flex min-h-screen flex-col justify-between"
style="background-color: {getPageBackground()};"
>
<!-- Header Controls (Theme Toggle, Language Selector, etc.) -->
{#if headerControls}
<div class="absolute right-4 top-4 z-50 flex items-center gap-3 opacity-60">
{@render headerControls()}
</div>
{/if}
<!-- Top Section - Logo -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
@ -433,6 +446,13 @@
</div>
</div>
<!-- Bottom padding -->
<div class="pb-8"></div>
<!-- App Slider (shown on initial mode) -->
{#if appSlider && mode === 'initial'}
<div class="w-full pb-8 px-2 pt-4">
{@render appSlider()}
</div>
{:else}
<!-- Bottom padding -->
<div class="pb-8"></div>
{/if}
</div>

View file

@ -0,0 +1,10 @@
{
"usage": {
"total": 0,
"lastWeek": 0,
"lastMonth": 0,
"currentMana": 150,
"maxMana": 150,
"history": []
}
}

View file

@ -5,6 +5,9 @@
* subscription plans, mana packages, and usage information.
*/
// Pages
export { default as SubscriptionPage } from './pages/SubscriptionPage.svelte';
// Components
export { default as SubscriptionCard } from './SubscriptionCard.svelte';
export { default as PackageCard } from './PackageCard.svelte';
@ -14,6 +17,11 @@ export { default as CostCard } from './CostCard.svelte';
export { default as SubscriptionButton } from './SubscriptionButton.svelte';
export { default as ManaIcon } from './ManaIcon.svelte';
// Default data exports
export { default as defaultSubscriptionData } from './data/subscriptionData.json';
export { default as defaultAppCosts } from './data/appCosts.json';
export { default as defaultUsageData } from './data/defaultUsageData.json';
// Re-export types for convenience
export type {
SubscriptionPlan,

View file

@ -0,0 +1,138 @@
<script lang="ts">
import type { SubscriptionPlan, ManaPackage, UsageData, CostItem, BillingCycle } from '@manacore/shared-subscription-types';
import BillingToggle from '../BillingToggle.svelte';
import SubscriptionCard from '../SubscriptionCard.svelte';
import PackageCard from '../PackageCard.svelte';
import UsageCard from '../UsageCard.svelte';
import CostCard from '../CostCard.svelte';
// Import default data
import defaultSubscriptionData from '../data/subscriptionData.json';
import defaultAppCosts from '../data/appCosts.json';
import defaultUsageData from '../data/defaultUsageData.json';
interface Props {
/** App name for the page title */
appName: string;
/** Handler when user selects a subscription plan */
onSubscribe: (planId: string) => void;
/** Handler when user selects a mana package */
onBuyPackage: (packageId: string) => void;
/** Current plan ID (e.g., 'free', 'Mana_Stream_Small_v1') */
currentPlanId?: string;
/** Current user's usage data (optional, uses defaults if not provided) */
usageData?: UsageData;
/** Custom subscription plans (optional, uses defaults if not provided) */
subscriptions?: SubscriptionPlan[];
/** Custom mana packages (optional, uses defaults if not provided) */
packages?: ManaPackage[];
/** Custom cost items (optional, uses defaults if not provided) */
costs?: CostItem[];
/** Page title */
pageTitle?: string;
/** Subscriptions section title */
subscriptionsTitle?: string;
/** One-time purchases section title */
packagesTitle?: string;
/** Yearly discount label */
yearlyDiscount?: string;
}
let {
appName,
onSubscribe,
onBuyPackage,
currentPlanId = 'free',
usageData = defaultUsageData.usage as UsageData,
subscriptions = defaultSubscriptionData.subscriptions as SubscriptionPlan[],
packages = defaultSubscriptionData.packages as ManaPackage[],
costs = defaultAppCosts.costs as CostItem[],
pageTitle = 'Mana kaufen',
subscriptionsTitle = 'Abonnements',
packagesTitle = 'Einmalkäufe',
yearlyDiscount = '33%'
}: Props = $props();
// State
let billingCycle = $state<BillingCycle>('monthly');
// Get current plan name for display
const currentPlanName = $derived(() => {
const plan = subscriptions.find(p => p.id === currentPlanId);
return plan?.name || 'Free';
});
// Get all subscription plans for current billing cycle
function getSubscriptionPlans() {
return subscriptions.filter(
(plan) => plan.id !== 'free' && plan.billingCycle === billingCycle
);
}
// Check if a plan is the current plan
function isCurrentPlan(planId: string) {
if (currentPlanId === 'free' && planId === 'free') return true;
return planId === currentPlanId;
}
</script>
<svelte:head>
<title>Mana - {appName}</title>
</svelte:head>
<div class="flex h-full flex-col">
<!-- Content Area -->
<div class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-5xl pb-12">
<h1 class="mb-8 text-3xl font-bold text-theme">{pageTitle}</h1>
<!-- Active Section (Usage & Costs) -->
<section class="mb-8">
<div class="mb-4">
<UsageCard {usageData} currentPlan={currentPlanName()} />
</div>
<div class="mb-4">
<CostCard {costs} />
</div>
</section>
<!-- Billing Toggle -->
<BillingToggle {billingCycle} onChange={(cycle: BillingCycle) => (billingCycle = cycle)} {yearlyDiscount} />
<!-- Subscriptions Section -->
<section class="mb-12 pt-2">
<h2 class="mb-6 text-2xl font-bold text-theme">{subscriptionsTitle}</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<!-- Free Tier -->
<SubscriptionCard
plan={subscriptions.find((plan) => plan.id === 'free')!}
onSelect={onSubscribe}
isCurrentPlan={isCurrentPlan('free')}
/>
<!-- All Paid Subscriptions -->
{#each getSubscriptionPlans() as plan}
<SubscriptionCard
{plan}
onSelect={onSubscribe}
isCurrentPlan={isCurrentPlan(plan.id)}
/>
{/each}
</div>
</section>
<!-- One-time Purchases Section -->
<section class="mb-12">
<h2 class="mb-6 text-2xl font-bold text-theme">{packagesTitle}</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each packages as pkg}
<PackageCard package={pkg} onSelect={onBuyPackage} />
{/each}
</div>
</section>
</div>
</div>
</div>

View file

@ -1,5 +1,4 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",

View file

@ -9,6 +9,7 @@
"./preset": "./src/preset.js",
"./colors": "./src/colors.js",
"./theme.css": "./src/theme-variables.css",
"./themes.css": "./src/themes.css",
"./components.css": "./src/components.css"
},
"peerDependencies": {

View file

@ -0,0 +1,48 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type CardVariant = 'elevated' | 'outlined' | 'ghost';
type CardPadding = 'none' | 'sm' | 'md' | 'lg';
interface Props {
variant?: CardVariant;
padding?: CardPadding;
class?: string;
onclick?: (e: MouseEvent) => void;
children: Snippet;
}
let {
variant = 'elevated',
padding = 'md',
class: className = '',
onclick,
children
}: Props = $props();
const variantClasses: Record<CardVariant, string> = {
elevated: 'bg-menu shadow-md border border-theme',
outlined: 'bg-content border border-theme',
ghost: 'bg-transparent'
};
const paddingClasses: Record<CardPadding, string> = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8'
};
const classes = $derived(
`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,3 +1,4 @@
export { default as Text } from './Text.svelte';
export { default as Button } from './Button.svelte';
export { default as Badge } from './Badge.svelte';
export { default as Card } from './Card.svelte';

View file

@ -1,8 +1,9 @@
// Atoms
export { Text, Button, Badge } from './atoms';
export { Text, Button, Badge, Card } from './atoms';
// Molecules
export { Toggle, Input } from './molecules';
// Organisms
export { Modal } from './organisms';
export { Modal, AppSlider } from './organisms';
export type { AppItem } from './organisms';

View file

@ -0,0 +1,283 @@
<script lang="ts">
import type { Snippet } from 'svelte';
export interface AppItem {
name: string;
description: string;
longDescription: string;
icon?: string;
color: string;
comingSoon?: boolean;
status: 'published' | 'beta' | 'development' | 'planning';
}
interface Props {
apps: AppItem[];
title?: string;
isDark?: boolean;
statusLabels?: {
published: string;
beta: string;
development: string;
planning: string;
};
comingSoonLabel?: string;
openAppLabel?: string;
onAppClick?: (app: AppItem, index: number) => void;
}
let {
apps,
title = 'Part of the Mana Ecosystem',
isDark = false,
statusLabels = {
published: 'Live',
beta: 'Beta',
development: 'In Development',
planning: 'Planned'
},
comingSoonLabel = 'Coming Soon',
openAppLabel = 'Open App',
onAppClick
}: Props = $props();
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);
function getStatusColor(status: AppItem['status']) {
const colors = {
published: '#4CAF50',
beta: '#FFD700',
development: '#FF9800',
planning: '#F44336'
};
return colors[status];
}
function getStatusLabel(status: AppItem['status']) {
return statusLabels[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 };
}
function handleAppAction(app: AppItem, index: number) {
if (onAppClick) {
onAppClick(app, index);
}
}
$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"
style="color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};"
>
{title}
</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"
style="width: 160px; background-color: {hoveredApp === index
? isDark
? 'rgba(255, 255, 255, 0.08)'
: 'rgba(0, 0, 0, 0.08)'
: isDark
? 'rgba(255, 255, 255, 0.05)'
: 'rgba(0, 0, 0, 0.05)'}; border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}; box-shadow: 0 4px 20px rgba(0, 0, 0, {isDark ? '0.3' : '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">
{#if app.icon}
<img src={app.icon} alt={app.name} class="w-16 h-16 object-contain" />
{:else}
<div
class="flex h-10 w-10 items-center justify-center rounded font-bold text-lg"
style="color: {app.color};"
>
{app.name.charAt(0)}
</div>
{/if}
</div>
<h4
class="text-base font-semibold text-center"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
{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"
style="min-width: 360px; max-width: 360px; background-color: {hoveredApp === index ? (isDark ? '#2A2A2A' : '#F5F5F5') : (isDark ? '#1E1E1E' : '#ffffff')}; 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" style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};">
{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>
{#if app.icon}
<img src={app.icon} alt={app.name} class="w-28 h-28 object-contain mb-3 mx-auto" />
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded font-bold text-3xl mb-3 mx-auto"
style="color: {app.color};"
>
{app.name.charAt(0)}
</div>
{/if}
<h3 class="text-2xl font-bold mb-2 text-center" style="color: {isDark ? '#ffffff' : '#000000'};">
{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" style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};">
{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"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};"
>
{comingSoonLabel}
</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};"
onclick={(e) => { e.stopPropagation(); handleAppAction(app, index); }}
>
{openAppLabel}
</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>

View file

@ -1 +1,3 @@
export { default as Modal } from './Modal.svelte';
export { default as AppSlider } from './AppSlider.svelte';
export type { AppItem } from './AppSlider.svelte';