mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
725db638ea
commit
ef70a1af0b
198 changed files with 11113 additions and 3656 deletions
356
CHANGELOG_2025-11-24.md
Normal file
356
CHANGELOG_2025-11-24.md
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
# Changelog - Shared Packages Integration (2025-11-24)
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Dieses Update führt eine umfassende **Shared Packages Architektur** ein, die gemeinsamen Code über alle vier Web-Apps im Monorepo vereinheitlicht. Die Änderungen reduzieren duplizierter Code erheblich (ca. 3.000 LOC gelöscht), verbessern die Wartbarkeit und sorgen für konsistentes Verhalten und Design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Neue Shared Packages
|
||||||
|
|
||||||
|
### 1. `@manacore/shared-auth` (Neu)
|
||||||
|
**Pfad**: `packages/shared-auth/`
|
||||||
|
|
||||||
|
Einheitliche Authentifizierungslogik für alle Web-Apps:
|
||||||
|
|
||||||
|
- **Core Services**:
|
||||||
|
- `authService.ts` - Login, Logout, Register, Passwort-Reset
|
||||||
|
- `tokenManager.ts` - JWT Token Storage, Refresh, Validierung
|
||||||
|
- `jwtUtils.ts` - Token-Dekodierung, Ablaufprüfung, B2B-Erkennung
|
||||||
|
|
||||||
|
- **Adapter-Pattern für Plattformunabhängigkeit**:
|
||||||
|
- `storage` - LocalStorage/Memory-Adapter
|
||||||
|
- `device` - Geräte-Info für Token-Binding
|
||||||
|
- `network` - Netzwerk-Status-Erkennung
|
||||||
|
|
||||||
|
- **Interceptor**:
|
||||||
|
- `fetchInterceptor.ts` - Automatische Token-Injection in API-Calls
|
||||||
|
|
||||||
|
- **API**:
|
||||||
|
```typescript
|
||||||
|
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||||
|
|
||||||
|
const { authService, tokenManager } = initializeWebAuth({
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `@manacore/shared-auth-ui` (Neu)
|
||||||
|
**Pfad**: `packages/shared-auth-ui/`
|
||||||
|
|
||||||
|
Wiederverwendbare Auth-UI-Komponenten:
|
||||||
|
|
||||||
|
- **Pages**:
|
||||||
|
- `LoginPage.svelte` - Vollständige Login-Seite mit OAuth
|
||||||
|
- `RegisterPage.svelte` - Vollständige Registrierungs-Seite
|
||||||
|
- `ResetPasswordPage.svelte` - Passwort-Reset-Flow
|
||||||
|
|
||||||
|
- **Components**:
|
||||||
|
- `GoogleSignInButton.svelte` - Google OAuth Button
|
||||||
|
- `AppleSignInButton.svelte` - Apple OAuth Button
|
||||||
|
- `PasswordInput.svelte` - Passwort-Input mit Validierung
|
||||||
|
|
||||||
|
- **Icons**:
|
||||||
|
- Google/Apple Logos als Svelte-Komponenten
|
||||||
|
|
||||||
|
- **Konfiguration**:
|
||||||
|
```typescript
|
||||||
|
import { setGoogleClientId, setAppleConfig } from '@manacore/shared-auth-ui';
|
||||||
|
|
||||||
|
setGoogleClientId('your-client-id');
|
||||||
|
setAppleConfig({ clientId: '...', redirectUri: '...' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `@manacore/shared-tailwind` (Neu)
|
||||||
|
**Pfad**: `packages/shared-tailwind/`
|
||||||
|
|
||||||
|
Einheitliche Tailwind-Konfiguration mit 4 Theme-Varianten:
|
||||||
|
|
||||||
|
- **Themes**:
|
||||||
|
- `lume` - Gold & Modern (Primary: #f8d62b)
|
||||||
|
- `nature` - Grün & Beruhigend (Primary: #4CAF50)
|
||||||
|
- `stone` - Slate & Elegant (Primary: #607D8B)
|
||||||
|
- `ocean` - Blau & Tranquil (Primary: #039BE5)
|
||||||
|
|
||||||
|
- **Features**:
|
||||||
|
- Light/Dark Mode für jedes Theme
|
||||||
|
- 13+ semantische Farb-Tokens pro Theme
|
||||||
|
- CSS-Variable-basiertes Theming
|
||||||
|
- Fertige Component-Utilities
|
||||||
|
|
||||||
|
- **Verwendung**:
|
||||||
|
```javascript
|
||||||
|
// tailwind.config.js
|
||||||
|
import preset from '@manacore/shared-tailwind/preset';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
presets: [preset],
|
||||||
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `@manacore/shared-icons` (Neu)
|
||||||
|
**Pfad**: `packages/shared-icons/`
|
||||||
|
|
||||||
|
Einheitliche Icon-Bibliothek basierend auf Phosphor Icons:
|
||||||
|
|
||||||
|
- **Komponente**:
|
||||||
|
```svelte
|
||||||
|
<script>
|
||||||
|
import { Icon } from '@manacore/shared-icons';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Icon name="play" size={24} class="text-primary" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Icons**: 40+ häufig verwendete Icons (play, pause, settings, user, etc.)
|
||||||
|
|
||||||
|
### 5. `@manacore/shared-ui` (Erweitert)
|
||||||
|
**Pfad**: `packages/shared-ui/`
|
||||||
|
|
||||||
|
Atomic Design System für Svelte-Komponenten:
|
||||||
|
|
||||||
|
- **Atoms** (`src/atoms/`):
|
||||||
|
- `Text.svelte` - Typography mit Varianten
|
||||||
|
- `Button.svelte` - Primary, Secondary, Ghost, Danger
|
||||||
|
- `Badge.svelte` - Status-Badges
|
||||||
|
|
||||||
|
- **Molecules** (`src/molecules/`):
|
||||||
|
- `Toggle.svelte` - Toggle-Switch
|
||||||
|
- `Input.svelte` - Text-Input mit Label & Validation
|
||||||
|
|
||||||
|
- **Organisms** (`src/organisms/`):
|
||||||
|
- `Modal.svelte` - Overlay-Modal mit Slots
|
||||||
|
|
||||||
|
### 6. `@manacore/shared-types` (Erweitert)
|
||||||
|
**Pfad**: `packages/shared-types/`
|
||||||
|
|
||||||
|
Neue Type-Module hinzugefügt:
|
||||||
|
|
||||||
|
- `auth.ts` - Auth-bezogene Types (User, Session, Token)
|
||||||
|
- `theme.ts` - Theme-Konfiguration Types
|
||||||
|
- `ui.ts` - UI-Komponenten Types
|
||||||
|
- `common.ts` - Gemeinsame Utility Types
|
||||||
|
|
||||||
|
### 7. `@manacore/shared-utils` (Erweitert)
|
||||||
|
**Pfad**: `packages/shared-utils/`
|
||||||
|
|
||||||
|
Neue Utility-Module hinzugefügt:
|
||||||
|
|
||||||
|
- `format.ts` - formatDuration, formatFileSize, formatNumber, formatCurrency
|
||||||
|
- `validation.ts` - isValidEmail, isValidUrl, validatePassword
|
||||||
|
|
||||||
|
### 8. `@manacore/shared-i18n` (Neu)
|
||||||
|
**Pfad**: `packages/shared-i18n/`
|
||||||
|
|
||||||
|
Einheitliche Internationalisierung:
|
||||||
|
|
||||||
|
- Locale-Detection
|
||||||
|
- Common Translations (Buttons, Errors)
|
||||||
|
- svelte-i18n Integration
|
||||||
|
|
||||||
|
### 9. `@manacore/shared-config` (Neu)
|
||||||
|
**Pfad**: `packages/shared-config/`
|
||||||
|
|
||||||
|
Environment-Konfiguration:
|
||||||
|
|
||||||
|
- Zod-basierte Env-Validierung
|
||||||
|
- Typsichere Config-Objekte
|
||||||
|
|
||||||
|
### 10. `@manacore/shared-subscription-types` (Neu) / `@manacore/shared-subscription-ui` (Neu)
|
||||||
|
**Pfad**: `packages/shared-subscription-types/`, `packages/shared-subscription-ui/`
|
||||||
|
|
||||||
|
Subscription-bezogene Types und UI-Komponenten (Vorbereitung für zukünftige Integration).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App-Spezifische Änderungen
|
||||||
|
|
||||||
|
### Memoro Web (`memoro/apps/web/`)
|
||||||
|
|
||||||
|
**Gelöschte Dateien** (Migration zu Shared Packages):
|
||||||
|
- `src/lib/components/AppleSignInButton.svelte` → `@manacore/shared-auth-ui`
|
||||||
|
- `src/lib/components/GoogleSignInButton.svelte` → `@manacore/shared-auth-ui`
|
||||||
|
- `src/lib/components/Modal.svelte` → `@manacore/shared-ui`
|
||||||
|
- `src/lib/components/Toggle.svelte` → `@manacore/shared-ui`
|
||||||
|
- `src/lib/components/BillingToggle.svelte` → Nicht mehr benötigt
|
||||||
|
- `src/lib/components/CostCard.svelte` → Refactored
|
||||||
|
- `src/lib/components/PackageCard.svelte` → Refactored
|
||||||
|
- `src/lib/components/SubscriptionCard.svelte` → Refactored
|
||||||
|
- `src/lib/components/SubscriptionButton.svelte` → Refactored
|
||||||
|
- `src/lib/components/UsageCard.svelte` → Refactored
|
||||||
|
- `src/lib/components/ManaIcon.svelte` → `@manacore/shared-icons`
|
||||||
|
- `src/lib/components/atoms/Text.svelte` → `@manacore/shared-ui`
|
||||||
|
- `src/lib/components/icons/` → `@manacore/shared-icons`
|
||||||
|
- `src/lib/utils/appleAuth.ts` → `@manacore/shared-auth-ui`
|
||||||
|
- `src/lib/utils/googleAuth.ts` → `@manacore/shared-auth-ui`
|
||||||
|
|
||||||
|
**Modifizierte Dateien**:
|
||||||
|
- `tailwind.config.js` - Reduziert von 165 auf 12 Zeilen (nutzt shared-tailwind preset)
|
||||||
|
- `src/app.css` - Drastisch reduziert (nutzt shared-tailwind CSS)
|
||||||
|
- `src/routes/(public)/login/+page.svelte` - Von 549 auf 46 Zeilen (nutzt LoginPage)
|
||||||
|
- `src/routes/(public)/register/+page.svelte` - Von 400+ auf 50 Zeilen (nutzt RegisterPage)
|
||||||
|
- 30+ Komponenten - Icon-Import auf `@manacore/shared-icons` umgestellt
|
||||||
|
|
||||||
|
### ManaCore Web (`manacore/apps/web/`)
|
||||||
|
|
||||||
|
**Gelöschte Dateien**:
|
||||||
|
- `src/routes/(auth)/login/+page.server.ts` → Client-side Auth
|
||||||
|
- `src/routes/(auth)/register/+page.server.ts` → Client-side Auth
|
||||||
|
|
||||||
|
**Neue Dateien**:
|
||||||
|
- `src/lib/stores/authStore.svelte.ts` - Auth-Store mit shared-auth
|
||||||
|
- `src/lib/components/Icon.svelte` - Icon-Wrapper
|
||||||
|
- `src/lib/components/ManaCoreLogo.svelte` - Logo-Komponente
|
||||||
|
- `src/lib/components/ThemeToggle.svelte` - Theme-Umschalter
|
||||||
|
- `src/lib/components/AppSlider.svelte` - App-Slider
|
||||||
|
|
||||||
|
**Modifizierte Dateien**:
|
||||||
|
- `tailwind.config.js` - Nutzt shared-tailwind preset
|
||||||
|
- `src/routes/(auth)/login/+page.svelte` - Nutzt LoginPage von shared-auth-ui
|
||||||
|
- `src/routes/(auth)/register/+page.svelte` - Nutzt RegisterPage von shared-auth-ui
|
||||||
|
|
||||||
|
### ManaDeck Web (`manadeck/apps/web/`)
|
||||||
|
|
||||||
|
**Gelöschte Dateien**:
|
||||||
|
- `src/lib/services/authService.ts` → `@manacore/shared-auth`
|
||||||
|
- `src/lib/services/tokenManager.ts` → `@manacore/shared-auth`
|
||||||
|
- `src/lib/services/deviceManager.ts` → `@manacore/shared-auth`
|
||||||
|
- `src/lib/utils/jwt.ts` → `@manacore/shared-auth`
|
||||||
|
|
||||||
|
**Neue Dateien**:
|
||||||
|
- `src/lib/auth.ts` - Auth-Initialisierung mit shared-auth
|
||||||
|
- `src/lib/components/Icon.svelte` - Icon-Wrapper
|
||||||
|
- `src/lib/components/ManaDeckLogo.svelte` - Logo-Komponente
|
||||||
|
|
||||||
|
**Modifizierte Dateien**:
|
||||||
|
- `tailwind.config.js` - Nutzt shared-tailwind
|
||||||
|
- `src/lib/stores/authStore.svelte.ts` - Nutzt shared-auth
|
||||||
|
- `src/routes/(auth)/login/+page.svelte` - Nutzt LoginPage
|
||||||
|
- `src/routes/(auth)/register/+page.svelte` - Nutzt RegisterPage
|
||||||
|
|
||||||
|
### Märchenzauber Web (`maerchenzauber/apps/web/`)
|
||||||
|
|
||||||
|
**Neue Dateien**:
|
||||||
|
- `src/lib/auth.ts` - Auth-Setup
|
||||||
|
- `src/lib/stores/` - Store-Implementierungen
|
||||||
|
- `src/lib/components/` - Komponenten
|
||||||
|
- `src/lib/utils/` - Utilities
|
||||||
|
- `src/lib/types/` - Type-Definitionen
|
||||||
|
- `src/routes/(auth)/` - Auth-Routen
|
||||||
|
- `src/app.css` - App-Styles
|
||||||
|
- `postcss.config.js` - PostCSS-Config
|
||||||
|
- `.env.example` - Environment-Template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quantitative Zusammenfassung
|
||||||
|
|
||||||
|
| Metrik | Vorher | Nachher | Einsparung |
|
||||||
|
|--------|--------|---------|------------|
|
||||||
|
| Dateien geändert | - | 102 | - |
|
||||||
|
| Zeilen hinzugefügt | - | ~1,400 | - |
|
||||||
|
| Zeilen gelöscht | - | ~4,300 | ~3,000 LOC |
|
||||||
|
| Login-Page LOC (Memoro) | 549 | 46 | 92% |
|
||||||
|
| Tailwind Config LOC (Memoro) | 165 | 12 | 93% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abhängigkeiten
|
||||||
|
|
||||||
|
Neue Dependencies in App `package.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@manacore/shared-auth": "workspace:*",
|
||||||
|
"@manacore/shared-auth-ui": "workspace:*",
|
||||||
|
"@manacore/shared-icons": "workspace:*",
|
||||||
|
"@manacore/shared-tailwind": "workspace:*",
|
||||||
|
"@manacore/shared-types": "workspace:*",
|
||||||
|
"@manacore/shared-ui": "workspace:*",
|
||||||
|
"@manacore/shared-utils": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
1. **Icon-Import-Pfade** - Alle Icons müssen von `@manacore/shared-icons` importiert werden
|
||||||
|
2. **Modal-Import** - Modal kommt jetzt von `@manacore/shared-ui`
|
||||||
|
3. **Auth-Services** - Lokale authService/tokenManager durch shared-auth ersetzt
|
||||||
|
4. **OAuth-Buttons** - Konfiguration erfolgt über `setGoogleClientId()` / `setAppleConfig()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Icon Migration
|
||||||
|
```svelte
|
||||||
|
<!-- Vorher -->
|
||||||
|
<script>
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Nachher -->
|
||||||
|
<script>
|
||||||
|
import { Icon } from '@manacore/shared-icons';
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Login Page Migration
|
||||||
|
```svelte
|
||||||
|
<!-- Vorher: 500+ Zeilen eigener Code -->
|
||||||
|
|
||||||
|
<!-- Nachher -->
|
||||||
|
<script>
|
||||||
|
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||||
|
import MyLogo from '$lib/components/MyLogo.svelte';
|
||||||
|
import { authStore } from '$lib/stores/authStore';
|
||||||
|
|
||||||
|
async function handleSignIn(email, password) {
|
||||||
|
return authStore.signIn(email, password);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LoginPage
|
||||||
|
onSignIn={handleSignIn}
|
||||||
|
onForgotPassword={handleForgotPassword}
|
||||||
|
registerUrl="/register"
|
||||||
|
>
|
||||||
|
{#snippet logo()}
|
||||||
|
<MyLogo />
|
||||||
|
{/snippet}
|
||||||
|
</LoginPage>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind Config Migration
|
||||||
|
```javascript
|
||||||
|
// Vorher: 150+ Zeilen mit Theme-Definitionen
|
||||||
|
|
||||||
|
// Nachher
|
||||||
|
import preset from '@manacore/shared-tailwind/preset';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
presets: [preset],
|
||||||
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
1. **Testing** - Alle Apps auf Funktionalität prüfen
|
||||||
|
2. **Type-Checking** - `pnpm run type-check` in allen Apps ausführen
|
||||||
|
3. **Build-Verification** - Production Builds testen
|
||||||
|
4. **Dokumentation** - README-Dateien für neue Packages erstellen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenzen
|
||||||
|
|
||||||
|
- `SHARED_PACKAGES_ROADMAP.md` - Vollständige Roadmap der Shared Packages
|
||||||
|
- `packages/shared-auth/src/index.ts` - Auth-API Dokumentation
|
||||||
|
- `packages/shared-tailwind/src/preset.js` - Theme-Konfiguration
|
||||||
247
SHARED_PACKAGES_ROADMAP.md
Normal file
247
SHARED_PACKAGES_ROADMAP.md
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
# Shared Packages Roadmap
|
||||||
|
|
||||||
|
This document outlines the plan to unify common code across all web apps in the monorepo.
|
||||||
|
|
||||||
|
## Current Shared Packages
|
||||||
|
|
||||||
|
- [x] `@manacore/shared-icons` - Unified Phosphor Icons for all web apps
|
||||||
|
- [x] `@manacore/shared-ui` - Unified UI Components (Text, Button, Badge, Toggle, Input, Modal)
|
||||||
|
- [x] `@manacore/shared-auth` - Unified Auth Logic (Supabase client, token management)
|
||||||
|
- [x] `@manacore/shared-auth-ui` - Unified Auth UI (LoginPage, RegisterPage, OAuth buttons)
|
||||||
|
- [x] `@manacore/shared-tailwind` - Unified Tailwind Config (4 themes, colors, preset)
|
||||||
|
- [x] `@manacore/shared-utils` - Unified Utilities (formatting, validation, async)
|
||||||
|
- [x] `@manacore/shared-types` - Unified TypeScript Types
|
||||||
|
- [x] `@manacore/shared-supabase` - Unified Supabase Client Factory
|
||||||
|
- [x] `@manacore/shared-i18n` - Unified i18n (languages, locale detection, translations)
|
||||||
|
- [x] `@manacore/shared-config` - Unified Config (env validation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planned Shared Packages
|
||||||
|
|
||||||
|
### 1. Shared UI Components (`@manacore/shared-ui`)
|
||||||
|
|
||||||
|
**Status**: Done
|
||||||
|
**Priority**: High
|
||||||
|
**Estimated LOC Savings**: 500-800 per app
|
||||||
|
|
||||||
|
**Components to unify**:
|
||||||
|
- `Button.svelte` - Primary, secondary, ghost, danger variants
|
||||||
|
- `Input.svelte` - Text input with label, error states
|
||||||
|
- `Text.svelte` - Typography component with variants
|
||||||
|
- `Modal.svelte` - Overlay modal with header, body, footer slots
|
||||||
|
- `Spinner.svelte` - Loading indicator
|
||||||
|
- `Toast.svelte` - Notification toasts
|
||||||
|
- `Badge.svelte` - Status badges
|
||||||
|
- `Card.svelte` - Content container
|
||||||
|
- `Dropdown.svelte` - Select/dropdown menus
|
||||||
|
|
||||||
|
**Apps using these**:
|
||||||
|
- ManaCore Web
|
||||||
|
- Memoro Web
|
||||||
|
- Maerchenzauber Web
|
||||||
|
- ManaDeck Web
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Shared Auth (`@manacore/shared-auth`)
|
||||||
|
|
||||||
|
**Status**: Done
|
||||||
|
**Priority**: High
|
||||||
|
**Estimated LOC Savings**: 800-1200 per app
|
||||||
|
|
||||||
|
**Modules to unify**:
|
||||||
|
- `tokenManager.ts` - JWT token storage, refresh, validation
|
||||||
|
- `authService.ts` - Login, logout, register, password reset
|
||||||
|
- `supabaseClient.ts` - Authenticated Supabase client factory
|
||||||
|
- `authStore.ts` - Svelte store for auth state
|
||||||
|
- `authGuard.ts` - Route protection utilities
|
||||||
|
|
||||||
|
**Considerations**:
|
||||||
|
- Each app may have different Supabase projects
|
||||||
|
- Token storage strategy (localStorage vs cookies)
|
||||||
|
- OAuth providers per app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Shared Tailwind Config (`@manacore/shared-tailwind`)
|
||||||
|
|
||||||
|
**Status**: Done
|
||||||
|
**Priority**: High
|
||||||
|
**Estimated Benefit**: Consistent branding, easier theme updates
|
||||||
|
|
||||||
|
**Config unified**:
|
||||||
|
- Color palette (primary, secondary, accent colors)
|
||||||
|
- Theme variants (Lume, Nature, Stone, Ocean) with light/dark modes
|
||||||
|
- Typography scale (font sizes, line heights)
|
||||||
|
- Border radius tokens
|
||||||
|
- Shadow tokens
|
||||||
|
- CSS variable-based theming system
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```
|
||||||
|
packages/shared-tailwind/
|
||||||
|
├── package.json
|
||||||
|
├── src/
|
||||||
|
│ ├── index.js # Main exports
|
||||||
|
│ ├── preset.js # Tailwind preset with all tokens
|
||||||
|
│ ├── colors.js # Color definitions (4 themes)
|
||||||
|
│ ├── theme-variables.css # CSS variables for themes
|
||||||
|
│ └── components.css # Component utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apps using this**:
|
||||||
|
- Memoro Web (full migration with theme.css + components.css)
|
||||||
|
- ManaCore Web (preset only, keeps local colors)
|
||||||
|
- ManaDeck Web (colors import, HSL-based system)
|
||||||
|
- Maerchenzauber Web (dependency added)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Shared Utilities (`@manacore/shared-utils`)
|
||||||
|
|
||||||
|
**Status**: Done
|
||||||
|
**Priority**: Medium
|
||||||
|
**Estimated LOC Savings**: 200-400 per app
|
||||||
|
|
||||||
|
**Utilities included**:
|
||||||
|
- `date.ts` - formatDate, formatRelativeTime, toISOString
|
||||||
|
- `format.ts` - formatDuration, formatFileSize, formatNumber, formatCurrency, formatPercent
|
||||||
|
- `validation.ts` - isValidEmail, isValidUrl, isValidPhone, validatePassword, isValidUuid
|
||||||
|
- `string.ts` - truncate, capitalize, generateId, slugify
|
||||||
|
- `async.ts` - sleep, retry, debounce
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Shared Types (`@manacore/shared-types`)
|
||||||
|
|
||||||
|
**Status**: Planned
|
||||||
|
**Priority**: Medium
|
||||||
|
**Estimated Benefit**: Type safety across packages
|
||||||
|
|
||||||
|
**Types to unify**:
|
||||||
|
- `User` - Common user type
|
||||||
|
- `ApiResponse<T>` - Standard API response wrapper
|
||||||
|
- `PaginatedResponse<T>` - Pagination types
|
||||||
|
- `Theme` - Theme configuration types
|
||||||
|
- `Locale` - i18n locale types
|
||||||
|
|
||||||
|
**Note**: App-specific database types (Supabase generated) should remain in each app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Shared i18n (`@manacore/shared-i18n`)
|
||||||
|
|
||||||
|
**Status**: Done
|
||||||
|
**Priority**: Medium
|
||||||
|
**Estimated LOC Savings**: 100-300 per app
|
||||||
|
|
||||||
|
**Modules to unify**:
|
||||||
|
- `i18n.ts` - svelte-i18n setup and initialization
|
||||||
|
- `detectLocale.ts` - Browser language detection
|
||||||
|
- Common translations:
|
||||||
|
- Error messages
|
||||||
|
- UI labels (Save, Cancel, Delete, etc.)
|
||||||
|
- Date/time formats
|
||||||
|
- Validation messages
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```
|
||||||
|
packages/shared-i18n/
|
||||||
|
├── package.json
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── setup.ts
|
||||||
|
│ ├── detectLocale.ts
|
||||||
|
│ └── translations/
|
||||||
|
│ ├── common/
|
||||||
|
│ │ ├── en.json
|
||||||
|
│ │ └── de.json
|
||||||
|
│ └── errors/
|
||||||
|
│ ├── en.json
|
||||||
|
│ └── de.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Shared Config (`@manacore/shared-config`)
|
||||||
|
|
||||||
|
**Status**: Planned
|
||||||
|
**Priority**: Low
|
||||||
|
**Estimated Benefit**: Consistent env handling
|
||||||
|
|
||||||
|
**Config to unify**:
|
||||||
|
- Environment variable validation (Zod schemas)
|
||||||
|
- API endpoint construction
|
||||||
|
- Feature flag utilities
|
||||||
|
- App metadata (version, name, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Phase 1** (Completed)
|
||||||
|
- [x] `@manacore/shared-icons`
|
||||||
|
- [x] `@manacore/shared-ui`
|
||||||
|
|
||||||
|
2. **Phase 2** (Completed)
|
||||||
|
- [x] `@manacore/shared-auth`
|
||||||
|
- [x] `@manacore/shared-auth-ui`
|
||||||
|
- [x] `@manacore/shared-tailwind`
|
||||||
|
|
||||||
|
3. **Phase 3** (Completed)
|
||||||
|
- [x] `@manacore/shared-utils`
|
||||||
|
- [x] `@manacore/shared-types`
|
||||||
|
- [x] `@manacore/shared-supabase`
|
||||||
|
|
||||||
|
4. **Phase 4** (Completed)
|
||||||
|
- [x] `@manacore/shared-i18n`
|
||||||
|
- [x] `@manacore/shared-config`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Guidelines for Shared Packages
|
||||||
|
|
||||||
|
### Package Structure
|
||||||
|
```
|
||||||
|
packages/shared-{name}/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Public exports
|
||||||
|
│ └── ...
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package.json Template
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@manacore/shared-{name}",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.ts",
|
||||||
|
"types": "./src/index.ts",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
1. **Keep it minimal** - Only share truly common code
|
||||||
|
2. **Document props** - Use TypeScript interfaces with JSDoc
|
||||||
|
3. **Version carefully** - Coordinate updates across apps
|
||||||
|
4. **Test thoroughly** - Changes affect all apps
|
||||||
|
5. **Avoid breaking changes** - Use deprecation warnings
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Created: 2025-11-24
|
||||||
|
- Last Updated: 2025-11-24
|
||||||
|
- Author: Claude Code
|
||||||
6
maerchenzauber/apps/web/.env.example
Normal file
6
maerchenzauber/apps/web/.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Storyteller Backend API URL
|
||||||
|
PUBLIC_API_URL=http://localhost:3002
|
||||||
|
|
||||||
|
# Supabase Configuration
|
||||||
|
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
|
@ -15,9 +15,27 @@
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/kit": "^2.47.1",
|
"@sveltejs/kit": "^2.47.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
"svelte": "^5.41.0",
|
"svelte": "^5.41.0",
|
||||||
"svelte-check": "^4.3.3",
|
"svelte-check": "^4.3.3",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.1.10"
|
"vite": "^7.1.10"
|
||||||
|
},
|
||||||
|
"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:*",
|
||||||
|
"@supabase/supabase-js": "^2.81.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
maerchenzauber/apps/web/postcss.config.js
Normal file
6
maerchenzauber/apps/web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
maerchenzauber/apps/web/src/app.css
Normal file
1
maerchenzauber/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@import "tailwindcss";
|
||||||
170
maerchenzauber/apps/web/src/lib/auth.ts
Normal file
170
maerchenzauber/apps/web/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
/**
|
||||||
|
* Storyteller Web Auth Configuration
|
||||||
|
*
|
||||||
|
* This file initializes the shared auth package for the Storyteller web app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PUBLIC_API_URL } from '$env/static/public';
|
||||||
|
import {
|
||||||
|
createAuthService,
|
||||||
|
createTokenManager,
|
||||||
|
setStorageAdapter,
|
||||||
|
setDeviceAdapter,
|
||||||
|
setNetworkAdapter,
|
||||||
|
setupFetchInterceptor,
|
||||||
|
type StorageAdapter,
|
||||||
|
type DeviceManagerAdapter,
|
||||||
|
type NetworkAdapter,
|
||||||
|
type DeviceInfo,
|
||||||
|
} from '@manacore/shared-auth';
|
||||||
|
|
||||||
|
// Storage keys
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
APP_TOKEN: 'storyteller_appToken',
|
||||||
|
REFRESH_TOKEN: 'storyteller_refreshToken',
|
||||||
|
USER_EMAIL: 'storyteller_userEmail',
|
||||||
|
DEVICE_ID: 'storyteller_device_id',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session storage adapter for Storyteller web
|
||||||
|
*/
|
||||||
|
const sessionStorageAdapter: StorageAdapter = {
|
||||||
|
async getItem<T = string>(key: string): Promise<T | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const value = sessionStorage.getItem(key);
|
||||||
|
if (value === null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
} catch {
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setItem(key: string, value: string): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device manager adapter for web
|
||||||
|
*/
|
||||||
|
const webDeviceAdapter: DeviceManagerAdapter = {
|
||||||
|
async getDeviceInfo(): Promise<DeviceInfo> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {
|
||||||
|
deviceId: '',
|
||||||
|
deviceName: 'Server',
|
||||||
|
deviceType: 'web',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = await webDeviceAdapter.getStoredDeviceId() || generateDeviceId();
|
||||||
|
localStorage.setItem(STORAGE_KEYS.DEVICE_ID, deviceId);
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
let deviceName = 'Web Browser';
|
||||||
|
|
||||||
|
if (userAgent.includes('Mac')) deviceName = 'Mac';
|
||||||
|
else if (userAgent.includes('Windows')) deviceName = 'Windows';
|
||||||
|
else if (userAgent.includes('Linux')) deviceName = 'Linux';
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId,
|
||||||
|
deviceName,
|
||||||
|
deviceType: 'web',
|
||||||
|
platform: 'web',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStoredDeviceId(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem(STORAGE_KEYS.DEVICE_ID);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network adapter for web
|
||||||
|
*/
|
||||||
|
const webNetworkAdapter: NetworkAdapter = {
|
||||||
|
async isDeviceConnected(): Promise<boolean> {
|
||||||
|
if (typeof navigator === 'undefined') return true;
|
||||||
|
return navigator.onLine;
|
||||||
|
},
|
||||||
|
|
||||||
|
async hasStableConnection(): Promise<boolean> {
|
||||||
|
if (typeof navigator === 'undefined') return true;
|
||||||
|
return navigator.onLine;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique device ID
|
||||||
|
*/
|
||||||
|
function generateDeviceId(): string {
|
||||||
|
return `web_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize adapters
|
||||||
|
setStorageAdapter(sessionStorageAdapter);
|
||||||
|
setDeviceAdapter(webDeviceAdapter);
|
||||||
|
setNetworkAdapter(webNetworkAdapter);
|
||||||
|
|
||||||
|
// Create auth service instance
|
||||||
|
export const authService = createAuthService({
|
||||||
|
baseUrl: PUBLIC_API_URL || 'http://localhost:3002',
|
||||||
|
storageKeys: {
|
||||||
|
APP_TOKEN: STORAGE_KEYS.APP_TOKEN,
|
||||||
|
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
|
||||||
|
USER_EMAIL: STORAGE_KEYS.USER_EMAIL,
|
||||||
|
},
|
||||||
|
endpoints: {
|
||||||
|
signIn: '/auth/signin',
|
||||||
|
signUp: '/auth/signup',
|
||||||
|
signOut: '/auth/logout',
|
||||||
|
refresh: '/auth/refresh',
|
||||||
|
validate: '/auth/validate',
|
||||||
|
forgotPassword: '/auth/forgot-password',
|
||||||
|
googleSignIn: '/auth/google-signin',
|
||||||
|
appleSignIn: '/auth/apple-signin',
|
||||||
|
credits: '/auth/credits',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create token manager instance
|
||||||
|
export const tokenManager = createTokenManager(authService);
|
||||||
|
|
||||||
|
// Setup fetch interceptor (only in browser)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setupFetchInterceptor(authService, tokenManager, {
|
||||||
|
backendUrl: PUBLIC_API_URL || 'http://localhost:3002',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export useful utilities from shared-auth
|
||||||
|
export {
|
||||||
|
decodeToken,
|
||||||
|
isTokenValidLocally,
|
||||||
|
isTokenExpired,
|
||||||
|
getUserFromToken,
|
||||||
|
isB2BUser,
|
||||||
|
getB2BInfo,
|
||||||
|
TokenState,
|
||||||
|
} from '@manacore/shared-auth';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type {
|
||||||
|
UserData,
|
||||||
|
DecodedToken,
|
||||||
|
AuthResult,
|
||||||
|
CreditBalance,
|
||||||
|
B2BInfo,
|
||||||
|
} from '@manacore/shared-auth';
|
||||||
34
maerchenzauber/apps/web/src/lib/components/Icon.svelte
Normal file
34
maerchenzauber/apps/web/src/lib/components/Icon.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Icon Component - Uses @manacore/shared-icons
|
||||||
|
* Phosphor Icons (Bold weight)
|
||||||
|
*/
|
||||||
|
import { iconPaths } from '@manacore/shared-icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: keyof typeof iconPaths;
|
||||||
|
size?: number;
|
||||||
|
class?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, size = 24, class: className = '', color }: Props = $props();
|
||||||
|
|
||||||
|
const path = $derived(iconPaths[name]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if path}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
fill={color || 'currentColor'}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
class={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{@html path}
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span class="text-red-500" title="Icon '{name}' not found">⚠</span>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { size = 55, color = '#6366f1' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={color}
|
||||||
|
>
|
||||||
|
<!-- Book/Story icon -->
|
||||||
|
<path
|
||||||
|
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
|
||||||
|
stroke={color}
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
@ -1 +1,9 @@
|
||||||
// place files you want to import through the `$lib` alias in this folder.
|
// Auth
|
||||||
|
export { authService, tokenManager } from './auth';
|
||||||
|
export { authStore } from './stores/authStore.svelte';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type { StorytellerUser, CreditBalance, AuthState } from './types/auth';
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export { getAuthenticatedSupabase, getSupabaseClient } from './utils/supabase';
|
||||||
|
|
|
||||||
142
maerchenzauber/apps/web/src/lib/stores/authStore.svelte.ts
Normal file
142
maerchenzauber/apps/web/src/lib/stores/authStore.svelte.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import type { StorytellerUser } from '$lib/types/auth';
|
||||||
|
import { authService, type UserData } from '$lib/auth';
|
||||||
|
|
||||||
|
// Svelte 5 runes-based auth store
|
||||||
|
let user = $state<StorytellerUser | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert UserData from shared-auth to StorytellerUser
|
||||||
|
*/
|
||||||
|
function toStorytellerUser(userData: UserData | null): StorytellerUser | null {
|
||||||
|
if (!userData) return null;
|
||||||
|
return {
|
||||||
|
id: userData.id,
|
||||||
|
email: userData.email,
|
||||||
|
role: userData.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStore = {
|
||||||
|
get user() {
|
||||||
|
return user;
|
||||||
|
},
|
||||||
|
get loading() {
|
||||||
|
return loading;
|
||||||
|
},
|
||||||
|
get isAuthenticated() {
|
||||||
|
return !!user;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize auth state from stored tokens
|
||||||
|
*/
|
||||||
|
async initialize() {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const isAuth = await authService.isAuthenticated();
|
||||||
|
if (isAuth) {
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = toStorytellerUser(userData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize auth:', error);
|
||||||
|
user = null;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with email and password
|
||||||
|
*/
|
||||||
|
async signIn(email: string, password: string) {
|
||||||
|
const result = await authService.signIn(email, password);
|
||||||
|
if (result.success) {
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = toStorytellerUser(userData);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign up with email and password
|
||||||
|
*/
|
||||||
|
async signUp(email: string, password: string) {
|
||||||
|
const result = await authService.signUp(email, password);
|
||||||
|
if (result.success && !result.needsVerification) {
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = toStorytellerUser(userData);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with Google
|
||||||
|
*/
|
||||||
|
async signInWithGoogle(idToken: string) {
|
||||||
|
const result = await authService.signInWithGoogle(idToken);
|
||||||
|
if (result.success) {
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = toStorytellerUser(userData);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with Apple
|
||||||
|
*/
|
||||||
|
async signInWithApple(identityToken: string) {
|
||||||
|
const result = await authService.signInWithApple(identityToken);
|
||||||
|
if (result.success) {
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = toStorytellerUser(userData);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set user
|
||||||
|
*/
|
||||||
|
setUser(newUser: StorytellerUser | null) {
|
||||||
|
user = newUser;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out
|
||||||
|
*/
|
||||||
|
async signOut() {
|
||||||
|
try {
|
||||||
|
await authService.signOut();
|
||||||
|
user = null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign out failed:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send password reset email
|
||||||
|
*/
|
||||||
|
async forgotPassword(email: string) {
|
||||||
|
return authService.forgotPassword(email);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user credits
|
||||||
|
*/
|
||||||
|
async getCredits() {
|
||||||
|
return authService.getUserCredits();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check authentication status
|
||||||
|
*/
|
||||||
|
async checkAuth() {
|
||||||
|
const isAuth = await authService.isAuthenticated();
|
||||||
|
if (!isAuth) {
|
||||||
|
user = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
28
maerchenzauber/apps/web/src/lib/types/auth.ts
Normal file
28
maerchenzauber/apps/web/src/lib/types/auth.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* User type for Storyteller web app
|
||||||
|
*/
|
||||||
|
export interface StorytellerUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credit balance
|
||||||
|
*/
|
||||||
|
export interface CreditBalance {
|
||||||
|
credits: number;
|
||||||
|
maxCreditLimit: number;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth state
|
||||||
|
*/
|
||||||
|
export interface AuthState {
|
||||||
|
user: StorytellerUser | null;
|
||||||
|
loading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
22
maerchenzauber/apps/web/src/lib/utils/supabase.ts
Normal file
22
maerchenzauber/apps/web/src/lib/utils/supabase.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Supabase client with authentication token
|
||||||
|
*/
|
||||||
|
export async function getAuthenticatedSupabase(appToken: string): Promise<SupabaseClient> {
|
||||||
|
return createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||||
|
global: {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${appToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an anonymous Supabase client
|
||||||
|
*/
|
||||||
|
export function getSupabaseClient(): SupabaseClient {
|
||||||
|
return createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
|
||||||
|
}
|
||||||
34
maerchenzauber/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
34
maerchenzauber/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||||
|
import StorytellerLogo from '$lib/components/StorytellerLogo.svelte';
|
||||||
|
import { authStore } from '$lib/stores/authStore.svelte';
|
||||||
|
|
||||||
|
async function handleSignIn(email: string, password: string) {
|
||||||
|
return authStore.signIn(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignInWithGoogle(idToken: string) {
|
||||||
|
return authStore.signInWithGoogle(idToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForgotPassword(email: string) {
|
||||||
|
return authStore.forgotPassword(email);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LoginPage
|
||||||
|
appName="Storyteller"
|
||||||
|
logo={StorytellerLogo}
|
||||||
|
primaryColor="#6366f1"
|
||||||
|
onSignIn={handleSignIn}
|
||||||
|
onSignInWithGoogle={handleSignInWithGoogle}
|
||||||
|
onForgotPassword={handleForgotPassword}
|
||||||
|
goto={goto}
|
||||||
|
enableGoogle={true}
|
||||||
|
enableApple={true}
|
||||||
|
successRedirect="/dashboard"
|
||||||
|
registerPath="/register"
|
||||||
|
lightBackground="#f5f5f5"
|
||||||
|
darkBackground="#121212"
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||||
|
import StorytellerLogo from '$lib/components/StorytellerLogo.svelte';
|
||||||
|
import { authStore } from '$lib/stores/authStore.svelte';
|
||||||
|
|
||||||
|
async function handleSignUp(email: string, password: string) {
|
||||||
|
return authStore.signUp(email, password);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<RegisterPage
|
||||||
|
appName="Storyteller"
|
||||||
|
logo={StorytellerLogo}
|
||||||
|
primaryColor="#6366f1"
|
||||||
|
onSignUp={handleSignUp}
|
||||||
|
goto={goto}
|
||||||
|
successRedirect="/dashboard"
|
||||||
|
loginPath="/login"
|
||||||
|
lightBackground="#f5f5f5"
|
||||||
|
darkBackground="#121212"
|
||||||
|
/>
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,18 @@
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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:*",
|
||||||
"@supabase/ssr": "^0.5.2",
|
"@supabase/ssr": "^0.5.2",
|
||||||
"@supabase/supabase-js": "^2.81.1"
|
"@supabase/supabase-js": "^2.81.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
264
manacore/apps/web/src/lib/components/AppSlider.svelte
Normal file
264
manacore/apps/web/src/lib/components/AppSlider.svelte
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface App {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
longDescription: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
comingSoon?: boolean;
|
||||||
|
status: 'published' | 'beta' | 'development' | 'planning';
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedApp = $state<number | null>(null);
|
||||||
|
let hoveredApp = $state<number | null>(null);
|
||||||
|
let cardRotations = $state<{ [key: number]: { rotateX: number; rotateY: number } }>({});
|
||||||
|
let modalScrollContainer = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const apps: App[] = [
|
||||||
|
{
|
||||||
|
name: 'Memoro',
|
||||||
|
description: 'AI Voice Memos',
|
||||||
|
longDescription: 'Transform your voice recordings into organized, searchable notes with AI-powered transcription and insights.',
|
||||||
|
icon: '/images/app-icons/memoro-logo-gradient.png',
|
||||||
|
color: '#f8d62b',
|
||||||
|
comingSoon: false,
|
||||||
|
status: 'published'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Märchenzauber',
|
||||||
|
description: 'AI Story Creator',
|
||||||
|
longDescription: 'Create magical personalized stories for children with AI-generated illustrations and consistent characters.',
|
||||||
|
icon: '/images/app-icons/maerchenzauber-logo-gradient.png',
|
||||||
|
color: '#FF6B9D',
|
||||||
|
comingSoon: true,
|
||||||
|
status: 'beta'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Moodlit',
|
||||||
|
description: 'AI Mood Tracker',
|
||||||
|
longDescription: 'Track your emotional well-being with AI-powered insights and personalized recommendations.',
|
||||||
|
icon: '/images/app-icons/moodlit-logo-gradient.png',
|
||||||
|
color: '#9C27B0',
|
||||||
|
comingSoon: true,
|
||||||
|
status: 'beta'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Manacore',
|
||||||
|
description: 'Central Hub',
|
||||||
|
longDescription: 'Your central hub for managing all Mana applications, subscriptions, and account settings.',
|
||||||
|
icon: '/images/app-icons/manacore-logo-gradient.png',
|
||||||
|
color: '#6366f1',
|
||||||
|
comingSoon: false,
|
||||||
|
status: 'development'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function getStatusColor(status: App['status']) {
|
||||||
|
const colors = {
|
||||||
|
published: '#4CAF50',
|
||||||
|
beta: '#FFD700',
|
||||||
|
development: '#FF9800',
|
||||||
|
planning: '#F44336'
|
||||||
|
};
|
||||||
|
return colors[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: App['status']) {
|
||||||
|
const labels = {
|
||||||
|
published: 'Live',
|
||||||
|
beta: 'Beta',
|
||||||
|
development: 'In Development',
|
||||||
|
planning: 'Planned'
|
||||||
|
};
|
||||||
|
return labels[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(index: number) {
|
||||||
|
selectedApp = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
selectedApp = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardMouseMove(e: MouseEvent, index: number, cardElement: HTMLElement) {
|
||||||
|
const rect = cardElement.getBoundingClientRect();
|
||||||
|
const cardCenterX = rect.left + rect.width / 2;
|
||||||
|
const cardCenterY = rect.top + rect.height / 2;
|
||||||
|
|
||||||
|
const mouseXRelative = e.clientX - cardCenterX;
|
||||||
|
const mouseYRelative = e.clientY - cardCenterY;
|
||||||
|
|
||||||
|
const maxRotation = 3;
|
||||||
|
const rotateY = (mouseXRelative / (rect.width / 2)) * maxRotation;
|
||||||
|
const rotateX = -(mouseYRelative / (rect.height / 2)) * maxRotation;
|
||||||
|
|
||||||
|
cardRotations[index] = { rotateX, rotateY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardMouseLeave(index: number) {
|
||||||
|
cardRotations[index] = { rotateX: 0, rotateY: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (selectedApp !== null && modalScrollContainer) {
|
||||||
|
const appIndex = selectedApp;
|
||||||
|
setTimeout(() => {
|
||||||
|
const cardWidth = 360 + 24;
|
||||||
|
const scrollPosition = appIndex * cardWidth;
|
||||||
|
modalScrollContainer?.scrollTo({
|
||||||
|
left: scrollPosition,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<h3 class="mb-4 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Part of the Mana Ecosystem
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<div class="flex gap-4 justify-center overflow-x-auto pb-6 scrollbar-hide snap-x snap-mandatory scroll-smooth px-4 py-4" style="perspective: 1000px;">
|
||||||
|
{#each apps as app, index}
|
||||||
|
<button
|
||||||
|
class="group relative flex-shrink-0 rounded-2xl p-5 cursor-pointer snap-center border transition-all"
|
||||||
|
class:bg-gray-100={hoveredApp !== index}
|
||||||
|
class:dark:bg-gray-800={hoveredApp !== index}
|
||||||
|
class:bg-gray-200={hoveredApp === index}
|
||||||
|
class:dark:bg-gray-700={hoveredApp === index}
|
||||||
|
style="width: 160px; border-color: rgba(0, 0, 0, 0.1); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); transform: perspective(1000px) rotateX({cardRotations[index]?.rotateX || 0}deg) rotateY({cardRotations[index]?.rotateY || 0}deg); transform-style: preserve-3d; transition: transform 0.1s ease-out, background-color 0.2s ease-out;"
|
||||||
|
onmouseenter={() => hoveredApp = index}
|
||||||
|
onmousemove={(e) => handleCardMouseMove(e, index, e.currentTarget)}
|
||||||
|
onmouseleave={() => { handleCardMouseLeave(index); hoveredApp = null; }}
|
||||||
|
onclick={() => openModal(index)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute top-3 right-3 w-3 h-3 rounded-full status-indicator"
|
||||||
|
style="background-color: {getStatusColor(app.status)}; box-shadow: 0 0 8px {getStatusColor(app.status)};"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="mb-2 flex h-20 w-20 mx-auto items-center justify-center rounded-xl transition-transform group-hover:scale-110">
|
||||||
|
<img src={app.icon} alt={app.name} class="w-16 h-16 object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="text-base font-semibold text-center text-gray-900 dark:text-white">
|
||||||
|
{app.name}
|
||||||
|
</h4>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedApp !== null}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style="background-color: rgba(0, 0, 0, 0.85);"
|
||||||
|
onclick={closeModal}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && closeModal()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={closeModal}
|
||||||
|
class="absolute top-6 right-6 rounded-full p-2 transition-all hover:bg-white/10 z-10"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<svg class="h-8 w-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={modalScrollContainer}
|
||||||
|
class="absolute inset-0 flex items-center overflow-x-auto scrollbar-hide snap-x snap-mandatory scroll-smooth"
|
||||||
|
>
|
||||||
|
<div class="flex gap-6 px-8 py-8 mx-auto" style="perspective: 1000px;">
|
||||||
|
{#each apps as app, index}
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 rounded-3xl p-8 snap-center shadow-2xl relative"
|
||||||
|
class:bg-gray-100={hoveredApp !== index}
|
||||||
|
class:dark:bg-gray-800={hoveredApp !== index}
|
||||||
|
class:bg-gray-200={hoveredApp === index}
|
||||||
|
class:dark:bg-gray-700={hoveredApp === index}
|
||||||
|
style="min-width: 360px; max-width: 360px; border: 3px solid {app.color}40; transform: perspective(1000px) rotateX({cardRotations[index]?.rotateX || 0}deg) rotateY({cardRotations[index]?.rotateY || 0}deg); transform-style: preserve-3d; transition: transform 0.1s ease-out, background-color 0.2s ease-out;"
|
||||||
|
onclick={(e) => { e.stopPropagation(); selectedApp = index; }}
|
||||||
|
onmouseenter={() => hoveredApp = index}
|
||||||
|
onmousemove={(e) => handleCardMouseMove(e, index, e.currentTarget)}
|
||||||
|
onmouseleave={() => { handleCardMouseLeave(index); hoveredApp = null; }}
|
||||||
|
onkeydown={() => {}}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="absolute top-4 right-4 flex items-center gap-2">
|
||||||
|
<span class="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||||
|
{getStatusLabel(app.status)}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 rounded-full status-indicator"
|
||||||
|
style="background-color: {getStatusColor(app.status)}; box-shadow: 0 0 12px {getStatusColor(app.status)};"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src={app.icon} alt={app.name} class="w-28 h-28 object-contain mb-3 mx-auto" />
|
||||||
|
|
||||||
|
<h3 class="text-2xl font-bold mb-2 text-center text-gray-900 dark:text-white">
|
||||||
|
{app.name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-sm mb-4 text-center font-medium" style="color: {app.color};">
|
||||||
|
{app.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-sm leading-relaxed mb-6 text-center text-gray-600 dark:text-gray-300">
|
||||||
|
{app.longDescription}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
{#if app.comingSoon}
|
||||||
|
<div class="inline-block rounded-full px-5 py-2.5 text-sm font-medium bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-400">
|
||||||
|
Coming Soon
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="rounded-xl px-8 py-3 text-sm font-semibold transition-all hover:opacity-80 border-2 text-white"
|
||||||
|
style="background-color: {app.color}60; border-color: {app.color};"
|
||||||
|
>
|
||||||
|
Open App
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
34
manacore/apps/web/src/lib/components/Icon.svelte
Normal file
34
manacore/apps/web/src/lib/components/Icon.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Icon Component - Re-exports from @manacore/shared-icons
|
||||||
|
* This wrapper ensures backward compatibility with existing imports
|
||||||
|
*/
|
||||||
|
import { iconPaths } from '@manacore/shared-icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: keyof typeof iconPaths;
|
||||||
|
size?: number;
|
||||||
|
class?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, size = 24, class: className = '', color }: Props = $props();
|
||||||
|
|
||||||
|
const path = $derived(iconPaths[name]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if path}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
fill={color || 'currentColor'}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
class={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{@html path}
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span class="text-red-500" title="Icon '{name}' not found">⚠</span>
|
||||||
|
{/if}
|
||||||
29
manacore/apps/web/src/lib/components/ManaCoreLogo.svelte
Normal file
29
manacore/apps/web/src/lib/components/ManaCoreLogo.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { size = 55, color = '#6366f1' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<!-- M letter for ManaCore -->
|
||||||
|
<text
|
||||||
|
x="50%"
|
||||||
|
y="55%"
|
||||||
|
dominant-baseline="middle"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="16"
|
||||||
|
font-weight="bold"
|
||||||
|
fill={color}
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
40
manacore/apps/web/src/lib/components/ThemeToggle.svelte
Normal file
40
manacore/apps/web/src/lib/components/ThemeToggle.svelte
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { theme } from '$lib/stores/theme';
|
||||||
|
|
||||||
|
let currentTheme = $derived($theme);
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
theme.toggleMode();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={toggleTheme}
|
||||||
|
class="rounded-lg p-2 transition-colors hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
title={currentTheme.effectiveMode === 'light'
|
||||||
|
? 'Switch to dark mode'
|
||||||
|
: 'Switch to light mode'}
|
||||||
|
>
|
||||||
|
{#if currentTheme.effectiveMode === 'light'}
|
||||||
|
<!-- Moon Icon (Dark Mode) -->
|
||||||
|
<svg class="h-5 w-5 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<!-- Sun Icon (Light Mode) -->
|
||||||
|
<svg class="h-5 w-5 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
class?: string;
|
class?: string;
|
||||||
autocomplete?: 'email' | 'current-password' | 'new-password' | 'username' | 'off' | string;
|
autocomplete?: 'email' | 'current-password' | 'new-password' | 'username' | 'off' | string;
|
||||||
oninput?: (event: Event) => void;
|
oninput?: (event: Event) => void;
|
||||||
|
minlength?: number;
|
||||||
|
maxlength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -22,7 +24,9 @@
|
||||||
disabled = false,
|
disabled = false,
|
||||||
class: className = '',
|
class: className = '',
|
||||||
autocomplete,
|
autocomplete,
|
||||||
oninput
|
oninput,
|
||||||
|
minlength,
|
||||||
|
maxlength
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -33,6 +37,8 @@
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{required}
|
{required}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
{minlength}
|
||||||
|
{maxlength}
|
||||||
autocomplete={autocomplete as any}
|
autocomplete={autocomplete as any}
|
||||||
bind:value
|
bind:value
|
||||||
oninput={oninput}
|
oninput={oninput}
|
||||||
|
|
|
||||||
82
manacore/apps/web/src/lib/stores/authStore.svelte.ts
Normal file
82
manacore/apps/web/src/lib/stores/authStore.svelte.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { createBrowserClient } from '@supabase/ssr';
|
||||||
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||||
|
|
||||||
|
// Create browser Supabase client
|
||||||
|
function getSupabaseClient() {
|
||||||
|
return createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStore = {
|
||||||
|
/**
|
||||||
|
* Sign in with email and password
|
||||||
|
*/
|
||||||
|
async signIn(email: string, password: string) {
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign up with email and password
|
||||||
|
*/
|
||||||
|
async signUp(email: string, password: string) {
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
const { data, error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email confirmation is required
|
||||||
|
const needsVerification = !data.session;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
needsVerification
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send password reset email
|
||||||
|
*/
|
||||||
|
async forgotPassword(email: string) {
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
|
redirectTo: `${window.location.origin}/reset-password`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign out
|
||||||
|
*/
|
||||||
|
async signOut() {
|
||||||
|
const supabase = getSupabaseClient();
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
}
|
||||||
|
};
|
||||||
79
manacore/apps/web/src/lib/stores/theme.ts
Normal file
79
manacore/apps/web/src/lib/stores/theme.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
mode: ThemeMode;
|
||||||
|
effectiveMode: 'light' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThemeStore() {
|
||||||
|
const getInitialMode = (): ThemeMode => {
|
||||||
|
if (browser) {
|
||||||
|
const stored = localStorage.getItem('theme-mode');
|
||||||
|
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'system';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSystemPreference = (): 'light' | 'dark' => {
|
||||||
|
if (browser && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
const mode = writable<ThemeMode>(getInitialMode());
|
||||||
|
|
||||||
|
const effectiveMode = derived(mode, ($mode) => {
|
||||||
|
if ($mode === 'system') {
|
||||||
|
return getSystemPreference();
|
||||||
|
}
|
||||||
|
return $mode;
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = derived([mode, effectiveMode], ([$mode, $effectiveMode]) => ({
|
||||||
|
mode: $mode,
|
||||||
|
effectiveMode: $effectiveMode
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Apply theme to document
|
||||||
|
if (browser) {
|
||||||
|
effectiveMode.subscribe((effective) => {
|
||||||
|
if (effective === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for system preference changes
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
mode.update((m) => m); // Trigger re-evaluation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe: state.subscribe,
|
||||||
|
setMode: (newMode: ThemeMode) => {
|
||||||
|
mode.set(newMode);
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('theme-mode', newMode);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleMode: () => {
|
||||||
|
mode.update((current) => {
|
||||||
|
const newMode = current === 'light' ? 'dark' : current === 'dark' ? 'system' : 'light';
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('theme-mode', newMode);
|
||||||
|
}
|
||||||
|
return newMode;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = createThemeStore();
|
||||||
|
|
@ -11,8 +11,4 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 dark:bg-gray-900 sm:px-6 lg:px-8">
|
{@render children()}
|
||||||
<div class="w-full max-w-md space-y-8">
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
import { redirect, fail } from '@sveltejs/kit';
|
|
||||||
import type { Actions } from './$types';
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
default: async ({ request, locals: { supabase } }) => {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const email = formData.get('email') as string;
|
|
||||||
const password = formData.get('password') as string;
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
return fail(400, {
|
|
||||||
error: 'Email and password are required',
|
|
||||||
email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
return fail(400, {
|
|
||||||
error: error.message,
|
|
||||||
email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw redirect(303, '/dashboard');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,86 +1,29 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { goto } from '$app/navigation';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||||
import Input from '$lib/components/ui/Input.svelte';
|
import ManaCoreLogo from '$lib/components/ManaCoreLogo.svelte';
|
||||||
import Card from '$lib/components/ui/Card.svelte';
|
import { authStore } from '$lib/stores/authStore.svelte';
|
||||||
|
|
||||||
let { form } = $props();
|
async function handleSignIn(email: string, password: string) {
|
||||||
let loading = $state(false);
|
return authStore.signIn(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleForgotPassword(email: string) {
|
||||||
|
return authStore.forgotPassword(email);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<LoginPage
|
||||||
<div class="text-center">
|
appName="ManaCore"
|
||||||
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">ManaCore</h2>
|
logo={ManaCoreLogo}
|
||||||
<p class="text-gray-600 dark:text-gray-400">Sign in to your account</p>
|
primaryColor="#6366f1"
|
||||||
</div>
|
onSignIn={handleSignIn}
|
||||||
|
onForgotPassword={handleForgotPassword}
|
||||||
<Card class="mt-8">
|
goto={goto}
|
||||||
<form
|
enableGoogle={false}
|
||||||
method="POST"
|
enableApple={false}
|
||||||
use:enhance={() => {
|
successRedirect="/dashboard"
|
||||||
loading = true;
|
registerPath="/register"
|
||||||
return async ({ update }) => {
|
lightBackground="#f3f4f6"
|
||||||
await update();
|
darkBackground="#121212"
|
||||||
loading = false;
|
/>
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if form?.error}
|
|
||||||
<div class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400">
|
|
||||||
{form.error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Email address
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
id="email"
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
required
|
|
||||||
value={form?.email ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="mb-2 flex items-center justify-between">
|
|
||||||
<label for="password" class="block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<a href="/forgot-password" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
id="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Button type="submit" {loading} class="w-full">
|
|
||||||
{loading ? 'Signing in...' : 'Sign in'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Don't have an account?
|
|
||||||
<a href="/register" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">
|
|
||||||
Sign up
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { fail } from '@sveltejs/kit';
|
|
||||||
import type { Actions } from './$types';
|
|
||||||
|
|
||||||
export const actions: Actions = {
|
|
||||||
default: async ({ request, locals: { supabase } }) => {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const email = formData.get('email') as string;
|
|
||||||
const password = formData.get('password') as string;
|
|
||||||
const confirmPassword = formData.get('confirmPassword') as string;
|
|
||||||
|
|
||||||
if (!email || !password || !confirmPassword) {
|
|
||||||
return fail(400, {
|
|
||||||
error: 'All fields are required',
|
|
||||||
email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
return fail(400, {
|
|
||||||
error: 'Passwords do not match',
|
|
||||||
email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 8) {
|
|
||||||
return fail(400, {
|
|
||||||
error: 'Password must be at least 8 characters',
|
|
||||||
email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.signUp({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
options: {
|
|
||||||
emailRedirectTo: `${new URL('/auth/callback', request.url).toString()}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Registration error:', error);
|
|
||||||
return fail(400, {
|
|
||||||
error: error.message,
|
|
||||||
email
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,101 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { goto } from '$app/navigation';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||||
import Input from '$lib/components/ui/Input.svelte';
|
import ManaCoreLogo from '$lib/components/ManaCoreLogo.svelte';
|
||||||
import Card from '$lib/components/ui/Card.svelte';
|
import { authStore } from '$lib/stores/authStore.svelte';
|
||||||
|
|
||||||
let { form } = $props();
|
async function handleSignUp(email: string, password: string) {
|
||||||
let loading = $state(false);
|
return authStore.signUp(email, password);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<RegisterPage
|
||||||
<div class="text-center">
|
appName="ManaCore"
|
||||||
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Create Account</h2>
|
logo={ManaCoreLogo}
|
||||||
<p class="text-gray-600 dark:text-gray-400">Sign up for ManaCore</p>
|
primaryColor="#6366f1"
|
||||||
</div>
|
onSignUp={handleSignUp}
|
||||||
|
goto={goto}
|
||||||
<Card class="mt-8">
|
successRedirect="/dashboard"
|
||||||
<form
|
loginPath="/login"
|
||||||
method="POST"
|
lightBackground="#f3f4f6"
|
||||||
use:enhance={() => {
|
darkBackground="#121212"
|
||||||
loading = true;
|
/>
|
||||||
return async ({ update }) => {
|
|
||||||
await update();
|
|
||||||
loading = false;
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if form?.error}
|
|
||||||
<div class="mb-4 rounded-lg bg-red-50 p-4 text-sm text-red-800 dark:bg-red-900/20 dark:text-red-400">
|
|
||||||
{form.error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if form?.success}
|
|
||||||
<div class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-400">
|
|
||||||
Account created! Please check your email to verify your account.
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="email" class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Email address
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
id="email"
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
required
|
|
||||||
value={form?.email ?? ''}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
id="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="confirmPassword" class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Confirm Password
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
name="confirmPassword"
|
|
||||||
id="confirmPassword"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Button type="submit" {loading} class="w-full">
|
|
||||||
{loading ? 'Creating account...' : 'Sign up'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center">
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Already have an account?
|
|
||||||
<a href="/login" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">
|
|
||||||
Sign in
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -136,12 +136,6 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if form?.success}
|
|
||||||
<div class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-400">
|
|
||||||
Password updated successfully! Redirecting to dashboard...
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
|
@ -154,7 +148,7 @@
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minlength="6"
|
minlength={6}
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Must be at least 6 characters
|
Must be at least 6 characters
|
||||||
|
|
@ -172,7 +166,7 @@
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minlength="6"
|
minlength={6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,12 +141,6 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if form?.success}
|
|
||||||
<div class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-800 dark:bg-green-900/20 dark:text-green-400">
|
|
||||||
Password updated successfully! Redirecting to dashboard...
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
<label for="password" class="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
|
@ -159,7 +153,7 @@
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minlength="6"
|
minlength={6}
|
||||||
/>
|
/>
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
Must be at least 6 characters
|
Must be at least 6 characters
|
||||||
|
|
@ -177,7 +171,7 @@
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minlength="6"
|
minlength={6}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 101 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 431 KiB |
|
|
@ -1,9 +1,17 @@
|
||||||
|
import preset from '@manacore/shared-tailwind/preset';
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
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}'
|
||||||
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// ManaCore specific primary blue
|
||||||
primary: {
|
primary: {
|
||||||
50: '#eff6ff',
|
50: '#eff6ff',
|
||||||
100: '#dbeafe',
|
100: '#dbeafe',
|
||||||
|
|
@ -19,6 +27,5 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
plugins: []
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,18 @@
|
||||||
"vite": "^7.1.10"
|
"vite": "^7.1.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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:*",
|
||||||
"@supabase/supabase-js": "^2.81.1"
|
"@supabase/supabase-js": "^2.81.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
177
manadeck/apps/web/src/lib/auth.ts
Normal file
177
manadeck/apps/web/src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
/**
|
||||||
|
* Manadeck Web Auth Configuration
|
||||||
|
*
|
||||||
|
* This file initializes the shared auth package for the manadeck web app.
|
||||||
|
* It replaces the previous individual auth files:
|
||||||
|
* - services/authService.ts
|
||||||
|
* - services/tokenManager.ts
|
||||||
|
* - services/deviceManager.ts
|
||||||
|
* - utils/jwt.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PUBLIC_API_URL } from '$env/static/public';
|
||||||
|
import {
|
||||||
|
createAuthService,
|
||||||
|
createTokenManager,
|
||||||
|
setStorageAdapter,
|
||||||
|
setDeviceAdapter,
|
||||||
|
setNetworkAdapter,
|
||||||
|
setupFetchInterceptor,
|
||||||
|
type StorageAdapter,
|
||||||
|
type DeviceManagerAdapter,
|
||||||
|
type NetworkAdapter,
|
||||||
|
type DeviceInfo,
|
||||||
|
} from '@manacore/shared-auth';
|
||||||
|
|
||||||
|
// Storage keys
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
APP_TOKEN: 'appToken',
|
||||||
|
REFRESH_TOKEN: 'refreshToken',
|
||||||
|
USER_EMAIL: 'userEmail',
|
||||||
|
DEVICE_ID: 'manadeck_device_id',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session storage adapter for manadeck web
|
||||||
|
* Uses sessionStorage for tokens (clears on tab close)
|
||||||
|
* Uses localStorage for device ID (persists)
|
||||||
|
*/
|
||||||
|
const sessionStorageAdapter: StorageAdapter = {
|
||||||
|
async getItem<T = string>(key: string): Promise<T | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const value = sessionStorage.getItem(key);
|
||||||
|
if (value === null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T;
|
||||||
|
} catch {
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setItem(key: string, value: string): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeItem(key: string): Promise<void> {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device manager adapter for web
|
||||||
|
*/
|
||||||
|
const webDeviceAdapter: DeviceManagerAdapter = {
|
||||||
|
async getDeviceInfo(): Promise<DeviceInfo> {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {
|
||||||
|
deviceId: '',
|
||||||
|
deviceName: 'Server',
|
||||||
|
deviceType: 'web',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceId = await webDeviceAdapter.getStoredDeviceId() || generateDeviceId();
|
||||||
|
localStorage.setItem(STORAGE_KEYS.DEVICE_ID, deviceId);
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent;
|
||||||
|
let deviceName = 'Web Browser';
|
||||||
|
|
||||||
|
if (userAgent.includes('Mac')) deviceName = 'Mac';
|
||||||
|
else if (userAgent.includes('Windows')) deviceName = 'Windows';
|
||||||
|
else if (userAgent.includes('Linux')) deviceName = 'Linux';
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId,
|
||||||
|
deviceName,
|
||||||
|
deviceType: 'web',
|
||||||
|
platform: 'web',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getStoredDeviceId(): Promise<string | null> {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return localStorage.getItem(STORAGE_KEYS.DEVICE_ID);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network adapter for web
|
||||||
|
*/
|
||||||
|
const webNetworkAdapter: NetworkAdapter = {
|
||||||
|
async isDeviceConnected(): Promise<boolean> {
|
||||||
|
if (typeof navigator === 'undefined') return true;
|
||||||
|
return navigator.onLine;
|
||||||
|
},
|
||||||
|
|
||||||
|
async hasStableConnection(): Promise<boolean> {
|
||||||
|
if (typeof navigator === 'undefined') return true;
|
||||||
|
return navigator.onLine;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique device ID
|
||||||
|
*/
|
||||||
|
function generateDeviceId(): string {
|
||||||
|
return `web_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize adapters
|
||||||
|
setStorageAdapter(sessionStorageAdapter);
|
||||||
|
setDeviceAdapter(webDeviceAdapter);
|
||||||
|
setNetworkAdapter(webNetworkAdapter);
|
||||||
|
|
||||||
|
// Create auth service instance
|
||||||
|
export const authService = createAuthService({
|
||||||
|
baseUrl: PUBLIC_API_URL,
|
||||||
|
storageKeys: {
|
||||||
|
APP_TOKEN: STORAGE_KEYS.APP_TOKEN,
|
||||||
|
REFRESH_TOKEN: STORAGE_KEYS.REFRESH_TOKEN,
|
||||||
|
USER_EMAIL: STORAGE_KEYS.USER_EMAIL,
|
||||||
|
},
|
||||||
|
endpoints: {
|
||||||
|
signIn: '/v1/auth/signin',
|
||||||
|
signUp: '/v1/auth/signup',
|
||||||
|
signOut: '/v1/auth/logout',
|
||||||
|
refresh: '/v1/auth/refresh',
|
||||||
|
validate: '/v1/auth/validate',
|
||||||
|
forgotPassword: '/v1/auth/forgot-password',
|
||||||
|
googleSignIn: '/v1/auth/google-signin',
|
||||||
|
appleSignIn: '/v1/auth/apple-signin',
|
||||||
|
credits: '/v1/auth/credits',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create token manager instance
|
||||||
|
export const tokenManager = createTokenManager(authService);
|
||||||
|
|
||||||
|
// Setup fetch interceptor (only in browser)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setupFetchInterceptor(authService, tokenManager, {
|
||||||
|
backendUrl: PUBLIC_API_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export useful utilities from shared-auth
|
||||||
|
export {
|
||||||
|
decodeToken,
|
||||||
|
isTokenValidLocally,
|
||||||
|
isTokenExpired,
|
||||||
|
getUserFromToken,
|
||||||
|
isB2BUser,
|
||||||
|
getB2BInfo,
|
||||||
|
TokenState,
|
||||||
|
} from '@manacore/shared-auth';
|
||||||
|
|
||||||
|
// Re-export types
|
||||||
|
export type {
|
||||||
|
UserData,
|
||||||
|
DecodedToken,
|
||||||
|
AuthResult,
|
||||||
|
CreditBalance,
|
||||||
|
B2BInfo,
|
||||||
|
} from '@manacore/shared-auth';
|
||||||
34
manadeck/apps/web/src/lib/components/Icon.svelte
Normal file
34
manadeck/apps/web/src/lib/components/Icon.svelte
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Icon Component - Uses @manacore/shared-icons
|
||||||
|
* Phosphor Icons (Bold weight)
|
||||||
|
*/
|
||||||
|
import { iconPaths } from '@manacore/shared-icons';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: keyof typeof iconPaths;
|
||||||
|
size?: number;
|
||||||
|
class?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, size = 24, class: className = '', color }: Props = $props();
|
||||||
|
|
||||||
|
const path = $derived(iconPaths[name]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if path}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
fill={color || 'currentColor'}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
class={className}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{@html path}
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<span class="text-red-500" title="Icon '{name}' not found">⚠</span>
|
||||||
|
{/if}
|
||||||
28
manadeck/apps/web/src/lib/components/ManaDeckLogo.svelte
Normal file
28
manadeck/apps/web/src/lib/components/ManaDeckLogo.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
size?: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { size = 55, color = '#8b5cf6' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<!-- Cards/Deck icon -->
|
||||||
|
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||||
|
<path d="M6 2v2" />
|
||||||
|
<path d="M18 2v2" />
|
||||||
|
<path d="M6 20v2" />
|
||||||
|
<path d="M18 20v2" />
|
||||||
|
<path d="M2 10h20" />
|
||||||
|
</svg>
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
import { PUBLIC_API_URL } from '$env/static/public';
|
|
||||||
import type {
|
|
||||||
SignInResponse,
|
|
||||||
SignUpResponse,
|
|
||||||
ManaUser,
|
|
||||||
CreditBalance
|
|
||||||
} from '$lib/types/auth';
|
|
||||||
import { getUserFromToken } from '$lib/utils/jwt';
|
|
||||||
import { tokenManager } from './tokenManager';
|
|
||||||
import { getDeviceInfo } from './deviceManager';
|
|
||||||
|
|
||||||
export const authService = {
|
|
||||||
/**
|
|
||||||
* Sign in with email and password
|
|
||||||
*/
|
|
||||||
async signIn(email: string, password: string): Promise<SignInResponse> {
|
|
||||||
const deviceInfo = getDeviceInfo();
|
|
||||||
|
|
||||||
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/signin`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
deviceId: deviceInfo.deviceId,
|
|
||||||
deviceName: deviceInfo.deviceName,
|
|
||||||
deviceType: deviceInfo.deviceType
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || 'Sign in failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: SignInResponse = await response.json();
|
|
||||||
|
|
||||||
// Store tokens
|
|
||||||
tokenManager.setTokens(data.appToken, data.refreshToken);
|
|
||||||
|
|
||||||
// Extract user from token
|
|
||||||
data.user = getUserFromToken(data.appToken) || undefined;
|
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign up with email and password
|
|
||||||
*/
|
|
||||||
async signUp(
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
username?: string
|
|
||||||
): Promise<SignUpResponse> {
|
|
||||||
const deviceInfo = getDeviceInfo();
|
|
||||||
|
|
||||||
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/signup`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
username,
|
|
||||||
deviceId: deviceInfo.deviceId,
|
|
||||||
deviceName: deviceInfo.deviceName,
|
|
||||||
deviceType: deviceInfo.deviceType
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || 'Sign up failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: SignUpResponse = await response.json();
|
|
||||||
|
|
||||||
// Store tokens
|
|
||||||
tokenManager.setTokens(data.appToken, data.refreshToken);
|
|
||||||
|
|
||||||
// Extract user from token
|
|
||||||
data.user = getUserFromToken(data.appToken) || undefined;
|
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign out
|
|
||||||
*/
|
|
||||||
async signOut(): Promise<void> {
|
|
||||||
const appToken = tokenManager.getAppToken();
|
|
||||||
|
|
||||||
if (appToken) {
|
|
||||||
try {
|
|
||||||
await fetch(`${PUBLIC_API_URL}/v1/auth/logout`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${appToken}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout request failed:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear tokens locally
|
|
||||||
tokenManager.clearTokens();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current user from token
|
|
||||||
*/
|
|
||||||
getCurrentUser(): ManaUser | null {
|
|
||||||
const appToken = tokenManager.getAppToken();
|
|
||||||
if (!appToken) return null;
|
|
||||||
return getUserFromToken(appToken);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get user credit balance
|
|
||||||
*/
|
|
||||||
async getCreditBalance(): Promise<CreditBalance> {
|
|
||||||
const appToken = await tokenManager.getValidToken();
|
|
||||||
|
|
||||||
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/credits`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${appToken}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch credits');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user is authenticated
|
|
||||||
*/
|
|
||||||
isAuthenticated(): boolean {
|
|
||||||
return !!tokenManager.getAppToken() && !tokenManager.isExpired();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get app token
|
|
||||||
*/
|
|
||||||
getAppToken(): string | null {
|
|
||||||
return tokenManager.getAppToken();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import type { DeviceInfo } from '$lib/types/auth';
|
|
||||||
|
|
||||||
const DEVICE_ID_KEY = 'manadeck_device_id';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a unique device ID for web
|
|
||||||
*/
|
|
||||||
function generateDeviceId(): string {
|
|
||||||
return `web_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get or create device ID
|
|
||||||
*/
|
|
||||||
export function getDeviceId(): string {
|
|
||||||
if (typeof window === 'undefined') return '';
|
|
||||||
|
|
||||||
let deviceId = localStorage.getItem(DEVICE_ID_KEY);
|
|
||||||
if (!deviceId) {
|
|
||||||
deviceId = generateDeviceId();
|
|
||||||
localStorage.setItem(DEVICE_ID_KEY, deviceId);
|
|
||||||
}
|
|
||||||
return deviceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get device info for authentication
|
|
||||||
*/
|
|
||||||
export function getDeviceInfo(): DeviceInfo {
|
|
||||||
const deviceId = getDeviceId();
|
|
||||||
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
|
||||||
|
|
||||||
// Simple device name based on user agent
|
|
||||||
let deviceName = 'Web Browser';
|
|
||||||
if (userAgent.includes('Mac')) deviceName = 'Mac';
|
|
||||||
else if (userAgent.includes('Windows')) deviceName = 'Windows';
|
|
||||||
else if (userAgent.includes('Linux')) deviceName = 'Linux';
|
|
||||||
|
|
||||||
return {
|
|
||||||
deviceId,
|
|
||||||
deviceName,
|
|
||||||
deviceType: 'web',
|
|
||||||
userAgent
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
import { PUBLIC_API_URL } from '$env/static/public';
|
|
||||||
import { decodeToken, isTokenExpired, getTokenExpiresIn } from '$lib/utils/jwt';
|
|
||||||
import { getDeviceInfo } from './deviceManager';
|
|
||||||
|
|
||||||
const TOKEN_REFRESH_BUFFER = 10; // seconds before expiry to trigger refresh
|
|
||||||
const MAX_RETRY_ATTEMPTS = 3;
|
|
||||||
const RETRY_DELAYS = [0, 1000, 2000, 5000]; // Progressive backoff
|
|
||||||
|
|
||||||
interface TokenState {
|
|
||||||
appToken: string | null;
|
|
||||||
refreshToken: string | null;
|
|
||||||
isRefreshing: boolean;
|
|
||||||
refreshPromise: Promise<string> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class TokenManager {
|
|
||||||
private state: TokenState = {
|
|
||||||
appToken: null,
|
|
||||||
refreshToken: null,
|
|
||||||
isRefreshing: false,
|
|
||||||
refreshPromise: null
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
this.loadTokens();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadTokens() {
|
|
||||||
this.state.appToken = sessionStorage.getItem('appToken');
|
|
||||||
this.state.refreshToken = sessionStorage.getItem('refreshToken');
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveTokens(appToken: string, refreshToken: string) {
|
|
||||||
this.state.appToken = appToken;
|
|
||||||
this.state.refreshToken = refreshToken;
|
|
||||||
sessionStorage.setItem('appToken', appToken);
|
|
||||||
sessionStorage.setItem('refreshToken', refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTokens() {
|
|
||||||
this.state.appToken = null;
|
|
||||||
this.state.refreshToken = null;
|
|
||||||
sessionStorage.removeItem('appToken');
|
|
||||||
sessionStorage.removeItem('refreshToken');
|
|
||||||
sessionStorage.removeItem('deviceId');
|
|
||||||
}
|
|
||||||
|
|
||||||
getAppToken(): string | null {
|
|
||||||
return this.state.appToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
getRefreshToken(): string | null {
|
|
||||||
return this.state.refreshToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTokens(appToken: string, refreshToken: string) {
|
|
||||||
this.saveTokens(appToken, refreshToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if token needs refresh
|
|
||||||
*/
|
|
||||||
needsRefresh(): boolean {
|
|
||||||
if (!this.state.appToken) return false;
|
|
||||||
const expiresIn = getTokenExpiresIn(this.state.appToken);
|
|
||||||
return expiresIn > 0 && expiresIn <= TOKEN_REFRESH_BUFFER;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if token is expired
|
|
||||||
*/
|
|
||||||
isExpired(): boolean {
|
|
||||||
if (!this.state.appToken) return true;
|
|
||||||
return isTokenExpired(this.state.appToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh token with retry logic
|
|
||||||
*/
|
|
||||||
async refreshAppToken(retryCount = 0): Promise<string> {
|
|
||||||
// If already refreshing, wait for that promise
|
|
||||||
if (this.state.isRefreshing && this.state.refreshPromise) {
|
|
||||||
return this.state.refreshPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new refresh
|
|
||||||
this.state.isRefreshing = true;
|
|
||||||
this.state.refreshPromise = this._performRefresh(retryCount);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newToken = await this.state.refreshPromise;
|
|
||||||
return newToken;
|
|
||||||
} finally {
|
|
||||||
this.state.isRefreshing = false;
|
|
||||||
this.state.refreshPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _performRefresh(retryCount: number): Promise<string> {
|
|
||||||
if (!this.state.refreshToken) {
|
|
||||||
throw new Error('No refresh token available');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deviceInfo = getDeviceInfo();
|
|
||||||
|
|
||||||
const response = await fetch(`${PUBLIC_API_URL}/v1/auth/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
refreshToken: this.state.refreshToken,
|
|
||||||
deviceId: deviceInfo.deviceId
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.message || 'Token refresh failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
const { appToken, refreshToken } = data;
|
|
||||||
|
|
||||||
// Save new tokens
|
|
||||||
this.saveTokens(appToken, refreshToken);
|
|
||||||
|
|
||||||
return appToken;
|
|
||||||
} catch (error) {
|
|
||||||
// Retry logic
|
|
||||||
if (retryCount < MAX_RETRY_ATTEMPTS) {
|
|
||||||
const delay = RETRY_DELAYS[retryCount];
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
return this.refreshAppToken(retryCount + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Max retries reached, clear tokens
|
|
||||||
this.clearTokens();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get valid token (refreshes if needed)
|
|
||||||
*/
|
|
||||||
async getValidToken(): Promise<string> {
|
|
||||||
if (!this.state.appToken) {
|
|
||||||
throw new Error('No token available');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isExpired()) {
|
|
||||||
// Token is expired, try to refresh
|
|
||||||
return this.refreshAppToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.needsRefresh()) {
|
|
||||||
// Token expires soon, refresh in background
|
|
||||||
this.refreshAppToken().catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.state.appToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton
|
|
||||||
export const tokenManager = new TokenManager();
|
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
import type { ManaUser } from '$lib/types/auth';
|
import type { ManaUser } from '$lib/types/auth';
|
||||||
import { authService } from '$lib/services/authService';
|
import { authService, type UserData } from '$lib/auth';
|
||||||
|
|
||||||
// Svelte 5 runes-based auth store
|
// Svelte 5 runes-based auth store
|
||||||
let user = $state<ManaUser | null>(null);
|
let user = $state<ManaUser | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert UserData from shared-auth to ManaUser
|
||||||
|
*/
|
||||||
|
function toManaUser(userData: UserData | null): ManaUser | null {
|
||||||
|
if (!userData) return null;
|
||||||
|
return {
|
||||||
|
id: userData.id,
|
||||||
|
email: userData.email,
|
||||||
|
role: userData.role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const authStore = {
|
export const authStore = {
|
||||||
get user() {
|
get user() {
|
||||||
return user;
|
return user;
|
||||||
|
|
@ -22,8 +34,10 @@ export const authStore = {
|
||||||
async initialize() {
|
async initialize() {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
if (authService.isAuthenticated()) {
|
const isAuth = await authService.isAuthenticated();
|
||||||
user = authService.getCurrentUser();
|
if (isAuth) {
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = toManaUser(userData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize auth:', error);
|
console.error('Failed to initialize auth:', error);
|
||||||
|
|
@ -55,11 +69,43 @@ export const authStore = {
|
||||||
/**
|
/**
|
||||||
* Check authentication status
|
* Check authentication status
|
||||||
*/
|
*/
|
||||||
checkAuth() {
|
async checkAuth() {
|
||||||
if (!authService.isAuthenticated()) {
|
const isAuth = await authService.isAuthenticated();
|
||||||
|
if (!isAuth) {
|
||||||
user = null;
|
user = null;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with email and password
|
||||||
|
*/
|
||||||
|
async signIn(email: string, password: string) {
|
||||||
|
const result = await authService.signIn(email, password);
|
||||||
|
if (result.success) {
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = toManaUser(userData);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign up with email and password
|
||||||
|
*/
|
||||||
|
async signUp(email: string, password: string) {
|
||||||
|
const result = await authService.signUp(email, password);
|
||||||
|
if (result.success && !result.needsVerification) {
|
||||||
|
const userData = await authService.getUserFromToken();
|
||||||
|
user = toManaUser(userData);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send password reset email
|
||||||
|
*/
|
||||||
|
async forgotPassword(email: string) {
|
||||||
|
return authService.forgotPassword(email);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Deck, CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
|
import type { Deck, CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
|
||||||
import { getAuthenticatedSupabase } from '$lib/utils/supabase';
|
import { getAuthenticatedSupabase } from '$lib/utils/supabase';
|
||||||
import { authService } from '$lib/services/authService';
|
import { authService } from '$lib/auth';
|
||||||
|
|
||||||
// Svelte 5 runes-based deck store
|
// Svelte 5 runes-based deck store
|
||||||
let decks = $state<Deck[]>([]);
|
let decks = $state<Deck[]>([]);
|
||||||
|
|
@ -30,12 +30,12 @@ export const deckStore = {
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appToken = authService.getAppToken();
|
const appToken = await authService.getAppToken();
|
||||||
if (!appToken) {
|
if (!appToken) {
|
||||||
throw new Error('Not authenticated');
|
throw new Error('Not authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = authService.getCurrentUser();
|
const user = await authService.getUserFromToken();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('No user found');
|
throw new Error('No user found');
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +71,7 @@ export const deckStore = {
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appToken = authService.getAppToken();
|
const appToken = await authService.getAppToken();
|
||||||
if (!appToken) throw new Error('Not authenticated');
|
if (!appToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
const supabase = await getAuthenticatedSupabase(appToken);
|
const supabase = await getAuthenticatedSupabase(appToken);
|
||||||
|
|
@ -104,10 +104,10 @@ export const deckStore = {
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appToken = authService.getAppToken();
|
const appToken = await authService.getAppToken();
|
||||||
if (!appToken) throw new Error('Not authenticated');
|
if (!appToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
const user = authService.getCurrentUser();
|
const user = await authService.getUserFromToken();
|
||||||
if (!user) throw new Error('No user found');
|
if (!user) throw new Error('No user found');
|
||||||
|
|
||||||
const supabase = await getAuthenticatedSupabase(appToken);
|
const supabase = await getAuthenticatedSupabase(appToken);
|
||||||
|
|
@ -150,7 +150,7 @@ export const deckStore = {
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appToken = authService.getAppToken();
|
const appToken = await authService.getAppToken();
|
||||||
if (!appToken) throw new Error('Not authenticated');
|
if (!appToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
const supabase = await getAuthenticatedSupabase(appToken);
|
const supabase = await getAuthenticatedSupabase(appToken);
|
||||||
|
|
@ -187,7 +187,7 @@ export const deckStore = {
|
||||||
error = null;
|
error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const appToken = authService.getAppToken();
|
const appToken = await authService.getAppToken();
|
||||||
if (!appToken) throw new Error('Not authenticated');
|
if (!appToken) throw new Error('Not authenticated');
|
||||||
|
|
||||||
const supabase = await getAuthenticatedSupabase(appToken);
|
const supabase = await getAuthenticatedSupabase(appToken);
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import type { JwtPayload, ManaUser } from '$lib/types/auth';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode JWT token without verification (client-side only)
|
|
||||||
*/
|
|
||||||
export function decodeToken(token: string): JwtPayload | null {
|
|
||||||
try {
|
|
||||||
const parts = token.split('.');
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = parts[1];
|
|
||||||
const decoded = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
|
||||||
return decoded as JwtPayload;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to decode token:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if token is expired
|
|
||||||
*/
|
|
||||||
export function isTokenExpired(token: string): boolean {
|
|
||||||
const decoded = decodeToken(token);
|
|
||||||
if (!decoded || !decoded.exp) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
return decoded.exp < now;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get time until token expires (in seconds)
|
|
||||||
*/
|
|
||||||
export function getTokenExpiresIn(token: string): number {
|
|
||||||
const decoded = decodeToken(token);
|
|
||||||
if (!decoded || !decoded.exp) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
return Math.max(0, decoded.exp - now);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract user info from token
|
|
||||||
*/
|
|
||||||
export function getUserFromToken(token: string): ManaUser | null {
|
|
||||||
const decoded = decodeToken(token);
|
|
||||||
if (!decoded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: decoded.sub || decoded.user_id || '',
|
|
||||||
email: decoded.email || '',
|
|
||||||
role: decoded.role || 'user',
|
|
||||||
organizationId: decoded.app_settings?.b2b?.organizationId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
async function loadCredits() {
|
async function loadCredits() {
|
||||||
loadingCredits = true;
|
loadingCredits = true;
|
||||||
try {
|
try {
|
||||||
const { authService } = await import('$lib/services/authService');
|
const { authService } = await import('$lib/auth');
|
||||||
const balance = await authService.getCreditBalance();
|
const balance = await authService.getUserCredits();
|
||||||
credits = balance.credits;
|
credits = balance?.credits ?? null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load credits:', error);
|
console.error('Failed to load credits:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,29 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||||
import Input from '$lib/components/ui/Input.svelte';
|
import ManaDeckLogo from '$lib/components/ManaDeckLogo.svelte';
|
||||||
import Card from '$lib/components/ui/Card.svelte';
|
import { authStore } from '$lib/stores/authStore.svelte';
|
||||||
import { authService } from '$lib/services/authService';
|
|
||||||
|
|
||||||
let email = $state('');
|
async function handleSignIn(email: string, password: string) {
|
||||||
let password = $state('');
|
return authStore.signIn(email, password);
|
||||||
let error = $state('');
|
}
|
||||||
let loading = $state(false);
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleForgotPassword(email: string) {
|
||||||
if (!email || !password) {
|
return authStore.forgotPassword(email);
|
||||||
error = 'Please fill in all fields';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await authService.signIn(email, password);
|
|
||||||
goto('/decks');
|
|
||||||
} catch (err: any) {
|
|
||||||
error = err.message || 'Sign in failed';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<LoginPage
|
||||||
<title>Sign In - Manadeck</title>
|
appName="ManaDeck"
|
||||||
</svelte:head>
|
logo={ManaDeckLogo}
|
||||||
|
primaryColor="#8b5cf6"
|
||||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
onSignIn={handleSignIn}
|
||||||
<div class="w-full max-w-md">
|
onForgotPassword={handleForgotPassword}
|
||||||
<div class="text-center mb-8">
|
goto={goto}
|
||||||
<h1 class="text-3xl font-bold mb-2">Welcome back</h1>
|
enableGoogle={false}
|
||||||
<p class="text-muted-foreground">Sign in to your Manadeck account</p>
|
enableApple={false}
|
||||||
</div>
|
successRedirect="/decks"
|
||||||
|
registerPath="/register"
|
||||||
<Card>
|
lightBackground="#faf5ff"
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
darkBackground="#1a1625"
|
||||||
<Input
|
/>
|
||||||
type="email"
|
|
||||||
label="Email"
|
|
||||||
bind:value={email}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
autocomplete="email"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
label="Password"
|
|
||||||
bind:value={password}
|
|
||||||
placeholder="••••••••"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Button type="submit" fullWidth {loading}>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center text-sm">
|
|
||||||
<span class="text-muted-foreground">Don't have an account?</span>
|
|
||||||
<a href="/register" class="ml-2 text-primary hover:underline">
|
|
||||||
Sign up
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -1,112 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||||
import Input from '$lib/components/ui/Input.svelte';
|
import ManaDeckLogo from '$lib/components/ManaDeckLogo.svelte';
|
||||||
import Card from '$lib/components/ui/Card.svelte';
|
import { authStore } from '$lib/stores/authStore.svelte';
|
||||||
import { authService } from '$lib/services/authService';
|
|
||||||
|
|
||||||
let email = $state('');
|
async function handleSignUp(email: string, password: string) {
|
||||||
let username = $state('');
|
return authStore.signUp(email, password);
|
||||||
let password = $state('');
|
|
||||||
let confirmPassword = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
let loading = $state(false);
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
if (!email || !password || !confirmPassword) {
|
|
||||||
error = 'Please fill in all fields';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
error = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 6) {
|
|
||||||
error = 'Password must be at least 6 characters';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await authService.signUp(email, password, username || undefined);
|
|
||||||
goto('/decks');
|
|
||||||
} catch (err: any) {
|
|
||||||
error = err.message || 'Sign up failed';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<RegisterPage
|
||||||
<title>Sign Up - Manadeck</title>
|
appName="ManaDeck"
|
||||||
</svelte:head>
|
logo={ManaDeckLogo}
|
||||||
|
primaryColor="#8b5cf6"
|
||||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
onSignUp={handleSignUp}
|
||||||
<div class="w-full max-w-md">
|
goto={goto}
|
||||||
<div class="text-center mb-8">
|
successRedirect="/decks"
|
||||||
<h1 class="text-3xl font-bold mb-2">Create your account</h1>
|
loginPath="/login"
|
||||||
<p class="text-muted-foreground">Start building your knowledge decks</p>
|
lightBackground="#faf5ff"
|
||||||
</div>
|
darkBackground="#1a1625"
|
||||||
|
/>
|
||||||
<Card>
|
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
label="Email"
|
|
||||||
bind:value={email}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
autocomplete="email"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
label="Username (optional)"
|
|
||||||
bind:value={username}
|
|
||||||
placeholder="Choose a username"
|
|
||||||
autocomplete="username"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
label="Password"
|
|
||||||
bind:value={password}
|
|
||||||
placeholder="••••••••"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
label="Confirm Password"
|
|
||||||
bind:value={confirmPassword}
|
|
||||||
placeholder="••••••••"
|
|
||||||
autocomplete="new-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<Button type="submit" fullWidth {loading}>
|
|
||||||
Create account
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-6 text-center text-sm">
|
|
||||||
<span class="text-muted-foreground">Already have an account?</span>
|
|
||||||
<a href="/login" class="ml-2 text-primary hover:underline">
|
|
||||||
Sign in
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
|
import { themeColors } from '@manacore/shared-tailwind/colors';
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
content: [
|
||||||
|
'./src/**/*.{html,js,svelte,ts}',
|
||||||
|
'../../packages/shared-ui/src/**/*.{html,js,svelte,ts}',
|
||||||
|
'../../packages/shared-auth-ui/src/**/*.{html,js,svelte,ts}'
|
||||||
|
],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// Shared theme colors
|
||||||
|
...themeColors,
|
||||||
|
// ManaDeck specific HSL-based colors
|
||||||
background: 'hsl(var(--background))',
|
background: 'hsl(var(--background))',
|
||||||
foreground: 'hsl(var(--foreground))',
|
foreground: 'hsl(var(--foreground))',
|
||||||
surface: 'hsl(var(--surface))',
|
surface: 'hsl(var(--surface))',
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.43.2",
|
"@sveltejs/kit": "^2.43.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"svelte": "^5.39.5",
|
"svelte": "^5.39.5",
|
||||||
|
|
@ -26,6 +25,18 @@
|
||||||
"vite": "^7.1.7"
|
"vite": "^7.1.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@phosphor-icons/core": "^2.1.1",
|
||||||
"@supabase/supabase-js": "^2.81.1",
|
"@supabase/supabase-js": "^2.81.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|
|
||||||
|
|
@ -1,408 +1,7 @@
|
||||||
|
@import '@manacore/shared-tailwind/theme.css';
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
@import '@manacore/shared-tailwind/components.css';
|
||||||
--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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data && data.advice) {
|
if (data && data.advice) {
|
||||||
advice = data.advice as AdviceData;
|
advice = data.advice as unknown as AdviceData;
|
||||||
currentIndex = 0; // Reset to first section
|
currentIndex = 0; // Reset to first section
|
||||||
} else {
|
} else {
|
||||||
advice = null;
|
advice = null;
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedApp !== null && modalScrollContainer) {
|
if (selectedApp !== null && modalScrollContainer) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
if (selectedApp === null) return;
|
||||||
const cardWidth = 360 + 24; // card width + gap
|
const cardWidth = 360 + 24; // card width + gap
|
||||||
const scrollPosition = selectedApp * cardWidth;
|
const scrollPosition = selectedApp * cardWidth;
|
||||||
modalScrollContainer?.scrollTo({
|
modalScrollContainer?.scrollTo({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Icon from '$lib/components/Icon.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();
|
let { src, duration }: { src: string; duration?: number } = $props();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { recording } from '$lib/stores/recording';
|
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 mediaRecorder: MediaRecorder | null = null;
|
||||||
let audioChunks: Blob[] = [];
|
let audioChunks: Blob[] = [];
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
let durationInterval: number;
|
let durationInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
let startTime: number = 0;
|
let startTime: number = 0;
|
||||||
|
|
||||||
let hasPermission = $state(false);
|
let hasPermission = $state(false);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createAuthClient } from '$lib/supabaseClient';
|
import { createAuthClient } from '$lib/supabaseClient';
|
||||||
import Modal from './Modal.svelte';
|
import { Modal } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Prompt {
|
interface Prompt {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -107,7 +107,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort prompts by sort_order (ascending) then created_at (descending)
|
// 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)
|
// First sort by sort_order (ascending)
|
||||||
if (a.sort_order !== undefined && b.sort_order !== undefined) {
|
if (a.sort_order !== undefined && b.sort_order !== undefined) {
|
||||||
if (a.sort_order !== b.sort_order) {
|
if (a.sort_order !== b.sort_order) {
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
blueprints = data || [];
|
blueprints = (data || []) as Blueprint[];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Unexpected error:', err);
|
console.error('Unexpected error:', err);
|
||||||
error = $t('errors.unexpected');
|
error = $t('errors.unexpected');
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
/**
|
/**
|
||||||
* Central Icon Component for Memoro Web App
|
* Icon Component - Re-exports from @manacore/shared-icons
|
||||||
* Uses Phosphor Icons (Bold weight) from @phosphor-icons/core
|
* Uses Phosphor Icons (Bold weight)
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* <Icon name="user-plus" size={24} />
|
|
||||||
* <Icon name="sign-in" size={20} class="text-primary" />
|
|
||||||
*/
|
*/
|
||||||
import { iconPaths } from './icons/iconPaths';
|
import { iconPaths } from '@manacore/shared-icons';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: keyof typeof iconPaths;
|
name: keyof typeof iconPaths;
|
||||||
size?: number;
|
size?: number;
|
||||||
class?: string;
|
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]);
|
const path = $derived(iconPaths[name]);
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -25,7 +22,7 @@
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
fill="currentColor"
|
fill={color || 'currentColor'}
|
||||||
viewBox="0 0 256 256"
|
viewBox="0 0 256 256"
|
||||||
class={className}
|
class={className}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Toggle from './Toggle.svelte';
|
import { Toggle } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { user } from '$lib/stores/auth';
|
import { user } from '$lib/stores/auth';
|
||||||
import { theme } from '$lib/stores/theme';
|
import { theme } from '$lib/stores/theme';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Tag } from '$lib/types/memo.types';
|
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 Icon from '$lib/components/Icon.svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tag: Tag;
|
tag: Tag;
|
||||||
|
|
@ -65,9 +64,9 @@
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<!-- Tag Name -->
|
<!-- Tag Name -->
|
||||||
<div class="mb-6">
|
<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
|
Tag-Name
|
||||||
</Text>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="tag-name"
|
id="tag-name"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
let newTagName = $state('');
|
let newTagName = $state('');
|
||||||
let newTagColor = $state('#3b82f6');
|
let newTagColor = $state('#3b82f6');
|
||||||
|
|
||||||
const tagService = new TagService(supabase);
|
const tagService = new TagService();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if ($tags.length === 0) {
|
if ($tags.length === 0) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { default as Text } from './Text.svelte';
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AudioArchiveStats } from '$lib/services/audioStorageService';
|
import type { AudioArchiveStats } from '$lib/services/audioStorageService';
|
||||||
import { audioStorageService } 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 {
|
interface Props {
|
||||||
stats: AudioArchiveStats;
|
stats: AudioArchiveStats;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { de } from 'date-fns/locale';
|
import { de } from 'date-fns/locale';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
audioFile: AudioFileInfo;
|
audioFile: AudioFileInfo;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import type { AudioFileInfo } from '$lib/services/audioStorageService';
|
import type { AudioFileInfo } from '$lib/services/audioStorageService';
|
||||||
import AudioFileCard from './AudioFileCard.svelte';
|
import AudioFileCard from './AudioFileCard.svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
audioFiles: AudioFileInfo[];
|
audioFiles: AudioFileInfo[];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
```
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
import type { AdditionalRecording } from '$lib/types/memo.types';
|
||||||
interface Recording {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
duration?: number;
|
|
||||||
created_at: string;
|
|
||||||
label?: string;
|
|
||||||
size?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
recordings: Recording[];
|
recordings: AdditionalRecording[];
|
||||||
onRecordingAdd?: () => void;
|
onRecordingAdd?: () => void;
|
||||||
onRecordingDelete?: (recordingId: string) => void;
|
onRecordingDelete?: (recordingId: string) => void;
|
||||||
onRecordingRename?: (recordingId: string, newLabel: string) => void;
|
onRecordingRename?: (recordingId: string, newLabel: string) => void;
|
||||||
|
|
@ -25,12 +17,13 @@
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
let editLabel = $state('');
|
let editLabel = $state('');
|
||||||
|
|
||||||
function formatDuration(seconds?: number): string {
|
function formatDuration(millis?: number): string {
|
||||||
if (!seconds) return '--:--';
|
if (!millis) return '--:--';
|
||||||
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
const totalSeconds = Math.floor(millis / 1000);
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
const secs = Math.floor(seconds % 60);
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(totalSeconds % 60);
|
||||||
|
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '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;
|
editingId = recording.id;
|
||||||
editLabel = recording.label || '';
|
editLabel = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEditing() {
|
function cancelEditing() {
|
||||||
|
|
@ -140,7 +133,7 @@
|
||||||
<!-- View Mode -->
|
<!-- View Mode -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Text variant="body" weight="semibold">
|
<Text variant="body" weight="semibold">
|
||||||
{recording.label || `Recording ${recordings.indexOf(recording) + 1}`}
|
Recording {recordings.indexOf(recording) + 1}
|
||||||
</Text>
|
</Text>
|
||||||
{#if canEdit && onRecordingRename}
|
{#if canEdit && onRecordingRename}
|
||||||
<button
|
<button
|
||||||
|
|
@ -172,7 +165,7 @@
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{formatDuration(recording.duration)}
|
{formatDuration(recording.duration_millis)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="muted" class="flex items-center gap-1">
|
<Text variant="muted" class="flex items-center gap-1">
|
||||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"
|
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>
|
</svg>
|
||||||
{formatSize(recording.size)}
|
--
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="muted" class="flex items-center gap-1">
|
<Text variant="muted" class="flex items-center gap-1">
|
||||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -223,7 +216,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Audio Player -->
|
<!-- Audio Player -->
|
||||||
<AudioPlayer src={recording.url} />
|
<AudioPlayer src={recording.audio_url} />
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import type { Memo } from '$lib/types/memo.types';
|
import type { Memo } from '$lib/types/memo.types';
|
||||||
import { formatDuration, getMemooDuration, formatTimestamp } from '$lib/utils/formatters';
|
import { formatDuration, getMemooDuration, formatTimestamp } from '$lib/utils/formatters';
|
||||||
import TagBadge from '$lib/components/TagBadge.svelte';
|
import TagBadge from '$lib/components/TagBadge.svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
memo: Memo;
|
memo: Memo;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Photo {
|
interface Photo {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -50,7 +50,8 @@
|
||||||
function navigatePhoto(direction: number) {
|
function navigatePhoto(direction: number) {
|
||||||
if (!selectedPhoto) return;
|
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;
|
const newIndex = currentIndex + direction;
|
||||||
|
|
||||||
if (newIndex >= 0 && newIndex < photos.length) {
|
if (newIndex >= 0 && newIndex < photos.length) {
|
||||||
|
|
@ -162,6 +163,7 @@
|
||||||
|
|
||||||
<!-- Lightbox Modal -->
|
<!-- Lightbox Modal -->
|
||||||
{#if showLightbox && selectedPhoto}
|
{#if showLightbox && selectedPhoto}
|
||||||
|
{@const currentPhoto = selectedPhoto}
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm"
|
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()}>
|
<div class="relative max-h-full max-w-5xl" onclick={(e) => e.stopPropagation()}>
|
||||||
<!-- Image -->
|
<!-- Image -->
|
||||||
<img
|
<img
|
||||||
src={selectedPhoto.url}
|
src={currentPhoto.url}
|
||||||
alt={selectedPhoto.caption || 'Photo'}
|
alt={currentPhoto.caption || 'Photo'}
|
||||||
class="max-h-[90vh] w-auto rounded-lg shadow-2xl"
|
class="max-h-[90vh] w-auto rounded-lg shadow-2xl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Caption -->
|
<!-- Caption -->
|
||||||
{#if selectedPhoto.caption}
|
{#if currentPhoto.caption}
|
||||||
<div class="mt-4 rounded-lg bg-menu p-4">
|
<div class="mt-4 rounded-lg bg-menu p-4">
|
||||||
<Text variant="body">{selectedPhoto.caption}</Text>
|
<Text variant="body">{currentPhoto.caption}</Text>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -206,7 +208,7 @@
|
||||||
<button
|
<button
|
||||||
onclick={() => navigatePhoto(-1)}
|
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"
|
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 (←)"
|
title="Previous (←)"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -222,7 +224,7 @@
|
||||||
<button
|
<button
|
||||||
onclick={() => navigatePhoto(1)}
|
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"
|
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 (→)"
|
title="Next (→)"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -239,7 +241,7 @@
|
||||||
<!-- Photo Counter -->
|
<!-- Photo Counter -->
|
||||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-4 py-2">
|
<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">
|
<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>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
|
@ -175,6 +175,8 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.kbd {
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Modal } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Blueprint {
|
interface Blueprint {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Modal, Text } from '@manacore/shared-ui';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Modal } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Modal } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Blueprint {
|
interface Blueprint {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,8 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.kbd {
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script lang="ts">
|
<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 type { Memo } from '$lib/types/memo.types';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Modal } from '@manacore/shared-ui';
|
||||||
import type { ShortcutGroup } from '$lib/utils/keyboardShortcuts';
|
import type { ShortcutGroup } from '$lib/utils/keyboardShortcuts';
|
||||||
import { formatShortcut } from '$lib/utils/keyboardShortcuts';
|
import { formatShortcut } from '$lib/utils/keyboardShortcuts';
|
||||||
|
|
||||||
|
|
@ -74,6 +74,9 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.kbd {
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Modal } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Space {
|
interface Space {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Modal } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Speaker {
|
interface Speaker {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Modal } from '@manacore/shared-ui';
|
||||||
import type { Tag } from '$lib/types/memo.types';
|
import type { Tag } from '$lib/types/memo.types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import { Modal } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Language {
|
interface Language {
|
||||||
code: string;
|
code: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import GlassCard from './GlassCard.svelte';
|
import GlassCard from './GlassCard.svelte';
|
||||||
import StatRow from './StatRow.svelte';
|
import StatRow from './StatRow.svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mostViewedMemo: { id: string; title: string; viewCount: number } | null;
|
mostViewedMemo: { id: string; title: string; viewCount: number } | null;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import GlassCard from './GlassCard.svelte';
|
import GlassCard from './GlassCard.svelte';
|
||||||
import StatRow from './StatRow.svelte';
|
import StatRow from './StatRow.svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
averageAudioDuration: number;
|
averageAudioDuration: number;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import GlassCard from './GlassCard.svelte';
|
import GlassCard from './GlassCard.svelte';
|
||||||
import StatRow from './StatRow.svelte';
|
import StatRow from './StatRow.svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
memoCount: number;
|
memoCount: number;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import GlassCard from './GlassCard.svelte';
|
import GlassCard from './GlassCard.svelte';
|
||||||
import StatRow from './StatRow.svelte';
|
import StatRow from './StatRow.svelte';
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
todayStats: {
|
todayStats: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Text from '$lib/components/atoms/Text.svelte';
|
import { Text } from '@manacore/shared-ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,8 @@ export class AudioStorageService {
|
||||||
|
|
||||||
if (memos) {
|
if (memos) {
|
||||||
totalDurationSeconds = memos.reduce((sum, memo) => {
|
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;
|
return sum + duration;
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
@ -167,14 +168,22 @@ export class AudioStorageService {
|
||||||
try {
|
try {
|
||||||
const supabase = await createAuthClient();
|
const supabase = await createAuthClient();
|
||||||
|
|
||||||
// Try to find memo by audio_url
|
// Try to find memo by source containing the audio file name
|
||||||
const { data: memo, error } = await supabase
|
const { data: memos, error } = await supabase
|
||||||
.from('memos')
|
.from('memos')
|
||||||
.select('id, title, audio_url')
|
.select('id, title, source')
|
||||||
.ilike('audio_url', `%${audioFileName}%`)
|
.not('source', 'is', null);
|
||||||
.single();
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
id: memo.id,
|
id: memo.id,
|
||||||
|
|
|
||||||
|
|
@ -96,11 +96,10 @@ export async function uploadAndProcessAudio({
|
||||||
fileName,
|
fileName,
|
||||||
duration: Math.floor(duration),
|
duration: Math.floor(duration),
|
||||||
memoId,
|
memoId,
|
||||||
spaceId,
|
spaceId: spaceId ?? undefined,
|
||||||
title,
|
title,
|
||||||
blueprintId,
|
blueprintId: blueprintId ?? undefined,
|
||||||
recordingLanguages,
|
recordingLanguages,
|
||||||
enableDiarization,
|
|
||||||
appToken
|
appToken
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -347,9 +347,9 @@ export class MemoService {
|
||||||
.eq('id', memoId)
|
.eq('id', memoId)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
const currentMetadata = memo?.metadata || {};
|
const currentMetadata = (memo?.metadata as Record<string, unknown>) || {};
|
||||||
const currentStats = currentMetadata.stats || {};
|
const currentStats = (currentMetadata.stats as Record<string, unknown>) || {};
|
||||||
const newViewCount = (currentStats.viewCount || 0) + 1;
|
const newViewCount = ((currentStats.viewCount as number) || 0) + 1;
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from('memos')
|
.from('memos')
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
import { env } from '$lib/config/env';
|
import { env } from '$lib/config/env';
|
||||||
import { tokenManager } from './tokenManager';
|
import { tokenManager } from './tokenManager';
|
||||||
import { createAuthClient } from '$lib/supabaseClient';
|
import { createAuthClient } from '$lib/supabaseClient';
|
||||||
|
import type { Memory } from '$lib/types/memo.types';
|
||||||
|
|
||||||
export interface QuestionResult {
|
export interface QuestionResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
@ -14,12 +15,7 @@ export interface QuestionResult {
|
||||||
creditsConsumed?: number;
|
creditsConsumed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Memory {
|
export type { Memory };
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class QuestionService {
|
class QuestionService {
|
||||||
/**
|
/**
|
||||||
|
|
@ -131,7 +127,7 @@ class QuestionService {
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('memories')
|
.from('memories')
|
||||||
.select('id, title, content, metadata')
|
.select('id, memo_id, title, content, metadata, style, media, created_at, updated_at')
|
||||||
.eq('memo_id', memoId)
|
.eq('memo_id', memoId)
|
||||||
.order('sort_order', { ascending: true })
|
.order('sort_order', { ascending: true })
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
@ -141,7 +137,18 @@ class QuestionService {
|
||||||
return [];
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading memories:', error);
|
console.error('Error loading memories:', error);
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ function createRecordingStore() {
|
||||||
audioBlob: blob,
|
audioBlob: blob,
|
||||||
audioUrl: URL.createObjectURL(blob)
|
audioUrl: URL.createObjectURL(blob)
|
||||||
})),
|
})),
|
||||||
setError: (error: string) => update((state) => ({ ...state, error })),
|
setError: (error: string | null) => update((state) => ({ ...state, error })),
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,611 @@
|
||||||
/**
|
export type Json =
|
||||||
* Database types for Supabase
|
| string
|
||||||
* This is a placeholder file - generate actual types using:
|
| number
|
||||||
* npx supabase gen types typescript --project-id YOUR_PROJECT_ID > src/lib/types/database.types.ts
|
| boolean
|
||||||
*/
|
| null
|
||||||
|
| { [key: string]: Json | undefined }
|
||||||
|
| Json[]
|
||||||
|
|
||||||
export type Database = {
|
export type Database = {
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
[key: string]: {
|
blueprints: {
|
||||||
Row: Record<string, unknown>;
|
Row: {
|
||||||
Insert: Record<string, unknown>;
|
advice: Json | null
|
||||||
Update: Record<string, unknown>;
|
category: Json | null
|
||||||
};
|
created_at: string
|
||||||
};
|
description: Json | null
|
||||||
Views: {
|
id: string
|
||||||
[key: string]: {
|
is_public: boolean
|
||||||
Row: Record<string, unknown>;
|
name: Json
|
||||||
};
|
style: Json | null
|
||||||
};
|
topic_id: string | null
|
||||||
Functions: {
|
updated_at: string
|
||||||
[key: string]: {
|
user_id: string | null
|
||||||
Args: Record<string, unknown>;
|
}
|
||||||
Returns: unknown;
|
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
|
||||||
|
|
@ -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 {
|
export interface Memo {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|
@ -8,6 +23,7 @@ export interface Memo {
|
||||||
duration_millis: number | null;
|
duration_millis: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
recorded_at?: string;
|
||||||
space_id: string | null;
|
space_id: string | null;
|
||||||
blueprint_id: string | null;
|
blueprint_id: string | null;
|
||||||
language: string | null;
|
language: string | null;
|
||||||
|
|
@ -35,6 +51,8 @@ export interface Memo {
|
||||||
memories?: Memory[];
|
memories?: Memory[];
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
space?: Space;
|
space?: Space;
|
||||||
|
photos?: MemoPhoto[];
|
||||||
|
additional_recordings?: AdditionalRecording[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||||
|
|
@ -64,6 +82,7 @@ export interface Tag {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
is_pinned?: boolean;
|
is_pinned?: boolean;
|
||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
|
usage?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemoTag {
|
export interface MemoTag {
|
||||||
|
|
@ -80,12 +99,25 @@ export interface Space {
|
||||||
updated_at: string;
|
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 {
|
export interface Blueprint {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
prompt: string;
|
prompt?: string;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
category?: string | null;
|
||||||
|
prompts?: Prompt[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -142,7 +142,7 @@
|
||||||
// Create a lookup for prompts by ID
|
// Create a lookup for prompts by ID
|
||||||
const promptsById: Record<string, any> = {};
|
const promptsById: Record<string, any> = {};
|
||||||
if (allPrompts) {
|
if (allPrompts) {
|
||||||
for (const prompt of allPrompts) {
|
for (const prompt of allPrompts as Prompt[]) {
|
||||||
promptsById[prompt.id] = prompt;
|
promptsById[prompt.id] = prompt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,10 +151,17 @@
|
||||||
const blueprintsWithPrompts: Blueprint[] = [];
|
const blueprintsWithPrompts: Blueprint[] = [];
|
||||||
for (const blueprint of blueprintsData || []) {
|
for (const blueprint of blueprintsData || []) {
|
||||||
const promptIds = promptLinksByBlueprintId[blueprint.id] || [];
|
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({
|
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
|
prompts: promptsForBlueprint
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue