diff --git a/DEV-NOTES.md b/DEV-NOTES.md new file mode 100644 index 0000000..a216720 --- /dev/null +++ b/DEV-NOTES.md @@ -0,0 +1,119 @@ +# Dev Notes – Stand 25.02.2026 + +## node_modules-Konflikt: Mac ↔ Docker ↔ Cursor (gelöst) + +### Ausgangslage + +Die Quasar App läuft in drei Kontexten parallel: + +| Kontext | Pfad | Plattform | +|---|---|---| +| **`quasar.app` Docker-Service** | Docker Volume (isoliert) | Linux ARM64 | +| **Cursor IDE Terminal** | `/workspace/frontend/node_modules` | Linux ARM64 | +| **Mac Terminal** | `~/Sites/thats-me.local/frontend/node_modules` | macOS ARM64 | + +Der `frontend/`-Ordner ist überall **derselbe** (gemountetes Host-Filesystem). Rollup und andere native Pakete benötigen plattformspezifische Binaries – ein `npm install` auf einer Seite überschrieb bisher die Binaries der anderen. + +--- + +### Fix 1 – Docker Volume für `quasar.app` (docker-compose.yml) + +Der `quasar.app`-Service nutzt ein eigenes benanntes Docker-Volume für `node_modules`. Dadurch bleibt sein Linux-`node_modules` vollständig vom Host isoliert: + +```yaml +# quasar.app Service: +volumes: + - './frontend:/app' + - 'quasar-node-modules:/app/node_modules' # überschattet Host-Ordner + +# volumes-Block: +volumes: + quasar-node-modules: + driver: local +``` + +Der `quasar.app`-Container führt beim Start automatisch `npm install && npm run dev` aus (in sein isoliertes Volume). Er ist damit **völlig unabhängig** vom Host-`node_modules`. + +--- + +### Fix 2 – Rollup-Binaries als `optionalDependencies` (package.json) + +Das Cursor-Terminal und der Mac teilen denselben `node_modules`-Ordner. Um den gegenseitigen Konflikt abzumildern, wurden alle relevanten Rollup-Plattform-Binaries explizit als `optionalDependencies` eingetragen: + +```json +"optionalDependencies": { + "@rollup/rollup-darwin-arm64": "^4.0.0", + "@rollup/rollup-linux-arm64-gnu": "^4.0.0", + "@rollup/rollup-linux-x64-gnu": "^4.0.0" +} +``` + +npm installiert davon nur die zur aktuellen Plattform passenden, schlägt aber nie fehl wenn eine fehlt. + +--- + +### Fix 3 – `npm run dev` repariert sich selbst (package.json) + +Das `dev`-Script führt vor dem Start automatisch `npm install` aus: + +```json +"dev": "npm install && quasar dev" +``` + +Damit werden nach einem Mac-`npm install` die Linux-Binaries im Cursor-Terminal beim nächsten `npm run dev` automatisch nachgezogen. + +--- + +### Wichtig: Cursor Terminal ≠ `quasar.app`-Container + +Das Cursor IDE Terminal hat **keinen** Zugriff auf das isolierte Docker-Volume des `quasar.app`-Services. Für den Web-Dev-Server gilt: + +- **Empfohlen:** `docker-compose up -d quasar.app` → App läuft unter `app.thats-me.test` (isoliertes Volume, kein Konflikt) +- **Alternativ:** `npm run dev` direkt im Cursor-Terminal (teilt node_modules mit dem Mac, aber Fix 3 gleicht das aus) + +--- + +### Workflow: iOS-Build auf dem Mac + +Nach einem `npm install` im Linux-Kontext (Cursor oder Docker) müssen auf dem Mac die node_modules neu installiert werden: + +```bash +cd ~/Sites/thats-me.local/frontend +rm -rf node_modules && npm install +npx quasar build -m capacitor -T ios --ide +``` + +--- + +## Allgemeiner Projekt-Stand + +- **Backend:** Laravel 12, API-Routes unter `api.thats-me.test`, OAuth2 via Passport +- **Frontend:** Quasar v2 SPA, offline-first mit Dexie.js (IndexedDB) +- **Mobile:** Capacitor für iOS/Android (in `frontend/src-capacitor/`) +- **Neue Komponenten:** + - `AppSettingsModal.vue`, `ModalCard.vue`, `UserMenu.vue`, `ZoomControl.vue` + - `frontend/src/composables/`, `frontend/src/db/`, `frontend/src/services/` + - Backend API-Controller + Requests + Resources in `backend/app/Http/` + - Event-Model + Migrations + Factory + +--- + +## Wichtige Domains (lokale Entwicklung) + +| Domain | Zweck | +|---|---| +| `app.thats-me.test` | Quasar Frontend (Quasar Dev-Server) | +| `api.thats-me.test` | Laravel REST API | +| `portal.thats-me.test` | Admin Panel | +| `thats-me.test` | Landingpage | +| `assets.thats-me.test` | Vite HMR (Laravel Backend Assets) | + +## Services & Ports + +| Service | Port | Purpose | +|---|---|---| +| `quasar.app` | 9000 | Quasar Frontend Dev-Server | +| `mysql` | 33070 | MySQL Datenbank | +| `mailpit` | 8028 | E-Mail Testing Dashboard | +| `redis` | 6383 | Cache & Queue | +| `laravel.test` | 5180 | Vite Dev-Server (HMR) | diff --git a/backend/app/Http/Controllers/Api/EventController.php b/backend/app/Http/Controllers/Api/EventController.php new file mode 100644 index 0000000..9ea816f --- /dev/null +++ b/backend/app/Http/Controllers/Api/EventController.php @@ -0,0 +1,199 @@ +user()->events()->orderBy('date'); + + // Delta sync: only events updated since a given timestamp + if ($request->has('since')) { + $since = $request->date('since'); + $query->where('updated_at', '>', $since); + } + + // Cursor-based pagination (default 50, max 200) + $limit = min((int) $request->input('limit', 50), 200); + + return EventResource::collection( + $query->cursorPaginate($limit) + ); + } + + /** + * POST /api/events + */ + public function store(StoreEventRequest $request): JsonResponse + { + $event = $request->user()->events()->create([ + 'client_id' => $request->validated('id'), + 'title' => $request->validated('title'), + 'date' => $request->validated('date'), + 'emotion' => $request->validated('emotion'), + 'custom_color' => $request->validated('customColor'), + 'gradient_preset' => $request->validated('gradientPreset'), + 'image' => $request->validated('image'), + 'note' => $request->validated('note'), + ]); + + return (new EventResource($event)) + ->response() + ->setStatusCode(201); + } + + /** + * GET /api/events/{clientId} + */ + public function show(Request $request, string $clientId): EventResource + { + $event = $request->user()->events() + ->where('client_id', $clientId) + ->firstOrFail(); + + return new EventResource($event); + } + + /** + * PUT /api/events/{clientId} + */ + public function update(UpdateEventRequest $request, string $clientId): EventResource + { + $event = $request->user()->events() + ->where('client_id', $clientId) + ->firstOrFail(); + + $data = []; + $validated = $request->validated(); + + if (isset($validated['title'])) { + $data['title'] = $validated['title']; + } + if (isset($validated['date'])) { + $data['date'] = $validated['date']; + } + if (isset($validated['emotion'])) { + $data['emotion'] = $validated['emotion']; + } + if (array_key_exists('customColor', $validated)) { + $data['custom_color'] = $validated['customColor']; + } + if (array_key_exists('gradientPreset', $validated)) { + $data['gradient_preset'] = $validated['gradientPreset']; + } + if (array_key_exists('image', $validated)) { + $data['image'] = $validated['image']; + } + if (array_key_exists('note', $validated)) { + $data['note'] = $validated['note']; + } + + $event->update($data); + + return new EventResource($event->fresh()); + } + + /** + * DELETE /api/events/{clientId} + */ + public function destroy(Request $request, string $clientId): JsonResponse + { + $event = $request->user()->events() + ->where('client_id', $clientId) + ->firstOrFail(); + + $event->delete(); + + return response()->json(null, 204); + } + + /** + * POST /api/events/sync + * Batch sync: process multiple mutations in one request. + */ + public function sync(Request $request): JsonResponse + { + $request->validate([ + 'mutations' => ['required', 'array', 'max:100'], + 'mutations.*.action' => ['required', 'in:create,update,delete'], + 'mutations.*.eventId' => ['required', 'uuid'], + 'mutations.*.payload' => ['nullable', 'array'], + ]); + + $user = $request->user(); + $results = []; + + foreach ($request->input('mutations') as $mutation) { + $action = $mutation['action']; + $clientId = $mutation['eventId']; + $payload = $mutation['payload'] ?? []; + + try { + if ($action === 'create') { + $event = $user->events()->where('client_id', $clientId)->first(); + if (! $event) { + $event = $user->events()->create([ + 'client_id' => $clientId, + 'title' => $payload['title'] ?? 'Untitled', + 'date' => $payload['date'] ?? now()->format('Y-m-d'), + 'emotion' => $payload['emotion'] ?? 0, + 'custom_color' => $payload['customColor'] ?? null, + 'gradient_preset' => $payload['gradientPreset'] ?? null, + 'image' => $payload['image'] ?? null, + 'note' => $payload['note'] ?? null, + ]); + } + $results[] = ['eventId' => $clientId, 'status' => 'ok']; + } elseif ($action === 'update') { + $event = $user->events()->where('client_id', $clientId)->first(); + if ($event) { + $data = []; + if (isset($payload['title'])) { + $data['title'] = $payload['title']; + } + if (isset($payload['date'])) { + $data['date'] = $payload['date']; + } + if (isset($payload['emotion'])) { + $data['emotion'] = $payload['emotion']; + } + if (array_key_exists('customColor', $payload)) { + $data['custom_color'] = $payload['customColor']; + } + if (array_key_exists('gradientPreset', $payload)) { + $data['gradient_preset'] = $payload['gradientPreset']; + } + if (array_key_exists('image', $payload)) { + $data['image'] = $payload['image']; + } + if (array_key_exists('note', $payload)) { + $data['note'] = $payload['note']; + } + $event->update($data); + } + $results[] = ['eventId' => $clientId, 'status' => 'ok']; + } elseif ($action === 'delete') { + $user->events()->where('client_id', $clientId)->delete(); + $results[] = ['eventId' => $clientId, 'status' => 'ok']; + } + } catch (\Throwable $e) { + $results[] = ['eventId' => $clientId, 'status' => 'error', 'message' => $e->getMessage()]; + } + } + + return response()->json(['results' => $results]); + } +} diff --git a/backend/app/Http/Requests/StoreEventRequest.php b/backend/app/Http/Requests/StoreEventRequest.php new file mode 100644 index 0000000..2556d13 --- /dev/null +++ b/backend/app/Http/Requests/StoreEventRequest.php @@ -0,0 +1,27 @@ + ['required', 'uuid', 'unique:events,client_id'], + 'title' => ['required', 'string', 'max:255'], + 'date' => ['required', 'date_format:Y-m-d'], + 'emotion' => ['required', 'numeric', 'min:-1', 'max:1'], + 'customColor' => ['nullable', 'string', 'max:20'], + 'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'], + 'image' => ['nullable', 'string', 'max:500'], + 'note' => ['nullable', 'string', 'max:5000'], + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateEventRequest.php b/backend/app/Http/Requests/UpdateEventRequest.php new file mode 100644 index 0000000..da31962 --- /dev/null +++ b/backend/app/Http/Requests/UpdateEventRequest.php @@ -0,0 +1,26 @@ + ['sometimes', 'required', 'string', 'max:255'], + 'date' => ['sometimes', 'required', 'date_format:Y-m-d'], + 'emotion' => ['sometimes', 'required', 'numeric', 'min:-1', 'max:1'], + 'customColor' => ['nullable', 'string', 'max:20'], + 'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'], + 'image' => ['nullable', 'string', 'max:500'], + 'note' => ['nullable', 'string', 'max:5000'], + ]; + } +} diff --git a/backend/app/Http/Resources/EventResource.php b/backend/app/Http/Resources/EventResource.php new file mode 100644 index 0000000..a547f62 --- /dev/null +++ b/backend/app/Http/Resources/EventResource.php @@ -0,0 +1,26 @@ + $this->client_id, + 'title' => $this->title, + 'date' => $this->date->format('Y-m-d'), + 'emotion' => (float) $this->emotion, + 'customColor' => $this->custom_color, + 'gradientPreset' => $this->gradient_preset, + 'image' => $this->image, + 'note' => $this->note ?? '', + 'syncStatus' => 'synced', + 'createdAt' => $this->created_at->getTimestampMs(), + 'updatedAt' => $this->updated_at->getTimestampMs(), + ]; + } +} diff --git a/backend/app/Models/Event.php b/backend/app/Models/Event.php new file mode 100644 index 0000000..a4463f7 --- /dev/null +++ b/backend/app/Models/Event.php @@ -0,0 +1,38 @@ + */ + use HasFactory; + + protected $fillable = [ + 'client_id', + 'title', + 'date', + 'emotion', + 'custom_color', + 'gradient_preset', + 'image', + 'note', + ]; + + protected function casts(): array + { + return [ + 'date' => 'date:Y-m-d', + 'emotion' => 'decimal:3', + 'gradient_preset' => 'integer', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 2d9bad8..abec552 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -4,14 +4,16 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; +use Laravel\Passport\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. @@ -50,6 +52,11 @@ class User extends Authenticatable /** * Get the user's initials */ + public function events(): HasMany + { + return $this->hasMany(Event::class); + } + public function initials(): string { return Str::of($this->name) diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index 7b162da..d654276 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/backend/composer.json b/backend/composer.json index cfe0ce9..cb428cb 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", + "laravel/passport": "^13.0", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.0", "livewire/volt": "^1.7.0" diff --git a/backend/composer.lock b/backend/composer.lock index be6f4be..227afe2 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d7d431a0966cef8d76de033164bdaf6d", + "content-hash": "0200c9a860f66ce072c8de2892b42e7c", "packages": [ { "name": "brick/math", @@ -135,6 +135,73 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "defuse/php-encryption", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/defuse/php-encryption.git", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "paragonie/random_compat": ">= 2", + "php": ">=5.6.0" + }, + "require-dev": { + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" + }, + "bin": [ + "bin/generate-defuse-key" + ], + "type": "library", + "autoload": { + "psr-4": { + "Defuse\\Crypto\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Hornby", + "email": "taylor@defuse.ca", + "homepage": "https://defuse.ca/" + }, + { + "name": "Scott Arciszewski", + "email": "info@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "Secure PHP Encryption Library", + "keywords": [ + "aes", + "authenticated encryption", + "cipher", + "crypto", + "cryptography", + "encrypt", + "encryption", + "openssl", + "security", + "symmetric key cryptography" + ], + "support": { + "issues": "https://github.com/defuse/php-encryption/issues", + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" + }, + "time": "2023-06-19T06:10:36+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -508,6 +575,69 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.0.2", + "source": { + "type": "git", + "url": "https://github.com/firebase/php-jwt.git", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "reference": "5645b43af647b6947daac1d0f659dd1fbe8d3b65", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/firebase/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/firebase/php-jwt/issues", + "source": "https://github.com/firebase/php-jwt/tree/v7.0.2" + }, + "time": "2025-12-16T22:17:28+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1274,6 +1404,81 @@ }, "time": "2026-02-17T17:07:04+00:00" }, + { + "name": "laravel/passport", + "version": "v13.5.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/passport.git", + "reference": "d5bff1040c764da679d96edbed1705b542b33c3d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passport/zipball/d5bff1040c764da679d96edbed1705b542b33c3d", + "reference": "d5bff1040c764da679d96edbed1705b542b33c3d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "firebase/php-jwt": "^6.4|^7.0", + "illuminate/auth": "^11.35|^12.0|^13.0", + "illuminate/console": "^11.35|^12.0|^13.0", + "illuminate/container": "^11.35|^12.0|^13.0", + "illuminate/contracts": "^11.35|^12.0|^13.0", + "illuminate/cookie": "^11.35|^12.0|^13.0", + "illuminate/database": "^11.35|^12.0|^13.0", + "illuminate/encryption": "^11.35|^12.0|^13.0", + "illuminate/http": "^11.35|^12.0|^13.0", + "illuminate/support": "^11.35|^12.0|^13.0", + "league/oauth2-server": "^9.2", + "php": "^8.2", + "php-http/discovery": "^1.20", + "phpseclib/phpseclib": "^3.0", + "psr/http-factory-implementation": "*", + "symfony/console": "^7.1|^8.0", + "symfony/psr-http-message-bridge": "^7.1|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passport\\PassportServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passport\\": "src/", + "Laravel\\Passport\\Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Passport provides OAuth2 server support to Laravel.", + "keywords": [ + "laravel", + "oauth", + "passport" + ], + "support": { + "issues": "https://github.com/laravel/passport/issues", + "source": "https://github.com/laravel/passport" + }, + "time": "2026-02-23T15:45:16+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.13", @@ -1460,6 +1665,143 @@ }, "time": "2026-02-06T14:12:35+00:00" }, + { + "name": "lcobucci/clock", + "version": "3.5.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "a3139d9e97d47826f27e6a17bb63f13621f86058" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/a3139d9e97d47826f27e6a17bb63f13621f86058", + "reference": "a3139d9e97d47826f27e6a17bb63f13621f86058", + "shasum": "" + }, + "require": { + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.31", + "lcobucci/coding-standard": "^11.2.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^2.0.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0.0", + "phpstan/phpstan-strict-rules": "^2.0.0", + "phpunit/phpunit": "^12.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.5.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-27T09:03:17+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "league/commonmark", "version": "2.8.0", @@ -1649,6 +1991,65 @@ ], "time": "2022-12-11T20:36:23+00:00" }, + { + "name": "league/event", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/event.git", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210", + "shasum": "" + }, + "require": { + "php": ">=7.2.0", + "psr/event-dispatcher": "^1.0" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "phpstan/phpstan": "^0.12.45", + "phpunit/phpunit": "^8.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Event\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Event package", + "keywords": [ + "emitter", + "event", + "listener" + ], + "support": { + "issues": "https://github.com/thephpleague/event/issues", + "source": "https://github.com/thephpleague/event/tree/3.0.3" + }, + "time": "2024-09-04T16:06:53+00:00" + }, { "name": "league/flysystem", "version": "3.31.0", @@ -1837,6 +2238,102 @@ ], "time": "2024-09-21T08:32:55+00:00" }, + { + "name": "league/oauth2-server", + "version": "9.3.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/d8e2f39f645a82b207bbac441694d6e6079357cb", + "reference": "d8e2f39f645a82b207bbac441694d6e6079357cb", + "shasum": "" + }, + "require": { + "defuse/php-encryption": "^2.4", + "ext-json": "*", + "ext-openssl": "*", + "lcobucci/clock": "^2.3 || ^3.0", + "lcobucci/jwt": "^5.0", + "league/event": "^3.0", + "league/uri": "^7.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "laminas/laminas-diactoros": "^3.5", + "php-parallel-lint/php-parallel-lint": "^1.3.2", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.12|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.1.4|^2.0", + "phpstan/phpstan-phpunit": "^1.3.15|^2.0", + "phpstan/phpstan-strict-rules": "^1.5.2|^2.0", + "phpunit/phpunit": "^10.5|^11.5|^12.0", + "roave/security-advisories": "dev-master", + "slevomat/coding-standard": "^8.14.1", + "squizlabs/php_codesniffer": "^3.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Andy Millington", + "email": "andrew@noexceptions.io", + "homepage": "https://www.noexceptions.io", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "https://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-server/issues", + "source": "https://github.com/thephpleague/oauth2-server/tree/9.3.0" + }, + "funding": [ + { + "url": "https://github.com/sephster", + "type": "github" + } + ], + "time": "2025-11-25T22:51:15+00:00" + }, { "name": "league/uri", "version": "7.8.0", @@ -2741,6 +3238,204 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "paragonie/constant_time_encoding", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/paragonie/constant_time_encoding.git", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77", + "shasum": "" + }, + "require": { + "php": "^8" + }, + "require-dev": { + "infection/infection": "^0", + "nikic/php-fuzzer": "^0", + "phpunit/phpunit": "^9|^10|^11", + "vimeo/psalm": "^4|^5|^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "ParagonIE\\ConstantTime\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com", + "role": "Maintainer" + }, + { + "name": "Steve 'Sc00bz' Thomas", + "email": "steve@tobtu.com", + "homepage": "https://www.tobtu.com", + "role": "Original Developer" + } + ], + "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", + "keywords": [ + "base16", + "base32", + "base32_decode", + "base32_encode", + "base64", + "base64_decode", + "base64_encode", + "bin2hex", + "encoding", + "hex", + "hex2bin", + "rfc4648" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/constant_time_encoding/issues", + "source": "https://github.com/paragonie/constant_time_encoding" + }, + "time": "2025-09-24T15:06:41+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -2816,6 +3511,116 @@ ], "time": "2025-12-27T19:41:33+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "3.0.49", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", + "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1|^2|^3", + "paragonie/random_compat": "^1.4|^2.0|^9.99.99", + "php": ">=5.6.1" + }, + "require-dev": { + "phpunit/phpunit": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib3\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "http://phpseclib.sourceforge.net", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-01-27T09:17:28+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3127,6 +3932,119 @@ }, "time": "2023-04-04T09:54:51+00:00" }, + { + "name": "psr/http-server-handler", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-handler.git", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4", + "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side request handler", + "keywords": [ + "handler", + "http", + "http-interop", + "psr", + "psr-15", + "psr-7", + "request", + "response", + "server" + ], + "support": { + "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2" + }, + "time": "2023-04-10T20:06:20+00:00" + }, + { + "name": "psr/http-server-middleware", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-server-middleware.git", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "psr/http-message": "^1.0 || ^2.0", + "psr/http-server-handler": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP server-side middleware", + "keywords": [ + "http", + "http-interop", + "middleware", + "psr", + "psr-15", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-server-middleware/issues", + "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2" + }, + "time": "2023-04-11T06:14:47+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -5395,6 +6313,93 @@ ], "time": "2026-01-26T15:07:59+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531", + "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^7.4|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-03T23:40:55+00:00" + }, { "name": "symfony/routing", "version": "v7.4.4", @@ -9796,5 +10801,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/backend/config/auth.php b/backend/config/auth.php index 0ba5d5d..d3c3651 100644 --- a/backend/config/auth.php +++ b/backend/config/auth.php @@ -40,6 +40,10 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + 'api' => [ + 'driver' => 'passport', + 'provider' => 'users', + ], ], /* diff --git a/backend/config/passport.php b/backend/config/passport.php new file mode 100644 index 0000000..aed4358 --- /dev/null +++ b/backend/config/passport.php @@ -0,0 +1,48 @@ + 'web', + + 'middleware' => [], + + /* + |-------------------------------------------------------------------------- + | Encryption Keys + |-------------------------------------------------------------------------- + | + | Passport uses encryption keys while generating secure access tokens for + | your application. By default, the keys are stored as local files but + | can be set via environment variables when that is more convenient. + | + */ + + 'private_key' => env('PASSPORT_PRIVATE_KEY'), + + 'public_key' => env('PASSPORT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passport Database Connection + |-------------------------------------------------------------------------- + | + | By default, Passport's models will utilize your application's default + | database connection. If you wish to use a different connection you + | may specify the configured name of the database connection here. + | + */ + + 'connection' => env('PASSPORT_CONNECTION'), + +]; diff --git a/backend/database/factories/EventFactory.php b/backend/database/factories/EventFactory.php new file mode 100644 index 0000000..6997173 --- /dev/null +++ b/backend/database/factories/EventFactory.php @@ -0,0 +1,28 @@ + + */ +class EventFactory extends Factory +{ + public function definition(): array + { + return [ + 'client_id' => Str::uuid()->toString(), + 'user_id' => User::factory(), + 'title' => fake()->sentence(3), + 'date' => fake()->date(), + 'emotion' => fake()->randomFloat(3, -1, 1), + 'custom_color' => null, + 'gradient_preset' => fake()->optional(0.3)->numberBetween(0, 9), + 'image' => null, + 'note' => fake()->optional(0.5)->sentence(), + ]; + } +} diff --git a/backend/database/migrations/2026_02_24_161650_create_oauth_auth_codes_table.php b/backend/database/migrations/2026_02_24_161650_create_oauth_auth_codes_table.php new file mode 100644 index 0000000..c700b50 --- /dev/null +++ b/backend/database/migrations/2026_02_24_161650_create_oauth_auth_codes_table.php @@ -0,0 +1,39 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->index(); + $table->foreignUuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_auth_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/backend/database/migrations/2026_02_24_161651_create_oauth_access_tokens_table.php b/backend/database/migrations/2026_02_24_161651_create_oauth_access_tokens_table.php new file mode 100644 index 0000000..3e50f7f --- /dev/null +++ b/backend/database/migrations/2026_02_24_161651_create_oauth_access_tokens_table.php @@ -0,0 +1,41 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_access_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/backend/database/migrations/2026_02_24_161652_create_oauth_refresh_tokens_table.php b/backend/database/migrations/2026_02_24_161652_create_oauth_refresh_tokens_table.php new file mode 100644 index 0000000..afb3c55 --- /dev/null +++ b/backend/database/migrations/2026_02_24_161652_create_oauth_refresh_tokens_table.php @@ -0,0 +1,37 @@ +char('id', 80)->primary(); + $table->char('access_token_id', 80)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/backend/database/migrations/2026_02_24_161653_create_oauth_clients_table.php b/backend/database/migrations/2026_02_24_161653_create_oauth_clients_table.php new file mode 100644 index 0000000..9794dc8 --- /dev/null +++ b/backend/database/migrations/2026_02_24_161653_create_oauth_clients_table.php @@ -0,0 +1,42 @@ +uuid('id')->primary(); + $table->nullableMorphs('owner'); + $table->string('name'); + $table->string('secret')->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect_uris'); + $table->text('grant_types'); + $table->boolean('revoked'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/backend/database/migrations/2026_02_24_161654_create_oauth_device_codes_table.php b/backend/database/migrations/2026_02_24_161654_create_oauth_device_codes_table.php new file mode 100644 index 0000000..ea07831 --- /dev/null +++ b/backend/database/migrations/2026_02_24_161654_create_oauth_device_codes_table.php @@ -0,0 +1,42 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id')->index(); + $table->char('user_code', 8)->unique(); + $table->text('scopes'); + $table->boolean('revoked'); + $table->dateTime('user_approved_at')->nullable(); + $table->dateTime('last_polled_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_device_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/backend/database/migrations/2026_02_24_161710_create_events_table.php b/backend/database/migrations/2026_02_24_161710_create_events_table.php new file mode 100644 index 0000000..e021372 --- /dev/null +++ b/backend/database/migrations/2026_02_24_161710_create_events_table.php @@ -0,0 +1,33 @@ +id(); + $table->uuid('client_id')->unique(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->date('date'); + $table->decimal('emotion', 4, 3)->default(0); + $table->string('custom_color')->nullable(); + $table->unsignedTinyInteger('gradient_preset')->nullable(); + $table->string('image')->nullable(); + $table->text('note')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'date']); + $table->index(['user_id', 'updated_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('events'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..110e93f --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,14 @@ +group(function () { + Route::get('/user', fn (Request $request) => $request->user()); + + Route::apiResource('events', EventController::class)->parameters([ + 'events' => 'clientId', + ]); + Route::post('/events/sync', [EventController::class, 'sync']); +}); diff --git a/backend/tests/Feature/Api/EventTest.php b/backend/tests/Feature/Api/EventTest.php new file mode 100644 index 0000000..6f25c78 --- /dev/null +++ b/backend/tests/Feature/Api/EventTest.php @@ -0,0 +1,186 @@ +user = User::factory()->create(); + Passport::actingAs($this->user); +}); + +test('can list events', function () { + Event::factory()->count(3)->create(['user_id' => $this->user->id]); + + $response = $this->getJson('/api/events'); + + $response->assertOk() + ->assertJsonCount(3, 'data'); +}); + +test('list only returns own events', function () { + Event::factory()->count(2)->create(['user_id' => $this->user->id]); + Event::factory()->count(3)->create(); // other user + + $response = $this->getJson('/api/events'); + + $response->assertOk() + ->assertJsonCount(2, 'data'); +}); + +test('can filter events by since parameter', function () { + Event::factory()->create([ + 'user_id' => $this->user->id, + 'updated_at' => now()->subDays(5), + ]); + Event::factory()->create([ + 'user_id' => $this->user->id, + 'updated_at' => now()->subHour(), + ]); + + $response = $this->getJson('/api/events?since=' . now()->subDay()->toISOString()); + + $response->assertOk() + ->assertJsonCount(1, 'data'); +}); + +test('can create an event', function () { + $clientId = Str::uuid()->toString(); + + $response = $this->postJson('/api/events', [ + 'id' => $clientId, + 'title' => 'Mein Event', + 'date' => '2024-06-15', + 'emotion' => 0.75, + 'customColor' => null, + 'gradientPreset' => 2, + 'image' => null, + 'note' => 'Eine Notiz', + ]); + + $response->assertCreated() + ->assertJsonPath('data.id', $clientId) + ->assertJsonPath('data.title', 'Mein Event') + ->assertJsonPath('data.syncStatus', 'synced'); + + $this->assertDatabaseHas('events', [ + 'client_id' => $clientId, + 'user_id' => $this->user->id, + ]); +}); + +test('create validates required fields', function () { + $response = $this->postJson('/api/events', []); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['id', 'title', 'date', 'emotion']); +}); + +test('can show a single event', function () { + $event = Event::factory()->create(['user_id' => $this->user->id]); + + $response = $this->getJson("/api/events/{$event->client_id}"); + + $response->assertOk() + ->assertJsonPath('data.id', $event->client_id) + ->assertJsonPath('data.title', $event->title); +}); + +test('cannot show another users event', function () { + $event = Event::factory()->create(); + + $response = $this->getJson("/api/events/{$event->client_id}"); + + $response->assertNotFound(); +}); + +test('can update an event', function () { + $event = Event::factory()->create(['user_id' => $this->user->id]); + + $response = $this->putJson("/api/events/{$event->client_id}", [ + 'title' => 'Updated Title', + 'emotion' => -0.5, + ]); + + $response->assertOk() + ->assertJsonPath('data.title', 'Updated Title'); +}); + +test('can delete an event', function () { + $event = Event::factory()->create(['user_id' => $this->user->id]); + + $response = $this->deleteJson("/api/events/{$event->client_id}"); + + $response->assertNoContent(); + $this->assertDatabaseMissing('events', ['id' => $event->id]); +}); + +test('cannot delete another users event', function () { + $event = Event::factory()->create(); + + $response = $this->deleteJson("/api/events/{$event->client_id}"); + + $response->assertNotFound(); +}); + +test('batch sync creates updates and deletes', function () { + $existingEvent = Event::factory()->create(['user_id' => $this->user->id]); + $newId = Str::uuid()->toString(); + $deleteEvent = Event::factory()->create(['user_id' => $this->user->id]); + + $response = $this->postJson('/api/events/sync', [ + 'mutations' => [ + [ + 'action' => 'create', + 'eventId' => $newId, + 'payload' => [ + 'title' => 'New via sync', + 'date' => '2025-01-01', + 'emotion' => 0.3, + ], + ], + [ + 'action' => 'update', + 'eventId' => $existingEvent->client_id, + 'payload' => [ + 'title' => 'Updated via sync', + ], + ], + [ + 'action' => 'delete', + 'eventId' => $deleteEvent->client_id, + 'payload' => null, + ], + ], + ]); + + $response->assertOk() + ->assertJsonCount(3, 'results'); + + $this->assertDatabaseHas('events', ['client_id' => $newId, 'title' => 'New via sync']); + $this->assertDatabaseHas('events', ['client_id' => $existingEvent->client_id, 'title' => 'Updated via sync']); + $this->assertDatabaseMissing('events', ['client_id' => $deleteEvent->client_id]); +}); + +test('sync is idempotent for creates', function () { + $clientId = Str::uuid()->toString(); + + $this->postJson('/api/events/sync', [ + 'mutations' => [[ + 'action' => 'create', + 'eventId' => $clientId, + 'payload' => ['title' => 'First', 'date' => '2025-01-01', 'emotion' => 0], + ]], + ])->assertOk(); + + $this->postJson('/api/events/sync', [ + 'mutations' => [[ + 'action' => 'create', + 'eventId' => $clientId, + 'payload' => ['title' => 'Duplicate', 'date' => '2025-01-01', 'emotion' => 0], + ]], + ])->assertOk(); + + expect(Event::where('client_id', $clientId)->count())->toBe(1); +}); diff --git a/docker-compose.yml b/docker-compose.yml index 1c6c6b9..2def612 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,7 @@ services: NODE_ENV: development volumes: - './frontend:/app' + - 'quasar-node-modules:/app/node_modules' networks: - sail - proxy @@ -160,3 +161,5 @@ volumes: driver: local sail-redis: driver: local + quasar-node-modules: + driver: local \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index f1d913c..1360c61 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -16,6 +16,8 @@ node_modules # Capacitor related directories and files /src-capacitor/www /src-capacitor/node_modules +/src-capacitor/ios +/src-capacitor/android # Log files npm-debug.log* diff --git a/frontend/MOBILE-APPS.md b/frontend/MOBILE-APPS.md new file mode 100644 index 0000000..835fca1 --- /dev/null +++ b/frontend/MOBILE-APPS.md @@ -0,0 +1,226 @@ +# Mobile Apps – iOS & Android + +Die "Thats Me" App wird mit **Capacitor** (v7) als native iOS- und Android-App verpackt. Capacitor bündelt die Quasar Web-App in eine WebView innerhalb einer nativen App-Shell. + +--- + +## Wichtig: Zwei Umgebungen + +| Umgebung | Zweck | +| --------------------- | ---------------------------------------------------------------------- | +| **Docker-Container** | Quasar Dev-Server, Vue-Code bearbeiten, `npm install`, Web-Entwicklung | +| **Mac Server / Host** | Xcode, CocoaPods, Capacitor-Builds, iOS Simulator | + +Xcode und CocoaPods laufen **ausschließlich auf dem Mac** (außerhalb Docker). Die installierten Tools auf dem Mac-Host beeinflussen den Docker-Container **nicht** – beide Umgebungen sind vollständig isoliert. + +--- + +## Projektstruktur + +``` +frontend/ +├── src/ # Quasar Quellcode (Vue.js) +├── src-capacitor/ # Capacitor nativer Wrapper +│ ├── ios/ # Xcode-Projekt (lokal, nicht im Git) +│ ├── android/ # Android Studio Projekt (lokal, nicht im Git) +│ ├── www/ # Kompilierte Web-Assets (nicht im Git) +│ ├── capacitor.config.json # App-Konfiguration +│ └── package.json # Capacitor Dependencies +└── quasar.config.js # Quasar Config +``` + +## App-Konfiguration + +| Eigenschaft | Wert | +| ----------- | ----------------------- | +| App ID | `media.adametz.thatsme` | +| App Name | `Thats Me` | + +--- + +## Einmaliges Setup auf dem Mac + +### 1. Voraussetzungen installieren + +```bash +# Homebrew (falls nicht vorhanden) +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Node.js +brew install node + +# CocoaPods (iOS Dependency Manager) +brew install cocoapods +# oder: sudo gem install cocoapods + +# Prüfen: +node --version # z.B. v25.x +pod --version # z.B. 1.16.2 +``` + +### 2. Xcode einrichten + +- Xcode aus dem **Mac App Store** installieren (~15 GB, etwas Geduld) +- Nach der Installation Xcode **einmal öffnen** und License akzeptieren +- Developer Directory auf Xcode setzen: + +```bash +sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer +sudo xcodebuild -license accept +``` + +Prüfen: + +```bash +xcode-select -p +# Ausgabe: /Applications/Xcode.app/Contents/Developer +``` + +### 3. Capacitor Dependencies installieren + +```bash +cd ~/Sites/thats-me.local/frontend/src-capacitor +npm install +``` + +> ⚠️ **Wichtig:** `npm install` immer nur im `src-capacitor/`-Unterordner ausführen, **niemals** direkt im `frontend/`-Ordner vom Mac-Host. Der `frontend/`-Ordner ist per Volume in den Docker-Container gemountet. Ein `npm install` dort überschreibt die Linux-Binaries mit macOS-Binaries und der Dev-Container funktioniert nicht mehr. +> +> Falls das passiert, im Dev-Container reparieren: +> ```bash +> cd /workspace/frontend +> rm -rf node_modules package-lock.json && npm install +> ``` + +### 4. iOS-Plattform hinzufügen (nur wenn `ios/`-Ordner fehlt) + +```bash +cd ~/Sites/thats-me.local/frontend/src-capacitor +npx cap add ios +``` + +### 5. CocoaPods für iOS-Projekt initialisieren + +```bash +cd ~/Sites/thats-me.local/frontend/src-capacitor/ios/App +pod install +``` + +--- + +## Build & Simulator starten (regulärer Workflow) + +### Schritt 1 – App bauen + +```bash +cd ~/Sites/thats-me.local/frontend +npx quasar build -m capacitor -T ios +``` + +Das kompiliert den Vue/Quasar-Code und synchronisiert die Assets in das Xcode-Projekt. + +### Schritt 2 – In Xcode öffnen + +```bash +cd src-capacitor +npx cap open ios +``` + +### Schritt 3 – Im Simulator ausführen + +In Xcode: + +1. Oben links das **Zielgerät** auf einen **Simulator** setzen (z.B. "iPhone 17") +2. **▶ Play** klicken + +> **Hinweis:** Für den Simulator wird **kein** Apple Developer Account / Signing benötigt. Signing ist nur für echte Geräte und den App Store erforderlich. + +--- + +## Nach Code-Änderungen + +Wenn Änderungen am Vue/Quasar-Code gemacht wurden, immer wieder: + +```bash +cd ~/Sites/thats-me.local/frontend +npx quasar build -m capacitor -T ios +cd src-capacitor && npx cap open ios +``` + +In Xcode dann erneut auf ▶ klicken. + +--- + +## Production Build (App Store) + +### iOS (App Store / TestFlight) + +```bash +npx quasar build -m capacitor -T ios +cd src-capacitor && npx cap open ios +``` + +In Xcode: + +- **Signing & Capabilities** → Apple Developer Account eintragen (kostenpflichtig, $99/Jahr) +- Zielgerät auf **"Any iOS Device (arm64)"** setzen +- **Product → Archive → Distribute App** + +### Android (Google Play) + +```bash +npx quasar build -m capacitor -T android +cd src-capacitor && npx cap open android +``` + +In Android Studio: + +- **Build → Generate Signed Bundle / APK** +- Keystore erstellen/auswählen + +--- + +## Native APIs / Plugins + +Capacitor-Plugins ermöglichen Zugriff auf native Funktionen: + +```bash +# Beispiele (im src-capacitor Ordner): +npm install @capacitor/camera +npm install @capacitor/geolocation +npm install @capacitor/push-notifications + +# Nach jedem neuen Plugin: +npx cap sync +``` + +--- + +## Git-Hinweise + +Folgende Ordner sind in `.gitignore` und werden **nicht** commitet: + +``` +src-capacitor/node_modules/ # Lokal neu installieren mit: npm install +src-capacitor/www/ # Wird vom Build befüllt +src-capacitor/ios/ # Lokal generiert mit: npx cap add ios +src-capacitor/android/ # Lokal generiert mit: npx cap add android +``` + +**Was commitet wird:** + +- `src-capacitor/capacitor.config.json` – App-Konfiguration +- `src-capacitor/package.json` – Capacitor Dependencies + +--- + +## Troubleshooting + +| Fehler | Lösung | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| `spawn pod ENOENT` | `brew install cocoapods` | +| `xcodebuild requires Xcode` | `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer` | +| `Cannot find module @rollup/rollup-darwin-arm64` | `rm -rf node_modules package-lock.json && npm install` im `frontend/`-Ordner | +| `Signing requires a development team` | Für Simulator: ignorieren. Für echtes Gerät: Apple Developer Account in Xcode hinterlegen | +| `ios platform already exists` | Normal – `npx cap add ios` überspringen, Ordner ist bereits vorhanden | +| Weißer Bildschirm im Simulator | `npx cap sync` im `src-capacitor`-Ordner, dann neu bauen | +| Pod-Fehler | `cd src-capacitor/ios/App && pod install --repo-update` | diff --git a/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 15.44.48.png b/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 15.44.48.png new file mode 100644 index 0000000..38592dd Binary files /dev/null and b/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 15.44.48.png differ diff --git a/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 15.47.21.png b/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 15.47.21.png new file mode 100644 index 0000000..2542beb Binary files /dev/null and b/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 15.47.21.png differ diff --git a/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 16.11.48.png b/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 16.11.48.png new file mode 100644 index 0000000..483b2ca Binary files /dev/null and b/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 16.11.48.png differ diff --git a/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 16.11.58.png b/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 16.11.58.png new file mode 100644 index 0000000..0534e0b Binary files /dev/null and b/frontend/dev/19-02-2026/Bildschirmfoto 2026-02-24 um 16.11.58.png differ diff --git a/frontend/dev/UMSETZUNG-FLOATING-LINES.md b/frontend/dev/UMSETZUNG-FLOATING-LINES.md index a70403c..9a35f00 100644 --- a/frontend/dev/UMSETZUNG-FLOATING-LINES.md +++ b/frontend/dev/UMSETZUNG-FLOATING-LINES.md @@ -79,11 +79,11 @@ displayEvents ──@viewUpdate──► onViewUpdate() // Layout: screenToUV(sx, sy) // sx, sy = CSS-Pixel vom oberen linken Viewport-Rand function screenToUV(sx, sy) { - const w = layoutWidth // = 100dvh Breite - const h = layoutHeight // = 100dvh Höhe + const w = layoutWidth // = 100dvh Breite + const h = layoutHeight // = 100dvh Höhe return { x: (2 * sx - w) / h, - y: (2 * sy - h) / h + y: (2 * sy - h) / h, } } ``` @@ -95,6 +95,7 @@ baseUv.y *= -1.0; // Y-Flip (CSS: top→bottom, GL: bottom→top) ``` **GlowDot Y-Position:** + ``` yPercent = 50 - emotion * 35 emotion +1.0 → top (15%) @@ -103,6 +104,7 @@ yPercent = 50 - emotion * 35 ``` **Screen Y für Shader:** + ``` TIMELINE_TOP = 60px (CSS: .timeline { top: 60px }) screenY = TIMELINE_TOP + (yPercent / 100) * containerHeight @@ -131,6 +133,7 @@ v-model="ghostEmotion" ──► ghostEmotion (ref) ### 3.3 Event-Farben Jeder Event hat eine Glow-Farbe basierend auf: + 1. `event.customColor` (falls gesetzt, hat Priorität) 2. `emotionToColor(emotion, gradientPreset)` — interpoliert zwischen 3 Farben @@ -143,6 +146,7 @@ events.js: getGlowColor(event) ``` Die Farbe fließt als `pointColor[8]` Uniform in den Shader: + - **Kreise:** `vec3 circCol = pointColor[p]` - **Liniensegmente:** `vec3 lineCol = mix(pointColor[s], pointColor[s+1], t_seg)` @@ -158,29 +162,30 @@ Die Farbe fließt als `pointColor[8]` Uniform in den Shader: **Props:** -| Prop | Typ | Default | Beschreibung | -|------|-----|---------|-------------| -| `numPoints` | Number | 0 | Anzahl aktiver Punkte (max 8) | -| `pointXValues` | Array | [] | X-UV-Koordinaten der Punkte | -| `pointYValues` | Array | [] | Y-UV-Koordinaten der Punkte | -| `pointColors` | Array | [] | Hex-Farben pro Punkt (z.B. '#ff0000') | -| `lineCount` | Array/Number | [10] | Anzahl Wellenlinien | -| `animationSpeed` | Number | 1 | Geschwindigkeit der Wellenanimation | -| `lineSpread` | Number | 0.05 | Wellenamplitude | -| `fanSpread` | Number | 0.05 | Fächerbreite der Linien | -| `lineSharpness` | Number | 8.0 | Feinheit/Schärfe der Linien | -| `waveFrequency` | Number | 7.0 | Welligkeit | -| `bezierCurvature` | Number | 0.2 | Kurvenstärke der Bezier-Verbindungen | -| `circleRadiusPx` | Number | 75 | Kreisradius in Pixeln | -| `circleGlowSize` | Number | 18 | Glow-Ausdehnung um den Kreis | -| `circleGlowStrength` | Number | 1.5 | Glow-Intensität | -| `linesGradient` | Array | [...] | Hex-Farbwerte für Linien-Gradient | -| `bgColorCenter` | String | '#0a0514' | Hintergrundfarbe Mitte | -| `bgColorEdge` | String | '#000000' | Hintergrundfarbe Rand | -| `backgroundImage` | String | '' | URL für Hintergrundbild | -| `mixBlendMode` | String | 'screen' | CSS Blend-Mode des Canvas | +| Prop | Typ | Default | Beschreibung | +| -------------------- | ------------ | --------- | ------------------------------------- | +| `numPoints` | Number | 0 | Anzahl aktiver Punkte (max 8) | +| `pointXValues` | Array | [] | X-UV-Koordinaten der Punkte | +| `pointYValues` | Array | [] | Y-UV-Koordinaten der Punkte | +| `pointColors` | Array | [] | Hex-Farben pro Punkt (z.B. '#ff0000') | +| `lineCount` | Array/Number | [10] | Anzahl Wellenlinien | +| `animationSpeed` | Number | 1 | Geschwindigkeit der Wellenanimation | +| `lineSpread` | Number | 0.05 | Wellenamplitude | +| `fanSpread` | Number | 0.05 | Fächerbreite der Linien | +| `lineSharpness` | Number | 8.0 | Feinheit/Schärfe der Linien | +| `waveFrequency` | Number | 7.0 | Welligkeit | +| `bezierCurvature` | Number | 0.2 | Kurvenstärke der Bezier-Verbindungen | +| `circleRadiusPx` | Number | 75 | Kreisradius in Pixeln | +| `circleGlowSize` | Number | 18 | Glow-Ausdehnung um den Kreis | +| `circleGlowStrength` | Number | 1.5 | Glow-Intensität | +| `linesGradient` | Array | [...] | Hex-Farbwerte für Linien-Gradient | +| `bgColorCenter` | String | '#0a0514' | Hintergrundfarbe Mitte | +| `bgColorEdge` | String | '#000000' | Hintergrundfarbe Rand | +| `backgroundImage` | String | '' | URL für Hintergrundbild | +| `mixBlendMode` | String | 'screen' | CSS Blend-Mode des Canvas | **Shader-Architektur:** + - `drawCircle()` — Zeichnet weißen Kern + farbigen Glow + Fog - `waveFocal()` — Berechnet Wellenlinien entlang Bezier-Segmenten - `bezierClosestT()` — Findet nächsten Punkt auf quadratischer Bezier-Kurve @@ -191,9 +196,10 @@ Die Farbe fließt als `pointColor[8]` Uniform in den Shader: **Zweck:** Klickbarer DOM-Overlay pro Event (weißer Kreis + optionales Bild). **Größe:** Dynamisch aus `settingsStore.floatingLines.circleRadius`: + ```js const dpr = Math.min(window.devicePixelRatio || 1, 2) -const dotSize = 2 * circleRadius / dpr // Matches shader circle +const dotSize = (2 * circleRadius) / dpr // Matches shader circle ``` **Kein Zoom-Scaling** — Größe ist konstant, unabhängig vom Zoom-Level. @@ -207,12 +213,14 @@ const dotSize = 2 * circleRadius / dpr // Matches shader circle **CSS-Position:** `top: 60px; bottom: 70px` (unterhalb Header, oberhalb AddButton) **Features:** + - Pinch-to-Zoom (Touch + Ctrl+Wheel) - Zoom-Range: 0.4x – 3.0x - Scroll-to-center beim Mount (letztes Event) - Ghost-Event-Insertion bei Panel-Open (Create-Mode) **Emits:** + - `@dotSelect(eventId)` — Event angeklickt - `@viewUpdate({ scrollLeft, viewportWidth, containerHeight, events[] })` — Bei jedem Scroll/Zoom/Resize/Event-Change @@ -221,6 +229,7 @@ const dotSize = 2 * circleRadius / dpr // Matches shader circle **Zweck:** Haupt-Layout, orchestriert alle Komponenten. **Verantwortlichkeiten:** + - Empfängt `@view-update` von TimelineView - Konvertiert Screen-Pixel → Shader-UV-Koordinaten - Berechnet `shaderNumPoints`, `shaderPointX[]`, `shaderPointY[]`, `shaderPointColors[]` @@ -234,6 +243,7 @@ const dotSize = 2 * circleRadius / dpr // Matches shader circle **Zweck:** Einstellungs-Panel (Slide-Up, 75dvh). **Sektionen:** + 1. **Linien** — Speed, Anzahl, Wellen-Amp, Fächerbreite, Feinheit, Welligkeit, Kurve, Kreis, Glow Größe, Glow Stärke 2. **Hintergrundbild** — 10 vordefinierte Bilder (`/images/bg-image-1.jpg` bis `10.jpg`) 3. **Hintergrundfarbe** — BG Mitte + BG Rand (Color Picker) @@ -246,6 +256,7 @@ const dotSize = 2 * circleRadius / dpr // Matches shader circle **Zweck:** Event-Erstellung und -Bearbeitung (Slide-Up, 75dvh). **Features:** + - Key Image Upload (Platzhalter) - Titel-Input (inline, groß) - Datum-Picker (QDate mit deutscher Locale) @@ -311,26 +322,36 @@ localStorage.setItem('thatsme-settings', JSON.stringify({...})) ### 6.2 Quasar Theme (`quasar.variables.scss`) ```scss -$primary : #d946ef; // Fuchsia — Slider, Toggles, aktive States -$secondary : #a855f7; // Purple -$accent : #ec4899; // Pink +$primary: #d946ef; // Fuchsia — Slider, Toggles, aktive States +$secondary: #a855f7; // Purple +$accent: #ec4899; // Pink ``` ### 6.3 Wichtige CSS-Hinweise **Timeline-Positionierung:** + ```css /* TimelineView.vue — eigene Positionierung */ -.timeline { position: absolute; top: 60px; bottom: 70px; } +.timeline { + position: absolute; + top: 60px; + bottom: 70px; +} /* LifeWaveLayout.vue — NUR z-index, KEIN inset: 0! */ /* inset: 0 würde top/bottom der Timeline überschreiben (CSS Cascade) */ -.lifewave-layout__timeline { z-index: 5; } +.lifewave-layout__timeline { + z-index: 5; +} ``` **GlowDot — kein Zoom-Scaling:** + ```css -.glow-dot { transform: translate(-50%, -50%); } +.glow-dot { + transform: translate(-50%, -50%); +} /* Breite/Höhe kommt dynamisch aus dem Settings-Store */ ``` @@ -366,16 +387,16 @@ npm run build ### Dateien für die Weiterentwicklung -| Was | Wo | -|-----|-----| -| Shader-Code (GLSL) | `FloatingLines.vue` (Zeile ~67–366) | -| UV-Konvertierung | `LifeWaveLayout.vue` → `screenToUV()` | -| Event-Farben | `events.js` → `emotionToColor()`, `getGlowColor()` | -| Settings-Defaults | `settings.js` → `FLOATING_LINES_DEFAULTS` | -| Slider-Ranges | `LifeWaveSettings.vue` (`:min`, `:max`, `:step` auf jedem `q-slider`) | -| Quasar-Theme | `quasar.variables.scss` | -| Glass-Styles | `app.scss` → `.glass--panel`, `.glass--button` | -| Dev-Referenz | `dev/init-fl.html`, `dev/floating-lines.js` (Original-Prototyp) | +| Was | Wo | +| ------------------ | --------------------------------------------------------------------- | +| Shader-Code (GLSL) | `FloatingLines.vue` (Zeile ~67–366) | +| UV-Konvertierung | `LifeWaveLayout.vue` → `screenToUV()` | +| Event-Farben | `events.js` → `emotionToColor()`, `getGlowColor()` | +| Settings-Defaults | `settings.js` → `FLOATING_LINES_DEFAULTS` | +| Slider-Ranges | `LifeWaveSettings.vue` (`:min`, `:max`, `:step` auf jedem `q-slider`) | +| Quasar-Theme | `quasar.variables.scss` | +| Glass-Styles | `app.scss` → `.glass--panel`, `.glass--button` | +| Dev-Referenz | `dev/init-fl.html`, `dev/floating-lines.js` (Original-Prototyp) | ### Nächste Schritte (offen) diff --git a/frontend/dev/UMSETZUNG-VIRTUALISIERUNG-OFFLINE.md b/frontend/dev/UMSETZUNG-VIRTUALISIERUNG-OFFLINE.md new file mode 100644 index 0000000..52574e4 --- /dev/null +++ b/frontend/dev/UMSETZUNG-VIRTUALISIERUNG-OFFLINE.md @@ -0,0 +1,456 @@ +# Virtualisierung & Offline-First Architektur + +**Stand:** 24. Februar 2026 +**Bereich:** Frontend (Quasar/Vue.js 3) + Backend (Laravel 12) + +--- + +## 1. Zusammenfassung + +Die Timeline-App wurde um eine skalierbare Architektur erweitert, die hunderte bis tausende Events performant darstellt und offline-fähig macht. Die Umsetzung erfolgte in 4 Phasen: + +1. **DOM-Virtualisierung** — Nur sichtbare Events werden gerendert +2. **IndexedDB-Persistenz** — Events überleben Page Reload (Dexie.js) +3. **Image Caching** — Thumbnails offline verfügbar +4. **Backend API + Sync** — Laravel REST API mit Passport OAuth2, bidirektionaler Sync + +### Ergebnis + +- Timeline scrollt flüssig mit 200+ Events (vorher: alle DOM-Nodes gleichzeitig) +- Events, Einstellungen und Thumbnails persistent in IndexedDB +- Sync Queue puffert Änderungen offline, synct automatisch bei Reconnect +- REST API mit Batch-Sync (bis 100 Mutationen/Request) +- 12 Backend-Tests (Pest v3) bestanden + +--- + +## 2. Phase 1: DOM-Virtualisierung + +### Problem + +Alle Events wurden als DOM-Nodes (`v-for displayEvents → GlowDot`) gerendert. Bei 200+ Events: zu viele DOM-Nodes, O(n) Label-Berechnung, unnötiger Render-Overhead. + +### Lösung + +**Visible Range Computation** in `TimelineView.vue`: + +``` +scrollLeft + viewportWidth → visibleRange { start, end } +→ nur visibleEvents rendern (+ 2 Buffer Events pro Seite) +``` + +#### Geänderte Dateien + +| Datei | Änderung | +| --------------------------------- | -------------------------------------------------------------------------------- | +| `src/components/TimelineView.vue` | `visibleRange`, `visibleEvents`, `visibleLabels`, `visibleYearMarkers` Computeds | +| `src/layouts/LifeWaveLayout.vue` | Smart 8-Punkt Shader-Selektion | + +#### TimelineView.vue — Kern-Logik + +```javascript +const VIS_BUFFER = 2 + +const visibleRange = computed(() => { + const start = Math.max(0, Math.floor((scrollLeft - PADDING) / EVENT_SPACING) - VIS_BUFFER) + const end = Math.min( + total - 1, + Math.ceil((scrollLeft + viewportWidth - PADDING) / EVENT_SPACING) + VIS_BUFFER, + ) + return { start, end } +}) + +const visibleEvents = computed(() => { + return displayEvents.slice(start, end + 1).map((event, i) => ({ + event, + globalIndex: start + i, + })) +}) +``` + +- `v-for` iteriert nur `visibleEvents` statt `displayEvents` +- `trackWidth` bleibt unverändert (Scrollbar korrekt) +- Labels und Year Markers ebenfalls gefiltert +- `activeLabel` optimiert von O(n) Scan auf O(1): `Math.round((centerX - PADDING) / EVENT_SPACING)` + +#### LifeWaveLayout.vue — Shader-Punkt-Selektion + +Der Shader akzeptiert max. 8 Punkte. Statt immer die ersten 8 Events zu nehmen, werden jetzt die sichtbaren Events + 1 Boundary auf jeder Seite gewählt: + +```javascript +const shaderSelection = computed(() => { + const rangeStart = Math.max(0, visibleStart - 1) + const rangeEnd = Math.min(events.length - 1, visibleEnd + 1) + let candidates = events.slice(rangeStart, rangeEnd + 1) + + if (candidates.length > 8) { + // Gleichmäßig subsamplen, first + last behalten + const sampled = [candidates[0]] + const step = (candidates.length - 1) / 7 + for (let i = 1; i < 7; i++) sampled.push(candidates[Math.round(i * step)]) + sampled.push(candidates[candidates.length - 1]) + candidates = sampled + } + return candidates +}) +``` + +--- + +## 3. Phase 2: IndexedDB-Persistenz (Dexie.js) + +### Problem + +Events existierten nur im Memory (Pinia ref). Page Reload = alles weg. + +### Lösung + +**Dexie.js v4.3** als IndexedDB-Wrapper. Events werden lokal persistent gespeichert. + +#### Neue Dateien + +| Datei | Zweck | +| ----------------- | ----------------------- | +| `src/db/index.js` | Dexie-Schema Definition | + +#### Geänderte Dateien + +| Datei | Änderung | +| ---------------------- | ---------------------------------------- | +| `src/stores/events.js` | Komplett refactored für Dexie-Persistenz | +| `package.json` | `dexie: ^4.3.0` hinzugefügt | + +#### DB-Schema (`src/db/index.js`) + +```javascript +import Dexie from 'dexie' + +export const db = new Dexie('thatsMeDB') + +db.version(1).stores({ + events: 'id, date, updatedAt, syncStatus', + syncQueue: '++queueId, eventId, action, createdAt', + imageCache: 'url, eventId, type, cachedAt', + meta: 'key', +}) +``` + +| Tabelle | Zweck | +| ------------ | ------------------------------------------ | +| `events` | Alle Events (PK: client-side UUID) | +| `syncQueue` | Outbound-Mutationen (FIFO) für API-Sync | +| `imageCache` | Offline-Thumbnails als Blobs | +| `meta` | Key-Value Store (Token, Sync-Cursor, etc.) | + +#### Events Store — Fire-and-Forget Pattern + +``` +User-Aktion → Vue ref sofort updaten (UI flüssig) → Dexie async schreiben (Background) +``` + +- `init()`: Lädt Events aus IndexedDB, seeded Demo-Daten wenn leer +- `dbPut(event)`: Fire-and-forget `db.events.put()` +- `dbDelete(id)`: Fire-and-forget `db.events.delete()` +- `dbQueueSync(eventId, action, payload)`: Mutation in Sync Queue +- Jedes Event hat `syncStatus`: `'local'` | `'synced'` | `'modified'` + +--- + +## 4. Phase 3: Image Caching + +### Problem + +Bilder in GlowDots und EventPanel laden nur mit Netzwerk. Offline = keine Bilder. + +### Lösung + +Thumbnails (200x200 JPEG) werden beim ersten Laden in IndexedDB gecacht. + +#### Neue Dateien + +| Datei | Zweck | +| ---------------------------------- | --------------------------- | +| `src/composables/useImageCache.js` | Composable für Bild-Caching | + +#### Geänderte Dateien + +| Datei | Änderung | +| ------------------------------- | --------------------------------------- | +| `src/components/GlowDot.vue` | Nutzt `useImageCache` für Thumbnail-Src | +| `src/components/EventPanel.vue` | Nutzt `resolveFullRes` für Key Image | + +#### Ablauf + +``` +1. Memory-Cache prüfen (Map, instant) + ↓ miss +2. IndexedDB prüfen (db.imageCache.get(url)) + ↓ miss +3. Fetch → Canvas 200x200 Thumbnail → toBlob('image/jpeg', 0.8) + → IndexedDB speichern → Blob URL zurückgeben +``` + +#### API + +```javascript +// In GlowDot.vue — reaktives Thumbnail +const { resolvedSrc: imageSrc } = useImageCache(event.image, event.id) + +// In EventPanel.vue — Full-Res (online) oder Thumbnail-Fallback (offline) +const src = await resolveFullRes(imageUrl) + +// Cleanup bei Event-Löschung +await clearEventImages(eventId) +``` + +**Strategie:** + +- **Thumbnails** (200x200, ~20KB): Immer lokal gecacht in IndexedDB +- **Full-Res**: On-Demand wenn EventPanel öffnet, Browser-Cache via HTTP Headers +- Durch Virtualisierung werden nur sichtbare GlowDots gerendert → Image Loading ist inherent lazy + +--- + +## 5. Phase 4: Backend API + Sync Service + +### 5.1 Backend (Laravel 12) + +#### Neue/Geänderte Dateien + +| Datei | Zweck | +| ----------------------------------------------- | ----------------------------------------- | +| `app/Models/Event.php` | Eloquent Model | +| `app/Models/User.php` | `HasApiTokens` Trait, `events()` Relation | +| `database/migrations/*_create_events_table.php` | DB-Schema | +| `database/factories/EventFactory.php` | Test-Factory | +| `app/Http/Controllers/Api/EventController.php` | REST Controller | +| `app/Http/Resources/EventResource.php` | JSON-Transformation | +| `app/Http/Requests/StoreEventRequest.php` | Validierung (Create) | +| `app/Http/Requests/UpdateEventRequest.php` | Validierung (Update) | +| `routes/api.php` | API-Routen | +| `config/auth.php` | Passport `api` Guard | +| `tests/Feature/Api/EventTest.php` | 12 Pest-Tests | + +#### Events-Tabelle + +```sql +events: + id BIGINT (PK, auto-increment) + client_id UUID (unique) — vom Frontend generiert + user_id BIGINT (FK → users) + title VARCHAR(255) + date DATE + emotion DECIMAL(4,3) — -1.000 bis +1.000 + custom_color VARCHAR(20) nullable + gradient_preset TINYINT nullable — 0-9 + image VARCHAR(500) nullable + note TEXT nullable + created_at TIMESTAMP + updated_at TIMESTAMP + + INDEX: (user_id, date) + INDEX: (user_id, updated_at) +``` + +#### API-Endpunkte + +| Method | Route | Beschreibung | +| -------- | ------------------------ | -------------------------------------------------------------------- | +| `GET` | `/api/events` | Alle Events (Cursor-Pagination, `?since=` für Delta-Sync, `?limit=`) | +| `POST` | `/api/events` | Neues Event erstellen | +| `GET` | `/api/events/{clientId}` | Einzelnes Event | +| `PUT` | `/api/events/{clientId}` | Event aktualisieren | +| `DELETE` | `/api/events/{clientId}` | Event löschen | +| `POST` | `/api/events/sync` | **Batch-Sync** — bis 100 Mutationen auf einmal | + +#### Batch-Sync Endpoint + +Der Kern des Sync-Systems. Verarbeitet create/update/delete in einem Request: + +```json +POST /api/events/sync +{ + "mutations": [ + { "action": "create", "eventId": "uuid", "payload": { "title": "...", ... } }, + { "action": "update", "eventId": "uuid", "payload": { "title": "Neu" } }, + { "action": "delete", "eventId": "uuid", "payload": null } + ] +} + +Response: +{ + "results": [ + { "eventId": "uuid", "status": "ok" }, + { "eventId": "uuid", "status": "ok" }, + { "eventId": "uuid", "status": "ok" } + ] +} +``` + +- **Idempotent**: Doppelte Creates werden ignoriert (kein Fehler) +- **Max 100 Mutationen** pro Request +- **Alle Operationen** sind user-scoped (kein Zugriff auf fremde Events) + +#### JSON-Mapping (EventResource) + +Backend (snake_case) → Frontend (camelCase): + +``` +client_id → id +custom_color → customColor +gradient_preset → gradientPreset +syncStatus → immer 'synced' (vom Server) +created_at → createdAt (Millisekunden) +updated_at → updatedAt (Millisekunden) +``` + +#### Authentifizierung + +- **Laravel Passport v13.5** (OAuth2) +- Guard: `auth:api` auf allen API-Routen +- Token wird im Frontend in IndexedDB `meta` Tabelle gespeichert + +#### Tests + +12 Pest-Tests in `tests/Feature/Api/EventTest.php`: + +| Test | Was | +| -------------------------------------- | ----------------------------------------- | +| can list events | GET /api/events gibt eigene Events zurück | +| list only returns own events | Keine fremden Events sichtbar | +| can filter events by since | Delta-Sync Filter funktioniert | +| can create an event | POST mit UUID → 201 Created | +| create validates required fields | Fehler bei fehlenden Pflichtfeldern | +| can show a single event | GET /api/events/{id} | +| cannot show another users event | 404 bei fremdem Event | +| can update an event | PUT mit Partial-Update | +| can delete an event | DELETE → 204 No Content | +| cannot delete another users event | 404 bei fremdem Event | +| batch sync creates updates and deletes | Alle 3 Aktionen in einem Request | +| sync is idempotent for creates | Doppelter Create = kein Fehler | + +### 5.2 Frontend Sync Service + +#### Neue Dateien + +| Datei | Zweck | +| ----------------------------- | ----------- | +| `src/services/syncService.js` | Sync-Engine | + +#### Geänderte Dateien + +| Datei | Änderung | +| ---------------------- | ----------------------------------------------- | +| `src/stores/events.js` | `startAutoSync()` bei Init wenn Token vorhanden | + +#### Sync-Ablauf + +``` +App Start + ↓ +Events aus IndexedDB laden + ↓ +Token vorhanden? → startAutoSync() + ↓ +┌─────────────────────────────────────┐ +│ fullSync() — alle 30s + reconnect │ +│ │ +│ 1. processSyncQueue() │ +│ → Sync Queue lesen (FIFO) │ +│ → POST /api/events/sync │ +│ → Erfolgreiche Items löschen │ +│ → syncStatus → 'synced' │ +│ │ +│ 2. pullRemoteChanges() │ +│ → GET /api/events?since=... │ +│ → Last-Write-Wins Merge │ +│ → Neue Events → IndexedDB │ +│ → Sync Cursor updaten │ +└─────────────────────────────────────┘ +``` + +#### Conflict Resolution + +**Last-Write-Wins** basierend auf `updatedAt`: + +- Remote neuer UND lokal `synced` → Remote übernehmen +- Lokal `modified` → Lokale Änderung behalten, wird via Sync Queue gepusht +- Neues Remote Event (nicht lokal vorhanden) → Einfügen + +#### Netzwerk-Erkennung + +```javascript +window.addEventListener('online', () => { + isOnline.value = true + processSyncQueue() // Sofort pushen bei Reconnect +}) +window.addEventListener('offline', () => { + isOnline.value = false +}) +``` + +#### Exports + +```javascript +import { + isOnline, // ref — Netzwerkstatus + isSyncing, // ref — Sync läuft gerade + lastSyncAt, // ref — Timestamp letzter Sync + getToken, // () → Promise + setToken, // (token) → Promise + apiFetch, // (path, options) → Promise + processSyncQueue, // () → Promise + pullRemoteChanges, // () → Promise + fullSync, // () → Promise + startAutoSync, // () → void — Startet 30s Intervall + stopAutoSync, // () → void — Stoppt Intervall +} from 'src/services/syncService' +``` + +--- + +## 6. Datenfluss-Übersicht + +``` +┌─────────────────────────────────────────────────────────────┐ +│ FRONTEND │ +│ │ +│ User → Vue Component → Pinia Store (ref sofort updaten) │ +│ ↓ │ +│ IndexedDB (Dexie.js) │ +│ ┌──────────────────┐ │ +│ │ events │ ← Alle Events │ +│ │ syncQueue │ ← Outbound Queue │ +│ │ imageCache │ ← Thumbnails │ +│ │ meta │ ← Token, Cursor │ +│ └──────────────────┘ │ +│ ↓ │ +│ Sync Service (30s) │ +│ Push Queue → Pull Changes │ +└──────────────────────────────┬──────────────────────────────┘ + │ + POST /api/events/sync + GET /api/events?since= + │ +┌──────────────────────────────┴──────────────────────────────┐ +│ BACKEND │ +│ │ +│ Laravel 12 + Passport OAuth2 │ +│ EventController → Event Model → MySQL │ +│ │ +│ events: id, client_id, user_id, title, date, emotion, ... │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 7. Noch offen (Phase 5) + +**Chunked Loading** — Erst bei 500+ Events relevant: + +- `src/services/chunkLoader.js` — Scroll-triggered Loading +- Nur 100 Events um aktuelle Scroll-Position laden +- Bei Scroll an Boundary: nächsten Chunk nachladen (200ms Debounce) +- API Cursor-Pagination für initiales Laden großer Datasets + +Wird erst implementiert wenn die Datenmenge es erfordert. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 88b8033..d9eaf63 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,7 @@ "hasInstallScript": true, "dependencies": { "@quasar/extras": "^1.16.4", - "gsap": "^3.13.0", + "dexie": "^4.3.0", "pinia": "^3.0.1", "quasar": "^2.16.0", "three": "^0.183.0", @@ -34,6 +34,11 @@ "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18", "npm": ">= 6.13.4", "yarn": ">= 1.21.1" + }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "^4.0.0", + "@rollup/rollup-linux-arm64-gnu": "^4.0.0", + "@rollup/rollup-linux-x64-gnu": "^4.0.0" } }, "node_modules/@babel/code-frame": { @@ -655,20 +660,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz", + "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", + "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" }, "engines": { @@ -717,9 +722,9 @@ "license": "MIT" }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz", + "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==", "dev": true, "license": "MIT", "engines": { @@ -1515,9 +1520,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -1529,9 +1534,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -1543,13 +1548,12 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1557,9 +1561,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -1571,9 +1575,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -1585,9 +1589,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -1599,9 +1603,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -1613,9 +1617,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -1627,13 +1631,12 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1641,9 +1644,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -1655,9 +1658,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -1669,9 +1672,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -1683,9 +1686,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -1697,9 +1700,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -1711,9 +1714,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -1725,9 +1728,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -1739,9 +1742,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -1753,13 +1756,12 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1767,9 +1769,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -1781,9 +1783,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -1795,9 +1797,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -1809,9 +1811,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -1823,9 +1825,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -1837,9 +1839,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1851,9 +1853,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -2077,13 +2079,13 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", - "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.28", + "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" @@ -2102,26 +2104,26 @@ } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", - "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", - "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.28", - "@vue/compiler-dom": "3.5.28", - "@vue/compiler-ssr": "3.5.28", - "@vue/shared": "3.5.28", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", @@ -2129,13 +2131,13 @@ } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", - "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" } }, "node_modules/@vue/devtools-api": { @@ -2187,53 +2189,53 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", - "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.28" + "@vue/shared": "3.5.29" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", - "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", - "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.28", - "@vue/runtime-core": "3.5.28", - "@vue/shared": "3.5.28", + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", - "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" }, "peerDependencies": { - "vue": "3.5.28" + "vue": "3.5.29" } }, "node_modules/@vue/shared": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", - "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", "license": "MIT" }, "node_modules/abort-controller": { @@ -2274,9 +2276,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2297,9 +2299,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2479,9 +2481,9 @@ } }, "node_modules/b4a": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.5.tgz", - "integrity": "sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", "dev": true, "license": "Apache-2.0", "peerDependencies": { @@ -2809,9 +2811,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001770", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", - "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "dev": true, "funding": [ { @@ -3329,6 +3331,12 @@ "node": ">=8" } }, + "node_modules/dexie": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.3.0.tgz", + "integrity": "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==", + "license": "Apache-2.0" + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -3415,9 +3423,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -3570,9 +3578,9 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.39.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz", + "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", "dependencies": { @@ -3582,7 +3590,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", + "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -4256,24 +4264,37 @@ "node": ">= 6" } }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz", + "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5072,9 +5093,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz", + "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==", "dev": true, "license": "ISC", "dependencies": { @@ -5829,9 +5850,9 @@ } }, "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz", + "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==", "dev": true, "license": "ISC", "dependencies": { @@ -5912,9 +5933,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -5928,31 +5949,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, @@ -7035,9 +7056,9 @@ } }, "node_modules/three": { - "version": "0.183.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.183.0.tgz", - "integrity": "sha512-G6SH2jfefIVa2YI4JL2VbgQhrrbp1A8dRc7lr3PW827kdVyaX2RgH6M5FmjmdVFLgSHppyg3OYOZdTfWElle+g==", + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.1.tgz", + "integrity": "sha512-Psv6bbd3d/M/01MT2zZ+VmD0Vj2dbWTNhfe4CuSg7w5TuW96M3NOyCVuh9SZQ05CpGmD7NEcJhZw4GVjhCYxfQ==", "license": "MIT" }, "node_modules/tiny-invariant": { @@ -8034,16 +8055,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.5.28", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", - "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.28", - "@vue/compiler-sfc": "3.5.28", - "@vue/runtime-dom": "3.5.28", - "@vue/server-renderer": "3.5.28", - "@vue/shared": "3.5.28" + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" }, "peerDependencies": { "typescript": "*" diff --git a/frontend/package.json b/frontend/package.json index e072091..91ed7f8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,12 +10,13 @@ "lint": "eslint -c ./eslint.config.js \"./src*/**/*.{js,cjs,mjs,vue}\"", "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore", "test": "echo \"No test specified\" && exit 0", - "dev": "quasar dev", + "dev": "npm install && quasar dev", "build": "quasar build", "postinstall": "quasar prepare" }, "dependencies": { "@quasar/extras": "^1.16.4", + "dexie": "^4.3.0", "pinia": "^3.0.1", "quasar": "^2.16.0", "three": "^0.183.0", @@ -23,6 +24,11 @@ "vue-router": "^4.0.0", "vue-select": "^4.0.0-beta.6" }, + "optionalDependencies": { + "@rollup/rollup-darwin-arm64": "^4.0.0", + "@rollup/rollup-linux-arm64-gnu": "^4.0.0", + "@rollup/rollup-linux-x64-gnu": "^4.0.0" + }, "devDependencies": { "@eslint/js": "^9.14.0", "@quasar/app-vite": "^2.1.0", @@ -40,4 +46,4 @@ "npm": ">= 6.13.4", "yarn": ">= 1.21.1" } -} +} \ No newline at end of file diff --git a/frontend/quasar.config.js b/frontend/quasar.config.js index 3ec6b18..b90233b 100644 --- a/frontend/quasar.config.js +++ b/frontend/quasar.config.js @@ -3,7 +3,7 @@ import { defineConfig } from '#q-app/wrappers' -export default defineConfig((/* ctx */) => { +export default defineConfig((ctx) => { return { // https://v2.quasar.dev/quasar-cli-vite/prefetch-feature // preFetch: true, @@ -11,13 +11,10 @@ export default defineConfig((/* ctx */) => { // app boot file (/src/boot) // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli-vite/boot-files - boot: [ - ], + boot: [], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css - css: [ - 'app.scss' - ], + css: ['app.scss'], // https://github.com/quasarframework/quasar/tree/dev/extras extras: [ @@ -36,8 +33,8 @@ export default defineConfig((/* ctx */) => { // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build build: { target: { - browser: [ 'es2022', 'firefox115', 'chrome115', 'safari14' ], - node: 'node20' + browser: ['es2022', 'firefox115', 'chrome115', 'safari14'], + node: 'node20', }, vueRouterMode: 'hash', // available values: 'hash', 'history' @@ -56,24 +53,34 @@ export default defineConfig((/* ctx */) => { // polyfillModulePreload: true, // distDir - // extendViteConf (viteConf) {}, + extendViteConf(viteConf) { + const srcDir = new URL('./src/composables', import.meta.url).pathname + viteConf.resolve.alias['composables'] = srcDir + }, // viteVuePluginOptions: {}, - - vitePlugins: [ - ['vite-plugin-checker', { - eslint: { - lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{js,mjs,cjs,vue}"', - useFlatConfig: true - } - }, { server: false }] - ] + + vitePlugins: + ctx.prod && ctx.modeName !== 'capacitor' + ? [ + [ + 'vite-plugin-checker', + { + eslint: { + lintCommand: 'eslint -c ./eslint.config.js "./src/**/*.{js,mjs,cjs,vue}"', + useFlatConfig: true, + }, + }, + { server: false }, + ], + ] + : [], }, // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver devServer: { // https: true, open: true, // opens browser window automatically - allowedHosts: ['app.thats-me.test'] + allowedHosts: ['app.thats-me.test'], }, // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework @@ -91,7 +98,7 @@ export default defineConfig((/* ctx */) => { // directives: [], // Quasar plugins - plugins: ['Dark'] + plugins: ['Dark'], }, htmlVariables: { @@ -118,10 +125,10 @@ export default defineConfig((/* ctx */) => { // https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr ssr: { prodPort: 3000, // The default port that the production server should use - // (gets superseded if process.env.PORT is specified at runtime) + // (gets superseded if process.env.PORT is specified at runtime) middlewares: [ - 'render' // keep this as last one + 'render', // keep this as last one ], // extendPackageJson (json) {}, @@ -132,7 +139,7 @@ export default defineConfig((/* ctx */) => { // manualStoreHydration: true, // manualPostHydrationTrigger: true, - pwa: false + pwa: false, // pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name! // pwaExtendGenerateSWOptions (cfg) {}, @@ -141,7 +148,7 @@ export default defineConfig((/* ctx */) => { // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa pwa: { - workboxMode: 'GenerateSW' // 'GenerateSW' or 'InjectManifest' + workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest' // swFilename: 'sw.js', // manifestFilename: 'manifest.json', // extendManifestJson (json) {}, @@ -159,7 +166,7 @@ export default defineConfig((/* ctx */) => { // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor capacitor: { - hideSplashscreen: true + hideSplashscreen: true, }, // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron @@ -170,7 +177,7 @@ export default defineConfig((/* ctx */) => { // extendPackageJson (json) {}, // Electron preload scripts (if any) from /src-electron, WITHOUT file extension - preloadScripts: [ 'electron-preload' ], + preloadScripts: ['electron-preload'], // specify the debugging port to use for the Electron app when running in development mode inspectPort: 5858, @@ -179,13 +186,11 @@ export default defineConfig((/* ctx */) => { packager: { // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options - // OS X / Mac App Store // appBundleId: '', // appCategoryType: '', // osxSign: '', // protocol: 'myapp://path', - // Windows only // win32metadata: { ... } }, @@ -193,8 +198,8 @@ export default defineConfig((/* ctx */) => { builder: { // https://www.electron.build/configuration/configuration - appId: 'thatsme-quasar' - } + appId: 'thatsme-quasar', + }, }, // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex @@ -210,7 +215,7 @@ export default defineConfig((/* ctx */) => { * * @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ] */ - extraScripts: [] - } + extraScripts: [], + }, } }) diff --git a/frontend/src-capacitor/capacitor.config.json b/frontend/src-capacitor/capacitor.config.json new file mode 100644 index 0000000..b4e059f --- /dev/null +++ b/frontend/src-capacitor/capacitor.config.json @@ -0,0 +1,21 @@ +{ + "appId": "media.adametz.thatsme", + "appName": "Thats Me", + "webDir": "www", + "plugins": { + "SplashScreen": { + "launchShowDuration": 0 + }, + "StatusBar": { + "overlaysWebView": false, + "style": "light", + "backgroundColor": "#000000" + } + }, + "ios": { + "contentInset": "never" + }, + "android": { + "allowMixedContent": false + } +} \ No newline at end of file diff --git a/frontend/src-capacitor/package-lock.json b/frontend/src-capacitor/package-lock.json new file mode 100644 index 0000000..048149f --- /dev/null +++ b/frontend/src-capacitor/package-lock.json @@ -0,0 +1,1094 @@ +{ + "name": "thatsme-quasar", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "thatsme-quasar", + "version": "0.0.1", + "dependencies": { + "@capacitor/app": "^7.0.0", + "@capacitor/cli": "^7.0.0", + "@capacitor/core": "^7.0.0", + "@capacitor/ios": "^7.5.0", + "@capacitor/status-bar": "^7.0.0" + } + }, + "node_modules/@capacitor/app": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-7.1.2.tgz", + "integrity": "sha512-d4I/oF/PRu4megL7/IGKYfe5j7yzSON1FRFgq6kH+m5kH6g7V+wyjHRLauCzGNjdRx4S+nWOumINds0qcRBtKg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, + "node_modules/@capacitor/cli": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.5.0.tgz", + "integrity": "sha512-mlohsvLZjWrO5eAVTn1+dABNQwQawcphVp6NQVJZ3I4x2BAoNmJj53QflX7PYGUipL9gF9EM9Yiku3m1McxFZg==", + "license": "MIT", + "dependencies": { + "@ionic/cli-framework-output": "^2.2.8", + "@ionic/utils-subprocess": "^3.0.1", + "@ionic/utils-terminal": "^2.3.5", + "commander": "^12.1.0", + "debug": "^4.4.0", + "env-paths": "^2.2.0", + "fs-extra": "^11.2.0", + "kleur": "^4.1.5", + "native-run": "^2.0.3", + "open": "^8.4.0", + "plist": "^3.1.0", + "prompts": "^2.4.2", + "rimraf": "^6.0.1", + "semver": "^7.6.3", + "tar": "^7.5.3", + "tslib": "^2.8.1", + "xml2js": "^0.6.2" + }, + "bin": { + "cap": "bin/capacitor", + "capacitor": "bin/capacitor" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@capacitor/core": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.5.0.tgz", + "integrity": "sha512-4Y4trISe2Bp3lwsoGFoQIvgX4hiZO8S1Slmbz6oFaMxAuEc4noipQGCQx974PF4glwVVe/8+H3P9iEmCXtrUgA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@capacitor/ios": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-7.5.0.tgz", + "integrity": "sha512-HlEWLjmPMSyjD8pM2FSTYF7a7aoYDdrlUoA3Ybm8OnmOaby3R7L8jpzJr96igh/i2SpsHvDI7v3sO2FhSNKCKA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": "^7.5.0" + } + }, + "node_modules/@capacitor/status-bar": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.5.tgz", + "integrity": "sha512-d0DI/Usy4RrNiV5xfnypXNRLmWYMixC2yAUAwe6sCQQ0HF7oskDf4RpCxcZqbxnpc4H4A0qqiOSltfJLFAYshg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, + "node_modules/@ionic/cli-framework-output": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ionic/cli-framework-output/-/cli-framework-output-2.2.8.tgz", + "integrity": "sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==", + "license": "MIT", + "dependencies": { + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-array": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz", + "integrity": "sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-fs/-/utils-fs-3.1.7.tgz", + "integrity": "sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==", + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^8.0.0", + "debug": "^4.0.0", + "fs-extra": "^9.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-fs/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@ionic/utils-object": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@ionic/utils-object/-/utils-object-2.1.6.tgz", + "integrity": "sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-process": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@ionic/utils-process/-/utils-process-2.1.12.tgz", + "integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==", + "license": "MIT", + "dependencies": { + "@ionic/utils-object": "2.1.6", + "@ionic/utils-terminal": "2.3.5", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "tree-kill": "^1.2.2", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@ionic/utils-stream/-/utils-stream-3.1.7.tgz", + "integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==", + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-subprocess": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz", + "integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==", + "license": "MIT", + "dependencies": { + "@ionic/utils-array": "2.1.6", + "@ionic/utils-fs": "3.1.7", + "@ionic/utils-process": "2.1.12", + "@ionic/utils-stream": "3.1.7", + "@ionic/utils-terminal": "2.3.5", + "cross-spawn": "^7.0.3", + "debug": "^4.0.0", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@ionic/utils-terminal": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz", + "integrity": "sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==", + "license": "MIT", + "dependencies": { + "@types/slice-ansi": "^4.0.0", + "debug": "^4.0.0", + "signal-exit": "^3.0.3", + "slice-ansi": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "tslib": "^2.0.1", + "untildify": "^4.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/fs-extra": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-8.1.5.tgz", + "integrity": "sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==", + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", + "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fs-extra": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.3.tgz", + "integrity": "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/minimatch": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/native-run": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/native-run/-/native-run-2.0.3.tgz", + "integrity": "sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==", + "license": "MIT", + "dependencies": { + "@ionic/utils-fs": "^3.1.7", + "@ionic/utils-terminal": "^2.3.4", + "bplist-parser": "^0.3.2", + "debug": "^4.3.4", + "elementtree": "^0.1.7", + "ini": "^4.1.1", + "plist": "^3.1.0", + "split2": "^4.2.0", + "through2": "^4.0.2", + "tslib": "^2.6.2", + "yauzl": "^2.10.0" + }, + "bin": { + "native-run": "bin/native-run" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "license": "ISC" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz", + "integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/frontend/src-capacitor/package.json b/frontend/src-capacitor/package.json new file mode 100644 index 0000000..688f5e5 --- /dev/null +++ b/frontend/src-capacitor/package.json @@ -0,0 +1,14 @@ +{ + "name": "thatsme-quasar", + "version": "0.0.1", + "description": " Thats me Quasar Project", + "author": "Kevin Adametz", + "private": true, + "dependencies": { + "@capacitor/app": "^7.0.0", + "@capacitor/cli": "^7.0.0", + "@capacitor/core": "^7.0.0", + "@capacitor/ios": "^7.5.0", + "@capacitor/status-bar": "^7.0.0" + } +} \ No newline at end of file diff --git a/frontend/src/components/AddEventButton.vue b/frontend/src/components/AddEventButton.vue index 485e17d..2a58325 100644 --- a/frontend/src/components/AddEventButton.vue +++ b/frontend/src/components/AddEventButton.vue @@ -20,7 +20,7 @@ const isDark = computed(() => $q.dark.isActive) bottom: 16px; left: 50%; transform: translateX(-50%); - z-index: 30; + z-index: 10; width: 44px; height: 44px; border-radius: 50%; diff --git a/frontend/src/components/AppSettingsModal.vue b/frontend/src/components/AppSettingsModal.vue new file mode 100644 index 0000000..09ab13b --- /dev/null +++ b/frontend/src/components/AppSettingsModal.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/frontend/src/components/EventPanel.vue b/frontend/src/components/EventPanel.vue index 3af7bdc..ea0d2de 100644 --- a/frontend/src/components/EventPanel.vue +++ b/frontend/src/components/EventPanel.vue @@ -1,8 +1,16 @@ @@ -76,14 +125,12 @@ function onSelect() { transition: opacity 0.3s ease, transform 0.15s ease; } -/* Clean inner circle — shader provides the glow around it */ .glow-dot__inner { position: relative; width: 100%; height: 100%; border-radius: 50%; background: #fff; - overflow: hidden; } .glow-dot__image { @@ -91,20 +138,63 @@ function onSelect() { height: 100%; object-fit: cover; display: block; + border-radius: 50%; } /* States */ .glow-dot--ghost { - opacity: 0.7; + opacity: 1; cursor: default; } + + .glow-dot--selected { transform: translate(-50%, -50%) scale(1.15); z-index: 15; } .glow-dot--dimmed { - opacity: 0.5; + opacity: 1; +} + +/* Event label (title + date) */ +.glow-dot__label { + position: absolute; + left: 50%; + transform: translateX(-50%); + top: calc(100% + 6px); + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + max-width: 90px; + pointer-events: none; +} + +/* When dot is in lower half, show label above */ +.glow-dot--label-above .glow-dot__label { + top: auto; + bottom: calc(100% + 6px); +} + +.glow-dot__title { + font-size: 10px; + font-weight: 600; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 90px; + text-align: center; + line-height: 1.2; +} + +.glow-dot__date { + font-size: 9px; + font-weight: 400; + opacity: 0.4; + white-space: nowrap; + line-height: 1.2; } diff --git a/frontend/src/components/LifeWaveSettings.vue b/frontend/src/components/LifeWaveSettings.vue index 95bdaef..c36579a 100644 --- a/frontend/src/components/LifeWaveSettings.vue +++ b/frontend/src/components/LifeWaveSettings.vue @@ -1,8 +1,16 @@ + + diff --git a/frontend/src/components/TimelineView.vue b/frontend/src/components/TimelineView.vue index e1c0724..1101565 100644 --- a/frontend/src/components/TimelineView.vue +++ b/frontend/src/components/TimelineView.vue @@ -1,51 +1,70 @@ @@ -117,51 +136,123 @@ const eventLabels = computed(() => { }) }) -// Year markers — shown between events where the year changes -const yearMarkers = computed(() => { - const markers = [] - const sorted = displayEvents.value - if (sorted.length === 0) return markers +// Year ranges — for each year, the world-space X range of its events +const yearRanges = computed(() => { + const ranges = [] + const events = displayEvents.value + if (events.length === 0) return ranges - // First event's year - const firstDate = new Date(sorted[0].date) - markers.push({ - key: `year-0`, - year: firstDate.getFullYear(), - x: getEventX(0) - }) + let currentYear = new Date(events[0].date).getFullYear() + let startIdx = 0 - for (let i = 1; i < sorted.length; i++) { - const prevYear = new Date(sorted[i - 1].date).getFullYear() - const currYear = new Date(sorted[i].date).getFullYear() - if (currYear !== prevYear) { - // Position between the two events - const x = (getEventX(i - 1) + getEventX(i)) / 2 - markers.push({ - key: `year-${i}`, - year: currYear, - x - }) + for (let i = 1; i < events.length; i++) { + const year = new Date(events[i].date).getFullYear() + if (year !== currentYear) { + ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) }) + currentYear = year + startIdx = i } } - return markers + ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(events.length - 1) }) + return ranges }) -// Active label — closest event to center of viewport -const activeLabel = computed(() => { - const sorted = displayEvents.value - if (sorted.length === 0) return null - const centerX = scrollLeft.value + viewportWidth.value / 2 - let closestIndex = 0 - let closestDist = Infinity - for (let i = 0; i < sorted.length; i++) { - const dist = Math.abs(getEventX(i) - centerX) - if (dist < closestDist) { - closestDist = dist - closestIndex = i - } +// All years that exist in the data (for prev/next navigation) +const allYears = computed(() => yearRanges.value.map(r => r.year)) + +// Sticky year labels — positioned relative to viewport, clamped to edges +const YEAR_MARGIN = 24 +const stickyYearLabels = computed(() => { + const sl = scrollLeft.value + const vw = viewportWidth.value + const viewLeft = sl + const viewRight = sl + vw + const years = allYears.value + + // Find years whose event range overlaps the viewport + const visible = yearRanges.value.filter(r => r.endX >= viewLeft && r.startX <= viewRight) + if (visible.length === 0) return [] + + function makeLabel(year, left) { + const idx = years.indexOf(year) + return { year, left, hasPrev: idx > 0, hasNext: idx < years.length - 1 } } - return eventLabels.value[closestIndex]?.key ?? null + + if (visible.length === 1) { + const r = visible[0] + const visStart = Math.max(r.startX, viewLeft) + const visEnd = Math.min(r.endX, viewRight) + const center = (visStart + visEnd) / 2 - sl + const clamped = Math.max(YEAR_MARGIN, Math.min(vw - YEAR_MARGIN, center)) + return [makeLabel(r.year, clamped)] + } + + // Multiple years: first pins left, last pins right, middles float naturally + const result = [] + for (let i = 0; i < visible.length; i++) { + const r = visible[i] + const visStart = Math.max(r.startX, viewLeft) + const visEnd = Math.min(r.endX, viewRight) + const center = (visStart + visEnd) / 2 - sl + + let pos + if (i === 0) { + pos = Math.max(YEAR_MARGIN, Math.min(vw / 2 - 30, center)) + } else if (i === visible.length - 1) { + pos = Math.min(vw - YEAR_MARGIN, Math.max(vw / 2 + 30, center)) + } else { + pos = Math.max(YEAR_MARGIN + 60, Math.min(vw - YEAR_MARGIN - 60, center)) + } + result.push(makeLabel(r.year, pos)) + } + return result +}) + +// Virtualization: only render events near the viewport +const VIS_BUFFER = 2 + +const visibleRange = computed(() => { + const total = displayEvents.value.length + if (total === 0) return { start: 0, end: -1 } + const spacing = EVENT_SPACING.value + if (spacing <= 0) return { start: 0, end: total - 1 } + const start = Math.max(0, + Math.floor((scrollLeft.value - PADDING.value) / spacing) - VIS_BUFFER + ) + const end = Math.min(total - 1, + Math.ceil((scrollLeft.value + viewportWidth.value - PADDING.value) / spacing) + VIS_BUFFER + ) + return { start, end } +}) + +const visibleEvents = computed(() => { + const { start, end } = visibleRange.value + if (end < start) return [] + return displayEvents.value.slice(start, end + 1).map((event, i) => ({ + event, + globalIndex: start + i + })) +}) + +const visibleLabels = computed(() => { + const { start, end } = visibleRange.value + if (end < start) return [] + return eventLabels.value.slice(start, end + 1).map((label, i) => ({ + label, + globalIndex: start + i + })) +}) + +// Active label — closest event to center of viewport (O(1) via index) +const activeLabel = computed(() => { + const total = displayEvents.value.length + if (total === 0) return null + const centerX = scrollLeft.value + viewportWidth.value / 2 + const spacing = EVENT_SPACING.value + if (spacing <= 0) return null + const index = Math.round((centerX - PADDING.value) / spacing) + const clamped = Math.max(0, Math.min(total - 1, index)) + return eventLabels.value[clamped]?.key ?? null }) function onScroll() { @@ -179,10 +270,19 @@ function scrollToIndex(index) { }) } -function scrollToX(x) { +function scrollToYearCenter(year) { if (!timelineRef.value) return + // Find exact year, or nearest in the requested direction + let range = yearRanges.value.find(r => r.year === year) + if (!range) { + // Find closest year + const sorted = [...yearRanges.value].sort((a, b) => Math.abs(a.year - year) - Math.abs(b.year - year)) + range = sorted[0] + } + if (!range) return + const centerX = (range.startX + range.endX) / 2 timelineRef.value.scrollTo({ - left: x - viewportWidth.value / 2, + left: centerX - viewportWidth.value / 2, behavior: 'smooth' }) } @@ -273,10 +373,13 @@ function onTouchEnd() { let resizeObserver = null // Emit timeline state so the layout can position shader points function emitViewState() { + const { start, end } = visibleRange.value emit('viewUpdate', { scrollLeft: scrollLeft.value, viewportWidth: viewportWidth.value, containerHeight: containerHeight.value, + visibleStart: start, + visibleEnd: end, events: displayEvents.value.map((e, i) => ({ emotion: e.emotion, x: getEventX(i), @@ -313,15 +416,32 @@ onMounted(async () => { onUnmounted(() => { resizeObserver?.disconnect() }) + +function zoomIn() { + const newZoom = Math.min(MAX_ZOOM, zoomLevel.value + ZOOM_STEP * 2) + if (newZoom !== zoomLevel.value) applyZoom(newZoom) +} + +function zoomOut() { + const newZoom = Math.max(MIN_ZOOM, zoomLevel.value - ZOOM_STEP * 2) + if (newZoom !== zoomLevel.value) applyZoom(newZoom) +} + +defineExpose({ timelineRef, zoomIn, zoomOut, zoomLevel, MIN_ZOOM, MAX_ZOOM }) diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue new file mode 100644 index 0000000..d78d130 --- /dev/null +++ b/frontend/src/components/UserMenu.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/frontend/src/components/ZoomControl.vue b/frontend/src/components/ZoomControl.vue new file mode 100644 index 0000000..c53bb45 --- /dev/null +++ b/frontend/src/components/ZoomControl.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/frontend/src/composables/useImageCache.js b/frontend/src/composables/useImageCache.js new file mode 100644 index 0000000..8685a28 --- /dev/null +++ b/frontend/src/composables/useImageCache.js @@ -0,0 +1,175 @@ +import { ref } from 'vue' +import { db } from 'src/db' + +const THUMB_SIZE = 200 + +// In-memory URL cache: avoids repeated IndexedDB reads and blob URL creation +// Shared across all component instances +const memoryCache = new Map() + +/** + * Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob. + * Returns a new Blob (JPEG, quality 0.8). + */ +function createThumbnail(blob) { + return new Promise((resolve, reject) => { + const img = new Image() + const url = URL.createObjectURL(blob) + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = THUMB_SIZE + canvas.height = THUMB_SIZE + + const ctx = canvas.getContext('2d') + // Cover crop: center the image + const scale = Math.max(THUMB_SIZE / img.width, THUMB_SIZE / img.height) + const w = img.width * scale + const h = img.height * scale + const x = (THUMB_SIZE - w) / 2 + const y = (THUMB_SIZE - h) / 2 + ctx.drawImage(img, x, y, w, h) + + canvas.toBlob( + (thumbBlob) => { + URL.revokeObjectURL(url) + if (thumbBlob) resolve(thumbBlob) + else reject(new Error('Canvas toBlob failed')) + }, + 'image/jpeg', + 0.8 + ) + } + img.onerror = () => { + URL.revokeObjectURL(url) + reject(new Error('Image load failed')) + } + img.src = url + }) +} + +/** + * Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL. + */ +async function fetchAndCache(imageUrl, eventId) { + const response = await fetch(imageUrl) + if (!response.ok) throw new Error(`Fetch failed: ${response.status}`) + const blob = await response.blob() + + // Create thumbnail + const thumbBlob = await createThumbnail(blob) + + // Store in IndexedDB + await db.imageCache.put({ + url: imageUrl, + eventId, + type: 'thumbnail', + blob: thumbBlob, + cachedAt: Date.now() + }) + + const blobUrl = URL.createObjectURL(thumbBlob) + memoryCache.set(imageUrl, blobUrl) + return blobUrl +} + +/** + * Get a cached thumbnail blob URL from IndexedDB. + * Returns null if not cached. + */ +async function getCachedImage(imageUrl) { + // Check memory first + if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl) + + try { + const entry = await db.imageCache.get(imageUrl) + if (entry?.blob) { + const blobUrl = URL.createObjectURL(entry.blob) + memoryCache.set(imageUrl, blobUrl) + return blobUrl + } + } catch (e) { + console.warn('Image cache read failed:', e) + } + return null +} + +/** + * Composable: resolves an event's image to a displayable src. + * - Checks memory cache → IndexedDB cache → fetches & caches thumbnail. + * - Returns reactive `resolvedSrc` ref. + */ +export function useImageCache(imageUrl, eventId) { + const resolvedSrc = ref(null) + const loading = ref(false) + + async function resolve() { + if (!imageUrl) { + resolvedSrc.value = null + return + } + + // 1. Memory cache (instant) + if (memoryCache.has(imageUrl)) { + resolvedSrc.value = memoryCache.get(imageUrl) + return + } + + // 2. IndexedDB cache + const cached = await getCachedImage(imageUrl) + if (cached) { + resolvedSrc.value = cached + return + } + + // 3. Fetch, create thumbnail, cache + loading.value = true + try { + const blobUrl = await fetchAndCache(imageUrl, eventId) + resolvedSrc.value = blobUrl + } catch (e) { + // Fallback: use original URL directly (works when online) + console.warn('Image cache failed, using direct URL:', e) + resolvedSrc.value = imageUrl + } finally { + loading.value = false + } + } + + resolve() + + return { resolvedSrc, loading } +} + +/** + * Resolve full-res image for EventPanel (no thumbnail, just cache check). + * Returns the original URL — browser Cache-Control handles caching. + * When offline, falls back to cached thumbnail. + */ +export async function resolveFullRes(imageUrl) { + if (!imageUrl) return null + + // If online, return original URL (browser caches via HTTP headers) + if (navigator.onLine) return imageUrl + + // Offline: try cached thumbnail as fallback + const cached = await getCachedImage(imageUrl) + return cached || imageUrl +} + +/** + * Clear all cached images for a specific event. + */ +export async function clearEventImages(eventId) { + try { + const entries = await db.imageCache.where('eventId').equals(eventId).toArray() + for (const entry of entries) { + if (memoryCache.has(entry.url)) { + URL.revokeObjectURL(memoryCache.get(entry.url)) + memoryCache.delete(entry.url) + } + } + await db.imageCache.where('eventId').equals(eventId).delete() + } catch (e) { + console.warn('Clear event images failed:', e) + } +} diff --git a/frontend/src/composables/usePanelDrag.js b/frontend/src/composables/usePanelDrag.js new file mode 100644 index 0000000..98a6896 --- /dev/null +++ b/frontend/src/composables/usePanelDrag.js @@ -0,0 +1,137 @@ +import { ref, onBeforeUnmount } from 'vue' + +/** + * Composable for draggable bottom-sheet panels with snap points. + * + * Snap stops (in dvh): 100, 75, 50 + * Close threshold: below 25dvh + * + * @param {Function} onClose - called when panel is dragged below threshold + * @returns {{ panelHeight, handleListeners, resetHeight }} + */ +export function usePanelDrag(onClose) { + const SNAP_POINTS = [100, 75, 50, 25] // dvh values + const CLOSE_THRESHOLD = 15 // below this → close + + // Current panel height in dvh (null = use CSS default) + const panelHeight = ref(null) + const isDragging = ref(false) + + let dragging = false + let startY = 0 + let startHeight = 0 + + function getViewportHeight() { + return window.innerHeight + } + + function pxToDvh(px) { + return (px / getViewportHeight()) * 100 + } + + function findNearestSnap(dvh) { + let nearest = SNAP_POINTS[0] + let minDist = Infinity + for (const snap of SNAP_POINTS) { + const dist = Math.abs(dvh - snap) + if (dist < minDist) { + minDist = dist + nearest = snap + } + } + return nearest + } + + function onPointerDown(e) { + // Only primary button / single touch + if (e.button && e.button !== 0) return + dragging = true + isDragging.value = true + + const clientY = e.touches ? e.touches[0].clientY : e.clientY + startY = clientY + + // Current height: if panelHeight is set use it, else measure from CSS + const currentDvh = panelHeight.value ?? 75 + startHeight = currentDvh + + document.addEventListener('pointermove', onPointerMove, { passive: false }) + document.addEventListener('pointerup', onPointerUp) + document.addEventListener('touchmove', onTouchMove, { passive: false }) + document.addEventListener('touchend', onTouchEnd) + + // Prevent text selection + e.preventDefault() + } + + function onPointerMove(e) { + if (!dragging) return + const clientY = e.clientY + handleMove(clientY) + } + + function onTouchMove(e) { + if (!dragging) return + if (e.touches.length !== 1) return + handleMove(e.touches[0].clientY) + e.preventDefault() + } + + function handleMove(clientY) { + const deltaY = clientY - startY + const deltaDvh = pxToDvh(deltaY) + const newHeight = Math.max(10, Math.min(100, startHeight - deltaDvh)) + panelHeight.value = newHeight + } + + function onPointerUp() { + finishDrag() + } + + function onTouchEnd() { + finishDrag() + } + + function finishDrag() { + if (!dragging) return + dragging = false + isDragging.value = false + + cleanup() + + const currentHeight = panelHeight.value ?? 75 + if (currentHeight < CLOSE_THRESHOLD) { + panelHeight.value = null + onClose() + } else { + // Snap to nearest point + panelHeight.value = findNearestSnap(currentHeight) + } + } + + function cleanup() { + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('pointerup', onPointerUp) + document.removeEventListener('touchmove', onTouchMove) + document.removeEventListener('touchend', onTouchEnd) + } + + function resetHeight() { + panelHeight.value = null + } + + onBeforeUnmount(cleanup) + + // Event listeners to bind on the handle element + const handleListeners = { + pointerdown: onPointerDown, + touchstart: onPointerDown, + } + + return { + panelHeight, + isDragging, + handleListeners, + resetHeight, + } +} diff --git a/frontend/src/db/index.js b/frontend/src/db/index.js new file mode 100644 index 0000000..85e7dbe --- /dev/null +++ b/frontend/src/db/index.js @@ -0,0 +1,17 @@ +import Dexie from 'dexie' + +export const db = new Dexie('thatsMeDB') + +db.version(1).stores({ + // Events: indexed by id (PK), date for sorted queries, syncStatus for dirty tracking + events: 'id, date, updatedAt, syncStatus', + + // Sync queue: outbound mutations waiting to be pushed to server + syncQueue: '++queueId, eventId, action, createdAt', + + // Image cache: offline blob storage for thumbnails + imageCache: 'url, eventId, type, cachedAt', + + // Metadata: key-value pairs (lastSyncCursor, userId, etc.) + meta: 'key' +}) diff --git a/frontend/src/layouts/LifeWaveLayout.vue b/frontend/src/layouts/LifeWaveLayout.vue index 63fffee..de8fa40 100644 --- a/frontend/src/layouts/LifeWaveLayout.vue +++ b/frontend/src/layouts/LifeWaveLayout.vue @@ -2,12 +2,14 @@
-
+ +
+ {{ floatingLinesRef?.fpsDisplay ?? 0 }} FPS + {{ floatingLinesRef?.dprDisplay ?? '0' }}x +
+
+ + + + + + @@ -64,7 +95,7 @@
@@ -74,6 +105,37 @@ :open="settingsOpen && !eventsStore.panelOpen" @close="settingsOpen = false" /> + + + +
+ + + + + + + +
+ + + +
@@ -85,6 +147,9 @@ import EventPanel from 'components/EventPanel.vue' import FloatingLines from 'components/FloatingLines.vue' import LifeWaveSettings from 'components/LifeWaveSettings.vue' import TimelineView from 'components/TimelineView.vue' +import AppSettingsModal from 'components/AppSettingsModal.vue' +import UserMenu from 'components/UserMenu.vue' +import ZoomControl from 'components/ZoomControl.vue' import { useEventsStore } from 'stores/events' import { useSettingsStore } from 'stores/settings' @@ -93,8 +158,15 @@ const eventsStore = useEventsStore() const settingsStore = useSettingsStore() const isDark = computed(() => $q.dark.isActive) const settingsOpen = ref(false) +const userMenuOpen = ref(false) +const appSettingsOpen = ref(false) +const floatingLinesRef = ref(null) const fl = computed(() => settingsStore.floatingLines) +// Timeline view ref (for direct scroll access in render loop) +const timelineViewRef = ref(null) +const scrollContainerEl = computed(() => timelineViewRef.value?.timelineRef ?? null) + // Layout dimensions (for screen→UV conversion) const layoutRef = ref(null) const layoutWidth = ref(window.innerWidth) @@ -139,33 +211,59 @@ function screenToUV(sx, sy) { } // Compute shader point positions from event positions -const TIMELINE_TOP = 60 // CSS: .timeline { top: 60px } +const TIMELINE_TOP = 40 // CSS: .timeline-container { top: 40px } -const shaderNumPoints = computed(() => { - if (!timelineState.value) return 0 - return Math.min(timelineState.value.events.length, 8) +// Select up to 8 points from visible window + boundary events for shader lines +const shaderSelection = computed(() => { + if (!timelineState.value) return [] + const { events, visibleStart, visibleEnd } = timelineState.value + if (events.length === 0) return [] + + // Include 3 events before and after visible range for smooth line continuity + const rangeStart = Math.max(0, (visibleStart ?? 0) - 3) + const rangeEnd = Math.min(events.length - 1, (visibleEnd ?? events.length - 1) + 3) + + let candidates = events.slice(rangeStart, rangeEnd + 1) + + // If more than 16, subsample evenly (keep first + last) + if (candidates.length > 16) { + const sampled = [candidates[0]] + const step = (candidates.length - 1) / 15 + for (let i = 1; i < 15; i++) { + sampled.push(candidates[Math.round(i * step)]) + } + sampled.push(candidates[candidates.length - 1]) + candidates = sampled + } + + return candidates }) -const shaderPointX = computed(() => { - const xs = Array(8).fill(0) - if (!timelineState.value) return xs - const { scrollLeft, events } = timelineState.value - const count = Math.min(events.length, 8) - for (let i = 0; i < count; i++) { - const screenX = events[i].x - scrollLeft - xs[i] = screenToUV(screenX, 0).x +const shaderNumPoints = computed(() => shaderSelection.value.length) + +// Base X positions in UV space WITHOUT scroll offset. +// FloatingLines applies the live scrollLeft in its render loop for perfect sync. +const shaderPointXBase = computed(() => { + const xs = Array(16).fill(0) + const sel = shaderSelection.value + const w = layoutWidth.value + const h = layoutHeight.value + for (let i = 0; i < sel.length; i++) { + xs[i] = (2 * sel[i].x - w) / h } return xs }) +// Scale factor to convert scrollLeft pixels → UV offset +const scrollUvScale = computed(() => 2.0 / layoutHeight.value) + const shaderPointY = computed(() => { - const ys = Array(8).fill(0) + const ys = Array(16).fill(0) if (!timelineState.value) return ys - const { containerHeight: tlHeight, events } = timelineState.value - const count = Math.min(events.length, 8) - for (let i = 0; i < count; i++) { - // GlowDot: top = (50 - emotion*35)% of timeline container - const yPercent = 50 - events[i].emotion * 35 + const sel = shaderSelection.value + const tlHeight = timelineState.value.containerHeight + for (let i = 0; i < sel.length; i++) { + const yPercent = 48 - sel[i].emotion * 30 const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight ys[i] = screenToUV(0, screenY).y } @@ -173,14 +271,7 @@ const shaderPointY = computed(() => { }) const shaderPointColors = computed(() => { - if (!timelineState.value) return [] - const { events } = timelineState.value - const count = Math.min(events.length, 8) - const colors = [] - for (let i = 0; i < count; i++) { - colors.push(events[i].color || '#ffffff') - } - return colors + return shaderSelection.value.map(e => e.color || '#ffffff') }) // Parse gradient stops from textarea string @@ -191,6 +282,29 @@ const parsedGradient = computed(() => { .filter(s => s.length > 0 && s.startsWith('#')) }) +// Zoom state from TimelineView +const currentZoom = computed(() => timelineViewRef.value?.zoomLevel ?? 1) +const zoomMin = computed(() => timelineViewRef.value?.MIN_ZOOM ?? 0.4) +const zoomMax = computed(() => timelineViewRef.value?.MAX_ZOOM ?? 3.0) + +function onZoomTo(value) { + if (!timelineViewRef.value) return + const clamped = Math.min(zoomMax.value, Math.max(zoomMin.value, value)) + // Use applyZoom exposed or set directly — we use the internal method indirectly + // by computing step from current to target + const tv = timelineViewRef.value + const el = tv.timelineRef + if (!el) return + const cx = el.clientWidth / 2 + const worldX = el.scrollLeft + cx + const ratio = clamped / tv.zoomLevel + tv.zoomLevel = clamped + // Restore scroll position to keep center stable + requestAnimationFrame(() => { + el.scrollLeft = worldX * ratio - cx + }) +} + const toggleDarkMode = () => { $q.dark.toggle() } @@ -203,6 +317,13 @@ const onDotSelect = (id) => { eventsStore.selectEvent(id) eventsStore.openPanel(id) } + +const onUserMenuNavigate = (target) => { + userMenuOpen.value = false + if (target === 'settings') { + appSettingsOpen.value = true + } +} diff --git a/frontend/src/services/syncService.js b/frontend/src/services/syncService.js new file mode 100644 index 0000000..f360f61 --- /dev/null +++ b/frontend/src/services/syncService.js @@ -0,0 +1,253 @@ +import { ref } from 'vue' +import { db } from 'src/db' + +// API base URL — configured per environment +const API_BASE = import.meta.env.VITE_API_BASE || '/api' + +const isSyncing = ref(false) +const isOnline = ref(navigator.onLine) +const lastSyncAt = ref(null) + +// Track online status +window.addEventListener('online', () => { + isOnline.value = true + processSyncQueue() +}) +window.addEventListener('offline', () => { + isOnline.value = false +}) + +/** + * Get the stored OAuth access token. + */ +async function getToken() { + try { + const meta = await db.meta.get('accessToken') + return meta?.value || null + } catch { + return null + } +} + +/** + * Store an OAuth access token. + */ +async function setToken(token) { + await db.meta.put({ key: 'accessToken', value: token }) +} + +/** + * Authenticated fetch wrapper. + */ +async function apiFetch(path, options = {}) { + const token = await getToken() + if (!token) throw new Error('Not authenticated') + + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${token}`, + ...options.headers, + }, + }) + + if (response.status === 401) { + // Token expired — clear it + await db.meta.delete('accessToken') + throw new Error('Unauthorized') + } + + return response +} + +/** + * Process the outbound sync queue (FIFO). + * Called on app start, every 30s when online, and on reconnect. + */ +async function processSyncQueue() { + if (!isOnline.value || isSyncing.value) return + + const token = await getToken() + if (!token) return + + isSyncing.value = true + + try { + const queue = await db.syncQueue.orderBy('queueId').toArray() + if (queue.length === 0) { + isSyncing.value = false + return + } + + // Batch sync: send up to 100 mutations at once + const batch = queue.slice(0, 100) + const mutations = batch.map((item) => ({ + action: item.action, + eventId: item.eventId, + payload: item.payload, + })) + + const response = await apiFetch('/events/sync', { + method: 'POST', + body: JSON.stringify({ mutations }), + }) + + if (response.ok) { + const data = await response.json() + + // Remove successfully processed items from queue + const processedIds = [] + data.results.forEach((result, i) => { + if (result.status === 'ok') { + processedIds.push(batch[i].queueId) + } + }) + + if (processedIds.length > 0) { + await db.syncQueue.bulkDelete(processedIds) + } + + // Update syncStatus on local events + for (const result of data.results) { + if (result.status === 'ok') { + const event = await db.events.get(result.eventId) + if (event && event.syncStatus !== 'local') { + await db.events.update(result.eventId, { syncStatus: 'synced' }) + } + } + } + + lastSyncAt.value = Date.now() + + // If there are more items, process next batch + if (queue.length > 100) { + await processSyncQueue() + } + } + } catch (e) { + console.warn('Sync queue processing failed:', e) + } finally { + isSyncing.value = false + } +} + +/** + * Pull remote changes since last sync cursor. + * Merges with local data using "last write wins" on updatedAt. + */ +async function pullRemoteChanges() { + if (!isOnline.value) return + + const token = await getToken() + if (!token) return + + try { + const lastSync = await db.meta.get('lastSyncCursor') + const since = lastSync?.value || null + + let url = '/events?limit=200' + if (since) { + url += `&since=${since}` + } + + const response = await apiFetch(url) + if (!response.ok) return + + const data = await response.json() + const remoteEvents = data.data || [] + + for (const remote of remoteEvents) { + const local = await db.events.get(remote.id) + + if (!local) { + // New event from server + await db.events.put({ + id: remote.id, + title: remote.title, + date: remote.date, + emotion: remote.emotion, + customColor: remote.customColor, + gradientPreset: remote.gradientPreset, + image: remote.image, + note: remote.note, + syncStatus: 'synced', + createdAt: remote.createdAt, + updatedAt: remote.updatedAt, + }) + } else if (remote.updatedAt > local.updatedAt && local.syncStatus === 'synced') { + // Remote is newer and local hasn't been modified — update + await db.events.update(remote.id, { + title: remote.title, + date: remote.date, + emotion: remote.emotion, + customColor: remote.customColor, + gradientPreset: remote.gradientPreset, + image: remote.image, + note: remote.note, + syncStatus: 'synced', + updatedAt: remote.updatedAt, + }) + } + // If local is modified, skip — local changes will be pushed via sync queue + } + + // Update sync cursor + await db.meta.put({ key: 'lastSyncCursor', value: new Date().toISOString() }) + + // Handle pagination (cursor-based) + if (data.next_cursor) { + // There are more pages — but for now we only pull one batch + // Future: iterate through pages + } + + lastSyncAt.value = Date.now() + } catch (e) { + console.warn('Pull remote changes failed:', e) + } +} + +/** + * Full sync: push local changes, then pull remote. + */ +async function fullSync() { + await processSyncQueue() + await pullRemoteChanges() +} + +// Auto-sync interval (30s) +let syncInterval = null + +function startAutoSync() { + if (syncInterval) return + syncInterval = setInterval(() => { + if (isOnline.value) { + fullSync() + } + }, 30000) + + // Initial sync + fullSync() +} + +function stopAutoSync() { + if (syncInterval) { + clearInterval(syncInterval) + syncInterval = null + } +} + +export { + isOnline, + isSyncing, + lastSyncAt, + getToken, + setToken, + apiFetch, + processSyncQueue, + pullRemoteChanges, + fullSync, + startAutoSync, + stopAutoSync, +} diff --git a/frontend/src/stores/events.js b/frontend/src/stores/events.js index 7afa82c..ab4eb55 100644 --- a/frontend/src/stores/events.js +++ b/frontend/src/stores/events.js @@ -1,5 +1,7 @@ import { defineStore } from 'pinia' import { ref, computed, watch } from 'vue' +import { db } from 'src/db' +import { startAutoSync, getToken } from 'src/services/syncService' // Color interpolation function lerpColor(a, b, t) { @@ -33,7 +35,6 @@ const GRADIENT_PRESETS = [ function emotionToColor(emotion, gradientIdx = null) { const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null if (preset) { - // 3-stop gradient: negative → neutral → positive const [neg, mid, pos] = preset.colors if (emotion >= 0) { return lerpColor(mid, pos, emotion) @@ -41,7 +42,6 @@ function emotionToColor(emotion, gradientIdx = null) { return lerpColor(mid, neg, Math.abs(emotion)) } } - // Default: 6-stop interpolation if (emotion >= 0) { if (emotion < 0.5) { return lerpColor('#FF6B35', '#FFD700', emotion / 0.5) @@ -56,114 +56,162 @@ function emotionToColor(emotion, gradientIdx = null) { } } -// Demo data — 8 events, 4 with images, 4 without +// Demo seed data const demoEvents = [ - { - id: crypto.randomUUID(), - title: 'Erster Schultag', - date: '1995-09-01', - emotion: 0.6, - customColor: null, - gradientPreset: null, - image: null, - note: '', - createdAt: Date.now(), - updatedAt: Date.now() - }, - { - id: crypto.randomUUID(), - title: 'Abiball', - date: '2004-06-25', - emotion: 0.85, - customColor: null, - gradientPreset: 1, - image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', - note: 'Was für eine Party!', - createdAt: Date.now(), - updatedAt: Date.now() - }, - { - id: crypto.randomUUID(), - title: 'Trennung', - date: '2010-03-15', - emotion: -0.7, - customColor: null, - gradientPreset: null, - image: null, - note: '', - createdAt: Date.now(), - updatedAt: Date.now() - }, - { - id: crypto.randomUUID(), - title: 'Bergwanderung', - date: '2014-08-12', - emotion: 0.75, - customColor: null, - gradientPreset: 4, - image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', - note: 'Unvergesslicher Ausblick', - createdAt: Date.now(), - updatedAt: Date.now() - }, - { - id: crypto.randomUUID(), - title: 'Jobverlust', - date: '2016-11-03', - emotion: -0.6, - customColor: null, - gradientPreset: null, - image: null, - note: '', - createdAt: Date.now(), - updatedAt: Date.now() - }, - { - id: crypto.randomUUID(), - title: 'Hochzeit', - date: '2018-07-20', - emotion: 0.95, - customColor: null, - gradientPreset: 5, - image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', - note: 'Der schönste Tag', - createdAt: Date.now(), - updatedAt: Date.now() - }, - { - id: crypto.randomUUID(), - title: 'Umzug', - date: '2021-04-01', - emotion: -0.3, - customColor: null, - gradientPreset: null, - image: null, - note: '', - createdAt: Date.now(), - updatedAt: Date.now() - }, - { - id: crypto.randomUUID(), - title: 'Neuer Job', - date: '2023-01-10', - emotion: 0.5, - customColor: null, - gradientPreset: null, - image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', - note: 'Neues Kapitel', - createdAt: Date.now(), - updatedAt: Date.now() - } + { id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', emotion: 0.85, customColor: null, gradientPreset: 1, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', emotion: 0.75, customColor: null, gradientPreset: 4, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', emotion: 0.95, customColor: null, gradientPreset: 5, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, + { id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() } ] -export { emotionToColor, GRADIENT_PRESETS } +// Generate realistic demo events for testing at scale +function generateManyEvents(count = 500) { + // Realistic life event categories with emotion ranges + const categories = [ + // Positive events + { titles: ['Geburtstag', 'Geburtstagsfeier', 'Überraschungsparty'], emotionRange: [0.3, 0.8], noteChance: 0.4, notes: ['Tolles Fest!', 'Viele Geschenke', 'Schöner Tag mit Freunden', 'Alles Gute!'] }, + { titles: ['Urlaub', 'Strandurlaub', 'Städtereise', 'Roadtrip', 'Backpacking'], emotionRange: [0.4, 0.95], noteChance: 0.6, notes: ['Unvergesslich', 'Wunderschöne Landschaft', 'Endlich Erholung', 'Muss ich wiederholen'] }, + { titles: ['Hochzeit', 'Verlobung', 'Jahrestag'], emotionRange: [0.7, 1.0], noteChance: 0.8, notes: ['Der schönste Tag', 'Für immer', 'Tränen der Freude', 'Unbeschreiblich'] }, + { titles: ['Beförderung', 'Neuer Job', 'Gehaltserhöhung', 'Jobangebot'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Endlich!', 'Harte Arbeit zahlt sich aus', 'Neues Kapitel', 'Verdient'] }, + { titles: ['Konzert', 'Festival', 'Theaterbesuch', 'Oper'], emotionRange: [0.3, 0.85], noteChance: 0.5, notes: ['Gänsehaut', 'Beste Band ever', 'Geniale Atmosphäre', 'Nächstes Jahr wieder'] }, + { titles: ['Geburt', 'Baby da!', 'Nachwuchs'], emotionRange: [0.85, 1.0], noteChance: 0.9, notes: ['Das größte Wunder', 'Willkommen auf der Welt', 'Unbeschreibliches Glück'] }, + { titles: ['Abschluss', 'Prüfung bestanden', 'Diplom', 'Master geschafft'], emotionRange: [0.6, 0.95], noteChance: 0.6, notes: ['Geschafft!', 'Jahre harter Arbeit', 'Stolz', 'Endlich vorbei'] }, + { titles: ['Bergwanderung', 'Gipfel erreicht', 'Marathon geschafft', 'Triathlon'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Was für ein Ausblick!', 'Körperliche Grenzen überwunden', 'Nie aufgeben'] }, + { titles: ['Hauskauf', 'Wohnungseinweihung', 'Renovierung fertig'], emotionRange: [0.4, 0.8], noteChance: 0.5, notes: ['Endlich eigene vier Wände', 'Traum wird wahr', 'Viel Arbeit, aber es lohnt sich'] }, + { titles: ['Erstes Date', 'Zusammengekommen', 'Liebeserklärung'], emotionRange: [0.5, 0.95], noteChance: 0.6, notes: ['Schmetterlinge', 'Liebe auf den ersten Blick', 'Endlich getraut'] }, + // Neutral events + { titles: ['Umzug', 'Neue Stadt', 'Wohnungswechsel'], emotionRange: [-0.2, 0.3], noteChance: 0.4, notes: ['Neuanfang', 'Alles anders', 'Spannend und stressig zugleich'] }, + { titles: ['Arztbesuch', 'Vorsorge', 'Check-up'], emotionRange: [-0.1, 0.1], noteChance: 0.2, notes: ['Alles okay', 'Routine'] }, + { titles: ['Meeting', 'Präsentation', 'Workshop'], emotionRange: [-0.1, 0.4], noteChance: 0.3, notes: ['Gut gelaufen', 'Viel gelernt', 'Anstrengend'] }, + { titles: ['Friseur', 'Shopping', 'Einkauf'], emotionRange: [0.0, 0.3], noteChance: 0.1, notes: ['Neuer Look', 'Guter Fund'] }, + // Negative events + { titles: ['Trennung', 'Beziehungsende', 'Scheidung'], emotionRange: [-1.0, -0.5], noteChance: 0.5, notes: ['Schmerzhaft', 'Warum?', 'Es ist besser so', 'Brauche Zeit'] }, + { titles: ['Jobverlust', 'Kündigung', 'Firma pleite'], emotionRange: [-0.9, -0.4], noteChance: 0.5, notes: ['Schock', 'Wie geht es weiter?', 'Unverdient'] }, + { titles: ['Krankheit', 'OP', 'Krankenhaus'], emotionRange: [-0.8, -0.3], noteChance: 0.6, notes: ['Wird schon', 'Hauptsache gesund werden', 'Lange Genesung'] }, + { titles: ['Abschied', 'Verlust', 'Trauer'], emotionRange: [-1.0, -0.6], noteChance: 0.7, notes: ['Ruhe in Frieden', 'Fehlt mir', 'Unvergessen', 'Schwerer Tag'] }, + { titles: ['Streit', 'Konflikt', 'Auseinandersetzung'], emotionRange: [-0.7, -0.2], noteChance: 0.3, notes: ['Muss nicht sein', 'Hoffe auf Klärung'] }, + { titles: ['Unfall', 'Panne', 'Autopanne'], emotionRange: [-0.6, -0.2], noteChance: 0.4, notes: ['Zum Glück nichts Schlimmes', 'Ärgerlich', 'Hätte schlimmer sein können'] }, + { titles: ['Prüfung nicht bestanden', 'Absage', 'Ablehnung'], emotionRange: [-0.7, -0.3], noteChance: 0.4, notes: ['Nächstes Mal', 'Nicht aufgeben', 'Enttäuschend'] }, + ] + + const demoImages = [ + 'demo/photo-1530103862676-de8c9debad1d.jpeg', + 'demo/photo-1534067783941-51c9c23ecefd.jpeg', + 'demo/photo-1506905925346-21bda4d32df4.jpeg' + ] + + // Seeded random for reproducibility + let seed = 42 + function rand() { + seed = (seed * 16807 + 0) % 2147483647 + return (seed - 1) / 2147483646 + } + + function randInt(min, max) { + return Math.floor(rand() * (max - min + 1)) + min + } + + function pick(arr) { + return arr[Math.floor(rand() * arr.length)] + } + + function randFloat(min, max) { + return Math.round((min + rand() * (max - min)) * 100) / 100 + } + + const evts = [] + const startYear = 1985 + const endYear = 2026 + + // Generate events with realistic distribution (more events in recent years) + for (let i = 0; i < count; i++) { + // Weight towards recent years: cube root distribution + const t = rand() + const yearFloat = startYear + (endYear - startYear) * (t * t * 0.4 + t * 0.6) + const year = Math.floor(yearFloat) + const month = randInt(1, 12) + const day = randInt(1, 28) // safe for all months + const date = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` + + const cat = pick(categories) + const title = pick(cat.titles) + const emotion = randFloat(cat.emotionRange[0], cat.emotionRange[1]) + const hasNote = rand() < cat.noteChance + const note = hasNote ? pick(cat.notes) : '' + const hasImage = rand() < 0.15 // 15% chance + const image = hasImage ? pick(demoImages) : null + const hasPreset = rand() < 0.25 // 25% chance + const gradientPreset = hasPreset ? randInt(0, 9) : null + + evts.push({ + id: crypto.randomUUID(), + title, + date, + emotion, + customColor: null, + gradientPreset, + image, + note, + syncStatus: 'local', + createdAt: Date.now(), + updatedAt: Date.now() + }) + } + + // Sort by date + evts.sort((a, b) => a.date.localeCompare(b.date)) + return evts +} + +export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents } export const useEventsStore = defineStore('events', () => { - const events = ref([...demoEvents]) + const events = ref([]) + const isLoaded = ref(false) const selectedEventId = ref(null) const panelOpen = ref(false) const editingEventId = ref(null) + // Load events from IndexedDB; seed demo data on first launch + async function init() { + try { + let stored = await db.events.orderBy('date').toArray() + if (stored.length === 0) { + const seed = generateManyEvents(500) + await db.events.bulkPut(seed) + stored = seed + } + events.value = stored + } catch (e) { + console.warn('Dexie load failed, using demo data:', e) + events.value = [...demoEvents] + } + isLoaded.value = true + + // Start auto-sync if authenticated + getToken().then((token) => { + if (token) startAutoSync() + }) + } + + // Fire-and-forget DB write (UI already updated via ref) + function dbPut(event) { + db.events.put(event).catch(e => console.warn('Dexie put failed:', e)) + } + + function dbDelete(id) { + db.events.delete(id).catch(e => console.warn('Dexie delete failed:', e)) + } + + function dbQueueSync(eventId, action, payload) { + db.syncQueue.add({ eventId, action, payload, createdAt: Date.now() }) + .catch(e => console.warn('Dexie sync queue failed:', e)) + } + // Ghost event for live preview while creating/editing const ghostEmotion = ref(0) const ghostCustomColor = ref(null) @@ -194,7 +242,6 @@ export const useEventsStore = defineStore('events', () => { function openPanel(eventId = null) { if (eventId) { - // Edit mode editingEventId.value = eventId const event = events.value.find((e) => e.id === eventId) if (event) { @@ -207,7 +254,6 @@ export const useEventsStore = defineStore('events', () => { ghostNote.value = event.note } } else { - // Create mode editingEventId.value = null ghostTitle.value = '' ghostDate.value = new Date().toISOString().slice(0, 10) @@ -220,12 +266,12 @@ export const useEventsStore = defineStore('events', () => { panelOpen.value = true } - // Auto-save: persist ghost → event in edit mode on every change + // Auto-save: persist ghost → event in edit mode function persistToEvent() { if (!editingEventId.value) return const idx = events.value.findIndex((e) => e.id === editingEventId.value) if (idx === -1) return - events.value[idx] = { + const updated = { ...events.value[idx], title: ghostTitle.value, date: ghostDate.value, @@ -234,20 +280,21 @@ export const useEventsStore = defineStore('events', () => { gradientPreset: ghostGradientPreset.value, image: ghostImage.value, note: ghostNote.value, + syncStatus: 'modified', updatedAt: Date.now() } + events.value[idx] = updated + dbPut(updated) } - // Watch all ghost fields — auto-save in edit mode watch( [ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote], () => { persistToEvent() } ) function closePanel() { - // Create mode: auto-create event if there's content if (!editingEventId.value && ghostTitle.value.trim()) { - events.value.push({ + const newEvent = { id: crypto.randomUUID(), title: ghostTitle.value, date: ghostDate.value, @@ -256,9 +303,13 @@ export const useEventsStore = defineStore('events', () => { gradientPreset: ghostGradientPreset.value, image: ghostImage.value, note: ghostNote.value, + syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() - }) + } + events.value.push(newEvent) + dbPut(newEvent) + dbQueueSync(newEvent.id, 'create', { ...newEvent }) } panelOpen.value = false editingEventId.value = null @@ -267,6 +318,8 @@ export const useEventsStore = defineStore('events', () => { function deleteEvent(id) { events.value = events.value.filter((e) => e.id !== id) + dbDelete(id) + dbQueueSync(id, 'delete', null) closePanel() } @@ -275,8 +328,12 @@ export const useEventsStore = defineStore('events', () => { return emotionToColor(event.emotion, event.gradientPreset ?? null) } + // Auto-init on store creation + init() + return { events, + isLoaded, selectedEventId, panelOpen, editingEventId, diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index 15b5a7e..bea0137 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -3,6 +3,20 @@ import { ref, watch } from 'vue' const STORAGE_KEY = 'thatsme-settings' +export const ACCENT_COLORS = [ + { label: 'Standard', value: 'default', hex: '#9e9e9e' }, + { label: 'Blau', value: 'blue', hex: '#2196F3' }, + { label: 'Grün', value: 'green', hex: '#4CAF50' }, + { label: 'Gelb', value: 'yellow', hex: '#FFC107' }, + { label: 'Rosa', value: 'pink', hex: '#E91E63' }, + { label: 'Orange', value: 'orange', hex: '#FF9800' } +] + +export const LANGUAGES = [ + { label: 'Deutsch', value: 'de' }, + { label: 'English', value: 'en' } +] + const FLOATING_LINES_DEFAULTS = { // Linien speed: 1.0, @@ -15,11 +29,15 @@ const FLOATING_LINES_DEFAULTS = { circleRadius: 75, glowSize: 18, glowStrength: 1.5, + lineBrightness: 1.0, // Hintergrund bgCenter: '#0a0514', bgEdge: '#000000', gradientStops: '#e947f5\n#2f4ba2\n#0a0a12', - backgroundImage: '' + backgroundImage: '', + // Labels + labelSize: 'small', // 'small' | 'medium' | 'large' + labelColor: '#ffffff' } function loadFromStorage() { @@ -39,17 +57,29 @@ export const useSettingsStore = defineStore('settings', () => { const theme = ref(stored?.theme ?? 'light') const floatingLines = ref(stored?.floatingLines ?? { ...FLOATING_LINES_DEFAULTS }) + // App preferences + const appearance = ref(stored?.appearance ?? 'system') // 'system' | 'light' | 'dark' + const accentColor = ref(stored?.accentColor ?? 'default') + const language = ref(stored?.language ?? 'de') + + // Developer / debug + const showFps = ref(stored?.showFps ?? false) + function persist() { localStorage.setItem( STORAGE_KEY, JSON.stringify({ theme: theme.value, - floatingLines: floatingLines.value + floatingLines: floatingLines.value, + appearance: appearance.value, + accentColor: accentColor.value, + language: language.value, + showFps: showFps.value }) ) } - watch([theme, floatingLines], persist, { deep: true }) + watch([theme, floatingLines, appearance, accentColor, language, showFps], persist, { deep: true }) function toggleTheme() { theme.value = theme.value === 'light' ? 'dark' : 'light' @@ -66,6 +96,10 @@ export const useSettingsStore = defineStore('settings', () => { return { theme, floatingLines, + appearance, + accentColor, + language, + showFps, toggleTheme, updateFloatingLines, resetFloatingLines