diff --git a/README.md b/README.md index ad4cd7b..8fb3d38 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ Eine kurze Beschreibung, was dieses Projekt macht (ein oder zwei Sätze). +## Version + +Aktuelle Projektversion: `0.0.1` + +Die Frontend-App-Version wird in `frontend/src/config/appVersion.js` gepflegt. Bei weiteren Entwicklungsschritten muessen `frontend/package.json`, Dokumentation und relevante Entwicklungsplaene synchron gehalten werden. + ## Domains auf dem Testserver app.thats-me.test = frontend Quasar APP diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..ca411ee --- /dev/null +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,54 @@ +validated(); + $user = User::query() + ->where('email', $credentials['email']) + ->first(); + + if (! $user || ! Hash::check($credentials['password'], $user->password)) { + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + $token = $user->createToken('quasar-app')->accessToken; + + return response()->json([ + 'token' => $token, + 'tokenType' => 'Bearer', + 'user' => [ + 'id' => (string) $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'avatar' => $this->avatarFor($user), + 'mode' => 'remote', + ], + ]); + } + + public function logout(Request $request): JsonResponse + { + $request->user()?->token()?->revoke(); + + return response()->json(null, 204); + } + + private function avatarFor(User $user): string + { + return substr(strtoupper($user->initials()), 0, 3); + } +} diff --git a/backend/app/Http/Controllers/Api/EventController.php b/backend/app/Http/Controllers/Api/EventController.php index 9ea816f..5968460 100644 --- a/backend/app/Http/Controllers/Api/EventController.php +++ b/backend/app/Http/Controllers/Api/EventController.php @@ -18,7 +18,7 @@ class EventController extends Controller */ public function index(Request $request): AnonymousResourceCollection { - $query = $request->user()->events()->orderBy('date'); + $query = $request->user()->events()->with('media')->orderBy('date'); // Delta sync: only events updated since a given timestamp if ($request->has('since')) { @@ -50,7 +50,7 @@ class EventController extends Controller 'note' => $request->validated('note'), ]); - return (new EventResource($event)) + return (new EventResource($event->load('media'))) ->response() ->setStatusCode(201); } @@ -64,7 +64,7 @@ class EventController extends Controller ->where('client_id', $clientId) ->firstOrFail(); - return new EventResource($event); + return new EventResource($event->load('media')); } /** @@ -103,7 +103,7 @@ class EventController extends Controller $event->update($data); - return new EventResource($event->fresh()); + return new EventResource($event->fresh()->load('media')); } /** diff --git a/backend/app/Http/Controllers/Api/EventMediaController.php b/backend/app/Http/Controllers/Api/EventMediaController.php new file mode 100644 index 0000000..8b5831c --- /dev/null +++ b/backend/app/Http/Controllers/Api/EventMediaController.php @@ -0,0 +1,138 @@ +user()->events() + ->where('client_id', $clientId) + ->firstOrFail(); + + return response()->json([ + 'data' => EventMediaResource::collection( + $event->media()->orderBy('created_at')->get() + ), + ]); + } + + public function store( + StoreEventMediaRequest $request, + EventMediaImageProcessor $processor, + string $clientId + ): JsonResponse { + $event = $request->user()->events() + ->where('client_id', $clientId) + ->firstOrFail(); + + $collection = $request->validated('collection') ?? 'gallery'; + $processed = $processor->process($request->file('file')); + $uuid = (string) str()->uuid(); + $directory = "event-media/{$request->user()->id}/{$event->client_id}"; + $path = "{$directory}/{$uuid}.jpg"; + $previewPath = "{$directory}/{$uuid}_preview.jpg"; + $thumbnailPath = "{$directory}/{$uuid}_thumb.jpg"; + + Storage::disk('local')->put($path, $processed['original']); + Storage::disk('local')->put($previewPath, $processed['preview']); + Storage::disk('local')->put($thumbnailPath, $processed['thumbnail']); + + if ($collection === 'key_image') { + $this->deleteExistingKeyImages($event->media()->where('collection', 'key_image')->get()); + } + + $media = $event->media()->create([ + 'uuid' => $uuid, + 'user_id' => $request->user()->id, + 'collection' => $collection, + 'name' => $request->file('file')->getClientOriginalName(), + 'mime_type' => $processed['mime_type'], + 'disk' => 'local', + 'path' => $path, + 'thumbnail_path' => $thumbnailPath, + 'preview_path' => $previewPath, + 'size' => $processed['size'], + 'width' => $processed['width'], + 'height' => $processed['height'], + 'thumbnail_width' => $processed['thumbnail_width'], + 'thumbnail_height' => $processed['thumbnail_height'], + 'preview_width' => $processed['preview_width'], + 'preview_height' => $processed['preview_height'], + ]); + + if ($collection === 'key_image') { + $event->update(['image' => "/event-media/{$media->id}/thumb"]); + } + + return (new EventMediaResource($media)) + ->response() + ->setStatusCode(201); + } + + public function show(Request $request, EventMedia $media, string $variant): Response + { + abort_unless(in_array($variant, ['thumb', 'preview', 'original'], true), 404); + abort_unless((int) $media->user_id === (int) $request->user()->id, 404); + + $path = match ($variant) { + 'thumb' => $media->thumbnail_path, + 'preview' => $media->preview_path ?: $media->thumbnail_path, + default => $media->path, + }; + + abort_unless(is_string($path) && $path !== '', 404); + abort_unless(Storage::disk($media->disk)->exists($path), 404); + + return response(Storage::disk($media->disk)->get($path), 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=604800', + ]); + } + + public function destroy(Request $request, string $clientId, EventMedia $media): JsonResponse + { + $event = $request->user()->events() + ->where('client_id', $clientId) + ->firstOrFail(); + + abort_unless((int) $media->event_id === (int) $event->id, 404); + + $wasKeyImage = $media->collection === 'key_image'; + $this->deleteMediaFiles($media); + $media->delete(); + + if ($wasKeyImage) { + $event->update(['image' => null]); + } + + return response()->json(null, 204); + } + + private function deleteExistingKeyImages(iterable $mediaItems): void + { + foreach ($mediaItems as $media) { + $this->deleteMediaFiles($media); + $media->delete(); + } + } + + private function deleteMediaFiles(EventMedia $media): void + { + Storage::disk($media->disk)->delete(array_filter([ + $media->path, + $media->preview_path, + $media->thumbnail_path, + ])); + } +} diff --git a/backend/app/Http/Controllers/Api/SettingsController.php b/backend/app/Http/Controllers/Api/SettingsController.php new file mode 100644 index 0000000..cd7e447 --- /dev/null +++ b/backend/app/Http/Controllers/Api/SettingsController.php @@ -0,0 +1,32 @@ +json([ + 'data' => $request->user()->settings?->settings ?? null, + ]); + } + + public function update(UpdateSettingsRequest $request): JsonResponse + { + $settings = $request->validated('settings'); + + $userSettings = $request->user()->settings()->updateOrCreate( + [], + ['settings' => $settings], + ); + + return response()->json([ + 'data' => $userSettings->settings, + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/SettingsMediaController.php b/backend/app/Http/Controllers/Api/SettingsMediaController.php new file mode 100644 index 0000000..108aba0 --- /dev/null +++ b/backend/app/Http/Controllers/Api/SettingsMediaController.php @@ -0,0 +1,97 @@ +processBackground($request->file('file')); + $path = $this->backgroundPath((int) $request->user()->id); + + Storage::disk('local')->put($path, $processed['contents']); + + return response()->json([ + 'data' => [ + 'url' => '/settings/media/background?v='.time(), + 'width' => $processed['width'], + 'height' => $processed['height'], + 'mimeType' => 'image/jpeg', + 'size' => strlen($processed['contents']), + ], + ], 201); + } + + public function show(Request $request): Response + { + $path = $this->backgroundPath((int) $request->user()->id); + + abort_unless(Storage::disk('local')->exists($path), 404); + + return response(Storage::disk('local')->get($path), 200, [ + 'Content-Type' => 'image/jpeg', + 'Cache-Control' => 'private, max-age=604800', + ]); + } + + public function destroy(Request $request): JsonResponse + { + Storage::disk('local')->delete($this->backgroundPath((int) $request->user()->id)); + + return response()->json(null, 204); + } + + private function backgroundPath(int $userId): string + { + return "settings-media/{$userId}/background.jpg"; + } + + /** + * @return array{contents: string, width: int, height: int} + */ + private function processBackground(UploadedFile $file): array + { + $source = imagecreatefromstring((string) file_get_contents($file->getRealPath())); + + if (! $source) { + throw new RuntimeException('The uploaded background image could not be processed.'); + } + + $sourceWidth = imagesx($source); + $sourceHeight = imagesy($source); + $scale = min(1, self::BACKGROUND_MAX_SIDE / max($sourceWidth, $sourceHeight)); + $width = max(1, (int) round($sourceWidth * $scale)); + $height = max(1, (int) round($sourceHeight * $scale)); + + $canvas = imagecreatetruecolor($width, $height); + $background = imagecolorallocate($canvas, 255, 255, 255); + imagefilledrectangle($canvas, 0, 0, $width, $height, $background); + imagecopyresampled($canvas, $source, 0, 0, 0, 0, $width, $height, $sourceWidth, $sourceHeight); + + imagedestroy($source); + + ob_start(); + imagejpeg($canvas, null, self::BACKGROUND_QUALITY); + $contents = (string) ob_get_clean(); + imagedestroy($canvas); + + return [ + 'contents' => $contents, + 'width' => $width, + 'height' => $height, + ]; + } +} diff --git a/backend/app/Http/Requests/LoginRequest.php b/backend/app/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..983a4ea --- /dev/null +++ b/backend/app/Http/Requests/LoginRequest.php @@ -0,0 +1,26 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]; + } +} diff --git a/backend/app/Http/Requests/StoreEventMediaRequest.php b/backend/app/Http/Requests/StoreEventMediaRequest.php new file mode 100644 index 0000000..8159843 --- /dev/null +++ b/backend/app/Http/Requests/StoreEventMediaRequest.php @@ -0,0 +1,26 @@ +|string> + */ + public function rules(): array + { + return [ + 'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'], + 'collection' => ['nullable', 'in:key_image,gallery'], + ]; + } +} diff --git a/backend/app/Http/Requests/StoreSettingsMediaRequest.php b/backend/app/Http/Requests/StoreSettingsMediaRequest.php new file mode 100644 index 0000000..31127e1 --- /dev/null +++ b/backend/app/Http/Requests/StoreSettingsMediaRequest.php @@ -0,0 +1,23 @@ +|string> + */ + public function rules(): array + { + return [ + 'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'], + ]; + } +} diff --git a/backend/app/Http/Requests/UpdateSettingsRequest.php b/backend/app/Http/Requests/UpdateSettingsRequest.php new file mode 100644 index 0000000..06c67b5 --- /dev/null +++ b/backend/app/Http/Requests/UpdateSettingsRequest.php @@ -0,0 +1,37 @@ +|string> + */ + public function rules(): array + { + return [ + 'settings' => ['required', 'array'], + 'settings.theme' => ['nullable', 'string', 'max:50'], + 'settings.floatingLines' => ['nullable', 'array'], + 'settings.appearance' => ['nullable', 'string', 'max:50'], + 'settings.accentColor' => ['nullable', 'string', 'max:50'], + 'settings.language' => ['nullable', 'string', 'max:10'], + 'settings.emotionGradientStart' => ['nullable', 'string', 'max:20'], + 'settings.emotionGradientEnd' => ['nullable', 'string', 'max:20'], + 'settings.timelineZoom' => ['nullable', 'numeric', 'min:0.1', 'max:10'], + 'settings.timelineScrollLeft' => ['nullable', 'numeric', 'min:0'], + 'settings.showFps' => ['nullable', 'boolean'], + 'settings.presets' => ['nullable', 'array'], + 'settings.activePresetId' => ['nullable', 'string', 'max:100'], + ]; + } +} diff --git a/backend/app/Http/Resources/EventMediaResource.php b/backend/app/Http/Resources/EventMediaResource.php new file mode 100644 index 0000000..072e2dc --- /dev/null +++ b/backend/app/Http/Resources/EventMediaResource.php @@ -0,0 +1,33 @@ + (string) $this->id, + 'uuid' => $this->uuid, + 'type' => 'image', + 'collection' => $this->collection, + 'name' => $this->name, + 'mimeType' => $this->mime_type, + 'size' => $this->size, + 'width' => $this->width, + 'height' => $this->height, + 'thumbnailWidth' => $this->thumbnail_width, + 'thumbnailHeight' => $this->thumbnail_height, + 'previewWidth' => $this->preview_width, + 'previewHeight' => $this->preview_height, + 'src' => "/event-media/{$this->id}/thumb", + 'thumbnailUrl' => "/event-media/{$this->id}/thumb", + 'previewUrl' => "/event-media/{$this->id}/preview", + 'originalUrl' => "/event-media/{$this->id}/original", + 'createdAt' => $this->created_at->getTimestampMs(), + ]; + } +} diff --git a/backend/app/Http/Resources/EventResource.php b/backend/app/Http/Resources/EventResource.php index a547f62..d0a0a10 100644 --- a/backend/app/Http/Resources/EventResource.php +++ b/backend/app/Http/Resources/EventResource.php @@ -18,6 +18,7 @@ class EventResource extends JsonResource 'gradientPreset' => $this->gradient_preset, 'image' => $this->image, 'note' => $this->note ?? '', + 'media' => EventMediaResource::collection($this->whenLoaded('media')), 'syncStatus' => 'synced', 'createdAt' => $this->created_at->getTimestampMs(), 'updatedAt' => $this->updated_at->getTimestampMs(), diff --git a/backend/app/Models/Event.php b/backend/app/Models/Event.php index a4463f7..438e011 100644 --- a/backend/app/Models/Event.php +++ b/backend/app/Models/Event.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Event extends Model { @@ -35,4 +36,9 @@ class Event extends Model { return $this->belongsTo(User::class); } + + public function media(): HasMany + { + return $this->hasMany(EventMedia::class); + } } diff --git a/backend/app/Models/EventMedia.php b/backend/app/Models/EventMedia.php new file mode 100644 index 0000000..a233efe --- /dev/null +++ b/backend/app/Models/EventMedia.php @@ -0,0 +1,66 @@ + */ + use HasFactory; + + protected $fillable = [ + 'uuid', + 'user_id', + 'event_id', + 'collection', + 'name', + 'mime_type', + 'disk', + 'path', + 'thumbnail_path', + 'preview_path', + 'size', + 'width', + 'height', + 'thumbnail_width', + 'thumbnail_height', + 'preview_width', + 'preview_height', + ]; + + protected function casts(): array + { + return [ + 'size' => 'integer', + 'width' => 'integer', + 'height' => 'integer', + 'thumbnail_width' => 'integer', + 'thumbnail_height' => 'integer', + 'preview_width' => 'integer', + 'preview_height' => 'integer', + ]; + } + + protected static function booted(): void + { + static::creating(function (EventMedia $media): void { + if (! $media->uuid) { + $media->uuid = (string) Str::uuid(); + } + }); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index abec552..47c38ea 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; @@ -57,6 +58,11 @@ class User extends Authenticatable return $this->hasMany(Event::class); } + public function settings(): HasOne + { + return $this->hasOne(UserSetting::class); + } + public function initials(): string { return Str::of($this->name) diff --git a/backend/app/Models/UserSetting.php b/backend/app/Models/UserSetting.php new file mode 100644 index 0000000..6e05127 --- /dev/null +++ b/backend/app/Models/UserSetting.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + protected $fillable = [ + 'settings', + ]; + + protected function casts(): array + { + return [ + 'settings' => 'array', + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/backend/app/Services/EventMediaImageProcessor.php b/backend/app/Services/EventMediaImageProcessor.php new file mode 100644 index 0000000..d47bcfa --- /dev/null +++ b/backend/app/Services/EventMediaImageProcessor.php @@ -0,0 +1,125 @@ +getRealPath())); + + if (! $source) { + throw new RuntimeException('The uploaded image could not be processed.'); + } + + $sourceWidth = imagesx($source); + $sourceHeight = imagesy($source); + + $original = $this->resizeContain($source, $sourceWidth, $sourceHeight, self::MAX_ORIGINAL_SIDE); + $preview = $this->resizeContain($source, $sourceWidth, $sourceHeight, self::PREVIEW_MAX_SIDE, 84); + $thumbnail = $this->resizeCover($source, $sourceWidth, $sourceHeight, self::THUMBNAIL_SIZE); + + imagedestroy($source); + + return [ + 'original' => $original['contents'], + 'preview' => $preview['contents'], + 'thumbnail' => $thumbnail['contents'], + 'width' => $original['width'], + 'height' => $original['height'], + 'preview_width' => $preview['width'], + 'preview_height' => $preview['height'], + 'thumbnail_width' => self::THUMBNAIL_SIZE, + 'thumbnail_height' => self::THUMBNAIL_SIZE, + 'mime_type' => 'image/jpeg', + 'size' => strlen($original['contents']), + ]; + } + + /** + * @return array{contents: string, width: int, height: int} + */ + private function resizeContain( + \GdImage $source, + int $sourceWidth, + int $sourceHeight, + int $maxSide, + int $quality = 90 + ): array { + $scale = min(1, $maxSide / max($sourceWidth, $sourceHeight)); + $width = max(1, (int) round($sourceWidth * $scale)); + $height = max(1, (int) round($sourceHeight * $scale)); + + $canvas = imagecreatetruecolor($width, $height); + $this->fillCanvas($canvas, $width, $height); + imagecopyresampled($canvas, $source, 0, 0, 0, 0, $width, $height, $sourceWidth, $sourceHeight); + + return [ + 'contents' => $this->encodeJpeg($canvas, $quality), + 'width' => $width, + 'height' => $height, + ]; + } + + /** + * @return array{contents: string, width: int, height: int} + */ + private function resizeCover(\GdImage $source, int $sourceWidth, int $sourceHeight, int $size): array + { + $scale = max($size / $sourceWidth, $size / $sourceHeight); + $width = (int) round($sourceWidth * $scale); + $height = (int) round($sourceHeight * $scale); + $x = (int) round(($size - $width) / 2); + $y = (int) round(($size - $height) / 2); + + $canvas = imagecreatetruecolor($size, $size); + $this->fillCanvas($canvas, $size, $size); + imagecopyresampled($canvas, $source, $x, $y, 0, 0, $width, $height, $sourceWidth, $sourceHeight); + + return [ + 'contents' => $this->encodeJpeg($canvas, 80), + 'width' => $size, + 'height' => $size, + ]; + } + + private function fillCanvas(\GdImage $canvas, int $width, int $height): void + { + $background = imagecolorallocate($canvas, 255, 255, 255); + imagefilledrectangle($canvas, 0, 0, $width, $height, $background); + } + + private function encodeJpeg(\GdImage $image, int $quality): string + { + ob_start(); + imagejpeg($image, null, $quality); + $contents = (string) ob_get_clean(); + imagedestroy($image); + + return $contents; + } +} diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..cb0ae2d --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,27 @@ + ['api/*'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => [ + 'https://thats-me.app', + 'https://www.thats-me.app', + 'https://app.thats-me.app', + 'https://app.thats-me.test', + 'http://app.thats-me.test:9000', + 'http://localhost:9000', + 'http://127.0.0.1:9000', + ], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => false, +]; diff --git a/backend/database/factories/EventMediaFactory.php b/backend/database/factories/EventMediaFactory.php new file mode 100644 index 0000000..7cd33cc --- /dev/null +++ b/backend/database/factories/EventMediaFactory.php @@ -0,0 +1,44 @@ + + */ +class EventMediaFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $user = User::factory(); + + return [ + 'uuid' => (string) Str::uuid(), + 'user_id' => $user, + 'event_id' => Event::factory()->for($user), + 'collection' => 'gallery', + 'name' => fake()->word().'.jpg', + 'mime_type' => 'image/jpeg', + 'disk' => 'local', + 'path' => 'event-media/test/original.jpg', + 'thumbnail_path' => 'event-media/test/thumb.jpg', + 'preview_path' => 'event-media/test/preview.jpg', + 'size' => 12345, + 'width' => 1200, + 'height' => 800, + 'thumbnail_width' => 320, + 'thumbnail_height' => 320, + 'preview_width' => 900, + 'preview_height' => 600, + ]; + } +} diff --git a/backend/database/factories/UserSettingFactory.php b/backend/database/factories/UserSettingFactory.php new file mode 100644 index 0000000..2fc3c31 --- /dev/null +++ b/backend/database/factories/UserSettingFactory.php @@ -0,0 +1,33 @@ + + */ +class UserSettingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'settings' => [ + 'appearance' => 'system', + 'accentColor' => 'base', + 'language' => 'de', + 'timelineZoom' => 1, + 'timelineScrollLeft' => null, + 'presets' => [], + 'activePresetId' => null, + ], + ]; + } +} diff --git a/backend/database/migrations/2026_06_03_130123_create_user_settings_table.php b/backend/database/migrations/2026_06_03_130123_create_user_settings_table.php new file mode 100644 index 0000000..490d2b2 --- /dev/null +++ b/backend/database/migrations/2026_06_03_130123_create_user_settings_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->json('settings'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_settings'); + } +}; diff --git a/backend/database/migrations/2026_06_03_131801_create_event_media_table.php b/backend/database/migrations/2026_06_03_131801_create_event_media_table.php new file mode 100644 index 0000000..512867e --- /dev/null +++ b/backend/database/migrations/2026_06_03_131801_create_event_media_table.php @@ -0,0 +1,47 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->string('collection')->default('gallery'); + $table->string('name'); + $table->string('mime_type'); + $table->string('disk')->default('local'); + $table->string('path'); + $table->string('thumbnail_path'); + $table->string('preview_path')->nullable(); + $table->unsignedBigInteger('size'); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->unsignedInteger('thumbnail_width')->nullable(); + $table->unsignedInteger('thumbnail_height')->nullable(); + $table->unsignedInteger('preview_width')->nullable(); + $table->unsignedInteger('preview_height')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'event_id']); + $table->index(['event_id', 'collection']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('event_media'); + } +}; diff --git a/backend/database/migrations/2026_06_03_133403_add_preview_paths_to_event_media_table.php b/backend/database/migrations/2026_06_03_133403_add_preview_paths_to_event_media_table.php new file mode 100644 index 0000000..662f6b5 --- /dev/null +++ b/backend/database/migrations/2026_06_03_133403_add_preview_paths_to_event_media_table.php @@ -0,0 +1,44 @@ +string('preview_path')->nullable()->after('thumbnail_path'); + } + if (! Schema::hasColumn('event_media', 'preview_width')) { + $table->unsignedInteger('preview_width')->nullable()->after('thumbnail_height'); + } + if (! Schema::hasColumn('event_media', 'preview_height')) { + $table->unsignedInteger('preview_height')->nullable()->after('preview_width'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('event_media', function (Blueprint $table) { + $columns = array_filter([ + Schema::hasColumn('event_media', 'preview_path') ? 'preview_path' : null, + Schema::hasColumn('event_media', 'preview_width') ? 'preview_width' : null, + Schema::hasColumn('event_media', 'preview_height') ? 'preview_height' : null, + ]); + + if ($columns !== []) { + $table->dropColumn($columns); + } + }); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index d01a0ef..f38c090 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -3,8 +3,9 @@ namespace Database\Seeders; use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Laravel\Passport\Client; +use Laravel\Passport\ClientRepository; class DatabaseSeeder extends Seeder { @@ -13,11 +14,37 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); + User::query()->updateOrCreate( + ['email' => 'test@example.com'], + [ + 'name' => 'Test User', + 'email_verified_at' => now(), + 'password' => 'password', + ], + ); - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + foreach (range(1, 6) as $number) { + User::query()->updateOrCreate( + ['email' => "user{$number}@thats-me.app"], + [ + 'name' => "User {$number}", + 'email_verified_at' => now(), + 'password' => 'pass', + ], + ); + } + + $hasPersonalAccessClient = Client::query() + ->where('provider', 'users') + ->where('revoked', false) + ->get() + ->contains(fn (Client $client): bool => $client->hasGrantType('personal_access')); + + if (! $hasPersonalAccessClient) { + app(ClientRepository::class)->createPersonalAccessGrantClient( + 'Thats Me Quasar Personal Access Client', + 'users', + ); + } } } diff --git a/backend/routes/api.php b/backend/routes/api.php index 110e93f..1f89025 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,11 +1,27 @@ group(function () { Route::get('/user', fn (Request $request) => $request->user()); + Route::post('/logout', [AuthController::class, 'logout']); + Route::get('/settings', [SettingsController::class, 'show']); + Route::put('/settings', [SettingsController::class, 'update']); + Route::get('/settings/media/background', [SettingsMediaController::class, 'show']); + Route::post('/settings/media/background', [SettingsMediaController::class, 'store']); + Route::delete('/settings/media/background', [SettingsMediaController::class, 'destroy']); + Route::get('/event-media/{media}/{variant}', [EventMediaController::class, 'show']); + Route::get('/events/{clientId}/media', [EventMediaController::class, 'index']); + Route::post('/events/{clientId}/media', [EventMediaController::class, 'store']); + Route::delete('/events/{clientId}/media/{media}', [EventMediaController::class, 'destroy']); Route::apiResource('events', EventController::class)->parameters([ 'events' => 'clientId', diff --git a/backend/tests/Feature/Api/AuthTest.php b/backend/tests/Feature/Api/AuthTest.php new file mode 100644 index 0000000..e75ba9e --- /dev/null +++ b/backend/tests/Feature/Api/AuthTest.php @@ -0,0 +1,92 @@ +getJson('/api/user') + ->assertUnauthorized(); +}); + +test('events endpoint requires a token', function () { + $this->getJson('/api/events') + ->assertUnauthorized(); +}); + +test('api user endpoint returns the authenticated user', function () { + $user = User::factory()->create([ + 'name' => 'API User', + 'email' => 'api-user@example.com', + ]); + + Passport::actingAs($user); + + $this->getJson('/api/user') + ->assertOk() + ->assertJsonPath('id', $user->id) + ->assertJsonPath('name', 'API User') + ->assertJsonPath('email', 'api-user@example.com'); +}); + +test('can login with presentation user credentials', function () { + $this->seed(DatabaseSeeder::class); + + $this->postJson('/api/login', [ + 'email' => 'user1@thats-me.app', + 'password' => 'pass', + ]) + ->assertOk() + ->assertJsonPath('tokenType', 'Bearer') + ->assertJsonPath('user.email', 'user1@thats-me.app') + ->assertJsonPath('user.name', 'User 1') + ->assertJsonPath('user.mode', 'remote') + ->assertJsonStructure([ + 'token', + 'tokenType', + 'user' => ['id', 'name', 'email', 'avatar', 'mode'], + ]); +}); + +test('login rejects invalid credentials', function () { + $this->seed(DatabaseSeeder::class); + + $this->postJson('/api/login', [ + 'email' => 'user1@thats-me.app', + 'password' => 'wrong-password', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['email']); +}); + +test('authenticated user can logout', function () { + $user = User::factory()->create(); + Passport::actingAs($user); + + $this->postJson('/api/logout') + ->assertNoContent(); +}); + +test('database seeder creates the presentation api users', function () { + $this->seed(DatabaseSeeder::class); + + foreach (range(1, 6) as $number) { + $user = User::query() + ->where('email', "user{$number}@thats-me.app") + ->first(); + + expect($user)->not->toBeNull() + ->and($user->name)->toBe("User {$number}") + ->and(Hash::check('pass', $user->password))->toBeTrue(); + } + + $hasPersonalAccessClient = Client::query() + ->where('provider', 'users') + ->where('revoked', false) + ->get() + ->contains(fn (Client $client): bool => $client->hasGrantType('personal_access')); + + expect($hasPersonalAccessClient)->toBeTrue(); +}); diff --git a/backend/tests/Feature/Api/EventMediaTest.php b/backend/tests/Feature/Api/EventMediaTest.php new file mode 100644 index 0000000..336aad4 --- /dev/null +++ b/backend/tests/Feature/Api/EventMediaTest.php @@ -0,0 +1,170 @@ +getJson('/api/events/event-id/media') + ->assertUnauthorized(); +}); + +test('can upload key image and receive protected urls', function () { + Storage::fake('local'); + $user = User::factory()->create(); + $event = Event::factory()->create(['user_id' => $user->id]); + Passport::actingAs($user); + + $response = $this->postJson("/api/events/{$event->client_id}/media", [ + 'collection' => 'key_image', + 'file' => UploadedFile::fake()->image('avatar.png', 1200, 800), + ]); + + $response->assertCreated() + ->assertJsonPath('data.collection', 'key_image') + ->assertJsonPath('data.type', 'image') + ->assertJsonPath('data.thumbnailWidth', 320) + ->assertJsonPath('data.thumbnailHeight', 320) + ->assertJsonPath('data.previewWidth', 900); + + $media = EventMedia::query()->firstOrFail(); + Storage::disk('local')->assertExists($media->path); + Storage::disk('local')->assertExists($media->preview_path); + Storage::disk('local')->assertExists($media->thumbnail_path); + + expect($event->fresh()->image)->toBe("/event-media/{$media->id}/thumb"); +}); + +test('uploaded originals keep a4 quality maximum side', function () { + Storage::fake('local'); + $user = User::factory()->create(); + $event = Event::factory()->create(['user_id' => $user->id]); + Passport::actingAs($user); + + $this->postJson("/api/events/{$event->client_id}/media", [ + 'collection' => 'gallery', + 'file' => UploadedFile::fake()->image('large.jpg', 5000, 2500), + ])->assertCreated(); + + $media = EventMedia::query()->firstOrFail(); + + expect($media->width)->toBe(3508) + ->and($media->height)->toBe(1754) + ->and($media->preview_width)->toBe(900) + ->and($media->preview_height)->toBe(450) + ->and(max($media->width, $media->height))->toBe(3508); +}); + +test('can list media for own event', function () { + $user = User::factory()->create(); + $event = Event::factory()->create(['user_id' => $user->id]); + EventMedia::factory()->create([ + 'user_id' => $user->id, + 'event_id' => $event->id, + 'collection' => 'gallery', + ]); + Passport::actingAs($user); + + $this->getJson("/api/events/{$event->client_id}/media") + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.collection', 'gallery'); +}); + +test('can stream thumbnail for own media', function () { + Storage::fake('local'); + $user = User::factory()->create(); + $event = Event::factory()->create(['user_id' => $user->id]); + $media = EventMedia::factory()->create([ + 'user_id' => $user->id, + 'event_id' => $event->id, + 'thumbnail_path' => 'event-media/test/thumb.jpg', + ]); + Storage::disk('local')->put($media->thumbnail_path, 'fake-jpeg'); + Passport::actingAs($user); + + $this->get("/api/event-media/{$media->id}/thumb") + ->assertOk() + ->assertHeader('Content-Type', 'image/jpeg'); +}); + +test('can stream preview for own media', function () { + Storage::fake('local'); + $user = User::factory()->create(); + $event = Event::factory()->create(['user_id' => $user->id]); + $media = EventMedia::factory()->create([ + 'user_id' => $user->id, + 'event_id' => $event->id, + 'preview_path' => 'event-media/test/preview.jpg', + ]); + Storage::disk('local')->put($media->preview_path, 'fake-jpeg'); + Passport::actingAs($user); + + $this->get("/api/event-media/{$media->id}/preview") + ->assertOk() + ->assertHeader('Content-Type', 'image/jpeg'); +}); + +test('preview falls back to thumbnail for legacy media', function () { + Storage::fake('local'); + $user = User::factory()->create(); + $event = Event::factory()->create(['user_id' => $user->id]); + $media = EventMedia::factory()->create([ + 'user_id' => $user->id, + 'event_id' => $event->id, + 'preview_path' => null, + 'thumbnail_path' => 'event-media/test/thumb.jpg', + ]); + Storage::disk('local')->put($media->thumbnail_path, 'legacy-thumb'); + Passport::actingAs($user); + + $this->get("/api/event-media/{$media->id}/preview") + ->assertOk() + ->assertHeader('Content-Type', 'image/jpeg'); +}); + +test('cannot access another users media', function () { + Storage::fake('local'); + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $event = Event::factory()->create(['user_id' => $otherUser->id]); + $media = EventMedia::factory()->create([ + 'user_id' => $otherUser->id, + 'event_id' => $event->id, + 'thumbnail_path' => 'event-media/test/thumb.jpg', + ]); + Storage::disk('local')->put($media->thumbnail_path, 'fake-jpeg'); + Passport::actingAs($user); + + $this->getJson("/api/events/{$event->client_id}/media") + ->assertNotFound(); + + $this->get("/api/event-media/{$media->id}/thumb") + ->assertNotFound(); +}); + +test('can delete own media and files', function () { + Storage::fake('local'); + $user = User::factory()->create(); + $event = Event::factory()->create(['user_id' => $user->id]); + $media = EventMedia::factory()->create([ + 'user_id' => $user->id, + 'event_id' => $event->id, + 'path' => 'event-media/test/original.jpg', + 'preview_path' => null, + 'thumbnail_path' => 'event-media/test/thumb.jpg', + ]); + Storage::disk('local')->put($media->path, 'original'); + Storage::disk('local')->put($media->thumbnail_path, 'thumb'); + Passport::actingAs($user); + + $this->deleteJson("/api/events/{$event->client_id}/media/{$media->id}") + ->assertNoContent(); + + $this->assertDatabaseMissing('event_media', ['id' => $media->id]); + Storage::disk('local')->assertMissing($media->path); + Storage::disk('local')->assertMissing($media->thumbnail_path); +}); diff --git a/backend/tests/Feature/Api/SettingsTest.php b/backend/tests/Feature/Api/SettingsTest.php new file mode 100644 index 0000000..7ae33ce --- /dev/null +++ b/backend/tests/Feature/Api/SettingsTest.php @@ -0,0 +1,148 @@ +getJson('/api/settings') + ->assertUnauthorized(); +}); + +test('can get empty settings for authenticated user', function () { + Passport::actingAs(User::factory()->create()); + + $this->getJson('/api/settings') + ->assertOk() + ->assertJsonPath('data', null); +}); + +test('can store and update settings', function () { + $user = User::factory()->create(); + Passport::actingAs($user); + + $settings = [ + 'appearance' => 'dark', + 'accentColor' => 'green', + 'language' => 'de', + 'timelineZoom' => 1.5, + 'timelineScrollLeft' => 420, + 'floatingLines' => [ + 'speed' => 1.2, + 'lineCount' => 12, + ], + 'presets' => [[ + 'id' => 'preset-1', + 'name' => 'Praesentation', + 'settings' => ['accentColor' => 'green'], + 'updatedAt' => 1710000000000, + ]], + 'activePresetId' => 'preset-1', + 'showFps' => false, + ]; + + $this->putJson('/api/settings', ['settings' => $settings]) + ->assertOk() + ->assertJsonPath('data.appearance', 'dark') + ->assertJsonPath('data.floatingLines.speed', 1.2) + ->assertJsonPath('data.presets.0.name', 'Praesentation'); + + expect($user->settings()->count())->toBe(1); + + $this->putJson('/api/settings', [ + 'settings' => [ + ...$settings, + 'accentColor' => 'blue', + ], + ]) + ->assertOk() + ->assertJsonPath('data.accentColor', 'blue'); + + expect($user->settings()->count())->toBe(1); +}); + +test('settings are isolated per user', function () { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + + UserSetting::factory()->create([ + 'user_id' => $otherUser->id, + 'settings' => [ + 'accentColor' => 'rose', + 'language' => 'en', + ], + ]); + + Passport::actingAs($user); + + $this->getJson('/api/settings') + ->assertOk() + ->assertJsonPath('data', null); + + $this->putJson('/api/settings', [ + 'settings' => [ + 'accentColor' => 'green', + 'language' => 'de', + ], + ])->assertOk(); + + expect($user->settings()->first()->settings['accentColor'])->toBe('green') + ->and($otherUser->settings()->first()->settings['accentColor'])->toBe('rose'); +}); + +test('settings payload is required', function () { + Passport::actingAs(User::factory()->create()); + + $this->putJson('/api/settings', []) + ->assertUnprocessable() + ->assertJsonValidationErrors(['settings']); +}); + +test('can upload and stream own settings background image', function () { + Storage::fake('local'); + $user = User::factory()->create(); + Passport::actingAs($user); + + $response = $this->postJson('/api/settings/media/background', [ + 'file' => UploadedFile::fake()->image('background.png', 2400, 1200), + ]); + + $response + ->assertCreated() + ->assertJsonPath('data.width', 1600) + ->assertJsonPath('data.height', 800) + ->assertJsonPath('data.mimeType', 'image/jpeg'); + + expect($response->json('data.url'))->toStartWith('/settings/media/background?v='); + Storage::disk('local')->assertExists("settings-media/{$user->id}/background.jpg"); + + $this->get('/api/settings/media/background') + ->assertOk() + ->assertHeader('Content-Type', 'image/jpeg'); +}); + +test('settings background images are isolated per user', function () { + Storage::fake('local'); + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + Storage::disk('local')->put("settings-media/{$otherUser->id}/background.jpg", 'other-background'); + + Passport::actingAs($user); + + $this->get('/api/settings/media/background') + ->assertNotFound(); +}); + +test('can delete own settings background image', function () { + Storage::fake('local'); + $user = User::factory()->create(); + Storage::disk('local')->put("settings-media/{$user->id}/background.jpg", 'background'); + Passport::actingAs($user); + + $this->deleteJson('/api/settings/media/background') + ->assertNoContent(); + + Storage::disk('local')->assertMissing("settings-media/{$user->id}/background.jpg"); +}); diff --git a/frontend/README.md b/frontend/README.md index 83105c4..d6b2c12 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -2,6 +2,12 @@ Thats me Quasar Project +## Version + +Aktuelle Frontend-Version: `0.0.1` + +Die sichtbare App-Version wird in `src/config/appVersion.js` gepflegt und muss bei weiteren Entwicklungsschritten zusammen mit `package.json` und relevanten Dokumenten aktualisiert werden. + ## Install the dependencies ```bash diff --git a/frontend/dev/db-api-connect/README.md b/frontend/dev/db-api-connect/README.md new file mode 100644 index 0000000..9d75135 --- /dev/null +++ b/frontend/dev/db-api-connect/README.md @@ -0,0 +1,303 @@ +# Entwicklungsplan DB/API-Anbindung + +Version: `0.0.1` + +Status: Konzept und Umsetzungsplan fuer die Anbindung der Quasar-App an das Laravel-Backend mit MySQL. + +## Zielbild + +Die App soll nicht mehr primaer auf browserlokaler Persistenz basieren. Laravel mit MySQL wird die fuehrende Datenquelle fuer User, Settings, Events, Presets und Medien. IndexedDB und `localStorage` duerfen spaeter weiter als Cache und Offline-Schicht genutzt werden, aber nicht als alleinige Wahrheit. + +Das System geht konsequent vom eingeloggten User aus: + +- Jeder User besitzt eigene Settings. +- Jeder User besitzt eigene Timeline-Daten. +- Jeder User besitzt eigene Events. +- Jeder User besitzt eigene Medien, Images, Presets und spaeter Videos, Audios und Texte. +- Kein User darf Medien, Events oder Settings eines anderen Users direkt oder indirekt lesen. +- Teilen und Veröffentlichen wird spaeter bewusst als eigener Rechte- und Freigabeprozess umgesetzt. + +## Versionsregel + +Die aktuelle App-Version ist `0.0.1`. + +Bei jeder weiteren Entwicklungsrunde muessen folgende Stellen mitgefuehrt werden: + +- `frontend/package.json` +- `frontend/src/config/appVersion.js` +- `frontend/README.md` +- dieses Planungsdokument, sofern sich Architektur, Datenmodell oder API-Verhalten aendern +- spaetere Release Notes oder Migrationsdokumente + +Versionen in der `0.x`-Phase duerfen Breaking Changes enthalten. Jede Aenderung am Datenmodell oder an API-Kontrakten muss trotzdem dokumentiert werden. + +## Grundentscheidung + +Wir verwenden direkt MySQL im Laravel-Backend. SQLite wird nicht als Zielsystem genutzt, weil die Anwendung mehrere Benutzer, geschuetzte Medien, Sync und spaetere Sharing-/Invite-Funktionen benoetigt. MySQL passt besser zu dauerhaftem Betrieb, Indizes, Relationen und wachsendem Medien-Metadatenbestand. + +## Sicherheitsprinzip + +Die Datenhoheit liegt beim User. Alle API-Abfragen muessen serverseitig ueber den authentifizierten User eingeschraenkt werden. Das Frontend darf keine fremden IDs als Vertrauensbasis liefern. + +Regeln: + +- Jede userbezogene Tabelle enthaelt `user_id`. +- API-Controller filtern immer ueber `$request->user()`. +- Policies pruefen Zugriff auf Events, Medien, Presets und Settings. +- Storage-Pfade enthalten keine erratbaren oeffentlichen URLs. +- Originalmedien werden nicht aus einem public disk ausgeliefert. +- Medien werden ueber autorisierte Backend-Routen oder signierte, kurzlebige URLs bereitgestellt. +- Thumbnails duerfen ebenfalls nur nach Berechtigungspruefung ausgeliefert werden. + +## Datenmodell + +Bestehende Basis: + +- `users` existiert bereits. +- `events` existiert bereits und hat bereits `user_id`. + +Geplante Erweiterungen: + +- `user_settings` + - `user_id` + - `settings` als JSON + - `timeline_zoom` + - `timeline_scroll_left` + - `active_preset_id` + - Timestamps + +- `setting_presets` + - `user_id` + - `name` + - `settings` als JSON + - `is_public` fuer spaeteres Veröffentlichen von reinen Timeline-/Visual-Presets + - Timestamps + +- `events` + - weiterhin strikt usergebunden + - Erweiterung um Felder aus dem aktuellen Frontend, z. B. Key-Image-Titel, Farbdaten, Notizen und spaetere Event-Metadaten + - `client_id` bleibt wichtig fuer Offline-/Sync-Kompatibilitaet + +- `event_media` + - `user_id` + - `event_id` + - `type`: image, video, audio, text + - `title` + - `original_path` + - `thumbnail_path` + - `mime_type` + - `size` + - `width` + - `height` + - `duration` + - `sort_order` + - `metadata` als JSON + - Timestamps + +- `event_media_variants` + - optional spaeter, wenn mehrere Bildgroessen benoetigt werden + - z. B. thumbnail, preview, original + +- `timeline_shares` + - spaeter + - Einladung oder Share-Link fuer eine komplette Timeline + - getrennt von Preset-Sharing + - eigene Berechtigungen und Ablaufdaten + +## Medienkonzept + +Medien werden nicht als Data-URL in der Datenbank gespeichert. Die Datenbank speichert nur Metadaten und Storage-Pfade. + +Upload-Ablauf: + +1. Frontend laedt Datei an Laravel hoch. +2. Backend validiert User, Dateityp und Groesse. +3. Backend speichert Originaldatei in einem geschuetzten Storage-Bereich. +4. Backend erzeugt Thumbnail oder Preview. +5. Backend speichert Metadaten in `event_media`. +6. API gibt Media-Objekt mit Thumbnail-Endpunkt zurueck. + +Performance-Regel: + +- Timeline und Event-Panel laden zuerst nur Thumbnails. +- Originaldateien werden erst bei grosser Betrachtung oder Download geladen. +- Videos und Audios bekommen spaeter eigene Preview-/Poster-Strategien. +- Texte werden spaeter als eigener Media-Typ behandelt, nicht als Bilderspezialfall. + +## API-Konzept + +Geplante API-Gruppen unter `/api`: + +- Auth + - Login + - Logout + - aktueller User + +- Settings + - `GET /settings` + - `PUT /settings` + +- Presets + - `GET /setting-presets` + - `POST /setting-presets` + - `PUT /setting-presets/{preset}` + - `DELETE /setting-presets/{preset}` + - spaeter: `POST /setting-presets/{preset}/publish` + +- Events + - `GET /events` + - `POST /events` + - `GET /events/{event}` + - `PUT /events/{event}` + - `DELETE /events/{event}` + - `POST /events/sync` + +- Event-Medien + - `GET /events/{event}/media` + - `POST /events/{event}/media` + - `PUT /events/{event}/media/{media}` + - `DELETE /events/{event}/media/{media}` + - `GET /media/{media}/thumbnail` + - `GET /media/{media}/original` + +Alle Endpunkte muessen authentifiziert sein. Jeder Zugriff auf `{event}`, `{media}` oder `{preset}` muss serverseitig gegen `user_id` abgesichert werden. + +## Frontend-Migration + +Aktueller Zustand: + +- Auth liegt in `localStorage`. +- Zusaetzlich angelegte lokale Test-User liegen in `localStorage` unter `thatsme-users`. +- Settings liegen in `localStorage`. +- Events, SyncQueue und Medien liegen in IndexedDB. + +Zwischenstand in Version `0.0.1`: + +- Die urspruenglichen Demo-User `user1` bis `user5` behalten ihre bestehenden lokalen Daten. +- Neue lokale User koennen auf der Login-Seite ueber den Plus-Button angelegt werden. +- Neue lokale User starten mit leerer Timeline und bekommen keine automatisch generierten Test-Events. +- Diese lokale User-Verwaltung ist nur eine Uebergangsloesung bis zur Laravel/MySQL-Auth. + +Zielzustand: + +- Auth laeuft ueber Laravel API. +- Settings werden nach Login vom Backend geladen. +- Events werden vom Backend geladen und im Store gehalten. +- IndexedDB bleibt als Cache und optionale Offline-Queue. +- Medien werden als Backend-URLs/IDs verwaltet, nicht mehr als Data-URLs im Event. + +Schrittweise Migration: + +1. API-Client im Frontend anlegen. +2. Auth-Store auf Backend-Login vorbereiten. +3. Settings-Store mit Backend-Laden/Speichern erweitern. +4. Events-Store auf Backend-CRUD umstellen. +5. Media-Upload ueber Backend einbauen. +6. IndexedDB als Cache neu definieren und nicht mehr als Primaerspeicher behandeln. +7. SyncQueue mit Server-Status und Konfliktregeln ueberarbeiten. + +## Konflikt- und Sync-Regeln + +Da spaeter Offline-Faehigkeit weiter sinnvoll ist, bleibt `client_id` wichtig. + +Regeln: + +- Jeder lokal erzeugte Datensatz bekommt eine stabile `client_id`. +- Backend antwortet mit Server-ID und `updated_at`. +- Bei Updates wird `updated_at` fuer Konflikterkennung genutzt. +- Bei Medien wird Upload nicht stillschweigend ueberschrieben. +- Konflikte werden spaeter sichtbar gemacht, nicht automatisch fremd geloest. + +## Sharing spaeter + +Preset-Sharing und Timeline-Sharing werden getrennt. + +Preset-Sharing: + +- Nur visuelle Timeline-/LifeWave-Einstellungen. +- Keine Events. +- Keine Medien. +- Keine personenbezogenen Inhalte. + +Timeline-Sharing: + +- Spaeter ueber Einladung, Token oder freigegebene Empfaenger. +- Enthält Settings, Events und Medien nur im explizit freigegebenen Umfang. +- Zugriff muss widerrufbar sein. +- Keine oeffentlichen, erratbaren Medienlinks. + +## Umsetzungsphasen + +Phase 1: Backend-Datenmodell + +- Bestehende `events`-Tabelle mit aktuellem Frontend-Modell abgleichen. +- Migrationen fuer `user_settings`, `setting_presets` und `event_media` erstellen. +- Models, Relationships, Factories und Policies anlegen. +- Tests fuer User-Isolation erstellen. + +Phase 2: API-Basis + +- API Resources und Form Requests erstellen. +- Settings- und Preset-Endpunkte implementieren. +- Event-Endpunkte usergesichert vervollstaendigen. +- Tests fuer CRUD und Fremdzugriff schreiben. + +Phase 3: Media Storage + +- Geschuetzten Storage-Disk konfigurieren. +- Upload-Endpunkte implementieren. +- Thumbnail-Erzeugung vorbereiten. +- Autorisierte Auslieferung fuer Thumbnail und Original bauen. +- Tests fuer Zugriffsschutz und Dateitypen schreiben. + +Phase 4: Frontend-Anbindung + +- API-Service im Frontend erstellen. +- Auth-Store an Backend anbinden. +- Settings-Store vom Backend laden und speichern. +- Events-Store CRUD und Sync an Backend anbinden. +- EventPanel-Medienupload auf Server umstellen. + +Phase 5: Migration aus Browserdaten + +- Einmalige Importstrategie fuer lokale Demo-Daten definieren. +- Pro User lokale Settings und Events optional zum Backend hochladen. +- Danach lokale Daten nur noch als Cache nutzen. + +Phase 6: Dokumentation und Stabilisierung + +- API-Kontrakte dokumentieren. +- Datenmodell dokumentieren. +- Version aktualisieren. +- Manuelle QA-Schritte dokumentieren. +- Bekannte Grenzen und Folgeaufgaben festhalten. + +## Dokumentationspflicht pro Schritt + +Jede Umsetzung in diesem Bereich muss dokumentiert werden. + +Pflicht je Schritt: + +- Was wurde geaendert? +- Welche Version betrifft es? +- Welche Tabellen/API-Endpunkte sind betroffen? +- Welche Sicherheitsregel wurde umgesetzt oder geprueft? +- Welche Tests wurden ausgefuehrt? +- Welche offenen Punkte bleiben? + +Empfohlene Struktur fuer neue Dokumente in diesem Ordner: + +- `README.md`: Gesamtplan und aktueller Stand +- `data-model.md`: finale Tabellen und Beziehungen +- `api-contract.md`: Request-/Response-Strukturen +- `migration-log.md`: chronologische Umsetzungsschritte +- `security-checklist.md`: Zugriffsschutz und Storage-Regeln + +## Offene Entscheidungen + +- Welche Laravel-Auth-Variante wird fuer die App final verwendet: vorhandenes Passport-Setup oder eine gezielte API-Token-Strategie? +- Welche maximale Upload-Groesse gilt pro Datei und pro User? +- Wo werden Originaldateien langfristig gespeichert: lokaler Storage, S3-kompatibel oder Synology C2? +- Wann wird echte Offline-Synchronisation wieder aktiviert? +- Wie detailliert soll spaeteres Timeline-Sharing steuerbar sein? + diff --git a/frontend/dev/db-api-connect/implementation-plan.md b/frontend/dev/db-api-connect/implementation-plan.md new file mode 100644 index 0000000..b99a9c2 --- /dev/null +++ b/frontend/dev/db-api-connect/implementation-plan.md @@ -0,0 +1,428 @@ +# Umsetzungsplan API- und DB-Anbindung + +Version: `0.5.0` + +Status: Hybrid-MVP weitgehend umgesetzt. Remote-User speichern Events, Settings und Bilder ueber die API; der lokale Demo-User bleibt browserlokal und startet leer. + +## Ziel + +Die Quasar-App soll zwei bewusst getrennte Speicherwege unterstuetzen: + +- **Remote/API-User**: `user1@thats-me.app` bis `user6@thats-me.app`, Passwort `pass`. Diese User speichern Events, Timeline-Einstellungen und Bilder serverseitig ueber die Laravel-API. +- **Lokaler Demo-User**: `Demo`. Dieser User startet leer und speichert weiterhin ausschliesslich im Browser Storage. Seine Daten werden nicht an die API uebertragen und sind nicht global oder fuer AI/API-Prozesse sichtbar. + +Der erste Umsetzungsstand soll praesentationstaugliche Backend-User ermoeglichen, ohne den lokalen Testmodus zu entfernen. + +## Grundregeln + +- Remote-User werden immer ueber Laravel authentifiziert. +- Lokale Demo-Daten bleiben privat im Browser und verlassen das Geraet nicht. +- Backend-Abfragen werden immer ueber den authentifizierten User eingeschraenkt. +- Bilder von Remote-Usern werden serverseitig gespeichert und usergebunden ausgeliefert. +- `client_id` bleibt die stabile Frontend-ID fuer Events und dient spaeter als Sync-Bruecke. +- IndexedDB bleibt fuer lokale Demo-Daten und optionalen Cache erhalten, ist fuer Remote-User aber nicht die fuehrende Datenquelle. + +## Aktueller Umsetzungsstand + +Stand: 2026-06-03 + +Abgeschlossen: + +- **Phase 1 Backend-Basis**: Laravel-API, Passport, Demo-API-User und geschuetzte Endpunkte sind vorbereitet. +- **Phase 2 API-Login**: Quasar kann `user1@thats-me.app` bis `user6@thats-me.app` ueber die API anmelden; Tokens werden im Frontend zentral verwaltet. +- **Phase 3 Hybrid-Auth**: Es gibt einen festen lokalen `Demo`-User ohne API-Login. Dieser startet leer und schreibt keine API-SyncQueue-Eintraege. +- **Phase 4 Remote-Events**: Remote-Events werden direkt per API erstellt, geladen, aktualisiert und geloescht. +- **Phase 5 Remote-Settings**: Timeline-, App- und LifeWave-Settings werden pro Remote-User serverseitig als JSON gespeichert. +- **Phase 6 Serverseitige Bilder**: Event-Bilder und Settings-Hintergrundbilder werden fuer Remote-User serverseitig gespeichert, optimiert und geschuetzt ausgeliefert. + +Noch offen: + +- **Phase 7 Praesentationsdaten**: Struktur und Inhalte fuer kontrollierte Beispiel-/Praesentationsdaten pro API-User. +- **Phase 8 Abschlussdokumentation**: API-Vertrag, Datenmodell und Security-Checkliste koennen auf Basis der jetzigen Umsetzung finalisiert werden. + +Wichtige technische Entscheidungen: + +- Remote-User nutzen die Laravel-API als fuehrende Datenquelle. +- Lokale Demo-Daten bleiben in IndexedDB/localStorage und werden nicht an die API gesendet. +- Geschuetzte Bilder werden im Frontend per Bearer Token geladen und als Blob-URL angezeigt. +- Event-Bilder haben drei Varianten: + - Timeline/Dot-Thumbnail: 320x320px, quadratisch gecroppt. + - Event-Panel/Preview: maximal 900px laengste Kante, JPEG-Qualitaet 84. + - Original/Download-Basis: maximal 3508px laengste Kante, JPEG-Qualitaet 90. +- Settings-Hintergrundbilder werden separat pro User gespeichert: maximal 1600px laengste Kante, JPEG-Qualitaet 86. + +Wichtige Dokumentationsquelle: + +- `migration-log.md` enthaelt die chronologische Umsetzung mit geaenderten Dateien, Tests und QA-Hinweisen. + +## Phase 1: Backend-Basis Pruefen Und Vorbereiten + +Ziel: Die bestehende Laravel-API lauffaehig, migriert und fuer API-User nutzbar machen. + +Status: Abgeschlossen. + +Umgesetzt: + +- `user1` bis `user6` werden idempotent ueber den Database Seeder angelegt. +- Alle sechs API-User nutzen das Passwort `pass`. +- Ein Passport Personal Access Client fuer den `users` Provider wird bei Bedarf idempotent angelegt. +- API-Endpunkte sind ohne Token geschuetzt. +- CORS wurde fuer die Quasar-App-Origin konfiguriert. + +Aufgaben: + +- Migrationen fuer `users`, Passport-Tabellen und `events` pruefen und ausfuehren. +- Sicherstellen, dass Passport fuer API-Token in der lokalen Umgebung funktioniert. +- Demo-Seeder fuer `user1` bis `user6` anlegen oder erweitern. +- Passwort fuer alle sechs API-User auf `pass` setzen. +- Bestehenden Test-User nicht als Praesentationsuser verwenden. +- CORS/API-Erreichbarkeit von `app.thats-me.test` nach `api.thats-me.test` pruefen. + +Akzeptanzkriterien: + +- `user1` bis `user6` existieren in der Datenbank. +- Jeder User kann serverseitig authentifiziert werden. +- `/api/user` liefert mit Token den passenden User. +- Ohne Token sind API-Endpunkte geschuetzt. + +Tests/Dokumentation: + +- Feature-Test fuer API-Login. +- Feature-Test fuer geschuetzten Zugriff ohne Token. +- Dokumentieren, welche Seeder und Migrations ausgefuehrt wurden. + +## Phase 2: API-Login Fuer Quasar + +Ziel: Die Quasar-App kann Remote-User ueber die Laravel-API anmelden. + +Status: Abgeschlossen. + +Umgesetzt: + +- `POST /api/login` validiert Laravel-User und erstellt Passport Bearer Tokens. +- `POST /api/logout` widerruft den aktuellen Token. +- Frontend nutzt `src/services/apiClient.js` als zentralen API-Client. +- Token-Ablage erfolgt in IndexedDB `meta.accessToken`. +- Login-Fehler werden auf der Login-Seite verstaendlich angezeigt. +- Bei `app.thats-me.test` wird automatisch `https://api.thats-me.test/api` als API-Basis genutzt, sofern `VITE_API_BASE` nicht gesetzt ist. +- Bei Live-Hosts unter `thats-me.app` wird automatisch `https://api.thats-me.app/api` als API-Basis genutzt. + +Aufgaben: + +- `POST /api/login` implementieren. +- `POST /api/logout` implementieren. +- Response-Struktur fuer Login festlegen: Token, User-ID, Name, E-Mail, Storage-Modus `remote`. +- Frontend-API-Client fuer authentifizierte Requests anlegen. +- Token-Ablage im Frontend festlegen und konsistent nutzen. +- Fehlertexte fuer falsche Zugangsdaten sauber zur Login-Seite durchreichen. + +Akzeptanzkriterien: + +- Login mit `user1@thats-me.app` und `pass` funktioniert. +- Falsches Passwort liefert einen klaren Fehler. +- Logout entfernt den Token im Frontend. +- Router erkennt Remote-Login als authentifiziert. + +Tests/Dokumentation: + +- Feature-Test fuer erfolgreichen Login. +- Feature-Test fuer falsche Zugangsdaten. +- Manuelle QA: Login, Reload, Logout. + +## Phase 3: Hybrid-Auth Im Frontend + +Ziel: Frontend unterscheidet klar zwischen lokalem Demo-Modus und Remote-Usern. + +Status: Abgeschlossen und manuell bestaetigt. + +Umgesetzt: + +- Fester lokaler User `Demo` mit `mode: local`. +- `Demo` benoetigt kein Passwort. +- `Demo` startet leer und seeded keine 500 Beispiel-Events mehr. +- Lokale User-Erstellung wurde entfernt. +- Remote-User-Liste enthaelt `user1` bis `user6`. +- Lokale Demo-Events bleiben in IndexedDB und werden nicht in die SyncQueue geschrieben. +- Wechsel zwischen Demo und Remote-Usern ist ueber `userId`/`mode` getrennt. + +Aufgaben: + +- Auth-Store um `mode: 'local' | 'remote'` erweitern. +- Lokalen User `Demo` definieren. +- `Demo` startet leer und nutzt Browser Storage. +- Remote-User-Liste fuer `user1` bis `user6` anzeigen. +- Lokale User-Erstellung per Plus-Button entfernen oder durch "Lokalen Demo-Modus starten" ersetzen. +- Router-Guard auf lokalen und remote Auth-Modus anpassen. + +Akzeptanzkriterien: + +- `Demo` kann ohne API gestartet werden. +- `Demo` startet ohne automatisch generierte 500 Events. +- `user1` bis `user6` laufen ueber API-Login. +- Wechsel zwischen Demo und Remote-Usern vermischt keine Daten. + +Tests/Dokumentation: + +- Frontend-Lint ausfuehren. +- Manuelle QA: Demo starten, Event anlegen, Reload, Daten bleiben lokal. +- Manuelle QA: Remote-User einloggen, Reload, Token/Auth bleibt konsistent. + +## Phase 4: Events Remote Persistieren + +Ziel: Events von Remote-Usern werden in der Datenbank gespeichert und aus der API geladen. + +Status: Abgeschlossen. + +Umgesetzt: + +- Remote-Events werden beim Login/Reload ueber `GET /api/events` geladen. +- Create/Update/Delete fuer Remote-Events laufen direkt ueber `POST`, `PUT` und `DELETE` gegen die Event-API. +- Events sind serverseitig ueber den authentifizierten User isoliert. +- Frontend haelt Remote-Events nur als lokalen Cache in IndexedDB. +- Lokaler Demo-User bleibt browserlokal. + +Aufgaben: + +- Bestehende `events`-Tabelle mit Frontend-Modell abgleichen. +- Fehlende Felder ergaenzen: mindestens `location`, `key_image_title`; Medien zunaechst ueber eigene Medientabelle. +- Event-Requests und `EventResource` erweitern. +- Vorhandene Event-API gegen User-Isolation testen. +- Frontend-Events-Store ueber Storage-Schicht anbinden: + - `local`: Dexie wie bisher. + - `remote`: Laravel API. +- Create, Update, Delete fuer Remote-Events direkt an API senden. +- Lokale SyncQueue fuer Remote-User nur als spaetere Retry-/Offline-Schicht behandeln. + +Akzeptanzkriterien: + +- Remote-User sieht nach Reload seine Events wieder. +- Andere Remote-User sehen diese Events nicht. +- Lokaler `Demo`-User bleibt vollstaendig browserlokal. +- Event-Felder aus dem aktuellen Panel bleiben erhalten. + +Tests/Dokumentation: + +- Feature-Tests fuer Event CRUD. +- Feature-Test fuer Fremdzugriff zwischen Usern. +- Manuelle QA mit `user1` und `user2`. + +## Phase 5: Timeline-Settings Remote Speichern + +Ziel: Timeline- und App-Einstellungen werden fuer Remote-User serverseitig gespeichert. + +Status: Abgeschlossen. + +Umgesetzt: + +- Tabelle `user_settings` speichert ein JSON-Dokument pro User. +- `GET /api/settings` und `PUT /api/settings` sind usergebunden hinter `auth:api`. +- Gespeichert werden u.a. Appearance, Accent Color, Sprache, LifeWave/Floating-Lines, Emotion-Gradient, Timeline-Zoom, Timeline-Scroll, Presets, aktives Preset und `showFps`. +- Remote-Settings werden beim Userwechsel aus der API geladen und lokal nur gecacht. +- Lokaler Demo-User nutzt weiterhin `localStorage`. + +Aufgaben: + +- Tabelle `user_settings` anlegen. +- Model und Beziehung zu `User` ergaenzen. +- Endpunkte anlegen: + - `GET /api/settings` + - `PUT /api/settings` +- Settings als JSON speichern, inklusive Timeline-Zoom, Scroll-Position, Floating-Lines, Farben, Sprache, Presets und aktivem Preset. +- Settings-Store ueber Storage-Schicht anbinden: + - `local`: `localStorage`. + - `remote`: API. + +Akzeptanzkriterien: + +- Remote-User behaelt Timeline-Settings nach Reload und Geraetewechsel. +- Lokaler `Demo`-User behaelt Settings nur im Browser. +- Settings anderer User sind nicht lesbar. + +Tests/Dokumentation: + +- Feature-Test fuer GET/PUT Settings. +- Feature-Test fuer User-Isolation. +- Manuelle QA: Settings aendern, Reload, anderer User pruefen. + +## Phase 6: Serverseitige Bilder Fuer Remote-User + +Ziel: Bilder von Remote-Events werden serverseitig gespeichert und sicher usergebunden ausgeliefert. + +Status: Abgeschlossen fuer Event-Bilder und Settings-Hintergrundbilder. + +Umgesetzt: + +- Tabelle `event_media` speichert Event-Medien user- und eventgebunden. +- Upload, Liste, Anzeige und Loeschen von Event-Medien sind implementiert. +- Event-Bilder werden als Thumbnail, Preview und Original-Variante erzeugt. +- Key Images liegen in der Collection `key_image`, Galerie-Bilder in `gallery`. +- Geschuetzte Event-Bildrouten pruefen `event_media.user_id` gegen den authentifizierten User. +- Frontend laedt geschuetzte Bild-URLs mit Bearer Token als Blob. +- Settings-Hintergrundbilder werden ueber eigene Endpunkte gespeichert: + - `POST /api/settings/media/background` + - `GET /api/settings/media/background` + - `DELETE /api/settings/media/background` +- Lokaler Demo-User speichert Bilder weiterhin lokal bzw. als Browser-Daten. + +Aufgaben: + +- Tabelle `event_media` anlegen. +- Model, Factory und Beziehung zu `User` und `Event` anlegen. +- Geschuetzten Storage-Bereich definieren. +- Upload-Endpunkt anlegen: `POST /api/events/{event}/media`. +- Medienliste laden: `GET /api/events/{event}/media`. +- Medien loeschen: `DELETE /api/events/{event}/media/{media}`. +- Auslieferung pruefen: + - Thumbnail/Preview, falls vorhanden. + - Original nur nach Auth-Pruefung. +- Frontend-EventPanel fuer Remote-User auf Upload umstellen. +- Lokaler `Demo`-User speichert Bilder weiter lokal. + +Akzeptanzkriterien: + +- Remote-User kann Bild zu Event hochladen. +- Bild ist nach Reload wieder sichtbar. +- Andere User koennen das Bild nicht abrufen. +- Lokaler Demo-Modus funktioniert unveraendert lokal. + +Tests/Dokumentation: + +- Feature-Test fuer Upload. +- Feature-Test fuer Fremdzugriff auf Medien. +- Feature-Test fuer Loeschen. +- Dokumentieren, wo Dateien gespeichert werden und welche Limits gelten. + +## Phase 7: Praesentationsdaten Fuer API-User + +Ziel: Die sechs API-User koennen gezielt mit Beispielinhalten fuer Praesentationen befuellt werden. + +Status: Offen, naechster sinnvoller Umsetzungsschritt. + +Aufgaben: + +- Entscheiden, welche Beispiel-Events pro User angelegt werden. +- Seeder fuer Praesentationsdaten vorbereiten. +- Bilder entweder als Fixture-Dateien oder ueber dokumentierten Upload-Prozess einspielen. +- Sicherstellen, dass `Demo` leer bleibt. +- AI/API-Zugriff konzeptionell dokumentieren: welche Endpunkte laden Userdaten, welche Auth ist noetig. + +Akzeptanzkriterien: + +- `user1` bis `user6` haben kontrollierte Praesentationsdaten. +- Beispielbilder sind serverseitig vorhanden. +- Lokaler Demo-Modus bleibt leer und privat. + +Tests/Dokumentation: + +- Seeder-Ausfuehrung dokumentieren. +- Manuelle QA pro User. +- Liste der Praesentationsdaten pflegen. + +Empfohlene Umsetzung: + +1. Kleine Datendefinition fuer Praesentationsdaten erstellen, z.B. `backend/database/seeders/PresentationDataSeeder.php` plus strukturierte Arrays. +2. Pro User entscheiden, ob er leer bleibt oder eine eigene Story bekommt: + - `user1`: Standard-Praesentation mit wenigen Events. + - `user2`: Bildlastige Galerie-Demo. + - `user3`: Settings-/LifeWave-Demo. + - `user4` bis `user6`: Reserve oder alternative Stories. +3. Seeder idempotent bauen: + - Events ueber stabile `client_id` upserten. + - Medien nicht doppelt anlegen. + - Settings pro User per `updateOrCreate` setzen. +4. Beispielbilder entweder als Fixture-Dateien in einem dokumentierten Ordner ablegen oder bewusst ueber die App hochladen. +5. Nach dem Seed pro User manuell pruefen: + - Login. + - Timeline sichtbar. + - Event-Panel oeffnet. + - Bilder laden als Thumbnail/Preview. + - Settings werden korrekt angewendet. + +Wichtig: + +- `Demo` darf durch den Seeder nicht veraendert werden. +- Praesentationsdaten sollten wiederholbar eingespielt werden koennen, ohne Dubletten zu erzeugen. +- Fuer echte Praesentationen sollten keine zufaelligen `crypto.randomUUID()` IDs verwendet werden, sondern stabile `client_id` Werte aus der Datendefinition. + +## Phase 8: Dokumentation Und Abschluss Pro Umsetzungsschritt + +Ziel: Jeder Schritt bleibt nachvollziehbar und kann spaeter fortgesetzt werden. + +Pflicht pro Umsetzungsschritt: + +- Datum und Version. +- Geaenderte Dateien. +- Betroffene Tabellen. +- Betroffene API-Endpunkte. +- Sicherheitsregel: Wie wurde User-Isolation geprueft? +- Ausgefuehrte Tests. +- Manuelle QA. +- Offene Punkte. + +Empfohlene Dokumente in diesem Ordner: + +- `README.md`: Zielbild und Gesamtuebersicht. +- `implementation-plan.md`: dieser abarbeitbare Plan. +- `api-contract.md`: konkrete Request-/Response-Strukturen. +- `data-model.md`: Tabellen, Relationen und Felder. +- `migration-log.md`: chronologische Umsetzungsschritte. +- `security-checklist.md`: Auth, User-Isolation und Medienzugriff. + +## Aktuelle Offene Entscheidungen + +- Ob die aktuellen Upload-Limits dauerhaft reichen: aktuell 10 MB pro Bild. +- Ob die erlaubten Bildtypen `jpg`, `jpeg`, `png`, `webp` fuer den MVP ausreichen. +- Ob Praesentationsdaten per Seeder oder ueber ein Admin-/Import-Skript gepflegt werden. +- Wie der spaetere AI-Zugriff authentifiziert wird. +- Ob fuer groessere Medienverarbeitung spaeter Queue-Jobs statt synchroner Verarbeitung eingesetzt werden. +- Ob alte, lokal gespeicherte Remote-Daten aktiv migriert oder bewusst ignoriert werden. + +## Naechster Konkreter Schritt + +Mit Phase 7 starten: + +1. Inhaltliche Rollen fuer `user1` bis `user6` festlegen. +2. Datensatzstruktur fuer Praesentationsdaten definieren. +3. Idempotenten `PresentationDataSeeder` vorbereiten. +4. Optional Fixture-Bilder festlegen. +5. Seeder lokal ausfuehren und pro User manuell pruefen. + +## Arbeitsnotizen Fuer Fortsetzung + +Relevante Frontend-Dateien: + +- `frontend/src/stores/auth.js`: Remote-User, Demo-User, Login/Logout, Token-Handling. +- `frontend/src/stores/events.js`: lokale Demo-Events und Remote-Event-API-Anbindung. +- `frontend/src/stores/settings.js`: lokale und remote Settings-Persistenz. +- `frontend/src/components/EventPanel.vue`: Event-Bild-Upload und Galerie. +- `frontend/src/components/LifeWaveSettings.vue`: Settings-Hintergrundbild-Upload. +- `frontend/src/composables/useImageCache.js`: geschuetzte Bildpfade als Blob laden. + +Relevante Backend-Dateien: + +- `backend/routes/api.php`: API-Routen. +- `backend/app/Http/Controllers/Api/AuthController.php`: Login/Logout. +- `backend/app/Http/Controllers/Api/EventController.php`: Event-CRUD und Sync. +- `backend/app/Http/Controllers/Api/EventMediaController.php`: Event-Medien. +- `backend/app/Http/Controllers/Api/SettingsController.php`: Settings JSON. +- `backend/app/Http/Controllers/Api/SettingsMediaController.php`: Settings-Hintergrundbilder. +- `backend/app/Services/EventMediaImageProcessor.php`: Bildvarianten fuer Event-Medien. +- `backend/database/seeders/DatabaseSeeder.php`: API-User und Passport Client. + +Relevante Tests: + +- `backend/tests/Feature/Api/AuthTest.php` +- `backend/tests/Feature/Api/EventTest.php` +- `backend/tests/Feature/Api/EventMediaTest.php` +- `backend/tests/Feature/Api/SettingsTest.php` + +Empfohlene Checks nach Aenderungen: + +```bash +cd backend +vendor/bin/pint --dirty --format agent +php artisan test --compact tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php tests/Feature/Api/EventMediaTest.php tests/Feature/Api/SettingsTest.php + +cd ../frontend +npm run lint +``` diff --git a/frontend/dev/db-api-connect/migration-log.md b/frontend/dev/db-api-connect/migration-log.md new file mode 100644 index 0000000..20d8124 --- /dev/null +++ b/frontend/dev/db-api-connect/migration-log.md @@ -0,0 +1,469 @@ +# Migration Log + +## 2026-06-05 - Live-Deployment API-Login + +Status: Live-Login gegen `https://api.thats-me.app` erfolgreich hergestellt. + +### Problem + +- Das Live-Frontend lief zuerst gegen eine falsche API-Basisadresse. +- Korrekte API-Basis ist `https://api.thats-me.app/api`. +- Danach blockierte CORS Requests von `https://thats-me.app`. +- Nach dem CORS-Fix kam der Request bis Laravel durch, scheiterte aber mit `Invalid key supplied` beim Passport-Token. + +### Ursache + +- `frontend/src/services/apiClient.js` normalisierte bisher nur `https://api.thats-me.test` automatisch auf `/api`. +- `backend/config/cors.php` erlaubte nur lokale `.test`- und localhost-Origins. +- Auf dem Live-Server fehlten bzw. griffen keine gueltigen Laravel-Passport-Keys fuer `createToken()`. + +### Umsetzung + +- `frontend/src/services/apiClient.js` nutzt fuer Live-Hosts unter `thats-me.app` automatisch `https://api.thats-me.app/api`. +- `VITE_API_BASE=https://api.thats-me.app` wird automatisch zu `https://api.thats-me.app/api` normalisiert. +- `backend/config/cors.php` erlaubt zusaetzlich: + - `https://thats-me.app` + - `https://www.thats-me.app` + - `https://app.thats-me.app` +- Auf dem Live-Server wurden Passport-Keys/Config korrigiert, danach war der Login erfolgreich. + +### Live-Kommandos + +```bash +php artisan passport:keys --force +php artisan db:seed --class=DatabaseSeeder --no-interaction +php artisan optimize:clear +php artisan config:cache +``` + +### Pruefung + +- Browser-Login im Live-Frontend gegen `https://api.thats-me.app/api/login` funktioniert. +- CORS-Fehler fuer `https://thats-me.app` ist behoben. +- Passport-Fehler `Invalid key supplied` ist behoben. + +## 2026-06-03 - Phase 3 Hybrid-Auth Demo-Modus + +Version: `0.2.1` + +Status: Abgeschlossen fuer festen lokalen Demo-User ohne API-Sync. + +### Geaenderte Dateien + +- `frontend/src/pages/LoginPage.vue` +- `frontend/src/stores/auth.js` +- `frontend/src/stores/events.js` +- `frontend/dev/db-api-connect/migration-log.md` + +### Umsetzung + +- Es gibt jetzt einen festen lokalen User `Demo` mit `mode: local`. +- Die Login-Auswahl zeigt `Demo` zusaetzlich zu den sechs API-Usern. +- Fuer `Demo` ist kein Passwort noetig; der Login-Button zeigt `Demo lokal starten`. +- Lokale User-Erstellung und lokale `userN@thats-me.app`-Custom-User wurden aus der Auth-Liste entfernt. +- `Demo` startet leer und seeded keine Beispiel-Events mehr. +- Lokale Demo-Events bleiben in IndexedDB, werden aber nicht mehr in die lokale SyncQueue geschrieben. +- Remote-User `user1` bis `user6` bleiben unveraendert API-basiert. + +### Sicherheitsregel + +Der Demo-Modus verwendet keinen API-Login und keinen Bearer Token. Events im Demo-Modus bleiben browserlokal in IndexedDB und werden nicht als API-Sync-Auftrag vorgemerkt. + +### Ausgefuehrte Kommandos + +```bash +npm run lint +``` + +### Testergebnis + +- `npm run lint`: erfolgreich. + +### Manuelle QA + +- Noch nicht im Browser durchgeklickt. +- Erwartete QA: `Demo` auswaehlen, ohne Passwort starten, leere Timeline sehen, Event anlegen, Reload pruefen. Danach mit `user1@thats-me.app` einloggen und sicherstellen, dass keine Demo-Events sichtbar sind. + +## 2026-06-03 - Nachtrag Settings-Hintergrundbilder + +Status: Abgeschlossen fuer serverseitige Settings-Hintergrundbilder bei Remote-Usern. + +### Geaenderte Dateien + +- `backend/app/Http/Controllers/Api/SettingsMediaController.php` +- `backend/app/Http/Requests/StoreSettingsMediaRequest.php` +- `backend/routes/api.php` +- `backend/tests/Feature/Api/SettingsTest.php` +- `frontend/src/components/LifeWaveSettings.vue` +- `frontend/src/composables/useImageCache.js` +- `frontend/src/layouts/LifeWaveLayout.vue` +- `frontend/src/stores/settings.js` +- `frontend/dev/db-api-connect/migration-log.md` + +### Betroffene API-Endpunkte + +- `POST /api/settings/media/background` +- `GET /api/settings/media/background` +- `DELETE /api/settings/media/background` + +### Umsetzung + +- Remote-User laden eigene LifeWave-Hintergrundbilder nicht mehr als Data-URL in das Settings-JSON. +- Der Backend-Endpunkt speichert pro User ein optimiertes JPEG unter dem privaten `local` Disk. +- Hintergrundbilder werden auf maximal 1600px laengste Kante und JPEG-Qualitaet 86 reduziert. +- Die Settings speichern nur den geschuetzten Pfad `/settings/media/background`. +- Das Frontend laedt geschuetzte Settings-Bilder wie Event-Medien per Bearer Token als Blob und verwendet die Blob-URL als CSS-Hintergrund. +- Der lokale Demo-/Browser-Modus nutzt weiterhin die bestehende Data-URL-Komprimierung im Browser. + +### Sicherheitsregel + +Alle Settings-Medienrouten liegen hinter `auth:api`. Dateien werden usergebunden unter `settings-media/{userId}/background.jpg` gespeichert und nur fuer den authentifizierten User ausgeliefert. + +### Ausgefuehrte Kommandos + +```bash +vendor/bin/pint --dirty --format agent +php artisan test --compact tests/Feature/Api/SettingsTest.php +npm run lint +``` + +### Testergebnis + +- `tests/Feature/Api/SettingsTest.php`: 8 Tests, 32 Assertions, erfolgreich. +- `npm run lint`: erfolgreich. + +## 2026-06-03 - Phase 6 Serverseitige Bilder Fuer Remote-User + +Version: `0.4.0` + +Status: Abgeschlossen fuer serverseitige Remote-Eventbilder, Thumbnail-Erzeugung und geschuetzte Auslieferung. + +### Geaenderte Dateien + +- `backend/app/Http/Controllers/Api/EventController.php` +- `backend/app/Http/Controllers/Api/EventMediaController.php` +- `backend/app/Http/Requests/StoreEventMediaRequest.php` +- `backend/app/Http/Resources/EventMediaResource.php` +- `backend/app/Http/Resources/EventResource.php` +- `backend/app/Models/Event.php` +- `backend/app/Models/EventMedia.php` +- `backend/app/Services/EventMediaImageProcessor.php` +- `backend/config/cors.php` +- `backend/database/factories/EventMediaFactory.php` +- `backend/database/migrations/2026_06_03_131801_create_event_media_table.php` +- `backend/database/migrations/2026_06_03_133403_add_preview_paths_to_event_media_table.php` +- `backend/routes/api.php` +- `backend/tests/Feature/Api/EventMediaTest.php` +- `frontend/src/components/EventPanel.vue` +- `frontend/src/composables/useImageCache.js` +- `frontend/src/stores/events.js` +- `frontend/dev/db-api-connect/migration-log.md` + +### Betroffene Tabellen + +- `event_media` +- `events` +- `users` + +### Betroffene API-Endpunkte + +- `GET /api/events/{event}/media` +- `POST /api/events/{event}/media` +- `DELETE /api/events/{event}/media/{media}` +- `GET /api/event-media/{media}/thumb` +- `GET /api/event-media/{media}/preview` +- `GET /api/event-media/{media}/original` +- `GET /api/events` + +### Umsetzung + +- Remote-Bilder werden auf dem Laravel-Server unter dem privaten `local` Disk gespeichert. +- Uploads werden user- und eventgebunden in `event_media` abgelegt. +- Server erzeugt drei JPEG-Varianten: 320px Thumbnail fuer Timeline/Dots, 900px Preview fuer Event-Panel/Galerie und ein Original mit maximal 3508px laengster Kante fuer A4 bei 300 DPI. +- Key Images verwenden die Collection `key_image`; weitere Bilder verwenden `gallery`. +- Beim Ersetzen eines Key Images wird das alte Key Image inklusive Dateien entfernt. +- Geschuetzte Bildauslieferung prueft den authentifizierten User gegen `event_media.user_id`. +- Event-API liefert Medien-Metadaten mit `thumbnailUrl` und `originalUrl`. +- Frontend-Remote-Uploads gehen direkt an `/api/events/{id}/media`. +- Frontend laedt geschuetzte Bildpfade mit Bearer Token als Blob und verwendet lokale Blob-URLs fuer ``. +- Timeline/Dots nutzen `thumbnailUrl`, Event-Panel und Galerie nutzen `previewUrl`, ein spaeterer Download kann `originalUrl` verwenden. +- Fuer Medien, die vor der Preview-Variante hochgeladen wurden, faellt `/preview` auf das vorhandene Thumbnail zurueck. +- Leere Medienpfade werden beim Loeschen ignoriert, damit Altbestaende ohne Preview-Datei keine 500er ausloesen. +- CORS ist fuer `https://app.thats-me.test` und lokale App-Dev-Origins explizit konfiguriert. +- Lokale Demo-/Browser-Bilder bleiben unveraendert als lokale Data-URLs. + +### Sicherheitsregel + +Alle Medienrouten liegen hinter `auth:api`. Event-Medien werden nur ueber Events des authentifizierten Users angelegt, gelesen oder geloescht. Direkter Zugriff auf fremde `event_media` IDs liefert `404`. + +### Ausgefuehrte Kommandos + +```bash +vendor/bin/pint --dirty --format agent +php artisan optimize:clear +php artisan test --compact tests/Feature/Api/EventMediaTest.php +php artisan migrate --no-interaction +npm run lint +php artisan test --compact tests/Feature/Api/EventMediaTest.php tests/Feature/Api/EventTest.php tests/Feature/Api/AuthTest.php tests/Feature/Api/SettingsTest.php +php artisan migrate:status +``` + +### Testergebnis + +- `tests/Feature/Api/EventMediaTest.php`: 9 Tests, erfolgreich. +- API-Feature-Tests: 33 Tests, 133 Assertions, erfolgreich. +- `npm run lint`: erfolgreich. +- `php artisan migrate:status`: `2026_06_03_131801_create_event_media_table` ist in Batch 2 ausgefuehrt. + +### Manuelle QA + +- Noch nicht im Browser vollstaendig durchgeklickt. +- Erwartete QA: Remote-User einloggen, Key Image hochladen, Galerie-Bild hochladen, Seite neu laden, zweiten Browser mit gleichem User oeffnen, Thumbnail und Original in Galerie pruefen, Bild loeschen und DB/Storage pruefen. + +### Offene Punkte + +- Thumbnail-Groesse und JPEG-Qualitaet sind aktuell feste MVP-Werte. +- Originalbilder werden aktuell mit JPEG-Qualitaet 90 gespeichert. +- Previewbilder werden aktuell mit maximal 900px laengster Kante und JPEG-Qualitaet 84 gespeichert. +- Spaeter koennen Queue-Jobs fuer groessere Medienverarbeitung und weitere Varianten ergaenzt werden. +- Upload-Limit ist aktuell 10 MB pro Bild. + +## 2026-06-03 - Phase 5 Timeline-Settings Remote Speichern + +Version: `0.3.0` + +Status: Abgeschlossen fuer Remote-Settings von API-Usern. + +### Geaenderte Dateien + +- `backend/app/Http/Controllers/Api/SettingsController.php` +- `backend/app/Http/Requests/UpdateSettingsRequest.php` +- `backend/app/Models/User.php` +- `backend/app/Models/UserSetting.php` +- `backend/database/factories/UserSettingFactory.php` +- `backend/database/migrations/2026_06_03_130123_create_user_settings_table.php` +- `backend/routes/api.php` +- `backend/tests/Feature/Api/SettingsTest.php` +- `frontend/src/stores/settings.js` +- `frontend/dev/db-api-connect/migration-log.md` + +### Betroffene Tabellen + +- `user_settings` +- `users` + +### Betroffene API-Endpunkte + +- `GET /api/settings` +- `PUT /api/settings` + +### Umsetzung + +- Pro User wird ein JSON-Dokument in `user_settings.settings` gespeichert. +- `GET /api/settings` liefert die Settings des authentifizierten Users oder `null`. +- `PUT /api/settings` erstellt oder aktualisiert die Settings des authentifizierten Users. +- Frontend-Remote-User laden Settings beim Userwechsel/Login aus der API. +- Frontend-Remote-User speichern Settings per API und halten `localStorage` nur als lokalen Cache. +- Lokale Demo-/Browser-User bleiben auf `localStorage`. +- Gespeichert werden unter anderem Appearance, Accent Color, Sprache, Floating-Lines, Emotion-Gradient, Timeline-Zoom, Timeline-Scroll, Presets, aktives Preset und `showFps`. + +### Sicherheitsregel + +Die Settings-Routen liegen hinter `auth:api`. Der Controller greift ausschliesslich ueber `$request->user()->settings()` auf Daten zu, dadurch sind Settings anderer User weder lesbar noch ueberschreibbar. + +### Ausgefuehrte Kommandos + +```bash +vendor/bin/pint --dirty --format agent +php artisan optimize:clear +php artisan test --compact tests/Feature/Api/SettingsTest.php +php artisan migrate --no-interaction +php artisan migrate:status +npm run lint +php artisan test --compact tests/Feature/Api/SettingsTest.php tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php +``` + +### Testergebnis + +- `tests/Feature/Api/SettingsTest.php`: 5 Tests, 19 Assertions, erfolgreich. +- `tests/Feature/Api/SettingsTest.php tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php`: 24 Tests, 97 Assertions, erfolgreich. +- `npm run lint`: erfolgreich. +- `php artisan migrate:status`: `2026_06_03_130123_create_user_settings_table` ist ausgefuehrt. + +### Manuelle QA + +- Noch nicht im Browser gegen zwei Browserprofile durchgeklickt. +- Erwartete QA: mit API-User einloggen, Appearance/Accent/Floating-Lines/Timeline-Zoom aendern, in zweitem Browser mit gleichem User einloggen und Settings pruefen. + +### Offene Punkte + +- Serverseitige Bilder fuer Remote-User folgen in Phase 6. +- Der lokale `Demo`-User als sauber getrennter Browser-only Modus bleibt fuer Phase 3 noch offen. + +## 2026-06-03 - Phase 2 API-Login Fuer Quasar + +Version: `0.2.0` + +Status: Abgeschlossen fuer Backend-Login, Logout, Token-Ablage im Frontend und Remote-User-Auswahl im Login. + +### Geaenderte Dateien + +- `backend/app/Http/Controllers/Api/AuthController.php` +- `backend/app/Http/Requests/LoginRequest.php` +- `backend/database/seeders/DatabaseSeeder.php` +- `backend/routes/api.php` +- `backend/tests/Feature/Api/AuthTest.php` +- `frontend/src/pages/LoginPage.vue` +- `frontend/package.json` +- `frontend/src/router/index.js` +- `frontend/src/services/apiClient.js` +- `frontend/src/services/syncService.js` +- `frontend/src/stores/auth.js` +- `frontend/dev/db-api-connect/migration-log.md` + +### Betroffene Tabellen + +- `users` +- `oauth_clients` +- `oauth_access_tokens` + +### Betroffene API-Endpunkte + +- `POST /api/login` +- `POST /api/logout` +- `GET /api/user` + +### Umsetzung + +- `POST /api/login` validiert E-Mail und Passwort gegen Laravel-User. +- Erfolgreicher Login erzeugt einen Passport Personal Access Token fuer die Quasar-App. +- Login-Response liefert Token, Token-Typ und Userdaten mit `mode: remote`. +- `POST /api/logout` widerruft den aktuellen Token. +- `DatabaseSeeder` legt zusaetzlich einen Passport Personal-Access-Client fuer den `users` Provider an, falls noch keiner existiert. +- Frontend hat mit `src/services/apiClient.js` einen zentralen API-Client inklusive Token-Ablage in IndexedDB `meta.accessToken`. +- `syncService` nutzt denselben API-Client und vermeidet dadurch doppelte Token-Logik. +- `auth` Store fuehrt `user1@thats-me.app` bis `user6@thats-me.app` als Remote-User. +- Login-Seite nutzt async API-Login, zeigt die sechs API-Accounts und entfernt den sichtbaren User-Anlegen-Button. +- Router erkennt gespeicherte Remote-Auth nach Reload ueber die persistierte Auth-Struktur. +- Frontend-Lint-Script nutzt den funktionierenden `src/**/*.{js,cjs,mjs,vue}` Glob. + +### Sicherheitsregel + +Falsche Zugangsdaten liefern Validierungsfehler ohne Token. Geschuetzte Endpunkte bleiben hinter `auth:api`; Logout widerruft den Bearer Token serverseitig. + +### Ausgefuehrte Kommandos + +```bash +vendor/bin/pint --dirty --format agent +php artisan test --compact tests/Feature/Api/AuthTest.php +php artisan test --compact tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php +php artisan db:seed --class=DatabaseSeeder --no-interaction +npm run lint +``` + +### Testergebnis + +- `tests/Feature/Api/AuthTest.php`: 7 Tests, 43 Assertions, erfolgreich. +- `tests/Feature/Api/AuthTest.php tests/Feature/Api/EventTest.php`: 19 Tests, 78 Assertions, erfolgreich. +- `npm run lint`: erfolgreich. + +### Manuelle QA + +- Browser-Login wurde noch nicht manuell durchgeklickt. +- Der Backend-Seed wurde gegen die lokale Datenbank erneut ausgefuehrt. + +### Offene Punkte + +- Phase 3 fuehrt den lokalen `Demo`-User sauber als getrennten Browser-only Modus ein. +- Phase 4 stellt die Event-Persistenz fuer Remote-User von lokaler Dexie-Fuehrung auf API-Fuehrung um. + +### Nachtrag 2026-06-03 + +- `frontend/src/services/apiClient.js` nutzt bei `app.thats-me.test` ohne explizites `VITE_API_BASE` automatisch `https://api.thats-me.test/api`. +- `frontend/src/services/apiClient.js` nutzt bei Live-Hosts unter `thats-me.app` automatisch `https://api.thats-me.app/api`. +- Damit laufen Login-Requests nicht mehr versehentlich gegen den Quasar-Devserver-Pfad `/api`. +- `VITE_API_BASE=https://api.thats-me.test` und `VITE_API_BASE=https://api.thats-me.app` werden automatisch um `/api` ergaenzt. +- `backend/config/cors.php` erlaubt fuer Live zusaetzlich `https://thats-me.app`, `https://www.thats-me.app` und `https://app.thats-me.app`. +- Im Dev-Modus wird die vollstaendige API-Request-URL mit `API request: ...` in die Browser-Konsole geschrieben. +- `frontend/src/stores/auth.js` loggt Remote-Login-Fehler zusaetzlich in der Browser-Konsole. +- Geprueft mit `npm run lint` und `php artisan test --compact tests/Feature/Api/AuthTest.php`. + +### Nachtrag Remote-Events 2026-06-03 + +- `frontend/src/stores/events.js` laedt Events fuer Remote-User beim Login direkt ueber `GET /api/events`. +- Remote-Create, Remote-Update und Remote-Delete laufen fuer API-User jetzt direkt ueber `POST /api/events`, `PUT /api/events/{id}` und `DELETE /api/events/{id}`. +- IndexedDB bleibt fuer Remote-User nur Cache; fuehrend ist die API. +- Neue Browser laden vorhandene Remote-Events direkt aus der Datenbank. +- Geprueft mit `npm run lint` und `php artisan test --compact tests/Feature/Api/EventTest.php tests/Feature/Api/AuthTest.php`. + +## 2026-06-03 - Phase 1 Backend-Basis + +Version: `0.1.0` + +Status: Abgeschlossen fuer Backend-Basis, Demo-API-User und geschuetzte API-Pruefungen. + +### Geaenderte Dateien + +- `backend/database/seeders/DatabaseSeeder.php` +- `backend/tests/Feature/Api/AuthTest.php` +- `frontend/dev/db-api-connect/migration-log.md` + +### Betroffene Tabellen + +- `users` +- `events` +- `oauth_auth_codes` +- `oauth_access_tokens` +- `oauth_refresh_tokens` +- `oauth_clients` +- `oauth_device_codes` +- `cache` +- `jobs` +- `sessions` +- `password_reset_tokens` + +### Betroffene API-Endpunkte + +- `GET /api/user` +- `GET /api/events` + +### Umsetzung + +- Lokale MySQL-Datenbank `thats-me` wurde angelegt, falls sie noch nicht vorhanden war. +- Alle vorhandenen Backend-Migrationen wurden ausgefuehrt. +- `DatabaseSeeder` wurde idempotent gemacht. +- `user1@thats-me.app` bis `user6@thats-me.app` werden mit Passwort `pass` angelegt oder aktualisiert. +- Der bestehende `test@example.com` bleibt als separater Test-User erhalten und wird nicht als Praesentationsuser verwendet. +- Feature-Tests pruefen geschuetzten API-Zugriff ohne Token, Rueckgabe des authentifizierten Users und die Seeder-User inklusive Passwort. + +### Sicherheitsregel + +Die geschuetzten API-Routen liegen weiterhin hinter `auth:api`. Ohne Token liefern `GET /api/user` und `GET /api/events` einen unauthorisierten Status. Mit Passport-Testauthentifizierung liefert `GET /api/user` nur den authentifizierten User. + +### Ausgefuehrte Kommandos + +```bash +vendor/bin/pint --dirty --format agent +php artisan test --compact tests/Feature/Api/AuthTest.php +php artisan test --compact tests/Feature/Api +php artisan migrate --no-interaction +php artisan db:seed --class=DatabaseSeeder --no-interaction +php artisan migrate:status +``` + +### Testergebnis + +- `tests/Feature/Api/AuthTest.php`: 4 Tests, 24 Assertions, erfolgreich. +- `tests/Feature/Api`: 16 Tests, 59 Assertions, erfolgreich. +- `php artisan migrate:status`: alle vorhandenen Migrationen in Batch 1 ausgefuehrt. + +### Manuelle QA + +- Nicht im Browser geprueft, da Phase 1 noch keine Frontend-Anbindung enthaelt. +- CORS/API-Erreichbarkeit von `app.thats-me.test` nach `api.thats-me.test` bleibt fuer Phase 2/3 offen, sobald die Login-Route und der Frontend-Client angebunden werden. + +### Offene Punkte + +- Echte Login-/Logout-Endpunkte fuer Quasar folgen in Phase 2. +- Token-Erzeugung fuer das Frontend wird in Phase 2 finalisiert. +- Frontend-Demo-Modus und Remote-User-Auswahl folgen in Phase 3. diff --git a/frontend/package.json b/frontend/package.json index 91ed7f8..762bf83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,7 @@ "type": "module", "private": true, "scripts": { - "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", "test": "echo \"No test specified\" && exit 0", "dev": "npm install && quasar dev", diff --git a/frontend/src/components/EventPanel.vue b/frontend/src/components/EventPanel.vue index 5f57d38..2024cd3 100644 --- a/frontend/src/components/EventPanel.vue +++ b/frontend/src/components/EventPanel.vue @@ -183,7 +183,7 @@ :key="item.id" class="event-panel__media-item" > - + @@ -485,7 +485,7 @@ const selectedPresetId = computed({ }) const isCustomBackground = computed(() => { const bg = fl.value.backgroundImage ?? '' - return bg.startsWith('data:image/') + return bg.startsWith('data:image/') || bg.startsWith('/settings/media/') }) const HORIZON_MODES = [ @@ -576,6 +576,11 @@ async function onBackgroundUpload(event) { if (!file) return try { + if (settingsStore.uploadBackgroundImage && settingsStore.floatingLines.backgroundImage !== undefined) { + const uploaded = await settingsStore.uploadBackgroundImage(file) + if (uploaded) return + } + const sourceDataUrl = await readImageAsDataUrl(file) const optimized = await optimizeImageDataUrl(sourceDataUrl) update({ backgroundImage: optimized }) diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index 2fe9b08..5480c19 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -96,7 +96,7 @@
{{ authStore.currentUser?.name ?? 'Demo User' }}
-
Demo Account
+
Demo Account · v{{ APP_VERSION }}
@@ -106,6 +106,7 @@ @@ -135,6 +159,16 @@ function onSubmit() { margin-bottom: 16px; } +.login-user-row { + display: flex; + align-items: flex-end; + gap: 10px; +} + +.login-field--user { + flex: 1; +} + .login-field span { display: block; margin: 0 0 6px 2px; diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 61939f8..bdde51d 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,7 +1,7 @@ import { defineRouter } from '#q-app/wrappers' import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router' import routes from './routes' -import { AUTH_STORAGE_KEY, DEMO_USERS } from 'stores/auth' +import { hasStoredAuth } from 'stores/auth' /* * If not building with SSR mode, you can @@ -28,16 +28,7 @@ export default defineRouter(function (/* { store, ssrContext } */) { }) Router.beforeEach((to) => { - const stored = localStorage.getItem(AUTH_STORAGE_KEY) - let userId = null - - try { - userId = stored ? JSON.parse(stored)?.userId ?? null : null - } catch { - userId = null - } - - const isAuthenticated = DEMO_USERS.some(user => user.id === userId) + const isAuthenticated = hasStoredAuth() if (to.meta.requiresAuth && !isAuthenticated) { return { path: '/login', query: { redirect: to.fullPath } } diff --git a/frontend/src/services/apiClient.js b/frontend/src/services/apiClient.js new file mode 100644 index 0000000..c989057 --- /dev/null +++ b/frontend/src/services/apiClient.js @@ -0,0 +1,136 @@ +import { db } from 'src/db' + +function normalizeApiBase(base) { + const trimmedBase = String(base).replace(/\/+$/, '') + + if (/^https?:\/\/api\.thats-me\.(app|test)$/i.test(trimmedBase)) { + return `${trimmedBase}/api` + } + + return trimmedBase +} + +function resolveApiBase() { + if (import.meta.env.VITE_API_BASE) { + return normalizeApiBase(import.meta.env.VITE_API_BASE) + } + + if (typeof window !== 'undefined' && window.location.hostname === 'app.thats-me.test') { + return normalizeApiBase('https://api.thats-me.test') + } + + if (typeof window !== 'undefined' && window.location.hostname.endsWith('thats-me.app')) { + return normalizeApiBase('https://api.thats-me.app') + } + + if (import.meta.env.PROD) { + return normalizeApiBase('https://api.thats-me.app') + } + + return normalizeApiBase('/api') +} + +export const API_BASE = resolveApiBase() + +export class ApiError extends Error { + constructor(message, { status = 0, errors = null } = {}) { + super(message) + this.name = 'ApiError' + this.status = status + this.errors = errors + } +} + +export async function getToken() { + try { + const meta = await db.meta.get('accessToken') + return meta?.value || null + } catch { + return null + } +} + +export async function setToken(token) { + if (!token) { + await clearToken() + return + } + + await db.meta.put({ key: 'accessToken', value: token }) +} + +export async function clearToken() { + await db.meta.delete('accessToken') +} + +async function readResponsePayload(response) { + if (response.status === 204) return null + + const text = await response.text() + if (!text) return null + + try { + return JSON.parse(text) + } catch { + return text + } +} + +export async function apiFetch(path, options = {}) { + const { auth = true, body, headers = {}, ...fetchOptions } = options + const token = auth ? await getToken() : null + + if (auth && !token) { + throw new ApiError('Nicht angemeldet.', { status: 401 }) + } + + const isFormData = typeof FormData !== 'undefined' && body instanceof FormData + const requestUrl = `${API_BASE}${path}` + + if (import.meta.env.DEV) { + console.debug('API request:', requestUrl) + } + + const response = await fetch(requestUrl, { + ...fetchOptions, + body: body && !isFormData && typeof body !== 'string' ? JSON.stringify(body) : body, + headers: { + Accept: 'application/json', + ...(!isFormData ? { 'Content-Type': 'application/json' } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers + } + }) + + if (response.status === 401 && auth) { + await clearToken() + } + + return response +} + +export async function apiJson(path, options = {}) { + const response = await apiFetch(path, options) + const payload = await readResponsePayload(response) + + if (!response.ok) { + throw new ApiError(payload?.message || 'Die API-Anfrage ist fehlgeschlagen.', { + status: response.status, + errors: payload?.errors ?? null + }) + } + + return payload +} + +export async function loginRemote(email, password) { + return apiJson('/login', { + method: 'POST', + auth: false, + body: { email, password } + }) +} + +export async function logoutRemote() { + return apiJson('/logout', { method: 'POST' }) +} diff --git a/frontend/src/services/syncService.js b/frontend/src/services/syncService.js index b3e0f36..7620486 100644 --- a/frontend/src/services/syncService.js +++ b/frontend/src/services/syncService.js @@ -1,8 +1,6 @@ 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' +import { apiFetch, getToken, setToken } from 'src/services/apiClient' const isSyncing = ref(false) const isOnline = ref(navigator.onLine) @@ -17,51 +15,6 @@ 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. diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 03de3ab..cd1e035 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -1,41 +1,98 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import { clearToken, loginRemote, logoutRemote, setToken } from 'src/services/apiClient' export const AUTH_STORAGE_KEY = 'thatsme-auth' -export const DEMO_USERS = Array.from({ length: 5 }, (_, index) => { +export const LOCAL_DEMO_USER = { + id: 'local-demo', + email: 'demo@local', + password: '', + name: 'Demo', + avatar: 'D', + mode: 'local', + seedDemoEvents: false +} + +export const REMOTE_USERS = Array.from({ length: 6 }, (_, index) => { const number = index + 1 return { - id: `demo-user-${number}`, + id: `remote-user-${number}`, email: `user${number}@thats-me.app`, - password: 'pass', name: `User ${number}`, - avatar: `U${number}` + avatar: `U${number}`, + mode: 'remote', + seedDemoEvents: false } }) -function loadStoredUserId() { +function normalizeUser(user) { + const mode = user.mode || 'local' + + return { + id: String(user.id), + email: String(user.email).trim().toLowerCase(), + password: mode === 'local' ? '' : String(user.password || 'pass'), + name: String(user.name || user.email), + avatar: String(user.avatar || '?').slice(0, 3).toUpperCase(), + mode, + seedDemoEvents: false + } +} + +function normalizeRemoteUser(user) { + return { + id: String(user.id), + email: String(user.email).trim().toLowerCase(), + name: String(user.name || user.email), + avatar: String(user.avatar || '?').slice(0, 3).toUpperCase(), + mode: 'remote', + seedDemoEvents: false + } +} + +export function getAvailableUsers() { + return [normalizeUser(LOCAL_DEMO_USER), ...REMOTE_USERS] +} + +function loadStoredAuth() { try { const stored = localStorage.getItem(AUTH_STORAGE_KEY) - return stored ? JSON.parse(stored)?.userId ?? null : null + return stored ? JSON.parse(stored) : null } catch { return null } } +export function hasStoredAuth() { + const stored = loadStoredAuth() + return Boolean(stored?.user || stored?.userId) +} + +function persistAuth(user) { + localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ + userId: user.id, + mode: user.mode, + user + })) +} + export const useAuthStore = defineStore('auth', () => { - const currentUserId = ref(loadStoredUserId()) + const storedAuth = loadStoredAuth() + const currentUserId = ref(storedAuth?.user?.id ?? storedAuth?.userId ?? null) + const currentUserProfile = ref(storedAuth?.user ? normalizeUser(storedAuth.user) : null) const lastError = ref('') + const users = ref(getAvailableUsers()) const currentUser = computed(() => - DEMO_USERS.find(user => user.id === currentUserId.value) ?? null + currentUserProfile.value ?? users.value.find(user => user.id === currentUserId.value) ?? null ) const isAuthenticated = computed(() => currentUser.value !== null) - function login(email, password) { + async function login(email, password) { const normalizedEmail = String(email).trim().toLowerCase() - const user = DEMO_USERS.find(candidate => - candidate.email === normalizedEmail && candidate.password === password + const user = users.value.find(candidate => + candidate.email === normalizedEmail ) if (!user) { @@ -43,20 +100,61 @@ export const useAuthStore = defineStore('auth', () => { return false } + if (user.mode === 'remote') { + try { + const data = await loginRemote(normalizedEmail, password) + const remoteUser = normalizeRemoteUser(data.user) + + await setToken(data.token) + currentUserId.value = remoteUser.id + currentUserProfile.value = remoteUser + lastError.value = '' + persistAuth(remoteUser) + + return true + } catch (error) { + console.warn('Remote login failed:', error) + lastError.value = error?.status === 422 + ? 'E-Mail oder Passwort ist falsch.' + : 'Login über die API ist gerade nicht möglich.' + return false + } + } + + if (user.mode === 'local') { + currentUserId.value = user.id + currentUserProfile.value = user + lastError.value = '' + persistAuth(user) + return true + } + + if (user.password !== password) { + lastError.value = 'E-Mail oder Passwort ist falsch.' + return false + } + currentUserId.value = user.id + currentUserProfile.value = user lastError.value = '' - localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ userId: user.id })) + persistAuth(user) return true } function logout() { + if (currentUser.value?.mode === 'remote') { + logoutRemote().catch(() => {}) + } + currentUserId.value = null + currentUserProfile.value = null lastError.value = '' localStorage.removeItem(AUTH_STORAGE_KEY) + clearToken().catch(() => {}) } return { - users: DEMO_USERS, + users, currentUserId, currentUser, isAuthenticated, diff --git a/frontend/src/stores/events.js b/frontend/src/stores/events.js index 935b00d..302b73b 100644 --- a/frontend/src/stores/events.js +++ b/frontend/src/stores/events.js @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref, computed, watch } from 'vue' import Dexie from 'dexie' import { db } from 'src/db' +import { apiJson } from 'src/services/apiClient' import { startAutoSync, getToken } from 'src/services/syncService' import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings' import { useAuthStore } from 'stores/auth' @@ -28,6 +29,14 @@ function emotionToColor(emotion, gradientStartColor = null, gradientEndColor = n return lerpColor(start, end, t) } +function todayLocalDate() { + const now = new Date() + const year = now.getFullYear() + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + // Demo seed data const demoEvents = [ { id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', location: '', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }, @@ -154,8 +163,179 @@ export const useEventsStore = defineStore('events', () => { const AUTOSAVE_DELAY_MS = 300 let persistTimer = null let skipNextPersist = false + const pendingRemoteCreates = new Map() - // Load events from IndexedDB; seed demo data on first launch + const isRemoteUser = computed(() => authStore.currentUser?.mode === 'remote') + + function normalizeRemoteEvent(event) { + const media = Array.isArray(event.media) ? event.media : [] + const keyImage = media.find(item => item.collection === 'key_image') + + return { + id: event.id, + userId: authStore.currentUserId, + title: event.title, + date: event.date, + location: event.location ?? '', + emotion: event.emotion, + customColor: event.customColor ?? null, + gradientPreset: event.gradientPreset ?? null, + gradientStartColor: event.gradientStartColor ?? null, + gradientEndColor: event.gradientEndColor ?? null, + image: keyImage?.thumbnailUrl ?? event.image ?? null, + keyImagePreviewUrl: keyImage?.previewUrl ?? null, + keyImageOriginalUrl: keyImage?.originalUrl ?? null, + keyImageTitle: event.keyImageTitle ?? '', + media, + note: event.note ?? '', + syncStatus: 'synced', + createdAt: event.createdAt ?? Date.now(), + updatedAt: event.updatedAt ?? Date.now() + } + } + + function eventPayload(event) { + return { + id: event.id, + title: event.title, + date: event.date, + emotion: event.emotion, + customColor: event.customColor, + gradientPreset: event.gradientPreset, + image: event.image, + note: event.note + } + } + + async function loadRemoteEvents() { + const remoteEvents = [] + let nextUrl = '/events?limit=200' + + while (nextUrl) { + const payload = await apiJson(nextUrl) + remoteEvents.push(...(payload.data || []).map(normalizeRemoteEvent)) + if (payload.links?.next) { + const url = new URL(payload.links.next) + nextUrl = `${url.pathname.replace(/^\/api/, '')}${url.search}` + } else { + nextUrl = null + } + } + + events.value = remoteEvents + await db.events + .where('[userId+date]') + .between([authStore.currentUserId, Dexie.minKey], [authStore.currentUserId, Dexie.maxKey]) + .delete() + + if (remoteEvents.length > 0) { + await db.events.bulkPut(remoteEvents) + } + } + + function createRemoteEvent(event) { + const request = apiJson('/events', { + method: 'POST', + body: eventPayload(event) + }) + .then((payload) => { + const syncedEvent = normalizeRemoteEvent(payload.data) + const idx = events.value.findIndex(item => item.id === syncedEvent.id) + if (idx !== -1) { + events.value[idx] = { + ...events.value[idx], + ...syncedEvent + } + } + dbPut(syncedEvent) + }) + .catch((error) => { + console.warn('Remote event create failed:', error) + const idx = events.value.findIndex(item => item.id === event.id) + if (idx !== -1) { + events.value[idx] = { ...events.value[idx], syncStatus: 'error' } + } + }) + .finally(() => { + pendingRemoteCreates.delete(event.id) + }) + + pendingRemoteCreates.set(event.id, request) + } + + async function updateRemoteEvent(event) { + const pendingCreate = pendingRemoteCreates.get(event.id) + if (pendingCreate) { + await pendingCreate + } + + try { + const payload = await apiJson(`/events/${event.id}`, { + method: 'PUT', + body: eventPayload(event) + }) + const syncedEvent = normalizeRemoteEvent(payload.data) + const idx = events.value.findIndex(item => item.id === syncedEvent.id) + if (idx !== -1) { + events.value[idx] = { + ...events.value[idx], + ...syncedEvent + } + } + dbPut(syncedEvent) + } catch (error) { + console.warn('Remote event update failed:', error) + const idx = events.value.findIndex(item => item.id === event.id) + if (idx !== -1) { + events.value[idx] = { ...events.value[idx], syncStatus: 'error' } + } + } + } + + async function deleteRemoteEvent(id) { + const pendingCreate = pendingRemoteCreates.get(id) + if (pendingCreate) { + await pendingCreate + } + + try { + await apiJson(`/events/${id}`, { method: 'DELETE' }) + } catch (error) { + console.warn('Remote event delete failed:', error) + } + } + + async function uploadRemoteMedia(file, collection = 'gallery') { + if (!editingEventId.value) return null + + const pendingCreate = pendingRemoteCreates.get(editingEventId.value) + if (pendingCreate) { + await pendingCreate + } + + const formData = new FormData() + formData.append('collection', collection) + formData.append('file', file) + + const payload = await apiJson(`/events/${editingEventId.value}/media`, { + method: 'POST', + body: formData + }) + + return payload.data + } + + async function deleteRemoteMedia(mediaId) { + if (!isRemoteUser.value || !editingEventId.value || !mediaId) return + + try { + await apiJson(`/events/${editingEventId.value}/media/${mediaId}`, { method: 'DELETE' }) + } catch (error) { + console.warn('Remote media delete failed:', error) + } + } + + // Load local Demo events from IndexedDB; Remote users load from the API. async function init() { const userId = authStore.currentUserId if (!userId) { @@ -166,22 +346,20 @@ export const useEventsStore = defineStore('events', () => { isLoaded.value = false try { - let stored = await db.events + if (isRemoteUser.value) { + await loadRemoteEvents() + isLoaded.value = true + return + } + + const stored = await db.events .where('[userId+date]') .between([userId, Dexie.minKey], [userId, Dexie.maxKey]) .toArray() - if (stored.length === 0) { - const seed = generateManyEvents(500).map(event => ({ - ...event, - userId - })) - await db.events.bulkPut(seed) - stored = seed - } events.value = stored } catch (e) { - console.warn('Dexie load failed, using demo data:', e) - events.value = [...demoEvents] + console.warn('Dexie load failed:', e) + events.value = [] } isLoaded.value = true @@ -197,26 +375,11 @@ export const useEventsStore = defineStore('events', () => { } function dbDelete(id) { - db.events.delete(id).catch(e => console.warn('Dexie delete failed:', e)) - } - - function dbQueueSync(eventId, action, payload) { - const userId = authStore.currentUserId - if (!userId) return - - const queue = async () => { - if (action === 'update') { - await db.syncQueue - .where('eventId') - .equals(eventId) - .and(item => item.userId === userId && item.action === 'update') - .delete() - } - - await db.syncQueue.add({ userId, eventId, action, payload, createdAt: Date.now() }) + const remove = async () => { + await db.events.delete(id) + await db.eventMedia.where('eventId').equals(id).delete() } - - queue().catch(e => console.warn('Dexie sync queue failed:', e)) + remove().catch(e => console.warn('Dexie delete failed:', e)) } function cloneMedia(media) { @@ -226,11 +389,16 @@ export const useEventsStore = defineStore('events', () => { } function mediaMeta(media) { - return cloneMedia(media).map(({ id, type, name, createdAt }) => ({ - id, - type, - name, - createdAt + return cloneMedia(media).map((item) => ({ + id: item.id, + uuid: item.uuid, + type: item.type, + collection: item.collection, + name: item.name, + src: item.src, + thumbnailUrl: item.thumbnailUrl, + originalUrl: item.originalUrl, + createdAt: item.createdAt })) } @@ -301,10 +469,40 @@ export const useEventsStore = defineStore('events', () => { selectedEventId.value = id } + function createEvent() { + const now = Date.now() + const newEvent = { + id: crypto.randomUUID(), + userId: authStore.currentUserId, + title: 'Neues Event', + date: todayLocalDate(), + location: '', + emotion: 0, + customColor: null, + gradientPreset: null, + image: null, + keyImageTitle: '', + media: [], + note: '', + syncStatus: 'local', + createdAt: now, + updatedAt: now + } + + events.value.push(newEvent) + dbPut(newEvent) + if (isRemoteUser.value) { + createRemoteEvent(newEvent) + } + return newEvent + } + function openPanel(eventId = null) { - if (eventId) { - editingEventId.value = eventId - const event = events.value.find((e) => e.id === eventId) + const panelEventId = eventId || createEvent().id + + if (panelEventId) { + editingEventId.value = panelEventId + const event = events.value.find((e) => e.id === panelEventId) if (event) { skipNextPersist = true ghostTitle.value = event.title @@ -312,22 +510,11 @@ export const useEventsStore = defineStore('events', () => { ghostLocation.value = event.location || '' ghostEmotion.value = event.emotion ghostCustomColor.value = event.customColor - ghostImage.value = event.image || null + ghostImage.value = event.keyImagePreviewUrl || event.image || null ghostKeyImageTitle.value = event.keyImageTitle || '' loadEventMedia(event) ghostNote.value = event.note } - } else { - editingEventId.value = null - ghostTitle.value = '' - ghostDate.value = new Date().toISOString().slice(0, 10) - ghostLocation.value = '' - ghostEmotion.value = 0 - ghostCustomColor.value = null - ghostImage.value = null - ghostKeyImageTitle.value = '' - ghostMedia.value = [] - ghostNote.value = '' } panelOpen.value = true } @@ -356,7 +543,9 @@ export const useEventsStore = defineStore('events', () => { events.value[idx] = updated dbPut(updated) persistEventMedia(updated.id, updated.userId, ghostMedia.value) - dbQueueSync(updated.id, 'update', { ...updated }) + if (isRemoteUser.value) { + updateRemoteEvent(updated) + } } function schedulePersistToEvent() { @@ -397,29 +586,6 @@ export const useEventsStore = defineStore('events', () => { function closePanel() { flushPersistToEvent() - if (!editingEventId.value && ghostTitle.value.trim()) { - const newEvent = { - id: crypto.randomUUID(), - userId: authStore.currentUserId, - title: ghostTitle.value, - date: ghostDate.value, - location: ghostLocation.value, - emotion: ghostEmotion.value, - customColor: ghostCustomColor.value, - gradientPreset: null, - image: ghostImage.value, - keyImageTitle: ghostKeyImageTitle.value, - media: mediaMeta(ghostMedia.value), - note: ghostNote.value, - syncStatus: 'local', - createdAt: Date.now(), - updatedAt: Date.now() - } - events.value.push(newEvent) - dbPut(newEvent) - persistEventMedia(newEvent.id, newEvent.userId, ghostMedia.value) - dbQueueSync(newEvent.id, 'create', { ...newEvent }) - } panelOpen.value = false editingEventId.value = null selectedEventId.value = null @@ -432,10 +598,105 @@ export const useEventsStore = defineStore('events', () => { } events.value = events.value.filter((e) => e.id !== id) dbDelete(id) - dbQueueSync(id, 'delete', null) + if (isRemoteUser.value) { + deleteRemoteEvent(id) + } closePanel() } + async function uploadGhostKeyImage(file) { + if (!isRemoteUser.value) return null + + const media = await uploadRemoteMedia(file, 'key_image') + if (!media) return null + + ghostImage.value = media.originalUrl + if (!ghostKeyImageTitle.value) { + ghostKeyImageTitle.value = 'Key Image' + } + + const idx = events.value.findIndex((event) => event.id === editingEventId.value) + if (idx !== -1) { + const nextMedia = [ + ...(events.value[idx].media || []).filter(item => item.collection !== 'key_image'), + media + ] + events.value[idx] = { + ...events.value[idx], + image: media.thumbnailUrl, + keyImagePreviewUrl: media.previewUrl, + keyImageOriginalUrl: media.originalUrl, + media: nextMedia, + updatedAt: Date.now() + } + dbPut(events.value[idx]) + } + + return media + } + + async function uploadGhostMedia(files) { + if (!isRemoteUser.value) return [] + + const uploaded = [] + for (const file of files) { + const media = await uploadRemoteMedia(file, 'gallery') + if (media) uploaded.push(media) + } + + if (uploaded.length > 0) { + ghostMedia.value = [...ghostMedia.value, ...uploaded] + const idx = events.value.findIndex((event) => event.id === editingEventId.value) + if (idx !== -1) { + events.value[idx] = { + ...events.value[idx], + media: [...(events.value[idx].media || []), ...uploaded], + updatedAt: Date.now() + } + dbPut(events.value[idx]) + } + } + + return uploaded + } + + async function deleteGhostMedia(mediaId) { + await deleteRemoteMedia(mediaId) + + const idx = events.value.findIndex((event) => event.id === editingEventId.value) + if (idx !== -1) { + events.value[idx] = { + ...events.value[idx], + media: (events.value[idx].media || []).filter(item => item.id !== mediaId), + updatedAt: Date.now() + } + dbPut(events.value[idx]) + } + } + + async function deleteGhostKeyImage() { + const idx = events.value.findIndex((event) => event.id === editingEventId.value) + const keyImage = idx !== -1 + ? (events.value[idx].media || []).find(item => item.collection === 'key_image') + : null + + if (keyImage?.id) { + await deleteRemoteMedia(keyImage.id) + } + + if (idx !== -1) { + events.value[idx] = { + ...events.value[idx], + image: null, + keyImagePreviewUrl: null, + keyImageOriginalUrl: null, + media: (events.value[idx].media || []).filter(item => item.collection !== 'key_image'), + updatedAt: Date.now() + } + dbPut(events.value[idx]) + } + } + function getGlowColor(event) { if (event.customColor) return event.customColor return emotionToColor( @@ -478,8 +739,13 @@ export const useEventsStore = defineStore('events', () => { sortedEvents, selectEvent, openPanel, + createEvent, closePanel, deleteEvent, + uploadGhostKeyImage, + uploadGhostMedia, + deleteGhostMedia, + deleteGhostKeyImage, saveGhostNow, getGlowColor } diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js index 74eaaa1..f219222 100644 --- a/frontend/src/stores/settings.js +++ b/frontend/src/stores/settings.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import { ref, watch } from 'vue' +import { apiJson } from 'src/services/apiClient' import { useAuthStore } from 'stores/auth' const STORAGE_KEY_PREFIX = 'thatsme-settings' @@ -126,6 +127,7 @@ export const useSettingsStore = defineStore('settings', () => { const initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId) const initialSettings = initialActivePreset?.settings ?? stored let persistTimer = null + let isApplyingSettings = false const theme = ref(initialSettings?.theme ?? 'light') const floatingLines = ref({ @@ -161,6 +163,19 @@ export const useSettingsStore = defineStore('settings', () => { } } + function createStoredSettings() { + return { + ...createSnapshot(), + timelineScrollLeft: timelineScrollLeft.value, + presets: presets.value, + activePresetId: activePresetId.value + } + } + + function isRemoteUser() { + return authStore.currentUser?.mode === 'remote' + } + function applySnapshot(snapshot) { theme.value = snapshot?.theme ?? 'light' floatingLines.value = { @@ -182,37 +197,59 @@ export const useSettingsStore = defineStore('settings', () => { persistTimer = null } - if (!authStore.currentUserId) return + if (!authStore.currentUserId || isApplyingSettings) return - localStorage.setItem( - getStorageKey(authStore.currentUserId), - JSON.stringify({ - ...createSnapshot(), - timelineScrollLeft: timelineScrollLeft.value, - presets: presets.value, - activePresetId: activePresetId.value - }) - ) + const storedSettings = createStoredSettings() + + localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings)) + + if (isRemoteUser()) { + apiJson('/settings', { + method: 'PUT', + body: { settings: storedSettings } + }).catch(error => console.warn('Remote settings persist failed:', error)) + } } function schedulePersist() { + if (isApplyingSettings) return if (persistTimer) clearTimeout(persistTimer) persistTimer = setTimeout(persist, PERSIST_DELAY_MS) } - function applyStoredSettingsForUser(userId) { + async function loadRemoteSettings() { + const payload = await apiJson('/settings') + return payload.data ?? null + } + + async function applyStoredSettingsForUser(userId) { if (persistTimer) { clearTimeout(persistTimer) persistTimer = null } - const nextStored = loadFromStorage(userId) + isApplyingSettings = true + + let nextStored = loadFromStorage(userId) + if (userId && isRemoteUser()) { + try { + nextStored = await loadRemoteSettings() + if (nextStored) { + localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored)) + } + } catch (error) { + console.warn('Remote settings load failed:', error) + } + } + presets.value = nextStored?.presets ?? [] activePresetId.value = nextStored?.activePresetId ?? null const activePreset = presets.value.find(preset => preset.id === activePresetId.value) applySnapshot(activePreset?.settings ?? nextStored) timelineScrollLeft.value = nextStored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT + + isApplyingSettings = false } watch([theme, floatingLines, appearance, accentColor, language, emotionGradientStart, emotionGradientEnd, timelineZoom, timelineScrollLeft, showFps, presets, activePresetId], schedulePersist, { deep: true }) @@ -234,6 +271,37 @@ export const useSettingsStore = defineStore('settings', () => { floatingLines.value = { ...floatingLines.value, ...updates } } + async function uploadBackgroundImage(file) { + if (!isRemoteUser()) return null + + const formData = new FormData() + formData.append('file', file) + + const payload = await apiJson('/settings/media/background', { + method: 'POST', + body: formData + }) + const url = payload?.data?.url + + if (url) { + updateFloatingLines({ backgroundImage: url }) + } + + return payload?.data ?? null + } + + async function clearBackgroundImage() { + updateFloatingLines({ backgroundImage: '' }) + + if (!isRemoteUser()) return + + try { + await apiJson('/settings/media/background', { method: 'DELETE' }) + } catch (error) { + console.warn('Remote settings background delete failed:', error) + } + } + function resetFloatingLines() { floatingLines.value = { ...FLOATING_LINES_DEFAULTS } } @@ -280,6 +348,8 @@ export const useSettingsStore = defineStore('settings', () => { return true } + applyStoredSettingsForUser(authStore.currentUserId) + return { theme, floatingLines, @@ -298,6 +368,8 @@ export const useSettingsStore = defineStore('settings', () => { resetFloatingLines, resetEmotionGradient, saveTimelineScrollLeft, + uploadBackgroundImage, + clearBackgroundImage, savePreset, applyPreset }