APP als Hybrid Version - Anbindung an API
This commit is contained in:
parent
d054732bf5
commit
c1514999be
46 changed files with 3418 additions and 196 deletions
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
Eine kurze Beschreibung, was dieses Projekt macht (ein oder zwei Sätze).
|
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
|
## Domains auf dem Testserver
|
||||||
|
|
||||||
app.thats-me.test = frontend Quasar APP
|
app.thats-me.test = frontend Quasar APP
|
||||||
|
|
|
||||||
54
backend/app/Http/Controllers/Api/AuthController.php
Normal file
54
backend/app/Http/Controllers/Api/AuthController.php
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\LoginRequest;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
public function login(LoginRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$credentials = $request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ class EventController extends Controller
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
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
|
// Delta sync: only events updated since a given timestamp
|
||||||
if ($request->has('since')) {
|
if ($request->has('since')) {
|
||||||
|
|
@ -50,7 +50,7 @@ class EventController extends Controller
|
||||||
'note' => $request->validated('note'),
|
'note' => $request->validated('note'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (new EventResource($event))
|
return (new EventResource($event->load('media')))
|
||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +64,7 @@ class EventController extends Controller
|
||||||
->where('client_id', $clientId)
|
->where('client_id', $clientId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
return new EventResource($event);
|
return new EventResource($event->load('media'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -103,7 +103,7 @@ class EventController extends Controller
|
||||||
|
|
||||||
$event->update($data);
|
$event->update($data);
|
||||||
|
|
||||||
return new EventResource($event->fresh());
|
return new EventResource($event->fresh()->load('media'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
138
backend/app/Http/Controllers/Api/EventMediaController.php
Normal file
138
backend/app/Http/Controllers/Api/EventMediaController.php
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreEventMediaRequest;
|
||||||
|
use App\Http\Resources\EventMediaResource;
|
||||||
|
use App\Models\EventMedia;
|
||||||
|
use App\Services\EventMediaImageProcessor;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class EventMediaController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, string $clientId): JsonResponse
|
||||||
|
{
|
||||||
|
$event = $request->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,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/app/Http/Controllers/Api/SettingsController.php
Normal file
32
backend/app/Http/Controllers/Api/SettingsController.php
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\UpdateSettingsRequest;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class SettingsController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
backend/app/Http/Controllers/Api/SettingsMediaController.php
Normal file
97
backend/app/Http/Controllers/Api/SettingsMediaController.php
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreSettingsMediaRequest;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class SettingsMediaController extends Controller
|
||||||
|
{
|
||||||
|
private const BACKGROUND_MAX_SIDE = 1600;
|
||||||
|
|
||||||
|
private const BACKGROUND_QUALITY = 86;
|
||||||
|
|
||||||
|
public function store(StoreSettingsMediaRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$processed = $this->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/app/Http/Requests/LoginRequest.php
Normal file
26
backend/app/Http/Requests/LoginRequest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/app/Http/Requests/StoreEventMediaRequest.php
Normal file
26
backend/app/Http/Requests/StoreEventMediaRequest.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreEventMediaRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
|
||||||
|
'collection' => ['nullable', 'in:key_image,gallery'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/app/Http/Requests/StoreSettingsMediaRequest.php
Normal file
23
backend/app/Http/Requests/StoreSettingsMediaRequest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreSettingsMediaRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/app/Http/Requests/UpdateSettingsRequest.php
Normal file
37
backend/app/Http/Requests/UpdateSettingsRequest.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateSettingsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/app/Http/Resources/EventMediaResource.php
Normal file
33
backend/app/Http/Resources/EventMediaResource.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class EventMediaResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ class EventResource extends JsonResource
|
||||||
'gradientPreset' => $this->gradient_preset,
|
'gradientPreset' => $this->gradient_preset,
|
||||||
'image' => $this->image,
|
'image' => $this->image,
|
||||||
'note' => $this->note ?? '',
|
'note' => $this->note ?? '',
|
||||||
|
'media' => EventMediaResource::collection($this->whenLoaded('media')),
|
||||||
'syncStatus' => 'synced',
|
'syncStatus' => 'synced',
|
||||||
'createdAt' => $this->created_at->getTimestampMs(),
|
'createdAt' => $this->created_at->getTimestampMs(),
|
||||||
'updatedAt' => $this->updated_at->getTimestampMs(),
|
'updatedAt' => $this->updated_at->getTimestampMs(),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Models;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Event extends Model
|
class Event extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -35,4 +36,9 @@ class Event extends Model
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function media(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EventMedia::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
backend/app/Models/EventMedia.php
Normal file
66
backend/app/Models/EventMedia.php
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class EventMedia extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\EventMediaFactory> */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ namespace App\Models;
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
@ -57,6 +58,11 @@ class User extends Authenticatable
|
||||||
return $this->hasMany(Event::class);
|
return $this->hasMany(Event::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function settings(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(UserSetting::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function initials(): string
|
public function initials(): string
|
||||||
{
|
{
|
||||||
return Str::of($this->name)
|
return Str::of($this->name)
|
||||||
|
|
|
||||||
29
backend/app/Models/UserSetting.php
Normal file
29
backend/app/Models/UserSetting.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserSetting extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserSettingFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'settings' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
backend/app/Services/EventMediaImageProcessor.php
Normal file
125
backend/app/Services/EventMediaImageProcessor.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class EventMediaImageProcessor
|
||||||
|
{
|
||||||
|
private const MAX_ORIGINAL_SIDE = 3508;
|
||||||
|
|
||||||
|
private const PREVIEW_MAX_SIDE = 900;
|
||||||
|
|
||||||
|
private const THUMBNAIL_SIZE = 320;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* original: string,
|
||||||
|
* preview: string,
|
||||||
|
* thumbnail: string,
|
||||||
|
* width: int,
|
||||||
|
* height: int,
|
||||||
|
* preview_width: int,
|
||||||
|
* preview_height: int,
|
||||||
|
* thumbnail_width: int,
|
||||||
|
* thumbnail_height: int,
|
||||||
|
* mime_type: string,
|
||||||
|
* size: int
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function process(UploadedFile $file): array
|
||||||
|
{
|
||||||
|
$source = imagecreatefromstring((string) file_get_contents($file->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/config/cors.php
Normal file
27
backend/config/cors.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'paths' => ['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,
|
||||||
|
];
|
||||||
44
backend/database/factories/EventMediaFactory.php
Normal file
44
backend/database/factories/EventMediaFactory.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EventMedia>
|
||||||
|
*/
|
||||||
|
class EventMediaFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/database/factories/UserSettingFactory.php
Normal file
33
backend/database/factories/UserSettingFactory.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserSetting>
|
||||||
|
*/
|
||||||
|
class UserSettingFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'settings' => [
|
||||||
|
'appearance' => 'system',
|
||||||
|
'accentColor' => 'base',
|
||||||
|
'language' => 'de',
|
||||||
|
'timelineZoom' => 1,
|
||||||
|
'timelineScrollLeft' => null,
|
||||||
|
'presets' => [],
|
||||||
|
'activePresetId' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_settings', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
|
||||||
|
$table->json('settings');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('event_media', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('event_media', function (Blueprint $table) {
|
||||||
|
if (! Schema::hasColumn('event_media', 'preview_path')) {
|
||||||
|
$table->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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
use Laravel\Passport\Client;
|
||||||
|
use Laravel\Passport\ClientRepository;
|
||||||
|
|
||||||
class DatabaseSeeder extends Seeder
|
class DatabaseSeeder extends Seeder
|
||||||
{
|
{
|
||||||
|
|
@ -13,11 +14,37 @@ class DatabaseSeeder extends Seeder
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
// User::factory(10)->create();
|
User::query()->updateOrCreate(
|
||||||
|
['email' => 'test@example.com'],
|
||||||
User::factory()->create([
|
[
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
'email' => 'test@example.com',
|
'email_verified_at' => now(),
|
||||||
]);
|
'password' => 'password',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,27 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\AuthController;
|
||||||
use App\Http\Controllers\Api\EventController;
|
use App\Http\Controllers\Api\EventController;
|
||||||
|
use App\Http\Controllers\Api\EventMediaController;
|
||||||
|
use App\Http\Controllers\Api\SettingsController;
|
||||||
|
use App\Http\Controllers\Api\SettingsMediaController;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
|
|
||||||
Route::middleware('auth:api')->group(function () {
|
Route::middleware('auth:api')->group(function () {
|
||||||
Route::get('/user', fn (Request $request) => $request->user());
|
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([
|
Route::apiResource('events', EventController::class)->parameters([
|
||||||
'events' => 'clientId',
|
'events' => 'clientId',
|
||||||
|
|
|
||||||
92
backend/tests/Feature/Api/AuthTest.php
Normal file
92
backend/tests/Feature/Api/AuthTest.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\DatabaseSeeder;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Laravel\Passport\Client;
|
||||||
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
|
test('api user endpoint requires a token', function () {
|
||||||
|
$this->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();
|
||||||
|
});
|
||||||
170
backend/tests/Feature/Api/EventMediaTest.php
Normal file
170
backend/tests/Feature/Api/EventMediaTest.php
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Models\EventMedia;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
|
test('media endpoints require a token', function () {
|
||||||
|
$this->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);
|
||||||
|
});
|
||||||
148
backend/tests/Feature/Api/SettingsTest.php
Normal file
148
backend/tests/Feature/Api/SettingsTest.php
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserSetting;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Laravel\Passport\Passport;
|
||||||
|
|
||||||
|
test('settings endpoint requires a token', function () {
|
||||||
|
$this->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");
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
Thats me Quasar Project
|
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
|
## Install the dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
303
frontend/dev/db-api-connect/README.md
Normal file
303
frontend/dev/db-api-connect/README.md
Normal file
|
|
@ -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?
|
||||||
|
|
||||||
428
frontend/dev/db-api-connect/implementation-plan.md
Normal file
428
frontend/dev/db-api-connect/implementation-plan.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
469
frontend/dev/db-api-connect/migration-log.md
Normal file
469
frontend/dev/db-api-connect/migration-log.md
Normal file
|
|
@ -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 `<img>`.
|
||||||
|
- 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.
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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",
|
"format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
||||||
"test": "echo \"No test specified\" && exit 0",
|
"test": "echo \"No test specified\" && exit 0",
|
||||||
"dev": "npm install && quasar dev",
|
"dev": "npm install && quasar dev",
|
||||||
|
|
|
||||||
|
|
@ -183,7 +183,7 @@
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="event-panel__media-item"
|
class="event-panel__media-item"
|
||||||
>
|
>
|
||||||
<img :src="item.src" class="event-panel__media-img" alt="" />
|
<img :src="mediaDisplaySrc(item)" class="event-panel__media-img" alt="" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="event-panel__media-remove"
|
class="event-panel__media-remove"
|
||||||
|
|
@ -221,7 +221,7 @@
|
||||||
flat
|
flat
|
||||||
dense
|
dense
|
||||||
no-caps
|
no-caps
|
||||||
label="Event löschen"
|
label="Event entfernen"
|
||||||
icon="delete_outline"
|
icon="delete_outline"
|
||||||
color="negative"
|
color="negative"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -278,7 +278,7 @@
|
||||||
:name="index"
|
:name="index"
|
||||||
class="event-panel__gallery-slide"
|
class="event-panel__gallery-slide"
|
||||||
>
|
>
|
||||||
<img :src="item.src" class="event-panel__gallery-img" alt="" />
|
<img :src="mediaDisplaySrc(item)" class="event-panel__gallery-img" alt="" />
|
||||||
</q-carousel-slide>
|
</q-carousel-slide>
|
||||||
</q-carousel>
|
</q-carousel>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -365,6 +365,7 @@ const mediaInputRef = ref(null)
|
||||||
const removeKeyImageDialogOpen = ref(false)
|
const removeKeyImageDialogOpen = ref(false)
|
||||||
const mediaGalleryOpen = ref(false)
|
const mediaGalleryOpen = ref(false)
|
||||||
const mediaGalleryIndex = ref(0)
|
const mediaGalleryIndex = ref(0)
|
||||||
|
const mediaSrcById = ref({})
|
||||||
const galleryItems = computed(() => {
|
const galleryItems = computed(() => {
|
||||||
const items = []
|
const items = []
|
||||||
if (eventsStore.ghostImage) {
|
if (eventsStore.ghostImage) {
|
||||||
|
|
@ -379,6 +380,18 @@ const galleryItems = computed(() => {
|
||||||
return [...items, ...eventsStore.ghostMedia]
|
return [...items, ...eventsStore.ghostMedia]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => eventsStore.ghostMedia,
|
||||||
|
async (mediaItems) => {
|
||||||
|
const entries = await Promise.all((mediaItems || []).map(async (item) => [
|
||||||
|
item.id,
|
||||||
|
await resolveFullRes(item.previewUrl || item.thumbnailUrl || item.src)
|
||||||
|
]))
|
||||||
|
mediaSrcById.value = Object.fromEntries(entries.filter(([, src]) => Boolean(src)))
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
function openKeyImageUpload() {
|
function openKeyImageUpload() {
|
||||||
keyImageInputRef.value?.click()
|
keyImageInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
@ -427,6 +440,11 @@ async function onKeyImageSelected(event) {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const uploaded = await eventsStore.uploadGhostKeyImage(file)
|
||||||
|
if (uploaded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const sourceDataUrl = await readImageAsDataUrl(file)
|
const sourceDataUrl = await readImageAsDataUrl(file)
|
||||||
eventsStore.ghostImage = await optimizeImageDataUrl(sourceDataUrl)
|
eventsStore.ghostImage = await optimizeImageDataUrl(sourceDataUrl)
|
||||||
if (!eventsStore.ghostKeyImageTitle) {
|
if (!eventsStore.ghostKeyImageTitle) {
|
||||||
|
|
@ -449,6 +467,11 @@ async function onMediaSelected(event) {
|
||||||
if (files.length === 0) return
|
if (files.length === 0) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const uploaded = await eventsStore.uploadGhostMedia(files)
|
||||||
|
if (uploaded.length > 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const images = await Promise.all(files.map(async (file) => ({
|
const images = await Promise.all(files.map(async (file) => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: 'image',
|
type: 'image',
|
||||||
|
|
@ -468,7 +491,12 @@ async function onMediaSelected(event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeMediaImage(id) {
|
function mediaDisplaySrc(item) {
|
||||||
|
return mediaSrcById.value[item.id] || item.previewUrl || item.thumbnailUrl || item.src
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMediaImage(id) {
|
||||||
|
await eventsStore.deleteGhostMedia(id)
|
||||||
eventsStore.ghostMedia = eventsStore.ghostMedia.filter(item => item.id !== id)
|
eventsStore.ghostMedia = eventsStore.ghostMedia.filter(item => item.id !== id)
|
||||||
eventsStore.saveGhostNow()
|
eventsStore.saveGhostNow()
|
||||||
if (mediaGalleryIndex.value >= galleryItems.value.length) {
|
if (mediaGalleryIndex.value >= galleryItems.value.length) {
|
||||||
|
|
@ -496,7 +524,8 @@ function confirmRemoveKeyImage() {
|
||||||
removeKeyImageDialogOpen.value = true
|
removeKeyImageDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeKeyImage() {
|
async function removeKeyImage() {
|
||||||
|
await eventsStore.deleteGhostKeyImage()
|
||||||
eventsStore.ghostImage = null
|
eventsStore.ghostImage = null
|
||||||
eventsStore.ghostKeyImageTitle = ''
|
eventsStore.ghostKeyImageTitle = ''
|
||||||
keyImageSrc.value = null
|
keyImageSrc.value = null
|
||||||
|
|
|
||||||
|
|
@ -262,7 +262,7 @@
|
||||||
<button
|
<button
|
||||||
class="lw-settings__img-btn"
|
class="lw-settings__img-btn"
|
||||||
:class="{ 'lw-settings__img-btn--active': fl.backgroundImage === '' }"
|
:class="{ 'lw-settings__img-btn--active': fl.backgroundImage === '' }"
|
||||||
@click="update({ backgroundImage: '' })"
|
@click="settingsStore.clearBackgroundImage()"
|
||||||
>
|
>
|
||||||
Keins
|
Keins
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -485,7 +485,7 @@ const selectedPresetId = computed({
|
||||||
})
|
})
|
||||||
const isCustomBackground = computed(() => {
|
const isCustomBackground = computed(() => {
|
||||||
const bg = fl.value.backgroundImage ?? ''
|
const bg = fl.value.backgroundImage ?? ''
|
||||||
return bg.startsWith('data:image/')
|
return bg.startsWith('data:image/') || bg.startsWith('/settings/media/')
|
||||||
})
|
})
|
||||||
|
|
||||||
const HORIZON_MODES = [
|
const HORIZON_MODES = [
|
||||||
|
|
@ -576,6 +576,11 @@ async function onBackgroundUpload(event) {
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (settingsStore.uploadBackgroundImage && settingsStore.floatingLines.backgroundImage !== undefined) {
|
||||||
|
const uploaded = await settingsStore.uploadBackgroundImage(file)
|
||||||
|
if (uploaded) return
|
||||||
|
}
|
||||||
|
|
||||||
const sourceDataUrl = await readImageAsDataUrl(file)
|
const sourceDataUrl = await readImageAsDataUrl(file)
|
||||||
const optimized = await optimizeImageDataUrl(sourceDataUrl)
|
const optimized = await optimizeImageDataUrl(sourceDataUrl)
|
||||||
update({ backgroundImage: optimized })
|
update({ backgroundImage: optimized })
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="user-menu__info">
|
<div class="user-menu__info">
|
||||||
<div class="user-menu__name user-menu__name--sm">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
|
<div class="user-menu__name user-menu__name--sm">{{ authStore.currentUser?.name ?? 'Demo User' }}</div>
|
||||||
<div class="user-menu__plan">Demo Account</div>
|
<div class="user-menu__plan">Demo Account · v{{ APP_VERSION }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,6 +106,7 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useAuthStore } from 'stores/auth'
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
import { APP_VERSION } from 'src/config/appVersion'
|
||||||
|
|
||||||
defineProps({ open: { type: Boolean, default: false } })
|
defineProps({ open: { type: Boolean, default: false } })
|
||||||
defineEmits(['close', 'navigate'])
|
defineEmits(['close', 'navigate'])
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { ref, unref, watch } from 'vue'
|
import { ref, unref, watch } from 'vue'
|
||||||
import { db } from 'src/db'
|
import { db } from 'src/db'
|
||||||
|
import { apiFetch } from 'src/services/apiClient'
|
||||||
|
|
||||||
const THUMB_SIZE = 200
|
const THUMB_SIZE = 200
|
||||||
|
|
||||||
|
|
@ -7,6 +8,10 @@ const THUMB_SIZE = 200
|
||||||
// Shared across all component instances
|
// Shared across all component instances
|
||||||
const memoryCache = new Map()
|
const memoryCache = new Map()
|
||||||
|
|
||||||
|
function isProtectedApiImageUrl(imageUrl) {
|
||||||
|
return imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob.
|
* Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob.
|
||||||
* Returns a new Blob (JPEG, quality 0.8).
|
* Returns a new Blob (JPEG, quality 0.8).
|
||||||
|
|
@ -51,7 +56,9 @@ function createThumbnail(blob) {
|
||||||
* Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL.
|
* Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL.
|
||||||
*/
|
*/
|
||||||
async function fetchAndCache(imageUrl, eventId) {
|
async function fetchAndCache(imageUrl, eventId) {
|
||||||
const response = await fetch(imageUrl)
|
const response = isProtectedApiImageUrl(imageUrl)
|
||||||
|
? await apiFetch(imageUrl)
|
||||||
|
: await fetch(imageUrl)
|
||||||
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
|
|
||||||
|
|
@ -159,6 +166,20 @@ export function useImageCache(imageUrl, eventId) {
|
||||||
export async function resolveFullRes(imageUrl) {
|
export async function resolveFullRes(imageUrl) {
|
||||||
if (!imageUrl) return null
|
if (!imageUrl) return null
|
||||||
|
|
||||||
|
if (isProtectedApiImageUrl(imageUrl)) {
|
||||||
|
if (memoryCache.has(imageUrl)) return memoryCache.get(imageUrl)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(imageUrl)
|
||||||
|
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
|
||||||
|
const blobUrl = URL.createObjectURL(await response.blob())
|
||||||
|
memoryCache.set(imageUrl, blobUrl)
|
||||||
|
return blobUrl
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Full-res image load failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If online, return original URL (browser caches via HTTP headers)
|
// If online, return original URL (browser caches via HTTP headers)
|
||||||
if (navigator.onLine) return imageUrl
|
if (navigator.onLine) return imageUrl
|
||||||
|
|
||||||
|
|
|
||||||
2
frontend/src/config/appVersion.js
Normal file
2
frontend/src/config/appVersion.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const APP_VERSION = '0.0.1'
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
:lines-gradient="parsedGradient"
|
:lines-gradient="parsedGradient"
|
||||||
:bg-color-center="fl.bgCenter"
|
:bg-color-center="fl.bgCenter"
|
||||||
:bg-color-edge="fl.bgEdge"
|
:bg-color-edge="fl.bgEdge"
|
||||||
:background-image="fl.backgroundImage"
|
:background-image="resolvedBackgroundImage"
|
||||||
:mix-blend-mode="fl.lineMode === 'static' ? 'normal' : 'screen'"
|
:mix-blend-mode="fl.lineMode === 'static' ? 'normal' : 'screen'"
|
||||||
:horizon-mode="fl.horizonMode ?? 'off'"
|
:horizon-mode="fl.horizonMode ?? 'off'"
|
||||||
:horizon-opacity="fl.horizonOpacity ?? 0.5"
|
:horizon-opacity="fl.horizonOpacity ?? 0.5"
|
||||||
|
|
@ -147,7 +147,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import AddEventButton from 'components/AddEventButton.vue'
|
import AddEventButton from 'components/AddEventButton.vue'
|
||||||
|
|
@ -161,6 +161,7 @@ import ZoomControl from 'components/ZoomControl.vue'
|
||||||
import { useAuthStore } from 'stores/auth'
|
import { useAuthStore } from 'stores/auth'
|
||||||
import { useEventsStore } from 'stores/events'
|
import { useEventsStore } from 'stores/events'
|
||||||
import { useSettingsStore } from 'stores/settings'
|
import { useSettingsStore } from 'stores/settings'
|
||||||
|
import { resolveFullRes } from 'composables/useImageCache'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
@ -173,6 +174,31 @@ const userMenuOpen = ref(false)
|
||||||
const appSettingsOpen = ref(false)
|
const appSettingsOpen = ref(false)
|
||||||
const floatingLinesRef = ref(null)
|
const floatingLinesRef = ref(null)
|
||||||
const fl = computed(() => settingsStore.floatingLines)
|
const fl = computed(() => settingsStore.floatingLines)
|
||||||
|
const resolvedBackgroundImage = ref('')
|
||||||
|
let backgroundRequestId = 0
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => fl.value.backgroundImage,
|
||||||
|
async (backgroundImage) => {
|
||||||
|
const requestId = ++backgroundRequestId
|
||||||
|
|
||||||
|
if (!backgroundImage) {
|
||||||
|
resolvedBackgroundImage.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backgroundImage.startsWith('/settings/media/')) {
|
||||||
|
resolvedBackgroundImage.value = backgroundImage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = await resolveFullRes(backgroundImage)
|
||||||
|
if (requestId === backgroundRequestId) {
|
||||||
|
resolvedBackgroundImage.value = resolved || ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
// Timeline view ref (for direct scroll access in render loop)
|
// Timeline view ref (for direct scroll access in render loop)
|
||||||
const timelineViewRef = ref(null)
|
const timelineViewRef = ref(null)
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,17 @@
|
||||||
<h1>Login</h1>
|
<h1>Login</h1>
|
||||||
<p>Welcome back. Wähle einen Demo-User und arbeite mit eigenen Events und Settings.</p>
|
<p>Welcome back. Wähle einen Demo-User und arbeite mit eigenen Events und Settings.</p>
|
||||||
|
|
||||||
<label class="login-field">
|
<div class="login-user-row">
|
||||||
|
<label class="login-field login-field--user">
|
||||||
<span>E-Mail</span>
|
<span>E-Mail</span>
|
||||||
<select v-model="email">
|
<select v-model="email">
|
||||||
<option v-for="user in authStore.users" :key="user.id" :value="user.email">
|
<option v-for="user in authStore.users" :key="user.id" :value="user.email">
|
||||||
{{ user.email }}
|
{{ user.mode === 'local' ? user.name : user.email }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<q-icon name="person_outline" size="22px" />
|
<q-icon name="person_outline" size="22px" />
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="login-field">
|
<label class="login-field">
|
||||||
<span>Passwort</span>
|
<span>Passwort</span>
|
||||||
|
|
@ -22,6 +24,8 @@
|
||||||
v-model="password"
|
v-model="password"
|
||||||
:type="showPassword ? 'text' : 'password'"
|
:type="showPassword ? 'text' : 'password'"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
|
:disabled="isLocalDemo"
|
||||||
|
:placeholder="isLocalDemo ? 'Für Demo nicht nötig' : ''"
|
||||||
>
|
>
|
||||||
<button class="login-field__icon-btn" type="button" @click="showPassword = !showPassword">
|
<button class="login-field__icon-btn" type="button" @click="showPassword = !showPassword">
|
||||||
<q-icon :name="showPassword ? 'visibility_off' : 'visibility'" size="22px" />
|
<q-icon :name="showPassword ? 'visibility_off' : 'visibility'" size="22px" />
|
||||||
|
|
@ -35,17 +39,19 @@
|
||||||
|
|
||||||
<div v-if="authStore.lastError" class="login-error">{{ authStore.lastError }}</div>
|
<div v-if="authStore.lastError" class="login-error">{{ authStore.lastError }}</div>
|
||||||
|
|
||||||
<button class="login-submit" type="submit">Login</button>
|
<button class="login-submit" type="submit" :disabled="isSubmitting">
|
||||||
|
{{ submitLabel }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="login-card__hint">
|
<div class="login-card__hint">
|
||||||
Demo-Accounts: <strong>user1-user5@thats-me.app</strong>, Passwort <strong>pass</strong>
|
Demo bleibt lokal im Browser. API-Accounts: <strong>user1-user6@thats-me.app</strong>, Passwort <strong>pass</strong>.
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from 'stores/auth'
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
|
||||||
|
|
@ -57,9 +63,27 @@ const email = ref(authStore.users[0]?.email ?? '')
|
||||||
const password = ref('pass')
|
const password = ref('pass')
|
||||||
const showPassword = ref(false)
|
const showPassword = ref(false)
|
||||||
const remember = ref(true)
|
const remember = ref(true)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const selectedUser = computed(() => authStore.users.find(user => user.email === email.value) ?? null)
|
||||||
|
const isLocalDemo = computed(() => selectedUser.value?.mode === 'local')
|
||||||
|
const submitLabel = computed(() => {
|
||||||
|
if (isSubmitting.value) return isLocalDemo.value ? 'Demo startet...' : 'Login läuft...'
|
||||||
|
return isLocalDemo.value ? 'Demo lokal starten' : 'Login'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isLocalDemo, (localDemo) => {
|
||||||
|
password.value = localDemo ? '' : 'pass'
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (isSubmitting.value) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
const isLoggedIn = await authStore.login(email.value, password.value)
|
||||||
|
isSubmitting.value = false
|
||||||
|
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
|
||||||
function onSubmit() {
|
|
||||||
if (!authStore.login(email.value, password.value)) return
|
|
||||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/')
|
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -135,6 +159,16 @@ function onSubmit() {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-field--user {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.login-field span {
|
.login-field span {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 0 6px 2px;
|
margin: 0 0 6px 2px;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { defineRouter } from '#q-app/wrappers'
|
import { defineRouter } from '#q-app/wrappers'
|
||||||
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
|
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||||
import routes from './routes'
|
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
|
* If not building with SSR mode, you can
|
||||||
|
|
@ -28,16 +28,7 @@ export default defineRouter(function (/* { store, ssrContext } */) {
|
||||||
})
|
})
|
||||||
|
|
||||||
Router.beforeEach((to) => {
|
Router.beforeEach((to) => {
|
||||||
const stored = localStorage.getItem(AUTH_STORAGE_KEY)
|
const isAuthenticated = hasStoredAuth()
|
||||||
let userId = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
userId = stored ? JSON.parse(stored)?.userId ?? null : null
|
|
||||||
} catch {
|
|
||||||
userId = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAuthenticated = DEMO_USERS.some(user => user.id === userId)
|
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
return { path: '/login', query: { redirect: to.fullPath } }
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
|
|
|
||||||
136
frontend/src/services/apiClient.js
Normal file
136
frontend/src/services/apiClient.js
Normal file
|
|
@ -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' })
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { db } from 'src/db'
|
import { db } from 'src/db'
|
||||||
|
import { apiFetch, getToken, setToken } from 'src/services/apiClient'
|
||||||
// API base URL — configured per environment
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
|
||||||
|
|
||||||
const isSyncing = ref(false)
|
const isSyncing = ref(false)
|
||||||
const isOnline = ref(navigator.onLine)
|
const isOnline = ref(navigator.onLine)
|
||||||
|
|
@ -17,51 +15,6 @@ window.addEventListener('offline', () => {
|
||||||
isOnline.value = false
|
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).
|
* Process the outbound sync queue (FIFO).
|
||||||
* Called on app start, every 30s when online, and on reconnect.
|
* Called on app start, every 30s when online, and on reconnect.
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,98 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { clearToken, loginRemote, logoutRemote, setToken } from 'src/services/apiClient'
|
||||||
|
|
||||||
export const AUTH_STORAGE_KEY = 'thatsme-auth'
|
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
|
const number = index + 1
|
||||||
return {
|
return {
|
||||||
id: `demo-user-${number}`,
|
id: `remote-user-${number}`,
|
||||||
email: `user${number}@thats-me.app`,
|
email: `user${number}@thats-me.app`,
|
||||||
password: 'pass',
|
|
||||||
name: `User ${number}`,
|
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 {
|
try {
|
||||||
const stored = localStorage.getItem(AUTH_STORAGE_KEY)
|
const stored = localStorage.getItem(AUTH_STORAGE_KEY)
|
||||||
return stored ? JSON.parse(stored)?.userId ?? null : null
|
return stored ? JSON.parse(stored) : null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
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', () => {
|
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 lastError = ref('')
|
||||||
|
const users = ref(getAvailableUsers())
|
||||||
|
|
||||||
const currentUser = computed(() =>
|
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)
|
const isAuthenticated = computed(() => currentUser.value !== null)
|
||||||
|
|
||||||
function login(email, password) {
|
async function login(email, password) {
|
||||||
const normalizedEmail = String(email).trim().toLowerCase()
|
const normalizedEmail = String(email).trim().toLowerCase()
|
||||||
const user = DEMO_USERS.find(candidate =>
|
const user = users.value.find(candidate =>
|
||||||
candidate.email === normalizedEmail && candidate.password === password
|
candidate.email === normalizedEmail
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
@ -43,20 +100,61 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUserId.value = user.id
|
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 = ''
|
lastError.value = ''
|
||||||
localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify({ userId: user.id }))
|
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 = ''
|
||||||
|
persistAuth(user)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
|
if (currentUser.value?.mode === 'remote') {
|
||||||
|
logoutRemote().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
currentUserId.value = null
|
currentUserId.value = null
|
||||||
|
currentUserProfile.value = null
|
||||||
lastError.value = ''
|
lastError.value = ''
|
||||||
localStorage.removeItem(AUTH_STORAGE_KEY)
|
localStorage.removeItem(AUTH_STORAGE_KEY)
|
||||||
|
clearToken().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
users: DEMO_USERS,
|
users,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
currentUser,
|
currentUser,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import Dexie from 'dexie'
|
import Dexie from 'dexie'
|
||||||
import { db } from 'src/db'
|
import { db } from 'src/db'
|
||||||
|
import { apiJson } from 'src/services/apiClient'
|
||||||
import { startAutoSync, getToken } from 'src/services/syncService'
|
import { startAutoSync, getToken } from 'src/services/syncService'
|
||||||
import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings'
|
import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings'
|
||||||
import { useAuthStore } from 'stores/auth'
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
|
@ -28,6 +29,14 @@ function emotionToColor(emotion, gradientStartColor = null, gradientEndColor = n
|
||||||
return lerpColor(start, end, t)
|
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
|
// Demo seed data
|
||||||
const demoEvents = [
|
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() },
|
{ 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
|
const AUTOSAVE_DELAY_MS = 300
|
||||||
let persistTimer = null
|
let persistTimer = null
|
||||||
let skipNextPersist = false
|
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() {
|
async function init() {
|
||||||
const userId = authStore.currentUserId
|
const userId = authStore.currentUserId
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
|
|
@ -166,22 +346,20 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
|
|
||||||
isLoaded.value = false
|
isLoaded.value = false
|
||||||
try {
|
try {
|
||||||
let stored = await db.events
|
if (isRemoteUser.value) {
|
||||||
|
await loadRemoteEvents()
|
||||||
|
isLoaded.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = await db.events
|
||||||
.where('[userId+date]')
|
.where('[userId+date]')
|
||||||
.between([userId, Dexie.minKey], [userId, Dexie.maxKey])
|
.between([userId, Dexie.minKey], [userId, Dexie.maxKey])
|
||||||
.toArray()
|
.toArray()
|
||||||
if (stored.length === 0) {
|
|
||||||
const seed = generateManyEvents(500).map(event => ({
|
|
||||||
...event,
|
|
||||||
userId
|
|
||||||
}))
|
|
||||||
await db.events.bulkPut(seed)
|
|
||||||
stored = seed
|
|
||||||
}
|
|
||||||
events.value = stored
|
events.value = stored
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Dexie load failed, using demo data:', e)
|
console.warn('Dexie load failed:', e)
|
||||||
events.value = [...demoEvents]
|
events.value = []
|
||||||
}
|
}
|
||||||
isLoaded.value = true
|
isLoaded.value = true
|
||||||
|
|
||||||
|
|
@ -197,26 +375,11 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function dbDelete(id) {
|
function dbDelete(id) {
|
||||||
db.events.delete(id).catch(e => console.warn('Dexie delete failed:', e))
|
const remove = async () => {
|
||||||
|
await db.events.delete(id)
|
||||||
|
await db.eventMedia.where('eventId').equals(id).delete()
|
||||||
}
|
}
|
||||||
|
remove().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() })
|
|
||||||
}
|
|
||||||
|
|
||||||
queue().catch(e => console.warn('Dexie sync queue failed:', e))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneMedia(media) {
|
function cloneMedia(media) {
|
||||||
|
|
@ -226,11 +389,16 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaMeta(media) {
|
function mediaMeta(media) {
|
||||||
return cloneMedia(media).map(({ id, type, name, createdAt }) => ({
|
return cloneMedia(media).map((item) => ({
|
||||||
id,
|
id: item.id,
|
||||||
type,
|
uuid: item.uuid,
|
||||||
name,
|
type: item.type,
|
||||||
createdAt
|
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
|
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) {
|
function openPanel(eventId = null) {
|
||||||
if (eventId) {
|
const panelEventId = eventId || createEvent().id
|
||||||
editingEventId.value = eventId
|
|
||||||
const event = events.value.find((e) => e.id === eventId)
|
if (panelEventId) {
|
||||||
|
editingEventId.value = panelEventId
|
||||||
|
const event = events.value.find((e) => e.id === panelEventId)
|
||||||
if (event) {
|
if (event) {
|
||||||
skipNextPersist = true
|
skipNextPersist = true
|
||||||
ghostTitle.value = event.title
|
ghostTitle.value = event.title
|
||||||
|
|
@ -312,22 +510,11 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
ghostLocation.value = event.location || ''
|
ghostLocation.value = event.location || ''
|
||||||
ghostEmotion.value = event.emotion
|
ghostEmotion.value = event.emotion
|
||||||
ghostCustomColor.value = event.customColor
|
ghostCustomColor.value = event.customColor
|
||||||
ghostImage.value = event.image || null
|
ghostImage.value = event.keyImagePreviewUrl || event.image || null
|
||||||
ghostKeyImageTitle.value = event.keyImageTitle || ''
|
ghostKeyImageTitle.value = event.keyImageTitle || ''
|
||||||
loadEventMedia(event)
|
loadEventMedia(event)
|
||||||
ghostNote.value = event.note
|
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
|
panelOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
@ -356,7 +543,9 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
events.value[idx] = updated
|
events.value[idx] = updated
|
||||||
dbPut(updated)
|
dbPut(updated)
|
||||||
persistEventMedia(updated.id, updated.userId, ghostMedia.value)
|
persistEventMedia(updated.id, updated.userId, ghostMedia.value)
|
||||||
dbQueueSync(updated.id, 'update', { ...updated })
|
if (isRemoteUser.value) {
|
||||||
|
updateRemoteEvent(updated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePersistToEvent() {
|
function schedulePersistToEvent() {
|
||||||
|
|
@ -397,29 +586,6 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
function closePanel() {
|
function closePanel() {
|
||||||
flushPersistToEvent()
|
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
|
panelOpen.value = false
|
||||||
editingEventId.value = null
|
editingEventId.value = null
|
||||||
selectedEventId.value = null
|
selectedEventId.value = null
|
||||||
|
|
@ -432,10 +598,105 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
}
|
}
|
||||||
events.value = events.value.filter((e) => e.id !== id)
|
events.value = events.value.filter((e) => e.id !== id)
|
||||||
dbDelete(id)
|
dbDelete(id)
|
||||||
dbQueueSync(id, 'delete', null)
|
if (isRemoteUser.value) {
|
||||||
|
deleteRemoteEvent(id)
|
||||||
|
}
|
||||||
closePanel()
|
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) {
|
function getGlowColor(event) {
|
||||||
if (event.customColor) return event.customColor
|
if (event.customColor) return event.customColor
|
||||||
return emotionToColor(
|
return emotionToColor(
|
||||||
|
|
@ -478,8 +739,13 @@ export const useEventsStore = defineStore('events', () => {
|
||||||
sortedEvents,
|
sortedEvents,
|
||||||
selectEvent,
|
selectEvent,
|
||||||
openPanel,
|
openPanel,
|
||||||
|
createEvent,
|
||||||
closePanel,
|
closePanel,
|
||||||
deleteEvent,
|
deleteEvent,
|
||||||
|
uploadGhostKeyImage,
|
||||||
|
uploadGhostMedia,
|
||||||
|
deleteGhostMedia,
|
||||||
|
deleteGhostKeyImage,
|
||||||
saveGhostNow,
|
saveGhostNow,
|
||||||
getGlowColor
|
getGlowColor
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
|
import { apiJson } from 'src/services/apiClient'
|
||||||
import { useAuthStore } from 'stores/auth'
|
import { useAuthStore } from 'stores/auth'
|
||||||
|
|
||||||
const STORAGE_KEY_PREFIX = 'thatsme-settings'
|
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 initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId)
|
||||||
const initialSettings = initialActivePreset?.settings ?? stored
|
const initialSettings = initialActivePreset?.settings ?? stored
|
||||||
let persistTimer = null
|
let persistTimer = null
|
||||||
|
let isApplyingSettings = false
|
||||||
|
|
||||||
const theme = ref(initialSettings?.theme ?? 'light')
|
const theme = ref(initialSettings?.theme ?? 'light')
|
||||||
const floatingLines = ref({
|
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) {
|
function applySnapshot(snapshot) {
|
||||||
theme.value = snapshot?.theme ?? 'light'
|
theme.value = snapshot?.theme ?? 'light'
|
||||||
floatingLines.value = {
|
floatingLines.value = {
|
||||||
|
|
@ -182,37 +197,59 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
persistTimer = null
|
persistTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authStore.currentUserId) return
|
if (!authStore.currentUserId || isApplyingSettings) return
|
||||||
|
|
||||||
localStorage.setItem(
|
const storedSettings = createStoredSettings()
|
||||||
getStorageKey(authStore.currentUserId),
|
|
||||||
JSON.stringify({
|
localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings))
|
||||||
...createSnapshot(),
|
|
||||||
timelineScrollLeft: timelineScrollLeft.value,
|
if (isRemoteUser()) {
|
||||||
presets: presets.value,
|
apiJson('/settings', {
|
||||||
activePresetId: activePresetId.value
|
method: 'PUT',
|
||||||
})
|
body: { settings: storedSettings }
|
||||||
)
|
}).catch(error => console.warn('Remote settings persist failed:', error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePersist() {
|
function schedulePersist() {
|
||||||
|
if (isApplyingSettings) return
|
||||||
if (persistTimer) clearTimeout(persistTimer)
|
if (persistTimer) clearTimeout(persistTimer)
|
||||||
persistTimer = setTimeout(persist, PERSIST_DELAY_MS)
|
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) {
|
if (persistTimer) {
|
||||||
clearTimeout(persistTimer)
|
clearTimeout(persistTimer)
|
||||||
persistTimer = null
|
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 ?? []
|
presets.value = nextStored?.presets ?? []
|
||||||
activePresetId.value = nextStored?.activePresetId ?? null
|
activePresetId.value = nextStored?.activePresetId ?? null
|
||||||
|
|
||||||
const activePreset = presets.value.find(preset => preset.id === activePresetId.value)
|
const activePreset = presets.value.find(preset => preset.id === activePresetId.value)
|
||||||
applySnapshot(activePreset?.settings ?? nextStored)
|
applySnapshot(activePreset?.settings ?? nextStored)
|
||||||
timelineScrollLeft.value = nextStored?.timelineScrollLeft ?? DEFAULT_TIMELINE_SCROLL_LEFT
|
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 })
|
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 }
|
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() {
|
function resetFloatingLines() {
|
||||||
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
|
floatingLines.value = { ...FLOATING_LINES_DEFAULTS }
|
||||||
}
|
}
|
||||||
|
|
@ -280,6 +348,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyStoredSettingsForUser(authStore.currentUserId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
theme,
|
theme,
|
||||||
floatingLines,
|
floatingLines,
|
||||||
|
|
@ -298,6 +368,8 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||||
resetFloatingLines,
|
resetFloatingLines,
|
||||||
resetEmotionGradient,
|
resetEmotionGradient,
|
||||||
saveTimelineScrollLeft,
|
saveTimelineScrollLeft,
|
||||||
|
uploadBackgroundImage,
|
||||||
|
clearBackgroundImage,
|
||||||
savePreset,
|
savePreset,
|
||||||
applyPreset
|
applyPreset
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue