25-02-2025
This commit is contained in:
parent
98084de7d0
commit
70a7776da5
53 changed files with 6719 additions and 833 deletions
119
DEV-NOTES.md
Normal file
119
DEV-NOTES.md
Normal 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) |
|
||||||
199
backend/app/Http/Controllers/Api/EventController.php
Normal file
199
backend/app/Http/Controllers/Api/EventController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/app/Http/Requests/StoreEventRequest.php
Normal file
27
backend/app/Http/Requests/StoreEventRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/app/Http/Requests/UpdateEventRequest.php
Normal file
26
backend/app/Http/Requests/UpdateEventRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/app/Http/Resources/EventResource.php
Normal file
26
backend/app/Http/Resources/EventResource.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
38
backend/app/Models/Event.php
Normal file
38
backend/app/Models/Event.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
1009
backend/composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -40,6 +40,10 @@ return [
|
||||||
'driver' => 'session',
|
'driver' => 'session',
|
||||||
'provider' => 'users',
|
'provider' => 'users',
|
||||||
],
|
],
|
||||||
|
'api' => [
|
||||||
|
'driver' => 'passport',
|
||||||
|
'provider' => 'users',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
48
backend/config/passport.php
Normal file
48
backend/config/passport.php
Normal 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'),
|
||||||
|
|
||||||
|
];
|
||||||
28
backend/database/factories/EventFactory.php
Normal file
28
backend/database/factories/EventFactory.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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
14
backend/routes/api.php
Normal 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']);
|
||||||
|
});
|
||||||
186
backend/tests/Feature/Api/EventTest.php
Normal file
186
backend/tests/Feature/Api/EventTest.php
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -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
2
frontend/.gitignore
vendored
|
|
@ -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
226
frontend/MOBILE-APPS.md
Normal 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 |
|
|
@ -83,7 +83,7 @@ function screenToUV(sx, sy) {
|
||||||
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)`
|
||||||
|
|
||||||
|
|
@ -159,7 +163,7 @@ 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 |
|
||||||
|
|
@ -181,6 +185,7 @@ Die Farbe fließt als `pointColor[8]` Uniform in den Shader:
|
||||||
| `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 */
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -367,7 +388,7 @@ npm run build
|
||||||
### Dateien für die Weiterentwicklung
|
### Dateien für die Weiterentwicklung
|
||||||
|
|
||||||
| Was | Wo |
|
| Was | Wo |
|
||||||
|-----|-----|
|
| ------------------ | --------------------------------------------------------------------- |
|
||||||
| Shader-Code (GLSL) | `FloatingLines.vue` (Zeile ~67–366) |
|
| Shader-Code (GLSL) | `FloatingLines.vue` (Zeile ~67–366) |
|
||||||
| UV-Konvertierung | `LifeWaveLayout.vue` → `screenToUV()` |
|
| UV-Konvertierung | `LifeWaveLayout.vue` → `screenToUV()` |
|
||||||
| Event-Farben | `events.js` → `emotionToColor()`, `getGlowColor()` |
|
| Event-Farben | `events.js` → `emotionToColor()`, `getGlowColor()` |
|
||||||
|
|
|
||||||
456
frontend/dev/UMSETZUNG-VIRTUALISIERUNG-OFFLINE.md
Normal file
456
frontend/dev/UMSETZUNG-VIRTUALISIERUNG-OFFLINE.md
Normal 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.
|
||||||
429
frontend/package-lock.json
generated
429
frontend/package-lock.json
generated
|
|
@ -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": "*"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
? [
|
||||||
|
[
|
||||||
|
'vite-plugin-checker',
|
||||||
|
{
|
||||||
eslint: {
|
eslint: {
|
||||||
lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{js,mjs,cjs,vue}"',
|
lintCommand: 'eslint -c ./eslint.config.js "./src/**/*.{js,mjs,cjs,vue}"',
|
||||||
useFlatConfig: true
|
useFlatConfig: true,
|
||||||
}
|
},
|
||||||
}, { server: false }]
|
},
|
||||||
|
{ 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: {
|
||||||
|
|
@ -121,7 +128,7 @@ export default defineConfig((/* ctx */) => {
|
||||||
// (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: [],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
21
frontend/src-capacitor/capacitor.config.json
Normal file
21
frontend/src-capacitor/capacitor.config.json
Normal 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
1094
frontend/src-capacitor/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
14
frontend/src-capacitor/package.json
Normal file
14
frontend/src-capacitor/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
322
frontend/src/components/AppSettingsModal.vue
Normal file
322
frontend/src/components/AppSettingsModal.vue
Normal 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>
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 bool enableMiddle;
|
|
||||||
uniform bool enableBottom;
|
|
||||||
|
|
||||||
uniform int topLineCount;
|
|
||||||
uniform int middleLineCount;
|
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,59 +155,31 @@ 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);
|
|
||||||
if (interactive) {
|
|
||||||
mouseUv = (2.0 * iMouse - iResolution.xy) / iResolution.y;
|
|
||||||
mouseUv.y *= -1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableBottom) {
|
|
||||||
for (int i = 0; i < bottomLineCount; ++i) {
|
|
||||||
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 ruv = baseUv * rotate(angle);
|
|
||||||
col += lineCol * wave(
|
|
||||||
ruv + vec2(bottomLineDistance * fi + bottomWavePosition.x, bottomWavePosition.y),
|
|
||||||
1.5 + 0.2 * fi,
|
|
||||||
baseUv,
|
|
||||||
mouseUv,
|
|
||||||
interactive
|
|
||||||
) * 0.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enableMiddle) {
|
|
||||||
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) {
|
for (int s = 0; s < MAX_SEGS; ++s) {
|
||||||
if (s >= numPoints - 1) break;
|
if (s >= numPoints - 1) break;
|
||||||
|
|
||||||
vec2 sp = vec2(pointX[s], pointY[s]);
|
vec2 sp = vec2(pointX[s], pointY[s]);
|
||||||
vec2 ep = vec2(pointX[s + 1], pointY[s + 1]);
|
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;
|
vec2 segD = ep - sp;
|
||||||
float segL = length(segD);
|
float segL = length(segD);
|
||||||
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
|
vec2 segDir = segL > 0.001 ? segD / segL : vec2(1.0, 0.0);
|
||||||
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
vec2 sPerp = vec2(-segDir.y, segDir.x);
|
||||||
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
vec2 pc = (sp + ep) * 0.5 + sPerp * segL * bezierCurvature;
|
||||||
|
|
||||||
|
float t_seg = clamp(dot(baseUv - sp, segDir) / segL, 0.0, 1.0);
|
||||||
|
vec3 lineCol = mix(pointColor[s], pointColor[s + 1], t_seg);
|
||||||
|
|
||||||
|
// bezierClosestT computed ONCE per segment — shared by fog + all lines
|
||||||
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
float bt = bezierClosestT(baseUv, sp, pc, ep);
|
||||||
float bmt = 1.0 - bt;
|
float bmt = 1.0 - bt;
|
||||||
vec2 bPos = bmt*bmt*sp + 2.0*bmt*bt*pc + bt*bt*ep;
|
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 bDist = length(baseUv - bPos);
|
||||||
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
float fogFade = smoothstep(-0.06, 0.05, bt) * smoothstep(1.06, 0.95, bt);
|
||||||
float fogEnv = sin(bt * 3.14159265359);
|
float fogEnv = sin(bt * 3.14159265359);
|
||||||
|
|
@ -321,36 +187,11 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) {
|
||||||
col += lineCol * segFog;
|
col += lineCol * segFog;
|
||||||
|
|
||||||
for (int i = 0; i < middleLineCount; ++i) {
|
for (int i = 0; i < middleLineCount; ++i) {
|
||||||
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), sp, ep);
|
col += lineCol * waveFocal(baseUv, float(i), float(middleLineCount), bt, bPos, bNorm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Circles at each point
|
col *= lineBrightness;
|
||||||
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)
|
||||||
|
if (props.parallax) {
|
||||||
const handlePointerMove = (event) => {
|
const handlePointerMove = (event) => {
|
||||||
const rect = renderer.domElement.getBoundingClientRect()
|
const rect = renderer.domElement.getBoundingClientRect()
|
||||||
const x = event.clientX - rect.left
|
const x = event.clientX - rect.left
|
||||||
const y = event.clientY - rect.top
|
const y = event.clientY - rect.top
|
||||||
const dpr = renderer.getPixelRatio()
|
|
||||||
|
|
||||||
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handlePointerLeave = () => {
|
|
||||||
targetInfluence = 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.domElement.addEventListener('pointermove', handlePointerMove)
|
renderer.domElement.addEventListener('pointermove', handlePointerMove)
|
||||||
renderer.domElement.addEventListener('pointerleave', handlePointerLeave)
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollHandler = () => {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.scrollContainer) {
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
171
frontend/src/components/ModalCard.vue
Normal file
171
frontend/src/components/ModalCard.vue
Normal 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>
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div class="timeline-container">
|
||||||
<div
|
<div
|
||||||
class="timeline"
|
class="timeline"
|
||||||
ref="timelineRef"
|
ref="timelineRef"
|
||||||
|
|
@ -9,43 +10,61 @@
|
||||||
@touchend.passive="onTouchEnd"
|
@touchend.passive="onTouchEnd"
|
||||||
>
|
>
|
||||||
<div class="timeline__track" :style="{ width: trackWidth + 'px' }">
|
<div class="timeline__track" :style="{ width: trackWidth + 'px' }">
|
||||||
<!-- GlowDots (real events + ghost) -->
|
<!-- GlowDots — only visible ones rendered -->
|
||||||
<GlowDot
|
<GlowDot
|
||||||
v-for="(event, index) in displayEvents"
|
v-for="{ event, globalIndex } in visibleEvents"
|
||||||
:key="event.id"
|
:key="event.id"
|
||||||
:event="event"
|
:event="event"
|
||||||
:x="getEventX(index)"
|
:x="getEventX(globalIndex)"
|
||||||
:is-ghost="event.id === '__ghost__'"
|
:is-ghost="event.id === '__ghost__'"
|
||||||
:selected="eventsStore.selectedEventId === event.id"
|
:selected="eventsStore.selectedEventId === event.id"
|
||||||
@select="$emit('dotSelect', 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
|
||||||
</div>
|
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>
|
||||||
|
</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 allYears = computed(() => yearRanges.value.map(r => r.year))
|
||||||
|
|
||||||
|
// Sticky year labels — positioned relative to viewport, clamped to edges
|
||||||
|
const YEAR_MARGIN = 24
|
||||||
|
const stickyYearLabels = computed(() => {
|
||||||
|
const sl = scrollLeft.value
|
||||||
|
const vw = viewportWidth.value
|
||||||
|
const viewLeft = sl
|
||||||
|
const viewRight = sl + vw
|
||||||
|
const years = allYears.value
|
||||||
|
|
||||||
|
// Find years whose event range overlaps the viewport
|
||||||
|
const visible = yearRanges.value.filter(r => r.endX >= viewLeft && r.startX <= viewRight)
|
||||||
|
if (visible.length === 0) return []
|
||||||
|
|
||||||
|
function makeLabel(year, left) {
|
||||||
|
const idx = years.indexOf(year)
|
||||||
|
return { year, left, hasPrev: idx > 0, hasNext: idx < years.length - 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 activeLabel = computed(() => {
|
||||||
const sorted = displayEvents.value
|
const total = displayEvents.value.length
|
||||||
if (sorted.length === 0) return null
|
if (total === 0) return null
|
||||||
const centerX = scrollLeft.value + viewportWidth.value / 2
|
const centerX = scrollLeft.value + viewportWidth.value / 2
|
||||||
let closestIndex = 0
|
const spacing = EVENT_SPACING.value
|
||||||
let closestDist = Infinity
|
if (spacing <= 0) return null
|
||||||
for (let i = 0; i < sorted.length; i++) {
|
const index = Math.round((centerX - PADDING.value) / spacing)
|
||||||
const dist = Math.abs(getEventX(i) - centerX)
|
const clamped = Math.max(0, Math.min(total - 1, index))
|
||||||
if (dist < closestDist) {
|
return eventLabels.value[clamped]?.key ?? null
|
||||||
closestDist = dist
|
|
||||||
closestIndex = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eventLabels.value[closestIndex]?.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>
|
||||||
|
|
|
||||||
278
frontend/src/components/UserMenu.vue
Normal file
278
frontend/src/components/UserMenu.vue
Normal 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>
|
||||||
134
frontend/src/components/ZoomControl.vue
Normal file
134
frontend/src/components/ZoomControl.vue
Normal 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>
|
||||||
175
frontend/src/composables/useImageCache.js
Normal file
175
frontend/src/composables/useImageCache.js
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
frontend/src/composables/usePanelDrag.js
Normal file
137
frontend/src/composables/usePanelDrag.js
Normal 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
17
frontend/src/db/index.js
Normal 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'
|
||||||
|
})
|
||||||
|
|
@ -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 screen→UV conversion)
|
// Layout dimensions (for screen→UV 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>
|
||||||
|
|
|
||||||
253
frontend/src/services/syncService.js
Normal file
253
frontend/src/services/syncService.js
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue