Commit Message feat: implement comprehensive shared packages architecture for monorepo SUMMARY: Introduce 10 shared packages to unify common code across all 4 web apps, reducing ~3,000 lines of duplicated code and establishing consistent patterns for authentication, UI components, theming, and utilities. NEW SHARED PACKAGES: - @manacore/shared-auth: Unified auth logic (token management, JWT utils, fetch interceptor, storage/device/network adapters) - @manacore/shared-auth-ui: Reusable auth UI (LoginPage, RegisterPage, OAuth buttons for Google/Apple) - @manacore/shared-tailwind: Unified Tailwind config with 4 themes (lume, nature, stone, ocean) and light/dark mode support - @manacore/shared-icons: Phosphor-based icon library (40+ icons) - @manacore/shared-ui: Atomic design system (Text, Button, Badge, Toggle, Input, Modal) - @manacore/shared-i18n: Unified i18n setup with locale detection - @manacore/shared-config: Environment validation with Zod - @manacore/shared-subscriptio n-types: Subscription type definitions - @manacore/shared-subscriptio n-ui: Subscription UI components (planned) EXTENDED PACKAGES: - @manacore/shared-types: Added auth.ts, theme.ts, ui.ts, common.ts - @manacore/shared-utils: Added format.ts, validation.ts APP MIGRATIONS: - memoro/web: Migrated login (549→46 LOC), tailwind (165→12 LOC), removed 15+ duplicate components - manacore/web: Migrated to client-side auth with shared-auth, added new components (Icon, ThemeToggle, Logo) - manadeck/web: Replaced local authService/tokenManager with shared-auth, migrated auth pages - maerchenzauber/web: Added auth setup, stores, components, routes DELETED FILES (migrated to shared packages): - OAuth buttons (Google/Apple) from memoro, manacore, manadeck - Local authService, tokenManager, deviceManager, jwt utils - Duplicate Modal, Toggle, Text components - iconPaths and ManaIcon components - Subscription-related components (CostCard, PackageCard, etc.) BENEFITS: - 92% reduction in login page code - 93% reduction in tailwind config code - Consistent theming across all apps - Single source of truth for auth logic - Easier maintenance and updates BREAKING CHANGES: - Icon imports now from @manacore/shared-icons - Modal imports from @manacore/shared-ui - OAuth config via setGoogleCl ientId()/setAppleConfig()

This commit is contained in:
Till-JS 2025-11-24 21:09:20 +01:00
parent 725db638ea
commit ef70a1af0b
198 changed files with 11113 additions and 3656 deletions

View file

@ -16,7 +16,6 @@
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.43.2",
"@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"svelte": "^5.39.5",
@ -26,6 +25,18 @@
"vite": "^7.1.7"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-auth-ui": "workspace:*",
"@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-tailwind": "workspace:*",
"@manacore/shared-types": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@phosphor-icons/core": "^2.1.1",
"@supabase/supabase-js": "^2.81.1",
"date-fns": "^4.1.0",

View file

