mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41: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/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"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">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
|
|
|
|||
|
|
@ -36,6 +36,18 @@
|
|||
"vitest": "^3.2.4"
|
||||
},
|
||||
"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/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;
|
||||
autocomplete?: 'email' | 'current-password' | 'new-password' | 'username' | 'off' | string;
|
||||
oninput?: (event: Event) => void;
|
||||
minlength?: number;
|
||||
maxlength?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -22,7 +24,9 @@
|
|||
disabled = false,
|
||||
class: className = '',
|
||||
autocomplete,
|
||||
oninput
|
||||
oninput,
|
||||
minlength,
|
||||
maxlength
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
|
|
@ -33,6 +37,8 @@
|
|||
{placeholder}
|
||||
{required}
|
||||
{disabled}
|
||||
{minlength}
|
||||
{maxlength}
|
||||
autocomplete={autocomplete as any}
|
||||
bind:value
|
||||
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>
|
||||
|
||||
<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">
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
{@render children()}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
import { enhance } from '$app/forms';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import ManaCoreLogo from '$lib/components/ManaCoreLogo.svelte';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
|
||||
let { form } = $props();
|
||||
let loading = $state(false);
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
|
||||
async function handleForgotPassword(email: string) {
|
||||
return authStore.forgotPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">ManaCore</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<Card class="mt-8">
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
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}
|
||||
|
||||
<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>
|
||||
<LoginPage
|
||||
appName="ManaCore"
|
||||
logo={ManaCoreLogo}
|
||||
primaryColor="#6366f1"
|
||||
onSignIn={handleSignIn}
|
||||
onForgotPassword={handleForgotPassword}
|
||||
goto={goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect="/dashboard"
|
||||
registerPath="/register"
|
||||
lightBackground="#f3f4f6"
|
||||
darkBackground="#121212"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
import { enhance } from '$app/forms';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import ManaCoreLogo from '$lib/components/ManaCoreLogo.svelte';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
|
||||
let { form } = $props();
|
||||
let loading = $state(false);
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="text-center">
|
||||
<h2 class="mb-2 text-3xl font-bold text-gray-900 dark:text-white">Create Account</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">Sign up for ManaCore</p>
|
||||
</div>
|
||||
|
||||
<Card class="mt-8">
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
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>
|
||||
<RegisterPage
|
||||
appName="ManaCore"
|
||||
logo={ManaCoreLogo}
|
||||
primaryColor="#6366f1"
|
||||
onSignUp={handleSignUp}
|
||||
goto={goto}
|
||||
successRedirect="/dashboard"
|
||||
loginPath="/login"
|
||||
lightBackground="#f3f4f6"
|
||||
darkBackground="#121212"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -136,12 +136,6 @@
|
|||
</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">
|
||||
Password updated successfully! Redirecting to dashboard...
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
minlength={6}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must be at least 6 characters
|
||||
|
|
@ -172,7 +166,7 @@
|
|||
autocomplete="new-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
minlength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -141,12 +141,6 @@
|
|||
</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">
|
||||
Password updated successfully! Redirecting to dashboard...
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<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"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
minlength={6}
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Must be at least 6 characters
|
||||
|
|
@ -177,7 +171,7 @@
|
|||
autocomplete="new-password"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
minlength={6}
|
||||
/>
|
||||
</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} */
|
||||
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: {
|
||||
extend: {
|
||||
colors: {
|
||||
// ManaCore specific primary blue
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
|
|
@ -19,6 +27,5 @@ export default {
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,18 @@
|
|||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 { authService } from '$lib/services/authService';
|
||||
import { authService, type UserData } from '$lib/auth';
|
||||
|
||||
// Svelte 5 runes-based auth store
|
||||
let user = $state<ManaUser | null>(null);
|
||||
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 = {
|
||||
get user() {
|
||||
return user;
|
||||
|
|
@ -22,8 +34,10 @@ export const authStore = {
|
|||
async initialize() {
|
||||
loading = true;
|
||||
try {
|
||||
if (authService.isAuthenticated()) {
|
||||
user = authService.getCurrentUser();
|
||||
const isAuth = await authService.isAuthenticated();
|
||||
if (isAuth) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toManaUser(userData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
|
|
@ -55,11 +69,43 @@ export const authStore = {
|
|||
/**
|
||||
* Check authentication status
|
||||
*/
|
||||
checkAuth() {
|
||||
if (!authService.isAuthenticated()) {
|
||||
async checkAuth() {
|
||||
const isAuth = await authService.isAuthenticated();
|
||||
if (!isAuth) {
|
||||
user = null;
|
||||
return false;
|
||||
}
|
||||
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 { getAuthenticatedSupabase } from '$lib/utils/supabase';
|
||||
import { authService } from '$lib/services/authService';
|
||||
import { authService } from '$lib/auth';
|
||||
|
||||
// Svelte 5 runes-based deck store
|
||||
let decks = $state<Deck[]>([]);
|
||||
|
|
@ -30,12 +30,12 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const user = authService.getCurrentUser();
|
||||
const user = await authService.getUserFromToken();
|
||||
if (!user) {
|
||||
throw new Error('No user found');
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) throw new Error('Not authenticated');
|
||||
|
||||
const supabase = await getAuthenticatedSupabase(appToken);
|
||||
|
|
@ -104,10 +104,10 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) throw new Error('Not authenticated');
|
||||
|
||||
const user = authService.getCurrentUser();
|
||||
const user = await authService.getUserFromToken();
|
||||
if (!user) throw new Error('No user found');
|
||||
|
||||
const supabase = await getAuthenticatedSupabase(appToken);
|
||||
|
|
@ -150,7 +150,7 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) throw new Error('Not authenticated');
|
||||
|
||||
const supabase = await getAuthenticatedSupabase(appToken);
|
||||
|
|
@ -187,7 +187,7 @@ export const deckStore = {
|
|||
error = null;
|
||||
|
||||
try {
|
||||
const appToken = authService.getAppToken();
|
||||
const appToken = await authService.getAppToken();
|
||||
if (!appToken) throw new Error('Not authenticated');
|
||||
|
||||
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() {
|
||||
loadingCredits = true;
|
||||
try {
|
||||
const { authService } = await import('$lib/services/authService');
|
||||
const balance = await authService.getCreditBalance();
|
||||
credits = balance.credits;
|
||||
const { authService } = await import('$lib/auth');
|
||||
const balance = await authService.getUserCredits();
|
||||
credits = balance?.credits ?? null;
|
||||
} catch (error) {
|
||||
console.error('Failed to load credits:', error);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,83 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { authService } from '$lib/services/authService';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import ManaDeckLogo from '$lib/components/ManaDeckLogo.svelte';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !password) {
|
||||
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;
|
||||
}
|
||||
async function handleForgotPassword(email: string) {
|
||||
return authStore.forgotPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign In - Manadeck</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">Welcome back</h1>
|
||||
<p class="text-muted-foreground">Sign in to your Manadeck account</p>
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
<LoginPage
|
||||
appName="ManaDeck"
|
||||
logo={ManaDeckLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
onForgotPassword={handleForgotPassword}
|
||||
goto={goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect="/decks"
|
||||
registerPath="/register"
|
||||
lightBackground="#faf5ff"
|
||||
darkBackground="#1a1625"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,112 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Card from '$lib/components/ui/Card.svelte';
|
||||
import { authService } from '$lib/services/authService';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import ManaDeckLogo from '$lib/components/ManaDeckLogo.svelte';
|
||||
import { authStore } from '$lib/stores/authStore.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let username = $state('');
|
||||
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;
|
||||
}
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign Up - Manadeck</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center bg-background px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold mb-2">Create your account</h1>
|
||||
<p class="text-muted-foreground">Start building your knowledge decks</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<RegisterPage
|
||||
appName="ManaDeck"
|
||||
logo={ManaDeckLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignUp={handleSignUp}
|
||||
goto={goto}
|
||||
successRedirect="/decks"
|
||||
loginPath="/login"
|
||||
lightBackground="#faf5ff"
|
||||
darkBackground="#1a1625"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import { themeColors } from '@manacore/shared-tailwind/colors';
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
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',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Shared theme colors
|
||||
...themeColors,
|
||||
// ManaDeck specific HSL-based colors
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
surface: 'hsl(var(--surface))',
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@
|
|||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.39.5",
|
||||
|
|
@ -26,6 +25,18 @@
|
|||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-config": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-supabase": "workspace:*",
|
||||
"@manacore/shared-subscription-types": "workspace:*",
|
||||
"@manacore/shared-subscription-ui": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@phosphor-icons/core": "^2.1.1",
|
||||
"@supabase/supabase-js": "^2.81.1",
|
||||
"date-fns": "^4.1.0",
|
||||
|
|
|
|||
|
|
@ -1,408 +1,7 @@
|
|||
@import '@manacore/shared-tailwind/theme.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--font-body: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
--font-mono: 'Fira Mono', monospace;
|
||||
}
|
||||
|
||||
/* Default Theme: Lume Light */
|
||||
:root {
|
||||
--color-primary: #f8d62b;
|
||||
--color-primary-button: #f8d62b;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #d4b200;
|
||||
--color-secondary-button: #ffe9a3;
|
||||
--color-content-bg: #ffffff;
|
||||
--color-content-bg-hover: #f5f5f5;
|
||||
--color-content-page-bg: #ffffff;
|
||||
--color-menu-bg: #dddddd;
|
||||
--color-menu-bg-hover: #cccccc;
|
||||
--color-panel-bg: #e8e8e8;
|
||||
--color-page-bg: #dddddd;
|
||||
--color-text: #2c2c2c;
|
||||
--color-border-light: #f2f2f2;
|
||||
--color-border: #999999;
|
||||
--color-border-strong: #cccccc;
|
||||
--color-error: #e74c3c;
|
||||
}
|
||||
|
||||
/* Lume Dark */
|
||||
:root.dark {
|
||||
--color-primary: #f8d62b;
|
||||
--color-primary-button: #7c6b16;
|
||||
--color-primary-button-text: #ffffff;
|
||||
--color-secondary: #d4b200;
|
||||
--color-secondary-button: #1e1e1e;
|
||||
--color-content-bg: #1e1e1e;
|
||||
--color-content-bg-hover: #333333;
|
||||
--color-content-page-bg: #121212;
|
||||
--color-menu-bg: #101010;
|
||||
--color-menu-bg-hover: #333333;
|
||||
--color-panel-bg: #1a1a1a;
|
||||
--color-page-bg: #101010;
|
||||
--color-text: #ffffff;
|
||||
--color-border-light: #333333;
|
||||
--color-border: #424242;
|
||||
--color-border-strong: #616161;
|
||||
--color-error: #e74c3c;
|
||||
}
|
||||
|
||||
/* Nature Light */
|
||||
:root[data-theme='nature'] {
|
||||
--color-primary: #4caf50;
|
||||
--color-primary-button: #a08500;
|
||||
--color-primary-button-text: #ffffff;
|
||||
--color-secondary: #81c784;
|
||||
--color-secondary-button: #f1f8e9;
|
||||
--color-content-bg: #f1f8e9;
|
||||
--color-content-bg-hover: #e8f5e9;
|
||||
--color-content-page-bg: #ffffff;
|
||||
--color-menu-bg: #e8f5e9;
|
||||
--color-menu-bg-hover: #c8e6c9;
|
||||
--color-panel-bg: #eff8f0;
|
||||
--color-page-bg: #fbfdf8;
|
||||
--color-text: #1b5e20;
|
||||
--color-border-light: #e8f5e9;
|
||||
--color-border: #c8e6c9;
|
||||
--color-border-strong: #a5d6a7;
|
||||
--color-error: #e57373;
|
||||
}
|
||||
|
||||
/* Nature Dark */
|
||||
:root[data-theme='nature'].dark {
|
||||
--color-primary: #4caf50;
|
||||
--color-primary-button: #ff9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #81c784;
|
||||
--color-secondary-button: #1e1e1e;
|
||||
--color-content-bg: #1e1e1e;
|
||||
--color-content-bg-hover: #2e7d32;
|
||||
--color-content-page-bg: #121212;
|
||||
--color-menu-bg: #252525;
|
||||
--color-menu-bg-hover: #2e7d32;
|
||||
--color-panel-bg: #2a2a2a;
|
||||
--color-page-bg: #121212;
|
||||
--color-text: #ffffff;
|
||||
--color-border-light: #1b5e20;
|
||||
--color-border: #2e7d32;
|
||||
--color-border-strong: #388e3c;
|
||||
--color-error: #cf6679;
|
||||
}
|
||||
|
||||
/* Stone Light */
|
||||
:root[data-theme='stone'] {
|
||||
--color-primary: #607d8b;
|
||||
--color-primary-button: #ff9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #90a4ae;
|
||||
--color-secondary-button: #eceff1;
|
||||
--color-content-bg: #eceff1;
|
||||
--color-content-bg-hover: #e0e6ea;
|
||||
--color-content-page-bg: #ffffff;
|
||||
--color-menu-bg: #e0e6ea;
|
||||
--color-menu-bg-hover: #cfd8dc;
|
||||
--color-panel-bg: #e8edf0;
|
||||
--color-page-bg: #f5f7f9;
|
||||
--color-text: #263238;
|
||||
--color-border-light: #eceff1;
|
||||
--color-border: #cfd8dc;
|
||||
--color-border-strong: #b0bec5;
|
||||
--color-error: #ef5350;
|
||||
}
|
||||
|
||||
/* Stone Dark */
|
||||
:root[data-theme='stone'].dark {
|
||||
--color-primary: #78909c;
|
||||
--color-primary-button: #ff9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #90a4ae;
|
||||
--color-secondary-button: #1e1e1e;
|
||||
--color-content-bg: #1e1e1e;
|
||||
--color-content-bg-hover: #37474f;
|
||||
--color-content-page-bg: #121212;
|
||||
--color-menu-bg: #252525;
|
||||
--color-menu-bg-hover: #37474f;
|
||||
--color-panel-bg: #2a2a2a;
|
||||
--color-page-bg: #121212;
|
||||
--color-text: #ffffff;
|
||||
--color-border-light: #37474f;
|
||||
--color-border: #455a64;
|
||||
--color-border-strong: #546e7a;
|
||||
--color-error: #cf6679;
|
||||
}
|
||||
|
||||
/* Ocean Light */
|
||||
:root[data-theme='ocean'] {
|
||||
--color-primary: #039be5;
|
||||
--color-primary-button: #ff9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #4fc3f7;
|
||||
--color-secondary-button: #e1f5fe;
|
||||
--color-content-bg: #e1f5fe;
|
||||
--color-content-bg-hover: #b3e5fc;
|
||||
--color-content-page-bg: #ffffff;
|
||||
--color-menu-bg: #e1f5fe;
|
||||
--color-menu-bg-hover: #b3e5fc;
|
||||
--color-panel-bg: #ecf8fe;
|
||||
--color-page-bg: #f5fcff;
|
||||
--color-text: #01579b;
|
||||
--color-border-light: #e1f5fe;
|
||||
--color-border: #b3e5fc;
|
||||
--color-border-strong: #81d4fa;
|
||||
--color-error: #ef5350;
|
||||
}
|
||||
|
||||
/* Ocean Dark */
|
||||
:root[data-theme='ocean'].dark {
|
||||
--color-primary: #039be5;
|
||||
--color-primary-button: #ff9500;
|
||||
--color-primary-button-text: #000000;
|
||||
--color-secondary: #4fc3f7;
|
||||
--color-secondary-button: #1e1e1e;
|
||||
--color-content-bg: #1e1e1e;
|
||||
--color-content-bg-hover: #0277bd;
|
||||
--color-content-page-bg: #121212;
|
||||
--color-menu-bg: #252525;
|
||||
--color-menu-bg-hover: #0277bd;
|
||||
--color-panel-bg: #2a2a2a;
|
||||
--color-page-bg: #121212;
|
||||
--color-text: #ffffff;
|
||||
--color-border-light: #01579b;
|
||||
--color-border: #0277bd;
|
||||
--color-border-strong: #0288d1;
|
||||
--color-error: #cf6679;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
h1 {
|
||||
@apply text-3xl font-bold;
|
||||
color: var(--color-text);
|
||||
}
|
||||
h2 {
|
||||
@apply text-2xl font-semibold;
|
||||
color: var(--color-text);
|
||||
}
|
||||
h3 {
|
||||
@apply text-xl font-semibold;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply rounded-lg px-4 py-2 font-semibold transition-colors;
|
||||
background-color: var(--color-primary-button);
|
||||
color: var(--color-primary-button-text);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply rounded-lg px-4 py-2 font-semibold transition-colors;
|
||||
background-color: var(--color-secondary-button);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-content-bg-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply rounded-lg px-4 py-2 font-semibold transition-colors;
|
||||
background-color: var(--color-error);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full rounded-lg px-4 py-2 transition-colors;
|
||||
background-color: var(--color-content-bg);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-lg p-6 shadow-sm;
|
||||
background-color: var(--color-content-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Header & Navigation */
|
||||
.header-style {
|
||||
background-color: var(--color-menu-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
@apply text-2xl font-bold;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@apply transition-colors;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.user-email {
|
||||
@apply text-sm;
|
||||
color: var(--color-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.main-content {
|
||||
background-color: var(--color-page-bg);
|
||||
}
|
||||
|
||||
/* Selected/Active State */
|
||||
.bg-selected {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Status Badge Colors */
|
||||
.status-completed {
|
||||
background-color: rgba(76, 175, 80, 0.15);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.status-processing {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
background-color: color-mix(in srgb, var(--color-error) 15%, transparent);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.status-default {
|
||||
background-color: color-mix(in srgb, var(--color-text) 10%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Info/Alert Boxes */
|
||||
.info-box {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.spinner-border {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Focus Ring */
|
||||
.focus\:ring-primary:focus {
|
||||
--tw-ring-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.focus\:ring-2:focus {
|
||||
box-shadow: 0 0 0 2px var(--tw-ring-color, var(--color-primary));
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Theme Color Utilities - in utilities layer for @apply support */
|
||||
.bg-content {
|
||||
background-color: var(--color-content-bg);
|
||||
}
|
||||
|
||||
.bg-content-hover {
|
||||
background-color: var(--color-content-bg-hover);
|
||||
}
|
||||
|
||||
.hover\:bg-content-hover:hover {
|
||||
background-color: var(--color-content-bg-hover);
|
||||
}
|
||||
|
||||
.bg-menu {
|
||||
background-color: var(--color-menu-bg);
|
||||
}
|
||||
|
||||
.bg-menu-hover {
|
||||
background-color: var(--color-menu-bg-hover);
|
||||
}
|
||||
|
||||
.hover\:bg-menu-hover:hover {
|
||||
background-color: var(--color-menu-bg-hover);
|
||||
}
|
||||
|
||||
.bg-panel {
|
||||
background-color: var(--color-panel-bg);
|
||||
}
|
||||
|
||||
.bg-page {
|
||||
background-color: var(--color-page-bg);
|
||||
}
|
||||
|
||||
.border-theme {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.border-theme-light {
|
||||
border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
.text-theme {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.text-theme-secondary {
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.text-theme-muted {
|
||||
color: var(--color-text);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.bg-primary-button {
|
||||
background-color: var(--color-primary-button);
|
||||
}
|
||||
|
||||
.text-primary-button-text {
|
||||
color: var(--color-primary-button-text);
|
||||
}
|
||||
|
||||
.bg-secondary-button {
|
||||
background-color: var(--color-secondary-button);
|
||||
}
|
||||
}
|
||||
@import '@manacore/shared-tailwind/components.css';
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
}
|
||||
|
||||
if (data && data.advice) {
|
||||
advice = data.advice as AdviceData;
|
||||
advice = data.advice as unknown as AdviceData;
|
||||
currentIndex = 0; // Reset to first section
|
||||
} else {
|
||||
advice = null;
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
$effect(() => {
|
||||
if (selectedApp !== null && modalScrollContainer) {
|
||||
setTimeout(() => {
|
||||
if (selectedApp === null) return;
|
||||
const cardWidth = 360 + 24; // card width + gap
|
||||
const scrollPosition = selectedApp * cardWidth;
|
||||
modalScrollContainer?.scrollTo({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
let { src, duration }: { src: string; duration?: number } = $props();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { recording } from '$lib/stores/recording';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
let mediaRecorder: MediaRecorder | null = null;
|
||||
let audioChunks: Blob[] = [];
|
||||
let stream: MediaStream | null = null;
|
||||
let durationInterval: number;
|
||||
let durationInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let startTime: number = 0;
|
||||
|
||||
let hasPermission = $state(false);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { createAuthClient } from '$lib/supabaseClient';
|
||||
import Modal from './Modal.svelte';
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
|
||||
interface Prompt {
|
||||
id: string;
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
}
|
||||
|
||||
// Sort prompts by sort_order (ascending) then created_at (descending)
|
||||
const sortedPrompts = [...promptsData].sort((a, b) => {
|
||||
const sortedPrompts = [...(promptsData as Prompt[])].sort((a, b) => {
|
||||
// First sort by sort_order (ascending)
|
||||
if (a.sort_order !== undefined && b.sort_order !== undefined) {
|
||||
if (a.sort_order !== b.sort_order) {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
blueprints = data || [];
|
||||
blueprints = (data || []) as Blueprint[];
|
||||
} catch (err) {
|
||||
console.error('Unexpected error:', err);
|
||||
error = $t('errors.unexpected');
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Central Icon Component for Memoro Web App
|
||||
* Uses Phosphor Icons (Bold weight) from @phosphor-icons/core
|
||||
*
|
||||
* Usage:
|
||||
* <Icon name="user-plus" size={24} />
|
||||
* <Icon name="sign-in" size={20} class="text-primary" />
|
||||
* Icon Component - Re-exports from @manacore/shared-icons
|
||||
* Uses Phosphor Icons (Bold weight)
|
||||
*/
|
||||
import { iconPaths } from './icons/iconPaths';
|
||||
import { iconPaths } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
name: keyof typeof iconPaths;
|
||||
size?: number;
|
||||
class?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
let { name, size = 24, class: className = '' }: Props = $props();
|
||||
let { name, size = 24, class: className = '', color }: Props = $props();
|
||||
|
||||
const path = $derived(iconPaths[name]);
|
||||
</script>
|
||||
|
|
@ -25,7 +22,7 @@
|
|||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
fill={color || 'currentColor'}
|
||||
viewBox="0 0 256 256"
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Toggle from './Toggle.svelte';
|
||||
import { Toggle } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { page } from '$app/stores';
|
||||
import { user } from '$lib/stores/auth';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
onLogout: () => void;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { Tag } from '$lib/types/memo.types';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal, Text } from '@manacore/shared-ui';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
|
||||
interface Props {
|
||||
tag: Tag;
|
||||
|
|
@ -65,9 +64,9 @@
|
|||
{#snippet children()}
|
||||
<!-- Tag Name -->
|
||||
<div class="mb-6">
|
||||
<Text variant="small" weight="medium" class="mb-2 block" for="tag-name">
|
||||
<label for="tag-name" class="mb-2 block text-sm font-medium text-theme">
|
||||
Tag-Name
|
||||
</Text>
|
||||
</label>
|
||||
<input
|
||||
id="tag-name"
|
||||
type="text"
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
let newTagName = $state('');
|
||||
let newTagColor = $state('#3b82f6');
|
||||
|
||||
const tagService = new TagService(supabase);
|
||||
const tagService = new TagService();
|
||||
|
||||
onMount(async () => {
|
||||
if ($tags.length === 0) {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
import type { AudioArchiveStats } from '$lib/services/audioStorageService';
|
||||
import { audioStorageService } from '$lib/services/audioStorageService';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
stats: AudioArchiveStats;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
audioFile: AudioFileInfo;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { AudioFileInfo } from '$lib/services/audioStorageService';
|
||||
import AudioFileCard from './AudioFileCard.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
audioFiles: AudioFileInfo[];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
|
||||
interface Recording {
|
||||
id: string;
|
||||
url: string;
|
||||
duration?: number;
|
||||
created_at: string;
|
||||
label?: string;
|
||||
size?: number;
|
||||
}
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
import type { AdditionalRecording } from '$lib/types/memo.types';
|
||||
|
||||
interface Props {
|
||||
recordings: Recording[];
|
||||
recordings: AdditionalRecording[];
|
||||
onRecordingAdd?: () => void;
|
||||
onRecordingDelete?: (recordingId: string) => void;
|
||||
onRecordingRename?: (recordingId: string, newLabel: string) => void;
|
||||
|
|
@ -25,12 +17,13 @@
|
|||
let editingId = $state<string | null>(null);
|
||||
let editLabel = $state('');
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (!seconds) return '--:--';
|
||||
function formatDuration(millis?: number): string {
|
||||
if (!millis) return '--:--';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
const totalSeconds = Math.floor(millis / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secs = Math.floor(totalSeconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
|
|
@ -60,9 +53,9 @@
|
|||
});
|
||||
}
|
||||
|
||||
function startEditing(recording: Recording) {
|
||||
function startEditing(recording: AdditionalRecording) {
|
||||
editingId = recording.id;
|
||||
editLabel = recording.label || '';
|
||||
editLabel = '';
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
|
|
@ -140,7 +133,7 @@
|
|||
<!-- View Mode -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Text variant="body" weight="semibold">
|
||||
{recording.label || `Recording ${recordings.indexOf(recording) + 1}`}
|
||||
Recording {recordings.indexOf(recording) + 1}
|
||||
</Text>
|
||||
{#if canEdit && onRecordingRename}
|
||||
<button
|
||||
|
|
@ -172,7 +165,7 @@
|
|||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{formatDuration(recording.duration)}
|
||||
{formatDuration(recording.duration_millis)}
|
||||
</Text>
|
||||
<Text variant="muted" class="flex items-center gap-1">
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -183,7 +176,7 @@
|
|||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{formatSize(recording.size)}
|
||||
--
|
||||
</Text>
|
||||
<Text variant="muted" class="flex items-center gap-1">
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -223,7 +216,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Audio Player -->
|
||||
<AudioPlayer src={recording.url} />
|
||||
<AudioPlayer src={recording.audio_url} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { Memo } from '$lib/types/memo.types';
|
||||
import { formatDuration, getMemooDuration, formatTimestamp } from '$lib/utils/formatters';
|
||||
import TagBadge from '$lib/components/TagBadge.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
memo: Memo;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
|
|
@ -50,7 +50,8 @@
|
|||
function navigatePhoto(direction: number) {
|
||||
if (!selectedPhoto) return;
|
||||
|
||||
const currentIndex = photos.findIndex((p) => p.id === selectedPhoto.id);
|
||||
const currentPhoto = selectedPhoto;
|
||||
const currentIndex = photos.findIndex((p) => p.id === currentPhoto.id);
|
||||
const newIndex = currentIndex + direction;
|
||||
|
||||
if (newIndex >= 0 && newIndex < photos.length) {
|
||||
|
|
@ -162,6 +163,7 @@
|
|||
|
||||
<!-- Lightbox Modal -->
|
||||
{#if showLightbox && selectedPhoto}
|
||||
{@const currentPhoto = selectedPhoto}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 bg-black/90 backdrop-blur-sm"
|
||||
|
|
@ -173,15 +175,15 @@
|
|||
<div class="relative max-h-full max-w-5xl" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Image -->
|
||||
<img
|
||||
src={selectedPhoto.url}
|
||||
alt={selectedPhoto.caption || 'Photo'}
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.caption || 'Photo'}
|
||||
class="max-h-[90vh] w-auto rounded-lg shadow-2xl"
|
||||
/>
|
||||
|
||||
<!-- Caption -->
|
||||
{#if selectedPhoto.caption}
|
||||
{#if currentPhoto.caption}
|
||||
<div class="mt-4 rounded-lg bg-menu p-4">
|
||||
<Text variant="body">{selectedPhoto.caption}</Text>
|
||||
<Text variant="body">{currentPhoto.caption}</Text>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
@ -206,7 +208,7 @@
|
|||
<button
|
||||
onclick={() => navigatePhoto(-1)}
|
||||
class="absolute top-1/2 left-4 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 disabled:opacity-50"
|
||||
disabled={photos.findIndex((p) => p.id === selectedPhoto.id) === 0}
|
||||
disabled={photos.findIndex((p) => p.id === currentPhoto.id) === 0}
|
||||
title="Previous (←)"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -222,7 +224,7 @@
|
|||
<button
|
||||
onclick={() => navigatePhoto(1)}
|
||||
class="absolute top-1/2 right-4 -translate-y-1/2 rounded-full bg-black/50 p-3 text-white transition-colors hover:bg-black/70 disabled:opacity-50"
|
||||
disabled={photos.findIndex((p) => p.id === selectedPhoto.id) === photos.length - 1}
|
||||
disabled={photos.findIndex((p) => p.id === currentPhoto.id) === photos.length - 1}
|
||||
title="Next (→)"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -239,7 +241,7 @@
|
|||
<!-- Photo Counter -->
|
||||
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-4 py-2">
|
||||
<Text variant="small" class="text-white">
|
||||
{photos.findIndex((p) => p.id === selectedPhoto.id) + 1} / {photos.length}
|
||||
{photos.findIndex((p) => p.id === currentPhoto.id) + 1} / {photos.length}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
|
|
@ -175,6 +175,8 @@
|
|||
|
||||
<style>
|
||||
.kbd {
|
||||
@apply rounded border border-theme bg-menu-hover px-1.5 py-0.5 font-mono;
|
||||
@apply rounded border px-1.5 py-0.5 font-mono text-xs;
|
||||
border-color: var(--color-border);
|
||||
background-color: var(--color-menu-bg-hover);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
|
||||
interface Blueprint {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Modal, Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
|
||||
interface Blueprint {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -205,6 +205,8 @@
|
|||
|
||||
<style>
|
||||
.kbd {
|
||||
@apply rounded border border-theme bg-menu-hover px-1.5 py-0.5 font-mono;
|
||||
@apply rounded border px-1.5 py-0.5 font-mono text-xs;
|
||||
border-color: var(--color-border);
|
||||
background-color: var(--color-menu-bg-hover);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal, Text } from '@manacore/shared-ui';
|
||||
import type { Memo } from '$lib/types/memo.types';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
import type { ShortcutGroup } from '$lib/utils/keyboardShortcuts';
|
||||
import { formatShortcut } from '$lib/utils/keyboardShortcuts';
|
||||
|
||||
|
|
@ -74,6 +74,9 @@
|
|||
|
||||
<style>
|
||||
.kbd {
|
||||
@apply inline-flex items-center gap-1 rounded border border-theme bg-menu-hover px-2 py-1 font-mono text-xs font-semibold text-theme;
|
||||
@apply inline-flex items-center gap-1 rounded border px-2 py-1 font-mono text-xs font-semibold;
|
||||
border-color: var(--color-border);
|
||||
background-color: var(--color-menu-bg-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
|
||||
interface Space {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
|
||||
interface Speaker {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
import type { Tag } from '$lib/types/memo.types';
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Modal } from '@manacore/shared-ui';
|
||||
|
||||
interface Language {
|
||||
code: string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import GlassCard from './GlassCard.svelte';
|
||||
import StatRow from './StatRow.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
mostViewedMemo: { id: string; title: string; viewCount: number } | null;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import GlassCard from './GlassCard.svelte';
|
||||
import StatRow from './StatRow.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
averageAudioDuration: number;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import GlassCard from './GlassCard.svelte';
|
||||
import StatRow from './StatRow.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
memoCount: number;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import GlassCard from './GlassCard.svelte';
|
||||
import StatRow from './StatRow.svelte';
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
todayStats: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import Text from '$lib/components/atoms/Text.svelte';
|
||||
import { Text } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -114,7 +114,8 @@ export class AudioStorageService {
|
|||
|
||||
if (memos) {
|
||||
totalDurationSeconds = memos.reduce((sum, memo) => {
|
||||
const duration = memo.source?.duration_seconds || memo.source?.duration || 0;
|
||||
const source = memo.source as { duration_seconds?: number; duration?: number } | null;
|
||||
const duration = source?.duration_seconds || source?.duration || 0;
|
||||
return sum + duration;
|
||||
}, 0);
|
||||
}
|
||||
|
|
@ -167,14 +168,22 @@ export class AudioStorageService {
|
|||
try {
|
||||
const supabase = await createAuthClient();
|
||||
|
||||
// Try to find memo by audio_url
|
||||
const { data: memo, error } = await supabase
|
||||
// Try to find memo by source containing the audio file name
|
||||
const { data: memos, error } = await supabase
|
||||
.from('memos')
|
||||
.select('id, title, audio_url')
|
||||
.ilike('audio_url', `%${audioFileName}%`)
|
||||
.single();
|
||||
.select('id, title, source')
|
||||
.not('source', 'is', null);
|
||||
|
||||
if (error || !memo) return null;
|
||||
if (error || !memos) return null;
|
||||
|
||||
// Find memo where source contains the audio file name
|
||||
const memo = memos.find((m) => {
|
||||
const source = m.source as { audioUrl?: string; audio_url?: string } | null;
|
||||
const audioUrl = source?.audioUrl || source?.audio_url || '';
|
||||
return audioUrl.includes(audioFileName);
|
||||
});
|
||||
|
||||
if (!memo) return null;
|
||||
|
||||
return {
|
||||
id: memo.id,
|
||||
|
|
|
|||
|
|
@ -96,11 +96,10 @@ export async function uploadAndProcessAudio({
|
|||
fileName,
|
||||
duration: Math.floor(duration),
|
||||
memoId,
|
||||
spaceId,
|
||||
spaceId: spaceId ?? undefined,
|
||||
title,
|
||||
blueprintId,
|
||||
blueprintId: blueprintId ?? undefined,
|
||||
recordingLanguages,
|
||||
enableDiarization,
|
||||
appToken
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -347,9 +347,9 @@ export class MemoService {
|
|||
.eq('id', memoId)
|
||||
.single();
|
||||
|
||||
const currentMetadata = memo?.metadata || {};
|
||||
const currentStats = currentMetadata.stats || {};
|
||||
const newViewCount = (currentStats.viewCount || 0) + 1;
|
||||
const currentMetadata = (memo?.metadata as Record<string, unknown>) || {};
|
||||
const currentStats = (currentMetadata.stats as Record<string, unknown>) || {};
|
||||
const newViewCount = ((currentStats.viewCount as number) || 0) + 1;
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('memos')
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { env } from '$lib/config/env';
|
||||
import { tokenManager } from './tokenManager';
|
||||
import { createAuthClient } from '$lib/supabaseClient';
|
||||
import type { Memory } from '$lib/types/memo.types';
|
||||
|
||||
export interface QuestionResult {
|
||||
success: boolean;
|
||||
|
|
@ -14,12 +15,7 @@ export interface QuestionResult {
|
|||
creditsConsumed?: number;
|
||||
}
|
||||
|
||||
export interface Memory {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
export type { Memory };
|
||||
|
||||
class QuestionService {
|
||||
/**
|
||||
|
|
@ -131,7 +127,7 @@ class QuestionService {
|
|||
|
||||
const { data, error } = await supabase
|
||||
.from('memories')
|
||||
.select('id, title, content, metadata')
|
||||
.select('id, memo_id, title, content, metadata, style, media, created_at, updated_at')
|
||||
.eq('memo_id', memoId)
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('created_at', { ascending: false });
|
||||
|
|
@ -141,7 +137,18 @@ class QuestionService {
|
|||
return [];
|
||||
}
|
||||
|
||||
return data || [];
|
||||
// Transform data to match Memory interface
|
||||
return (data || []).map(item => ({
|
||||
id: item.id,
|
||||
memo_id: item.memo_id,
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
metadata: item.metadata as Record<string, any> | null | undefined,
|
||||
style: item.style as Record<string, any> | null | undefined,
|
||||
media: item.media as Record<string, any> | null | undefined,
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading memories:', error);
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ function createRecordingStore() {
|
|||
audioBlob: blob,
|
||||
audioUrl: URL.createObjectURL(blob)
|
||||
})),
|
||||
setError: (error: string) => update((state) => ({ ...state, error })),
|
||||
setError: (error: string | null) => update((state) => ({ ...state, error })),
|
||||
reset: () =>
|
||||
set({
|
||||
status: 'idle',
|
||||
|
|
|
|||
|
|
@ -1,28 +1,611 @@
|
|||
/**
|
||||
* Database types for Supabase
|
||||
* This is a placeholder file - generate actual types using:
|
||||
* npx supabase gen types typescript --project-id YOUR_PROJECT_ID > src/lib/types/database.types.ts
|
||||
*/
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[]
|
||||
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
[key: string]: {
|
||||
Row: Record<string, unknown>;
|
||||
Insert: Record<string, unknown>;
|
||||
Update: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
Views: {
|
||||
[key: string]: {
|
||||
Row: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
Functions: {
|
||||
[key: string]: {
|
||||
Args: Record<string, unknown>;
|
||||
Returns: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
public: {
|
||||
Tables: {
|
||||
blueprints: {
|
||||
Row: {
|
||||
advice: Json | null
|
||||
category: Json | null
|
||||
created_at: string
|
||||
description: Json | null
|
||||
id: string
|
||||
is_public: boolean
|
||||
name: Json
|
||||
style: Json | null
|
||||
topic_id: string | null
|
||||
updated_at: string
|
||||
user_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
advice?: Json | null
|
||||
category?: Json | null
|
||||
created_at?: string
|
||||
description?: Json | null
|
||||
id?: string
|
||||
is_public?: boolean
|
||||
name?: Json
|
||||
style?: Json | null
|
||||
topic_id?: string | null
|
||||
updated_at?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
advice?: Json | null
|
||||
category?: Json | null
|
||||
created_at?: string
|
||||
description?: Json | null
|
||||
id?: string
|
||||
is_public?: boolean
|
||||
name?: Json
|
||||
style?: Json | null
|
||||
topic_id?: string | null
|
||||
updated_at?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
debug_logs: {
|
||||
Row: {
|
||||
created_at: string
|
||||
data: Json
|
||||
id: string
|
||||
type: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
data: Json
|
||||
id?: string
|
||||
type: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
data?: Json
|
||||
id?: string
|
||||
type?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
memo_spaces: {
|
||||
Row: {
|
||||
created_at: string
|
||||
memo_id: string
|
||||
space_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
memo_id: string
|
||||
space_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
memo_id?: string
|
||||
space_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "memo_spaces_memo_id_fkey"
|
||||
columns: ["memo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "memos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
memo_tags: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: string
|
||||
memo_id: string
|
||||
tag_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
memo_id: string
|
||||
tag_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
memo_id?: string
|
||||
tag_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "memo_tags_memo_id_fkey"
|
||||
columns: ["memo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "memos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "memo_tags_tag_id_fkey"
|
||||
columns: ["tag_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "tags"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
memories: {
|
||||
Row: {
|
||||
content: string | null
|
||||
created_at: string
|
||||
id: string
|
||||
media: Json | null
|
||||
memo_id: string
|
||||
metadata: Json | null
|
||||
style: Json | null
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
content?: string | null
|
||||
created_at?: string
|
||||
id?: string
|
||||
media?: Json | null
|
||||
memo_id: string
|
||||
metadata?: Json | null
|
||||
style?: Json | null
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
content?: string | null
|
||||
created_at?: string
|
||||
id?: string
|
||||
media?: Json | null
|
||||
memo_id?: string
|
||||
metadata?: Json | null
|
||||
style?: Json | null
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "memories_memo_id_fkey"
|
||||
columns: ["memo_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "memos"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
memos: {
|
||||
Row: {
|
||||
created_at: string
|
||||
id: string
|
||||
intro: string | null
|
||||
is_archived: boolean
|
||||
is_pinned: boolean
|
||||
is_public: boolean
|
||||
location: unknown | null
|
||||
metadata: Json | null
|
||||
shared_with_users: string[] | null
|
||||
source: Json
|
||||
style: Json | null
|
||||
title: string | null
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
intro?: string | null
|
||||
is_archived?: boolean
|
||||
is_pinned?: boolean
|
||||
is_public?: boolean
|
||||
location?: unknown | null
|
||||
metadata?: Json | null
|
||||
shared_with_users?: string[] | null
|
||||
source?: Json
|
||||
style?: Json | null
|
||||
title?: string | null
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
id?: string
|
||||
intro?: string | null
|
||||
is_archived?: boolean
|
||||
is_pinned?: boolean
|
||||
is_public?: boolean
|
||||
location?: unknown | null
|
||||
metadata?: Json | null
|
||||
shared_with_users?: string[] | null
|
||||
source?: Json
|
||||
style?: Json | null
|
||||
title?: string | null
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
prompt_blueprints: {
|
||||
Row: {
|
||||
blueprint_id: string
|
||||
created_at: string
|
||||
prompt_id: string
|
||||
}
|
||||
Insert: {
|
||||
blueprint_id: string
|
||||
created_at?: string
|
||||
prompt_id: string
|
||||
}
|
||||
Update: {
|
||||
blueprint_id?: string
|
||||
created_at?: string
|
||||
prompt_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "blueprint_prompts_blueprint_id_fkey"
|
||||
columns: ["blueprint_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "blueprints"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "blueprint_prompts_prompt_id_fkey"
|
||||
columns: ["prompt_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "prompts"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
prompts: {
|
||||
Row: {
|
||||
created_at: string
|
||||
description: Json | null
|
||||
id: string
|
||||
is_public: boolean
|
||||
memory_title: Json
|
||||
prompt_text: Json
|
||||
updated_at: string
|
||||
user_id: string | null
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
description?: Json | null
|
||||
id?: string
|
||||
is_public?: boolean
|
||||
memory_title?: Json
|
||||
prompt_text?: Json
|
||||
updated_at?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
description?: Json | null
|
||||
id?: string
|
||||
is_public?: boolean
|
||||
memory_title?: Json
|
||||
prompt_text?: Json
|
||||
updated_at?: string
|
||||
user_id?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
space_members: {
|
||||
Row: {
|
||||
added_at: string | null
|
||||
added_by: string | null
|
||||
id: string
|
||||
role: string
|
||||
space_id: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
added_at?: string | null
|
||||
added_by?: string | null
|
||||
id?: string
|
||||
role: string
|
||||
space_id: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
added_at?: string | null
|
||||
added_by?: string | null
|
||||
id?: string
|
||||
role?: string
|
||||
space_id?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
spatial_ref_sys: {
|
||||
Row: {
|
||||
auth_name: string | null
|
||||
auth_srid: number | null
|
||||
proj4text: string | null
|
||||
srid: number
|
||||
srtext: string | null
|
||||
}
|
||||
Insert: {
|
||||
auth_name?: string | null
|
||||
auth_srid?: number | null
|
||||
proj4text?: string | null
|
||||
srid: number
|
||||
srtext?: string | null
|
||||
}
|
||||
Update: {
|
||||
auth_name?: string | null
|
||||
auth_srid?: number | null
|
||||
proj4text?: string | null
|
||||
srid?: number
|
||||
srtext?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
tags: {
|
||||
Row: {
|
||||
created_at: string
|
||||
description: Json | null
|
||||
id: string
|
||||
is_pinned: boolean | null
|
||||
name: string
|
||||
sort_order: number | null
|
||||
style: Json | null
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
description?: Json | null
|
||||
id?: string
|
||||
is_pinned?: boolean | null
|
||||
name?: string
|
||||
sort_order?: number | null
|
||||
style?: Json | null
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
description?: Json | null
|
||||
id?: string
|
||||
is_pinned?: boolean | null
|
||||
name?: string
|
||||
sort_order?: number | null
|
||||
style?: Json | null
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
user_active_blueprints: {
|
||||
Row: {
|
||||
blueprint_id: string
|
||||
created_at: string
|
||||
id: string
|
||||
is_active: boolean
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
blueprint_id: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
blueprint_id?: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
is_active?: boolean
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "user_active_blueprints_blueprint_id_fkey"
|
||||
columns: ["blueprint_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "blueprints"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
geography_columns: {
|
||||
Row: {
|
||||
coord_dimension: number | null
|
||||
f_geography_column: unknown | null
|
||||
f_table_catalog: unknown | null
|
||||
f_table_name: unknown | null
|
||||
f_table_schema: unknown | null
|
||||
srid: number | null
|
||||
type: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
geometry_columns: {
|
||||
Row: {
|
||||
coord_dimension: number | null
|
||||
f_geometry_column: unknown | null
|
||||
f_table_catalog: string | null
|
||||
f_table_name: unknown | null
|
||||
f_table_schema: unknown | null
|
||||
srid: number | null
|
||||
type: string | null
|
||||
}
|
||||
Insert: {
|
||||
coord_dimension?: number | null
|
||||
f_geometry_column?: unknown | null
|
||||
f_table_catalog?: string | null
|
||||
f_table_name?: unknown | null
|
||||
f_table_schema?: unknown | null
|
||||
srid?: number | null
|
||||
type?: string | null
|
||||
}
|
||||
Update: {
|
||||
coord_dimension?: number | null
|
||||
f_geometry_column?: unknown | null
|
||||
f_table_catalog?: string | null
|
||||
f_table_name?: unknown | null
|
||||
f_table_schema?: unknown | null
|
||||
srid?: number | null
|
||||
type?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Functions: {
|
||||
[key: string]: any
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
}
|
||||
CompositeTypes: {
|
||||
geometry_dump: {
|
||||
path: number[] | null
|
||||
geom: unknown | null
|
||||
}
|
||||
http_header: {
|
||||
field: string | null
|
||||
value: string | null
|
||||
}
|
||||
http_request: {
|
||||
method: unknown | null
|
||||
uri: string | null
|
||||
headers: Database["public"]["CompositeTypes"]["http_header"][] | null
|
||||
content_type: string | null
|
||||
content: string | null
|
||||
}
|
||||
http_response: {
|
||||
status: number | null
|
||||
content_type: string | null
|
||||
headers: Database["public"]["CompositeTypes"]["http_header"][] | null
|
||||
content: string | null
|
||||
}
|
||||
valid_detail: {
|
||||
valid: boolean | null
|
||||
reason: string | null
|
||||
location: unknown | null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DefaultSchema = Database[Extract<keyof Database, "public">]
|
||||
|
||||
export type Tables<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
||||
DefaultSchema["Views"])
|
||||
? (DefaultSchema["Tables"] &
|
||||
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: never
|
||||
|
||||
export type TablesInsert<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: never
|
||||
|
||||
export type TablesUpdate<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema["Tables"]
|
||||
| { schema: keyof Database },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: never
|
||||
|
||||
export type Enums<
|
||||
DefaultSchemaEnumNameOrOptions extends
|
||||
| keyof DefaultSchema["Enums"]
|
||||
| { schema: keyof Database },
|
||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
||||
: never = never,
|
||||
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
|
||||
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||
: never
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof DefaultSchema["CompositeTypes"]
|
||||
| { schema: keyof Database },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof Database
|
||||
}
|
||||
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||
: never
|
||||
|
||||
export const Constants = {
|
||||
public: {
|
||||
Enums: {},
|
||||
},
|
||||
} as const
|
||||
|
|
@ -1,3 +1,18 @@
|
|||
export interface MemoPhoto {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
caption?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdditionalRecording {
|
||||
id: string;
|
||||
audio_url: string;
|
||||
duration_millis: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Memo {
|
||||
id: string;
|
||||
user_id: string;
|
||||
|
|
@ -8,6 +23,7 @@ export interface Memo {
|
|||
duration_millis: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
recorded_at?: string;
|
||||
space_id: string | null;
|
||||
blueprint_id: string | null;
|
||||
language: string | null;
|
||||
|
|
@ -35,6 +51,8 @@ export interface Memo {
|
|||
memories?: Memory[];
|
||||
tags?: Tag[];
|
||||
space?: Space;
|
||||
photos?: MemoPhoto[];
|
||||
additional_recordings?: AdditionalRecording[];
|
||||
}
|
||||
|
||||
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed';
|
||||
|
|
@ -64,6 +82,7 @@ export interface Tag {
|
|||
created_at: string;
|
||||
is_pinned?: boolean;
|
||||
sort_order?: number;
|
||||
usage?: number;
|
||||
}
|
||||
|
||||
export interface MemoTag {
|
||||
|
|
@ -80,12 +99,25 @@ export interface Space {
|
|||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Prompt {
|
||||
id: string;
|
||||
memory_title: string;
|
||||
prompt_text: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Blueprint {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
prompt: string;
|
||||
prompt?: string;
|
||||
user_id: string | null;
|
||||
is_public: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
category?: string | null;
|
||||
prompts?: Prompt[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
const promptsById: Record<string, any> = {};
|
||||
if (allPrompts) {
|
||||
for (const prompt of allPrompts) {
|
||||
for (const prompt of allPrompts as Prompt[]) {
|
||||
promptsById[prompt.id] = prompt;
|
||||
}
|
||||
}
|
||||
|
|
@ -151,10 +151,17 @@
|
|||
const blueprintsWithPrompts: Blueprint[] = [];
|
||||
for (const blueprint of blueprintsData || []) {
|
||||
const promptIds = promptLinksByBlueprintId[blueprint.id] || [];
|
||||
const promptsForBlueprint = promptIds.map((id) => promptsById[id]).filter(Boolean);
|
||||
const promptsForBlueprint = promptIds.map((id) => promptsById[id]).filter(Boolean) as Prompt[];
|
||||
|
||||
blueprintsWithPrompts.push({
|
||||
...blueprint,
|
||||
id: blueprint.id,
|
||||
name: blueprint.name as { de?: string; en?: string },
|
||||
description: blueprint.description as { de?: string; en?: string } | undefined,
|
||||
category: blueprint.category as unknown as Category | undefined,
|
||||
is_public: blueprint.is_public,
|
||||
created_at: blueprint.created_at,
|
||||
updated_at: blueprint.updated_at,
|
||||
user_id: blueprint.user_id || '',
|
||||
prompts: promptsForBlueprint
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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