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,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,28 @@
{
"costs": [
{
"action": "Pro Minute Aufnahme",
"actionKey": "subscription.cost_per_minute_recording",
"cost": 2,
"icon": "mic-outline"
},
{
"action": "Frage an Memo stellen",
"actionKey": "subscription.cost_ask_memo_question",
"cost": 5,
"icon": "chatbubble-outline"
},
{
"action": "Neue Memory erstellen",
"actionKey": "subscription.cost_create_memory",
"cost": 5,
"icon": "add-circle-outline"
},
{
"action": "Memos kombinieren (pro Memo)",
"actionKey": "subscription.cost_combine_memos",
"cost": 5,
"icon": "copy-outline"
}
]
}

View file

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

View file

@ -0,0 +1,167 @@
{
"subscriptions": [
{
"id": "free",
"name": "Mana Stream Free",
"nameEn": "Mana Stream Free",
"nameIt": "Mana Stream Free",
"price": 0,
"priceUnit": "",
"monthlyMana": 150,
"canGiftMana": false,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Stream_Small_v1",
"name": "Kleiner Mana Stream",
"nameEn": "Small Mana Stream",
"nameIt": "Piccolo Mana Stream",
"price": 5.99,
"priceUnit": "/ Monat",
"monthlyMana": 600,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Stream_Small_Yearly_v1",
"name": "Kleiner Mana Stream",
"nameEn": "Small Mana Stream",
"nameIt": "Piccolo Mana Stream",
"price": 47.99,
"priceUnit": "/ Jahr",
"priceBreakdown": "(entspricht 3,99€ / Monat, 33% Rabatt)",
"monthlyMana": 600,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 3.99,
"available": true
},
{
"id": "Mana_Stream_Medium_v1",
"name": "Mittlerer Mana Stream",
"nameEn": "Medium Mana Stream",
"nameIt": "Medio Mana Stream",
"price": 14.99,
"priceUnit": "/ Monat",
"monthlyMana": 1500,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Stream_Medium_Yearly_v1",
"name": "Mittlerer Mana Stream",
"nameEn": "Medium Mana Stream",
"nameIt": "Medio Mana Stream",
"price": 119.99,
"priceUnit": "/ Jahr",
"priceBreakdown": "(entspricht 9,99€ / Monat, 33% Rabatt)",
"monthlyMana": 1500,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 9.99,
"available": true
},
{
"id": "Mana_Stream_Large_v1",
"name": "Großer Mana Stream",
"nameEn": "Large Mana Stream",
"nameIt": "Grande Mana Stream",
"price": 29.99,
"priceUnit": "/ Monat",
"monthlyMana": 3000,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Stream_Large_Yearly_v1",
"name": "Großer Mana Stream",
"nameEn": "Large Mana Stream",
"nameIt": "Grande Mana Stream",
"price": 239.99,
"priceUnit": "/ Jahr",
"priceBreakdown": "(entspricht 19,99€ / Monat, 33% Rabatt)",
"monthlyMana": 3000,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 19.99,
"available": true
},
{
"id": "Mana_Stream_Giant_v1",
"name": "Riesiger Mana Stream",
"nameEn": "Giant Mana Stream",
"nameIt": "Gigante Mana Stream",
"price": 49.99,
"priceUnit": "/ Monat",
"monthlyMana": 5000,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Stream_Giant_Yearly_v1",
"name": "Riesiger Mana Stream",
"nameEn": "Giant Mana Stream",
"nameIt": "Gigante Mana Stream",
"price": 399.99,
"priceUnit": "/ Jahr",
"priceBreakdown": "(entspricht 33,33€ / Monat, 33% Rabatt)",
"monthlyMana": 5000,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 33.33,
"available": true
}
],
"packages": [
{
"id": "Mana_Potion_Small_v1",
"name": "Kleiner Mana Trank",
"nameEn": "Small Mana Potion",
"nameIt": "Piccola Pozione di Mana",
"manaAmount": 350,
"price": 4.99,
"popular": false
},
{
"id": "Mana_Potion_Medium_v1",
"name": "Mittlerer Mana Trank",
"nameEn": "Medium Mana Potion",
"nameIt": "Media Pozione di Mana",
"manaAmount": 700,
"price": 9.99,
"popular": false
},
{
"id": "Mana_Potion_Large_v1",
"name": "Großer Mana Trank",
"nameEn": "Large Mana Potion",
"nameIt": "Grande Pozione di Mana",
"manaAmount": 1400,
"price": 19.99,
"popular": false
},
{
"id": "Mana_Potion_Giant_v2",
"name": "Riesiger Mana Trank",
"nameEn": "Giant Mana Potion",
"nameIt": "Pozione di Mana Gigante",
"manaAmount": 2800,
"price": 39.99,
"popular": false
}
]
}

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