@ -1,408 +1,7 @@
@import '@manacore/shared-tailwind/theme.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-body: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'Fira Mono', monospace;
}
/* Default Theme: Lume Light */
:root {
--color-primary: #f8d62b;
--color-primary-button: #f8d62b;
--color-primary-button-text: #000000;
--color-secondary: #d4b200;
--color-secondary-button: #ffe9a3;
--color-content-bg: #ffffff;
--color-content-bg-hover: #f5f5f5;
--color-content-page-bg: #ffffff;
--color-menu-bg: #dddddd;
--color-menu-bg-hover: #cccccc;
--color-panel-bg: #e8e8e8;
--color-page-bg: #dddddd;
--color-text: #2c2c2c;
--color-border-light: #f2f2f2;
--color-border: #999999;
--color-border-strong: #cccccc;
--color-error: #e74c3c;
}
/* Lume Dark */
:root.dark {
--color-primary: #f8d62b;
--color-primary-button: #7c6b16;
--color-primary-button-text: #ffffff;
--color-secondary: #d4b200;
--color-secondary-button: #1e1e1e;
--color-content-bg: #1e1e1e;
--color-content-bg-hover: #333333;
--color-content-page-bg: #121212;
--color-menu-bg: #101010;
--color-menu-bg-hover: #333333;
--color-panel-bg: #1a1a1a;
--color-page-bg: #101010;
--color-text: #ffffff;
--color-border-light: #333333;
--color-border: #424242;
--color-border-strong: #616161;
--color-error: #e74c3c;
}
/* Nature Light */
:root[data-theme='nature'] {
--color-primary: #4caf50;
--color-primary-button: #a08500;
--color-primary-button-text: #ffffff;
--color-secondary: #81c784;
--color-secondary-button: #f1f8e9;
--color-content-bg: #f1f8e9;
--color-content-bg-hover: #e8f5e9;
--color-content-page-bg: #ffffff;
--color-menu-bg: #e8f5e9;
--color-menu-bg-hover: #c8e6c9;
--color-panel-bg: #eff8f0;
--color-page-bg: #fbfdf8;
--color-text: #1b5e20;
--color-border-light: #e8f5e9;
--color-border: #c8e6c9;
--color-border-strong: #a5d6a7;
--color-error: #e57373;
}
/* Nature Dark */
:root[data-theme='nature'].dark {
--color-primary: #4caf50;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #81c784;
--color-secondary-button: #1e1e1e;
--color-content-bg: #1e1e1e;
--color-content-bg-hover: #2e7d32;
--color-content-page-bg: #121212;
--color-menu-bg: #252525;
--color-menu-bg-hover: #2e7d32;
--color-panel-bg: #2a2a2a;
--color-page-bg: #121212;
--color-text: #ffffff;
--color-border-light: #1b5e20;
--color-border: #2e7d32;
--color-border-strong: #388e3c;
--color-error: #cf6679;
}
/* Stone Light */
:root[data-theme='stone'] {
--color-primary: #607d8b;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #90a4ae;
--color-secondary-button: #eceff1;
--color-content-bg: #eceff1;
--color-content-bg-hover: #e0e6ea;
--color-content-page-bg: #ffffff;
--color-menu-bg: #e0e6ea;
--color-menu-bg-hover: #cfd8dc;
--color-panel-bg: #e8edf0;
--color-page-bg: #f5f7f9;
--color-text: #263238;
--color-border-light: #eceff1;
--color-border: #cfd8dc;
--color-border-strong: #b0bec5;
--color-error: #ef5350;
}
/* Stone Dark */
:root[data-theme='stone'].dark {
--color-primary: #78909c;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #90a4ae;
--color-secondary-button: #1e1e1e;
--color-content-bg: #1e1e1e;
--color-content-bg-hover: #37474f;
--color-content-page-bg: #121212;
--color-menu-bg: #252525;
--color-menu-bg-hover: #37474f;
--color-panel-bg: #2a2a2a;
--color-page-bg: #121212;
--color-text: #ffffff;
--color-border-light: #37474f;
--color-border: #455a64;
--color-border-strong: #546e7a;
--color-error: #cf6679;
}
/* Ocean Light */
:root[data-theme='ocean'] {
--color-primary: #039be5;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #4fc3f7;
--color-secondary-button: #e1f5fe;
--color-content-bg: #e1f5fe;
--color-content-bg-hover: #b3e5fc;
--color-content-page-bg: #ffffff;
--color-menu-bg: #e1f5fe;
--color-menu-bg-hover: #b3e5fc;
--color-panel-bg: #ecf8fe;
--color-page-bg: #f5fcff;
--color-text: #01579b;
--color-border-light: #e1f5fe;
--color-border: #b3e5fc;
--color-border-strong: #81d4fa;
--color-error: #ef5350;
}
/* Ocean Dark */
:root[data-theme='ocean'].dark {
--color-primary: #039be5;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #4fc3f7;
--color-secondary-button: #1e1e1e;
--color-content-bg: #1e1e1e;
--color-content-bg-hover: #0277bd;
--color-content-page-bg: #121212;
--color-menu-bg: #252525;
--color-menu-bg-hover: #0277bd;
--color-panel-bg: #2a2a2a;
--color-page-bg: #121212;
--color-text: #ffffff;
--color-border-light: #01579b;
--color-border: #0277bd;
--color-border-strong: #0288d1;
--color-error: #cf6679;
}
body {
margin: 0;
font-family: var(--font-body);
}
html {
margin: 0;
padding: 0;
}
@layer base {
h1 {
@apply text-3xl font-bold;
color: var(--color-text);
}
h2 {
@apply text-2xl font-semibold;
color: var(--color-text);
}
h3 {
@apply text-xl font-semibold;
color: var(--color-text);
}
}
@layer components {
.btn-primary {
@apply rounded-lg px-4 py-2 font-semibold transition-colors;
background-color: var(--color-primary-button);
color: var(--color-primary-button-text);
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
@apply rounded-lg px-4 py-2 font-semibold transition-colors;
background-color: var(--color-secondary-button);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background-color: var(--color-content-bg-hover);
}
.btn-danger {
@apply rounded-lg px-4 py-2 font-semibold transition-colors;
background-color: var(--color-error);
color: #ffffff;
}
.btn-danger:hover {
opacity: 0.9;
}
.input-field {
@apply w-full rounded-lg px-4 py-2 transition-colors;
background-color: var(--color-content-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.input-field:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.card {
@apply rounded-lg p-6 shadow-sm;
background-color: var(--color-content-bg);
color: var(--color-text);
}
/* Header & Navigation */
.header-style {
background-color: var(--color-menu-bg);
border-bottom: 1px solid var(--color-border);
}
.logo-text {
@apply text-2xl font-bold;
color: var(--color-primary);
}
.nav-link {
@apply transition-colors;
color: var(--color-text);
}
.nav-link:hover {
color: var(--color-primary);
}
.user-email {
@apply text-sm;
color: var(--color-text);
opacity: 0.7;
}
/* Main Content Area */
.main-content {
background-color: var(--color-page-bg);
}
/* Selected/Active State */
.bg-selected {
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
/* Status Badge Colors */
.status-completed {
background-color: rgba(76, 175, 80, 0.15);
color: #4caf50;
}
.status-processing {
background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
}
.status-failed {
background-color: color-mix(in srgb, var(--color-error) 15%, transparent);
color: var(--color-error);
}
.status-default {
background-color: color-mix(in srgb, var(--color-text) 10%, transparent);
color: var(--color-text);
}
/* Info/Alert Boxes */
.info-box {
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
}
/* Loading Spinner */
.spinner-border {
border-color: var(--color-primary);
}
/* Focus Ring */
.focus\:ring-primary:focus {
--tw-ring-color: var(--color-primary);
}
.focus\:ring-2:focus {
box-shadow: 0 0 0 2px var(--tw-ring-color, var(--color-primary));
}
}
@layer utilities {
/* Theme Color Utilities - in utilities layer for @apply support */
.bg-content {
background-color: var(--color-content-bg);
}
.bg-content-hover {
background-color: var(--color-content-bg-hover);
}
.hover\:bg-content-hover:hover {
background-color: var(--color-content-bg-hover);
}
.bg-menu {
background-color: var(--color-menu-bg);
}
.bg-menu-hover {
background-color: var(--color-menu-bg-hover);
}
.hover\:bg-menu-hover:hover {
background-color: var(--color-menu-bg-hover);
}
.bg-panel {
background-color: var(--color-panel-bg);
}
.bg-page {
background-color: var(--color-page-bg);
}
.border-theme {
border-color: var(--color-border);
}
.border-theme-light {
border-color: var(--color-border-light);
}
.text-theme {
color: var(--color-text);
}
.text-theme-secondary {
color: var(--color-text);
opacity: 0.6;
}
.text-theme-muted {
color: var(--color-text);
opacity: 0.4;
}
.text-primary {
color: var(--color-primary);
}
.bg-primary {
background-color: var(--color-primary);
}
.bg-primary-button {
background-color: var(--color-primary-button);
}
.text-primary-button-text {
color: var(--color-primary-button-text);
}
.bg-secondary-button {
background-color: var(--color-secondary-button);
}
}
@import '@manacore/shared-tailwind/components.css';

View file

@ -69,7 +69,7 @@
}
if (data && data.advice) {
advice = data.advice as AdviceData;
advice = data.advice as unknown as AdviceData;
currentIndex = 0; // Reset to first section
} else {
advice = null;

View file

@ -123,6 +123,7 @@
$effect(() => {
if (selectedApp !== null && modalScrollContainer) {
setTimeout(() => {
if (selectedApp === null) return;
const cardWidth = 360 + 24; // card width + gap
const scrollPosition = selectedApp * cardWidth;
modalScrollContainer?.scrollTo({

View file

@ -1,81 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initializeAppleAuth, signInWithApple, waitForAppleAuth } from '$lib/utils/appleAuth';
// Props
interface Props {
onError?: (error: Error) => void;
}
let { onError }: Props = $props();
// State
let isLoading = $state(false);
let error = $state<string | null>(null);
let sdkLoaded = $state(false);
// Handle Apple Sign-In click
async function handleAppleSignIn() {
isLoading = true;
error = null;
try {
// Redirect to Apple (will come back to /auth/apple-callback)
await signInWithApple();
} catch (err) {
console.error('Error initiating Apple Sign-In:', err);
error = err instanceof Error ? err.message : 'Failed to initiate Apple Sign-In';
onError?.(err instanceof Error ? err : new Error('Unknown error during Apple Sign-In'));
isLoading = false;
}
}
// Initialize Apple SDK on mount
onMount(async () => {
try {
// Wait for Apple ID SDK to load
await waitForAppleAuth();
// Initialize Apple Sign-In
const initialized = initializeAppleAuth();
if (initialized) {
sdkLoaded = true;
} else {
// Don't show error - just hide the button if not configured
console.warn('Apple Sign-In not configured - hiding button');
}
} catch (err) {
console.error('Error loading Apple Sign-In:', err);
// Don't show error - just hide the button
}
});
</script>
<!-- Only show if SDK loaded successfully (i.e., properly configured) -->
{#if sdkLoaded}
<div class="space-y-3">
{#if error}
<div class="rounded-xl bg-red-500/20 border border-red-500/30 p-3 text-sm text-red-500">
{error}
</div>
{/if}
<!-- Apple Sign-In Button -->
<button
onclick={handleAppleSignIn}
disabled={isLoading}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl bg-black border border-gray-800 px-4 font-medium text-white transition-all hover:bg-gray-900 disabled:opacity-50"
>
{#if isLoading}
<div class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
{:else}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path
d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
/>
</svg>
{/if}
<span>{isLoading ? 'Signing in...' : 'Continue with Apple'}</span>
</button>
</div>
{/if}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '$lib/components/Icon.svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
let { src, duration }: { src: string; duration?: number } = $props();

View file

@ -1,12 +1,12 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { recording } from '$lib/stores/recording';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
let mediaRecorder: MediaRecorder | null = null;
let audioChunks: Blob[] = [];
let stream: MediaStream | null = null;
let durationInterval: number;
let durationInterval: ReturnType<typeof setInterval> | undefined;
let startTime: number = 0;
let hasPermission = $state(false);

View file

@ -1,44 +0,0 @@
<script lang="ts">
export type BillingCycle = 'monthly' | 'yearly';
interface Props {
billingCycle: BillingCycle;
onChange: (cycle: BillingCycle) => void;
yearlyDiscount?: string;
}
let { billingCycle, onChange, yearlyDiscount = '33%' }: Props = $props();
</script>
<div class="mx-auto mb-2 flex max-w-lg rounded-lg p-1 bg-menu">
<button
onclick={() => onChange('monthly')}
class="flex flex-1 items-center justify-center rounded-md py-3 transition-colors"
class:bg-content={billingCycle === 'monthly'}
class:text-mana={billingCycle === 'monthly'}
class:font-bold={billingCycle === 'monthly'}
class:text-theme={billingCycle !== 'monthly'}
>
<span class="text-sm">
Monatlich
</span>
</button>
<button
onclick={() => onChange('yearly')}
class="flex flex-1 items-center justify-center gap-2 rounded-md py-3 transition-colors"
class:bg-content={billingCycle === 'yearly'}
class:text-mana={billingCycle === 'yearly'}
class:font-bold={billingCycle === 'yearly'}
class:text-theme={billingCycle !== 'yearly'}
>
<span class="text-sm">
Jährlich
</span>
{#if yearlyDiscount}
<span class="rounded-xl bg-mana px-2 py-1 text-xs font-bold text-white">
-{yearlyDiscount}
</span>
{/if}
</button>
</div>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { createAuthClient } from '$lib/supabaseClient';
import Modal from './Modal.svelte';
import { Modal } from '@manacore/shared-ui';
interface Prompt {
id: string;
@ -107,7 +107,7 @@
}
// Sort prompts by sort_order (ascending) then created_at (descending)
const sortedPrompts = [...promptsData].sort((a, b) => {
const sortedPrompts = [...(promptsData as Prompt[])].sort((a, b) => {
// First sort by sort_order (ascending)
if (a.sort_order !== undefined && b.sort_order !== undefined) {
if (a.sort_order !== b.sort_order) {

View file

@ -73,7 +73,7 @@
return;
}
blueprints = data || [];
blueprints = (data || []) as Blueprint[];
} catch (err) {
console.error('Unexpected error:', err);
error = $t('errors.unexpected');

View file

@ -1,56 +0,0 @@
<script lang="ts">
export interface CostItem {
action: string;
actionKey?: string;
cost: number;
icon: string;
}
interface Props {
costs: CostItem[];
}
let { costs }: Props = $props();
// Icon mapping
const iconPaths: Record<string, string> = {
'mic-outline':
'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z',
'chatbubble-outline':
'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
'add-circle-outline':
'M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z',
'copy-outline':
'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z'
};
</script>
<div class="rounded-xl p-4 bg-content border border-theme">
<h3 class="mb-4 text-xl font-bold text-theme">Mana-Kosten</h3>
<div class="space-y-3">
{#each costs as item}
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg
class="mr-2 h-[18px] w-[18px]"
fill="none"
stroke="#4287f5"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={iconPaths[item.icon] || iconPaths['mic-outline']} />
</svg>
<p class="text-sm text-theme-secondary">
{item.action}
</p>
</div>
<p class="text-base font-semibold text-theme">
{item.cost} Mana
</p>
</div>
{/each}
</div>
</div>

View file

@ -1,109 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth';
import { initializeGoogleAuth, renderGoogleButton, waitForGoogleAuth } from '$lib/utils/googleAuth';
// Props
interface Props {
onSuccess?: () => void;
onError?: (error: Error) => void;
}
let { onSuccess, onError }: Props = $props();
// State
let buttonContainer: HTMLDivElement;
let isLoading = $state(false);
let error = $state<string | null>(null);
// Handle Google Sign-In callback
async function handleGoogleSignIn(idToken: string) {
isLoading = true;
error = null;
try {
console.log('Google Sign-In successful, received ID token');
// Call auth store's signInWithGoogle method
// This handles everything: middleware call, token storage, state update
const result = await auth.signInWithGoogle(idToken);
if (!result.success) {
throw new Error(result.error || 'Failed to authenticate with Google');
}
console.log('Successfully authenticated with middleware');
// Navigate to dashboard
goto('/dashboard');
onSuccess?.();
} catch (err) {
console.error('Error during Google Sign-In:', err);
error = err instanceof Error ? err.message : 'Google Sign-In failed';
onError?.(err instanceof Error ? err : new Error('Unknown error during Google Sign-In'));
} finally {
isLoading = false;
}
}
// Initialize Google Sign-In on mount
onMount(async () => {
try {
// Wait for Google Identity Services to load
await waitForGoogleAuth();
// Initialize with callback
initializeGoogleAuth(handleGoogleSignIn);
// Render the button
if (buttonContainer) {
renderGoogleButton(buttonContainer, {
type: 'standard',
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'pill'
});
}
} catch (err) {
console.error('Error initializing Google Sign-In:', err);
error = 'Failed to load Google Sign-In';
}
});
</script>
{#if error}
<div class="rounded-xl bg-red-500/20 border border-red-500/30 p-3 text-sm text-red-500 mb-2">
{error}
</div>
{/if}
<!-- Google button container with fixed height to prevent layout shift -->
<div bind:this={buttonContainer} class="relative w-full google-btn-wrapper" style="min-height: 56px;">
{#if isLoading}
<div class="absolute inset-0 flex items-center justify-center rounded-xl bg-menu/80 backdrop-blur-sm z-10">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
</div>
{/if}
</div>
<style>
:global(.google-btn-wrapper > div) {
width: 100% !important;
height: 56px !important;
}
:global(.google-btn-wrapper iframe) {
height: 56px !important;
border-radius: 0.75rem !important;
}
</style>
<!-- Fallback message if Google SDK doesn't load -->
<noscript>
<div class="rounded-xl bg-yellow-500/20 border border-yellow-500/30 p-3 text-sm text-yellow-600 dark:text-yellow-400">
Please enable JavaScript to use Google Sign-In
</div>
</noscript>

View file

@ -1,21 +1,18 @@
<script lang="ts">
/**
* Central Icon Component for Memoro Web App
* Uses Phosphor Icons (Bold weight) from @phosphor-icons/core
*
* Usage:
* <Icon name="user-plus" size={24} />
* <Icon name="sign-in" size={20} class="text-primary" />
* Icon Component - Re-exports from @manacore/shared-icons
* Uses Phosphor Icons (Bold weight)
*/
import { iconPaths } from './icons/iconPaths';
import { iconPaths } from '@manacore/shared-icons';
interface Props {
name: keyof typeof iconPaths;
size?: number;
class?: string;
color?: string;
}
let { name, size = 24, class: className = '' }: Props = $props();
let { name, size = 24, class: className = '', color }: Props = $props();
const path = $derived(iconPaths[name]);
</script>
@ -25,7 +22,7 @@
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill="currentColor"
fill={color || 'currentColor'}
viewBox="0 0 256 256"
class={className}
aria-hidden="true"

View file

@ -1,16 +0,0 @@
<script lang="ts">
interface Props {
color?: string;
size?: number;
class?: string;
}
let { color = '#0099FF', size = 24, class: className = '' }: Props = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" class={className}>
<path
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
fill={color}
/>
</svg>

View file

@ -1,94 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from './Icon.svelte';
import Text from '$lib/components/atoms/Text.svelte';
interface Props {
visible: boolean;
onClose: () => void;
title?: string;
icon?: Snippet;
children: Snippet;
footer?: Snippet;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
showHeader?: boolean;
}
let { visible, onClose, title, icon, children, footer, maxWidth = 'lg', showHeader = true }: Props = $props();
const maxWidthClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl'
};
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && visible) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if visible}
<!-- Modal Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<!-- Modal Content -->
<div
class="relative flex max-h-[90vh] w-full {maxWidthClasses[
maxWidth
]} flex-col rounded-xl border border-theme bg-menu shadow-xl"
onclick={(e) => e.stopPropagation()}
>
{#if showHeader}
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-theme">
<div class="flex items-center gap-2 flex-1">
{#if icon}
{@render icon()}
{/if}
{#if title}
<Text variant="large" weight="semibold">
{title}
</Text>
{/if}
</div>
<button
onclick={onClose}
class="p-2 rounded-full hover:bg-menu-hover transition-colors"
aria-label="Schließen"
>
<Icon name="x" size={20} class="text-theme-muted" />
</button>
</div>
{/if}
<!-- Body (scrollable) -->
<div class="flex-1 overflow-y-auto p-6">
{@render children()}
</div>
<!-- Footer (optional) -->
{#if footer}
<div class="border-t border-theme p-6">
{@render footer()}
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,104 +0,0 @@
<script lang="ts">
import SubscriptionButton from './SubscriptionButton.svelte';
import ManaIcon from './ManaIcon.svelte';
export interface Package {
id: string;
name: string;
manaAmount: number;
price: number;
priceString?: string;
currencyCode?: string;
isTeamPackage?: boolean;
isEnterprisePackage?: boolean;
popular?: boolean;
}
interface Props {
package: Package;
onSelect: (packageId: string) => void;
}
let { package: pkg, onSelect }: Props = $props();
function formatPrice(pkg: Package) {
return pkg.priceString || `${pkg.price.toFixed(2).replace('.', ',')}€`;
}
// Package-specific colors and background sizes
function getPackageStyles() {
switch (pkg.id) {
case 'Mana_Potion_Small_v1':
return { bg: '#E3F2FD', icon: '#2196F3', bgSize: '45%' };
case 'Mana_Potion_Medium_v1':
return { bg: '#BBDEFB', icon: '#1976D2', bgSize: '60%' };
case 'Mana_Potion_Large_v1':
return { bg: '#90CAF9', icon: '#1565C0', bgSize: '75%' };
case 'Mana_Potion_Giant_v2':
return { bg: '#64B5F6', icon: '#0D47A1', bgSize: '90%' };
default:
return { bg: '#E1F5FE', icon: '#0288D1', bgSize: '50%' };
}
}
const packageStyles = $derived(getPackageStyles());
// Hover state
let isHovered = $state(false);
</script>
<div
class="relative rounded-xl p-4 transition-all duration-200 bg-content border hover:-translate-y-0.5 hover:shadow-lg"
class:border-mana={pkg.popular}
class:border-theme={!pkg.popular}
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
>
{#if pkg.popular}
<div class="absolute -top-3 right-4 rounded-xl bg-mana px-3 py-1 text-xs font-bold text-white">
Popular
</div>
{/if}
<!-- Package Name -->
<h3 class="mb-4 text-center text-lg font-bold text-theme">
{pkg.name}
</h3>
<!-- Three column layout -->
<div class="mb-5 flex justify-between gap-2">
<!-- Mana Icon with background -->
<div class="flex aspect-square flex-1 items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<div
class="flex items-center justify-center rounded-lg"
style="width: {packageStyles.bgSize}; height: {packageStyles.bgSize}; background-color: {packageStyles.bg};"
>
<ManaIcon size={32} color={packageStyles.icon} />
</div>
</div>
<!-- Mana Amount -->
<div class="flex aspect-square flex-1 flex-col items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<p class="mb-0.5 text-2xl font-bold text-theme">
{pkg.manaAmount}
</p>
<p class="text-center text-xs text-theme-secondary">Mana</p>
</div>
<!-- Price -->
<div class="flex aspect-square flex-1 flex-col items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<p class="text-xl font-bold text-theme">
{formatPrice(pkg)}
</p>
<p class="mt-0.5 text-[10px] text-theme-secondary">Einmalig</p>
</div>
</div>
<SubscriptionButton
label="Kaufen"
onclick={() => onSelect(pkg.id)}
iconName="arrow-forward-outline"
leftIconName="cart-outline"
variant={pkg.popular ? 'accent' : 'primary'}
/>
</div>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import Toggle from './Toggle.svelte';
import { Toggle } from '@manacore/shared-ui';
interface Props {
title: string;

View file

@ -2,7 +2,7 @@
import { page } from '$app/stores';
import { user } from '$lib/stores/auth';
import { theme } from '$lib/stores/theme';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
onLogout: () => void;

View file

@ -1,70 +0,0 @@
<script lang="ts">
interface Props {
label: string;
onclick: () => void;
iconName?: string;
leftIconName?: string;
variant?: 'primary' | 'secondary' | 'accent';
disabled?: boolean;
}
let {
label,
onclick,
iconName = 'arrow-forward-outline',
leftIconName = 'cart-outline',
variant = 'primary',
disabled = false
}: Props = $props();
// Icon mapping (simple SVG paths for common icons)
const iconPaths: Record<string, string> = {
'arrow-forward-outline': 'M5 12h14M12 5l7 7-7 7',
'checkmark-circle-outline': 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
'cart-outline':
'M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z'
};
</script>
<button
{disabled}
onclick={disabled ? undefined : onclick}
class="flex w-full h-12 items-center justify-between rounded-lg border px-4 font-medium transition-all duration-200"
class:bg-mana={variant === 'accent' && !disabled}
class:border-mana={variant === 'accent' && !disabled}
class:text-white={variant === 'accent' && !disabled}
class:bg-menu={variant === 'primary' && !disabled}
class:border-theme={variant === 'primary' && !disabled}
class:text-theme={variant === 'primary' && !disabled}
class:bg-content={variant === 'secondary' && !disabled}
class:hover:bg-menu-hover={!disabled}
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
>
<div class="flex items-center justify-center">
<svg
class="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={iconPaths[leftIconName] || iconPaths['cart-outline']} />
</svg>
<span>{label}</span>
</div>
<svg
class="ml-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={iconPaths[iconName] || iconPaths['arrow-forward-outline']} />
</svg>
</button>

View file

@ -1,136 +0,0 @@
<script lang="ts">
import SubscriptionButton from './SubscriptionButton.svelte';
import ManaIcon from './ManaIcon.svelte';
export interface SubscriptionPlan {
id: string;
name: string;
price: number;
priceString?: string;
currencyCode?: string;
priceBreakdown?: string;
monthlyMana: number;
canGiftMana: boolean;
popular?: boolean;
billingCycle?: 'monthly' | 'yearly';
monthlyEquivalent?: number;
isTeamSubscription?: boolean;
isEnterpriseSubscription?: boolean;
}
interface Props {
plan: SubscriptionPlan;
onSelect: (planId: string) => void;
isCurrentPlan?: boolean;
isLegacy?: boolean;
}
let { plan, onSelect, isCurrentPlan = false, isLegacy = false }: Props = $props();
function formatPrice(plan: SubscriptionPlan) {
return plan.priceString || `${plan.price.toFixed(2).replace('.', ',')}€`;
}
// Tier-specific background colors and sizes for Mana icon
function getTierStyles() {
switch (plan.id) {
case 'free':
return { bg: '#F5F5F5', icon: '#9E9E9E', bgSize: '30%' };
case 'Mana_Stream_Small_v1':
case 'Mana_Stream_Small_Yearly_v1':
return { bg: '#E3F2FD', icon: '#2196F3', bgSize: '45%' };
case 'Mana_Stream_Medium_v1':
case 'Mana_Stream_Medium_Yearly_v1':
return { bg: '#BBDEFB', icon: '#1976D2', bgSize: '60%' };
case 'Mana_Stream_Large_v1':
case 'Mana_Stream_Large_Yearly_v1':
return { bg: '#90CAF9', icon: '#1565C0', bgSize: '75%' };
case 'Mana_Stream_Giant_v1':
case 'Mana_Stream_Giant_Yearly_v1':
return { bg: '#64B5F6', icon: '#0D47A1', bgSize: '90%' };
default:
return { bg: '#E1F5FE', icon: '#0288D1', bgSize: '50%' };
}
}
const tierStyles = $derived(getTierStyles());
// Hover state
let isHovered = $state(false);
</script>
<div
class="relative rounded-xl p-4 transition-all duration-200 bg-content border hover:-translate-y-0.5 hover:shadow-lg"
class:border-2={isCurrentPlan}
class:border-mana={isCurrentPlan || plan.popular}
class:border-theme={!isCurrentPlan && !plan.popular}
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
>
{#if isCurrentPlan}
<div class="absolute -top-3 left-4 rounded-xl bg-mana px-3 py-1 text-xs font-bold text-white">
{isLegacy ? 'Legacy Plan' : 'Current Plan'}
</div>
{/if}
{#if plan.popular && !isCurrentPlan}
<div class="absolute -top-3 right-4 rounded-xl bg-mana px-3 py-1 text-xs font-bold text-white">
Popular
</div>
{/if}
<!-- Tier Name -->
<h3 class="mb-4 text-center text-lg font-bold text-theme">
{plan.name}
</h3>
<!-- Three column layout -->
<div class="mb-5 flex justify-between gap-2">
<!-- Mana Icon with background -->
<div class="flex aspect-square flex-1 items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<div
class="flex items-center justify-center rounded-lg"
style="width: {tierStyles.bgSize}; height: {tierStyles.bgSize}; background-color: {tierStyles.bg};"
>
<ManaIcon size={32} color={tierStyles.icon} />
</div>
</div>
<!-- Mana Amount -->
<div class="flex aspect-square flex-1 flex-col items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<p class="mb-0.5 text-2xl font-bold text-theme">
{plan.monthlyMana}
</p>
<p class="text-center text-xs text-theme-secondary">pro Monat</p>
</div>
<!-- Price -->
<div class="flex aspect-square flex-1 flex-col items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<p class="text-xl font-bold text-theme">
{formatPrice(plan)}
</p>
<p class="mt-0.5 text-xs text-theme-secondary">
{plan.billingCycle === 'yearly' ? 'pro Jahr' : 'pro Monat'}
</p>
{#if plan.billingCycle === 'yearly' && plan.monthlyEquivalent}
<p class="mt-0 text-[9px] text-theme-secondary">
({plan.monthlyEquivalent.toFixed(2).replace('.', ',')}€/Monat)
</p>
{/if}
</div>
</div>
<!-- Button only show if NOT free plan -->
{#if plan.id !== 'free'}
<SubscriptionButton
label={isCurrentPlan
? isLegacy
? 'Dein Legacy-Plan'
: 'Dein Plan'
: 'Kaufen'}
onclick={() => onSelect(plan.id)}
iconName={isCurrentPlan ? 'checkmark-circle-outline' : 'arrow-forward-outline'}
variant={isCurrentPlan ? 'secondary' : plan.popular ? 'accent' : 'primary'}
disabled={isCurrentPlan}
/>
{/if}
</div>

View file

@ -1,8 +1,7 @@
<script lang="ts">
import type { Tag } from '$lib/types/memo.types';
import Modal from '$lib/components/Modal.svelte';
import { Modal, Text } from '@manacore/shared-ui';
import Icon from '$lib/components/Icon.svelte';
import Text from '$lib/components/atoms/Text.svelte';
interface Props {
tag: Tag;
@ -65,9 +64,9 @@
{#snippet children()}
<!-- Tag Name -->
<div class="mb-6">
<Text variant="small" weight="medium" class="mb-2 block" for="tag-name">
<label for="tag-name" class="mb-2 block text-sm font-medium text-theme">
Tag-Name
</Text>
</label>
<input
id="tag-name"
type="text"

View file

@ -22,7 +22,7 @@
let newTagName = $state('');
let newTagColor = $state('#3b82f6');
const tagService = new TagService(supabase);
const tagService = new TagService();
onMount(async () => {
if ($tags.length === 0) {

View file

@ -1,31 +0,0 @@
<script lang="ts">
interface Props {
isOn: boolean;
onToggle: (value: boolean) => void;
disabled?: boolean;
}
let { isOn = false, onToggle, disabled = false }: Props = $props();
function handleToggle() {
if (!disabled) {
onToggle(!isOn);
}
}
</script>
<button
onclick={handleToggle}
class="relative h-8 w-14 flex-shrink-0 rounded-full transition-colors {isOn
? 'bg-primary'
: 'bg-menu'} {disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}"
role="switch"
aria-checked={isOn}
{disabled}
>
<span
class="absolute top-1 left-1 h-6 w-6 rounded-full bg-white shadow-md transition-transform {isOn
? 'translate-x-6'
: 'translate-x-0'}"
></span>
</button>

View file

@ -1,78 +0,0 @@
<script lang="ts">
export interface UsageData {
total: number;
lastWeek: number;
lastMonth: number;
currentMana: number;
maxMana: number;
history?: Array<{
date: string;
amount: number;
}>;
}
interface Props {
title?: string;
usageData: UsageData;
currentPlan?: string;
}
let { title, usageData, currentPlan }: Props = $props();
// Use real credits (this would normally come from a store/API)
const currentMana = usageData.currentMana;
// Calculate used vs available Mana
const usedMana = usageData.maxMana - currentMana;
const formattedCurrentMana = currentMana.toString();
const formattedUsedMana = usedMana.toString();
const calculatedPercentage = Math.round((currentMana / usageData.maxMana) * 100);
// Minimum 1% for numbers up to 5, so that a small blue bar is always visible
const availablePercentage =
currentMana <= 5 && currentMana > 0 ? Math.max(1, calculatedPercentage) : calculatedPercentage;
</script>
<div class="rounded-2xl p-5 bg-content border border-theme shadow-lg">
<!-- Mana Progress Bar -->
<div>
<div class="mb-4 flex items-start justify-between">
<div class="flex-1">
<h2 class="text-xl font-bold text-theme">Dein Mana</h2>
</div>
<div class="flex items-end">
<div class="self-start rounded-xl px-4 py-1.5 bg-menu">
<p class="text-xl font-bold text-theme">
{formattedCurrentMana}
</p>
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="relative mb-2 h-4 overflow-hidden rounded-lg bg-menu">
<div
class="h-full rounded-lg"
style="width: {availablePercentage}%; background: linear-gradient(90deg, #4287f5 0%, #66B2FF 100%); box-shadow: 0 0 4px #4287f580;"
></div>
</div>
<!-- Percentage -->
<div class="flex justify-between">
<p class="text-sm font-medium text-theme-secondary">
{availablePercentage}% verfügbar
</p>
<p class="text-sm font-medium text-theme-secondary">
{formattedUsedMana} verbraucht
</p>
</div>
<!-- Current Plan -->
{#if currentPlan}
<div class="mt-3 border-t border-theme pt-3">
<p class="text-center text-sm font-medium text-theme-secondary">
Aktueller Plan: {currentPlan}
</p>
</div>
{/if}
</div>
</div>

View file

@ -1,50 +0,0 @@
<script lang="ts">
type TextVariant = 'body' | 'body-secondary' | 'small' | 'large' | 'muted';
type TextAlign = 'left' | 'center' | 'right';
type TextWeight = 'normal' | 'medium' | 'semibold' | 'bold';
interface Props {
variant?: TextVariant;
align?: TextAlign;
weight?: TextWeight;
class?: string;
children?: any;
}
let {
variant = 'body',
align = 'left',
weight = 'normal',
class: className = '',
children
}: Props = $props();
const variantClasses: Record<TextVariant, string> = {
body: 'text-base text-theme leading-relaxed',
'body-secondary': 'text-base text-theme-secondary leading-relaxed',
small: 'text-sm text-theme',
large: 'text-lg text-theme leading-relaxed',
muted: 'text-sm text-theme-muted'
};
const alignClasses: Record<TextAlign, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right'
};
const weightClasses: Record<TextWeight, string> = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold'
};
const classes = $derived(
`${variantClasses[variant]} ${alignClasses[align]} ${weightClasses[weight]} ${className}`
);
</script>
<p class={classes}>
{@render children?.()}
</p>

View file

@ -1 +0,0 @@
export { default as Text } from './Text.svelte';

View file

@ -1,7 +1,7 @@
<script lang="ts">
import type { AudioArchiveStats } from '$lib/services/audioStorageService';
import { audioStorageService } from '$lib/services/audioStorageService';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
stats: AudioArchiveStats;

View file

@ -4,7 +4,7 @@
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
import Icon from '$lib/components/Icon.svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
audioFile: AudioFileInfo;

View file

@ -2,7 +2,7 @@
import type { AudioFileInfo } from '$lib/services/audioStorageService';
import AudioFileCard from './AudioFileCard.svelte';
import Icon from '$lib/components/Icon.svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
audioFiles: AudioFileInfo[];

View file

@ -1,131 +0,0 @@
# Icon System
Centralized icon management using **Phosphor Icons (Bold weight)** from `@phosphor-icons/core`.
## Usage
Import and use the `Icon` component throughout the application:
```svelte
<script>
import Icon from '$lib/components/Icon.svelte';
</script>
<!-- Basic usage -->
<Icon name="user" size={24} />
<!-- With custom size -->
<Icon name="heart" size={32} />
<!-- With custom classes -->
<Icon name="star" size={20} class="text-primary hover:text-primary-dark" />
```
## Available Icons
See `iconPaths.ts` for the complete list of available icons. Some commonly used icons:
### Auth & User
- `user`, `user-plus`, `users`, `sign-in`, `sign-out`
### Navigation
- `arrow-left`, `arrow-right`, `arrow-up`, `arrow-down`
- `caret-down`, `caret-up`, `caret-left`, `caret-right`
### Actions
- `plus`, `minus`, `x`, `check`
- `trash`, `copy`, `share`
- `download`, `upload`
### Media
- `play`, `pause`, `microphone`
### Edit
- `pencil`, `pen`, `note-pencil`
### Files & Folders
- `folder`, `folder-open`, `file`
### UI Elements
- `dots-three`, `dots-three-vertical`, `list`
- `magnifying-glass`, `eye`, `eye-slash`
### Misc
- `key`, `tag`, `link`, `lock`
- `star`, `heart`, `bell`
- `calendar`, `clock`, `image`
## Adding New Icons
1. Find the icon you need at [phosphoricons.com](https://phosphoricons.com/)
2. Locate the bold version in `node_modules/@phosphor-icons/core/assets/bold/`
3. Copy the SVG `<path>` content
4. Add it to `iconPaths.ts`:
```typescript
export const iconPaths = {
// ... existing icons
'new-icon': '<path d="..." />',
}
```
5. Use it:
```svelte
<Icon name="new-icon" size={24} />
```
## Icon Weight
All icons use the **Bold** weight for consistency across the application. This provides:
- Better visibility
- Consistent visual hierarchy
- Improved readability at smaller sizes
## TypeScript Support
The Icon component is fully typed. TypeScript will autocomplete icon names and show errors if you use an icon that doesn't exist.
```svelte
<!-- ✅ Valid - TypeScript knows this icon exists -->
<Icon name="user" />
<!-- ❌ Error - TypeScript will show an error -->
<Icon name="invalid-icon" />
```
## Best Practices
1. **Use semantic names**: Icons should have clear, descriptive names
2. **Consistent sizing**: Stick to common sizes (16, 20, 24, 32, 40)
3. **Color inheritance**: Icons use `currentColor` - control color via text color classes
4. **Accessibility**: Icons are marked with `aria-hidden="true"` - provide text alternatives when needed
## Examples
### Button with Icon
```svelte
<button class="flex items-center gap-2">
<Icon name="plus" size={20} />
Add Item
</button>
```
### Icon Button
```svelte
<button class="p-2 hover:bg-gray-100 rounded">
<Icon name="trash" size={20} class="text-red-500" />
</button>
```
### Loading State
```svelte
<button disabled={loading}>
{#if loading}
<Icon name="arrow-clockwise" size={20} class="animate-spin" />
{:else}
<Icon name="upload" size={20} />
{/if}
Upload
</button>
```

View file

@ -1,88 +0,0 @@
/**
* Phosphor Icons (Bold weight) - Official icons from @phosphor-icons/core
*
* This is a centralized icon catalog for the entire application.
* All icons use the Bold weight for consistency.
*
* To add new icons:
* 1. Find the icon in node_modules/@phosphor-icons/core/assets/bold/
* 2. Copy the SVG content (the <path> tag)
* 3. Add it to this file with a descriptive key
*
* Usage:
* import Icon from '$lib/components/Icon.svelte';
* <Icon name="user-plus" size={24} />
*/
export const iconPaths = {
// Auth & User
'user-plus': '<path d="M256,136a12,12,0,0,1-12,12h-8v8a12,12,0,0,1-24,0v-8h-8a12,12,0,0,1,0-24h8v-8a12,12,0,0,1,24,0v8h8A12,12,0,0,1,256,136Zm-54.81,56.28a12,12,0,1,1-18.38,15.44C169.12,191.42,145,172,108,172c-28.89,0-55.46,12.68-74.81,35.72a12,12,0,0,1-18.38-15.44A124.08,124.08,0,0,1,63.5,156.53a72,72,0,1,1,89,0A124,124,0,0,1,201.19,192.28ZM108,148a48,48,0,1,0-48-48A48.05,48.05,0,0,0,108,148Z"/>',
'sign-in': '<path d="M144.49,136.49l-40,40a12,12,0,0,1-17-17L107,140H24a12,12,0,0,1,0-24h83L87.51,96.49a12,12,0,0,1,17-17l40,40A12,12,0,0,1,144.49,136.49ZM200,28H136a12,12,0,0,0,0,24h52V204H136a12,12,0,0,0,0,24h64a12,12,0,0,0,12-12V40A12,12,0,0,0,200,28Z"/>',
'sign-out': '<path d="M116,216a12,12,0,0,1-12,12H48a20,20,0,0,1-20-20V48A20,20,0,0,1,48,28h56a12,12,0,0,1,0,24H52V204h52A12,12,0,0,1,116,216Zm108.49-96.49-40-40a12,12,0,0,0-17,17L187,116H104a12,12,0,0,0,0,24h83l-19.52,19.51a12,12,0,0,0,17,17l40-40A12,12,0,0,0,224.49,119.51Z"/>',
'user': '<path d="M234.38,210a123.36,123.36,0,0,0-60.78-53.23,76,76,0,1,0-91.2,0A123.36,123.36,0,0,0,21.62,210a12,12,0,1,0,20.77,12c18.12-31.32,50.12-50,85.61-50s67.49,18.69,85.61,50a12,12,0,0,0,20.77-12ZM76,96a52,52,0,1,1,52,52A52.06,52.06,0,0,1,76,96Z"/>',
'users': '<path d="M125.18,156.94a64,64,0,1,0-82.36,0,100.23,100.23,0,0,0-39.49,32,12,12,0,0,0,20.54,12.08C36.18,184.88,55.25,172,76,172c22.44,0,41.84,14.31,54.86,40.39a12,12,0,1,0,21.19-11.17A99.33,99.33,0,0,0,125.18,156.94ZM44,108a40,40,0,1,1,40,40A40,40,0,0,1,44,108Zm206.1,97.67a12,12,0,0,1-16.78-2.57C221.44,183.6,202,172,180,172a41.86,41.86,0,0,0-13.54,2.24,12,12,0,0,1-7.84-22.68,64.36,64.36,0,0,0,35.69-27.15,64,64,0,1,0-43.49-45.85,12,12,0,0,1-23.71-3.15,88,88,0,1,1,73.54,84.28,99.33,99.33,0,0,1,26.84,44.53A12,12,0,0,1,250.1,205.67Z"/>',
// Navigation & Arrows
'arrow-left': '<path d="M228,128a12,12,0,0,1-12,12H69l51.52,51.51a12,12,0,0,1-17,17l-72-72a12,12,0,0,1,0-17l72-72a12,12,0,0,1,17,17L69,116H216A12,12,0,0,1,228,128Z"/>',
'arrow-right': '<path d="M224.49,136.49l-72,72a12,12,0,0,1-17-17L187,140H40a12,12,0,0,1,0-24H187L135.51,64.48a12,12,0,0,1,17-17l72,72A12,12,0,0,1,224.49,136.49Z"/>',
'arrow-up': '<path d="M208.49,152.49a12,12,0,0,1-17,17L140,118v98a12,12,0,0,1-24,0V118L64.49,169.51a12,12,0,0,1-17-17l72-72a12,12,0,0,1,17,0Z"/>',
'arrow-down': '<path d="M208.49,168.49l-72,72a12,12,0,0,1-17,0l-72-72a12,12,0,0,1,17-17L116,203V40a12,12,0,0,1,24,0V203l51.51-51.52a12,12,0,0,1,17,17Z"/>',
'caret-down': '<path d="M216.49,104.49l-80,80a12,12,0,0,1-17,0l-80-80a12,12,0,0,1,17-17L128,159l71.51-71.52a12,12,0,0,1,17,17Z"/>',
'caret-up': '<path d="M216.49,168.49a12,12,0,0,1-17,17L128,114,56.49,185.51a12,12,0,0,1-17-17l80-80a12,12,0,0,1,17,0Z"/>',
'caret-left': '<path d="M168.49,199.51a12,12,0,0,1-17,17l-80-80a12,12,0,0,1,0-17l80-80a12,12,0,0,1,17,17L97,128Z"/>',
'caret-right': '<path d="M184.49,136.49l-80,80a12,12,0,0,1-17-17L159,128,87.51,56.49a12,12,0,0,1,17-17l80,80A12,12,0,0,1,184.49,136.49Z"/>',
// Actions
'plus': '<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"/>',
'minus': '<path d="M228,128a12,12,0,0,1-12,12H40a12,12,0,0,1,0-24H216A12,12,0,0,1,228,128Z"/>',
'x': '<path d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z"/>',
'check': '<path d="M232.49,80.49l-128,128a12,12,0,0,1-17,0l-56-56a12,12,0,1,1,17-17L96,183,215.51,63.51a12,12,0,0,1,17,17Z"/>',
'trash': '<path d="M216,48H180V36A28,28,0,0,0,152,8H104A28,28,0,0,0,76,36V48H40a12,12,0,0,0,0,24h4V208a20,20,0,0,0,20,20H192a20,20,0,0,0,20-20V72h4a12,12,0,0,0,0-24ZM100,36a4,4,0,0,1,4-4h48a4,4,0,0,1,4,4V48H100Zm88,168H68V72H188ZM116,104v64a12,12,0,0,1-24,0V104a12,12,0,0,1,24,0Zm48,0v64a12,12,0,0,1-24,0V104a12,12,0,0,1,24,0Z"/>',
'copy': '<path d="M216,28H88A12,12,0,0,0,76,40V76H40A12,12,0,0,0,28,88V216a12,12,0,0,0,12,12H168a12,12,0,0,0,12-12V180h36a12,12,0,0,0,12-12V40A12,12,0,0,0,216,28ZM156,204H52V100H156Zm48-48H180V88a12,12,0,0,0-12-12H100V52H204Z"/>',
// Media
'play': '<path d="M240.82,114.67,88.82,26.67a16,16,0,0,0-16.12-.41A15.68,15.68,0,0,0,64,40.36V215.64a15.68,15.68,0,0,0,8.7,14.1,16,16,0,0,0,16.12-.41l152-88a15.76,15.76,0,0,0,0-26.66ZM88,199.93V56.07L216.16,128Z"/>',
'pause': '<path d="M200,28H160a20,20,0,0,0-20,20V208a20,20,0,0,0,20,20h40a20,20,0,0,0,20-20V48A20,20,0,0,0,200,28Zm-4,176H164V52h32ZM96,28H56A20,20,0,0,0,36,48V208a20,20,0,0,0,20,20H96a20,20,0,0,0,20-20V48A20,20,0,0,0,96,28ZM92,204H60V52H92Z"/>',
'microphone': '<path d="M128,176a52.06,52.06,0,0,0,52-52V64a52,52,0,0,0-104,0v60A52.06,52.06,0,0,0,128,176ZM100,64a28,28,0,0,1,56,0v60a28,28,0,0,1-56,0Zm112,56a12,12,0,0,1-24,0,76,76,0,0,0-152,0,12,12,0,0,1-24,0,100.11,100.11,0,0,1,88-99.26V8a12,12,0,0,1,24,0V20.74A100.11,100.11,0,0,1,212,120Z"/>',
'skip-back': '<path d="M201.75,30.52a20,20,0,0,0-20.3.53L68,102V40a12,12,0,0,0-24,0V216a12,12,0,0,0,24,0V154l113.45,71A20,20,0,0,0,212,208.12V47.88A19.86,19.86,0,0,0,201.75,30.52ZM188,200.73,71.7,128,188,55.27Z"/>',
'skip-forward': '<path d="M200,28a12,12,0,0,0-12,12v62l-113.45-71A20,20,0,0,0,44,47.88V208.12A20,20,0,0,0,74.55,225L188,154v62a12,12,0,0,0,24,0V40A12,12,0,0,0,200,28ZM68,200.73V55.27L184.3,128Z"/>',
// Edit
'pencil': '<path d="M230.14,70.54,185.46,25.85a20,20,0,0,0-28.29,0L33.86,149.17A19.85,19.85,0,0,0,28,163.31V208a20,20,0,0,0,20,20H92.69a19.86,19.86,0,0,0,14.14-5.86L230.14,98.82a20,20,0,0,0,0-28.28ZM91,204H52V165l84-84,39,39ZM192,103,153,64l18.34-18.34,39,39Z"/>',
'pen': '<path d="M228.12,67.07,188.93,27.88a20,20,0,0,0-28.28,0L33.88,154.64A19.86,19.86,0,0,0,28,168.78V208a20,20,0,0,0,20,20H87.22a19.86,19.86,0,0,0,14.14-5.86L228.12,95.35a20,20,0,0,0,0-28.28ZM91,204H52V165l84-84,39,39ZM192,103,153,64l21.52-21.52L213.49,81.5Z"/>',
'note-pencil': '<path d="M228,128v80a20,20,0,0,1-20,20H48a20,20,0,0,1-20-20V48A20,20,0,0,1,48,28h80a12,12,0,0,1,0,24H52V204H204V132a12,12,0,0,1,24,0Zm-26.34-84.49a12,12,0,0,0-17,0L112.3,115.86a20,20,0,0,0-5.59,11.63l-5.46,32.74a12,12,0,0,0,14.79,14.05l32.73-8.73a20.08,20.08,0,0,0,11.07-6.41l72.4-72.39a12,12,0,0,0,0-17Zm-47.29,63.9-18.64,5-3.53-3.52,4.95-18.63,40.31-40.31,16.72,16.72Z"/>',
// Files & Folders
'folder': '<path d="M216,68H130.67L102.93,51.2a20.12,20.12,0,0,0-11.07-3.2H40A20,20,0,0,0,20,68V200a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V88A20,20,0,0,0,216,68Zm-4,128H44V92H212Z"/>',
'folder-open': '<path d="M248.23,112.31l-60,112A12,12,0,0,1,177.6,228H36a20,20,0,0,1-20-20V88A20,20,0,0,1,36,68H88a11.93,11.93,0,0,1,7.88,2.93l29.51,25.89A4,4,0,0,0,128,98h88a12,12,0,0,1,10.62,6.38A11.88,11.88,0,0,1,248.23,112.31ZM218.72,122H123.88L96,99.47,40,92v112H172.52Z"/>',
'file': '<path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A20,20,0,0,0,36,44V212a20,20,0,0,0,20,20H200a20,20,0,0,0,20-20V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM196,208H60V48h76V92a12,12,0,0,0,12,12h48Z"/>',
// UI Elements
'dots-three': '<path d="M144,128a16,16,0,1,1-16-16A16,16,0,0,1,144,128Zm56-16a16,16,0,1,0,16,16A16,16,0,0,0,200,112ZM56,112a16,16,0,1,0,16,16A16,16,0,0,0,56,112Z"/>',
'dots-three-vertical': '<path d="M128,112a16,16,0,1,0,16,16A16,16,0,0,0,128,112Zm0-56a16,16,0,1,0-16-16A16,16,0,0,0,128,56Zm0,144a16,16,0,1,0,16,16A16,16,0,0,0,128,200Z"/>',
'list': '<path d="M228,128a12,12,0,0,1-12,12H40a12,12,0,0,1,0-24H216A12,12,0,0,1,228,128ZM40,76H216a12,12,0,0,0,0-24H40a12,12,0,0,0,0,24ZM216,180H40a12,12,0,0,0,0,24H216a12,12,0,0,0,0-24Z"/>',
'magnifying-glass': '<path d="M232.49,215.51,185,168a92.12,92.12,0,1,0-17,17l47.53,47.54a12,12,0,0,0,17-17ZM44,112a68,68,0,1,1,68,68A68.07,68.07,0,0,1,44,112Z"/>',
// Misc
'key': '<path d="M196,76a16,16,0,1,1-16-16A16,16,0,0,1,196,76Zm48,22.74A84.3,84.3,0,0,1,160.11,180H160a83.52,83.52,0,0,1-23.65-3.38l-7.86,7.87A12,12,0,0,1,120,188H108v12a12,12,0,0,1-12,12H84v12a12,12,0,0,1-12,12H40a20,20,0,0,1-20-20V187.31a19.86,19.86,0,0,1,5.86-14.14l53.52-53.52A84,84,0,1,1,244,98.74ZM202.43,53.57A59.48,59.48,0,0,0,158,36c-32,1-58,27.89-58,59.89a59.69,59.69,0,0,0,4.2,22.19,12,12,0,0,1-2.55,13.21L44,189v23H60V200a12,12,0,0,1,12-12H84V176a12,12,0,0,1,12-12h19l9.65-9.65a12,12,0,0,1,13.22-2.55A59.58,59.58,0,0,0,160,156h.08c32,0,58.87-26.07,59.89-58A59.55,59.55,0,0,0,202.43,53.57Z"/>',
'info': '<path d="M108,84a16,16,0,1,1,16,16A16,16,0,0,1,108,84Zm128,44A108,108,0,1,1,128,20,108.12,108.12,0,0,1,236,128Zm-24,0a84,84,0,1,0-84,84A84.09,84.09,0,0,0,212,128Zm-72,36.68V132a20,20,0,0,0-20-20,12,12,0,0,0-4,23.32V168a20,20,0,0,0,20,20,12,12,0,0,0,4-23.32Z"/>',
'tag': '<path d="M246.66,123.56,201,55.13A20,20,0,0,0,184.06,44H39.38A20.07,20.07,0,0,0,20,63.38V192.62A20.07,20.07,0,0,0,39.38,212H184.06a20,20,0,0,0,16.94-11.13l45.66-68.43A12,12,0,0,0,246.66,123.56ZM180.91,188H44V68H180.91l40,60ZM96,84a16,16,0,1,1-16,16A16,16,0,0,1,96,84Z"/>',
'share': '<path d="M236,200a12,12,0,0,1-12,12H32a12,12,0,0,1,0-24H224A12,12,0,0,1,236,200ZM88.49,80.49,116,53v83a12,12,0,0,0,24,0V53l27.51,27.52a12,12,0,1,0,17-17l-48-48a12,12,0,0,0-17,0l-48,48a12,12,0,1,0,17,17Z"/>',
'download': '<path d="M228,144v64a20,20,0,0,1-20,20H48a20,20,0,0,1-20-20V144a12,12,0,0,1,24,0v60H204V144a12,12,0,0,1,24,0Zm-108.49,8.49a12,12,0,0,0,17,0l40-40a12,12,0,0,0-17-17L140,115V24a12,12,0,0,0-24,0v91L96.49,95.51a12,12,0,0,0-17,17Z"/>',
'upload': '<path d="M228,144v64a20,20,0,0,1-20,20H48a20,20,0,0,1-20-20V144a12,12,0,0,1,24,0v60H204V144a12,12,0,0,1,24,0Zm-108.49-4.49a12,12,0,0,0,17,0l40-40a12,12,0,0,0-17-17L140,101V24a12,12,0,0,0-24,0v77L96.49,81.51a12,12,0,1,0-17,17Z"/>',
'link': '<path d="M122.34,109.66a12,12,0,0,1,0-17l32-32a52,52,0,0,1,73.56,73.56L196,166.14a52,52,0,0,1-73.56,0,12,12,0,1,1,17-17,28,28,0,0,0,39.6,0l31.89-31.88a28,28,0,0,0-39.6-39.6l-32,32A12,12,0,0,1,122.34,109.66Zm-20.68,36.68-32,32a28,28,0,0,0,39.6,39.6l31.89-31.88a28,28,0,0,0,0-39.6,12,12,0,0,0-17,17,4,4,0,0,1,0,5.66L92.22,200.94a4,4,0,0,1-5.66,0,4,4,0,0,1,0-5.66l32-32a12,12,0,0,0-17-17Z"/>',
'eye': '<path d="M251.89,122.47a140.13,140.13,0,0,0-46.65-50.37C185,60,157.49,52,128,52S71,60,50.76,72.1a140.13,140.13,0,0,0-46.65,50.37,12.44,12.44,0,0,0,0,11.06,140.13,140.13,0,0,0,46.65,50.37C71,196,98.51,204,128,204s57-8,77.24-20.1a140.13,140.13,0,0,0,46.65-50.37A12.44,12.44,0,0,0,251.89,122.47ZM128,180c-42.86,0-78.64-23.44-105.73-63C49.36,77.44,85.14,54,128,54s78.64,23.44,105.73,63C206.64,156.56,170.86,180,128,180Zm0-98a34,34,0,1,0,34,34A34,34,0,0,0,128,82Zm0,44a10,10,0,1,1,10-10A10,10,0,0,1,128,126Z"/>',
'eye-slash': '<path d="M55.58,37.76a12,12,0,0,0-17,17l20.7,22.77C29.09,102.4,11.72,137.48,4.09,151.56a12.44,12.44,0,0,0,0,11.06A140.13,140.13,0,0,0,50.74,213c20.26,12.12,47.75,20.1,77.26,20.1a127.36,127.36,0,0,0,51.71-10.52l22.71,25a12,12,0,0,0,17-17Zm49.77,120A34,34,0,0,1,94,128a33.55,33.55,0,0,1,1-8.27l45.64,50.21A33.56,33.56,0,0,1,128,172,34,34,0,0,1,105.35,157.76Zm116.81,53.3a12,12,0,0,1-6.16,16,128,128,0,0,1-51.62,9.78,34,34,0,0,1-45.31-50.35L98.11,163.44a58.05,58.05,0,0,0,79.46,19.74,34,34,0,0,0,12.33-12A127.85,127.85,0,0,0,206.16,211.06ZM237.94,133.56a140.13,140.13,0,0,1-46.65,50.37A130.12,130.12,0,0,1,128,203.93a57.6,57.6,0,0,1-15.47-1.9l-19.24-21.16a82.06,82.06,0,0,1,0-117.74A82,82,0,0,1,128,50h.09a133.85,133.85,0,0,1,63,19.93,140.13,140.13,0,0,1,46.65,50.37A12.44,12.44,0,0,1,237.94,133.56ZM215.82,128C188.83,87.44,153,64,128,64a58.1,58.1,0,0,0-40.66,16.55L128,123.3a10,10,0,0,1,0,14.13,10.19,10.19,0,0,1-14.13,0l-40.66,44.72A82,82,0,0,0,128,204c25,0,60.83-23.44,87.82-64C215.82,128,215.82,128,215.82,128Z"/>',
'lock': '<path d="M208,76H180V56a52,52,0,0,0-104,0V76H48A20,20,0,0,0,28,96V208a20,20,0,0,0,20,20H208a20,20,0,0,0,20-20V96A20,20,0,0,0,208,76ZM100,56a28,28,0,0,1,56,0V76H100ZM204,204H52V100H204Zm-72-76a16,16,0,1,1-16-16A16,16,0,0,1,132,128Z"/>',
'star': '<path d="M234.29,114.85l-45,38.83L203,211.75a20.81,20.81,0,0,1-7.24,21.31,20.25,20.25,0,0,1-21.7,1.32L128,213.15,81.94,234.38a20.25,20.25,0,0,1-21.7-1.32,20.81,20.81,0,0,1-7.24-21.31l13.72-58.07-45-38.83A20.86,20.86,0,0,1,33.31,86.56l59.51-5.16,23.17-55.23a20.88,20.88,0,0,1,38,0l23.17,55.23,59.51,5.16a20.86,20.86,0,0,1,11.58,28.29Zm-74.81-28.8-18.28-43.54a.84.84,0,0,0-1.58,0L121.34,86.05a12,12,0,0,1-10.09,7.34L51.58,98.83a.87.87,0,0,0-.49,1.53L89.83,131.8a12,12,0,0,1,3.86,11.93L82.84,201.1a.87.87,0,0,0,.34.94.88.88,0,0,0,1,0l43.45-19.95a12.09,12.09,0,0,1,10.68,0l43.45,19.95a.88.88,0,0,0,1,0,.87.87,0,0,0,.34-.94l-10.85-57.37a12,12,0,0,1,3.86-11.93l38.74-33.44a.87.87,0,0,0-.49-1.53l-59.67-5.17A12,12,0,0,1,159.48,86.05Z"/>',
'heart': '<path d="M178,36c-21.4,0-39.31,10.64-50,27.14C117.31,46.64,99.4,36,78,36a66.08,66.08,0,0,0-66,66c0,72.25,105.53,130.44,110.66,132.94a12,12,0,0,0,10.68,0C138.47,232.44,244,174.25,244,102A66.08,66.08,0,0,0,178,36Zm-5.42,142.24c-18.3,10.88-38.26,21.14-44.58,24.78-6.32-3.64-26.28-13.9-44.58-24.78C56.75,159.16,28,136,28,102A42,42,0,0,1,70,60c18.12,0,33.05,9.48,42.64,27.22a12,12,0,0,0,20.72,0C142.94,69.48,157.88,60,176,60a42,42,0,0,1,42,42C220,136,191.25,159.16,172.58,178.24Z"/>',
'bell': '<path d="M225.29,165.93C216.61,151,212,129.57,212,104a84,84,0,0,0-168,0c0,25.58-4.59,47-13.27,61.93A20.08,20.08,0,0,0,30.66,186a19.77,19.77,0,0,0,17.79,11H94.2a36,36,0,0,0,67.6,0H207.55a19.77,19.77,0,0,0,17.79-11A20.08,20.08,0,0,0,225.29,165.93ZM128,216a12,12,0,0,1-11.62-9h23.24A12,12,0,0,1,128,216Zm-76.55-39C62.19,157.17,68,133.1,68,104a60,60,0,0,1,120,0c0,29.1,5.81,53.17,16.55,73Z"/>',
'calendar': '<path d="M208,28H188V24a12,12,0,0,0-24,0v4H92V24a12,12,0,0,0-24,0v4H48A20,20,0,0,0,28,48V208a20,20,0,0,0,20,20H208a20,20,0,0,0,20-20V48A20,20,0,0,0,208,28ZM68,52a12,12,0,0,0,24,0h72a12,12,0,0,0,24,0h16V76H52V52ZM52,204V100H204V204Z"/>',
'clock': '<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm76-84a12,12,0,0,1-12,12H128a12,12,0,0,1-12-12V72a12,12,0,0,1,24,0v44h52A12,12,0,0,1,204,128Z"/>',
'image': '<path d="M216,36H40A20,20,0,0,0,20,56V200a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V56A20,20,0,0,0,216,36Zm-4,24V158.75l-26.07-26.06a20,20,0,0,0-28.28,0l-23.23,23.23-59.8-59.8a20,20,0,0,0-28.28,0L44,98.75V60ZM44,196V121.66l56-56,59.8,59.8a20,20,0,0,0,28.28,0l23.23-23.23L212,103V196Zm28-96a20,20,0,1,0-20-20A20,20,0,0,0,72,100Z"/>',
'shield-check': '<path d="M208,36H48A20,20,0,0,0,28,56v56c0,54.29,26.32,87.22,48.4,105.29,23.71,19.39,47.44,26,48.44,26.29a12.1,12.1,0,0,0,6.32,0c1-.28,24.73-6.9,48.44-26.29,22.08-18.07,48.4-51,48.4-105.29V56A20,20,0,0,0,208,36Zm-4,76c0,35.71-13.09,64.69-38.91,86.15A126.28,126.28,0,0,1,128,219.38a126.14,126.14,0,0,1-37.09-21.23C65.09,176.69,52,147.71,52,112V60H204ZM79.51,144.49a12,12,0,1,1,17-17L112,143l47.51-47.52a12,12,0,0,1,17,17l-56,56a12,12,0,0,1-17,0Z"/>',
'envelope': '<path d="M224,44H32A12,12,0,0,0,20,56V192a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V56A12,12,0,0,0,224,44Zm-96,83.72L62.85,68h130.3ZM92.79,128,44,172.72V83.28Zm17.76,16.28,9.34,8.57a12,12,0,0,0,16.22,0l9.34-8.57L193.15,188H62.85ZM163.21,128,212,83.28v89.44Z"/>',
'arrows-left-right': '<path d="M216.49,184.49l-32,32a12,12,0,0,1-17-17L179,188H48a12,12,0,0,1,0-24H179l-11.52-11.51a12,12,0,0,1,17-17l32,32A12,12,0,0,1,216.49,184.49Zm-145-64a12,12,0,0,0,17-17L77,92H208a12,12,0,0,0,0-24H77L88.49,56.49a12,12,0,0,0-17-17l-32,32a12,12,0,0,0,0,17Z"/>',
} as const;
export type IconName = keyof typeof iconPaths;

View file

@ -1,18 +1,10 @@
<script lang="ts">
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import Text from '$lib/components/atoms/Text.svelte';
interface Recording {
id: string;
url: string;
duration?: number;
created_at: string;
label?: string;
size?: number;
}
import { Text } from '@manacore/shared-ui';
import type { AdditionalRecording } from '$lib/types/memo.types';
interface Props {
recordings: Recording[];
recordings: AdditionalRecording[];
onRecordingAdd?: () => void;
onRecordingDelete?: (recordingId: string) => void;
onRecordingRename?: (recordingId: string, newLabel: string) => void;
@ -25,12 +17,13 @@
let editingId = $state<string | null>(null);
let editLabel = $state('');
function formatDuration(seconds?: number): string {
if (!seconds) return '--:--';
function formatDuration(millis?: number): string {
if (!millis) return '--:--';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const totalSeconds = Math.floor(millis / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = Math.floor(totalSeconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
@ -60,9 +53,9 @@
});
}
function startEditing(recording: Recording) {
function startEditing(recording: AdditionalRecording) {
editingId = recording.id;
editLabel = recording.label || '';
editLabel = '';
}
function cancelEditing() {
@ -140,7 +133,7 @@
<!-- View Mode -->
<div class="flex items-center gap-2">
<Text variant="body" weight="semibold">
{recording.label || `Recording ${recordings.indexOf(recording) + 1}`}
Recording {recordings.indexOf(recording) + 1}
</Text>
{#if canEdit && onRecordingRename}
<button
@ -172,7 +165,7 @@
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{formatDuration(recording.duration)}
{formatDuration(recording.duration_millis)}
</Text>
<Text variant="muted" class="flex items-center gap-1">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -183,7 +176,7 @@
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
{formatSize(recording.size)}
--
</Text>
<Text variant="muted" class="flex items-center gap-1">
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -223,7 +216,7 @@
</div>
<!-- Audio Player -->
<AudioPlayer src={recording.url} />
<AudioPlayer src={recording.audio_url} />
</div>
{/each}
</div>

View file

@ -2,7 +2,7 @@
import type { Memo } from '$lib/types/memo.types';
import { formatDuration, getMemooDuration, formatTimestamp } from '$lib/utils/formatters';
import TagBadge from '$lib/components/TagBadge.svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
memo: Memo;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Photo {
id: string;
@ -50,7 +50,8 @@
function navigatePhoto(direction: number) {
if (!selectedPhoto) return;
const currentIndex = photos.findIndex((p) => p.id === selectedPhoto.id);
const currentPhoto = selectedPhoto;
const currentIndex = photos.findIndex((p) => p.id === currentPhoto.id);
const newIndex = currentIndex + direction;
if (newIndex >= 0 && newIndex < photos.length) {
@ -162,6 +163,7 @@
<!-- Lightbox Modal -->
{#if showLightbox && selectedPhoto}
{@const currentPhoto = selectedPhoto}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm"
@ -173,15 +175,15 @@
<div class="relative max-h-full max-w-5xl" onclick={(e) => e.stopPropagation()}>
<!-- Image -->
<img
src={selectedPhoto.url}
alt={selectedPhoto.caption || 'Photo'}
src={currentPhoto.url}
alt={currentPhoto.caption || 'Photo'}
class="max-h-[90vh] w-auto rounded-lg shadow-2xl"
/>
<!-- Caption -->
{#if selectedPhoto.caption}
{#if currentPhoto.caption}
<div class="mt-4 rounded-lg bg-menu p-4">
<Text variant="body">{selectedPhoto.caption}</Text>
<Text variant="body">{currentPhoto.caption}</Text>
</div>
{/if}
@ -206,7 +208,7 @@
<button
onclick={() => navigatePhoto(-1)}
class="absolute top-1/2 left-4 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 disabled:opacity-50"
disabled={photos.findIndex((p) => p.id === selectedPhoto.id) === 0}
disabled={photos.findIndex((p) => p.id === currentPhoto.id) === 0}
title="Previous (←)"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -222,7 +224,7 @@
<button
onclick={() => navigatePhoto(1)}
class="absolute top-1/2 right-4 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 disabled:opacity-50"
disabled={photos.findIndex((p) => p.id === selectedPhoto.id) === photos.length - 1}
disabled={photos.findIndex((p) => p.id === currentPhoto.id) === photos.length - 1}
title="Next (→)"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -239,7 +241,7 @@
<!-- Photo Counter -->
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-4 py-2">
<Text variant="small" class="text-white">
{photos.findIndex((p) => p.id === selectedPhoto.id) + 1} / {photos.length}
{photos.findIndex((p) => p.id === currentPhoto.id) + 1} / {photos.length}
</Text>
</div>
</div>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
visible: boolean;
@ -175,6 +175,8 @@
<style>
.kbd {
@apply rounded border border-theme bg-menu-hover px-1.5 py-0.5 font-mono;
@apply rounded border px-1.5 py-0.5 font-mono text-xs;
border-color: var(--color-border);
background-color: var(--color-menu-bg-hover);
}
</style>

View file

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

View file

@ -1,6 +1,5 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Modal, Text } from '@manacore/shared-ui';
interface Props {
visible: boolean;

View file

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

View file

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

View file

@ -205,6 +205,8 @@
<style>
.kbd {
@apply rounded border border-theme bg-menu-hover px-1.5 py-0.5 font-mono;
@apply rounded border px-1.5 py-0.5 font-mono text-xs;
border-color: var(--color-border);
background-color: var(--color-menu-bg-hover);
}
</style>

View file

@ -1,7 +1,6 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import { Modal, Text } from '@manacore/shared-ui';
import type { Memo } from '$lib/types/memo.types';
import Text from '$lib/components/atoms/Text.svelte';
interface Props {
visible: boolean;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import { Modal } from '@manacore/shared-ui';
import type { ShortcutGroup } from '$lib/utils/keyboardShortcuts';
import { formatShortcut } from '$lib/utils/keyboardShortcuts';
@ -74,6 +74,9 @@
<style>
.kbd {
@apply inline-flex items-center gap-1 rounded border border-theme bg-menu-hover px-2 py-1 font-mono text-xs font-semibold text-theme;
@apply inline-flex items-center gap-1 rounded border px-2 py-1 font-mono text-xs font-semibold;
border-color: var(--color-border);
background-color: var(--color-menu-bg-hover);
color: var(--color-text);
}
</style>

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import { Modal } from '@manacore/shared-ui';
import type { Tag } from '$lib/types/memo.types';
interface Props {

View file

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

View file

@ -1,7 +1,7 @@
<script lang="ts">
import GlassCard from './GlassCard.svelte';
import StatRow from './StatRow.svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
mostViewedMemo: { id: string; title: string; viewCount: number } | null;

View file

@ -1,7 +1,7 @@
<script lang="ts">
import GlassCard from './GlassCard.svelte';
import StatRow from './StatRow.svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
averageAudioDuration: number;

View file

@ -1,7 +1,7 @@
<script lang="ts">
import GlassCard from './GlassCard.svelte';
import StatRow from './StatRow.svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
memoCount: number;

View file

@ -1,7 +1,7 @@
<script lang="ts">
import GlassCard from './GlassCard.svelte';
import StatRow from './StatRow.svelte';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
todayStats: {

View file

@ -1,5 +1,5 @@
<script lang="ts">
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
interface Props {
title: string;

View file

@ -114,7 +114,8 @@ export class AudioStorageService {
if (memos) {
totalDurationSeconds = memos.reduce((sum, memo) => {
const duration = memo.source?.duration_seconds || memo.source?.duration || 0;
const source = memo.source as { duration_seconds?: number; duration?: number } | null;
const duration = source?.duration_seconds || source?.duration || 0;
return sum + duration;
}, 0);
}
@ -167,14 +168,22 @@ export class AudioStorageService {
try {
const supabase = await createAuthClient();
// Try to find memo by audio_url
const { data: memo, error } = await supabase
// Try to find memo by source containing the audio file name
const { data: memos, error } = await supabase
.from('memos')
.select('id, title, audio_url')
.ilike('audio_url', `%${audioFileName}%`)
.single();
.select('id, title, source')
.not('source', 'is', null);
if (error || !memo) return null;
if (error || !memos) return null;
// Find memo where source contains the audio file name
const memo = memos.find((m) => {
const source = m.source as { audioUrl?: string; audio_url?: string } | null;
const audioUrl = source?.audioUrl || source?.audio_url || '';
return audioUrl.includes(audioFileName);
});
if (!memo) return null;
return {
id: memo.id,

View file

@ -96,11 +96,10 @@ export async function uploadAndProcessAudio({
fileName,
duration: Math.floor(duration),
memoId,
spaceId,
spaceId: spaceId ?? undefined,
title,
blueprintId,
blueprintId: blueprintId ?? undefined,
recordingLanguages,
enableDiarization,
appToken
});

View file

@ -347,9 +347,9 @@ export class MemoService {
.eq('id', memoId)
.single();
const currentMetadata = memo?.metadata || {};
const currentStats = currentMetadata.stats || {};
const newViewCount = (currentStats.viewCount || 0) + 1;
const currentMetadata = (memo?.metadata as Record<string, unknown>) || {};
const currentStats = (currentMetadata.stats as Record<string, unknown>) || {};
const newViewCount = ((currentStats.viewCount as number) || 0) + 1;
const { error: updateError } = await supabase
.from('memos')

View file

@ -6,6 +6,7 @@
import { env } from '$lib/config/env';
import { tokenManager } from './tokenManager';
import { createAuthClient } from '$lib/supabaseClient';
import type { Memory } from '$lib/types/memo.types';
export interface QuestionResult {
success: boolean;
@ -14,12 +15,7 @@ export interface QuestionResult {
creditsConsumed?: number;
}
export interface Memory {
id: string;
title: string;
content: string;
metadata?: Record<string, unknown>;
}
export type { Memory };
class QuestionService {
/**
@ -131,7 +127,7 @@ class QuestionService {
const { data, error } = await supabase
.from('memories')
.select('id, title, content, metadata')
.select('id, memo_id, title, content, metadata, style, media, created_at, updated_at')
.eq('memo_id', memoId)
.order('sort_order', { ascending: true })
.order('created_at', { ascending: false });
@ -141,7 +137,18 @@ class QuestionService {
return [];
}
return data || [];
// Transform data to match Memory interface
return (data || []).map(item => ({
id: item.id,
memo_id: item.memo_id,
title: item.title,
content: item.content,
metadata: item.metadata as Record<string, any> | null | undefined,
style: item.style as Record<string, any> | null | undefined,
media: item.media as Record<string, any> | null | undefined,
created_at: item.created_at,
updated_at: item.updated_at
}));
} catch (error) {
console.error('Error loading memories:', error);
return [];

View file

@ -29,7 +29,7 @@ function createRecordingStore() {
audioBlob: blob,
audioUrl: URL.createObjectURL(blob)
})),
setError: (error: string) => update((state) => ({ ...state, error })),
setError: (error: string | null) => update((state) => ({ ...state, error })),
reset: () =>
set({
status: 'idle',

View file

@ -1,28 +1,611 @@
/**
* Database types for Supabase
* This is a placeholder file - generate actual types using:
* npx supabase gen types typescript --project-id YOUR_PROJECT_ID > src/lib/types/database.types.ts
*/
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
public: {
Tables: {
[key: string]: {
Row: Record<string, unknown>;
Insert: Record<string, unknown>;
Update: Record<string, unknown>;
};
};
Views: {
[key: string]: {
Row: Record<string, unknown>;
};
};
Functions: {
[key: string]: {
Args: Record<string, unknown>;
Returns: unknown;
};
};
};
};
public: {
Tables: {
blueprints: {
Row: {
advice: Json | null
category: Json | null
created_at: string
description: Json | null
id: string
is_public: boolean
name: Json
style: Json | null
topic_id: string | null
updated_at: string
user_id: string | null
}
Insert: {
advice?: Json | null
category?: Json | null
created_at?: string
description?: Json | null
id?: string
is_public?: boolean
name?: Json
style?: Json | null
topic_id?: string | null
updated_at?: string
user_id?: string | null
}
Update: {
advice?: Json | null
category?: Json | null
created_at?: string
description?: Json | null
id?: string
is_public?: boolean
name?: Json
style?: Json | null
topic_id?: string | null
updated_at?: string
user_id?: string | null
}
Relationships: []
}
debug_logs: {
Row: {
created_at: string
data: Json
id: string
type: string
}
Insert: {
created_at?: string
data: Json
id?: string
type: string
}
Update: {
created_at?: string
data?: Json
id?: string
type?: string
}
Relationships: []
}
memo_spaces: {
Row: {
created_at: string
memo_id: string
space_id: string
}
Insert: {
created_at?: string
memo_id: string
space_id: string
}
Update: {
created_at?: string
memo_id?: string
space_id?: string
}
Relationships: [
{
foreignKeyName: "memo_spaces_memo_id_fkey"
columns: ["memo_id"]
isOneToOne: false
referencedRelation: "memos"
referencedColumns: ["id"]
},
]
}
memo_tags: {
Row: {
created_at: string
id: string
memo_id: string
tag_id: string
}
Insert: {
created_at?: string
id?: string
memo_id: string
tag_id: string
}
Update: {
created_at?: string
id?: string
memo_id?: string
tag_id?: string
}
Relationships: [
{
foreignKeyName: "memo_tags_memo_id_fkey"
columns: ["memo_id"]
isOneToOne: false
referencedRelation: "memos"
referencedColumns: ["id"]
},
{
foreignKeyName: "memo_tags_tag_id_fkey"
columns: ["tag_id"]
isOneToOne: false
referencedRelation: "tags"
referencedColumns: ["id"]
},
]
}
memories: {
Row: {
content: string | null
created_at: string
id: string
media: Json | null
memo_id: string
metadata: Json | null
style: Json | null
title: string
updated_at: string
}
Insert: {
content?: string | null
created_at?: string
id?: string
media?: Json | null
memo_id: string
metadata?: Json | null
style?: Json | null
title?: string
updated_at?: string
}
Update: {
content?: string | null
created_at?: string
id?: string
media?: Json | null
memo_id?: string
metadata?: Json | null
style?: Json | null
title?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "memories_memo_id_fkey"
columns: ["memo_id"]
isOneToOne: false
referencedRelation: "memos"
referencedColumns: ["id"]
},
]
}
memos: {
Row: {
created_at: string
id: string
intro: string | null
is_archived: boolean
is_pinned: boolean
is_public: boolean
location: unknown | null
metadata: Json | null
shared_with_users: string[] | null
source: Json
style: Json | null
title: string | null
updated_at: string
user_id: string
}
Insert: {
created_at?: string
id?: string
intro?: string | null
is_archived?: boolean
is_pinned?: boolean
is_public?: boolean
location?: unknown | null
metadata?: Json | null
shared_with_users?: string[] | null
source?: Json
style?: Json | null
title?: string | null
updated_at?: string
user_id?: string
}
Update: {
created_at?: string
id?: string
intro?: string | null
is_archived?: boolean
is_pinned?: boolean
is_public?: boolean
location?: unknown | null
metadata?: Json | null
shared_with_users?: string[] | null
source?: Json
style?: Json | null
title?: string | null
updated_at?: string
user_id?: string
}
Relationships: []
}
prompt_blueprints: {
Row: {
blueprint_id: string
created_at: string
prompt_id: string
}
Insert: {
blueprint_id: string
created_at?: string
prompt_id: string
}
Update: {
blueprint_id?: string
created_at?: string
prompt_id?: string
}
Relationships: [
{
foreignKeyName: "blueprint_prompts_blueprint_id_fkey"
columns: ["blueprint_id"]
isOneToOne: false
referencedRelation: "blueprints"
referencedColumns: ["id"]
},
{
foreignKeyName: "blueprint_prompts_prompt_id_fkey"
columns: ["prompt_id"]
isOneToOne: false
referencedRelation: "prompts"
referencedColumns: ["id"]
},
]
}
prompts: {
Row: {
created_at: string
description: Json | null
id: string
is_public: boolean
memory_title: Json
prompt_text: Json
updated_at: string
user_id: string | null
}
Insert: {
created_at?: string
description?: Json | null
id?: string
is_public?: boolean
memory_title?: Json
prompt_text?: Json
updated_at?: string
user_id?: string | null
}
Update: {
created_at?: string
description?: Json | null
id?: string
is_public?: boolean
memory_title?: Json
prompt_text?: Json
updated_at?: string
user_id?: string | null
}
Relationships: []
}
space_members: {
Row: {
added_at: string | null
added_by: string | null
id: string
role: string
space_id: string
user_id: string
}
Insert: {
added_at?: string | null
added_by?: string | null
id?: string
role: string
space_id: string
user_id: string
}
Update: {
added_at?: string | null
added_by?: string | null
id?: string
role?: string
space_id?: string
user_id?: string
}
Relationships: []
}
spatial_ref_sys: {
Row: {
auth_name: string | null
auth_srid: number | null
proj4text: string | null
srid: number
srtext: string | null
}
Insert: {
auth_name?: string | null
auth_srid?: number | null
proj4text?: string | null
srid: number
srtext?: string | null
}
Update: {
auth_name?: string | null
auth_srid?: number | null
proj4text?: string | null
srid?: number
srtext?: string | null
}
Relationships: []
}
tags: {
Row: {
created_at: string
description: Json | null
id: string
is_pinned: boolean | null
name: string
sort_order: number | null
style: Json | null
updated_at: string
user_id: string
}
Insert: {
created_at?: string
description?: Json | null
id?: string
is_pinned?: boolean | null
name?: string
sort_order?: number | null
style?: Json | null
updated_at?: string
user_id: string
}
Update: {
created_at?: string
description?: Json | null
id?: string
is_pinned?: boolean | null
name?: string
sort_order?: number | null
style?: Json | null
updated_at?: string
user_id?: string
}
Relationships: []
}
user_active_blueprints: {
Row: {
blueprint_id: string
created_at: string
id: string
is_active: boolean
updated_at: string
user_id: string
}
Insert: {
blueprint_id: string
created_at?: string
id?: string
is_active?: boolean
updated_at?: string
user_id: string
}
Update: {
blueprint_id?: string
created_at?: string
id?: string
is_active?: boolean
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "user_active_blueprints_blueprint_id_fkey"
columns: ["blueprint_id"]
isOneToOne: false
referencedRelation: "blueprints"
referencedColumns: ["id"]
},
]
}
}
Views: {
geography_columns: {
Row: {
coord_dimension: number | null
f_geography_column: unknown | null
f_table_catalog: unknown | null
f_table_name: unknown | null
f_table_schema: unknown | null
srid: number | null
type: string | null
}
Relationships: []
}
geometry_columns: {
Row: {
coord_dimension: number | null
f_geometry_column: unknown | null
f_table_catalog: string | null
f_table_name: unknown | null
f_table_schema: unknown | null
srid: number | null
type: string | null
}
Insert: {
coord_dimension?: number | null
f_geometry_column?: unknown | null
f_table_catalog?: string | null
f_table_name?: unknown | null
f_table_schema?: unknown | null
srid?: number | null
type?: string | null
}
Update: {
coord_dimension?: number | null
f_geometry_column?: unknown | null
f_table_catalog?: string | null
f_table_name?: unknown | null
f_table_schema?: unknown | null
srid?: number | null
type?: string | null
}
Relationships: []
}
}
Functions: {
[key: string]: any
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
geometry_dump: {
path: number[] | null
geom: unknown | null
}
http_header: {
field: string | null
value: string | null
}
http_request: {
method: unknown | null
uri: string | null
headers: Database["public"]["CompositeTypes"]["http_header"][] | null
content_type: string | null
content: string | null
}
http_response: {
status: number | null
content_type: string | null
headers: Database["public"]["CompositeTypes"]["http_header"][] | null
content: string | null
}
valid_detail: {
valid: boolean | null
reason: string | null
location: unknown | null
}
}
}
}
type DefaultSchema = Database[Extract<keyof Database, "public">]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
DefaultSchema["Views"])
? (DefaultSchema["Tables"] &
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema["Tables"]
| { schema: keyof Database },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema["Enums"]
| { schema: keyof Database },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof Database
}
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema["CompositeTypes"]
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
export const Constants = {
public: {
Enums: {},
},
} as const

View file

@ -1,3 +1,18 @@
export interface MemoPhoto {
id: string;
url: string;
thumbnail_url?: string;
caption?: string;
created_at: string;
}
export interface AdditionalRecording {
id: string;
audio_url: string;
duration_millis: number;
created_at: string;
}
export interface Memo {
id: string;
user_id: string;
@ -8,6 +23,7 @@ export interface Memo {
duration_millis: number | null;
created_at: string;
updated_at: string;
recorded_at?: string;
space_id: string | null;
blueprint_id: string | null;
language: string | null;
@ -35,6 +51,8 @@ export interface Memo {
memories?: Memory[];
tags?: Tag[];
space?: Space;
photos?: MemoPhoto[];
additional_recordings?: AdditionalRecording[];
}
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed';
@ -64,6 +82,7 @@ export interface Tag {
created_at: string;
is_pinned?: boolean;
sort_order?: number;
usage?: number;
}
export interface MemoTag {
@ -80,12 +99,25 @@ export interface Space {
updated_at: string;
}
export interface Prompt {
id: string;
memory_title: string;
prompt_text: string;
name?: string;
description?: string | null;
created_at?: string;
updated_at?: string;
}
export interface Blueprint {
id: string;
name: string;
description: string | null;
prompt: string;
prompt?: string;
user_id: string | null;
is_public: boolean;
created_at: string;
updated_at?: string;
category?: string | null;
prompts?: Prompt[];
}

View file

@ -1,233 +0,0 @@
/**
* Apple Sign-In integration for web
* Uses redirect flow (not popup) - different from mobile native flow
*/
import { env } from '$lib/config/env';
import { browser } from '$app/environment';
// TypeScript definitions for Apple ID SDK
declare global {
interface Window {
AppleID?: {
auth: {
init: (config: AppleIDInitConfig) => void;
signIn: () => Promise<AppleIDSignInResponse>;
};
};
}
}
interface AppleIDInitConfig {
clientId: string;
scope: string;
redirectURI: string;
state?: string;
nonce?: string;
usePopup?: boolean;
responseType?: string;
responseMode?: string;
}
interface AppleIDSignInResponse {
authorization: {
code: string;
id_token?: string;
state?: string;
};
user?: {
email?: string;
name?: {
firstName?: string;
lastName?: string;
};
};
}
interface AppleAuthorizationResponse {
code: string;
id_token?: string;
state?: string;
user?: string; // JSON string of user info (only on first sign-in)
}
/**
* Initialize Apple ID SDK
* Must be called before using Apple Sign-In
*/
export function initializeAppleAuth() {
if (!browser || !window.AppleID) {
console.warn('Apple ID SDK not loaded');
return false;
}
const clientId = env.oauth.appleClientId;
// Use the handler endpoint for POST, not the page route
const redirectURI = env.oauth.appleRedirectUri?.replace('/auth/apple-callback', '/auth/apple-callback-handler') || 'https://app.memoro.ai/auth/apple-callback-handler';
// Log configuration for debugging (especially useful in production)
console.log('Apple Sign-In Configuration:', {
clientId: clientId || '❌ NOT SET',
redirectURI: redirectURI,
originalRedirectURI: env.oauth.appleRedirectUri || '❌ NOT SET',
responseMode: 'form_post',
responseType: 'code id_token'
});
if (!clientId) {
console.error('❌ Apple Client ID not configured');
console.error('Expected: PUBLIC_APPLE_CLIENT_ID=com.memoro.web');
return false;
}
try {
window.AppleID.auth.init({
clientId,
scope: 'name email',
redirectURI,
state: generateState(),
usePopup: false, // Must use redirect on web
responseType: 'code id_token', // Request both code and id_token
responseMode: 'form_post' // Use form_post for secure POST to server - required for email/name scopes
});
console.log('✅ Apple ID SDK initialized successfully with form_post response mode');
return true;
} catch (error) {
console.error('Error initializing Apple ID SDK:', error);
return false;
}
}
/**
* Initiate Apple Sign-In (redirect flow)
* Stores state and redirects to Apple
*/
export async function signInWithApple(): Promise<void> {
if (!browser) {
throw new Error('Apple Sign-In only available in browser');
}
if (!window.AppleID) {
throw new Error('Apple ID SDK not loaded');
}
try {
// Store return URL before redirect
const returnTo = window.location.pathname + window.location.search;
sessionStorage.setItem('apple_signin_return_to', returnTo);
// Initiate sign-in (will redirect to Apple)
await window.AppleID.auth.signIn();
} catch (error) {
console.error('Error initiating Apple Sign-In:', error);
throw error;
}
}
/**
* Parse Apple authorization response from URL
* Called by the callback page after redirect from Apple
*/
export function parseAppleAuthorizationResponse(
urlParams: URLSearchParams
): AppleAuthorizationResponse | null {
const code = urlParams.get('code');
const id_token = urlParams.get('id_token');
const state = urlParams.get('state');
const user = urlParams.get('user');
const error = urlParams.get('error');
// Check for errors
if (error) {
console.error('Apple Sign-In error:', error);
return null;
}
// Validate state (CSRF protection)
const storedState = sessionStorage.getItem('apple_signin_state');
if (state !== storedState) {
console.error('State mismatch - possible CSRF attack');
return null;
}
// IMPORTANT: We need either id_token OR code
// For backend compatibility, we prefer id_token if available
if (!id_token && !code) {
console.error('No id_token or authorization code in Apple response');
return null;
}
// Log what we received
console.log('Apple response:', {
hasIdToken: !!id_token,
hasCode: !!code,
hasState: !!state,
hasUser: !!user
});
return {
code: code || '',
id_token: id_token || undefined,
state: state || undefined,
user: user || undefined
};
}
/**
* Generate random state for CSRF protection
*/
function generateState(): string {
const state = Math.random().toString(36).substring(2, 15);
if (browser) {
sessionStorage.setItem('apple_signin_state', state);
}
return state;
}
/**
* Get stored return URL (where to redirect after sign-in)
*/
export function getStoredReturnUrl(): string {
if (!browser) return '/dashboard';
return sessionStorage.getItem('apple_signin_return_to') || '/dashboard';
}
/**
* Clear Apple Sign-In session data
*/
export function clearAppleSignInSession() {
if (!browser) return;
sessionStorage.removeItem('apple_signin_state');
sessionStorage.removeItem('apple_signin_return_to');
}
/**
* Check if Apple ID SDK is loaded
*/
export function isAppleAuthLoaded(): boolean {
return browser && !!window.AppleID?.auth;
}
/**
* Wait for Apple ID SDK to load
*/
export function waitForAppleAuth(timeout = 10000): Promise<void> {
return new Promise((resolve, reject) => {
if (isAppleAuthLoaded()) {
resolve();
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
if (isAppleAuthLoaded()) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error('Apple ID SDK failed to load'));
}
}, 100);
});
}

View file

@ -1,187 +0,0 @@
/**
* Google Identity Services integration
* Provides helper functions for Google Sign-In on web
*/
import { env } from '$lib/config/env';
// TypeScript definitions for Google Identity Services
declare global {
interface Window {
google?: {
accounts: {
id: {
initialize: (config: GoogleIdConfiguration) => void;
prompt: (momentListener?: (notification: PromptMomentNotification) => void) => void;
renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void;
disableAutoSelect: () => void;
storeCredential: (credential: { id: string; password: string }) => void;
cancel: () => void;
onGoogleLibraryLoad: () => void;
revoke: (hint: string, callback: (done: RevocationResponse) => void) => void;
};
};
};
}
}
interface GoogleIdConfiguration {
client_id: string;
callback: (response: CredentialResponse) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
context?: 'signin' | 'signup' | 'use';
ux_mode?: 'popup' | 'redirect';
login_uri?: string;
native_callback?: (response: { id: string; password: string }) => void;
itp_support?: boolean;
}
interface CredentialResponse {
credential: string; // JWT ID token
select_by: string;
clientId?: string;
}
interface GsiButtonConfiguration {
type?: 'standard' | 'icon';
theme?: 'outline' | 'filled_blue' | 'filled_black';
size?: 'large' | 'medium' | 'small';
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
logo_alignment?: 'left' | 'center';
width?: string;
locale?: string;
}
interface PromptMomentNotification {
isDisplayMoment: () => boolean;
isDisplayed: () => boolean;
isNotDisplayed: () => boolean;
getNotDisplayedReason: () =>
| 'browser_not_supported'
| 'invalid_client'
| 'missing_client_id'
| 'opt_out_or_no_session'
| 'secure_http_required'
| 'suppressed_by_user'
| 'unregistered_origin'
| 'unknown_reason';
isSkippedMoment: () => boolean;
getSkippedReason: () =>
| 'auto_cancel'
| 'user_cancel'
| 'tap_outside'
| 'issuing_failed'
| 'unknown_reason';
isDismissedMoment: () => boolean;
getDismissedReason: () => 'credential_returned' | 'cancel_called' | 'flow_restarted' | 'unknown_reason';
getMomentType: () => 'display' | 'skipped' | 'dismissed';
}
interface RevocationResponse {
successful: boolean;
error?: string;
}
/**
* Initialize Google Identity Services
* @param callback Function to call when user signs in with Google
*/
export function initializeGoogleAuth(callback: (idToken: string) => void) {
if (typeof window === 'undefined') {
console.warn('Google Auth: Cannot initialize on server-side');
return;
}
if (!window.google) {
console.warn('Google Identity Services not loaded yet');
return;
}
const clientId = env.oauth.googleClientId;
if (!clientId) {
console.error('Google Client ID not configured');
return;
}
try {
window.google.accounts.id.initialize({
client_id: clientId,
callback: (response: CredentialResponse) => {
// response.credential is the JWT ID token
callback(response.credential);
},
auto_select: false,
cancel_on_tap_outside: true,
ux_mode: 'popup'
});
} catch (error) {
console.error('Error initializing Google Auth:', error);
}
}
/**
* Render Google Sign-In button
* @param element HTML element to render button into
* @param options Button configuration options
*/
export function renderGoogleButton(
element: HTMLElement,
options?: Partial<GsiButtonConfiguration>
) {
if (typeof window === 'undefined' || !window.google) {
console.warn('Google Identity Services not available');
return;
}
const defaultOptions: GsiButtonConfiguration = {
type: 'standard',
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'rectangular',
logo_alignment: 'left'
// Note: width is omitted - Google button will auto-size to container
};
try {
window.google.accounts.id.renderButton(element, {
...defaultOptions,
...options
});
} catch (error) {
console.error('Error rendering Google button:', error);
}
}
/**
* Check if Google Identity Services is loaded
*/
export function isGoogleAuthLoaded(): boolean {
return typeof window !== 'undefined' && !!window.google?.accounts?.id;
}
/**
* Wait for Google Identity Services to load
* @param timeout Maximum time to wait in milliseconds (default: 10000ms)
*/
export function waitForGoogleAuth(timeout = 10000): Promise<void> {
return new Promise((resolve, reject) => {
if (isGoogleAuthLoaded()) {
resolve();
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
if (isGoogleAuthLoaded()) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error('Google Identity Services failed to load'));
}
}, 100);
});
}

View file

@ -142,7 +142,7 @@
// Create a lookup for prompts by ID
const promptsById: Record<string, any> = {};
if (allPrompts) {
for (const prompt of allPrompts) {
for (const prompt of allPrompts as Prompt[]) {
promptsById[prompt.id] = prompt;
}
}
@ -151,10 +151,17 @@
const blueprintsWithPrompts: Blueprint[] = [];
for (const blueprint of blueprintsData || []) {
const promptIds = promptLinksByBlueprintId[blueprint.id] || [];
const promptsForBlueprint = promptIds.map((id) => promptsById[id]).filter(Boolean);
const promptsForBlueprint = promptIds.map((id) => promptsById[id]).filter(Boolean) as Prompt[];
blueprintsWithPrompts.push({
...blueprint,
id: blueprint.id,
name: blueprint.name as { de?: string; en?: string },
description: blueprint.description as { de?: string; en?: string } | undefined,
category: blueprint.category as unknown as Category | undefined,
is_public: blueprint.is_public,
created_at: blueprint.created_at,
updated_at: blueprint.updated_at,
user_id: blueprint.user_id || '',
prompts: promptsForBlueprint
});
}

View file

@ -13,7 +13,7 @@
import { supabase, createAuthClient } from '$lib/supabaseClient';
import SplitView from '$lib/components/SplitView.svelte';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import Modal from '$lib/components/Modal.svelte';
import { Modal } from '@manacore/shared-ui';
import VirtualList from '$lib/components/VirtualList.svelte';
import { DashboardSkeleton } from '$lib/components/skeletons';
import type { Memo } from '$lib/types/memo.types';
@ -599,17 +599,18 @@ ${memo.memories && memo.memories.length > 0 ? '\nKI-Analyse:\n' + memo.memories.
<!-- Context Menu -->
{#if contextMenu}
{@const menuMemo = contextMenu.memo}
<ContextMenu
items={[
{
label: $t('memo.open_in_new_tab'),
icon: 'open',
action: () => handleOpenInNewTab(contextMenu.memo)
action: () => handleOpenInNewTab(menuMemo)
},
{
label: contextMenu.memo.is_pinned ? $t('memo.unpin') : $t('memo.pin'),
label: menuMemo.is_pinned ? $t('memo.unpin') : $t('memo.pin'),
icon: 'pin',
action: () => handlePinMemo(contextMenu.memo)
action: () => handlePinMemo(menuMemo)
},
{
separator: true,
@ -619,17 +620,17 @@ ${memo.memories && memo.memories.length > 0 ? '\nKI-Analyse:\n' + memo.memories.
{
label: $t('memo.edit_title'),
icon: 'edit',
action: () => handleEditTitle(contextMenu.memo)
action: () => handleEditTitle(menuMemo)
},
{
label: $t('memo.share'),
icon: 'share',
action: () => handleShareMemo(contextMenu.memo)
action: () => handleShareMemo(menuMemo)
},
{
label: $t('memo.export_text'),
icon: 'download',
action: () => handleExportMemo(contextMenu.memo)
action: () => handleExportMemo(menuMemo)
},
{
separator: true,
@ -640,7 +641,7 @@ ${memo.memories && memo.memories.length > 0 ? '\nKI-Analyse:\n' + memo.memories.
label: $t('common.delete'),
icon: 'delete',
danger: true,
action: () => handleDeleteMemo(contextMenu.memo)
action: () => handleDeleteMemo(menuMemo)
}
]}
x={contextMenu.x}

View file

@ -56,7 +56,7 @@
while (hasMore) {
const { data, error } = await supabase
.from(tableName)
.from(tableName as any)
.select(selectFields)
.range(from, from + batchSize - 1);

View file

@ -1,11 +1,16 @@
<script lang="ts">
import BillingToggle, { type BillingCycle } from '$lib/components/BillingToggle.svelte';
import SubscriptionCard, {
type SubscriptionPlan
} from '$lib/components/SubscriptionCard.svelte';
import PackageCard, { type Package } from '$lib/components/PackageCard.svelte';
import UsageCard, { type UsageData } from '$lib/components/UsageCard.svelte';
import CostCard, { type CostItem } from '$lib/components/CostCard.svelte';
import {
BillingToggle,
SubscriptionCard,
PackageCard,
UsageCard,
CostCard,
type BillingCycle,
type SubscriptionPlan,
type ManaPackage,
type UsageData,
type CostItem
} from '@manacore/shared-subscription-ui';
import subscriptionData from '$lib/data/subscriptionData.json';
import appCostsData from '$lib/data/appCosts.json';
@ -16,7 +21,7 @@
// Data from JSON files
const subscriptionOptions = subscriptionData.subscriptions as SubscriptionPlan[];
const manaPackages = subscriptionData.packages as Package[];
const manaPackages = subscriptionData.packages as ManaPackage[];
const appCosts = appCostsData.costs as CostItem[];
const usage = usageData.usage as UsageData;

View file

@ -120,7 +120,7 @@
});
// Optimistic UI update
tags.updateTag(updatedTag);
tags.updateTag(tagId, updatedTag);
} catch (error) {
console.error('Failed to update tag:', error);
actionError = error instanceof Error ? error.message : 'Fehler beim Speichern des Tags';

View file

@ -5,7 +5,7 @@
import { uploadAndProcessAudio } from '$lib/services/audioUploadService';
import { createAuthClient } from '$lib/supabaseClient';
import { UploadPageSkeleton } from '$lib/components/skeletons';
import Text from '$lib/components/atoms/Text.svelte';
import { Text } from '@manacore/shared-ui';
// Upload state
let uploading = $state(false);

View file

@ -2,8 +2,13 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import type { Session } from '@supabase/supabase-js';
let { children, data } = $props();
interface LayoutData {
session?: Session | null;
}
let { children, data }: { children: any; data: LayoutData } = $props();
// Redirect to dashboard if already authenticated
onMount(() => {

View file

@ -1,549 +1,46 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import GoogleSignInButton from '$lib/components/GoogleSignInButton.svelte';
import AppleSignInButton from '$lib/components/AppleSignInButton.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import { LoginPage, setGoogleClientId, setAppleConfig } from '@manacore/shared-auth-ui';
import MemoroLogo from '$lib/components/MemoroLogo.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { auth } from '$lib/stores/auth';
import { authService } from '$lib/services/authService';
import { theme } from '$lib/stores/theme';
import { t } from 'svelte-i18n';
import Icon from '$lib/components/Icon.svelte';
import Modal from '$lib/components/Modal.svelte';
import { env } from '$lib/config/env';
import { onMount } from 'svelte';
type AuthMode = 'initial' | 'login' | 'forgot-password' | 'password-reset-success';
let loading = $state(false);
let error = $state<string | null>(null);
let email = $state('');
let password = $state('');
let mode = $state<AuthMode>('initial');
let showManaInfoModal = $state(false);
let resetEmail = $state(''); // Store email for success message
let showPassword = $state(false); // Toggle password visibility
let currentTheme = $derived($theme);
let isDark = $derived(currentTheme.effectiveMode === 'dark');
// Password validation requirements
let passwordRequirements = $derived.by(() => {
if (!password) {
return {
length: false,
lowercase: false,
uppercase: false,
digit: false,
special: false
};
// Configure OAuth on mount
onMount(() => {
if (env.oauth.googleClientId) {
setGoogleClientId(env.oauth.googleClientId);
}
if (env.oauth.appleClientId && env.oauth.appleRedirectUri) {
setAppleConfig(env.oauth.appleClientId, env.oauth.appleRedirectUri);
}
return {
length: password.length >= 8,
lowercase: /[a-z]/.test(password),
uppercase: /[A-Z]/.test(password),
digit: /[0-9]/.test(password),
special: /[^a-zA-Z0-9]/.test(password)
};
});
// Display OAuth error from URL if present
let oauthErrorRaw = $derived($page.url.searchParams.get('error'));
let oauthError = $derived.by(() => {
if (!oauthErrorRaw) return null;
// Map OAuth error codes to translation keys
const errorMap: Record<string, string> = {
'access_denied': 'auth.oauth_error_access_denied',
'server_error': 'auth.oauth_error_server_error',
'temporarily_unavailable': 'auth.oauth_error_temporarily_unavailable',
'invalid_request': 'auth.oauth_error_invalid_request',
'unauthorized_client': 'auth.oauth_error_unauthorized_client',
'unsupported_response_type': 'auth.oauth_error_unsupported_response_type',
'invalid_scope': 'auth.oauth_error_invalid_scope'
};
const translationKey = errorMap[oauthErrorRaw] || 'auth.oauth_error_unknown';
return $t(translationKey);
});
// Get primary color based on theme variant
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];
}
async function handleSignIn(email: string, password: string) {
return auth.signIn(email, password);
}
// Get page background based on theme variant
function getPageBackground() {
const variant = currentTheme.variant;
if (isDark) {
const colors = {
lume: '#101010',
nature: '#121212',
stone: '#121212',
ocean: '#121212'
};
return colors[variant];
} else {
const colors = {
lume: '#dddddd',
nature: '#FBFDF8',
stone: '#F5F7F9',
ocean: '#F5FCFF'
};
return colors[variant];
}
async function handleSignInWithGoogle(idToken: string) {
return auth.signInWithGoogle(idToken);
}
async function handleLogin() {
loading = true;
error = null;
// Validate inputs
if (!email) {
error = $t('auth.error_email_required');
loading = false;
return;
}
if (!password) {
error = $t('auth.error_password_required');
loading = false;
return;
}
const result = await auth.signIn(email, password);
loading = false;
if (result.success) {
goto('/dashboard');
} else {
error = result.error || $t('auth.logging_in');
}
}
async function handleForgotPassword() {
loading = true;
error = null;
// Validate email
if (!email) {
error = $t('auth.error_email_required');
loading = false;
return;
}
// Send password reset email
const result = await authService.forgotPassword(email);
loading = false;
if (result.success) {
// Store email for success message and switch to success screen
resetEmail = email;
resetForm();
switchMode('password-reset-success');
} else {
// Show error with specific handling for rate limiting
let errorMessage = result.error || $t('auth.reset_password_error');
// Check if it's a rate limit error
if (result.error?.includes('rate limit') || result.error?.includes('too many')) {
errorMessage = $t('auth.reset_password_rate_limit');
}
error = errorMessage;
}
}
function resetForm() {
email = '';
password = '';
error = null;
}
function switchMode(newMode: AuthMode) {
mode = newMode;
error = null;
async function handleForgotPassword(email: string) {
return auth.forgotPassword(email);
}
</script>
<svelte:head>
<title>Login - Memoro</title>
</svelte:head>
<div
class="flex min-h-screen flex-col justify-between"
style="background-color: {getPageBackground()};"
>
<!-- Language Selector and Theme Toggle in top right -->
<div class="absolute right-4 top-4 z-50 flex items-center gap-3 opacity-60">
<LanguageSelector />
<ThemeToggle />
</div>
<!-- Top Section - Logo and Welcome -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4 bg-black"
style="width: 120px; height: 120px; border: 3px solid {getPrimaryColor()}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
>
<MemoroLogo size={55} color={getPrimaryColor()} />
</div>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
Memoro
</h1>
</div>
<!-- Middle Section - Auth Buttons Container -->
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<div class="mb-6">
<h2 class="text-center text-xl font-semibold text-theme flex items-center justify-center gap-2">
{#if mode === 'initial'}
<svg class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z" />
</svg>
{$t('auth.mana_login')}
{:else if mode === 'login'}
{$t('auth.sign_in')}
{:else if mode === 'forgot-password'}
{$t('auth.reset_password')}
{:else if mode === 'password-reset-success'}
{$t('auth.reset_email_sent_title')}
{/if}
</h2>
{#if mode === 'initial'}
<div class="mt-3 flex flex-col items-center justify-center gap-2">
<p class="text-sm text-center text-theme-muted">
{$t('auth.mana_login_description')}
</p>
<button
onclick={() => showManaInfoModal = true}
class="flex items-center gap-2 text-xs font-medium hover:opacity-80 transition-opacity"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="info" size={16} />
Mehr erfahren
</button>
</div>
{/if}
</div>
<!-- Error Messages -->
{#if error}
<div class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3">
<p class="text-sm text-red-500">{error}</p>
</div>
{/if}
{#if oauthError}
<div class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3">
<p class="text-sm text-red-500">{oauthError}</p>
</div>
{/if}
<!-- Initial Mode -->
{#if mode === 'initial'}
<div class="mb-2 flex flex-col gap-3">
<button
onclick={() => goto('/register')}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
style="background-color: {getPrimaryColor()}60; border-color: {getPrimaryColor()}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="user-plus" size={20} />
{$t('auth.create_account')}
</button>
<button
onclick={() => switchMode('login')}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all bg-content hover:bg-menu-hover text-theme border border-theme"
>
<Icon name="sign-in" size={20} />
{$t('auth.sign_in')}
</button>
</div>
<!-- Terms and Privacy -->
<div class="mt-4 text-center px-2">
<p class="text-xs text-theme-muted">
{@html $t('auth.terms_agreement')}
</p>
</div>
<!-- Login Mode -->
{:else if mode === 'login'}
<form
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
class="pb-4"
>
<div class="mb-2">
<input
type="email"
bind:value={email}
placeholder="Email"
required
class="h-14 w-full rounded-xl border border-theme bg-content px-4 text-lg transition-colors focus:outline-none focus:ring-2 focus:ring-primary text-theme placeholder:text-theme-muted"
/>
</div>
<div class="mb-2 relative">
<input
type={showPassword ? 'text' : 'password'}
bind:value={password}
placeholder={$t('auth.password')}
required
class="h-14 w-full rounded-xl border border-theme bg-content px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2 focus:ring-primary text-theme placeholder:text-theme-muted"
/>
<button
type="button"
onclick={() => showPassword = !showPassword}
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-menu-hover transition-colors"
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} size={20} class="text-theme-muted" />
</button>
</div>
<button
type="button"
onclick={() => switchMode('forgot-password')}
class="mb-4 flex h-10 w-full items-center justify-center rounded-xl font-medium transition-all bg-content hover:bg-menu-hover text-theme border border-theme"
>
{$t('auth.forgot_password')}
</button>
<div class="mb-0 flex flex-col gap-4">
<button
type="submit"
disabled={loading}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {getPrimaryColor()}60; border-color: {getPrimaryColor()}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="sign-in" size={20} />
{loading ? $t('auth.logging_in') : $t('auth.sign_in')}
</button>
</div>
</form>
<!-- Divider -->
<div class="my-4 flex items-center gap-3">
<div class="flex-1 border-t border-theme-light"></div>
<p class="text-xs text-theme-muted">{$t('common.or')}</p>
<div class="flex-1 border-t border-theme-light"></div>
</div>
<!-- Social Sign-In Options -->
<div class="mb-4 flex flex-col gap-2">
<GoogleSignInButton />
<AppleSignInButton />
</div>
<!-- Back Button -->
<div class="mt-4">
<button
onclick={() => {
resetForm();
switchMode('initial');
}}
class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:bg-menu-hover text-theme"
>
<Icon name="arrow-left" size={20} />
{$t('auth.back')}
</button>
</div>
<!-- Forgot Password Mode -->
{:else if mode === 'forgot-password'}
<form
onsubmit={(e) => {
e.preventDefault();
handleForgotPassword();
}}
class="pb-4"
>
<p class="mb-4 text-sm text-theme-secondary">
{$t('auth.reset_password_description')}
</p>
<div class="mb-4">
<input
type="email"
bind:value={email}
placeholder={$t('auth.email')}
required
class="h-14 w-full rounded-xl border border-theme bg-content px-4 text-lg transition-colors focus:outline-none focus:ring-2 focus:ring-primary text-theme placeholder:text-theme-muted"
/>
</div>
<div class="flex flex-col gap-4">
<button
type="submit"
disabled={loading}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {getPrimaryColor()}60; border-color: {getPrimaryColor()}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="key" size={20} />
{loading ? $t('auth.sending') : $t('auth.reset_password')}
</button>
<button
type="button"
onclick={() => {
resetForm();
switchMode('login');
}}
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:bg-menu-hover text-theme"
>
<Icon name="arrow-left" size={20} />
{$t('auth.back')}
</button>
</div>
</form>
<!-- Password Reset Success Mode -->
{:else if mode === 'password-reset-success'}
<div class="pb-4">
<div class="flex flex-col items-center mb-6">
<div
class="w-20 h-20 rounded-full flex items-center justify-center mb-6"
style="background-color: {getPrimaryColor()}30;"
>
<Icon name="mail-open" size={40} color={getPrimaryColor()} />
</div>
<p class="text-sm text-theme-secondary text-center px-2">
{$t('auth.reset_email_sent_description').replace('{email}', resetEmail)}
</p>
</div>
<div class="flex flex-col gap-3">
<button
onclick={() => {
resetEmail = '';
switchMode('login');
}}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
style="background-color: {getPrimaryColor()}60; border-color: {getPrimaryColor()}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="sign-in" size={20} />
{$t('auth.back_to_login')}
</button>
<button
onclick={() => switchMode('forgot-password')}
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all bg-content hover:bg-menu-hover text-theme border border-theme"
>
{$t('auth.resend_email')}
</button>
</div>
</div>
{/if}
</div>
</div>
<!-- Bottom Section - App Slider -->
{#if mode === 'initial'}
<div class="w-full pb-8 px-2 pt-4">
<AppSlider />
</div>
{/if}
<!-- Mana Login Info Modal -->
<Modal
visible={showManaInfoModal}
onClose={() => showManaInfoModal = false}
title="Mana Login"
maxWidth="lg"
>
{#snippet icon()}
<svg class="h-6 w-6 text-theme" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z" />
</svg>
{/snippet}
{#snippet children()}
<div class="space-y-4">
<p class="text-theme">
Mana Login ist dein zentraler Zugang zu allen Apps im Mana-Ökosystem. Mit einem
einzigen Account kannst du dich bei allen Mana-Anwendungen anmelden.
</p>
<div class="space-y-2">
<h4 class="font-semibold text-theme mb-1">Vorteile:</h4>
<div class="rounded-xl bg-content p-3 border border-theme flex items-start gap-2">
<svg class="h-5 w-5 text-theme flex-shrink-0 mt-0.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z" />
</svg>
<span class="text-theme">{$t('auth.mana_login_benefit_0')}</span>
</div>
<div class="rounded-xl bg-content p-3 border border-theme flex items-start gap-2">
<Icon name="key" size={20} class="text-theme flex-shrink-0 mt-0.5" />
<span class="text-theme">Ein Login für alle Mana Apps</span>
</div>
<div class="rounded-xl bg-content p-3 border border-theme flex items-start gap-2">
<Icon name="shield-check" size={20} class="text-theme flex-shrink-0 mt-0.5" />
<span class="text-theme">Sichere Authentifizierung mit modernen Standards</span>
</div>
<div class="rounded-xl bg-content p-3 border border-theme flex items-start gap-2">
<Icon name="arrows-left-right" size={20} class="text-theme flex-shrink-0 mt-0.5" />
<span class="text-theme">Synchronisation deiner Einstellungen über alle Apps hinweg</span>
</div>
<div class="rounded-xl bg-content p-3 border border-theme flex items-start gap-2">
<Icon name="folder" size={20} class="text-theme flex-shrink-0 mt-0.5" />
<span class="text-theme">Einfache Verwaltung deiner Daten an einem zentralen Ort</span>
</div>
</div>
<p class="text-sm text-theme">
Weitere Mana Apps werden in Zukunft hinzugefügt und können dann ebenfalls mit deinem
Mana Login genutzt werden.
</p>
</div>
{/snippet}
{#snippet footer()}
<button
onclick={() => showManaInfoModal = false}
class="w-full px-6 py-2 rounded-xl font-medium bg-content hover:bg-menu-hover text-theme border border-theme transition-colors"
>
Verstanden
</button>
{/snippet}
</Modal>
</div>
<LoginPage
appName="Memoro"
logo={MemoroLogo}
primaryColor="#f8d62b"
onSignIn={handleSignIn}
onSignInWithGoogle={handleSignInWithGoogle}
onForgotPassword={handleForgotPassword}
goto={goto}
enableGoogle={!!env.oauth.googleClientId}
enableApple={!!env.oauth.appleClientId}
successRedirect="/dashboard"
registerPath="/register"
lightBackground="#dddddd"
darkBackground="#101010"
/>

View file

@ -1,408 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import { RegisterPage } from '@manacore/shared-auth-ui';
import MemoroLogo from '$lib/components/MemoroLogo.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import Modal from '$lib/components/Modal.svelte';
import Icon from '$lib/components/Icon.svelte';
import { auth } from '$lib/stores/auth';
import { theme } from '$lib/stores/theme';
import { t } from 'svelte-i18n';
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
let needsVerification = $state(false);
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let showModal = $state(false);
let showPassword = $state(false);
let showConfirmPassword = $state(false);
let currentTheme = $derived($theme);
let isDark = $derived(currentTheme.effectiveMode === 'dark');
// Password validation requirements
let passwordRequirements = $derived.by(() => {
if (!password) {
return {
length: false,
lowercase: false,
uppercase: false,
digit: false,
special: false
};
}
return {
length: password.length >= 8,
lowercase: /[a-z]/.test(password),
uppercase: /[A-Z]/.test(password),
digit: /[0-9]/.test(password),
special: /[^a-zA-Z0-9]/.test(password)
};
});
// Display OAuth error from URL if present
let oauthError = $derived($page.url.searchParams.get('error'));
// Get primary color based on theme variant
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];
}
}
// Get page background based on theme variant
function getPageBackground() {
const variant = currentTheme.variant;
if (isDark) {
const colors = {
lume: '#101010',
nature: '#121212',
stone: '#121212',
ocean: '#121212'
};
return colors[variant];
} else {
const colors = {
lume: '#dddddd',
nature: '#FBFDF8',
stone: '#F5F7F9',
ocean: '#F5FCFF'
};
return colors[variant];
}
}
async function handleRegister() {
loading = true;
error = null;
success = false;
// Client-side validation
if (!email) {
error = $t('auth.error_email_required');
loading = false;
return;
}
if (!password) {
error = $t('auth.error_password_required');
loading = false;
return;
}
if (!confirmPassword) {
error = $t('auth.error_confirm_password');
loading = false;
return;
}
if (password !== confirmPassword) {
error = $t('auth.error_passwords_not_match');
loading = false;
return;
}
if (password.length < 8) {
error = $t('auth.error_password_too_short');
loading = false;
return;
}
// Check password strength
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasDigit = /[0-9]/.test(password);
const hasSymbol = /[^a-zA-Z0-9]/.test(password);
if (!hasLowercase || !hasUppercase || !hasDigit || !hasSymbol) {
error = $t('auth.error_password_requirements');
loading = false;
return;
}
const result = await auth.signUp(email, password);
loading = false;
if (result.success) {
if (result.needsVerification) {
needsVerification = true;
success = true;
// Clear sensitive fields but keep email
password = '';
confirmPassword = '';
} else {
goto('/dashboard');
}
} else {
error = result.error || $t('auth.error_registration_failed');
}
}
function resetForm() {
email = '';
password = '';
confirmPassword = '';
error = null;
success = false;
needsVerification = false;
async function handleSignUp(email: string, password: string) {
return auth.signUp(email, password);
}
</script>
<svelte:head>
<title>{$t('auth.create_account')} - Memoro</title>
</svelte:head>
<div
class="flex min-h-screen flex-col justify-between"
style="background-color: {getPageBackground()};"
>
<!-- Language Selector and Theme Toggle in top right -->
<div class="absolute right-4 top-4 z-50 flex items-center gap-3 opacity-60">
<LanguageSelector />
<ThemeToggle />
</div>
<!-- Top Section - Logo and Welcome -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4 bg-black"
style="width: 120px; height: 120px; border: 3px solid {getPrimaryColor()}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
>
<MemoroLogo size={55} color={getPrimaryColor()} />
</div>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
Memoro
</h1>
</div>
<!-- Middle Section - Auth Buttons Container -->
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<h2
class="mb-6 text-center text-xl font-semibold"
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
>
{$t('auth.create_account')}
</h2>
<!-- Error Messages -->
{#if error}
<div class="mb-4 rounded-lg bg-red-500/20 p-3">
<p class="text-sm text-red-500">{error}</p>
</div>
{/if}
{#if success && needsVerification}
<div class="mb-4 rounded-lg bg-green-500/20 p-3">
<p class="text-sm text-green-500">
{$t('auth.registration_success')}
</p>
</div>
{/if}
{#if oauthError}
<div class="mb-4 rounded-lg bg-red-500/20 p-3">
<p class="text-sm text-red-500">{oauthError}</p>
</div>
{/if}
<!-- Email Register Form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleRegister();
}}
class="pb-4"
>
<div class="mb-2">
<input
type="email"
bind:value={email}
placeholder={$t('auth.email')}
required
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(241, 248, 233, 0.5)'}; border-color: {isDark
? '#424242'
: '#C8E6C9'}; color: {isDark ? '#ffffff' : '#000000'};"
/>
</div>
<div class="mb-2">
<input
type="password"
bind:value={password}
placeholder={$t('auth.password')}
required
minlength="8"
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(241, 248, 233, 0.5)'}; border-color: {isDark
? '#424242'
: '#C8E6C9'}; color: {isDark ? '#ffffff' : '#000000'};"
/>
</div>
<div class="mb-2">
<input
type="password"
bind:value={confirmPassword}
placeholder={$t('auth.confirm_password')}
required
minlength="8"
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors"
style="background-color: {isDark
? 'rgba(0, 0, 0, 0.2)'
: 'rgba(241, 248, 233, 0.5)'}; border-color: {isDark
? '#424242'
: '#C8E6C9'}; color: {isDark ? '#ffffff' : '#000000'};"
/>
</div>
<p
class="mb-4 mt-2 text-xs"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
{$t('auth.password_requirement')}
</p>
<div class="mb-0 flex flex-col gap-4">
<button
type="submit"
disabled={loading}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {getPrimaryColor()}60; border-color: {getPrimaryColor()}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="user-plus" size={20} />
{loading ? $t('auth.creating_account') : $t('auth.create_account')}
</button>
</div>
</form>
<!-- Info about email-only authentication -->
<div class="mt-4 mb-2 rounded-xl p-4 flex flex-col items-center gap-2" style="background-color: {isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'};">
<p class="text-xs text-center" style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};">
{$t('auth.email_only_info')}
</p>
<button
onclick={() => showModal = true}
class="flex items-center gap-2 text-xs font-medium hover:opacity-80 transition-opacity"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="info" size={16} />
{$t('auth.email_only_learn_more')}
</button>
</div>
<!-- Back Button -->
<div class="mt-4">
<button
onclick={() => goto('/login')}
class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="arrow-left" size={20} />
{$t('common.back')}
</button>
</div>
</div>
</div>
</div>
<!-- Modal for "Learn More" -->
<Modal visible={showModal} onClose={() => showModal = false} title={$t('auth.email_only_title')} maxWidth="lg">
{#snippet children()}
<div class="space-y-4">
<p class="text-sm leading-relaxed text-theme">
{$t('auth.email_only_intro')}
</p>
<div class="space-y-2">
<div class="rounded-xl bg-content p-3 border border-theme flex gap-3">
<div class="flex-shrink-0 mt-0.5">
<Icon name="lock" size={20} class="text-theme" />
</div>
<div>
<h3 class="font-medium mb-1 text-theme">{$t('auth.email_only_benefit_1_title')}</h3>
<p class="text-sm text-theme">{$t('auth.email_only_benefit_1_desc')}</p>
</div>
</div>
<div class="rounded-xl bg-content p-3 border border-theme flex gap-3">
<div class="flex-shrink-0 mt-0.5">
<Icon name="shield-check" size={20} class="text-theme" />
</div>
<div>
<h3 class="font-medium mb-1 text-theme">{$t('auth.email_only_benefit_2_title')}</h3>
<p class="text-sm text-theme">{$t('auth.email_only_benefit_2_desc')}</p>
</div>
</div>
<div class="rounded-xl bg-content p-3 border border-theme flex gap-3">
<div class="flex-shrink-0 mt-0.5">
<Icon name="arrows-left-right" size={20} class="text-theme" />
</div>
<div>
<h3 class="font-medium mb-1 text-theme">{$t('auth.email_only_benefit_3_title')}</h3>
<p class="text-sm text-theme">{$t('auth.email_only_benefit_3_desc')}</p>
</div>
</div>
<div class="rounded-xl bg-content p-3 border border-theme flex gap-3">
<div class="flex-shrink-0 mt-0.5">
<Icon name="envelope" size={20} class="text-theme" />
</div>
<div>
<h3 class="font-medium mb-1 text-theme">{$t('auth.email_only_benefit_4_title')}</h3>
<p class="text-sm text-theme">{$t('auth.email_only_benefit_4_desc')}</p>
</div>
</div>
</div>
<p class="text-sm leading-relaxed text-theme">
{$t('auth.email_only_modal_footer')}
</p>
</div>
{/snippet}
{#snippet footer()}
<button
onclick={() => showModal = false}
class="w-full px-6 py-2 rounded-xl font-medium bg-content hover:bg-menu-hover text-theme border border-theme transition-colors"
>
{$t('auth.got_it')}
</button>
{/snippet}
</Modal>
<RegisterPage
appName="Memoro"
logo={MemoroLogo}
primaryColor="#f8d62b"
onSignUp={handleSignUp}
goto={goto}
successRedirect="/dashboard"
loginPath="/login"
lightBackground="#dddddd"
darkBackground="#101010"
/>

View file

@ -1,8 +1,13 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import type { Session } from '@supabase/supabase-js';
let { data } = $props();
interface PageData {
session?: Session | null;
}
let { data }: { data: PageData } = $props();
onMount(() => {
if (data.session) {

View file

@ -7,7 +7,7 @@
parseAppleAuthorizationResponse,
getStoredReturnUrl,
clearAppleSignInSession
} from '$lib/utils/appleAuth';
} from '@manacore/shared-auth-ui';
let error = $state<string | null>(null);
let processing = $state(true);

View file

@ -1,165 +1,12 @@
import preset from '@manacore/shared-tailwind/preset';
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {
colors: {
// Mana - Consistent color for subscription page
mana: '#4287f5',
// Lume Theme - Modern Gold & Dark
lume: {
primary: '#f8d62b',
primaryButton: '#f8d62b',
primaryButtonText: '#000000',
secondary: '#D4B200',
secondaryButton: '#FFE9A3',
contentBackground: '#ffffff',
contentBackgroundHover: '#f5f5f5',
contentPageBackground: '#ffffff',
menuBackground: '#dddddd',
menuBackgroundHover: '#cccccc',
pageBackground: '#dddddd',
text: '#2c2c2c',
borderLight: '#f2f2f2',
border: '#e6e6e6',
borderStrong: '#cccccc',
error: '#e74c3c'
},
// Nature Theme - Soothing Green
nature: {
primary: '#4CAF50',
primaryButton: '#A08500',
primaryButtonText: '#ffffff',
secondary: '#81C784',
secondaryButton: '#F1F8E9',
contentBackground: '#F1F8E9',
contentBackgroundHover: '#E8F5E9',
contentPageBackground: '#ffffff',
menuBackground: '#E8F5E9',
menuBackgroundHover: '#C8E6C9',
pageBackground: '#FBFDF8',
text: '#1B5E20',
borderLight: '#E8F5E9',
border: '#C8E6C9',
borderStrong: '#A5D6A7',
error: '#E57373'
},
// Stone Theme - Elegant Slate
stone: {
primary: '#607D8B',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#90A4AE',
secondaryButton: '#ECEFF1',
contentBackground: '#ECEFF1',
contentBackgroundHover: '#E0E6EA',
contentPageBackground: '#ffffff',
menuBackground: '#E0E6EA',
menuBackgroundHover: '#CFD8DC',
pageBackground: '#F5F7F9',
text: '#263238',
borderLight: '#ECEFF1',
border: '#CFD8DC',
borderStrong: '#B0BEC5',
error: '#EF5350'
},
// Ocean Theme - Tranquil Blue
ocean: {
primary: '#039BE5',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#4FC3F7',
secondaryButton: '#E1F5FE',
contentBackground: '#E1F5FE',
contentBackgroundHover: '#B3E5FC',
contentPageBackground: '#ffffff',
menuBackground: '#E1F5FE',
menuBackgroundHover: '#B3E5FC',
pageBackground: '#F5FCFF',
text: '#01579B',
borderLight: '#E1F5FE',
border: '#B3E5FC',
borderStrong: '#81D4FA',
error: '#EF5350'
},
// Dark Mode Variants
dark: {
lume: {
primary: '#f8d62b',
primaryButton: '#7C6B16',
primaryButtonText: '#ffffff',
secondary: '#D4B200',
secondaryButton: '#1E1E1E',
contentBackground: '#1E1E1E',
contentBackgroundHover: '#333333',
contentPageBackground: '#121212',
menuBackground: '#101010',
menuBackgroundHover: '#333333',
pageBackground: '#101010',
text: '#ffffff',
borderLight: '#333333',
border: '#424242',
borderStrong: '#616161',
error: '#e74c3c'
},
nature: {
primary: '#4CAF50',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#81C784',
secondaryButton: '#1E1E1E',
contentBackground: '#1E1E1E',
contentBackgroundHover: '#2E7D32',
contentPageBackground: '#121212',
menuBackground: '#252525',
menuBackgroundHover: '#2E7D32',
pageBackground: '#121212',
text: '#FFFFFF',
borderLight: '#1B5E20',
border: '#2E7D32',
borderStrong: '#388E3C',
error: '#CF6679'
},
stone: {
primary: '#78909C',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#90A4AE',
secondaryButton: '#1E1E1E',
contentBackground: '#1E1E1E',
contentBackgroundHover: '#37474F',
contentPageBackground: '#121212',
menuBackground: '#252525',
menuBackgroundHover: '#37474F',
pageBackground: '#121212',
text: '#FFFFFF',
borderLight: '#37474F',
border: '#455A64',
borderStrong: '#546E7A',
error: '#CF6679'
},
ocean: {
primary: '#039BE5',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#4FC3F7',
secondaryButton: '#1E1E1E',
contentBackground: '#1E1E1E',
contentBackgroundHover: '#0277BD',
contentPageBackground: '#121212',
menuBackground: '#252525',
menuBackgroundHover: '#0277BD',
pageBackground: '#121212',
text: '#FFFFFF',
borderLight: '#01579B',
border: '#0277BD',
borderStrong: '#0288D1',
error: '#CF6679'
}
}
}
}
},
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}'
],
plugins: [require('@tailwindcss/typography')]
};