APP als Hybrid Version - Anbindung an API

This commit is contained in:
Kevin Adametz 2026-06-05 09:54:12 +02:00
parent d054732bf5
commit c1514999be
46 changed files with 3418 additions and 196 deletions

View 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);
}
}

View file

@ -18,7 +18,7 @@ class EventController extends Controller
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = $request->user()->events()->orderBy('date');
$query = $request->user()->events()->with('media')->orderBy('date');
// Delta sync: only events updated since a given timestamp
if ($request->has('since')) {
@ -50,7 +50,7 @@ class EventController extends Controller
'note' => $request->validated('note'),
]);
return (new EventResource($event))
return (new EventResource($event->load('media')))
->response()
->setStatusCode(201);
}
@ -64,7 +64,7 @@ class EventController extends Controller
->where('client_id', $clientId)
->firstOrFail();
return new EventResource($event);
return new EventResource($event->load('media'));
}
/**
@ -103,7 +103,7 @@ class EventController extends Controller
$event->update($data);
return new EventResource($event->fresh());
return new EventResource($event->fresh()->load('media'));
}
/**

View 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,
]));
}
}

View 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,
]);
}
}

View 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,
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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(),
];
}
}

View file

@ -18,6 +18,7 @@ class EventResource extends JsonResource
'gradientPreset' => $this->gradient_preset,
'image' => $this->image,
'note' => $this->note ?? '',
'media' => EventMediaResource::collection($this->whenLoaded('media')),
'syncStatus' => 'synced',
'createdAt' => $this->created_at->getTimestampMs(),
'updatedAt' => $this->updated_at->getTimestampMs(),