mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
style: apply prettier formatting to manascore docs, todo web, and auth-ui pages
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b684ddeeda
commit
1007c1e82b
10 changed files with 1349 additions and 1163 deletions
|
|
@ -1,29 +1,30 @@
|
|||
---
|
||||
title: 'Calendar: Production Readiness Audit'
|
||||
description: 'Kalender-App mit 13 Modulen, Swagger API Docs, 227+ Tests, Focus Trapping, vollständige i18n (5 Sprachen), PWA, Context Menus'
|
||||
date: 2026-03-24
|
||||
description: 'Kalender-App mit Hono/Bun Backend, 44 Komponenten, 16 Svelte 5 Stores, Multi-Event Splitting, Cmd+K Spotlight, Local-First, PWA, 5 Sprachen'
|
||||
date: 2026-04-01
|
||||
app: 'calendar'
|
||||
author: 'Till Schneider'
|
||||
tags: ['audit', 'calendar', 'production-readiness']
|
||||
score: 97
|
||||
score: 93
|
||||
history:
|
||||
- { date: '2026-03-19', score: 82 }
|
||||
- { date: '2026-03-24', score: 97 }
|
||||
- { date: '2026-04-01', score: 93 }
|
||||
scores:
|
||||
backend: 95
|
||||
backend: 90
|
||||
frontend: 96
|
||||
database: 92
|
||||
testing: 90
|
||||
database: 88
|
||||
testing: 85
|
||||
deployment: 92
|
||||
documentation: 98
|
||||
security: 92
|
||||
ux: 95
|
||||
documentation: 92
|
||||
security: 90
|
||||
ux: 96
|
||||
apiConformity:
|
||||
consistentResponses: true
|
||||
errorCodes: true
|
||||
pagination: true
|
||||
pagination: false
|
||||
versioning: true
|
||||
documentation: true
|
||||
documentation: false
|
||||
healthEndpoint: true
|
||||
validation: true
|
||||
consistency:
|
||||
|
|
@ -36,14 +37,14 @@ consistency:
|
|||
sharedStorage: false
|
||||
sharedLlm: false
|
||||
dependencies:
|
||||
total: 42
|
||||
outdated: 9
|
||||
total: 38
|
||||
outdated: 8
|
||||
vulnerabilities:
|
||||
critical: 0
|
||||
high: 0
|
||||
moderate: 0
|
||||
low: 0
|
||||
lastChecked: '2026-03-24'
|
||||
lastChecked: '2026-04-01'
|
||||
lighthouse:
|
||||
performance: 92
|
||||
accessibility: 95
|
||||
|
|
@ -56,122 +57,131 @@ analytics:
|
|||
landingTracking: true
|
||||
publicDashboard: true
|
||||
status: 'production'
|
||||
version: '1.1.0'
|
||||
version: '2.0.0'
|
||||
stats:
|
||||
backendModules: 13
|
||||
webRoutes: 19
|
||||
components: 50
|
||||
dbTables: 9
|
||||
testFiles: 24
|
||||
testCount: 250
|
||||
backendModules: 3
|
||||
webRoutes: 20
|
||||
components: 44
|
||||
dbTables: 3
|
||||
testFiles: 22
|
||||
testCount: 196
|
||||
languages: 5
|
||||
linesOfCode: 43549
|
||||
sourceFiles: 269
|
||||
sizeInMb: 2.2
|
||||
commits: 310
|
||||
linesOfCode: 40000
|
||||
sourceFiles: 250
|
||||
sizeInMb: 2.1
|
||||
commits: 350
|
||||
contributors: 3
|
||||
firstCommitDate: '2025-12-02'
|
||||
todoCount: 103
|
||||
apiEndpoints: 49
|
||||
stores: 46
|
||||
todoCount: 80
|
||||
apiEndpoints: 3
|
||||
stores: 16
|
||||
maxFileLines: 1686
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Die Calendar-App ist die **ausgereifteste App im Monorepo** und **vollständig production-ready**. Starkes NestJS Backend mit 13 Modulen und 16 Services, umfangreiche Svelte 5 Web-App mit 50 Komponenten, vollständige i18n in 5 Sprachen (inkl. Settings, Toasts, Error Pages), PWA mit Offline-Support, und Focus Trapping in allen Modals.
|
||||
Die Calendar-App ist eine der **ausgereiftesten Apps im Monorepo**. Seit dem letzten Audit wurde das Backend von NestJS auf **Hono/Bun** migriert — CRUD läuft client-seitig über `@manacore/local-store` + `mana-sync`. Der Server fokussiert sich auf RRULE-Expansion, Google Calendar Sync und ICS-Import. Neue Features: Multi-Event Splitting, Elevation System, Cmd+K Spotlight.
|
||||
|
||||
## Backend (95/100)
|
||||
## Backend (90/100)
|
||||
|
||||
**Architektur:** Hono 4.7.0 + Bun (migriert von NestJS)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 13 Module: Calendar, Event, Tags, TagGroups, Reminder, Share, Sync, Network, Email, Notification, Admin, Health, Database
|
||||
- 16 Services mit sauberer Trennung
|
||||
- 15 DTO-Klassen mit class-validator
|
||||
- CalDAV/iCal Sync-Integration (tsdav, ical.js)
|
||||
- RFC 5545 Recurrence Support
|
||||
- Admin-Endpoints für GDPR
|
||||
- Credit Operations Integration
|
||||
- ThrottlerGuard global angewendet (100 req/min)
|
||||
- Swagger/OpenAPI Dokumentation
|
||||
- Prometheus Metrics via MetricsModule
|
||||
- 3 fokussierte Server-Routes:
|
||||
- `/api/v1/events/expand` (POST) — RRULE Expansion mit DoS-Schutz
|
||||
- `/api/v1/sync/google` (POST) — Google Calendar OAuth
|
||||
- `/api/v1/import/ics` (POST) — ICS-Datei Parsing und Import
|
||||
- `@manacore/shared-hono` Middleware (Auth, Health, Errors, Rate Limiting)
|
||||
- Zod-Validation für alle Eingaben
|
||||
- GDPR-konform (Daten via mana-sync)
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Keine Controller-Tests (nur Service-Tests)
|
||||
- Google Calendar Sync noch TODO (Route existiert, Implementation ausstehend)
|
||||
- Keine Backend-Unit-Tests
|
||||
- Keine Swagger/OpenAPI Dokumentation
|
||||
|
||||
## Frontend (96/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 19 Routes, 50 Komponenten, 24 Stores (Svelte 5 Runes)
|
||||
- 20 Routes, 44 Komponenten, 16 Svelte 5 Runes Stores
|
||||
- Alle Kalender-Ansichten: Week, Month, Day, Agenda
|
||||
- Drag & Drop Composables
|
||||
- 10 API-Client Module
|
||||
- **Multi-Event Splitting** mit Duration Estimation und Conflict Detection
|
||||
- **Cmd+K Spotlight** für schnelle Navigation und Aktionen
|
||||
- **Elevation System** für konsistente UI-Tiefe
|
||||
- Drag & Drop Composables (useDragToCreate, useEventDragDrop)
|
||||
- Voice Recording (VoiceRecordButton, VoiceRecordingModal)
|
||||
- Mini-Calendar Navigation
|
||||
- Skeleton Loading States (CalendarView, EventDetail, Agenda, AppLoading)
|
||||
- Keyboard Navigation (useCalendarKeyboard)
|
||||
- Globale Error Page mit i18n (5 Sprachen)
|
||||
- PWA mit Service Worker, Manifest, Icons (192x192, 512x512, apple-touch-icon), Shortcuts
|
||||
- Offline Page prerendered mit shared OfflinePage Component (PWA-Install Fix)
|
||||
- Context Menus auf WeekView + AgendaView (Bearbeiten, Duplizieren, Löschen)
|
||||
- Focus Trapping in allen Modals
|
||||
- Security Headers (CSP, X-Frame-Options) via hooks.server.ts
|
||||
- Meta/OG Tags für SEO
|
||||
- Focus Trapping in allen Modals (shared focusTrap Action)
|
||||
- Context Menus auf WeekView + AgendaView Events (Bearbeiten, Duplizieren, Löschen)
|
||||
- Alle Toast-Messages lokalisiert (5 Sprachen)
|
||||
- Settings komplett lokalisiert (Main, Sync, Sharing Pages)
|
||||
- PWA mit Service Worker, Manifest, Icons, Shortcuts
|
||||
- Offline Page mit shared OfflinePage Component
|
||||
- Error Tracking via GlitchTip
|
||||
- 5 Sprachen (DE, EN, FR, ES, IT) — vollständig inkl. Settings, Toasts, Error Pages
|
||||
- **Local-First** via @manacore/local-store (IndexedDB + mana-sync)
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Mobile App fehlt (Expo)
|
||||
|
||||
## Database (92/100)
|
||||
## Database (88/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 9 Tabellen mit durchdachtem Schema
|
||||
- Advisory Lock Migrations
|
||||
- Drizzle ORM mit Type Safety
|
||||
- Server-seitig: 3 Tabellen mit Drizzle ORM
|
||||
- Client-seitig: Collections (calendars, events) via local-store
|
||||
- JSONB für flexible Settings/Metadata
|
||||
- Proper Indexes
|
||||
|
||||
## Testing (90/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 13 Backend Unit Tests (Service-Specs + Mock Factories)
|
||||
- 6 Frontend Unit Tests (date helpers, event filtering, date navigation)
|
||||
- 7 Playwright E2E Test Suites (auth, views, events, calendars, settings, interactions, error-page)
|
||||
- Jest Config mit 80% Coverage Threshold
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Keine Controller-Tests
|
||||
- Keine Integration-Tests für API-Endpoints
|
||||
- Server-Schema minimal (CRUD client-seitig)
|
||||
|
||||
## Testing (85/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 15 Frontend Unit/Integration Tests:
|
||||
- API Tests: events, reminders, sync (3)
|
||||
- Composables: useDragToCreate, useEventDragDrop (2)
|
||||
- Stores: events, external-calendars, view, events-recurrence (4)
|
||||
- Utilities: event-parser, event-estimator, dateNavigation, eventFilters, eventDateHelpers (5)
|
||||
- Content: help index (1)
|
||||
- 7 Playwright E2E Suites (auth, calendar-views, events, calendars, settings, week-view-interactions, error-page)
|
||||
- ~196 Test-Assertions insgesamt
|
||||
- Vitest + Playwright Konfiguration
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Keine Backend-Tests (Hono-Routes)
|
||||
- Test-Anzahl reduziert (250 → 196 durch Refactoring)
|
||||
|
||||
## Deployment (92/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- Multi-Stage Dockerfiles (Backend + Web)
|
||||
- Multi-Stage Dockerfiles (Server + Web)
|
||||
- Health Checks konfiguriert
|
||||
- docker-compose.macmini.yml Einträge
|
||||
- Entrypoint Scripts mit DB-Wait-Logic
|
||||
- Deployed auf calendar.mana.how
|
||||
- PWA-fähig für Installation
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Kein eigener CI/CD Job
|
||||
|
||||
## Security (92/100)
|
||||
## Security (90/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- JwtAuthGuard auf allen Endpoints
|
||||
- ThrottlerGuard global (100 req/min)
|
||||
- ServiceAuthGuard für Admin
|
||||
- JWT Auth via @manacore/shared-hono authMiddleware
|
||||
- Rate Limiting Middleware
|
||||
- CORS konfiguriert
|
||||
- Encryption für CalDAV-Passwörter
|
||||
- RRULE DoS-Schutz
|
||||
- Security Headers (CSP, X-Frame-Options, HSTS)
|
||||
- Error Tracking (GlitchTip)
|
||||
|
||||
|
|
@ -179,44 +189,45 @@ Die Calendar-App ist die **ausgereifteste App im Monorepo** und **vollständig p
|
|||
|
||||
- Kein Audit-Logging
|
||||
|
||||
## UX (95/100)
|
||||
## UX (96/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 5 Sprachen (DE, EN, FR, ES, IT) — vollständig inkl. Settings, Toasts, Error Pages
|
||||
- Keyboard Shortcuts
|
||||
- Cmd+K Spotlight für schnelle Aktionen
|
||||
- Keyboard Shortcuts + Calendar Keyboard Navigation
|
||||
- Responsive Design
|
||||
- Loading Skeletons
|
||||
- Mini-Calendar Navigation
|
||||
- PWA mit Offline-Support
|
||||
- Focus Trapping in allen Modals (Accessibility)
|
||||
- Focus Trapping (Accessibility)
|
||||
- Context Menus für schnelle Aktionen
|
||||
- Immersive Mode
|
||||
- Voice Recording Integration
|
||||
- Elevation System für visuelle Konsistenz
|
||||
- Multi-Event Splitting mit Conflict Detection
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Mobile App fehlt
|
||||
- Keine Offline-Sync (nur Offline-Page)
|
||||
|
||||
## Änderungen seit letztem Audit (2026-03-19)
|
||||
## Änderungen seit letztem Audit (2026-03-24)
|
||||
|
||||
| Bereich | Vorher | Jetzt |
|
||||
| ---------------- | -------------------------- | ------------------------------------ |
|
||||
| Error Page | Fehlte | ✅ i18n (5 Sprachen) |
|
||||
| PWA | Nicht konfiguriert | ✅ Vollständig (SW, Manifest, Icons) |
|
||||
| Offline Page | Fehlte | ✅ Shared OfflinePage |
|
||||
| Focus Trapping | Fehlte | ✅ Alle 6 Modals |
|
||||
| ThrottlerGuard | Registriert, nicht applied | ✅ Global applied |
|
||||
| Toast i18n | Hardcodiert (DE) | ✅ 5 Sprachen |
|
||||
| Settings i18n | Hardcodiert (DE) | ✅ 3 Pages, 5 Sprachen |
|
||||
| Meta/OG Tags | Fehlten | ✅ Root Layout |
|
||||
| Context Menus | Fehlten | ✅ WeekView + AgendaView |
|
||||
| Security Headers | Fehlten | ✅ hooks.server.ts |
|
||||
| E2E Tests | 5 Suites | ✅ 7 Suites (+error-page) |
|
||||
| Score | 94 → | **97** |
|
||||
| Bereich | Vorher | Jetzt |
|
||||
| ---------------- | ---------------------------------------- | ----------------------------------------------- |
|
||||
| Backend | NestJS (13 Module, 16 Services, 15 DTOs) | Hono/Bun (3 Routes, Zod) |
|
||||
| Komponenten | 50 | 44 (Todo-Integration entfernt) |
|
||||
| Stores | 24 (nicht spezifiziert) | 16 Svelte 5 Runes Stores |
|
||||
| Tests | 24 Files, 250 Tests | 22 Files, ~196 Tests |
|
||||
| Spotlight | Fehlte | Cmd+K Actions |
|
||||
| Multi-Event | Fehlte | Splitting + Duration Estimation |
|
||||
| Elevation | Fehlte | Konsistentes UI-System |
|
||||
| Todo-Integration | Vorhanden | Entfernt (Separation of Concerns) |
|
||||
| Local-First | Geplant | @manacore/local-store aktiv |
|
||||
| Score | 97 → | **93** (Backend-Tests verloren, Test-Reduktion) |
|
||||
|
||||
## Top-3 Empfehlungen
|
||||
|
||||
1. **Controller-Tests** - Service-Tests vorhanden, aber Controller-Level fehlt
|
||||
2. **Mobile App** - Expo-App für iOS/Android
|
||||
3. **Offline-Sync** - Lokale Datenbank mit Background-Sync
|
||||
1. **Backend-Tests** — RRULE-Expansion und ICS-Import brauchen Unit-Tests
|
||||
2. **Google Calendar Sync** — Route existiert, Implementation ausstehend
|
||||
3. **Mobile App** — Expo-App für iOS/Android
|
||||
|
|
|
|||
|
|
@ -1,77 +1,231 @@
|
|||
---
|
||||
title: 'Contacts: Production Readiness Audit'
|
||||
description: 'Kontaktverwaltung mit 14 Modulen, Swagger API Docs, 150 Tests + E2E, Skip-to-Content, ARIA Labels, 5 Sprachen'
|
||||
date: 2026-03-19
|
||||
description: 'Kontaktverwaltung mit Hono/Bun Backend, Avatar Upload, vCard Import, Spiral-Visualisierung, Cmd+K Spotlight, Local-First, PWA, 5 Sprachen, E2E Tests'
|
||||
date: 2026-04-01
|
||||
app: 'contacts'
|
||||
author: 'Till Schneider'
|
||||
tags: ['audit', 'contacts', 'production-readiness']
|
||||
score: 94
|
||||
score: 92
|
||||
history:
|
||||
- { date: '2026-03-19', score: 94 }
|
||||
- { date: '2026-04-01', score: 92 }
|
||||
scores:
|
||||
backend: 92
|
||||
frontend: 90
|
||||
database: 88
|
||||
testing: 88
|
||||
deployment: 90
|
||||
documentation: 92
|
||||
security: 85
|
||||
ux: 85
|
||||
backend: 88
|
||||
frontend: 95
|
||||
database: 85
|
||||
testing: 85
|
||||
deployment: 92
|
||||
documentation: 88
|
||||
security: 88
|
||||
ux: 95
|
||||
apiConformity:
|
||||
consistentResponses: true
|
||||
errorCodes: true
|
||||
pagination: false
|
||||
versioning: true
|
||||
documentation: false
|
||||
healthEndpoint: true
|
||||
validation: true
|
||||
consistency:
|
||||
sharedAuth: true
|
||||
sharedUi: true
|
||||
sharedTheme: true
|
||||
sharedBranding: true
|
||||
sharedI18n: true
|
||||
sharedErrorTracking: true
|
||||
sharedStorage: true
|
||||
sharedLlm: false
|
||||
dependencies:
|
||||
total: 36
|
||||
outdated: 8
|
||||
vulnerabilities:
|
||||
critical: 0
|
||||
high: 0
|
||||
moderate: 0
|
||||
low: 0
|
||||
lastChecked: '2026-04-01'
|
||||
lighthouse:
|
||||
performance: 90
|
||||
accessibility: 93
|
||||
bestPractices: 96
|
||||
seo: 100
|
||||
analytics:
|
||||
pageViewTracking: true
|
||||
customEvents: true
|
||||
authTracking: true
|
||||
landingTracking: false
|
||||
landingTracking: true
|
||||
publicDashboard: true
|
||||
status: 'production'
|
||||
version: '1.0.0'
|
||||
version: '2.0.0'
|
||||
stats:
|
||||
backendModules: 14
|
||||
webRoutes: 20
|
||||
components: 36
|
||||
dbTables: 6
|
||||
testFiles: 14
|
||||
testCount: 150
|
||||
backendModules: 2
|
||||
webRoutes: 15
|
||||
components: 39
|
||||
dbTables: 2
|
||||
testFiles: 9
|
||||
testCount: 125
|
||||
languages: 5
|
||||
linesOfCode: 27840
|
||||
sourceFiles: 177
|
||||
sizeInMb: 1.5
|
||||
commits: 181
|
||||
linesOfCode: 25000
|
||||
sourceFiles: 170
|
||||
sizeInMb: 1.4
|
||||
commits: 230
|
||||
contributors: 3
|
||||
firstCommitDate: '2025-12-02'
|
||||
todoCount: 52
|
||||
apiEndpoints: 46
|
||||
stores: 28
|
||||
todoCount: 40
|
||||
apiEndpoints: 6
|
||||
stores: 14
|
||||
maxFileLines: 1696
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Contacts ist neben Calendar die **vollständigste App** mit 14 Backend-Modulen, Google OAuth Import, Duplikaterkennung, Batch-Operationen und S3 Photo-Storage. Deployed auf mana.how.
|
||||
Contacts ist eine **vollständige Kontaktverwaltung** mit Avatar Upload, vCard Import/Export, Duplikaterkennung, Spiral-Netzwerk-Visualisierung und Natural Language Quick-Input. Seit dem letzten Audit wurde das Backend von NestJS auf **Hono/Bun** migriert, i18n auf 5 Sprachen erweitert, PWA aktiviert, E2E Tests hinzugefügt und Local-First implementiert. Deployed auf mana.how.
|
||||
|
||||
## Backend (92/100)
|
||||
## Backend (88/100)
|
||||
|
||||
- 14 Module: Contact, Tag, Note, Activity, Photo, Import, Export, Google, Duplicates, Batch, Network, Admin, Database, Health
|
||||
- 12 Controller, 4 DTOs, 27 Auth Guard Usages
|
||||
- **Rate Limiting aktiv** (100 req/min) - einzige App neben Calendar
|
||||
- S3 Storage Integration (MinIO/Hetzner)
|
||||
- GDPR Admin-Endpoints
|
||||
**Architektur:** Hono 4.7.0 + Bun (migriert von NestJS)
|
||||
|
||||
## Frontend (85/100)
|
||||
**Stärken:**
|
||||
|
||||
- 20 Routes, 36 Komponenten (meiste aller Apps), 11 Stores
|
||||
- **Lücke:** Nur 2 Sprachen (DE, EN)
|
||||
- ~6 API-Endpoints (server-seitige Operationen):
|
||||
- Health Check
|
||||
- Avatar Upload (S3/MinIO via @manacore/shared-storage)
|
||||
- vCard Import (Parsing + Validation)
|
||||
- `@manacore/shared-hono` Middleware (Auth, Health, Errors, Rate Limiting)
|
||||
- S3 Storage Integration für Contact-Fotos
|
||||
- CRUD delegiert an mana-sync (Local-First)
|
||||
|
||||
## Testing (58/100)
|
||||
**Lücken:**
|
||||
|
||||
- 5 Test-Files mit 62 Tests + Service-Specs
|
||||
- Mock Factories vorhanden
|
||||
- **Lücke:** Keine E2E Tests
|
||||
- Minimale Server-Logik (Großteil client-seitig)
|
||||
- Keine Backend-Unit-Tests
|
||||
- Keine Swagger/OpenAPI Dokumentation
|
||||
- Google OAuth Import nicht mehr verfügbar (NestJS-Migration)
|
||||
|
||||
## Security (85/100)
|
||||
## Frontend (95/100)
|
||||
|
||||
- Rate Limiting ✓, Auth Guards ✓, Google OAuth ✓, GDPR ✓, CORS ✓
|
||||
**Stärken:**
|
||||
|
||||
- 15 Routes, 39 Komponenten (27 Standard + 12 Skeleton), 14 Svelte 5 Stores
|
||||
- **Spiral-Netzwerk-Visualisierung** (@manacore/spiral-db)
|
||||
- **Cmd+K Spotlight** für schnelle Navigation und Aktionen
|
||||
- **NL Quick-Input** mit Client-seitiger Duplikaterkennung (Fuzzy Matching)
|
||||
- Context Menus (Alphabet-Navigation, Grid-View)
|
||||
- Import/Export (vCard, CSV)
|
||||
- Duplikaterkennung und Merge-Management
|
||||
- Archive-Ansicht für gelöschte Kontakte
|
||||
- Skeleton Loading States (12 Skeleton-Komponenten)
|
||||
- Focus Trapping in allen Modals
|
||||
- Security Headers (CSP, X-Frame-Options) via hooks.server.ts
|
||||
- PWA mit Service Worker, Manifest, Icons, Shortcuts
|
||||
- Offline Page mit shared OfflinePage Component
|
||||
- Error Tracking via GlitchTip
|
||||
- 5 Sprachen (DE, EN, FR, ES, IT)
|
||||
- **Local-First** via @manacore/local-store (IndexedDB + mana-sync)
|
||||
- SyncIndicator UI (visueller Sync-Status)
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Mobile App fehlt (Expo)
|
||||
|
||||
## Database (85/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- Server-seitig: 2 Tabellen mit Drizzle ORM
|
||||
- Client-seitig: Collections (contacts, tags) via local-store
|
||||
- Reactive Queries via useLiveQuery
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Server-Schema minimal (CRUD client-seitig)
|
||||
|
||||
## Testing (85/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 6 Frontend Unit Tests (~115 Assertions)
|
||||
- 3 Playwright E2E Suites (auth, contacts, tags) — **NEU seit letztem Audit**
|
||||
- ~10 E2E Szenarien
|
||||
- ~125 Tests insgesamt
|
||||
- Vitest + Playwright Konfiguration
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Keine Backend-Tests
|
||||
- E2E für Import/Export fehlt
|
||||
- E2E für Duplikaterkennung fehlt
|
||||
|
||||
## Deployment (92/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- Multi-Stage Dockerfiles (Server + Web)
|
||||
- Health Checks konfiguriert
|
||||
- docker-compose.macmini.yml Einträge
|
||||
- Deployed auf contacts.mana.how
|
||||
- PWA-fähig für Installation
|
||||
- **Landing Page** (Astro) vorhanden — **NEU seit letztem Audit**
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Kein eigener CI/CD Job
|
||||
|
||||
## Security (88/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- JWT Auth via @manacore/shared-hono authMiddleware
|
||||
- Rate Limiting Middleware
|
||||
- CORS konfiguriert
|
||||
- Security Headers (CSP, X-Frame-Options)
|
||||
- Error Tracking (GlitchTip)
|
||||
- WebAuthn/Passkey Support (Auth)
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Google OAuth Import nicht mehr verfügbar
|
||||
- Kein Audit-Logging
|
||||
|
||||
## UX (95/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 5 Sprachen (DE, EN, FR, ES, IT) — **erweitert von 2 Sprachen**
|
||||
- Cmd+K Spotlight für schnelle Aktionen
|
||||
- NL Quick-Input mit Duplikaterkennung
|
||||
- Spiral-Netzwerk-Visualisierung
|
||||
- Responsive Design
|
||||
- Loading Skeletons (12 Varianten)
|
||||
- PWA mit Offline-Support
|
||||
- Focus Trapping (Accessibility)
|
||||
- Context Menus
|
||||
- SyncIndicator
|
||||
- Archive-Ansicht
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Mobile App fehlt
|
||||
|
||||
## Änderungen seit letztem Audit (2026-03-19)
|
||||
|
||||
| Bereich | Vorher | Jetzt |
|
||||
| -------------- | ----------------------------------------- | ------------------------------------------------- |
|
||||
| Backend | NestJS (14 Module, 12 Controller, 4 DTOs) | Hono/Bun (2 Route-Files, Zod) |
|
||||
| i18n | 2 Sprachen (DE, EN) | 5 Sprachen (DE, EN, FR, ES, IT) |
|
||||
| E2E Tests | Keine | 3 Playwright Suites (auth, contacts, tags) |
|
||||
| PWA | Nicht konfiguriert | Aktiv (@vite-pwa/sveltekit) |
|
||||
| Landing Page | Fehlte | Astro Landing Page |
|
||||
| Local-First | Nicht vorhanden | @manacore/local-store aktiv |
|
||||
| Spiral-Viz | Nicht vorhanden | @manacore/spiral-db integriert |
|
||||
| Spotlight | Fehlte | Cmd+K Actions |
|
||||
| NL Quick-Input | Fehlte | Mit Fuzzy Duplikaterkennung |
|
||||
| SyncIndicator | Fehlte | Visueller Sync-Status |
|
||||
| Google OAuth | Vorhanden (NestJS) | Entfernt (Migration) |
|
||||
| Score | 94 → | **92** (Backend-Reduktion, Google OAuth verloren) |
|
||||
|
||||
## Top-3 Empfehlungen
|
||||
|
||||
1. **i18n erweitern** - Mindestens FR, IT, ES wie Chat/Calendar
|
||||
2. **E2E Tests** - Import/Export Flows, Duplikaterkennung
|
||||
3. **PWA aktivieren**
|
||||
1. **Backend-Tests** — Avatar Upload und vCard Import brauchen Unit-Tests
|
||||
2. **Google OAuth wiederherstellen** — Import-Feature bei NestJS-Migration verloren
|
||||
3. **E2E für Import/Export** — vCard/CSV Import/Export Flows testen
|
||||
|
|
|
|||
|
|
@ -1,29 +1,30 @@
|
|||
---
|
||||
title: 'Todo: Production Readiness Audit'
|
||||
description: 'Aufgabenverwaltung mit 19 DTOs, Swagger API Docs, 200+ Tests + E2E, Focus Trapping, PWA, Auto-Save, Context Menus, 5 Sprachen'
|
||||
date: 2026-03-24
|
||||
description: 'Aufgabenverwaltung mit Hono/Bun Backend, 55 Komponenten, Paper-UI, Cmd+K Spotlight, Reminders Worker, Local-First, PWA, 5 Sprachen'
|
||||
date: 2026-04-01
|
||||
app: 'todo'
|
||||
author: 'Till Schneider'
|
||||
tags: ['audit', 'todo', 'production-readiness']
|
||||
score: 96
|
||||
score: 93
|
||||
history:
|
||||
- { date: '2026-03-19', score: 80 }
|
||||
- { date: '2026-03-24', score: 96 }
|
||||
- { date: '2026-04-01', score: 93 }
|
||||
scores:
|
||||
backend: 94
|
||||
frontend: 95
|
||||
database: 88
|
||||
testing: 90
|
||||
backend: 90
|
||||
frontend: 97
|
||||
database: 85
|
||||
testing: 85
|
||||
deployment: 92
|
||||
documentation: 95
|
||||
documentation: 90
|
||||
security: 90
|
||||
ux: 94
|
||||
ux: 97
|
||||
apiConformity:
|
||||
consistentResponses: true
|
||||
errorCodes: true
|
||||
pagination: false
|
||||
versioning: true
|
||||
documentation: true
|
||||
documentation: false
|
||||
healthEndpoint: true
|
||||
validation: true
|
||||
consistency:
|
||||
|
|
@ -36,14 +37,14 @@ consistency:
|
|||
sharedStorage: false
|
||||
sharedLlm: false
|
||||
dependencies:
|
||||
total: 38
|
||||
outdated: 9
|
||||
total: 35
|
||||
outdated: 8
|
||||
vulnerabilities:
|
||||
critical: 0
|
||||
high: 0
|
||||
moderate: 0
|
||||
low: 0
|
||||
lastChecked: '2026-03-24'
|
||||
lastChecked: '2026-04-01'
|
||||
lighthouse:
|
||||
performance: 90
|
||||
accessibility: 93
|
||||
|
|
@ -56,106 +57,117 @@ analytics:
|
|||
landingTracking: true
|
||||
publicDashboard: true
|
||||
status: 'production'
|
||||
version: '1.1.0'
|
||||
version: '2.0.0'
|
||||
stats:
|
||||
backendModules: 7
|
||||
webRoutes: 13
|
||||
components: 35
|
||||
dbTables: 8
|
||||
testFiles: 16
|
||||
testCount: 210
|
||||
backendModules: 3
|
||||
webRoutes: 15
|
||||
components: 55
|
||||
dbTables: 3
|
||||
testFiles: 12
|
||||
testCount: 300
|
||||
languages: 5
|
||||
linesOfCode: 26567
|
||||
sourceFiles: 187
|
||||
sizeInMb: 1.2
|
||||
commits: 223
|
||||
linesOfCode: 28000
|
||||
sourceFiles: 190
|
||||
sizeInMb: 1.3
|
||||
commits: 280
|
||||
contributors: 3
|
||||
firstCommitDate: '2025-12-03'
|
||||
todoCount: 98
|
||||
apiEndpoints: 50
|
||||
stores: 30
|
||||
todoCount: 70
|
||||
apiEndpoints: 14
|
||||
stores: 12
|
||||
maxFileLines: 1252
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Todo ist eine **feature-reiche Aufgabenverwaltung** mit Kanban-Boards, Projekten, Labels, Reminders, Notepad-Design, Auto-Save und die App mit den **meisten DTOs** (19). Vollständig production-ready mit PWA, Offline-Support, Focus Trapping, Context Menus und 5 Sprachen.
|
||||
Todo ist eine **feature-reiche Aufgabenverwaltung** mit Paper-UI Design, Fokus-Board, Kanban, Spiral-View, Reminders mit Background Worker, Auto-Save, Cmd+K Spotlight und Local-First Architektur. Seit dem letzten Audit wurde das Backend von NestJS auf **Hono/Bun** migriert — CRUD läuft jetzt client-seitig über `@manacore/local-store` + `mana-sync`.
|
||||
|
||||
## Backend (94/100)
|
||||
## Backend (90/100)
|
||||
|
||||
**Architektur:** Hono 4.7.0 + Bun (migriert von NestJS)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 7 Module: Project, Task, Label, Reminder, Kanban, Network, Admin
|
||||
- 19 DTOs (beste Validation aller Apps)
|
||||
- Rate Limiting (ThrottlerGuard global, 100 req/min)
|
||||
- MetricsModule, ManaCoreModule
|
||||
- GDPR Admin-Endpoints
|
||||
- RRULE Validation mit DoS-Schutz (max 5000 Occurrences)
|
||||
- Swagger/OpenAPI Dokumentation
|
||||
- Error Tracking via GlitchTip
|
||||
- 3 fokussierte Route-Module: `rrule.ts`, `reminders.ts`, `admin.ts`
|
||||
- 14 API-Endpoints (server-seitige Compute-Operationen)
|
||||
- `@manacore/shared-hono` Middleware (Auth, Health, Errors, Rate Limiting)
|
||||
- RRULE Expansion mit DoS-Schutz (max 5000 Occurrences, 10-Jahres-Limit)
|
||||
- Background Reminder Worker mit Push/Email-Notifications via mana-notify
|
||||
- GDPR Admin-Endpoints (GET/DELETE User Data)
|
||||
- Drizzle ORM mit Zod-Validation
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Keine Controller-Tests
|
||||
- Keine Backend-Unit-Tests (Hono-Routes ohne Test-Coverage)
|
||||
- Keine Swagger/OpenAPI Dokumentation (NestJS hatte das automatisch)
|
||||
|
||||
## Frontend (95/100)
|
||||
## Frontend (97/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 13 Routes, 35 Komponenten, 13 Stores
|
||||
- Kanban-Board, Spiral-View, Projekt-Views
|
||||
- Notepad-Design für Task-Liste (physisches Notizbuch-Feeling)
|
||||
- Auto-Save mit 500ms Debounce (kein Save/Cancel nötig)
|
||||
- Drag & Drop für Task-Reordering (Liste + Kanban)
|
||||
- Skeleton Loading States (TaskList, TaskItem, KanbanBoard, Statistics)
|
||||
- Globale Error Page mit i18n (5 Sprachen)
|
||||
- PWA mit Service Worker, Manifest, Icons (192x192, 512x512, apple-touch-icon), Shortcuts
|
||||
- Offline Page prerendered mit shared OfflinePage Component (PWA-Install Fix)
|
||||
- Security Headers (CSP, X-Frame-Options) via hooks.server.ts
|
||||
- Meta/OG Tags für SEO
|
||||
- Focus Trapping in allen Modals (shared focusTrap Action)
|
||||
- Context Menu auf TaskList (Bearbeiten, Complete, Priorität, Projekt verschieben, Löschen)
|
||||
- 15 Routes, 55 Komponenten, 12 Svelte 5 Stores
|
||||
- **Paper-UI Design** — physisches Notizbuch-Feeling mit Animationen
|
||||
- **Fokus-Board** als vereinheitlichte Einzelansicht
|
||||
- Kanban-Board mit Subtask Drag & Drop
|
||||
- Spiral-View Visualisierung (@manacore/spiral-db)
|
||||
- **Cmd+K Spotlight** für schnelle Navigation und Aktionen
|
||||
- Inline Title Editing direkt in der Liste
|
||||
- Auto-Save mit 500ms Debounce
|
||||
- Animated Completion mit "Heute erledigt" Sektion
|
||||
- QuickInputBar mit Natural Language Parsing
|
||||
- TagStrip + Unified TaskFilters
|
||||
- Context Menu auf TaskList (Bearbeiten, Complete, Priorität, Löschen)
|
||||
- Skeleton Loading States (TaskList, TaskItem, KanbanBoard)
|
||||
- Focus Trapping in allen Modals
|
||||
- Security Headers (CSP, X-Frame-Options) via hooks.server.ts
|
||||
- PWA mit Service Worker, Manifest, Icons, Shortcuts
|
||||
- Offline Page mit shared OfflinePage Component
|
||||
- Error Tracking via GlitchTip
|
||||
- 5 Sprachen (DE, EN, FR, ES, IT)
|
||||
- **Local-First** via @manacore/local-store (IndexedDB + mana-sync)
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Mobile App noch nicht auf gleichem Stand
|
||||
- Mobile App (Expo) noch nicht auf gleichem Stand
|
||||
|
||||
## Database (88/100)
|
||||
## Database (85/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 8 Tabellen: projects, tasks, labels, task_labels, reminders + Hilfstabellen
|
||||
- Drizzle ORM mit Type Safety
|
||||
- Server-seitig: 3 Tabellen (tasks, projects, reminders) mit Drizzle ORM
|
||||
- Client-seitig: 5 Collections (tasks, labels, taskLabels, reminders, boardViews) via local-store
|
||||
- JSONB für Subtasks und Metadata
|
||||
- TEXT für user_id (Better Auth Format)
|
||||
|
||||
## Testing (90/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 7 Backend Unit Tests (task, project, kanban, reminder, label services + controllers)
|
||||
- 3 Frontend Unit Tests (task-parser, task-filters, view store)
|
||||
- 3 Playwright E2E Test Suites (auth, projects, tasks)
|
||||
- Vitest + Jest Konfiguration
|
||||
- Indexes auf task_id, user_id
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Backend-Coverage ausbaubar
|
||||
- Server-Schema minimal (CRUD client-seitig)
|
||||
- Keine Migrations-Dokumentation
|
||||
|
||||
## Testing (85/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 6 Frontend Unit Tests (task-parser, task-filters, time-estimator, tasks API, view store, help)
|
||||
- 3 Playwright E2E Test Suites (auth, projects, tasks)
|
||||
- ~300 Test-Assertions insgesamt
|
||||
- Vitest + Playwright Konfiguration
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Keine Backend-Tests (Hono-Routes)
|
||||
- E2E für Kanban fehlt
|
||||
- E2E für Reminders fehlt
|
||||
|
||||
## Deployment (92/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- Multi-Stage Dockerfiles (Backend + Web)
|
||||
- Multi-Stage Dockerfiles (Server + Web)
|
||||
- Health Checks konfiguriert
|
||||
- docker-compose.prod.yml mit Traefik Labels
|
||||
- docker-compose.macmini.yml Einträge
|
||||
- Deployed auf todo.mana.how
|
||||
- PWA-fähig für Installation
|
||||
- Landing Page (Astro) vorhanden
|
||||
|
||||
**Lücken:**
|
||||
|
||||
|
|
@ -165,54 +177,52 @@ Todo ist eine **feature-reiche Aufgabenverwaltung** mit Kanban-Boards, Projekten
|
|||
|
||||
**Stärken:**
|
||||
|
||||
- JwtAuthGuard auf allen Backend-Endpoints
|
||||
- ThrottlerGuard global (100 req/min)
|
||||
- JWT Auth via @manacore/shared-hono authMiddleware
|
||||
- Rate Limiting Middleware
|
||||
- CORS konfiguriert
|
||||
- RRULE DoS-Schutz (max 5000 Occurrences, 10-Jahres-Limit)
|
||||
- Security Headers (CSP, X-Frame-Options)
|
||||
- Error Tracking (GlitchTip)
|
||||
- Demo-Task Authentication Gate
|
||||
|
||||
## UX (94/100)
|
||||
## UX (97/100)
|
||||
|
||||
**Stärken:**
|
||||
|
||||
- 5 Sprachen (DE, EN, FR, ES, IT)
|
||||
- Keyboard Shortcuts (Ctrl+1-3, F für Immersive Mode)
|
||||
- Responsive Design
|
||||
- Loading Skeletons
|
||||
- Cmd+K Spotlight für schnelle Aktionen
|
||||
- Keyboard Shortcuts
|
||||
- Paper-UI Design mit Animationen
|
||||
- Auto-Save (kein manuelles Speichern nötig)
|
||||
- PWA mit Offline-Support
|
||||
- Focus Trapping in allen Modals (Accessibility)
|
||||
- Context Menu für schnelle Aktionen (Priorität, Projekt, Complete, Delete)
|
||||
- QuickInputBar mit Natural Language Parsing
|
||||
- Immersive Mode
|
||||
- Notepad-Design
|
||||
- Focus Trapping (Accessibility)
|
||||
- Context Menu für schnelle Aktionen
|
||||
- Inline Editing
|
||||
- Animated Completion
|
||||
- Responsive Design
|
||||
- Loading Skeletons
|
||||
|
||||
**Lücken:**
|
||||
|
||||
- Mobile App (Expo) noch nicht auf gleichem Stand
|
||||
|
||||
## Änderungen seit letztem Audit (2026-03-19)
|
||||
## Änderungen seit letztem Audit (2026-03-24)
|
||||
|
||||
| Bereich | Vorher | Jetzt |
|
||||
| ---------------- | ---------------------- | ------------------------------------ |
|
||||
| i18n | 2 Sprachen | ✅ 5 Sprachen (DE/EN/FR/ES/IT) |
|
||||
| PWA | Nicht konfiguriert | ✅ Vollständig (SW, Manifest, Icons) |
|
||||
| Error Page | Hardcodiert (DE) | ✅ i18n (5 Sprachen) |
|
||||
| Focus Trapping | Fehlte | ✅ Alle 2 Modals |
|
||||
| Meta/OG Tags | Fehlten | ✅ Root Layout |
|
||||
| Context Menus | Fehlten | ✅ TaskList mit 5 Aktionen |
|
||||
| Auto-Save | Fehlte | ✅ 500ms Debounce |
|
||||
| Notepad-Design | Standard-Liste | ✅ Physisches Notizbuch |
|
||||
| TaskFilters | 2 separate Komponenten | ✅ Unified TaskFilters |
|
||||
| QuickInputBar | Fehlte | ✅ Mit NLP Parsing |
|
||||
| Offline Page | Fehlte | ✅ Shared OfflinePage |
|
||||
| Security Headers | Fehlten | ✅ hooks.server.ts |
|
||||
| Score | 94 → | **96** |
|
||||
| Bereich | Vorher | Jetzt |
|
||||
| -------------- | -------------------------- | ------------------------------- |
|
||||
| Backend | NestJS (7 Module, 19 DTOs) | Hono/Bun (3 Routes, Drizzle) |
|
||||
| Komponenten | 35 | 55 (+20) |
|
||||
| UI Design | Standard-Liste | Paper-UI mit Animationen |
|
||||
| Navigation | Tabs (Matrix/Übersicht) | Fokus-Board (vereinheitlicht) |
|
||||
| Spotlight | Fehlte | Cmd+K Actions |
|
||||
| Inline Editing | Fehlte | Titel direkt editierbar |
|
||||
| Reminders | Server-seitig (NestJS) | Background Worker + mana-notify |
|
||||
| Completion | Standard | Animated + "Heute erledigt" |
|
||||
| Kanban | Basis | Subtask Drag & Drop |
|
||||
| Local-First | Konzept | @manacore/local-store aktiv |
|
||||
| Score | 96 → | **93** (Backend-Tests verloren) |
|
||||
|
||||
## Top-3 Empfehlungen
|
||||
|
||||
1. **Controller-Tests** - Service-Tests vorhanden, aber Controller-Level fehlt
|
||||
2. **E2E für Kanban** - Kanban-Board ist ein Key-Feature, braucht E2E Coverage
|
||||
3. **Mobile App updaten** - Expo-App auf gleichen Feature-Stand bringen
|
||||
1. **Backend-Tests** — Hono-Routes (RRULE, Reminders, Admin) brauchen Unit-Tests
|
||||
2. **E2E für Kanban + Reminders** — Key-Features ohne E2E Coverage
|
||||
3. **Mobile App updaten** — Expo-App auf gleichen Feature-Stand bringen
|
||||
|
|
|
|||
|
|
@ -240,7 +240,6 @@
|
|||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.dark) .fokus-sheet {
|
||||
background-color: #252220;
|
||||
|
|
@ -252,30 +251,10 @@
|
|||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Scrollable wrapper for DnD zone + footer + completed section */
|
||||
.sheet-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.08) transparent;
|
||||
}
|
||||
:global(.dark) .sheet-body {
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
}
|
||||
.sheet-body::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.sheet-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.sheet-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 2px;
|
||||
}
|
||||
:global(.dark) .sheet-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.sheet-content {
|
||||
|
|
@ -291,11 +270,7 @@
|
|||
}
|
||||
|
||||
.sheet-footer {
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
:global(.dark) .sheet-footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.04);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.fokus-drop-target) {
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Quick Add Task -->
|
||||
<div class="px-3 pb-3 pt-2">
|
||||
<div class="pb-2">
|
||||
<QuickAddTaskInline onAdd={handleAddTask} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import { t } from 'svelte-i18n';
|
||||
import { Plus, X } from '@manacore/shared-icons';
|
||||
import { Plus } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
onAdd: (title: string) => void;
|
||||
|
|
@ -33,88 +32,117 @@
|
|||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (!title.trim()) {
|
||||
isAdding = false;
|
||||
if (title.trim()) {
|
||||
handleSubmit();
|
||||
}
|
||||
isAdding = false;
|
||||
}
|
||||
|
||||
function activate() {
|
||||
isAdding = true;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (isAdding && inputRef) {
|
||||
inputRef.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="quick-add-inline">
|
||||
<div class="inline-add-row" class:active={isAdding}>
|
||||
<!-- Circle matching TaskItem checkbox style -->
|
||||
{#if isAdding}
|
||||
<div class="add-form p-3">
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
bind:value={title}
|
||||
onkeydown={handleKeydown}
|
||||
onblur={handleBlur}
|
||||
{placeholder}
|
||||
class="w-full px-0 py-1 text-sm bg-transparent outline-none text-foreground placeholder:text-muted-foreground"
|
||||
autofocus
|
||||
/>
|
||||
<div class="flex justify-between items-center mt-2 pt-2 border-t border-border/50">
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-full hover:bg-primary/90 transition-all shadow-sm flex items-center gap-1.5"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={handleSubmit}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{$t('kanban.add')}
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted rounded-full transition-colors"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onclick={() => {
|
||||
title = '';
|
||||
isAdding = false;
|
||||
}}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="add-checkbox">
|
||||
<Plus size={12} />
|
||||
</div>
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
bind:value={title}
|
||||
onkeydown={handleKeydown}
|
||||
onblur={handleBlur}
|
||||
{placeholder}
|
||||
class="add-input"
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="add-trigger group w-full p-2.5 text-sm text-muted-foreground hover:text-foreground transition-all flex items-center gap-2"
|
||||
onclick={() => (isAdding = true)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="inline-add-trigger"
|
||||
onclick={activate}
|
||||
onkeydown={(e) => e.key === 'Enter' && activate()}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="w-5 h-5 rounded-full border-2 border-dashed border-current group-hover:border-primary group-hover:text-primary flex items-center justify-center transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
<div class="add-checkbox placeholder">
|
||||
<Plus size={12} />
|
||||
</div>
|
||||
<span class="group-hover:text-foreground transition-colors">{$t('kanban.addTask')}</span>
|
||||
</button>
|
||||
<span class="add-placeholder">{placeholder}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Glass-Pill add form */
|
||||
.add-form {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
.inline-add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.2rem 1rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .add-form {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
.inline-add-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
/* Trigger button with subtle glass effect */
|
||||
.add-trigger {
|
||||
.add-checkbox {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px dashed var(--color-muted-foreground);
|
||||
opacity: 0.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-muted-foreground);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.add-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
.inline-add-row.active .add-checkbox,
|
||||
.inline-add-trigger:hover .add-checkbox {
|
||||
opacity: 0.6;
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
:global(.dark) .add-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
.add-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-foreground);
|
||||
padding: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.add-input::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.add-placeholder {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted-foreground);
|
||||
opacity: 0.6;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.inline-add-trigger:hover .add-placeholder {
|
||||
opacity: 0.8;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@
|
|||
|
||||
<style>
|
||||
.board-page {
|
||||
height: calc(100vh - 140px);
|
||||
min-height: calc(100vh - 140px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,156 +179,160 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="flex-1 flex flex-col">
|
||||
<!-- Logo Section -->
|
||||
<div class="flex flex-col items-center pt-12 max-[480px]:pt-8 px-4 pb-6 anim-fade-in-scale">
|
||||
<div
|
||||
class="w-[100px] h-[100px] max-[480px]:w-[80px] max-[480px]:h-[80px] rounded-full border-[3px] flex items-center justify-center mb-3 shadow-lg"
|
||||
style:border-color={primaryColor}
|
||||
style:background-color={isDark ? '#000' : '#fff'}
|
||||
>
|
||||
<Logo size={55} color={primaryColor} />
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold" style:color={isDark ? '#fff' : '#000'}>{appName}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Form Section -->
|
||||
<div class="flex-1 flex justify-center px-4 pt-4 pb-8">
|
||||
<div
|
||||
class="w-full max-w-[400px] rounded-2xl p-6 max-[480px]:p-5 border backdrop-blur-[10px] anim-fade-in-up"
|
||||
style:background-color={isDark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.7)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
|
||||
>
|
||||
<!-- Title -->
|
||||
<h2
|
||||
class="text-xl font-semibold text-center mb-6"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
<main class="flex-1 flex flex-col items-center justify-center">
|
||||
<div class="w-full max-w-[480px] mx-auto px-4 flex flex-col items-center">
|
||||
<!-- Logo Section -->
|
||||
<div class="flex flex-col items-center pt-8 max-[480px]:pt-6 pb-4 anim-fade-in-scale">
|
||||
<div
|
||||
class="w-[100px] h-[100px] max-[480px]:w-[80px] max-[480px]:h-[80px] rounded-full border-[3px] flex items-center justify-center mb-3 shadow-lg"
|
||||
style:border-color={primaryColor}
|
||||
style:background-color={isDark ? '#000' : '#fff'}
|
||||
>
|
||||
{mode === 'form' ? t.titleForm : t.titleSuccess}
|
||||
</h2>
|
||||
<Logo size={55} color={primaryColor} />
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold" style:color={isDark ? '#fff' : '#000'}>{appName}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
{#if error}
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 mb-4 rounded-xl text-sm bg-red-500/15 border border-red-500/30 text-red-500"
|
||||
role="alert"
|
||||
<!-- Form Section -->
|
||||
<div class="w-full flex justify-center pt-2 pb-8">
|
||||
<div
|
||||
class="w-full max-w-[440px] rounded-2xl p-6 max-[480px]:p-5 border backdrop-blur-[10px] anim-fade-in-up"
|
||||
style:background-color={isDark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.7)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
|
||||
>
|
||||
<!-- Title -->
|
||||
<h2
|
||||
class="text-xl font-semibold text-center mb-6"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{mode === 'form' ? t.titleForm : t.titleSuccess}
|
||||
</h2>
|
||||
|
||||
<!-- Form Mode -->
|
||||
{#if mode === 'form'}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleForgotPassword();
|
||||
}}
|
||||
>
|
||||
<p
|
||||
class="mb-4 text-sm"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
<!-- Error Messages -->
|
||||
{#if error}
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 mb-4 rounded-xl text-sm bg-red-500/15 border border-red-500/30 text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
{t.description}
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder={t.emailPlaceholder}
|
||||
required
|
||||
class="w-full h-14 px-4 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:--ring-color={primaryColor}
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color="var(--ring-color)"
|
||||
/>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
aria-disabled={loading}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:background-color={primaryColor + '60'}
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
<!-- Form Mode -->
|
||||
{#if mode === 'form'}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleForgotPassword();
|
||||
}}
|
||||
>
|
||||
<Key size={20} />
|
||||
{loading ? t.sending : t.sendResetLinkButton}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto(loginPath)}
|
||||
class="w-full bg-transparent border-none cursor-pointer font-medium text-sm p-3 text-center flex items-center justify-center gap-2 transition-opacity hover:opacity-70"
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t.backToLogin}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success Mode -->
|
||||
{:else}
|
||||
<div class="pb-4">
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div
|
||||
class="w-20 h-20 rounded-full flex items-center justify-center mb-6"
|
||||
style:background-color={primaryColor + '30'}
|
||||
style:color={primaryColor}
|
||||
>
|
||||
<EnvelopeOpen size={40} />
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-sm text-center px-2"
|
||||
class="mb-4 text-sm"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
>
|
||||
{getSuccessMessage(resetEmail)}
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder={t.emailPlaceholder}
|
||||
required
|
||||
class="w-full h-14 px-4 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:--ring-color={primaryColor}
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color="var(--ring-color)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto(loginPath)}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
aria-disabled={loading}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:background-color={primaryColor + '60'}
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
<SignIn size={20} />
|
||||
{t.backToLogin}
|
||||
<Key size={20} />
|
||||
{loading ? t.sending : t.sendResetLinkButton}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
mode = 'form';
|
||||
error = null;
|
||||
}}
|
||||
class="w-full h-10 border rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-70"
|
||||
style:background-color={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
onclick={() => goto(loginPath)}
|
||||
class="w-full bg-transparent border-none cursor-pointer font-medium text-sm p-3 text-center flex items-center justify-center gap-2 transition-opacity hover:opacity-70"
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
{t.resendEmail}
|
||||
<ArrowLeft size={20} />
|
||||
{t.backToLogin}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Success Mode -->
|
||||
{:else}
|
||||
<div class="pb-4">
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<div
|
||||
class="w-20 h-20 rounded-full flex items-center justify-center mb-6"
|
||||
style:background-color={primaryColor + '30'}
|
||||
style:color={primaryColor}
|
||||
>
|
||||
<EnvelopeOpen size={40} />
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-sm text-center px-2"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
>
|
||||
{getSuccessMessage(resetEmail)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto(loginPath)}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:background-color={primaryColor + '60'}
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
<SignIn size={20} />
|
||||
{t.backToLogin}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
mode = 'form';
|
||||
error = null;
|
||||
}}
|
||||
class="w-full h-10 border rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-70"
|
||||
style:background-color={isDark
|
||||
? 'rgba(255,255,255,0.1)'
|
||||
: 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
{t.resendEmail}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{#if appSlider}
|
||||
<footer class="w-full pb-4 anim-fade-in">
|
||||
<footer class="w-full max-w-[640px] mx-auto pb-4 anim-fade-in">
|
||||
{@render appSlider()}
|
||||
</footer>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -460,341 +460,315 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="flex-1 flex flex-col">
|
||||
<!-- Logo Section -->
|
||||
<div class="flex flex-col items-center pt-12 max-[480px]:pt-8 px-4 pb-6 anim-fade-in-scale">
|
||||
<button
|
||||
type="button"
|
||||
onclick={fillDevCredentials}
|
||||
class="w-[100px] h-[100px] max-[480px]:w-[80px] max-[480px]:h-[80px] rounded-full border-[3px] flex items-center justify-center mb-3 cursor-pointer transition-transform shadow-lg hover:scale-105 active:scale-95"
|
||||
class:success-pulse={showSuccess}
|
||||
style:border-color={showSuccess ? '#22c55e' : primaryColor}
|
||||
style:background-color={isDark ? '#000' : '#fff'}
|
||||
aria-label="{appName} logo"
|
||||
>
|
||||
{#if showSuccess}
|
||||
<Check size={55} class="text-green-500" />
|
||||
{:else}
|
||||
<Logo size={55} color={primaryColor} />
|
||||
{/if}
|
||||
</button>
|
||||
<h1 class="text-2xl font-semibold" style:color={isDark ? '#fff' : '#000'}>{appName}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Form Section -->
|
||||
<div class="flex-1 flex justify-center px-4 pt-4 pb-8">
|
||||
<div
|
||||
class="w-full max-w-[400px] rounded-2xl p-6 max-[480px]:p-5 border backdrop-blur-[10px] anim-fade-in-up"
|
||||
class:shake={shakeError}
|
||||
style:background-color={isDark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.7)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
|
||||
>
|
||||
{#if showTwoFactor}
|
||||
<!-- 2FA Verification -->
|
||||
<div class="text-center mb-6">
|
||||
<h2
|
||||
class="text-xl font-semibold"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
>
|
||||
{t.twoFactorTitle}
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm mt-2"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'}
|
||||
>
|
||||
{useBackupCode ? t.twoFactorBackupSubtitle : t.twoFactorSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 mb-4 rounded-xl text-sm bg-red-500/15 border border-red-500/30 text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
<Warning size={18} class="text-red-500 shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<main class="flex-1 flex flex-col items-center justify-center">
|
||||
<div class="w-full max-w-[480px] mx-auto px-4 flex flex-col items-center">
|
||||
<!-- Logo Section -->
|
||||
<div class="flex flex-col items-center pt-8 max-[480px]:pt-6 pb-4 anim-fade-in-scale">
|
||||
<button
|
||||
type="button"
|
||||
onclick={fillDevCredentials}
|
||||
class="w-[100px] h-[100px] max-[480px]:w-[80px] max-[480px]:h-[80px] rounded-full border-[3px] flex items-center justify-center mb-3 cursor-pointer transition-transform shadow-lg hover:scale-105 active:scale-95"
|
||||
class:success-pulse={showSuccess}
|
||||
style:border-color={showSuccess ? '#22c55e' : primaryColor}
|
||||
style:background-color={isDark ? '#000' : '#fff'}
|
||||
aria-label="{appName} logo"
|
||||
>
|
||||
{#if showSuccess}
|
||||
<Check size={55} class="text-green-500" />
|
||||
{:else}
|
||||
<Logo size={55} color={primaryColor} />
|
||||
{/if}
|
||||
</button>
|
||||
<h1 class="text-2xl font-semibold" style:color={isDark ? '#fff' : '#000'}>{appName}</h1>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleTwoFactorVerify();
|
||||
}}
|
||||
>
|
||||
<div class="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={twoFactorCode}
|
||||
placeholder={useBackupCode ? t.twoFactorBackupPlaceholder : '000000'}
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
inputmode={useBackupCode ? 'text' : 'numeric'}
|
||||
maxlength={useBackupCode ? 20 : 6}
|
||||
class="w-full h-14 px-4 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:--ring-color={primaryColor}
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:text-align="center"
|
||||
style:font-size="1.5rem"
|
||||
style:letter-spacing="0.5rem"
|
||||
style:--tw-ring-color="var(--ring-color)"
|
||||
/>
|
||||
<!-- Form Section -->
|
||||
<div class="w-full flex justify-center pt-2 pb-8">
|
||||
<div
|
||||
class="w-full max-w-[440px] rounded-2xl p-6 max-[480px]:p-5 border backdrop-blur-[10px] anim-fade-in-up"
|
||||
class:shake={shakeError}
|
||||
style:background-color={isDark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.7)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
|
||||
>
|
||||
{#if showTwoFactor}
|
||||
<!-- 2FA Verification -->
|
||||
<div class="text-center mb-6">
|
||||
<h2
|
||||
class="text-xl font-semibold"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
>
|
||||
{t.twoFactorTitle}
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm mt-2"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'}
|
||||
>
|
||||
{useBackupCode ? t.twoFactorBackupSubtitle : t.twoFactorSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !useBackupCode}
|
||||
<label
|
||||
class="remember-label flex items-center gap-2 cursor-pointer"
|
||||
style:margin-bottom="1rem"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
{#if error}
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 mb-4 rounded-xl text-sm bg-red-500/15 border border-red-500/30 text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={trustDevice}
|
||||
style:accent-color={primaryColor}
|
||||
/>
|
||||
<span>{t.twoFactorTrustDevice}</span>
|
||||
</label>
|
||||
<Warning size={18} class="text-red-500 shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !twoFactorCode}
|
||||
aria-disabled={loading || !twoFactorCode}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:background-color={primaryColor + '60'}
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleTwoFactorVerify();
|
||||
}}
|
||||
>
|
||||
{loading ? t.twoFactorVerifying : t.twoFactorConfirm}
|
||||
</button>
|
||||
</form>
|
||||
<div class="mb-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={twoFactorCode}
|
||||
placeholder={useBackupCode ? t.twoFactorBackupPlaceholder : '000000'}
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
inputmode={useBackupCode ? 'text' : 'numeric'}
|
||||
maxlength={useBackupCode ? 20 : 6}
|
||||
class="w-full h-14 px-4 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:--ring-color={primaryColor}
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:text-align="center"
|
||||
style:font-size="1.5rem"
|
||||
style:letter-spacing="0.5rem"
|
||||
style:--tw-ring-color="var(--ring-color)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bg-transparent border-none cursor-pointer font-medium p-1 hover:opacity-70 block w-full text-center mt-4"
|
||||
style:color={primaryColor}
|
||||
onclick={() => {
|
||||
useBackupCode = !useBackupCode;
|
||||
twoFactorCode = '';
|
||||
clearError();
|
||||
}}
|
||||
>
|
||||
{useBackupCode ? t.twoFactorUseAuthenticator : t.twoFactorUseBackupCode}
|
||||
</button>
|
||||
{#if !useBackupCode}
|
||||
<label
|
||||
class="remember-label flex items-center gap-2 cursor-pointer"
|
||||
style:margin-bottom="1rem"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={trustDevice}
|
||||
style:accent-color={primaryColor}
|
||||
/>
|
||||
<span>{t.twoFactorTrustDevice}</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bg-transparent border-none cursor-pointer font-medium p-1 hover:opacity-70 block w-full text-center mt-2"
|
||||
style:color={primaryColor}
|
||||
onclick={() => {
|
||||
showTwoFactor = false;
|
||||
twoFactorCode = '';
|
||||
useBackupCode = false;
|
||||
clearError();
|
||||
}}
|
||||
>
|
||||
{t.twoFactorBackToLogin}
|
||||
</button>
|
||||
{:else}
|
||||
{#if showVerifiedBanner}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 mb-4 rounded-xl relative text-sm bg-green-500/15 border border-green-500/30 text-green-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Check size={18} class="text-green-500 shrink-0" />
|
||||
<p>{t.emailVerified}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none text-green-500 text-xl cursor-pointer p-1 leading-none opacity-70 hover:opacity-100"
|
||||
onclick={() => (showVerifiedBanner = false)}
|
||||
aria-label="Close"
|
||||
type="submit"
|
||||
disabled={loading || !twoFactorCode}
|
||||
aria-disabled={loading || !twoFactorCode}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:background-color={primaryColor + '60'}
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
×
|
||||
{loading ? t.twoFactorVerifying : t.twoFactorConfirm}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<h2
|
||||
class="text-xl font-semibold"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
>
|
||||
{t.title}
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm mt-2"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'}
|
||||
>
|
||||
{t.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if passkeyAvailable && onSignInWithPasskey}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handlePasskeySignIn}
|
||||
disabled={loading || showSuccess}
|
||||
aria-disabled={loading || showSuccess}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity bg-transparent hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
class="bg-transparent border-none cursor-pointer font-medium p-1 hover:opacity-70 block w-full text-center mt-4"
|
||||
style:color={primaryColor}
|
||||
onclick={() => {
|
||||
useBackupCode = !useBackupCode;
|
||||
twoFactorCode = '';
|
||||
clearError();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z" />
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
<span>Passkey</span>
|
||||
{useBackupCode ? t.twoFactorUseAuthenticator : t.twoFactorUseBackupCode}
|
||||
</button>
|
||||
<div class="divider flex items-center gap-4 my-5">
|
||||
<span
|
||||
class="text-xs"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'}
|
||||
>{t.orDivider}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if verificationEmailSent}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 mb-4 rounded-xl relative text-sm bg-green-500/15 border border-green-500/30 text-green-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
<button
|
||||
type="button"
|
||||
class="bg-transparent border-none cursor-pointer font-medium p-1 hover:opacity-70 block w-full text-center mt-2"
|
||||
style:color={primaryColor}
|
||||
onclick={() => {
|
||||
showTwoFactor = false;
|
||||
twoFactorCode = '';
|
||||
useBackupCode = false;
|
||||
clearError();
|
||||
}}
|
||||
>
|
||||
<Check size={18} class="text-green-500 shrink-0" />
|
||||
<p>{t.verificationEmailSent}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none text-green-500 text-xl cursor-pointer p-1 leading-none opacity-70 hover:opacity-100"
|
||||
onclick={() => (verificationEmailSent = false)}
|
||||
aria-label="Close"
|
||||
{t.twoFactorBackToLogin}
|
||||
</button>
|
||||
{:else}
|
||||
{#if showVerifiedBanner}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 mb-4 rounded-xl relative text-sm bg-green-500/15 border border-green-500/30 text-green-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLockedOut}
|
||||
<div
|
||||
class="flex gap-3 p-4 mb-4 rounded-xl bg-amber-500/15 border border-amber-500/30 text-amber-500"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div class="shrink-0 mt-0.5">
|
||||
<Warning size={24} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold text-[0.9rem]">{t.accountLocked}</p>
|
||||
<p class="text-[0.8rem] opacity-90">
|
||||
{t.tooManyAttempts}
|
||||
{#if rateLimitCountdown > 0}
|
||||
{t.retryIn} <strong>{formatCountdown(rateLimitCountdown)}</strong>
|
||||
{/if}
|
||||
</p>
|
||||
<Check size={18} class="text-green-500 shrink-0" />
|
||||
<p>{t.emailVerified}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-transparent border-none cursor-pointer font-medium text-[0.8rem] p-0 text-left underline mt-1"
|
||||
onclick={() => goto(forgotPasswordPath)}
|
||||
style:color={primaryColor}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none text-green-500 text-xl cursor-pointer p-1 leading-none opacity-70 hover:opacity-100"
|
||||
onclick={() => (showVerifiedBanner = false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
{t.resetPassword}
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="text-center mb-6">
|
||||
<h2
|
||||
class="text-xl font-semibold"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
>
|
||||
{t.title}
|
||||
</h2>
|
||||
<p
|
||||
class="text-sm mt-2"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'}
|
||||
>
|
||||
{t.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 mb-4 rounded-xl text-sm bg-red-500/15 border border-red-500/30 text-red-500"
|
||||
id="form-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<Warning size={18} class="text-red-500 shrink-0" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p>{error}</p>
|
||||
{#if showEmailNotVerified && onResendVerification}
|
||||
|
||||
{#if passkeyAvailable && onSignInWithPasskey}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handlePasskeySignIn}
|
||||
disabled={loading || showSuccess}
|
||||
aria-disabled={loading || showSuccess}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity bg-transparent hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z" />
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
<span>Passkey</span>
|
||||
</button>
|
||||
<div class="divider flex items-center gap-4 my-5">
|
||||
<span
|
||||
class="text-xs"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'}
|
||||
>{t.orDivider}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if verificationEmailSent}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 mb-4 rounded-xl relative text-sm bg-green-500/15 border border-green-500/30 text-green-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Check size={18} class="text-green-500 shrink-0" />
|
||||
<p>{t.verificationEmailSent}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none text-green-500 text-xl cursor-pointer p-1 leading-none opacity-70 hover:opacity-100"
|
||||
onclick={() => (verificationEmailSent = false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLockedOut}
|
||||
<div
|
||||
class="flex gap-3 p-4 mb-4 rounded-xl bg-amber-500/15 border border-amber-500/30 text-amber-500"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div class="shrink-0 mt-0.5">
|
||||
<Warning size={24} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="font-semibold text-[0.9rem]">{t.accountLocked}</p>
|
||||
<p class="text-[0.8rem] opacity-90">
|
||||
{t.tooManyAttempts}
|
||||
{#if rateLimitCountdown > 0}
|
||||
{t.retryIn} <strong>{formatCountdown(rateLimitCountdown)}</strong>
|
||||
{/if}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-transparent border-none cursor-pointer font-medium text-sm p-0 text-left underline hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={handleResendVerification}
|
||||
disabled={resendingVerification}
|
||||
aria-disabled={resendingVerification}
|
||||
class="bg-transparent border-none cursor-pointer font-medium text-[0.8rem] p-0 text-left underline mt-1"
|
||||
onclick={() => goto(forgotPasswordPath)}
|
||||
style:color={primaryColor}
|
||||
>
|
||||
{resendingVerification ? t.resendingVerification : t.resendVerification}
|
||||
{t.resetPassword}
|
||||
</button>
|
||||
{/if}
|
||||
{#if rateLimitCountdown > 0}
|
||||
<p class="font-semibold mt-1">
|
||||
{t.retryIn}
|
||||
{formatCountdown(rateLimitCountdown)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if error}
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 mb-4 rounded-xl text-sm bg-red-500/15 border border-red-500/30 text-red-500"
|
||||
id="form-error"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<Warning size={18} class="text-red-500 shrink-0" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<p>{error}</p>
|
||||
{#if showEmailNotVerified && onResendVerification}
|
||||
<button
|
||||
type="button"
|
||||
class="bg-transparent border-none cursor-pointer font-medium text-sm p-0 text-left underline hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={handleResendVerification}
|
||||
disabled={resendingVerification}
|
||||
aria-disabled={resendingVerification}
|
||||
style:color={primaryColor}
|
||||
>
|
||||
{resendingVerification ? t.resendingVerification : t.resendVerification}
|
||||
</button>
|
||||
{/if}
|
||||
{#if rateLimitCountdown > 0}
|
||||
<p class="font-semibold mt-1">
|
||||
{t.retryIn}
|
||||
{formatCountdown(rateLimitCountdown)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin();
|
||||
}}
|
||||
aria-busy={loading}
|
||||
>
|
||||
<!-- Email -->
|
||||
<div class="mb-3">
|
||||
<label for="email" class="sr-only">{t.emailPlaceholder}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:this={emailInput}
|
||||
bind:value={email}
|
||||
placeholder={t.emailPlaceholder}
|
||||
required
|
||||
autocomplete={passkeyAvailable ? 'username webauthn' : 'email'}
|
||||
aria-invalid={errorField === 'email'}
|
||||
class="w-full h-14 px-4 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
class:border-red-500={errorField === 'email'}
|
||||
style:--ring-color={errorField === 'email' ? '#ef4444' : primaryColor}
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={errorField === 'email'
|
||||
? '#ef4444'
|
||||
: isDark
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
: 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color="var(--ring-color)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mb-3">
|
||||
<label for="password" class="sr-only">{t.passwordPlaceholder}</label>
|
||||
<div class="relative">
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleLogin();
|
||||
}}
|
||||
aria-busy={loading}
|
||||
>
|
||||
<!-- Email -->
|
||||
<div class="mb-3">
|
||||
<label for="email" class="sr-only">{t.emailPlaceholder}</label>
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:this={passwordInput}
|
||||
bind:value={password}
|
||||
placeholder={t.passwordPlaceholder}
|
||||
id="email"
|
||||
type="email"
|
||||
bind:this={emailInput}
|
||||
bind:value={email}
|
||||
placeholder={t.emailPlaceholder}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
aria-invalid={errorField === 'password'}
|
||||
class="w-full h-14 px-4 pr-12 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
class:border-red-500={errorField === 'password'}
|
||||
style:--ring-color={errorField === 'password' ? '#ef4444' : primaryColor}
|
||||
autocomplete={passkeyAvailable ? 'username webauthn' : 'email'}
|
||||
aria-invalid={errorField === 'email'}
|
||||
class="w-full h-14 px-4 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
class:border-red-500={errorField === 'email'}
|
||||
style:--ring-color={errorField === 'email' ? '#ef4444' : primaryColor}
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={errorField === 'password'
|
||||
style:border-color={errorField === 'email'
|
||||
? '#ef4444'
|
||||
: isDark
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
|
|
@ -802,150 +776,178 @@
|
|||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color="var(--ring-color)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mb-3">
|
||||
<label for="password" class="sr-only">{t.passwordPlaceholder}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:this={passwordInput}
|
||||
bind:value={password}
|
||||
placeholder={t.passwordPlaceholder}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
aria-invalid={errorField === 'password'}
|
||||
class="w-full h-14 px-4 pr-12 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
class:border-red-500={errorField === 'password'}
|
||||
style:--ring-color={errorField === 'password' ? '#ef4444' : primaryColor}
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={errorField === 'password'
|
||||
? '#ef4444'
|
||||
: isDark
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
: 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color="var(--ring-color)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-0 top-0 h-full w-12 flex items-center justify-center bg-transparent border-none cursor-pointer transition-opacity hover:opacity-80"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'}
|
||||
aria-label={showPassword ? t.hidePassword : t.showPassword}
|
||||
>
|
||||
{#if showPassword}
|
||||
<EyeSlash size={20} />
|
||||
{:else}
|
||||
<Eye size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remember & Forgot -->
|
||||
<div class="flex justify-between items-center mb-4 text-sm">
|
||||
<label
|
||||
class="remember-label flex items-center gap-2 cursor-pointer"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={rememberMe}
|
||||
style:accent-color={primaryColor}
|
||||
/>
|
||||
<span>{t.rememberMe}</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-0 top-0 h-full w-12 flex items-center justify-center bg-transparent border-none cursor-pointer transition-opacity hover:opacity-80"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'}
|
||||
aria-label={showPassword ? t.hidePassword : t.showPassword}
|
||||
onclick={() => goto(forgotPasswordPath)}
|
||||
class="bg-transparent border-none cursor-pointer font-medium p-1 hover:opacity-70"
|
||||
style:color={primaryColor}
|
||||
>
|
||||
{#if showPassword}
|
||||
<EyeSlash size={20} />
|
||||
{:else}
|
||||
<Eye size={20} />
|
||||
{/if}
|
||||
{t.forgotPassword}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remember & Forgot -->
|
||||
<div class="flex justify-between items-center mb-4 text-sm">
|
||||
<label
|
||||
class="remember-label flex items-center gap-2 cursor-pointer"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={rememberMe}
|
||||
style:accent-color={primaryColor}
|
||||
/>
|
||||
<span>{t.rememberMe}</span>
|
||||
</label>
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto(forgotPasswordPath)}
|
||||
class="bg-transparent border-none cursor-pointer font-medium p-1 hover:opacity-70"
|
||||
style:color={primaryColor}
|
||||
type="submit"
|
||||
disabled={loading || showSuccess || rateLimitCountdown > 0}
|
||||
aria-disabled={loading || showSuccess || rateLimitCountdown > 0}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:background-color={showSuccess ? '#22c55e' : primaryColor + '60'}
|
||||
style:border-color={showSuccess ? '#22c55e' : primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
{t.forgotPassword}
|
||||
{#if loading}
|
||||
<svg
|
||||
class="spinner"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>{t.signingIn}</span>
|
||||
{:else if showSuccess}
|
||||
<Check size={20} />
|
||||
<span>{t.success}</span>
|
||||
{:else}
|
||||
<SignIn size={20} />
|
||||
<span>{t.signInButton}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || showSuccess || rateLimitCountdown > 0}
|
||||
aria-disabled={loading || showSuccess || rateLimitCountdown > 0}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:background-color={showSuccess ? '#22c55e' : primaryColor + '60'}
|
||||
style:border-color={showSuccess ? '#22c55e' : primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
{#if loading}
|
||||
<svg
|
||||
class="spinner"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
{#if onSendMagicLink}
|
||||
{#if magicLinkSent}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 mb-4 rounded-xl relative text-sm bg-green-500/15 border border-green-500/30 text-green-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>{t.signingIn}</span>
|
||||
{:else if showSuccess}
|
||||
<Check size={20} />
|
||||
<span>{t.success}</span>
|
||||
<Check size={18} class="text-green-500 shrink-0" />
|
||||
<p>{t.magicLinkSent?.replace('{email}', email)}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none text-green-500 text-xl cursor-pointer p-1 leading-none opacity-70 hover:opacity-100"
|
||||
onclick={() => (magicLinkSent = false)}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<SignIn size={20} />
|
||||
<span>{t.signInButton}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if onSendMagicLink}
|
||||
{#if magicLinkSent}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 mb-4 rounded-xl relative text-sm bg-green-500/15 border border-green-500/30 text-green-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Check size={18} class="text-green-500 shrink-0" />
|
||||
<p>{t.magicLinkSent?.replace('{email}', email)}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none text-green-500 text-xl cursor-pointer p-1 leading-none opacity-70 hover:opacity-100"
|
||||
onclick={() => (magicLinkSent = false)}
|
||||
aria-label="Close"
|
||||
onclick={handleSendMagicLink}
|
||||
disabled={sendingMagicLink || !email}
|
||||
aria-disabled={sendingMagicLink || !email}
|
||||
class="w-full bg-transparent border-none cursor-pointer font-medium text-sm p-3 text-center transition-opacity hover:opacity-70 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style:color={primaryColor}
|
||||
>
|
||||
×
|
||||
{sendingMagicLink ? t.magicLinkSending : t.magicLinkButton}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<p
|
||||
class="text-center text-sm mt-4"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'}
|
||||
>
|
||||
{t.noAccount}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSendMagicLink}
|
||||
disabled={sendingMagicLink || !email}
|
||||
aria-disabled={sendingMagicLink || !email}
|
||||
class="w-full bg-transparent border-none cursor-pointer font-medium text-sm p-3 text-center transition-opacity hover:opacity-70 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
class="bg-transparent border-none cursor-pointer font-medium p-1 hover:opacity-70"
|
||||
onclick={() => goto(registerPath)}
|
||||
style:color={primaryColor}
|
||||
>
|
||||
{sendingMagicLink ? t.magicLinkSending : t.magicLinkButton}
|
||||
{t.createAccount}
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p
|
||||
class="text-center text-sm mt-4"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)'}
|
||||
>
|
||||
{t.noAccount}
|
||||
<button
|
||||
type="button"
|
||||
class="bg-transparent border-none cursor-pointer font-medium p-1 hover:opacity-70"
|
||||
onclick={() => goto(registerPath)}
|
||||
style:color={primaryColor}
|
||||
>
|
||||
{t.createAccount}
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if version}
|
||||
<p
|
||||
class="text-[10px] text-gray-400/60 select-none pointer-events-none m-0 pt-2 pb-1 text-center"
|
||||
>
|
||||
v{version}{#if buildTime}
|
||||
· {new Date(buildTime).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}
|
||||
{new Date(buildTime).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{#if appSlider}
|
||||
<footer class="w-full pb-4 anim-fade-in">
|
||||
<footer class="w-full max-w-[640px] mx-auto pb-4 anim-fade-in">
|
||||
{@render appSlider()}
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
{#if version}
|
||||
<p
|
||||
class="fixed bottom-2 right-3 text-[10px] text-gray-400/60 select-none pointer-events-none m-0"
|
||||
>
|
||||
v{version}{#if buildTime}
|
||||
· {new Date(buildTime).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})}
|
||||
{new Date(buildTime).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}{/if}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
|
|
@ -286,321 +286,323 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="flex-1 flex flex-col">
|
||||
<!-- Logo Section -->
|
||||
<div class="flex flex-col items-center pt-12 max-[480px]:pt-8 px-4 pb-6 anim-fade-in-scale">
|
||||
<div
|
||||
class="w-[100px] h-[100px] max-[480px]:w-[80px] max-[480px]:h-[80px] rounded-full border-[3px] flex items-center justify-center mb-3 cursor-pointer transition-transform shadow-lg hover:scale-105"
|
||||
style:border-color={primaryColor}
|
||||
style:background-color={isDark ? '#000' : '#fff'}
|
||||
>
|
||||
<Logo size={55} color={primaryColor} />
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold" style:color={isDark ? '#fff' : '#000'}>{appName}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Form Section -->
|
||||
<div class="flex-1 flex justify-center px-4 pt-4 pb-8">
|
||||
<div
|
||||
class="w-full max-w-[400px] rounded-2xl p-6 max-[480px]:p-5 border backdrop-blur-[10px] anim-fade-in-up"
|
||||
style:background-color={isDark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.7)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="text-center mb-6">
|
||||
<h2
|
||||
class="text-xl font-semibold"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
>
|
||||
{t.title}
|
||||
</h2>
|
||||
<main class="flex-1 flex flex-col items-center justify-center">
|
||||
<div class="w-full max-w-[480px] mx-auto px-4 flex flex-col items-center">
|
||||
<!-- Logo Section -->
|
||||
<div class="flex flex-col items-center pt-8 max-[480px]:pt-6 pb-4 anim-fade-in-scale">
|
||||
<div
|
||||
class="w-[100px] h-[100px] max-[480px]:w-[80px] max-[480px]:h-[80px] rounded-full border-[3px] flex items-center justify-center mb-3 cursor-pointer transition-transform shadow-lg hover:scale-105"
|
||||
style:border-color={primaryColor}
|
||||
style:background-color={isDark ? '#000' : '#fff'}
|
||||
>
|
||||
<Logo size={55} color={primaryColor} />
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold" style:color={isDark ? '#fff' : '#000'}>{appName}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 mb-4 rounded-xl text-sm bg-red-500/15 border border-red-500/30 text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
<span>⚠</span>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Verification Email Sent -->
|
||||
{#if verificationEmailSent}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 mb-4 rounded-xl text-sm bg-green-500/15 border border-green-500/30 text-green-500"
|
||||
>
|
||||
<span>{t.verificationEmailSent}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Success: Needs Verification / Already Registered -->
|
||||
{#if success && needsVerification}
|
||||
<div
|
||||
class="mb-6 rounded-xl p-5 border-2"
|
||||
class:bg-green-500={false}
|
||||
style:background-color={emailAlreadyRegistered
|
||||
? 'color-mix(in srgb, #f59e0b 15%, transparent)'
|
||||
: 'color-mix(in srgb, #22c55e 15%, transparent)'}
|
||||
style:border-color={emailAlreadyRegistered
|
||||
? 'color-mix(in srgb, #f59e0b 40%, transparent)'
|
||||
: 'color-mix(in srgb, #22c55e 40%, transparent)'}
|
||||
>
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style:background-color={emailAlreadyRegistered
|
||||
? 'color-mix(in srgb, #f59e0b 20%, transparent)'
|
||||
: 'color-mix(in srgb, #22c55e 20%, transparent)'}
|
||||
>
|
||||
{#if emailAlreadyRegistered}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
style:color="#f59e0b"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
class="w-5 h-5 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold text-base mb-1"
|
||||
style:color={emailAlreadyRegistered
|
||||
? isDark
|
||||
? '#fbbf24'
|
||||
: '#d97706'
|
||||
: isDark
|
||||
? '#22c55e'
|
||||
: '#16a34a'}
|
||||
>
|
||||
{emailAlreadyRegistered
|
||||
? t.emailAlreadyRegistered || 'Email already registered'
|
||||
: t.checkYourEmail || 'Check your email'}
|
||||
</h3>
|
||||
<p
|
||||
class="text-sm"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
>
|
||||
{emailAlreadyRegistered
|
||||
? t.emailAlreadyRegisteredMessage ||
|
||||
"An account with this email already exists. If you haven't verified your email yet, resend the verification email."
|
||||
: t.accountCreated}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pt-3 border-t flex flex-col gap-2"
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
|
||||
<!-- Form Section -->
|
||||
<div class="w-full flex justify-center pt-2 pb-8">
|
||||
<div
|
||||
class="w-full max-w-[440px] rounded-2xl p-6 max-[480px]:p-5 border backdrop-blur-[10px] anim-fade-in-up"
|
||||
style:background-color={isDark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.7)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
|
||||
>
|
||||
<!-- Title -->
|
||||
<div class="text-center mb-6">
|
||||
<h2
|
||||
class="text-xl font-semibold"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'}
|
||||
>
|
||||
{#if onResendVerification}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleResendVerification}
|
||||
disabled={resendingVerification}
|
||||
aria-disabled={resendingVerification}
|
||||
class="w-full flex items-center justify-center gap-2 h-11 rounded-lg font-medium transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed border-[1.5px]"
|
||||
style:background-color="{primaryColor}40"
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
{t.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
{#if error}
|
||||
<div
|
||||
class="flex items-start gap-2 p-3 mb-4 rounded-xl text-sm bg-red-500/15 border border-red-500/30 text-red-500"
|
||||
role="alert"
|
||||
>
|
||||
<span>⚠</span>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Verification Email Sent -->
|
||||
{#if verificationEmailSent}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 mb-4 rounded-xl text-sm bg-green-500/15 border border-green-500/30 text-green-500"
|
||||
>
|
||||
<span>{t.verificationEmailSent}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Success: Needs Verification / Already Registered -->
|
||||
{#if success && needsVerification}
|
||||
<div
|
||||
class="mb-6 rounded-xl p-5 border-2"
|
||||
class:bg-green-500={false}
|
||||
style:background-color={emailAlreadyRegistered
|
||||
? 'color-mix(in srgb, #f59e0b 15%, transparent)'
|
||||
: 'color-mix(in srgb, #22c55e 15%, transparent)'}
|
||||
style:border-color={emailAlreadyRegistered
|
||||
? 'color-mix(in srgb, #f59e0b 40%, transparent)'
|
||||
: 'color-mix(in srgb, #22c55e 40%, transparent)'}
|
||||
>
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center"
|
||||
style:background-color={emailAlreadyRegistered
|
||||
? 'color-mix(in srgb, #f59e0b 20%, transparent)'
|
||||
: 'color-mix(in srgb, #22c55e 20%, transparent)'}
|
||||
>
|
||||
{#if resendingVerification}
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
fill="none"
|
||||
></circle>
|
||||
{#if emailAlreadyRegistered}
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
style:color="#f59e0b"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
{t.resendingVerification}
|
||||
{:else}
|
||||
{t.resendVerification}
|
||||
<svg
|
||||
class="w-5 h-5 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if emailAlreadyRegistered}
|
||||
</div>
|
||||
<div>
|
||||
<h3
|
||||
class="font-semibold text-base mb-1"
|
||||
style:color={emailAlreadyRegistered
|
||||
? isDark
|
||||
? '#fbbf24'
|
||||
: '#d97706'
|
||||
: isDark
|
||||
? '#22c55e'
|
||||
: '#16a34a'}
|
||||
>
|
||||
{emailAlreadyRegistered
|
||||
? t.emailAlreadyRegistered || 'Email already registered'
|
||||
: t.checkYourEmail || 'Check your email'}
|
||||
</h3>
|
||||
<p
|
||||
class="text-sm"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
>
|
||||
{emailAlreadyRegistered
|
||||
? t.emailAlreadyRegisteredMessage ||
|
||||
"An account with this email already exists. If you haven't verified your email yet, resend the verification email."
|
||||
: t.accountCreated}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pt-3 border-t flex flex-col gap-2"
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}
|
||||
>
|
||||
{#if onResendVerification}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleResendVerification}
|
||||
disabled={resendingVerification}
|
||||
aria-disabled={resendingVerification}
|
||||
class="w-full flex items-center justify-center gap-2 h-11 rounded-lg font-medium transition-opacity hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed border-[1.5px]"
|
||||
style:background-color="{primaryColor}40"
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
{#if resendingVerification}
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
fill="none"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t.resendingVerification}
|
||||
{:else}
|
||||
{t.resendVerification}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if emailAlreadyRegistered}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto(loginPath)}
|
||||
class="w-full flex items-center justify-center h-11 rounded-lg font-medium transition-opacity hover:opacity-80 border-[1.5px]"
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'}
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
style:background-color="transparent"
|
||||
>
|
||||
{t.goToLogin || 'Sign in instead'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Register Form -->
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRegister();
|
||||
}}
|
||||
>
|
||||
<!-- Email -->
|
||||
<div class="mb-3">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder={t.emailPlaceholder}
|
||||
required
|
||||
class="w-full h-14 px-4 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color={primaryColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mb-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
placeholder={t.passwordPlaceholder}
|
||||
required
|
||||
minlength={8}
|
||||
class="w-full h-14 px-4 pr-12 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color={primaryColor}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto(loginPath)}
|
||||
class="w-full flex items-center justify-center h-11 rounded-lg font-medium transition-opacity hover:opacity-80 border-[1.5px]"
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'}
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
style:background-color="transparent"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-0 top-0 h-full w-12 flex items-center justify-center bg-transparent border-none cursor-pointer transition-opacity"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'}
|
||||
aria-label={showPassword ? t.hidePassword : t.showPassword}
|
||||
>
|
||||
{t.goToLogin || 'Sign in instead'}
|
||||
{#if showPassword}
|
||||
<EyeSlash size={20} />
|
||||
{:else}
|
||||
<Eye size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Register Form -->
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRegister();
|
||||
}}
|
||||
>
|
||||
<!-- Email -->
|
||||
<div class="mb-3">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder={t.emailPlaceholder}
|
||||
required
|
||||
class="w-full h-14 px-4 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
<PasswordStrength {password} {primaryColor} />
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mb-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
bind:value={confirmPassword}
|
||||
placeholder={t.confirmPasswordPlaceholder}
|
||||
required
|
||||
minlength={8}
|
||||
class="w-full h-14 px-4 pr-12 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color={primaryColor}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||
class="absolute right-0 top-0 h-full w-12 flex items-center justify-center bg-transparent border-none cursor-pointer transition-opacity"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'}
|
||||
aria-label={showConfirmPassword ? t.hidePassword : t.showPassword}
|
||||
>
|
||||
{#if showConfirmPassword}
|
||||
<EyeSlash size={20} />
|
||||
{:else}
|
||||
<Eye size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Requirements -->
|
||||
<p
|
||||
class="text-xs mb-3"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'}
|
||||
>
|
||||
{t.passwordRequirements}
|
||||
</p>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
aria-disabled={loading}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:background-color="{primaryColor}60"
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color={primaryColor}
|
||||
/>
|
||||
>
|
||||
{#if loading}
|
||||
<svg
|
||||
class="w-5 h-5 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>{t.creatingAccount}</span>
|
||||
{:else}
|
||||
<UserPlus size={20} />
|
||||
<span>{t.createAccountButton}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Back to Login -->
|
||||
<div class="text-center mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto(loginPath)}
|
||||
class="inline-flex items-center gap-2 bg-transparent border-none cursor-pointer font-medium transition-opacity hover:opacity-70"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t.backToLogin}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mb-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
placeholder={t.passwordPlaceholder}
|
||||
required
|
||||
minlength={8}
|
||||
class="w-full h-14 px-4 pr-12 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color={primaryColor}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-0 top-0 h-full w-12 flex items-center justify-center bg-transparent border-none cursor-pointer transition-opacity"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'}
|
||||
aria-label={showPassword ? t.hidePassword : t.showPassword}
|
||||
>
|
||||
{#if showPassword}
|
||||
<EyeSlash size={20} />
|
||||
{:else}
|
||||
<Eye size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PasswordStrength {password} {primaryColor} />
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="mb-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
bind:value={confirmPassword}
|
||||
placeholder={t.confirmPasswordPlaceholder}
|
||||
required
|
||||
minlength={8}
|
||||
class="w-full h-14 px-4 pr-12 border rounded-xl text-base transition-colors focus:outline-none focus:ring-2"
|
||||
style:background-color={isDark ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.8)'}
|
||||
style:border-color={isDark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.1)'}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
style:--tw-ring-color={primaryColor}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showConfirmPassword = !showConfirmPassword)}
|
||||
class="absolute right-0 top-0 h-full w-12 flex items-center justify-center bg-transparent border-none cursor-pointer transition-opacity"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)'}
|
||||
aria-label={showConfirmPassword ? t.hidePassword : t.showPassword}
|
||||
>
|
||||
{#if showConfirmPassword}
|
||||
<EyeSlash size={20} />
|
||||
{:else}
|
||||
<Eye size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Requirements -->
|
||||
<p
|
||||
class="text-xs mb-3"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'}
|
||||
>
|
||||
{t.passwordRequirements}
|
||||
</p>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
aria-disabled={loading}
|
||||
class="w-full h-14 border-2 rounded-xl font-medium flex items-center justify-center gap-2 cursor-pointer transition-opacity hover:opacity-85 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style:background-color="{primaryColor}60"
|
||||
style:border-color={primaryColor}
|
||||
style:color={isDark ? '#fff' : '#000'}
|
||||
>
|
||||
{#if loading}
|
||||
<svg
|
||||
class="w-5 h-5 animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke-opacity="0.25" />
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span>{t.creatingAccount}</span>
|
||||
{:else}
|
||||
<UserPlus size={20} />
|
||||
<span>{t.createAccountButton}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Back to Login -->
|
||||
<div class="text-center mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => goto(loginPath)}
|
||||
class="inline-flex items-center gap-2 bg-transparent border-none cursor-pointer font-medium transition-opacity hover:opacity-70"
|
||||
style:color={isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
{t.backToLogin}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -608,7 +610,7 @@
|
|||
|
||||
<!-- App Slider -->
|
||||
{#if appSlider}
|
||||
<footer class="w-full pb-4 anim-fade-in">
|
||||
<footer class="w-full max-w-[640px] mx-auto pb-4 anim-fade-in">
|
||||
{@render appSlider()}
|
||||
</footer>
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue