25-02-2025

This commit is contained in:
Kevin Adametz 2026-02-25 17:05:52 +01:00
parent 98084de7d0
commit 70a7776da5
53 changed files with 6719 additions and 833 deletions

119
DEV-NOTES.md Normal file
View file

@ -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) |

View file

@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEventRequest;
use App\Http\Requests\UpdateEventRequest;
use App\Http\Resources\EventResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class EventController extends Controller
{
/**
* GET /api/events
* Cursor-based pagination by date. Supports ?since=ISO for delta sync.
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = $request->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]);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'id' => ['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'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['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'],
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $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(),
];
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Event extends Model
{
/** @use HasFactory<\Database\Factories\EventFactory> */
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);
}
}

View file

@ -4,14 +4,16 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Passport\HasApiTokens;
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable; use HasApiTokens, HasFactory, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -50,6 +52,11 @@ class User extends Authenticatable
/** /**
* Get the user's initials * Get the user's initials
*/ */
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
public function initials(): string public function initials(): string
{ {
return Str::of($this->name) return Str::of($this->name)

View file

@ -7,6 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )

View file

@ -11,6 +11,7 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/passport": "^13.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0", "livewire/flux": "^2.0",
"livewire/volt": "^1.7.0" "livewire/volt": "^1.7.0"

1009
backend/composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -40,6 +40,10 @@ return [
'driver' => 'session', 'driver' => 'session',
'provider' => 'users', 'provider' => 'users',
], ],
'api' => [
'driver' => 'passport',
'provider' => 'users',
],
], ],
/* /*

View file

@ -0,0 +1,48 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Passport Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Passport will use when
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => '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'),
];

View file

@ -0,0 +1,28 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Event>
*/
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(),
];
}
}

View file

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_auth_codes', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_access_tokens', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_clients', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_device_codes', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('events', function (Blueprint $table) {
$table->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');
}
};

14
backend/routes/api.php Normal file
View file

@ -0,0 +1,14 @@
<?php
use App\Http\Controllers\Api\EventController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:api')->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']);
});

View file

@ -0,0 +1,186 @@
<?php
use App\Models\Event;
use App\Models\User;
use Illuminate\Support\Str;
use Laravel\Passport\Passport;
beforeEach(function () {
$this->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);
});

View file

@ -81,6 +81,7 @@ services:
NODE_ENV: development NODE_ENV: development
volumes: volumes:
- './frontend:/app' - './frontend:/app'
- 'quasar-node-modules:/app/node_modules'
networks: networks:
- sail - sail
- proxy - proxy
@ -160,3 +161,5 @@ volumes:
driver: local driver: local
sail-redis: sail-redis:
driver: local driver: local
quasar-node-modules:
driver: local

2
frontend/.gitignore vendored
View file

@ -16,6 +16,8 @@ node_modules
# Capacitor related directories and files # Capacitor related directories and files
/src-capacitor/www /src-capacitor/www
/src-capacitor/node_modules /src-capacitor/node_modules
/src-capacitor/ios
/src-capacitor/android
# Log files # Log files
npm-debug.log* npm-debug.log*

226
frontend/MOBILE-APPS.md Normal file
View file

@ -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` |

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View file

@ -79,11 +79,11 @@ displayEvents ──@viewUpdate──► onViewUpdate()
// Layout: screenToUV(sx, sy) // Layout: screenToUV(sx, sy)
// sx, sy = CSS-Pixel vom oberen linken Viewport-Rand // sx, sy = CSS-Pixel vom oberen linken Viewport-Rand
function screenToUV(sx, sy) { function screenToUV(sx, sy) {
const w = layoutWidth // = 100dvh Breite const w = layoutWidth // = 100dvh Breite
const h = layoutHeight // = 100dvh Höhe const h = layoutHeight // = 100dvh Höhe
return { return {
x: (2 * sx - w) / h, 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:** **GlowDot Y-Position:**
``` ```
yPercent = 50 - emotion * 35 yPercent = 50 - emotion * 35
emotion +1.0 → top (15%) emotion +1.0 → top (15%)
@ -103,6 +104,7 @@ yPercent = 50 - emotion * 35
``` ```
**Screen Y für Shader:** **Screen Y für Shader:**
``` ```
TIMELINE_TOP = 60px (CSS: .timeline { top: 60px }) TIMELINE_TOP = 60px (CSS: .timeline { top: 60px })
screenY = TIMELINE_TOP + (yPercent / 100) * containerHeight screenY = TIMELINE_TOP + (yPercent / 100) * containerHeight
@ -131,6 +133,7 @@ v-model="ghostEmotion" ──► ghostEmotion (ref)
### 3.3 Event-Farben ### 3.3 Event-Farben
Jeder Event hat eine Glow-Farbe basierend auf: Jeder Event hat eine Glow-Farbe basierend auf:
1. `event.customColor` (falls gesetzt, hat Priorität) 1. `event.customColor` (falls gesetzt, hat Priorität)
2. `emotionToColor(emotion, gradientPreset)` — interpoliert zwischen 3 Farben 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: Die Farbe fließt als `pointColor[8]` Uniform in den Shader:
- **Kreise:** `vec3 circCol = pointColor[p]` - **Kreise:** `vec3 circCol = pointColor[p]`
- **Liniensegmente:** `vec3 lineCol = mix(pointColor[s], pointColor[s+1], t_seg)` - **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:** **Props:**
| Prop | Typ | Default | Beschreibung | | Prop | Typ | Default | Beschreibung |
|------|-----|---------|-------------| | -------------------- | ------------ | --------- | ------------------------------------- |
| `numPoints` | Number | 0 | Anzahl aktiver Punkte (max 8) | | `numPoints` | Number | 0 | Anzahl aktiver Punkte (max 8) |
| `pointXValues` | Array | [] | X-UV-Koordinaten der Punkte | | `pointXValues` | Array | [] | X-UV-Koordinaten der Punkte |
| `pointYValues` | Array | [] | Y-UV-Koordinaten der Punkte | | `pointYValues` | Array | [] | Y-UV-Koordinaten der Punkte |
| `pointColors` | Array | [] | Hex-Farben pro Punkt (z.B. '#ff0000') | | `pointColors` | Array | [] | Hex-Farben pro Punkt (z.B. '#ff0000') |
| `lineCount` | Array/Number | [10] | Anzahl Wellenlinien | | `lineCount` | Array/Number | [10] | Anzahl Wellenlinien |
| `animationSpeed` | Number | 1 | Geschwindigkeit der Wellenanimation | | `animationSpeed` | Number | 1 | Geschwindigkeit der Wellenanimation |
| `lineSpread` | Number | 0.05 | Wellenamplitude | | `lineSpread` | Number | 0.05 | Wellenamplitude |
| `fanSpread` | Number | 0.05 | Fächerbreite der Linien | | `fanSpread` | Number | 0.05 | Fächerbreite der Linien |
| `lineSharpness` | Number | 8.0 | Feinheit/Schärfe der Linien | | `lineSharpness` | Number | 8.0 | Feinheit/Schärfe der Linien |
| `waveFrequency` | Number | 7.0 | Welligkeit | | `waveFrequency` | Number | 7.0 | Welligkeit |
| `bezierCurvature` | Number | 0.2 | Kurvenstärke der Bezier-Verbindungen | | `bezierCurvature` | Number | 0.2 | Kurvenstärke der Bezier-Verbindungen |
| `circleRadiusPx` | Number | 75 | Kreisradius in Pixeln | | `circleRadiusPx` | Number | 75 | Kreisradius in Pixeln |
| `circleGlowSize` | Number | 18 | Glow-Ausdehnung um den Kreis | | `circleGlowSize` | Number | 18 | Glow-Ausdehnung um den Kreis |
| `circleGlowStrength` | Number | 1.5 | Glow-Intensität | | `circleGlowStrength` | Number | 1.5 | Glow-Intensität |
| `linesGradient` | Array | [...] | Hex-Farbwerte für Linien-Gradient | | `linesGradient` | Array | [...] | Hex-Farbwerte für Linien-Gradient |
| `bgColorCenter` | String | '#0a0514' | Hintergrundfarbe Mitte | | `bgColorCenter` | String | '#0a0514' | Hintergrundfarbe Mitte |
| `bgColorEdge` | String | '#000000' | Hintergrundfarbe Rand | | `bgColorEdge` | String | '#000000' | Hintergrundfarbe Rand |
| `backgroundImage` | String | '' | URL für Hintergrundbild | | `backgroundImage` | String | '' | URL für Hintergrundbild |
| `mixBlendMode` | String | 'screen' | CSS Blend-Mode des Canvas | | `mixBlendMode` | String | 'screen' | CSS Blend-Mode des Canvas |
**Shader-Architektur:** **Shader-Architektur:**
- `drawCircle()` — Zeichnet weißen Kern + farbigen Glow + Fog - `drawCircle()` — Zeichnet weißen Kern + farbigen Glow + Fog
- `waveFocal()` — Berechnet Wellenlinien entlang Bezier-Segmenten - `waveFocal()` — Berechnet Wellenlinien entlang Bezier-Segmenten
- `bezierClosestT()` — Findet nächsten Punkt auf quadratischer Bezier-Kurve - `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). **Zweck:** Klickbarer DOM-Overlay pro Event (weißer Kreis + optionales Bild).
**Größe:** Dynamisch aus `settingsStore.floatingLines.circleRadius`: **Größe:** Dynamisch aus `settingsStore.floatingLines.circleRadius`:
```js ```js
const dpr = Math.min(window.devicePixelRatio || 1, 2) 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. **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) **CSS-Position:** `top: 60px; bottom: 70px` (unterhalb Header, oberhalb AddButton)
**Features:** **Features:**
- Pinch-to-Zoom (Touch + Ctrl+Wheel) - Pinch-to-Zoom (Touch + Ctrl+Wheel)
- Zoom-Range: 0.4x 3.0x - Zoom-Range: 0.4x 3.0x
- Scroll-to-center beim Mount (letztes Event) - Scroll-to-center beim Mount (letztes Event)
- Ghost-Event-Insertion bei Panel-Open (Create-Mode) - Ghost-Event-Insertion bei Panel-Open (Create-Mode)
**Emits:** **Emits:**
- `@dotSelect(eventId)` — Event angeklickt - `@dotSelect(eventId)` — Event angeklickt
- `@viewUpdate({ scrollLeft, viewportWidth, containerHeight, events[] })` — Bei jedem Scroll/Zoom/Resize/Event-Change - `@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. **Zweck:** Haupt-Layout, orchestriert alle Komponenten.
**Verantwortlichkeiten:** **Verantwortlichkeiten:**
- Empfängt `@view-update` von TimelineView - Empfängt `@view-update` von TimelineView
- Konvertiert Screen-Pixel → Shader-UV-Koordinaten - Konvertiert Screen-Pixel → Shader-UV-Koordinaten
- Berechnet `shaderNumPoints`, `shaderPointX[]`, `shaderPointY[]`, `shaderPointColors[]` - Berechnet `shaderNumPoints`, `shaderPointX[]`, `shaderPointY[]`, `shaderPointColors[]`
@ -234,6 +243,7 @@ const dotSize = 2 * circleRadius / dpr // Matches shader circle
**Zweck:** Einstellungs-Panel (Slide-Up, 75dvh). **Zweck:** Einstellungs-Panel (Slide-Up, 75dvh).
**Sektionen:** **Sektionen:**
1. **Linien** — Speed, Anzahl, Wellen-Amp, Fächerbreite, Feinheit, Welligkeit, Kurve, Kreis, Glow Größe, Glow Stärke 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`) 2. **Hintergrundbild** — 10 vordefinierte Bilder (`/images/bg-image-1.jpg` bis `10.jpg`)
3. **Hintergrundfarbe** — BG Mitte + BG Rand (Color Picker) 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). **Zweck:** Event-Erstellung und -Bearbeitung (Slide-Up, 75dvh).
**Features:** **Features:**
- Key Image Upload (Platzhalter) - Key Image Upload (Platzhalter)
- Titel-Input (inline, groß) - Titel-Input (inline, groß)
- Datum-Picker (QDate mit deutscher Locale) - Datum-Picker (QDate mit deutscher Locale)
@ -311,26 +322,36 @@ localStorage.setItem('thatsme-settings', JSON.stringify({...}))
### 6.2 Quasar Theme (`quasar.variables.scss`) ### 6.2 Quasar Theme (`quasar.variables.scss`)
```scss ```scss
$primary : #d946ef; // Fuchsia — Slider, Toggles, aktive States $primary: #d946ef; // Fuchsia — Slider, Toggles, aktive States
$secondary : #a855f7; // Purple $secondary: #a855f7; // Purple
$accent : #ec4899; // Pink $accent: #ec4899; // Pink
``` ```
### 6.3 Wichtige CSS-Hinweise ### 6.3 Wichtige CSS-Hinweise
**Timeline-Positionierung:** **Timeline-Positionierung:**
```css ```css
/* TimelineView.vue — eigene Positionierung */ /* 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! */ /* LifeWaveLayout.vue — NUR z-index, KEIN inset: 0! */
/* inset: 0 würde top/bottom der Timeline überschreiben (CSS Cascade) */ /* 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:** **GlowDot — kein Zoom-Scaling:**
```css ```css
.glow-dot { transform: translate(-50%, -50%); } .glow-dot {
transform: translate(-50%, -50%);
}
/* Breite/Höhe kommt dynamisch aus dem Settings-Store */ /* Breite/Höhe kommt dynamisch aus dem Settings-Store */
``` ```
@ -366,16 +387,16 @@ npm run build
### Dateien für die Weiterentwicklung ### Dateien für die Weiterentwicklung
| Was | Wo | | Was | Wo |
|-----|-----| | ------------------ | --------------------------------------------------------------------- |
| Shader-Code (GLSL) | `FloatingLines.vue` (Zeile ~67366) | | Shader-Code (GLSL) | `FloatingLines.vue` (Zeile ~67366) |
| UV-Konvertierung | `LifeWaveLayout.vue``screenToUV()` | | UV-Konvertierung | `LifeWaveLayout.vue``screenToUV()` |
| Event-Farben | `events.js``emotionToColor()`, `getGlowColor()` | | Event-Farben | `events.js``emotionToColor()`, `getGlowColor()` |
| Settings-Defaults | `settings.js``FLOATING_LINES_DEFAULTS` | | Settings-Defaults | `settings.js``FLOATING_LINES_DEFAULTS` |
| Slider-Ranges | `LifeWaveSettings.vue` (`:min`, `:max`, `:step` auf jedem `q-slider`) | | Slider-Ranges | `LifeWaveSettings.vue` (`:min`, `:max`, `:step` auf jedem `q-slider`) |
| Quasar-Theme | `quasar.variables.scss` | | Quasar-Theme | `quasar.variables.scss` |
| Glass-Styles | `app.scss``.glass--panel`, `.glass--button` | | Glass-Styles | `app.scss``.glass--panel`, `.glass--button` |
| Dev-Referenz | `dev/init-fl.html`, `dev/floating-lines.js` (Original-Prototyp) | | Dev-Referenz | `dev/init-fl.html`, `dev/floating-lines.js` (Original-Prototyp) |
### Nächste Schritte (offen) ### Nächste Schritte (offen)

View file

@ -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<boolean> — Netzwerkstatus
isSyncing, // ref<boolean> — Sync läuft gerade
lastSyncAt, // ref<number|null> — Timestamp letzter Sync
getToken, // () → Promise<string|null>
setToken, // (token) → Promise<void>
apiFetch, // (path, options) → Promise<Response>
processSyncQueue, // () → Promise<void>
pullRemoteChanges, // () → Promise<void>
fullSync, // () → Promise<void>
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.

View file

@ -10,7 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@quasar/extras": "^1.16.4", "@quasar/extras": "^1.16.4",
"gsap": "^3.13.0", "dexie": "^4.3.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"quasar": "^2.16.0", "quasar": "^2.16.0",
"three": "^0.183.0", "three": "^0.183.0",
@ -34,6 +34,11 @@
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18", "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
"yarn": ">= 1.21.1" "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": { "node_modules/@babel/code-frame": {
@ -655,20 +660,20 @@
} }
}, },
"node_modules/@eslint/eslintrc": { "node_modules/@eslint/eslintrc": {
"version": "3.3.3", "version": "3.3.4",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.4.tgz",
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "integrity": "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ajv": "^6.12.4", "ajv": "^6.14.0",
"debug": "^4.3.2", "debug": "^4.3.2",
"espree": "^10.0.1", "espree": "^10.0.1",
"globals": "^14.0.0", "globals": "^14.0.0",
"ignore": "^5.2.0", "ignore": "^5.2.0",
"import-fresh": "^3.2.1", "import-fresh": "^3.2.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"minimatch": "^3.1.2", "minimatch": "^3.1.3",
"strip-json-comments": "^3.1.1" "strip-json-comments": "^3.1.1"
}, },
"engines": { "engines": {
@ -717,9 +722,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.39.2", "version": "9.39.3",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.3.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "integrity": "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1515,9 +1520,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1529,9 +1534,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1543,13 +1548,12 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1557,9 +1561,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1571,9 +1575,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1585,9 +1589,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1599,9 +1603,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1613,9 +1617,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1627,13 +1631,12 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1641,9 +1644,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1655,9 +1658,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -1669,9 +1672,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -1683,9 +1686,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1697,9 +1700,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1711,9 +1714,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1725,9 +1728,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1739,9 +1742,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1753,13 +1756,12 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -1767,9 +1769,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1781,9 +1783,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1795,9 +1797,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1809,9 +1811,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1823,9 +1825,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1837,9 +1839,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1851,9 +1853,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2077,13 +2079,13 @@
} }
}, },
"node_modules/@vue/compiler-core": { "node_modules/@vue/compiler-core": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
"integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.0",
"@vue/shared": "3.5.28", "@vue/shared": "3.5.29",
"entities": "^7.0.1", "entities": "^7.0.1",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
@ -2102,26 +2104,26 @@
} }
}, },
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
"integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.28", "@vue/compiler-core": "3.5.29",
"@vue/shared": "3.5.28" "@vue/shared": "3.5.29"
} }
}, },
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
"integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.28", "@vue/compiler-core": "3.5.29",
"@vue/compiler-dom": "3.5.28", "@vue/compiler-dom": "3.5.29",
"@vue/compiler-ssr": "3.5.28", "@vue/compiler-ssr": "3.5.29",
"@vue/shared": "3.5.28", "@vue/shared": "3.5.29",
"estree-walker": "^2.0.2", "estree-walker": "^2.0.2",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
"postcss": "^8.5.6", "postcss": "^8.5.6",
@ -2129,13 +2131,13 @@
} }
}, },
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
"integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.28", "@vue/compiler-dom": "3.5.29",
"@vue/shared": "3.5.28" "@vue/shared": "3.5.29"
} }
}, },
"node_modules/@vue/devtools-api": { "node_modules/@vue/devtools-api": {
@ -2187,53 +2189,53 @@
} }
}, },
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
"integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/shared": "3.5.28" "@vue/shared": "3.5.29"
} }
}, },
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
"integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.28", "@vue/reactivity": "3.5.29",
"@vue/shared": "3.5.28" "@vue/shared": "3.5.29"
} }
}, },
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
"integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.28", "@vue/reactivity": "3.5.29",
"@vue/runtime-core": "3.5.28", "@vue/runtime-core": "3.5.29",
"@vue/shared": "3.5.28", "@vue/shared": "3.5.29",
"csstype": "^3.2.3" "csstype": "^3.2.3"
} }
}, },
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
"integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.28", "@vue/compiler-ssr": "3.5.29",
"@vue/shared": "3.5.28" "@vue/shared": "3.5.29"
}, },
"peerDependencies": { "peerDependencies": {
"vue": "3.5.28" "vue": "3.5.29"
} }
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
"integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/abort-controller": { "node_modules/abort-controller": {
@ -2274,9 +2276,9 @@
} }
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -2297,9 +2299,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2479,9 +2481,9 @@
} }
}, },
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.7.5", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.5.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
"integrity": "sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA==", "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
@ -2809,9 +2811,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001770", "version": "1.0.30001774",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
"integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -3329,6 +3331,12 @@
"node": ">=8" "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": { "node_modules/dot-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
@ -3415,9 +3423,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.286", "version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -3570,9 +3578,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.39.2", "version": "9.39.3",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.3.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3582,7 +3590,7 @@
"@eslint/config-helpers": "^0.4.2", "@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.2", "@eslint/js": "9.39.3",
"@eslint/plugin-kit": "^0.4.1", "@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
@ -4256,24 +4264,37 @@
"node": ">= 6" "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": { "node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
} }
}, },
"node_modules/glob/node_modules/minimatch": { "node_modules/glob/node_modules/minimatch": {
"version": "9.0.5", "version": "9.0.7",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.7.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "integrity": "sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^5.0.2"
}, },
"engines": { "engines": {
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
@ -5072,9 +5093,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.4.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@ -5829,9 +5850,9 @@
} }
}, },
"node_modules/readdir-glob/node_modules/minimatch": { "node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.6", "version": "5.1.8",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.8.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "integrity": "sha512-7RN35vit8DeBclkofOVmBY0eDAZZQd1HzmukRdSyz95CRh8FT54eqnbj0krQr3mrHR6sfRyYkyhwBWjoV5uqlQ==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@ -5912,9 +5933,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.57.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -5928,31 +5949,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -7035,9 +7056,9 @@
} }
}, },
"node_modules/three": { "node_modules/three": {
"version": "0.183.0", "version": "0.183.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.183.1.tgz",
"integrity": "sha512-G6SH2jfefIVa2YI4JL2VbgQhrrbp1A8dRc7lr3PW827kdVyaX2RgH6M5FmjmdVFLgSHppyg3OYOZdTfWElle+g==", "integrity": "sha512-Psv6bbd3d/M/01MT2zZ+VmD0Vj2dbWTNhfe4CuSg7w5TuW96M3NOyCVuh9SZQ05CpGmD7NEcJhZw4GVjhCYxfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
@ -8034,16 +8055,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.28", "version": "3.5.29",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.28", "@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.28", "@vue/compiler-sfc": "3.5.29",
"@vue/runtime-dom": "3.5.28", "@vue/runtime-dom": "3.5.29",
"@vue/server-renderer": "3.5.28", "@vue/server-renderer": "3.5.29",
"@vue/shared": "3.5.28" "@vue/shared": "3.5.29"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "*" "typescript": "*"

View file

@ -10,12 +10,13 @@
"lint": "eslint -c ./eslint.config.js \"./src*/**/*.{js,cjs,mjs,vue}\"", "lint": "eslint -c ./eslint.config.js \"./src*/**/*.{js,cjs,mjs,vue}\"",
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore", "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0", "test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev", "dev": "npm install && quasar dev",
"build": "quasar build", "build": "quasar build",
"postinstall": "quasar prepare" "postinstall": "quasar prepare"
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.16.4", "@quasar/extras": "^1.16.4",
"dexie": "^4.3.0",
"pinia": "^3.0.1", "pinia": "^3.0.1",
"quasar": "^2.16.0", "quasar": "^2.16.0",
"three": "^0.183.0", "three": "^0.183.0",
@ -23,6 +24,11 @@
"vue-router": "^4.0.0", "vue-router": "^4.0.0",
"vue-select": "^4.0.0-beta.6" "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": { "devDependencies": {
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",
"@quasar/app-vite": "^2.1.0", "@quasar/app-vite": "^2.1.0",
@ -40,4 +46,4 @@
"npm": ">= 6.13.4", "npm": ">= 6.13.4",
"yarn": ">= 1.21.1" "yarn": ">= 1.21.1"
} }
} }

View file

@ -3,7 +3,7 @@
import { defineConfig } from '#q-app/wrappers' import { defineConfig } from '#q-app/wrappers'
export default defineConfig((/* ctx */) => { export default defineConfig((ctx) => {
return { return {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature // https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true, // preFetch: true,
@ -11,13 +11,10 @@ export default defineConfig((/* ctx */) => {
// app boot file (/src/boot) // app boot file (/src/boot)
// --> boot files are part of "main.js" // --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files // https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [ boot: [],
],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: [ css: ['app.scss'],
'app.scss'
],
// https://github.com/quasarframework/quasar/tree/dev/extras // https://github.com/quasarframework/quasar/tree/dev/extras
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 // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
build: { build: {
target: { target: {
browser: [ 'es2022', 'firefox115', 'chrome115', 'safari14' ], browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
node: 'node20' node: 'node20',
}, },
vueRouterMode: 'hash', // available values: 'hash', 'history' vueRouterMode: 'hash', // available values: 'hash', 'history'
@ -56,24 +53,34 @@ export default defineConfig((/* ctx */) => {
// polyfillModulePreload: true, // polyfillModulePreload: true,
// distDir // distDir
// extendViteConf (viteConf) {}, extendViteConf(viteConf) {
const srcDir = new URL('./src/composables', import.meta.url).pathname
viteConf.resolve.alias['composables'] = srcDir
},
// viteVuePluginOptions: {}, // viteVuePluginOptions: {},
vitePlugins: [ vitePlugins:
['vite-plugin-checker', { ctx.prod && ctx.modeName !== 'capacitor'
eslint: { ? [
lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{js,mjs,cjs,vue}"', [
useFlatConfig: true 'vite-plugin-checker',
} {
}, { server: false }] 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 // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
devServer: { devServer: {
// https: true, // https: true,
open: true, // opens browser window automatically 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 // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
@ -91,7 +98,7 @@ export default defineConfig((/* ctx */) => {
// directives: [], // directives: [],
// Quasar plugins // Quasar plugins
plugins: ['Dark'] plugins: ['Dark'],
}, },
htmlVariables: { htmlVariables: {
@ -118,10 +125,10 @@ export default defineConfig((/* ctx */) => {
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr // https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: { ssr: {
prodPort: 3000, // The default port that the production server should use 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: [ middlewares: [
'render' // keep this as last one 'render', // keep this as last one
], ],
// extendPackageJson (json) {}, // extendPackageJson (json) {},
@ -132,7 +139,7 @@ export default defineConfig((/* ctx */) => {
// manualStoreHydration: true, // manualStoreHydration: true,
// manualPostHydrationTrigger: true, // manualPostHydrationTrigger: true,
pwa: false pwa: false,
// pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name! // pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
// pwaExtendGenerateSWOptions (cfg) {}, // pwaExtendGenerateSWOptions (cfg) {},
@ -141,7 +148,7 @@ export default defineConfig((/* ctx */) => {
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: { pwa: {
workboxMode: 'GenerateSW' // 'GenerateSW' or 'InjectManifest' workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
// swFilename: 'sw.js', // swFilename: 'sw.js',
// manifestFilename: 'manifest.json', // manifestFilename: 'manifest.json',
// extendManifestJson (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 // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: { capacitor: {
hideSplashscreen: true hideSplashscreen: true,
}, },
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron // 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) {}, // extendPackageJson (json) {},
// Electron preload scripts (if any) from /src-electron, WITHOUT file extension // 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 // specify the debugging port to use for the Electron app when running in development mode
inspectPort: 5858, inspectPort: 5858,
@ -179,13 +186,11 @@ export default defineConfig((/* ctx */) => {
packager: { packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store // OS X / Mac App Store
// appBundleId: '', // appBundleId: '',
// appCategoryType: '', // appCategoryType: '',
// osxSign: '', // osxSign: '',
// protocol: 'myapp://path', // protocol: 'myapp://path',
// Windows only // Windows only
// win32metadata: { ... } // win32metadata: { ... }
}, },
@ -193,8 +198,8 @@ export default defineConfig((/* ctx */) => {
builder: { builder: {
// https://www.electron.build/configuration/configuration // 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 // 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' ] * @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
*/ */
extraScripts: [] extraScripts: [],
} },
} }
}) })

View file

@ -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
}
}

1094
frontend/src-capacitor/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -20,7 +20,7 @@ const isDark = computed(() => $q.dark.isActive)
bottom: 16px; bottom: 16px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
z-index: 30; z-index: 10;
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 50%; border-radius: 50%;

View file

@ -0,0 +1,322 @@
<template>
<ModalCard
:open="open"
title="Allgemein"
:tabs="tabs"
v-model="activeTab"
@close="$emit('close')"
>
<!-- Allgemein -->
<div v-if="activeTab === 'general'">
<div class="settings-section__title">Allgemein</div>
<div class="settings-section__divider" />
<!-- Aussehen -->
<div class="settings-row">
<span class="settings-row__label">Aussehen</span>
<div class="settings-row__control">
<select
:value="settingsStore.appearance"
@change="onAppearanceChange"
class="settings-select"
:class="{ 'settings-select--dark': isDark }"
>
<option value="system">System</option>
<option value="light">Hell</option>
<option value="dark">Dunkel</option>
</select>
</div>
</div>
<div class="settings-section__divider" />
<!-- Akzentfarbe -->
<div class="settings-row">
<span class="settings-row__label">Akzentfarbe</span>
<div class="settings-row__control">
<button
class="settings-accent-btn"
:class="{ 'settings-accent-btn--dark': isDark }"
@click="accentDropdownOpen = !accentDropdownOpen"
>
<span
class="settings-accent-dot"
:style="{ background: currentAccentHex }"
/>
<span>{{ currentAccentLabel }}</span>
<q-icon name="expand_more" size="16px" />
</button>
<!-- Accent dropdown -->
<Transition name="dropdown">
<div
v-if="accentDropdownOpen"
class="settings-dropdown"
:class="{ 'settings-dropdown--dark': isDark }"
>
<button
v-for="color in ACCENT_COLORS"
:key="color.value"
class="settings-dropdown__item"
@click="selectAccent(color.value)"
>
<span class="settings-accent-dot" :style="{ background: color.hex }" />
<span>{{ color.label }}</span>
<q-icon
v-if="settingsStore.accentColor === color.value"
name="check"
size="18px"
class="settings-dropdown__check"
/>
</button>
</div>
</Transition>
</div>
</div>
<div class="settings-section__divider" />
<!-- Sprache -->
<div class="settings-row">
<span class="settings-row__label">Sprache</span>
<div class="settings-row__control">
<select
:value="settingsStore.language"
@change="e => { settingsStore.language = e.target.value }"
class="settings-select"
:class="{ 'settings-select--dark': isDark }"
>
<option v-for="lang in LANGUAGES" :key="lang.value" :value="lang.value">
{{ lang.label }}
</option>
</select>
</div>
</div>
<div class="settings-section__divider" />
<!-- FPS-Anzeige -->
<div class="settings-row">
<span class="settings-row__label">FPS-Anzeige</span>
<div class="settings-row__control">
<q-toggle
:model-value="settingsStore.showFps"
@update:model-value="v => { settingsStore.showFps = v }"
dense
color="green"
/>
</div>
</div>
</div>
<!-- Benachrichtigungen (placeholder) -->
<div v-else-if="activeTab === 'notifications'">
<div class="settings-section__title">Benachrichtigungen</div>
<div class="settings-section__divider" />
<p class="settings-placeholder">Kommt bald.</p>
</div>
<!-- Personalisierung (placeholder) -->
<div v-else-if="activeTab === 'personalize'">
<div class="settings-section__title">Personalisierung</div>
<div class="settings-section__divider" />
<p class="settings-placeholder">Kommt bald.</p>
</div>
</ModalCard>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useQuasar } from 'quasar'
import ModalCard from 'components/ModalCard.vue'
import { useSettingsStore, ACCENT_COLORS, LANGUAGES } from 'stores/settings'
defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close'])
const $q = useQuasar()
const settingsStore = useSettingsStore()
const isDark = computed(() => $q.dark.isActive)
const activeTab = ref('general')
const accentDropdownOpen = ref(false)
const tabs = [
{ value: 'general', label: 'Allgemein', icon: 'settings' },
{ value: 'notifications', label: 'Benachrichtigungen', icon: 'notifications' },
{ value: 'personalize', label: 'Personalisierung', icon: 'history' }
]
const currentAccentHex = computed(() => {
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
return found?.hex ?? '#9e9e9e'
})
const currentAccentLabel = computed(() => {
const found = ACCENT_COLORS.find(c => c.value === settingsStore.accentColor)
return found?.label ?? 'Standard'
})
function selectAccent(value) {
settingsStore.accentColor = value
accentDropdownOpen.value = false
}
function onAppearanceChange(e) {
const value = e.target.value
settingsStore.appearance = value
applyAppearance(value)
}
function applyAppearance(mode) {
if (mode === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
$q.dark.set(prefersDark)
} else {
$q.dark.set(mode === 'dark')
}
}
// Apply appearance on mount and when it changes externally
watch(() => settingsStore.appearance, applyAppearance, { immediate: true })
</script>
<style scoped>
.settings-section__title {
font-size: 18px;
font-weight: 700;
margin-bottom: 4px;
}
.settings-section__divider {
height: 1px;
background: rgba(128, 128, 128, 0.12);
margin: 16px 0;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 40px;
}
.settings-row__label {
font-size: 15px;
}
.settings-row__control {
position: relative;
}
/* Native select styled */
.settings-select {
appearance: none;
-webkit-appearance: none;
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
color: inherit;
font-family: inherit;
font-size: 14px;
padding: 6px 28px 6px 12px;
border-radius: 8px;
cursor: pointer;
outline: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'%3E%3Cpath fill='%23999' d='M7 10l5 5 5-5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
}
.settings-select--dark {
background-color: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
/* Accent button */
.settings-accent-btn {
display: flex;
align-items: center;
gap: 8px;
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
color: inherit;
font-family: inherit;
font-size: 14px;
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
}
.settings-accent-btn--dark {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
.settings-accent-dot {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
/* Accent Dropdown */
.settings-dropdown {
position: absolute;
right: 0;
top: calc(100% + 6px);
min-width: 180px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(128, 128, 128, 0.15);
border-radius: 12px;
padding: 6px;
z-index: 10;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
.settings-dropdown--dark {
background: rgba(40, 40, 40, 0.92);
border-color: rgba(255, 255, 255, 0.1);
}
.settings-dropdown__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 12px;
border: none;
background: none;
color: inherit;
font-family: inherit;
font-size: 14px;
cursor: pointer;
border-radius: 8px;
transition: background 0.12s;
}
.settings-dropdown__item:hover {
background: rgba(128, 128, 128, 0.1);
}
.settings-dropdown__check {
margin-left: auto;
opacity: 0.7;
}
/* Placeholder text */
.settings-placeholder {
font-size: 14px;
opacity: 0.5;
}
/* Dropdown transition */
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View file

@ -1,8 +1,16 @@
<template> <template>
<Transition name="slide-up"> <Transition name="slide-up">
<div v-if="eventsStore.panelOpen" class="event-panel glass--panel"> <div
<!-- Drag Handle tap to close --> v-if="eventsStore.panelOpen"
<div class="event-panel__handle" @click="eventsStore.closePanel()"> class="event-panel glass--panel"
:style="panelHeight != null ? { height: panelHeight + 'dvh' } : {}"
:class="{ 'event-panel--dragging': isDragging }"
>
<!-- Drag Handle drag to resize, tap to close -->
<div
class="event-panel__handle"
v-on="handleListeners"
>
<div class="event-panel__handle-bar"></div> <div class="event-panel__handle-bar"></div>
</div> </div>
@ -11,7 +19,7 @@
<!-- Key Image --> <!-- Key Image -->
<div class="event-panel__image-section"> <div class="event-panel__image-section">
<div v-if="eventsStore.ghostImage" class="event-panel__image-wrap"> <div v-if="eventsStore.ghostImage" class="event-panel__image-wrap">
<img :src="eventsStore.ghostImage" class="event-panel__image" alt="" /> <img :src="keyImageSrc || eventsStore.ghostImage" class="event-panel__image" alt="" />
<span class="event-panel__image-badge">Key Image</span> <span class="event-panel__image-badge">Key Image</span>
</div> </div>
<div v-else class="event-panel__image-placeholder" @click="onAddImage"> <div v-else class="event-panel__image-placeholder" @click="onAddImage">
@ -146,12 +154,28 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, watch, ref } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useEventsStore, emotionToColor, GRADIENT_PRESETS } from 'stores/events' import { useEventsStore, emotionToColor, GRADIENT_PRESETS } from 'stores/events'
import { usePanelDrag } from 'composables/usePanelDrag'
import { resolveFullRes } from 'composables/useImageCache'
const $q = useQuasar() const $q = useQuasar()
const eventsStore = useEventsStore() const eventsStore = useEventsStore()
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => eventsStore.closePanel())
// Resolve key image: full-res when online, cached thumbnail when offline
const keyImageSrc = ref(null)
watch(
() => eventsStore.ghostImage,
async (img) => {
keyImageSrc.value = await resolveFullRes(img)
},
{ immediate: true }
)
// Reset height when panel opens
watch(() => eventsStore.panelOpen, (open) => { if (open) resetHeight() })
const isDark = computed(() => $q.dark.isActive) const isDark = computed(() => $q.dark.isActive)
const gradientPresets = GRADIENT_PRESETS const gradientPresets = GRADIENT_PRESETS
@ -231,6 +255,11 @@ function onAddMedia() {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 20px 20px 0 0; border-radius: 20px 20px 0 0;
transition: height 0.25s ease;
}
.event-panel--dragging {
transition: none;
} }
.event-panel__handle { .event-panel__handle {
@ -238,6 +267,12 @@ function onAddMedia() {
justify-content: center; justify-content: center;
padding: 10px 0 4px; padding: 10px 0 4px;
flex-shrink: 0; flex-shrink: 0;
cursor: grab;
touch-action: none;
}
.event-panel__handle:active {
cursor: grabbing;
} }
.event-panel__handle-bar { .event-panel__handle-bar {

View file

@ -21,7 +21,6 @@ import {
} from 'three' } from 'three'
const props = defineProps({ const props = defineProps({
enabledWaves: { type: Array, default: () => ['middle'] },
lineCount: { type: [Array, Number], default: () => [10] }, lineCount: { type: [Array, Number], default: () => [10] },
numPoints: { type: Number, default: 0 }, numPoints: { type: Number, default: 0 },
pointXValues: { type: Array, default: () => [] }, pointXValues: { type: Array, default: () => [] },
@ -35,16 +34,22 @@ const props = defineProps({
circleRadiusPx: { type: Number, default: 75 }, circleRadiusPx: { type: Number, default: 75 },
circleGlowSize: { type: Number, default: 18 }, circleGlowSize: { type: Number, default: 18 },
circleGlowStrength: { type: Number, default: 1.5 }, circleGlowStrength: { type: Number, default: 1.5 },
lineBrightness: { type: Number, default: 1.0 },
scrollContainer: { type: Object, default: null },
scrollUvScale: { type: Number, default: 0 },
animationSpeed: { type: Number, default: 1 }, animationSpeed: { type: Number, default: 1 },
linesGradient: { type: Array, default: () => ['#e947f5', '#2f4ba2', '#0a0a12'] }, linesGradient: { type: Array, default: () => ['#e947f5', '#2f4ba2', '#0a0a12'] },
bgColorCenter: { type: String, default: '#0a0514' }, bgColorCenter: { type: String, default: '#0a0514' },
bgColorEdge: { type: String, default: '#000000' }, bgColorEdge: { type: String, default: '#000000' },
backgroundImage: { type: String, default: '' }, backgroundImage: { type: String, default: '' },
mixBlendMode: { type: String, default: 'screen' }, mixBlendMode: { type: String, default: 'screen' },
interactive: { type: Boolean, default: false },
parallax: { type: Boolean, default: false } parallax: { type: Boolean, default: false }
}) })
// FPS display
const fpsDisplay = ref(0)
const dprDisplay = ref('0.0')
const containerStyle = computed(() => { const containerStyle = computed(() => {
const style = {} const style = {}
if (props.backgroundImage) { if (props.backgroundImage) {
@ -66,47 +71,25 @@ void main() {
` `
const fragmentShader = ` const fragmentShader = `
precision highp float; precision mediump float;
uniform float iTime; uniform float iTime;
uniform vec3 iResolution; uniform vec3 iResolution;
uniform float animationSpeed; uniform float animationSpeed;
uniform bool enableTop; uniform int middleLineCount;
uniform bool enableMiddle;
uniform bool enableBottom;
uniform int topLineCount;
uniform int middleLineCount;
uniform int bottomLineCount;
uniform float topLineDistance;
uniform float bottomLineDistance;
uniform vec3 topWavePosition;
uniform vec3 bottomWavePosition;
uniform int numPoints; uniform int numPoints;
uniform float pointX[8]; uniform float pointX[16];
uniform float pointY[8]; uniform float pointY[16];
uniform float lineSpread; uniform float lineSpread;
uniform float fanSpread; uniform float fanSpread;
uniform float lineSharpness; uniform float lineSharpness;
uniform float waveFrequency; uniform float waveFrequency;
uniform float bezierCurvature; uniform float bezierCurvature;
uniform float circleRadiusPx; uniform float lineBrightness;
uniform float circleGlowSize; uniform vec3 pointColor[16];
uniform float circleGlowStrength;
uniform vec3 pointColor[8];
uniform vec2 iMouse;
uniform bool interactive;
uniform float bendRadius;
uniform float bendStrength;
uniform float bendInfluence;
uniform bool parallax; uniform bool parallax;
uniform float parallaxStrength;
uniform vec2 parallaxOffset; uniform vec2 parallaxOffset;
uniform vec3 lineGradient[8]; uniform vec3 lineGradient[8];
@ -114,63 +97,6 @@ uniform int lineGradientCount;
uniform vec3 bgColorCenter; uniform vec3 bgColorCenter;
uniform vec3 bgColorEdge; uniform vec3 bgColorEdge;
const vec3 BLACK = vec3(0.0);
const vec3 PINK = vec3(233.0, 71.0, 245.0) / 255.0;
const vec3 BLUE = vec3(47.0, 75.0, 162.0) / 255.0;
mat2 rotate(float r) {
return mat2(cos(r), sin(r), -sin(r), cos(r));
}
vec3 background_color(vec2 uv) {
vec3 col = vec3(0.0);
float y = sin(uv.x - 0.2) * 0.3 - 0.1;
float m = uv.y - y;
col += mix(BLUE, BLACK, smoothstep(0.0, 1.0, abs(m)));
col += mix(PINK, BLACK, smoothstep(0.0, 1.0, abs(m - 0.8)));
return col * 0.5;
}
vec3 getLineColor(float t, vec3 baseColor) {
if (lineGradientCount <= 0) {
return baseColor;
}
vec3 gradientColor;
if (lineGradientCount == 1) {
gradientColor = lineGradient[0];
} else {
float clampedT = clamp(t, 0.0, 0.9999);
float scaled = clampedT * float(lineGradientCount - 1);
int idx = int(floor(scaled));
float f = fract(scaled);
int idx2 = min(idx + 1, lineGradientCount - 1);
vec3 c1 = lineGradient[idx];
vec3 c2 = lineGradient[idx2];
gradientColor = mix(c1, c2, f);
}
return gradientColor * 0.5;
}
vec3 drawCircle(vec2 uv, vec2 center, float r, vec3 color) {
float d = length(uv - center);
float glowW = circleGlowSize / iResolution.y * 2.0;
float glow = exp(-pow(max(d - r, 0.0) / glowW, 2.0)) * circleGlowStrength;
float fog = 0.008 / max(d * d * 3.0 + 0.016, 0.001);
float aa = 1.5 / iResolution.y;
float core = 1.0 - smoothstep(r - aa, r + aa, d);
vec3 result = color * (glow + fog) * (1.0 - core);
result += vec3(core);
return result;
}
float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) { float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
float bestT = 0.0; float bestT = 0.0;
float bestD = 1e9; float bestD = 1e9;
@ -200,21 +126,8 @@ float bezierClosestT(vec2 q, vec2 p0, vec2 pc, vec2 p1) {
return t; return t;
} }
float waveFocal(vec2 uv, float fi, float totalLines, vec2 sp, vec2 ep) { // Accepts precomputed bezier values (t, curvePos, norm) computed once per segment
vec2 seg = ep - sp; float waveFocal(vec2 uv, float fi, float totalLines, float t, vec2 curvePos, vec2 norm) {
float segLen = length(seg);
if (segLen < 0.001) return 0.0;
vec2 segDir = seg / segLen;
vec2 segPerp = vec2(-segDir.y, segDir.x);
vec2 pc = (sp + ep) * 0.5 + segPerp * segLen * bezierCurvature;
float t = bezierClosestT(uv, sp, pc, ep);
float mt = 1.0 - t;
vec2 curvePos = mt*mt*sp + 2.0*mt*t*pc + t*t*ep;
vec2 tang = normalize(2.0*mt*(pc - sp) + 2.0*t*(ep - pc));
vec2 norm = vec2(-tang.y, tang.x);
float s = dot(uv - curvePos, norm); float s = dot(uv - curvePos, norm);
float time = iTime * animationSpeed; float time = iTime * animationSpeed;
@ -232,25 +145,6 @@ float waveFocal(vec2 uv, float fi, float totalLines, vec2 sp, vec2 ep) {
return fade * (0.013 / max(abs(dist) * lineSharpness + 0.004, 1e-4) + 0.003); return fade * (0.013 / max(abs(dist) * lineSharpness + 0.004, 1e-4) + 0.003);
} }
float wave(vec2 uv, float offset, vec2 screenUv, vec2 mouseUv, bool shouldBend) {
float time = iTime * animationSpeed;
float x_offset = offset;
float x_movement = time * 0.1;
float amp = sin(offset + time * 0.2) * 0.3;
float y = sin(uv.x + x_offset + x_movement) * amp;
if (shouldBend) {
vec2 d = screenUv - mouseUv;
float influence = exp(-dot(d, d) * bendRadius);
float bendOffset = (mouseUv.y - screenUv.y) * influence * bendStrength * bendInfluence;
y += bendOffset;
}
float m = uv.y - y;
return 0.0175 / max(abs(m) + 0.01, 1e-3) + 0.01;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) { void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y; vec2 baseUv = (2.0 * fragCoord - iResolution.xy) / iResolution.y;
baseUv.y *= -1.0; baseUv.y *= -1.0;
@ -261,96 +155,43 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 col = vec3(0.0); vec3 col = vec3(0.0);
vec3 b = lineGradientCount > 0 ? bgColorCenter : background_color(baseUv); const int MAX_PTS = 16;
const int MAX_SEGS = 15;
vec2 mouseUv = vec2(0.0); for (int s = 0; s < MAX_SEGS; ++s) {
if (interactive) { if (s >= numPoints - 1) break;
mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y;
mouseUv.y *= -1.0;
}
if (enableBottom) { vec2 sp = vec2(pointX[s], pointY[s]);
for (int i = 0; i < bottomLineCount; ++i) { vec2 ep = vec2(pointX[s + 1], pointY[s + 1]);
float fi = float(i);
float t = fi / max(float(bottomLineCount - 1), 1.0);
vec3 lineCol = getLineColor(t, b);
float angle = bottomWavePosition.z * log(length(baseUv) + 1.0); vec2 segD = ep - sp;
vec2 ruv = baseUv * rotate(angle); float segL = length(segD);
col += lineCol * wave( vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
ruv + vec2(bottomLineDistance * fi + bottomWavePosition.x, bottomWavePosition.y), vec2 sPerp = vec2(-segDir.y, segDir.x);
1.5 + 0.2 * fi, vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
baseUv,
mouseUv, float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
interactive vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg);
) * 0.2;
// bezierClosestT computed ONCE per segment shared by fog + all lines
float bt = bezierClosestT(baseUv, sp, pc, ep);
float bmt = 1.0 - bt;
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
vec2 bTang = normalize(bmt*(pc - sp) + bt*(ep - pc));
vec2 bNorm = vec2(-bTang.y, bTang.x);
float bDist = length(baseUv - bPos);
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
float fogEnv = sin(bt * 3.14159265359);
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
col += lineCol * segFog;
for (int i = 0; i < middleLineCount; ++i) {
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
} }
} }
if (enableMiddle) { col *= lineBrightness;
const int MAX_PTS = 8;
const int MAX_SEGS = 7;
float r = circleRadiusPx / iResolution.y * 2.0;
// Segments: connect consecutive points using pointX[] and pointY[]
for (int s = 0; s < MAX_SEGS; ++s) {
if (s >= numPoints - 1) break;
vec2 sp = vec2(pointX[s], pointY[s]);
vec2 ep = vec2(pointX[s + 1], pointY[s + 1]);
vec2 pd = ep - sp;
float pl = length(pd);
vec2 pa = pl > 0.001 ? pd / pl : vec2(1.0, 0.0);
float t_seg = clamp(dot(baseUv - sp, pa) / pl, 0.0, 1.0);
vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg);
vec2 segD = ep - sp;
float segL = length(segD);
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
vec2 sPerp = vec2(-segDir.y, segDir.x);
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
float bt = bezierClosestT(baseUv, sp, pc, ep);
float bmt = 1.0 - bt;
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
float bDist = length(baseUv - bPos);
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
float fogEnv = sin(bt * 3.14159265359);
float segFog = fogFade * fogEnv * 0.0018 / max(bDist * bDist * 4.0 + 0.012, 0.001);
col += lineCol * segFog;
for (int i = 0; i < middleLineCount; ++i) {
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), sp, ep);
}
}
// Circles at each point
for (int p = 0; p < MAX_PTS; ++p) {
if (p >= numPoints) break;
vec3 circCol = pointColor[p];
col += drawCircle(baseUv, vec2(pointX[p], pointY[p]), r, circCol);
}
}
if (enableTop) {
for (int i = 0; i < topLineCount; ++i) {
float fi = float(i);
float t = fi / max(float(topLineCount - 1), 1.0);
vec3 lineCol = getLineColor(t, b);
float angle = topWavePosition.z * log(length(baseUv) + 1.0);
vec2 ruv = baseUv * rotate(angle);
ruv.x *= -1.0;
col += lineCol * wave(
ruv + vec2(topLineDistance * fi + topWavePosition.x, topWavePosition.y),
1.0 + 0.2 * fi,
baseUv,
mouseUv,
interactive
) * 0.1;
}
}
float dist = length(baseUv) / 1.8; float dist = length(baseUv) / 1.8;
vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0)); vec3 bg = mix(bgColorCenter, bgColorEdge, clamp(dist, 0.0, 1.0));
@ -396,21 +237,17 @@ let clock = null
let rafId = null let rafId = null
let resizeObserver = null let resizeObserver = null
let uniforms = null let uniforms = null
let scrollHandler = null
let scrollIdleTimer = null
let visibilityHandler = null
// Mouse tracking // Parallax tracking
let targetMouse = null
let currentMouse = null
let targetInfluence = 0
let currentInfluence = 0
let targetParallax = null let targetParallax = null
let currentParallax = null let currentParallax = null
const mouseDamping = 0.05 const parallaxDamping = 0.05
function getLineCount(waveType) { function getLineCount() {
if (typeof props.lineCount === 'number') return props.lineCount return typeof props.lineCount === 'number' ? props.lineCount : (props.lineCount[0] ?? 6)
if (!props.enabledWaves.includes(waveType)) return 0
const index = props.enabledWaves.indexOf(waveType)
return props.lineCount[index] ?? 6
} }
function applyGradient() { function applyGradient() {
@ -426,7 +263,7 @@ function applyGradient() {
function applyPointColors() { function applyPointColors() {
if (!uniforms) return if (!uniforms) return
for (let i = 0; i < 8; i++) { for (let i = 0; i < 16; i++) {
const hex = props.pointColors[i] const hex = props.pointColors[i]
if (hex) { if (hex) {
const c = hexToVec3(hex) const c = hexToVec3(hex)
@ -449,26 +286,24 @@ function applyBgColors() {
watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v }) watch(() => props.animationSpeed, (v) => { if (uniforms) uniforms.animationSpeed.value = v })
watch(() => props.lineCount, () => { watch(() => props.lineCount, () => {
if (!uniforms) return if (!uniforms) return
uniforms.middleLineCount.value = props.enabledWaves.includes('middle') ? getLineCount('middle') : 0 uniforms.middleLineCount.value = getLineCount()
}) })
watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v }) watch(() => props.lineSpread, (v) => { if (uniforms) uniforms.lineSpread.value = v })
watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v }) watch(() => props.fanSpread, (v) => { if (uniforms) uniforms.fanSpread.value = v })
watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.value = v }) watch(() => props.lineSharpness, (v) => { if (uniforms) uniforms.lineSharpness.value = v })
watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v }) watch(() => props.waveFrequency, (v) => { if (uniforms) uniforms.waveFrequency.value = v })
watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v }) watch(() => props.bezierCurvature, (v) => { if (uniforms) uniforms.bezierCurvature.value = v })
watch(() => props.circleRadiusPx, (v) => { if (uniforms) uniforms.circleRadiusPx.value = v }) watch(() => props.lineBrightness, (v) => { if (uniforms) uniforms.lineBrightness.value = v })
watch(() => props.circleGlowSize, (v) => { if (uniforms) uniforms.circleGlowSize.value = v })
watch(() => props.circleGlowStrength, (v) => { if (uniforms) uniforms.circleGlowStrength.value = v })
watch(() => props.numPoints, (v) => { if (uniforms) uniforms.numPoints.value = v }) watch(() => props.numPoints, (v) => { if (uniforms) uniforms.numPoints.value = v })
watch(() => props.pointXValues, (values) => { watch(() => props.pointXValues, (values) => {
if (!uniforms) return if (!uniforms) return
for (let i = 0; i < 8; i++) { for (let i = 0; i < 16; i++) {
uniforms.pointX.value[i] = values[i] ?? 0 uniforms.pointX.value[i] = values[i] ?? 0
} }
}, { deep: true }) }, { deep: true })
watch(() => props.pointYValues, (values) => { watch(() => props.pointYValues, (values) => {
if (!uniforms) return if (!uniforms) return
for (let i = 0; i < 8; i++) { for (let i = 0; i < 16; i++) {
uniforms.pointY.value[i] = values[i] ?? 0 uniforms.pointY.value[i] = values[i] ?? 0
} }
}, { deep: true }) }, { deep: true })
@ -480,8 +315,6 @@ watch(() => props.bgColorEdge, applyBgColors)
onMounted(() => { onMounted(() => {
if (!containerRef.value) return if (!containerRef.value) return
targetMouse = new Vector2(-1000, -1000)
currentMouse = new Vector2(-1000, -1000)
targetParallax = new Vector2(0, 0) targetParallax = new Vector2(0, 0)
currentParallax = new Vector2(0, 0) currentParallax = new Vector2(0, 0)
@ -489,39 +322,35 @@ onMounted(() => {
camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1) camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1)
camera.position.z = 1 camera.position.z = 1
renderer = new WebGLRenderer({ antialias: true, alpha: false }) // Adaptive DPR: full quality when idle, reduced during scroll
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) const nativeDpr = Math.min(window.devicePixelRatio || 1, 2)
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent)
const DPR_IDLE = isMobile ? Math.min(nativeDpr, 1.6) : nativeDpr
const DPR_SCROLL = isMobile ? 0.75 : nativeDpr // reduced on mobile during scroll
const DPR_RESTORE_DELAY = 100 // ms after scroll stops to restore quality
let currentDpr = DPR_IDLE
let scrolling = false
renderer = new WebGLRenderer({ antialias: !isMobile, alpha: false, powerPreference: 'high-performance' })
renderer.setPixelRatio(currentDpr)
renderer.domElement.style.width = '100%' renderer.domElement.style.width = '100%'
renderer.domElement.style.height = '100%' renderer.domElement.style.height = '100%'
renderer.domElement.style.display = 'block' renderer.domElement.style.display = 'block'
renderer.domElement.style.mixBlendMode = props.mixBlendMode renderer.domElement.style.mixBlendMode = props.mixBlendMode
containerRef.value.appendChild(renderer.domElement) containerRef.value.appendChild(renderer.domElement)
const middleLineCount = props.enabledWaves.includes('middle') ? getLineCount('middle') : 0 const middleLineCount = getLineCount()
// Initial point positions (UV space, no flip) // Initial point positions (UV space, no flip)
const initX = [...props.pointXValues].slice(0, 8).concat(Array(8).fill(0)).slice(0, 8) const initX = [...props.pointXValues].slice(0, 16).concat(Array(16).fill(0)).slice(0, 16)
const initY = [...props.pointYValues].slice(0, 8).concat(Array(8).fill(0)).slice(0, 8) const initY = [...props.pointYValues].slice(0, 16).concat(Array(16).fill(0)).slice(0, 16)
uniforms = { uniforms = {
iTime: { value: 0 }, iTime: { value: 0 },
iResolution: { value: new Vector3(1, 1, 1) }, iResolution: { value: new Vector3(1, 1, 1) },
animationSpeed: { value: props.animationSpeed }, animationSpeed: { value: props.animationSpeed },
enableTop: { value: props.enabledWaves.includes('top') },
enableMiddle: { value: props.enabledWaves.includes('middle') },
enableBottom: { value: props.enabledWaves.includes('bottom') },
topLineCount: { value: 0 },
middleLineCount: { value: middleLineCount }, middleLineCount: { value: middleLineCount },
bottomLineCount: { value: 0 },
topLineDistance: { value: 0.01 },
bottomLineDistance: { value: 0.01 },
topWavePosition: { value: new Vector3(10.0, 0.5, -0.4) },
bottomWavePosition: { value: new Vector3(2.0, -0.7, -1) },
numPoints: { value: props.numPoints }, numPoints: { value: props.numPoints },
pointX: { value: initX }, pointX: { value: initX },
pointY: { value: initY }, pointY: { value: initY },
@ -530,21 +359,12 @@ onMounted(() => {
lineSharpness: { value: props.lineSharpness }, lineSharpness: { value: props.lineSharpness },
waveFrequency: { value: props.waveFrequency }, waveFrequency: { value: props.waveFrequency },
bezierCurvature: { value: props.bezierCurvature }, bezierCurvature: { value: props.bezierCurvature },
circleRadiusPx: { value: props.circleRadiusPx }, lineBrightness: { value: props.lineBrightness },
circleGlowSize: { value: props.circleGlowSize },
circleGlowStrength: { value: props.circleGlowStrength },
pointColor: { pointColor: {
value: Array.from({ length: 8 }, () => new Vector3(1, 1, 1)) value: Array.from({ length: 16 }, () => new Vector3(1, 1, 1))
}, },
iMouse: { value: new Vector2(-1000, -1000) },
interactive: { value: props.interactive },
bendRadius: { value: 5.0 },
bendStrength: { value: -0.5 },
bendInfluence: { value: 0 },
parallax: { value: props.parallax }, parallax: { value: props.parallax },
parallaxStrength: { value: 0.2 },
parallaxOffset: { value: new Vector2(0, 0) }, parallaxOffset: { value: new Vector2(0, 0) },
lineGradient: { lineGradient: {
@ -587,57 +407,148 @@ onMounted(() => {
resizeObserver = new ResizeObserver(setSize) resizeObserver = new ResizeObserver(setSize)
resizeObserver.observe(containerRef.value) resizeObserver.observe(containerRef.value)
// Pointer events // Pointer events (parallax only)
const handlePointerMove = (event) => { if (props.parallax) {
const rect = renderer.domElement.getBoundingClientRect() const handlePointerMove = (event) => {
const x = event.clientX - rect.left const rect = renderer.domElement.getBoundingClientRect()
const y = event.clientY - rect.top const x = event.clientX - rect.left
const dpr = renderer.getPixelRatio() const y = event.clientY - rect.top
targetMouse.set(x * dpr, (rect.height - y) * dpr)
targetInfluence = 1.0
if (props.parallax) {
const centerX = rect.width / 2 const centerX = rect.width / 2
const centerY = rect.height / 2 const centerY = rect.height / 2
const offsetX = (x - centerX) / rect.width targetParallax.set(
const offsetY = -(y - centerY) / rect.height ((x - centerX) / rect.width) * 0.2,
targetParallax.set(offsetX * 0.2, offsetY * 0.2) (-(y - centerY) / rect.height) * 0.2
)
}
renderer.domElement.addEventListener('pointermove', handlePointerMove)
}
// Scroll sync: update cached scrollLeft + trigger adaptive DPR reduction.
let cachedScrollLeft = 0
function setDpr(dpr) {
if (dpr === currentDpr) return
currentDpr = dpr
renderer.setPixelRatio(dpr)
// Re-apply size so resolution updates
if (containerRef.value) {
const w = containerRef.value.clientWidth || 1
const h = containerRef.value.clientHeight || 1
renderer.setSize(w, h, false)
uniforms.iResolution.value.set(renderer.domElement.width, renderer.domElement.height, 1)
} }
} }
const handlePointerLeave = () => { scrollHandler = () => {
targetInfluence = 0.0 if (props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
}
// Drop DPR while scrolling on mobile
if (!scrolling && DPR_SCROLL < DPR_IDLE) {
scrolling = true
setDpr(DPR_SCROLL)
}
clearTimeout(scrollIdleTimer)
scrollIdleTimer = setTimeout(() => {
scrolling = false
setDpr(DPR_IDLE)
}, DPR_RESTORE_DELAY)
} }
renderer.domElement.addEventListener('pointermove', handlePointerMove) if (props.scrollContainer) {
renderer.domElement.addEventListener('pointerleave', handlePointerLeave) cachedScrollLeft = props.scrollContainer.scrollLeft || 0
props.scrollContainer.addEventListener('scroll', scrollHandler, { passive: true })
}
// Fast inline scroll sync reads cached scrollLeft instead of DOM during render
function syncScrollFromCache() {
if (props.scrollUvScale > 0) {
const sl = cachedScrollLeft
for (let i = 0; i < 16; i++) {
uniforms.pointX.value[i] = (props.pointXValues[i] ?? 0) - sl * props.scrollUvScale
}
}
}
// FPS tracking + adaptive DPR
let frameCount = 0
let fpsLastTime = performance.now()
// Adaptive DPR steps based on measured FPS (mobile only)
// Steps are tried from lowest to highest first step where fps >= threshold wins
const DPR_STEPS = isMobile ? [
{ threshold: 50, dpr: DPR_IDLE }, // good fps full quality
{ threshold: 35, dpr: Math.max(DPR_IDLE * 0.75, 0.75) }, // mid fps 75%
{ threshold: 0, dpr: Math.max(DPR_IDLE * 0.5, 0.5) }, // low fps 50%
] : null
let adaptiveDprTarget = DPR_IDLE
// Pause rendering when app/tab is hidden (e.g. iPhone home screen)
visibilityHandler = () => {
if (document.hidden) {
if (rafId) cancelAnimationFrame(rafId)
rafId = null
} else if (!rafId) {
fpsLastTime = performance.now()
frameCount = 0
renderLoop()
}
}
document.addEventListener('visibilitychange', visibilityHandler)
// Render loop // Render loop
const renderLoop = () => { const renderLoop = () => {
// FPS counter + adaptive DPR
frameCount++
const now = performance.now()
if (now - fpsLastTime >= 1000) {
const fps = Math.round(frameCount / ((now - fpsLastTime) / 1000))
fpsDisplay.value = fps
frameCount = 0
fpsLastTime = now
// Adapt DPR based on measured FPS (only when not scroll-throttled)
if (DPR_STEPS && !scrolling) {
const step = DPR_STEPS.find(s => fps >= s.threshold) ?? DPR_STEPS[DPR_STEPS.length - 1]
if (step.dpr !== adaptiveDprTarget) {
adaptiveDprTarget = step.dpr
setDpr(adaptiveDprTarget)
}
}
dprDisplay.value = currentDpr.toFixed(2)
}
// Read latest scrollLeft from DOM in case scroll event was missed
if (props.scrollContainer) {
cachedScrollLeft = props.scrollContainer.scrollLeft || 0
}
uniforms.iTime.value = clock.getElapsedTime() uniforms.iTime.value = clock.getElapsedTime()
if (props.interactive) {
currentMouse.lerp(targetMouse, mouseDamping)
uniforms.iMouse.value.copy(currentMouse)
currentInfluence += (targetInfluence - currentInfluence) * mouseDamping
uniforms.bendInfluence.value = currentInfluence
}
if (props.parallax) { if (props.parallax) {
currentParallax.lerp(targetParallax, mouseDamping) currentParallax.lerp(targetParallax, parallaxDamping)
uniforms.parallaxOffset.value.copy(currentParallax) uniforms.parallaxOffset.value.copy(currentParallax)
} }
syncScrollFromCache()
renderer.render(scene, camera) renderer.render(scene, camera)
rafId = requestAnimationFrame(renderLoop) rafId = requestAnimationFrame(renderLoop)
} }
renderLoop() renderLoop()
}) })
defineExpose({ fpsDisplay, dprDisplay })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (rafId) cancelAnimationFrame(rafId) if (rafId) cancelAnimationFrame(rafId)
if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler)
if (resizeObserver) resizeObserver.disconnect() if (resizeObserver) resizeObserver.disconnect()
if (props.scrollContainer && scrollHandler) {
props.scrollContainer.removeEventListener('scroll', scrollHandler)
}
clearTimeout(scrollIdleTimer)
if (geometry) geometry.dispose() if (geometry) geometry.dispose()
if (material) material.dispose() if (material) material.dispose()
if (renderer) { if (renderer) {
@ -654,5 +565,7 @@ onBeforeUnmount(() => {
position: absolute; position: absolute;
inset: 0; inset: 0;
overflow: hidden; overflow: hidden;
will-change: transform;
transform: translateZ(0);
} }
</style> </style>

View file

@ -4,20 +4,24 @@
:class="{ :class="{
'glow-dot--ghost': isGhost, 'glow-dot--ghost': isGhost,
'glow-dot--selected': selected, 'glow-dot--selected': selected,
'glow-dot--dimmed': isDimmed 'glow-dot--dimmed': isDimmed,
'glow-dot--label-above': labelAbove
}" }"
:style="dotStyle" :style="dotStyle"
@click.stop="onSelect" @click.stop="onSelect"
> >
<!-- White inner circle shader provides the glow --> <div class="glow-dot__inner" :style="{ boxShadow: glowShadow }">
<div class="glow-dot__inner">
<img <img
v-if="event.image" v-if="imageSrc"
:src="event.image" :src="imageSrc"
class="glow-dot__image" class="glow-dot__image"
alt="" alt=""
/> />
</div> </div>
<div v-if="!isGhost && event.title" class="glow-dot__label" :style="labelStyle">
<span class="glow-dot__title" :style="titleStyle">{{ event.title }}</span>
<span class="glow-dot__date" :style="dateStyle">{{ formattedDate }}</span>
</div>
</div> </div>
</template> </template>
@ -25,6 +29,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useEventsStore } from 'stores/events' import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
import { useImageCache } from 'composables/useImageCache'
const props = defineProps({ const props = defineProps({
event: { type: Object, required: true }, event: { type: Object, required: true },
@ -37,17 +42,49 @@ const emit = defineEmits(['select'])
const eventsStore = useEventsStore() const eventsStore = useEventsStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
// Match shader circle: CSS diameter = 2 * circleRadiusPx / dpr // Resolve image: cached thumbnail from IndexedDB or fetch & cache
const { resolvedSrc: imageSrc } = props.event.image
? useImageCache(props.event.image, props.event.id)
: { resolvedSrc: computed(() => null) }
const fl = computed(() => settingsStore.floatingLines)
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
// CSS diameter derived from settings (circleRadius is half-size in px)
const dpr = Math.min(window.devicePixelRatio || 1, 2) const dpr = Math.min(window.devicePixelRatio || 1, 2)
const dotSize = computed(() => 2 * fl.value.circleRadius / dpr)
const dotSize = computed(() => { // Y position: emotion +1 top (20%), 0 middle (47%), -1 bottom (74%)
return 2 * settingsStore.floatingLines.circleRadius / dpr
const yPercent = computed(() => 48 - props.event.emotion * 30)
// Label above dot when dot is in lower half, below when in upper half
const labelAbove = computed(() => yPercent.value > 48)
// Format date as "12. Feb 2024"
const MONTH_SHORT = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez']
const formattedDate = computed(() => {
const d = new Date(props.event.date)
return `${d.getDate()}. ${MONTH_SHORT[d.getMonth()]} ${d.getFullYear()}`
}) })
// Y position: emotion +1 top (15%), 0 middle (50%), -1 bottom (85%) // Label font sizes per setting
const yPercent = computed(() => { const LABEL_FONT = { small: { title: 10, date: 9 }, medium: { title: 12, date: 11 }, large: { title: 14, date: 13 } }
return 50 - props.event.emotion * 35 const labelFont = computed(() => LABEL_FONT[fl.value.labelSize] ?? LABEL_FONT.small)
}) const labelColor = computed(() => fl.value.labelColor ?? '#ffffff')
const labelStyle = computed(() => ({
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
}))
const titleStyle = computed(() => ({
fontSize: `${labelFont.value.title}px`,
color: labelColor.value,
maxWidth: labelFont.value.title >= 14 ? '120px' : '90px'
}))
const dateStyle = computed(() => ({
fontSize: `${labelFont.value.date}px`,
color: labelColor.value
}))
const dotStyle = computed(() => ({ const dotStyle = computed(() => ({
left: `${props.x}px`, left: `${props.x}px`,
@ -56,14 +93,26 @@ const dotStyle = computed(() => ({
height: `${dotSize.value}px` height: `${dotSize.value}px`
})) }))
const isDimmed = computed(() => { // Two-layer box-shadow: tight bright core + wide soft halo
return eventsStore.selectedEventId !== null && !props.selected && !props.isGhost const glowShadow = computed(() => {
const size = fl.value.glowSize
const strength = fl.value.glowStrength
const color = glowColor.value
const core = alphaHex(Math.min(strength / 3, 1))
const halo = alphaHex(Math.min(strength / 7, 1))
return `0 0 ${size}px 0px ${color}${core}, 0 0 ${size * 2.5}px ${size * 0.3}px ${color}${halo}`
}) })
function alphaHex(a) {
return Math.round(Math.min(1, Math.max(0, a)) * 255).toString(16).padStart(2, '0')
}
const isDimmed = computed(() =>
eventsStore.selectedEventId !== null && !props.selected && !props.isGhost
)
function onSelect() { function onSelect() {
if (!props.isGhost) { if (!props.isGhost) emit('select')
emit('select')
}
} }
</script> </script>
@ -76,14 +125,12 @@ function onSelect() {
transition: opacity 0.3s ease, transform 0.15s ease; transition: opacity 0.3s ease, transform 0.15s ease;
} }
/* Clean inner circle — shader provides the glow around it */
.glow-dot__inner { .glow-dot__inner {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 50%; border-radius: 50%;
background: #fff; background: #fff;
overflow: hidden;
} }
.glow-dot__image { .glow-dot__image {
@ -91,20 +138,63 @@ function onSelect() {
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
border-radius: 50%;
} }
/* States */ /* States */
.glow-dot--ghost { .glow-dot--ghost {
opacity: 0.7; opacity: 1;
cursor: default; cursor: default;
} }
.glow-dot--selected { .glow-dot--selected {
transform: translate(-50%, -50%) scale(1.15); transform: translate(-50%, -50%) scale(1.15);
z-index: 15; z-index: 15;
} }
.glow-dot--dimmed { .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;
} }
</style> </style>

View file

@ -1,8 +1,16 @@
<template> <template>
<Transition name="slide-up"> <Transition name="slide-up">
<div v-if="open" class="lw-settings glass--panel"> <div
<!-- Handle tap to close --> v-if="open"
<div class="lw-settings__handle" @click="$emit('close')"> class="lw-settings glass--panel"
:style="panelHeight != null ? { height: panelHeight + 'dvh' } : {}"
:class="{ 'lw-settings--dragging': isDragging }"
>
<!-- Handle drag to resize, tap to close -->
<div
class="lw-settings__handle"
v-on="handleListeners"
>
<div class="lw-settings__handle-bar"></div> <div class="lw-settings__handle-bar"></div>
</div> </div>
@ -112,6 +120,46 @@
@update:model-value="v => update({ glowStrength: v })" @update:model-value="v => update({ glowStrength: v })"
:min="0.5" :max="12" :step="0.5" :min="0.5" :max="12" :step="0.5"
/> />
<div class="lw-settings__row">
<span>Helligkeit</span>
<span class="lw-settings__value">{{ (fl.lineBrightness ?? 1).toFixed(2) }}</span>
</div>
<q-slider
:model-value="fl.lineBrightness ?? 1"
@update:model-value="v => update({ lineBrightness: v })"
:min="0.05" :max="2" :step="0.05"
/>
</div>
<!-- Labels -->
<div class="lw-settings__card" :class="{ 'lw-settings__card--dark': isDark }">
<span class="lw-settings__card-label">Labels</span>
<div class="lw-settings__row">
<span>Schriftgröße</span>
<div class="lw-settings__segmented">
<button
v-for="size in LABEL_SIZES"
:key="size.value"
class="lw-settings__seg-btn"
:class="{ 'lw-settings__seg-btn--active': fl.labelSize === size.value }"
@click="update({ labelSize: size.value })"
>
{{ size.label }}
</button>
</div>
</div>
<div class="lw-settings__row">
<span>Schriftfarbe</span>
<input
type="color"
:value="fl.labelColor ?? '#ffffff'"
@input="e => update({ labelColor: e.target.value })"
class="lw-settings__color-input"
/>
</div>
</div> </div>
<!-- Hintergrundbild --> <!-- Hintergrundbild -->
@ -206,18 +254,28 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
import { usePanelDrag } from 'composables/usePanelDrag'
defineProps({ open: { type: Boolean, default: false } }) const props = defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close']) const emit = defineEmits(['close'])
const { panelHeight, isDragging, handleListeners, resetHeight } = usePanelDrag(() => emit('close'))
watch(() => props.open, (open) => { if (open) resetHeight() })
const $q = useQuasar() const $q = useQuasar()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const isDark = computed(() => $q.dark.isActive) const isDark = computed(() => $q.dark.isActive)
const fl = computed(() => settingsStore.floatingLines) const fl = computed(() => settingsStore.floatingLines)
const LABEL_SIZES = [
{ label: 'Klein', value: 'small' },
{ label: 'Mittel', value: 'medium' },
{ label: 'Groß', value: 'large' }
]
function update(changes) { function update(changes) {
settingsStore.updateFloatingLines(changes) settingsStore.updateFloatingLines(changes)
} }
@ -238,6 +296,11 @@ function toggleDark() {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 20px 20px 0 0; border-radius: 20px 20px 0 0;
transition: height 0.25s ease;
}
.lw-settings--dragging {
transition: none;
} }
.lw-settings__handle { .lw-settings__handle {
@ -245,7 +308,12 @@ function toggleDark() {
justify-content: center; justify-content: center;
padding: 10px 0 4px; padding: 10px 0 4px;
flex-shrink: 0; flex-shrink: 0;
cursor: pointer; cursor: grab;
touch-action: none;
}
.lw-settings__handle:active {
cursor: grabbing;
} }
.lw-settings__handle-bar { .lw-settings__handle-bar {
@ -306,6 +374,35 @@ function toggleDark() {
opacity: 0.6; opacity: 0.6;
} }
/* Segmented control */
.lw-settings__segmented {
display: flex;
background: rgba(128, 128, 128, 0.1);
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(128, 128, 128, 0.15);
}
.lw-settings__seg-btn {
flex: 1;
padding: 5px 12px;
border: none;
background: none;
color: inherit;
font-family: inherit;
font-size: 12px;
cursor: pointer;
opacity: 0.5;
transition: all 0.15s ease;
white-space: nowrap;
}
.lw-settings__seg-btn--active {
background: rgba(168, 85, 247, 0.25);
opacity: 1;
font-weight: 600;
}
.lw-settings__img-grid { .lw-settings__img-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -0,0 +1,171 @@
<template>
<Transition name="modal-card">
<div v-if="open" class="modal-card glass--panel" :class="{ 'modal-card--dark': isDark }">
<!-- Header: title + close -->
<div class="modal-card__header">
<span class="modal-card__title">{{ title }}</span>
<button class="modal-card__close" @click="$emit('close')">
<q-icon name="close" size="22px" />
</button>
</div>
<!-- Tabs (optional) -->
<div v-if="tabs.length" class="modal-card__tabs">
<div class="modal-card__tabs-scroll">
<button
v-for="tab in tabs"
:key="tab.value"
class="modal-card__tab"
:class="{ 'modal-card__tab--active': modelValue === tab.value }"
@click="$emit('update:modelValue', tab.value)"
>
<q-icon v-if="tab.icon" :name="tab.icon" size="16px" />
<span>{{ tab.label }}</span>
</button>
</div>
</div>
<!-- Content -->
<div class="modal-card__body">
<slot />
</div>
</div>
</Transition>
</template>
<script setup>
import { computed } from 'vue'
import { useQuasar } from 'quasar'
defineProps({
open: { type: Boolean, default: false },
title: { type: String, default: '' },
tabs: { type: Array, default: () => [] },
modelValue: { type: String, default: '' }
})
defineEmits(['close', 'update:modelValue'])
const $q = useQuasar()
const isDark = computed(() => $q.dark.isActive)
</script>
<style scoped>
.modal-card {
position: fixed;
top: 48px;
left: 12px;
right: 12px;
bottom: 12px;
z-index: 30;
display: flex;
flex-direction: column;
border-radius: 20px;
border: 1px solid rgba(128, 128, 128, 0.15);
overflow: hidden;
}
/* Header */
.modal-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 20px 12px;
flex-shrink: 0;
}
.modal-card__title {
font-size: 20px;
font-weight: 700;
}
.modal-card__close {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(128, 128, 128, 0.1);
color: inherit;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s;
}
.modal-card__close:hover {
background: rgba(128, 128, 128, 0.2);
}
/* Tabs */
.modal-card__tabs {
flex-shrink: 0;
padding: 0 16px;
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
}
.modal-card__tabs-scroll {
display: flex;
gap: 4px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding-bottom: 8px;
}
.modal-card__tabs-scroll::-webkit-scrollbar {
display: none;
}
.modal-card__tab {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: none;
background: rgba(128, 128, 128, 0.08);
color: inherit;
font-family: inherit;
font-size: 13px;
font-weight: 500;
border-radius: 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
opacity: 0.6;
}
.modal-card__tab:hover {
opacity: 0.85;
background: rgba(128, 128, 128, 0.14);
}
.modal-card__tab--active {
opacity: 1;
background: rgba(128, 128, 128, 0.18);
}
.modal-card--dark .modal-card__tab--active {
background: rgba(255, 255, 255, 0.12);
}
/* Body */
.modal-card__body {
flex: 1;
overflow-y: auto;
padding: 20px;
-webkit-overflow-scrolling: touch;
}
/* Transition */
.modal-card-enter-active,
.modal-card-leave-active {
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease;
}
.modal-card-enter-from,
.modal-card-leave-to {
transform: translateY(30px) scale(0.96);
opacity: 0;
}
</style>

View file

@ -1,51 +1,70 @@
<template> <template>
<div <div class="timeline-container">
class="timeline" <div
ref="timelineRef" class="timeline"
@scroll="onScroll" ref="timelineRef"
@wheel.prevent="onWheel" @scroll="onScroll"
@touchstart.passive="onTouchStart" @wheel.prevent="onWheel"
@touchmove.passive="onTouchMove" @touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd" @touchmove.passive="onTouchMove"
> @touchend.passive="onTouchEnd"
<div class="timeline__track" :style="{ width: trackWidth + 'px' }"> >
<!-- GlowDots (real events + ghost) --> <div class="timeline__track" :style="{ width: trackWidth + 'px' }">
<GlowDot <!-- GlowDots only visible ones rendered -->
v-for="(event, index) in displayEvents" <GlowDot
:key="event.id" v-for="{ event, globalIndex } in visibleEvents"
:event="event" :key="event.id"
:x="getEventX(index)" :event="event"
:is-ghost="event.id === '__ghost__'" :x="getEventX(globalIndex)"
:selected="eventsStore.selectedEventId === event.id" :is-ghost="event.id === '__ghost__'"
@select="$emit('dotSelect', event.id)" :selected="eventsStore.selectedEventId === event.id"
/> @select="$emit('dotSelect', event.id)"
/>
<!-- Month labels one per event, clickable --> <!-- Month labels only visible -->
<div class="timeline__labels"> <div class="timeline__labels">
<div <div
v-for="(label, index) in eventLabels" v-for="{ label, globalIndex } in visibleLabels"
:key="label.key" :key="label.key"
class="timeline__month" class="timeline__month"
:style="{ left: getEventX(index) + 'px' }" :style="{ left: getEventX(globalIndex) + 'px' }"
:class="{ 'timeline__month--active': label.key === activeLabel }" :class="{ 'timeline__month--active': label.key === activeLabel }"
@click="scrollToIndex(index)" @click="scrollToIndex(globalIndex)"
> >
{{ label.month }} {{ label.month }}
</div>
</div> </div>
</div> </div>
</div>
<!-- Year labels shown at year transitions, clickable --> <!-- Sticky year labels with nav arrows (outside scroll container) -->
<div class="timeline__years"> <div class="timeline__sticky-years">
<TransitionGroup name="year-fade">
<div <div
v-for="year in yearMarkers" v-for="label in stickyYearLabels"
:key="year.key" :key="label.year"
class="timeline__year" class="timeline__sticky-year-group"
:style="{ left: year.x + 'px' }" :style="{ left: label.left + 'px' }"
@click="scrollToX(year.x)"
> >
{{ year.year }} <button
v-if="label.hasPrev"
class="timeline__year-arrow timeline__year-arrow--prev"
@click="scrollToYearCenter(label.year - 1)"
>
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M7 1L3 5l4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<span class="timeline__sticky-year" @click="scrollToYearCenter(label.year)">
{{ label.year }}
</span>
<button
v-if="label.hasNext"
class="timeline__year-arrow timeline__year-arrow--next"
@click="scrollToYearCenter(label.year + 1)"
>
<svg width="14" height="14" viewBox="0 0 10 10"><path d="M3 1l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div> </div>
</div> </TransitionGroup>
</div> </div>
</div> </div>
</template> </template>
@ -117,51 +136,123 @@ const eventLabels = computed(() => {
}) })
}) })
// Year markers shown between events where the year changes // Year ranges for each year, the world-space X range of its events
const yearMarkers = computed(() => { const yearRanges = computed(() => {
const markers = [] const ranges = []
const sorted = displayEvents.value const events = displayEvents.value
if (sorted.length === 0) return markers if (events.length === 0) return ranges
// First event's year let currentYear = new Date(events[0].date).getFullYear()
const firstDate = new Date(sorted[0].date) let startIdx = 0
markers.push({
key: `year-0`,
year: firstDate.getFullYear(),
x: getEventX(0)
})
for (let i = 1; i < sorted.length; i++) { for (let i = 1; i < events.length; i++) {
const prevYear = new Date(sorted[i - 1].date).getFullYear() const year = new Date(events[i].date).getFullYear()
const currYear = new Date(sorted[i].date).getFullYear() if (year !== currentYear) {
if (currYear !== prevYear) { ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(i - 1) })
// Position between the two events currentYear = year
const x = (getEventX(i - 1) + getEventX(i)) / 2 startIdx = i
markers.push({
key: `year-${i}`,
year: currYear,
x
})
} }
} }
return markers ranges.push({ year: currentYear, startX: getEventX(startIdx), endX: getEventX(events.length - 1) })
return ranges
}) })
// Active label closest event to center of viewport // All years that exist in the data (for prev/next navigation)
const activeLabel = computed(() => { const allYears = computed(() => yearRanges.value.map(r => r.year))
const sorted = displayEvents.value
if (sorted.length === 0) return null // Sticky year labels positioned relative to viewport, clamped to edges
const centerX = scrollLeft.value + viewportWidth.value / 2 const YEAR_MARGIN = 24
let closestIndex = 0 const stickyYearLabels = computed(() => {
let closestDist = Infinity const sl = scrollLeft.value
for (let i = 0; i < sorted.length; i++) { const vw = viewportWidth.value
const dist = Math.abs(getEventX(i) - centerX) const viewLeft = sl
if (dist < closestDist) { const viewRight = sl + vw
closestDist = dist const years = allYears.value
closestIndex = i
} // 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() { function onScroll() {
@ -179,10 +270,19 @@ function scrollToIndex(index) {
}) })
} }
function scrollToX(x) { function scrollToYearCenter(year) {
if (!timelineRef.value) return 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({ timelineRef.value.scrollTo({
left: x - viewportWidth.value / 2, left: centerX - viewportWidth.value / 2,
behavior: 'smooth' behavior: 'smooth'
}) })
} }
@ -273,10 +373,13 @@ function onTouchEnd() {
let resizeObserver = null let resizeObserver = null
// Emit timeline state so the layout can position shader points // Emit timeline state so the layout can position shader points
function emitViewState() { function emitViewState() {
const { start, end } = visibleRange.value
emit('viewUpdate', { emit('viewUpdate', {
scrollLeft: scrollLeft.value, scrollLeft: scrollLeft.value,
viewportWidth: viewportWidth.value, viewportWidth: viewportWidth.value,
containerHeight: containerHeight.value, containerHeight: containerHeight.value,
visibleStart: start,
visibleEnd: end,
events: displayEvents.value.map((e, i) => ({ events: displayEvents.value.map((e, i) => ({
emotion: e.emotion, emotion: e.emotion,
x: getEventX(i), x: getEventX(i),
@ -313,15 +416,32 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
resizeObserver?.disconnect() 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 })
</script> </script>
<style scoped> <style scoped>
.timeline { .timeline-container {
position: absolute; position: absolute;
top: 60px; top: 40px;
left: 0; left: 0;
right: 0; right: 0;
bottom: 70px; bottom: 60px;
}
.timeline {
position: absolute;
inset: 0;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@ -340,7 +460,7 @@ onUnmounted(() => {
/* Month labels */ /* Month labels */
.timeline__labels { .timeline__labels {
position: absolute; position: absolute;
bottom: 28px; bottom: 44px;
left: 0; left: 0;
right: 0; right: 0;
height: 24px; height: 24px;
@ -363,22 +483,65 @@ onUnmounted(() => {
opacity: 1; opacity: 1;
} }
/* Year labels */ /* Sticky year labels — positioned outside the scroll container */
.timeline__years { .timeline__sticky-years {
position: absolute; position: absolute;
bottom: 6px; bottom: 4px;
left: 0; left: 0;
right: 0; right: 0;
height: 20px; height: 32px;
pointer-events: none;
z-index: 2;
} }
.timeline__year { .timeline__sticky-year-group {
position: absolute; position: absolute;
transform: translateX(-50%); transform: translateX(-50%);
font-size: 12px; display: flex;
font-weight: 600; align-items: center;
opacity: 0.4; gap: 6px;
pointer-events: auto;
transition: left 0.15s ease-out;
}
.timeline__sticky-year {
font-size: 16px;
font-weight: 700;
opacity: 0.6;
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.timeline__year-arrow {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: inherit;
opacity: 0.45;
cursor: pointer;
padding: 0;
transition: opacity 0.2s ease, background 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.timeline__year-arrow:active {
opacity: 0.8;
background: rgba(255, 255, 255, 0.25);
}
/* TransitionGroup animations */
.year-fade-enter-active,
.year-fade-leave-active {
transition: opacity 0.3s ease;
}
.year-fade-enter-from,
.year-fade-leave-to {
opacity: 0;
}
</style> </style>

View file

@ -0,0 +1,278 @@
<template>
<Transition name="slide-right">
<div v-if="open" class="user-menu glass--panel">
<!-- User header -->
<div class="user-menu__header">
<div class="user-menu__avatar">
<span>K</span>
</div>
<div class="user-menu__info">
<div class="user-menu__name">k-adam</div>
<div class="user-menu__handle">@k-adam</div>
</div>
</div>
<div class="user-menu__divider" />
<!-- Menu items -->
<div class="user-menu__items">
<button class="user-menu__item" @click="$emit('navigate', 'upgrade')">
<q-icon name="auto_awesome" size="20px" />
<span>Plan upgraden</span>
</button>
<button class="user-menu__item" @click="$emit('navigate', 'personalize')">
<q-icon name="palette" size="20px" />
<span>Personalisierung</span>
</button>
<button class="user-menu__item" @click="$emit('navigate', 'settings')">
<q-icon name="settings" size="20px" />
<span>Einstellungen</span>
</button>
</div>
<div class="user-menu__divider" />
<!-- Help with submenu -->
<div class="user-menu__items">
<button
class="user-menu__item"
:class="{ 'user-menu__item--active': helpOpen }"
@click="helpOpen = !helpOpen"
>
<q-icon name="support_agent" size="20px" />
<span>Hilfe</span>
<q-icon
name="chevron_right"
size="18px"
class="user-menu__chevron"
:class="{ 'user-menu__chevron--open': helpOpen }"
/>
</button>
<!-- Help sub-items -->
<Transition name="expand">
<div v-if="helpOpen" class="user-menu__sub">
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'help-center')">
<q-icon name="help_outline" size="18px" />
<span>Hilfecenter</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'release-notes')">
<q-icon name="new_releases" size="18px" />
<span>Release-Hinweise</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'terms')">
<q-icon name="description" size="18px" />
<span>AGB und Richtlinien</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'report-bug')">
<q-icon name="bug_report" size="18px" />
<span>Fehler melden</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'download')">
<q-icon name="download" size="18px" />
<span>Apps herunterladen</span>
</button>
<button class="user-menu__item user-menu__item--sub" @click="$emit('navigate', 'shortcuts')">
<q-icon name="keyboard" size="18px" />
<span>Tastaturkuerzel</span>
</button>
</div>
</Transition>
</div>
<div class="user-menu__divider" />
<div class="user-menu__items">
<button class="user-menu__item" @click="$emit('navigate', 'logout')">
<q-icon name="logout" size="20px" />
<span>Abmelden</span>
</button>
</div>
<!-- Footer: user + plan -->
<div class="user-menu__footer">
<div class="user-menu__avatar user-menu__avatar--sm">
<span>K</span>
</div>
<div class="user-menu__info">
<div class="user-menu__name user-menu__name--sm">Kevin Ada</div>
<div class="user-menu__plan">Free</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref } from 'vue'
defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close', 'navigate'])
const helpOpen = ref(false)
</script>
<style scoped>
.user-menu {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 25;
width: 280px;
max-width: 85vw;
display: flex;
flex-direction: column;
border-radius: 20px 0 0 20px;
border-top: none;
border-left: 1px solid rgba(255, 255, 255, 0.08);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* User header */
.user-menu__header {
display: flex;
align-items: center;
gap: 12px;
padding: 24px 20px 16px;
}
.user-menu__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e05a33;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.user-menu__avatar--sm {
width: 32px;
height: 32px;
font-size: 13px;
}
.user-menu__info {
min-width: 0;
}
.user-menu__name {
font-size: 15px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-menu__name--sm {
font-size: 13px;
}
.user-menu__handle {
font-size: 12px;
opacity: 0.5;
}
.user-menu__plan {
font-size: 11px;
opacity: 0.45;
}
/* Divider */
.user-menu__divider {
height: 1px;
margin: 4px 16px;
background: rgba(128, 128, 128, 0.15);
}
/* Menu items */
.user-menu__items {
padding: 4px 8px;
}
.user-menu__item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border: none;
background: none;
color: inherit;
font-family: inherit;
font-size: 14px;
cursor: pointer;
border-radius: 10px;
transition: background 0.15s ease;
text-align: left;
}
.user-menu__item:hover,
.user-menu__item--active {
background: rgba(128, 128, 128, 0.12);
}
.user-menu__item:active {
background: rgba(128, 128, 128, 0.2);
}
.user-menu__item--sub {
padding-left: 24px;
font-size: 13px;
}
.user-menu__chevron {
margin-left: auto;
transition: transform 0.2s ease;
}
.user-menu__chevron--open {
transform: rotate(90deg);
}
/* Help sub-items */
.user-menu__sub {
overflow: hidden;
}
/* Footer */
.user-menu__footer {
margin-top: auto;
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px 24px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
}
/* Slide-right transition */
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.slide-right-enter-from,
.slide-right-leave-to {
transform: translateX(100%);
}
/* Expand transition for help sub-menu */
.expand-enter-active,
.expand-leave-active {
transition: max-height 0.25s ease, opacity 0.2s ease;
max-height: 300px;
opacity: 1;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
</style>

View file

@ -0,0 +1,134 @@
<template>
<div class="zoom-control glass--button">
<button class="zoom-control__btn" @click="$emit('zoomIn')">
<q-icon name="add" size="18px" />
</button>
<div class="zoom-control__track" ref="trackRef" @pointerdown="onTrackDown">
<div class="zoom-control__fill" :style="{ height: fillPercent + '%' }" />
<div class="zoom-control__thumb" :style="{ bottom: fillPercent + '%' }" />
</div>
<button class="zoom-control__btn" @click="$emit('zoomOut')">
<q-icon name="remove" size="18px" />
</button>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
zoom: { type: Number, default: 1 },
min: { type: Number, default: 0.4 },
max: { type: Number, default: 3.0 }
})
const emit = defineEmits(['zoomIn', 'zoomOut', 'zoomTo'])
const trackRef = ref(null)
const fillPercent = computed(() => {
return ((props.zoom - props.min) / (props.max - props.min)) * 100
})
function onTrackDown(e) {
if (!trackRef.value) return
updateFromPointer(e)
const onMove = (ev) => updateFromPointer(ev)
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
function updateFromPointer(e) {
if (!trackRef.value) return
const rect = trackRef.value.getBoundingClientRect()
// Bottom = min, top = max invert Y
const ratio = 1 - Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
const newZoom = props.min + ratio * (props.max - props.min)
emit('zoomTo', newZoom)
}
</script>
<style scoped>
.zoom-control {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 6px 4px;
border-radius: 22px;
width: 36px;
}
.zoom-control__btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: none;
color: inherit;
cursor: pointer;
border-radius: 50%;
padding: 0;
transition: background 0.15s ease;
}
.zoom-control__btn:active {
background: rgba(255, 255, 255, 0.12);
}
.zoom-control__track {
position: relative;
width: 3px;
height: 80px;
background: rgba(255, 255, 255, 0.12);
border-radius: 2px;
cursor: pointer;
touch-action: none;
}
.zoom-control__fill {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
border-radius: 2px;
background: rgba(255, 255, 255, 0.35);
pointer-events: none;
}
.zoom-control__thumb {
position: absolute;
left: 50%;
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
transform: translate(-50%, 50%);
pointer-events: none;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.4);
}
/* Light mode adjustments */
.body--light .zoom-control__track {
background: rgba(0, 0, 0, 0.1);
}
.body--light .zoom-control__fill {
background: rgba(0, 0, 0, 0.25);
}
.body--light .zoom-control__thumb {
background: #333;
box-shadow: 0 0 6px rgba(0, 0, 0, 0.2);
}
.body--light .zoom-control__btn:active {
background: rgba(0, 0, 0, 0.08);
}
</style>

View file

@ -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)
}
}

View file

@ -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,
}
}

17
frontend/src/db/index.js Normal file
View file

@ -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'
})

View file

@ -2,12 +2,14 @@
<div ref="layoutRef" class="lifewave-layout" :class="{ 'lifewave-layout--dark': isDark }"> <div ref="layoutRef" class="lifewave-layout" :class="{ 'lifewave-layout--dark': isDark }">
<!-- FloatingLines Fullscreen Background (always visible) --> <!-- FloatingLines Fullscreen Background (always visible) -->
<FloatingLines <FloatingLines
ref="floatingLinesRef"
class="lifewave-layout__background" class="lifewave-layout__background"
:enabled-waves="['middle']"
:line-count="[fl.lineCount]" :line-count="[fl.lineCount]"
:animation-speed="fl.speed" :animation-speed="fl.speed"
:num-points="shaderNumPoints" :num-points="shaderNumPoints"
:point-x-values="shaderPointX" :point-x-values="shaderPointXBase"
:scroll-container="scrollContainerEl"
:scroll-uv-scale="scrollUvScale"
:point-y-values="shaderPointY" :point-y-values="shaderPointY"
:point-colors="shaderPointColors" :point-colors="shaderPointColors"
:line-spread="fl.spread" :line-spread="fl.spread"
@ -18,6 +20,7 @@
:circle-radius-px="fl.circleRadius" :circle-radius-px="fl.circleRadius"
:circle-glow-size="fl.glowSize" :circle-glow-size="fl.glowSize"
:circle-glow-strength="fl.glowStrength" :circle-glow-strength="fl.glowStrength"
:line-brightness="fl.lineBrightness ?? 1"
:lines-gradient="parsedGradient" :lines-gradient="parsedGradient"
:bg-color-center="fl.bgCenter" :bg-color-center="fl.bgCenter"
:bg-color-edge="fl.bgEdge" :bg-color-edge="fl.bgEdge"
@ -27,6 +30,7 @@
<!-- Scrollable Timeline --> <!-- Scrollable Timeline -->
<TimelineView <TimelineView
ref="timelineViewRef"
class="lifewave-layout__timeline" class="lifewave-layout__timeline"
@dot-select="onDotSelect" @dot-select="onDotSelect"
@view-update="onViewUpdate" @view-update="onViewUpdate"
@ -35,16 +39,43 @@
<!-- Header --> <!-- Header -->
<header class="lifewave-header"> <header class="lifewave-header">
<span class="lifewave-header__logo" @click="toggleDarkMode">ThatsMe</span> <span class="lifewave-header__logo" @click="toggleDarkMode">ThatsMe</span>
<button class="lifewave-header__user glass--button" @click="settingsOpen = !settingsOpen"> <button class="lifewave-header__user glass--button" @click="userMenuOpen = !userMenuOpen">
<q-icon name="tune" size="22px" /> <q-icon name="person" size="22px" :color="isDark ? 'white' : 'grey-8'" />
</button> </button>
</header> </header>
<!-- FPS Overlay -->
<div v-if="settingsStore.showFps" class="lifewave-fps">
{{ floatingLinesRef?.fpsDisplay ?? 0 }} FPS
<span class="lifewave-fps__dpr">{{ floatingLinesRef?.dprDisplay ?? '0' }}x</span>
</div>
<!-- Content --> <!-- Content -->
<main class="lifewave-content"> <main class="lifewave-content">
<router-view /> <router-view />
</main> </main>
<!-- Zoom Control (Left Side) hidden when panel is open -->
<ZoomControl
v-if="!eventsStore.panelOpen"
class="lifewave-layout__zoom"
:zoom="currentZoom"
:min="zoomMin"
:max="zoomMax"
@zoom-in="timelineViewRef?.zoomIn()"
@zoom-out="timelineViewRef?.zoomOut()"
@zoom-to="onZoomTo"
/>
<!-- Settings Button (Bottom-Left, below zoom) hidden when panel is open -->
<button
v-if="!eventsStore.panelOpen"
class="lifewave-settings-btn glass--button"
@click="settingsOpen = !settingsOpen"
>
<q-icon name="tune" size="22px" :color="isDark ? 'white' : 'grey-8'" />
</button>
<!-- Add Event Button (Bottom-Center) hidden when panel is open --> <!-- Add Event Button (Bottom-Center) hidden when panel is open -->
<AddEventButton v-if="!eventsStore.panelOpen" @click="onAddEvent" /> <AddEventButton v-if="!eventsStore.panelOpen" @click="onAddEvent" />
@ -64,7 +95,7 @@
<Transition name="fade"> <Transition name="fade">
<div <div
v-if="settingsOpen && !eventsStore.panelOpen" v-if="settingsOpen && !eventsStore.panelOpen"
class="lifewave-backdrop" class="lifewave-backdrop lifewave-backdrop--no-blur"
@click="settingsOpen = false" @click="settingsOpen = false"
/> />
</Transition> </Transition>
@ -74,6 +105,37 @@
:open="settingsOpen && !eventsStore.panelOpen" :open="settingsOpen && !eventsStore.panelOpen"
@close="settingsOpen = false" @close="settingsOpen = false"
/> />
<!-- User Menu Backdrop -->
<Transition name="fade">
<div
v-if="userMenuOpen"
class="lifewave-backdrop"
@click="userMenuOpen = false"
/>
</Transition>
<!-- User Menu (Slide from right) -->
<UserMenu
:open="userMenuOpen"
@close="userMenuOpen = false"
@navigate="onUserMenuNavigate"
/>
<!-- App Settings Modal Backdrop -->
<Transition name="fade">
<div
v-if="appSettingsOpen"
class="lifewave-backdrop"
@click="appSettingsOpen = false"
/>
</Transition>
<!-- App Settings Modal -->
<AppSettingsModal
:open="appSettingsOpen"
@close="appSettingsOpen = false"
/>
</div> </div>
</template> </template>
@ -85,6 +147,9 @@ import EventPanel from 'components/EventPanel.vue'
import FloatingLines from 'components/FloatingLines.vue' import FloatingLines from 'components/FloatingLines.vue'
import LifeWaveSettings from 'components/LifeWaveSettings.vue' import LifeWaveSettings from 'components/LifeWaveSettings.vue'
import TimelineView from 'components/TimelineView.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 { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
@ -93,8 +158,15 @@ const eventsStore = useEventsStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const isDark = computed(() => $q.dark.isActive) const isDark = computed(() => $q.dark.isActive)
const settingsOpen = ref(false) const settingsOpen = ref(false)
const userMenuOpen = ref(false)
const appSettingsOpen = ref(false)
const floatingLinesRef = ref(null)
const fl = computed(() => settingsStore.floatingLines) 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 screenUV conversion) // Layout dimensions (for screenUV conversion)
const layoutRef = ref(null) const layoutRef = ref(null)
const layoutWidth = ref(window.innerWidth) const layoutWidth = ref(window.innerWidth)
@ -139,33 +211,59 @@ function screenToUV(sx, sy) {
} }
// Compute shader point positions from event positions // 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(() => { // Select up to 8 points from visible window + boundary events for shader lines
if (!timelineState.value) return 0 const shaderSelection = computed(() => {
return Math.min(timelineState.value.events.length, 8) 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 shaderNumPoints = computed(() => shaderSelection.value.length)
const xs = Array(8).fill(0)
if (!timelineState.value) return xs // Base X positions in UV space WITHOUT scroll offset.
const { scrollLeft, events } = timelineState.value // FloatingLines applies the live scrollLeft in its render loop for perfect sync.
const count = Math.min(events.length, 8) const shaderPointXBase = computed(() => {
for (let i = 0; i < count; i++) { const xs = Array(16).fill(0)
const screenX = events[i].x - scrollLeft const sel = shaderSelection.value
xs[i] = screenToUV(screenX, 0).x 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 return xs
}) })
// Scale factor to convert scrollLeft pixels UV offset
const scrollUvScale = computed(() => 2.0 / layoutHeight.value)
const shaderPointY = computed(() => { const shaderPointY = computed(() => {
const ys = Array(8).fill(0) const ys = Array(16).fill(0)
if (!timelineState.value) return ys if (!timelineState.value) return ys
const { containerHeight: tlHeight, events } = timelineState.value const sel = shaderSelection.value
const count = Math.min(events.length, 8) const tlHeight = timelineState.value.containerHeight
for (let i = 0; i < count; i++) { for (let i = 0; i < sel.length; i++) {
// GlowDot: top = (50 - emotion*35)% of timeline container const yPercent = 48 - sel[i].emotion * 30
const yPercent = 50 - events[i].emotion * 35
const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight const screenY = TIMELINE_TOP + (yPercent / 100) * tlHeight
ys[i] = screenToUV(0, screenY).y ys[i] = screenToUV(0, screenY).y
} }
@ -173,14 +271,7 @@ const shaderPointY = computed(() => {
}) })
const shaderPointColors = computed(() => { const shaderPointColors = computed(() => {
if (!timelineState.value) return [] return shaderSelection.value.map(e => e.color || '#ffffff')
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
}) })
// Parse gradient stops from textarea string // Parse gradient stops from textarea string
@ -191,6 +282,29 @@ const parsedGradient = computed(() => {
.filter(s => s.length > 0 && s.startsWith('#')) .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 = () => { const toggleDarkMode = () => {
$q.dark.toggle() $q.dark.toggle()
} }
@ -203,6 +317,13 @@ const onDotSelect = (id) => {
eventsStore.selectEvent(id) eventsStore.selectEvent(id)
eventsStore.openPanel(id) eventsStore.openPanel(id)
} }
const onUserMenuNavigate = (target) => {
userMenuOpen.value = false
if (target === 'settings') {
appSettingsOpen.value = true
}
}
</script> </script>
<style scoped> <style scoped>
@ -214,6 +335,7 @@ const onDotSelect = (id) => {
background: #000; background: #000;
color: #F5F5F5; color: #F5F5F5;
transition: background 0.3s ease, color 0.3s ease; transition: background 0.3s ease, color 0.3s ease;
touch-action: pan-x pan-y;
} }
.lifewave-layout--dark { .lifewave-layout--dark {
@ -265,6 +387,34 @@ const onDotSelect = (id) => {
padding: 0; padding: 0;
} }
/* Zoom Control */
.lifewave-layout__zoom {
position: fixed;
left: 12px;
top: 50%;
transform: translateY(-50%);
z-index: 10;
}
/* Settings Button — bottom-left, below zoom */
.lifewave-settings-btn {
position: fixed;
bottom: 16px;
left: 12px;
z-index: 10;
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
color: #F5F5F5;
}
.lifewave-settings-btn--dark {
color: #000;
}
/* Content */ /* Content */
.lifewave-content { .lifewave-content {
padding-top: 72px; padding-top: 72px;
@ -277,8 +427,13 @@ const onDotSelect = (id) => {
inset: 0; inset: 0;
z-index: 15; z-index: 15;
background: rgba(0, 0, 0, 0.15); background: rgba(0, 0, 0, 0.15);
backdrop-filter: blur(6px); backdrop-filter: blur(1px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(1px);
}
.lifewave-backdrop--no-blur {
backdrop-filter: none;
-webkit-backdrop-filter: none;
} }
.fade-enter-active, .fade-enter-active,
@ -290,4 +445,27 @@ const onDotSelect = (id) => {
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
} }
/* FPS Overlay */
.lifewave-fps {
position: fixed;
top: 52px;
right: 16px;
z-index: 30;
padding: 4px 10px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.6);
color: #0f0;
font-family: 'SF Mono', 'Menlo', monospace;
font-size: 11px;
font-weight: 600;
line-height: 1;
pointer-events: none;
user-select: none;
}
.lifewave-fps__dpr {
color: #999;
margin-left: 6px;
}
</style> </style>

View file

@ -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,
}

View file

@ -1,5 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { db } from 'src/db'
import { startAutoSync, getToken } from 'src/services/syncService'
// Color interpolation // Color interpolation
function lerpColor(a, b, t) { function lerpColor(a, b, t) {
@ -33,7 +35,6 @@ const GRADIENT_PRESETS = [
function emotionToColor(emotion, gradientIdx = null) { function emotionToColor(emotion, gradientIdx = null) {
const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null
if (preset) { if (preset) {
// 3-stop gradient: negative → neutral → positive
const [neg, mid, pos] = preset.colors const [neg, mid, pos] = preset.colors
if (emotion >= 0) { if (emotion >= 0) {
return lerpColor(mid, pos, emotion) return lerpColor(mid, pos, emotion)
@ -41,7 +42,6 @@ function emotionToColor(emotion, gradientIdx = null) {
return lerpColor(mid, neg, Math.abs(emotion)) return lerpColor(mid, neg, Math.abs(emotion))
} }
} }
// Default: 6-stop interpolation
if (emotion >= 0) { if (emotion >= 0) {
if (emotion < 0.5) { if (emotion < 0.5) {
return lerpColor('#FF6B35', '#FFD700', 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 = [ const demoEvents = [
{ { 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(), { 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() },
title: 'Erster Schultag', { 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() },
date: '1995-09-01', { 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() },
emotion: 0.6, { 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() },
customColor: null, { 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() },
gradientPreset: null, { 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() },
image: null, { 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() }
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()
}
] ]
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', () => { export const useEventsStore = defineStore('events', () => {
const events = ref([...demoEvents]) const events = ref([])
const isLoaded = ref(false)
const selectedEventId = ref(null) const selectedEventId = ref(null)
const panelOpen = ref(false) const panelOpen = ref(false)
const editingEventId = ref(null) 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 // Ghost event for live preview while creating/editing
const ghostEmotion = ref(0) const ghostEmotion = ref(0)
const ghostCustomColor = ref(null) const ghostCustomColor = ref(null)
@ -194,7 +242,6 @@ export const useEventsStore = defineStore('events', () => {
function openPanel(eventId = null) { function openPanel(eventId = null) {
if (eventId) { if (eventId) {
// Edit mode
editingEventId.value = eventId editingEventId.value = eventId
const event = events.value.find((e) => e.id === eventId) const event = events.value.find((e) => e.id === eventId)
if (event) { if (event) {
@ -207,7 +254,6 @@ export const useEventsStore = defineStore('events', () => {
ghostNote.value = event.note ghostNote.value = event.note
} }
} else { } else {
// Create mode
editingEventId.value = null editingEventId.value = null
ghostTitle.value = '' ghostTitle.value = ''
ghostDate.value = new Date().toISOString().slice(0, 10) ghostDate.value = new Date().toISOString().slice(0, 10)
@ -220,12 +266,12 @@ export const useEventsStore = defineStore('events', () => {
panelOpen.value = true panelOpen.value = true
} }
// Auto-save: persist ghost → event in edit mode on every change // Auto-save: persist ghost → event in edit mode
function persistToEvent() { function persistToEvent() {
if (!editingEventId.value) return if (!editingEventId.value) return
const idx = events.value.findIndex((e) => e.id === editingEventId.value) const idx = events.value.findIndex((e) => e.id === editingEventId.value)
if (idx === -1) return if (idx === -1) return
events.value[idx] = { const updated = {
...events.value[idx], ...events.value[idx],
title: ghostTitle.value, title: ghostTitle.value,
date: ghostDate.value, date: ghostDate.value,
@ -234,20 +280,21 @@ export const useEventsStore = defineStore('events', () => {
gradientPreset: ghostGradientPreset.value, gradientPreset: ghostGradientPreset.value,
image: ghostImage.value, image: ghostImage.value,
note: ghostNote.value, note: ghostNote.value,
syncStatus: 'modified',
updatedAt: Date.now() updatedAt: Date.now()
} }
events.value[idx] = updated
dbPut(updated)
} }
// Watch all ghost fields — auto-save in edit mode
watch( watch(
[ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote], [ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote],
() => { persistToEvent() } () => { persistToEvent() }
) )
function closePanel() { function closePanel() {
// Create mode: auto-create event if there's content
if (!editingEventId.value && ghostTitle.value.trim()) { if (!editingEventId.value && ghostTitle.value.trim()) {
events.value.push({ const newEvent = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: ghostTitle.value, title: ghostTitle.value,
date: ghostDate.value, date: ghostDate.value,
@ -256,9 +303,13 @@ export const useEventsStore = defineStore('events', () => {
gradientPreset: ghostGradientPreset.value, gradientPreset: ghostGradientPreset.value,
image: ghostImage.value, image: ghostImage.value,
note: ghostNote.value, note: ghostNote.value,
syncStatus: 'local',
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now() updatedAt: Date.now()
}) }
events.value.push(newEvent)
dbPut(newEvent)
dbQueueSync(newEvent.id, 'create', { ...newEvent })
} }
panelOpen.value = false panelOpen.value = false
editingEventId.value = null editingEventId.value = null
@ -267,6 +318,8 @@ export const useEventsStore = defineStore('events', () => {
function deleteEvent(id) { function deleteEvent(id) {
events.value = events.value.filter((e) => e.id !== id) events.value = events.value.filter((e) => e.id !== id)
dbDelete(id)
dbQueueSync(id, 'delete', null)
closePanel() closePanel()
} }
@ -275,8 +328,12 @@ export const useEventsStore = defineStore('events', () => {
return emotionToColor(event.emotion, event.gradientPreset ?? null) return emotionToColor(event.emotion, event.gradientPreset ?? null)
} }
// Auto-init on store creation
init()
return { return {
events, events,
isLoaded,
selectedEventId, selectedEventId,
panelOpen, panelOpen,
editingEventId, editingEventId,

View file

@ -3,6 +3,20 @@ import { ref, watch } from 'vue'
const STORAGE_KEY = 'thatsme-settings' 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 = { const FLOATING_LINES_DEFAULTS = {
// Linien // Linien
speed: 1.0, speed: 1.0,
@ -15,11 +29,15 @@ const FLOATING_LINES_DEFAULTS = {
circleRadius: 75, circleRadius: 75,
glowSize: 18, glowSize: 18,
glowStrength: 1.5, glowStrength: 1.5,
lineBrightness: 1.0,
// Hintergrund // Hintergrund
bgCenter: '#0a0514', bgCenter: '#0a0514',
bgEdge: '#000000', bgEdge: '#000000',
gradientStops: '#e947f5\n#2f4ba2\n#0a0a12', gradientStops: '#e947f5\n#2f4ba2\n#0a0a12',
backgroundImage: '' backgroundImage: '',
// Labels
labelSize: 'small', // 'small' | 'medium' | 'large'
labelColor: '#ffffff'
} }
function loadFromStorage() { function loadFromStorage() {
@ -39,17 +57,29 @@ export const useSettingsStore = defineStore('settings', () => {
const theme = ref(stored?.theme ?? 'light') const theme = ref(stored?.theme ?? 'light')
const floatingLines = ref(stored?.floatingLines ?? { ...FLOATING_LINES_DEFAULTS }) 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() { function persist() {
localStorage.setItem( localStorage.setItem(
STORAGE_KEY, STORAGE_KEY,
JSON.stringify({ JSON.stringify({
theme: theme.value, 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() { function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light' theme.value = theme.value === 'light' ? 'dark' : 'light'
@ -66,6 +96,10 @@ export const useSettingsStore = defineStore('settings', () => {
return { return {
theme, theme,
floatingLines, floatingLines,
appearance,
accentColor,
language,
showFps,
toggleTheme, toggleTheme,
updateFloatingLines, updateFloatingLines,
resetFloatingLines resetFloatingLines