Backend: Medien-Typen, Sprachnotizen, Event-Felder und Sync-Härtung
- Event-Medien tragen Typ (image/video/audio), Dauer und Notiz; Videos bekommen ein Poster, Audio wird per ffmpeg nach AAC (audio/mp4) gewandelt, Originale werden mit Range-Unterstützung gestreamt. - Upload nimmt eine Notiz (caption) direkt mit, PATCH ändert sie später. - Neue Event-Felder: Zeitraum (end_date), Kategorie, Personen, Meilenstein. Regeln zentral in EventFields, damit CRUD und Sync gleich prüfen. - Titel darf leer sein: ein Event entsteht mit dem ersten Eintrag. - Artisan-Befehl media:transcode-voice-notes wandelt alte WebM/Ogg-Notizen, die Safari nicht abspielen kann. - Login-Throttling, Settings-Validierung, Sync-Fehlercodes ohne Rohtext. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
f79dcb9928
commit
b262a6771f
59 changed files with 11879 additions and 197 deletions
108
backend/app/Console/Commands/ImportLifelineImages.php
Normal file
108
backend/app/Console/Commands/ImportLifelineImages.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use App\Services\EventMediaStore;
|
||||
use Database\Seeders\LifelineSeeder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Attaches generated key images to the seeded lifeline events.
|
||||
*
|
||||
* Images are expected as `<age>-<month>.png|jpg` (zero padded, e.g. 04-07)
|
||||
* in `storage/app/private/lifeline-images/<handle>/`. Events that already
|
||||
* have a key image are skipped unless --force is given.
|
||||
*/
|
||||
class ImportLifelineImages extends Command
|
||||
{
|
||||
protected $signature = 'lifelines:import-images
|
||||
{handle : Lifeline handle, e.g. bruno}
|
||||
{--from= : Image directory (default: storage/app/private/lifeline-images/<handle>)}
|
||||
{--force : Replace key images that are already attached}
|
||||
{--dry-run : Only report what would be imported}';
|
||||
|
||||
protected $description = 'Attach generated key images to seeded lifeline events';
|
||||
|
||||
public function handle(EventMediaStore $store): int
|
||||
{
|
||||
$handle = (string) $this->argument('handle');
|
||||
$dataPath = database_path("data/lifelines/{$handle}.json");
|
||||
$imageDirectory = (string) ($this->option('from') ?: storage_path("app/private/lifeline-images/{$handle}"));
|
||||
|
||||
if (! File::exists($dataPath)) {
|
||||
$this->error("No lifeline data found at {$dataPath}.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$lifeline = json_decode(File::get($dataPath), true, flags: JSON_THROW_ON_ERROR);
|
||||
$user = User::query()->where('email', $lifeline['person']['email'])->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error("User {$lifeline['person']['email']} is not seeded yet. Run the LifelineSeeder first.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$imported = $skipped = $missing = 0;
|
||||
|
||||
foreach ($lifeline['events'] as $data) {
|
||||
$slug = sprintf('%02d-%02d', $data['age'], $data['month']);
|
||||
$imagePath = $this->findImage($imageDirectory, $slug);
|
||||
|
||||
if ($imagePath === null) {
|
||||
$missing++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$clientId = LifelineSeeder::clientId($lifeline['person']['email'], $data['age'], $data['month'], $data['day'] ?? null);
|
||||
$event = Event::query()->where('user_id', $user->id)->where('client_id', $clientId)->first();
|
||||
|
||||
if ($event === null) {
|
||||
$this->warn("{$slug}: event not seeded, skipping.");
|
||||
$missing++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($event->image !== null && ! $this->option('force')) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("{$slug}: would import ".basename($imagePath)." → {$event->title}");
|
||||
$imported++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$store->store($event, new UploadedFile($imagePath, basename($imagePath), null, null, true), 'key_image');
|
||||
$this->line("{$slug}: {$event->title}");
|
||||
$imported++;
|
||||
}
|
||||
|
||||
$this->info("Imported {$imported}, skipped {$skipped} (already attached), {$missing} without image.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function findImage(string $directory, string $slug): ?string
|
||||
{
|
||||
foreach (['png', 'jpg', 'jpeg'] as $extension) {
|
||||
$path = "{$directory}/{$slug}.{$extension}";
|
||||
|
||||
if (File::exists($path)) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
94
backend/app/Console/Commands/TranscodeVoiceNotes.php
Normal file
94
backend/app/Console/Commands/TranscodeVoiceNotes.php
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\EventMedia;
|
||||
use App\Services\EventMediaProbe;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Re-encodes voice notes that are still stored as they were recorded.
|
||||
*
|
||||
* Uploads are transcoded to AAC (audio/mp4) since 2026-09-23. Notes from
|
||||
* before, or notes uploaded while ffmpeg was missing, sit in WebM or Ogg,
|
||||
* which Safari cannot play. This brings them to the same format.
|
||||
*/
|
||||
class TranscodeVoiceNotes extends Command
|
||||
{
|
||||
protected $signature = 'media:transcode-voice-notes
|
||||
{--dry-run : Only report what would be converted}';
|
||||
|
||||
protected $description = 'Convert voice notes still stored as WebM/Ogg to AAC so every browser can play them';
|
||||
|
||||
public function handle(EventMediaProbe $probe): int
|
||||
{
|
||||
$pending = EventMedia::query()
|
||||
->where('collection', 'voice_note')
|
||||
->where('mime_type', '!=', 'audio/mp4')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
$this->info('Every voice note is already audio/mp4.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("{$pending->count()} voice note(s) to convert.");
|
||||
|
||||
$converted = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($pending as $media) {
|
||||
$disk = Storage::disk($media->disk);
|
||||
|
||||
if (! $disk->exists($media->path)) {
|
||||
$this->warn("#{$media->id}: file missing ({$media->path}), skipped.");
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("#{$media->id}: {$media->mime_type} → audio/mp4");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$oldPath = $media->path;
|
||||
$source = $disk->path($oldPath);
|
||||
$targetPath = preg_replace('/\.[A-Za-z0-9]+$/', '', $oldPath).'.m4a';
|
||||
$target = $disk->path($targetPath);
|
||||
|
||||
if (! $probe->transcodeAudioToM4a($source, $target)) {
|
||||
$this->error("#{$media->id}: ffmpeg could not convert {$media->path}.");
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$media->update([
|
||||
'path' => $targetPath,
|
||||
'mime_type' => 'audio/mp4',
|
||||
'size' => (int) filesize($target),
|
||||
'duration_ms' => $probe->durationMs($target) ?? $media->duration_ms,
|
||||
]);
|
||||
|
||||
if ($targetPath !== $oldPath) {
|
||||
$disk->delete($oldPath);
|
||||
}
|
||||
|
||||
$this->line("#{$media->id}: converted.");
|
||||
$converted++;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Converted {$converted}, failed {$failed}.");
|
||||
|
||||
return $failed === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,17 +14,23 @@ class AuthController extends Controller
|
|||
{
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
{
|
||||
$request->ensureIsNotRateLimited();
|
||||
|
||||
$credentials = $request->validated();
|
||||
$user = User::query()
|
||||
->where('email', $credentials['email'])
|
||||
->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
||||
$request->hitRateLimiter();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('auth.failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->clearRateLimiter();
|
||||
|
||||
$token = $user->createToken('quasar-app')->accessToken;
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -6,31 +6,47 @@ use App\Http\Controllers\Controller;
|
|||
use App\Http\Requests\StoreEventRequest;
|
||||
use App\Http\Requests\UpdateEventRequest;
|
||||
use App\Http\Resources\EventResource;
|
||||
use App\Models\User;
|
||||
use App\Services\MediaCleaner;
|
||||
use App\Support\EventFields;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
public function __construct(private readonly MediaCleaner $mediaCleaner) {}
|
||||
|
||||
/**
|
||||
* GET /api/events
|
||||
* Cursor-based pagination by date. Supports ?since=ISO for delta sync.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$query = $request->user()->events()->with('media')->orderBy('date');
|
||||
$validated = $request->validate([
|
||||
'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
|
||||
'since' => ['sometimes', 'date'],
|
||||
'cursor' => ['sometimes', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
// `date` is not unique, so it cannot carry a cursor on its own: a page
|
||||
// boundary inside one day would skip the rest of that day. `id` makes
|
||||
// the ordering total.
|
||||
$query = $request->user()->events()
|
||||
->with('media')
|
||||
->orderBy('date')
|
||||
->orderBy('id');
|
||||
|
||||
// Delta sync: only events updated since a given timestamp
|
||||
if ($request->has('since')) {
|
||||
$since = $request->date('since');
|
||||
$query->where('updated_at', '>', $since);
|
||||
if (array_key_exists('since', $validated)) {
|
||||
$query->where('updated_at', '>', $request->date('since'));
|
||||
}
|
||||
|
||||
// Cursor-based pagination (default 50, max 200)
|
||||
$limit = min((int) $request->input('limit', 50), 200);
|
||||
|
||||
return EventResource::collection(
|
||||
$query->cursorPaginate($limit)
|
||||
$query->cursorPaginate($validated['limit'] ?? 50)->withQueryString()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -41,13 +57,7 @@ class EventController extends Controller
|
|||
{
|
||||
$event = $request->user()->events()->create([
|
||||
'client_id' => $request->validated('id'),
|
||||
'title' => $request->validated('title'),
|
||||
'date' => $request->validated('date'),
|
||||
'emotion' => $request->validated('emotion'),
|
||||
'custom_color' => $request->validated('customColor'),
|
||||
'gradient_preset' => $request->validated('gradientPreset'),
|
||||
'image' => $request->validated('image'),
|
||||
'note' => $request->validated('note'),
|
||||
...EventFields::toColumns($request->validated()),
|
||||
]);
|
||||
|
||||
return (new EventResource($event->load('media')))
|
||||
|
|
@ -76,32 +86,7 @@ class EventController extends Controller
|
|||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
$data = [];
|
||||
$validated = $request->validated();
|
||||
|
||||
if (isset($validated['title'])) {
|
||||
$data['title'] = $validated['title'];
|
||||
}
|
||||
if (isset($validated['date'])) {
|
||||
$data['date'] = $validated['date'];
|
||||
}
|
||||
if (isset($validated['emotion'])) {
|
||||
$data['emotion'] = $validated['emotion'];
|
||||
}
|
||||
if (array_key_exists('customColor', $validated)) {
|
||||
$data['custom_color'] = $validated['customColor'];
|
||||
}
|
||||
if (array_key_exists('gradientPreset', $validated)) {
|
||||
$data['gradient_preset'] = $validated['gradientPreset'];
|
||||
}
|
||||
if (array_key_exists('image', $validated)) {
|
||||
$data['image'] = $validated['image'];
|
||||
}
|
||||
if (array_key_exists('note', $validated)) {
|
||||
$data['note'] = $validated['note'];
|
||||
}
|
||||
|
||||
$event->update($data);
|
||||
$event->update(EventFields::toColumns($request->validated()));
|
||||
|
||||
return new EventResource($event->fresh()->load('media'));
|
||||
}
|
||||
|
|
@ -115,6 +100,7 @@ class EventController extends Controller
|
|||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
$this->mediaCleaner->purgeEventMedia($event);
|
||||
$event->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
|
|
@ -123,6 +109,9 @@ class EventController extends Controller
|
|||
/**
|
||||
* POST /api/events/sync
|
||||
* Batch sync: process multiple mutations in one request.
|
||||
*
|
||||
* Every payload runs through the same field rules as the CRUD endpoints,
|
||||
* so this endpoint cannot be used to write values plain CRUD rejects.
|
||||
*/
|
||||
public function sync(Request $request): JsonResponse
|
||||
{
|
||||
|
|
@ -142,58 +131,97 @@ class EventController extends Controller
|
|||
$payload = $mutation['payload'] ?? [];
|
||||
|
||||
try {
|
||||
if ($action === 'create') {
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
if (! $event) {
|
||||
$event = $user->events()->create([
|
||||
'client_id' => $clientId,
|
||||
'title' => $payload['title'] ?? 'Untitled',
|
||||
'date' => $payload['date'] ?? now()->format('Y-m-d'),
|
||||
'emotion' => $payload['emotion'] ?? 0,
|
||||
'custom_color' => $payload['customColor'] ?? null,
|
||||
'gradient_preset' => $payload['gradientPreset'] ?? null,
|
||||
'image' => $payload['image'] ?? null,
|
||||
'note' => $payload['note'] ?? null,
|
||||
]);
|
||||
}
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
} elseif ($action === 'update') {
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
if ($event) {
|
||||
$data = [];
|
||||
if (isset($payload['title'])) {
|
||||
$data['title'] = $payload['title'];
|
||||
}
|
||||
if (isset($payload['date'])) {
|
||||
$data['date'] = $payload['date'];
|
||||
}
|
||||
if (isset($payload['emotion'])) {
|
||||
$data['emotion'] = $payload['emotion'];
|
||||
}
|
||||
if (array_key_exists('customColor', $payload)) {
|
||||
$data['custom_color'] = $payload['customColor'];
|
||||
}
|
||||
if (array_key_exists('gradientPreset', $payload)) {
|
||||
$data['gradient_preset'] = $payload['gradientPreset'];
|
||||
}
|
||||
if (array_key_exists('image', $payload)) {
|
||||
$data['image'] = $payload['image'];
|
||||
}
|
||||
if (array_key_exists('note', $payload)) {
|
||||
$data['note'] = $payload['note'];
|
||||
}
|
||||
$event->update($data);
|
||||
}
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
} elseif ($action === 'delete') {
|
||||
$user->events()->where('client_id', $clientId)->delete();
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
$results[] = match ($action) {
|
||||
'create' => $this->syncCreate($user, $clientId, $payload),
|
||||
'update' => $this->syncUpdate($user, $clientId, $payload),
|
||||
'delete' => $this->syncDelete($user, $clientId),
|
||||
};
|
||||
} catch (ValidationException $e) {
|
||||
$results[] = [
|
||||
'eventId' => $clientId,
|
||||
'status' => 'invalid',
|
||||
'errors' => $e->errors(),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = ['eventId' => $clientId, 'status' => 'error', 'message' => $e->getMessage()];
|
||||
// Technical detail stays in the log; the client gets a stable
|
||||
// code instead of a raw SQL/schema message.
|
||||
Log::error('Event sync mutation failed', [
|
||||
'userId' => $user->id,
|
||||
'action' => $action,
|
||||
'eventId' => $clientId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
$results[] = [
|
||||
'eventId' => $clientId,
|
||||
'status' => 'error',
|
||||
'code' => 'sync_failed',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['results' => $results]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function syncCreate(User $user, string $clientId, array $payload): array
|
||||
{
|
||||
$existing = $user->events()->where('client_id', $clientId)->first();
|
||||
|
||||
if ($existing) {
|
||||
// Create is idempotent: replaying a queued mutation must not
|
||||
// duplicate or silently overwrite the stored event.
|
||||
return ['eventId' => $clientId, 'status' => 'exists'];
|
||||
}
|
||||
|
||||
$validated = Validator::make($payload, EventFields::storeRules())->validate();
|
||||
|
||||
$user->events()->create([
|
||||
'client_id' => $clientId,
|
||||
...EventFields::toColumns($validated),
|
||||
]);
|
||||
|
||||
return ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function syncUpdate(User $user, string $clientId, array $payload): array
|
||||
{
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
|
||||
if (! $event) {
|
||||
// Previously reported as 'ok', which let the client drop a
|
||||
// mutation that never reached the database.
|
||||
return ['eventId' => $clientId, 'status' => 'not_found'];
|
||||
}
|
||||
|
||||
$validated = Validator::make($payload, EventFields::updateRules())->validate();
|
||||
|
||||
$event->update(EventFields::toColumns($validated));
|
||||
|
||||
return ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function syncDelete(User $user, string $clientId): array
|
||||
{
|
||||
$event = $user->events()->where('client_id', $clientId)->first();
|
||||
|
||||
if (! $event) {
|
||||
return ['eventId' => $clientId, 'status' => 'not_found'];
|
||||
}
|
||||
|
||||
$this->mediaCleaner->purgeEventMedia($event);
|
||||
$event->delete();
|
||||
|
||||
return ['eventId' => $clientId, 'status' => 'ok'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,21 @@ namespace App\Http\Controllers\Api;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEventMediaRequest;
|
||||
use App\Http\Requests\UpdateEventMediaRequest;
|
||||
use App\Http\Resources\EventMediaResource;
|
||||
use App\Models\EventMedia;
|
||||
use App\Services\EventMediaImageProcessor;
|
||||
use App\Services\EventMediaStore;
|
||||
use App\Services\MediaCleaner;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class EventMediaController extends Controller
|
||||
{
|
||||
public function __construct(private readonly MediaCleaner $mediaCleaner) {}
|
||||
|
||||
public function index(Request $request, string $clientId): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
|
|
@ -29,58 +34,26 @@ class EventMediaController extends Controller
|
|||
|
||||
public function store(
|
||||
StoreEventMediaRequest $request,
|
||||
EventMediaImageProcessor $processor,
|
||||
EventMediaStore $store,
|
||||
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"]);
|
||||
}
|
||||
$media = $store->store(
|
||||
$event,
|
||||
$request->file('file'),
|
||||
$request->validated('collection') ?? 'gallery',
|
||||
$request->validated('caption'),
|
||||
);
|
||||
|
||||
return (new EventMediaResource($media))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Request $request, EventMedia $media, string $variant): Response
|
||||
public function show(Request $request, EventMedia $media, string $variant): Response|BinaryFileResponse
|
||||
{
|
||||
abort_unless(in_array($variant, ['thumb', 'preview', 'original'], true), 404);
|
||||
abort_unless((int) $media->user_id === (int) $request->user()->id, 404);
|
||||
|
|
@ -94,12 +67,41 @@ class EventMediaController extends Controller
|
|||
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',
|
||||
$headers = ['Cache-Control' => 'private, max-age=604800'];
|
||||
|
||||
if ($variant !== 'original') {
|
||||
// Thumbnails and previews are always JPEG, whatever the original.
|
||||
return response(Storage::disk($media->disk)->get($path), 200, [
|
||||
...$headers,
|
||||
'Content-Type' => 'image/jpeg',
|
||||
]);
|
||||
}
|
||||
|
||||
// Originals may be videos or audio: a file response streams them and
|
||||
// honours Range requests, which media players need for seeking.
|
||||
return response()->file(Storage::disk($media->disk)->path($path), [
|
||||
...$headers,
|
||||
'Content-Type' => $media->mime_type ?: 'application/octet-stream',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/events/{clientId}/media/{media} — the caption only; the
|
||||
* file itself is immutable.
|
||||
*/
|
||||
public function update(UpdateEventMediaRequest $request, string $clientId, EventMedia $media): EventMediaResource
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
->where('client_id', $clientId)
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless((int) $media->event_id === (int) $event->id, 404);
|
||||
|
||||
$media->update(['caption' => $request->validated('caption') ?? '']);
|
||||
|
||||
return new EventMediaResource($media->fresh());
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $clientId, EventMedia $media): JsonResponse
|
||||
{
|
||||
$event = $request->user()->events()
|
||||
|
|
@ -109,7 +111,7 @@ class EventMediaController extends Controller
|
|||
abort_unless((int) $media->event_id === (int) $event->id, 404);
|
||||
|
||||
$wasKeyImage = $media->collection === 'key_image';
|
||||
$this->deleteMediaFiles($media);
|
||||
$this->mediaCleaner->purgeMedia($media);
|
||||
$media->delete();
|
||||
|
||||
if ($wasKeyImage) {
|
||||
|
|
@ -118,21 +120,4 @@ class EventMediaController extends Controller
|
|||
|
||||
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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,22 @@
|
|||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Failed login attempts allowed per throttle key before locking out.
|
||||
*/
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
|
||||
/**
|
||||
* Lockout duration in seconds once the attempt limit is exceeded.
|
||||
*/
|
||||
private const DECAY_SECONDS = 60;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
|
|
@ -23,4 +36,53 @@ class LoginRequest extends FormRequest
|
|||
'password' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the request once too many failed attempts came from this
|
||||
* identity/IP pair, before any password hash is ever computed.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function ensureIsNotRateLimited(): void
|
||||
{
|
||||
if (! RateLimiter::tooManyAttempts($this->throttleKey(), self::MAX_ATTEMPTS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seconds = RateLimiter::availableIn($this->throttleKey());
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
]),
|
||||
])->status(429);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a failed attempt against this identity/IP pair.
|
||||
*/
|
||||
public function hitRateLimiter(): void
|
||||
{
|
||||
RateLimiter::hit($this->throttleKey(), self::DECAY_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the counter after a successful login.
|
||||
*/
|
||||
public function clearRateLimiter(): void
|
||||
{
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle per normalised e-mail *and* IP, so one attacker cannot lock
|
||||
* out an unrelated account and one IP cannot cycle through accounts.
|
||||
*/
|
||||
public function throttleKey(): string
|
||||
{
|
||||
return Str::transliterate(
|
||||
'api-login|'.Str::lower((string) $this->input('email')).'|'.$this->ip()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,19 @@ use Illuminate\Foundation\Http\FormRequest;
|
|||
|
||||
class StoreEventMediaRequest extends FormRequest
|
||||
{
|
||||
/** Images accepted everywhere an image is allowed. */
|
||||
private const IMAGE_MIMES = 'jpg,jpeg,png,webp';
|
||||
|
||||
/** Container formats phones and browsers actually produce. */
|
||||
private const VIDEO_MIMETYPES = 'video/mp4,video/quicktime,video/webm,video/x-m4v';
|
||||
|
||||
/**
|
||||
* MediaRecorder output differs per browser: Chrome and Firefox record
|
||||
* audio into a WebM container, which libmagic reports as video/webm;
|
||||
* Safari records audio/mp4 (often sniffed as audio/x-m4a or video/mp4).
|
||||
*/
|
||||
private const AUDIO_MIMETYPES = 'audio/webm,video/webm,audio/ogg,application/ogg,audio/mp4,audio/x-m4a,video/mp4,audio/mpeg,audio/wav,audio/x-wav,audio/aac';
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
|
|
@ -14,13 +27,44 @@ class StoreEventMediaRequest extends FormRequest
|
|||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* The accepted file type depends on the collection: the key image stays
|
||||
* an image, the gallery takes images and videos, and a voice note is
|
||||
* audio only.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$file = match ($this->input('collection', 'gallery')) {
|
||||
'voice_note' => ['required', 'file', 'mimetypes:'.self::AUDIO_MIMETYPES, 'max:20480'],
|
||||
'gallery' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimetypes:image/jpeg,image/png,image/webp,'.self::VIDEO_MIMETYPES,
|
||||
'max:102400',
|
||||
],
|
||||
default => ['required', 'image', 'mimes:'.self::IMAGE_MIMES, 'max:10240'],
|
||||
};
|
||||
|
||||
return [
|
||||
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
|
||||
'collection' => ['nullable', 'in:key_image,gallery'],
|
||||
'file' => $file,
|
||||
'collection' => ['nullable', 'in:key_image,gallery,voice_note'],
|
||||
// Sent along with the file when the note was written before the
|
||||
// upload could happen (offline), so it needs no second request.
|
||||
'caption' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'file.mimetypes' => 'Dieses Dateiformat wird hier nicht unterstützt.',
|
||||
'file.mimes' => 'Dieses Dateiformat wird hier nicht unterstützt.',
|
||||
'file.image' => 'Für das Key Image ist nur ein Bild erlaubt.',
|
||||
'file.max' => 'Die Datei ist zu groß.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Support\EventFields;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEventRequest extends FormRequest
|
||||
|
|
@ -15,13 +16,7 @@ class StoreEventRequest extends FormRequest
|
|||
{
|
||||
return [
|
||||
'id' => ['required', 'uuid', 'unique:events,client_id'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'date' => ['required', 'date_format:Y-m-d'],
|
||||
'emotion' => ['required', 'numeric', 'min:-1', 'max:1'],
|
||||
'customColor' => ['nullable', 'string', 'max:20'],
|
||||
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
|
||||
'image' => ['nullable', 'string', 'max:500'],
|
||||
'note' => ['nullable', 'string', 'max:5000'],
|
||||
...EventFields::storeRules(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
23
backend/app/Http/Requests/UpdateEventMediaRequest.php
Normal file
23
backend/app/Http/Requests/UpdateEventMediaRequest.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateEventMediaRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'caption' => ['present', 'nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Support\EventFields;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateEventRequest extends FormRequest
|
||||
|
|
@ -13,14 +14,6 @@ class UpdateEventRequest extends FormRequest
|
|||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'date' => ['sometimes', 'required', 'date_format:Y-m-d'],
|
||||
'emotion' => ['sometimes', 'required', 'numeric', 'min:-1', 'max:1'],
|
||||
'customColor' => ['nullable', 'string', 'max:20'],
|
||||
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
|
||||
'image' => ['nullable', 'string', 'max:500'],
|
||||
'note' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
return EventFields::updateRules();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Support\SettingsSchema;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpdateSettingsRequest extends FormRequest
|
||||
{
|
||||
|
|
@ -17,21 +19,59 @@ class UpdateSettingsRequest extends FormRequest
|
|||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return SettingsSchema::rules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject keys the schema does not know about instead of persisting them.
|
||||
*
|
||||
* @return array<int, callable>
|
||||
*/
|
||||
public function after(): 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'],
|
||||
function (Validator $validator): void {
|
||||
$this->rejectUnknownKeys(
|
||||
$validator,
|
||||
'settings',
|
||||
(array) $this->input('settings', []),
|
||||
SettingsSchema::allowedKeys(),
|
||||
);
|
||||
|
||||
$floatingLines = $this->input('settings.floatingLines');
|
||||
|
||||
if (is_array($floatingLines)) {
|
||||
$this->rejectUnknownKeys(
|
||||
$validator,
|
||||
'settings.floatingLines',
|
||||
$floatingLines,
|
||||
SettingsSchema::allowedFloatingLineKeys(),
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $given
|
||||
* @param array<int, string> $allowed
|
||||
*/
|
||||
private function rejectUnknownKeys(
|
||||
Validator $validator,
|
||||
string $attribute,
|
||||
array $given,
|
||||
array $allowed,
|
||||
): void {
|
||||
$unknown = array_diff(array_keys($given), $allowed);
|
||||
|
||||
if ($unknown === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$validator->errors()->add(
|
||||
$attribute,
|
||||
'Unknown settings keys: '.implode(', ', array_map('strval', $unknown)).'.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,24 +9,32 @@ class EventMediaResource extends JsonResource
|
|||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$base = "/event-media/{$this->id}";
|
||||
$hasThumbnail = is_string($this->thumbnail_path) && $this->thumbnail_path !== '';
|
||||
$hasPreview = $hasThumbnail || (is_string($this->preview_path) && $this->preview_path !== '');
|
||||
|
||||
return [
|
||||
'id' => (string) $this->id,
|
||||
'uuid' => $this->uuid,
|
||||
'type' => 'image',
|
||||
'type' => $this->type ?? 'image',
|
||||
'collection' => $this->collection,
|
||||
'name' => $this->name,
|
||||
'caption' => $this->caption ?? '',
|
||||
'mimeType' => $this->mime_type,
|
||||
'size' => $this->size,
|
||||
'durationMs' => $this->duration_ms,
|
||||
'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",
|
||||
// Audio has no image variants; a video without an extracted
|
||||
// poster frame has none either. The client shows a placeholder.
|
||||
'src' => $hasThumbnail ? "{$base}/thumb" : null,
|
||||
'thumbnailUrl' => $hasThumbnail ? "{$base}/thumb" : null,
|
||||
'previewUrl' => $hasPreview ? "{$base}/preview" : null,
|
||||
'originalUrl' => "{$base}/original",
|
||||
'createdAt' => $this->created_at->getTimestampMs(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,17 @@ class EventResource extends JsonResource
|
|||
return [
|
||||
'id' => $this->client_id,
|
||||
'title' => $this->title,
|
||||
'date' => $this->date->format('Y-m-d'),
|
||||
'date' => substr((string) $this->date, 0, 10),
|
||||
'endDate' => $this->end_date ? substr((string) $this->end_date, 0, 10) : null,
|
||||
'location' => $this->location ?? '',
|
||||
'category' => $this->category,
|
||||
'people' => $this->people ?? [],
|
||||
'emotion' => (float) $this->emotion,
|
||||
'milestone' => (bool) $this->milestone,
|
||||
'customColor' => $this->custom_color,
|
||||
'gradientPreset' => $this->gradient_preset,
|
||||
'image' => $this->image,
|
||||
'keyImageTitle' => $this->key_image_title ?? '',
|
||||
'note' => $this->note ?? '',
|
||||
'media' => EventMediaResource::collection($this->whenLoaded('media')),
|
||||
'syncStatus' => 'synced',
|
||||
|
|
|
|||
|
|
@ -16,19 +16,31 @@ class Event extends Model
|
|||
'client_id',
|
||||
'title',
|
||||
'date',
|
||||
'end_date',
|
||||
'location',
|
||||
'category',
|
||||
'people',
|
||||
'emotion',
|
||||
'milestone',
|
||||
'custom_color',
|
||||
'gradient_preset',
|
||||
'image',
|
||||
'key_image_title',
|
||||
'note',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date:Y-m-d',
|
||||
// `date` is a pure calendar date and is deliberately NOT cast to a
|
||||
// Carbon instance: a casted value is bound into cursor-pagination
|
||||
// queries as "Y-m-d H:i:s", which never matches the "Y-m-d" the
|
||||
// DATE column holds, and it drags a timezone into a value that has
|
||||
// none. It stays the raw "Y-m-d" string.
|
||||
'emotion' => 'decimal:3',
|
||||
'gradient_preset' => 'integer',
|
||||
'people' => 'array',
|
||||
'milestone' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,13 +17,16 @@ class EventMedia extends Model
|
|||
'user_id',
|
||||
'event_id',
|
||||
'collection',
|
||||
'type',
|
||||
'name',
|
||||
'caption',
|
||||
'mime_type',
|
||||
'disk',
|
||||
'path',
|
||||
'thumbnail_path',
|
||||
'preview_path',
|
||||
'size',
|
||||
'duration_ms',
|
||||
'width',
|
||||
'height',
|
||||
'thumbnail_width',
|
||||
|
|
@ -36,6 +39,7 @@ class EventMedia extends Model
|
|||
{
|
||||
return [
|
||||
'size' => 'integer',
|
||||
'duration_ms' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'thumbnail_width' => 'integer',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
|
|
@ -19,6 +22,22 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
$this->configureRateLimiting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limiters for the API surface consumed by the Quasar app.
|
||||
*/
|
||||
private function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('api', function (Request $request): Limit {
|
||||
return $request->user()
|
||||
? Limit::perMinute(300)->by('api|user|'.$request->user()->getAuthIdentifier())
|
||||
: Limit::perMinute(60)->by('api|ip|'.$request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('uploads', function (Request $request): Limit {
|
||||
return Limit::perMinute(30)->by('uploads|user|'.$request->user()?->getAuthIdentifier());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
33
backend/app/Rules/ScalarSetting.php
Normal file
33
backend/app/Rules/ScalarSetting.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
/**
|
||||
* Allows only bounded scalar values inside the settings document, so a
|
||||
* client cannot nest arrays or objects where a shader uniform or a colour
|
||||
* string is expected.
|
||||
*/
|
||||
class ScalarSetting implements ValidationRule
|
||||
{
|
||||
public function __construct(private readonly int $maxLength = 4096) {}
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || is_bool($value) || is_int($value) || is_float($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! is_string($value)) {
|
||||
$fail('The :attribute must be a string, number or boolean.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (mb_strlen($value) > $this->maxLength) {
|
||||
$fail("The :attribute may not be greater than {$this->maxLength} characters.");
|
||||
}
|
||||
}
|
||||
}
|
||||
134
backend/app/Services/EventMediaProbe.php
Normal file
134
backend/app/Services/EventMediaProbe.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Process\ExecutableFinder;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Reads the duration of an audio or video file and extracts a poster frame
|
||||
* from a video, using ffprobe / ffmpeg when they are installed.
|
||||
*
|
||||
* Both are optional: without them a video is stored without a poster and a
|
||||
* recording without a duration. The upload itself never fails because of a
|
||||
* missing tool.
|
||||
*/
|
||||
class EventMediaProbe
|
||||
{
|
||||
private const TIMEOUT_SECONDS = 30;
|
||||
|
||||
public function durationMs(string $path): ?int
|
||||
{
|
||||
$ffprobe = $this->binary('ffprobe');
|
||||
if ($ffprobe === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
$ffprobe,
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
$path,
|
||||
]);
|
||||
$process->setTimeout(self::TIMEOUT_SECONDS);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('ffprobe failed', ['path' => $path, 'exception' => $e->getMessage()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$seconds = (float) trim($process->getOutput());
|
||||
|
||||
return $seconds > 0 ? (int) round($seconds * 1000) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a JPEG poster frame of the video to $targetPath.
|
||||
*
|
||||
* @return bool true when the frame was written
|
||||
*/
|
||||
public function posterFrame(string $videoPath, string $targetPath, ?int $durationMs = null): bool
|
||||
{
|
||||
$ffmpeg = $this->binary('ffmpeg');
|
||||
if ($ffmpeg === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A frame a little into the clip avoids the black first frame many
|
||||
// recordings start with; very short clips take the first frame.
|
||||
$seek = $durationMs !== null && $durationMs > 2000 ? '0.5' : '0';
|
||||
|
||||
$process = new Process([
|
||||
$ffmpeg,
|
||||
'-v', 'error',
|
||||
'-y',
|
||||
'-ss', $seek,
|
||||
'-i', $videoPath,
|
||||
'-frames:v', '1',
|
||||
'-q:v', '3',
|
||||
$targetPath,
|
||||
]);
|
||||
$process->setTimeout(self::TIMEOUT_SECONDS);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('ffmpeg poster extraction failed', ['path' => $videoPath, 'exception' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $process->isSuccessful() && is_file($targetPath) && filesize($targetPath) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-encode an audio recording as AAC in an MP4 container.
|
||||
*
|
||||
* @return bool true when the file was written
|
||||
*/
|
||||
public function transcodeAudioToM4a(string $sourcePath, string $targetPath): bool
|
||||
{
|
||||
$ffmpeg = $this->binary('ffmpeg');
|
||||
if ($ffmpeg === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$process = new Process([
|
||||
$ffmpeg,
|
||||
'-v', 'error',
|
||||
'-y',
|
||||
'-i', $sourcePath,
|
||||
'-vn',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '96k',
|
||||
'-movflags', '+faststart',
|
||||
'-f', 'mp4',
|
||||
$targetPath,
|
||||
]);
|
||||
$process->setTimeout(self::TIMEOUT_SECONDS * 4);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('ffmpeg audio transcode failed', ['path' => $sourcePath, 'exception' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $process->isSuccessful() && is_file($targetPath) && filesize($targetPath) > 0;
|
||||
}
|
||||
|
||||
private function binary(string $name): ?string
|
||||
{
|
||||
return (new ExecutableFinder)->find($name);
|
||||
}
|
||||
}
|
||||
283
backend/app/Services/EventMediaStore.php
Normal file
283
backend/app/Services/EventMediaStore.php
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMedia;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Stores a file as event media and creates the row.
|
||||
*
|
||||
* Images get an original, a preview and a thumbnail. Videos keep their
|
||||
* original file and get a poster frame (preview + thumbnail) when ffmpeg is
|
||||
* available. Voice notes are transcoded to AAC in an MP4 container when
|
||||
* ffmpeg is available, because browser recordings (WebM/Opus) lack duration
|
||||
* and index data and do not play in Safari at all. An event can carry any
|
||||
* number of voice notes. The key image points the event at the new
|
||||
* thumbnail. Shared by the API upload and the lifeline importer.
|
||||
*/
|
||||
class EventMediaStore
|
||||
{
|
||||
public const TYPE_IMAGE = 'image';
|
||||
|
||||
public const TYPE_VIDEO = 'video';
|
||||
|
||||
public const TYPE_AUDIO = 'audio';
|
||||
|
||||
public function __construct(
|
||||
private readonly EventMediaImageProcessor $processor,
|
||||
private readonly EventMediaProbe $probe,
|
||||
private readonly MediaCleaner $mediaCleaner,
|
||||
) {}
|
||||
|
||||
public function store(Event $event, UploadedFile $file, string $collection = 'gallery', ?string $caption = null): EventMedia
|
||||
{
|
||||
$type = $this->typeFor($file, $collection);
|
||||
$uuid = (string) Str::uuid();
|
||||
$directory = "event-media/{$event->user_id}/{$event->client_id}";
|
||||
|
||||
$attributes = match ($type) {
|
||||
self::TYPE_IMAGE => $this->storeImage($file, $directory, $uuid),
|
||||
self::TYPE_VIDEO => $this->storeVideo($file, $directory, $uuid),
|
||||
default => $this->storeAudio($file, $directory, $uuid),
|
||||
};
|
||||
|
||||
if ($collection === 'key_image') {
|
||||
$this->deleteExistingInCollection($event, 'key_image');
|
||||
}
|
||||
|
||||
$media = $event->media()->create([
|
||||
'uuid' => $uuid,
|
||||
'user_id' => $event->user_id,
|
||||
'collection' => $collection,
|
||||
'type' => $type,
|
||||
'caption' => $caption !== null && $caption !== '' ? $caption : null,
|
||||
'name' => $file->getClientOriginalName(),
|
||||
'disk' => 'local',
|
||||
...$attributes,
|
||||
]);
|
||||
|
||||
if ($collection === 'key_image') {
|
||||
$event->update(['image' => "/event-media/{$media->id}/thumb"]);
|
||||
}
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
public function typeFor(UploadedFile $file, string $collection): string
|
||||
{
|
||||
if ($collection === 'voice_note') {
|
||||
return self::TYPE_AUDIO;
|
||||
}
|
||||
|
||||
$mime = (string) ($file->getMimeType() ?: $file->getClientMimeType());
|
||||
|
||||
return str_starts_with($mime, 'video/') ? self::TYPE_VIDEO : self::TYPE_IMAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function storeImage(UploadedFile $file, string $directory, string $uuid): array
|
||||
{
|
||||
$processed = $this->processor->process($file);
|
||||
$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']);
|
||||
|
||||
return [
|
||||
'mime_type' => $processed['mime_type'],
|
||||
'path' => $path,
|
||||
'thumbnail_path' => $thumbnailPath,
|
||||
'preview_path' => $previewPath,
|
||||
'size' => $processed['size'],
|
||||
'duration_ms' => null,
|
||||
'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'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function storeVideo(UploadedFile $file, string $directory, string $uuid): array
|
||||
{
|
||||
$extension = $this->extensionFor($file, 'mp4');
|
||||
$path = "{$directory}/{$uuid}.{$extension}";
|
||||
$realPath = (string) $file->getRealPath();
|
||||
|
||||
Storage::disk('local')->putFileAs($directory, $file, "{$uuid}.{$extension}");
|
||||
|
||||
$durationMs = $this->probe->durationMs($realPath);
|
||||
$poster = $this->posterFor($realPath, $durationMs);
|
||||
|
||||
$attributes = [
|
||||
'mime_type' => $file->getMimeType() ?: ($file->getClientMimeType() ?: 'video/mp4'),
|
||||
'path' => $path,
|
||||
'thumbnail_path' => null,
|
||||
'preview_path' => null,
|
||||
'size' => $file->getSize(),
|
||||
'duration_ms' => $durationMs,
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
'thumbnail_width' => null,
|
||||
'thumbnail_height' => null,
|
||||
'preview_width' => null,
|
||||
'preview_height' => null,
|
||||
];
|
||||
|
||||
if ($poster !== null) {
|
||||
$previewPath = "{$directory}/{$uuid}_preview.jpg";
|
||||
$thumbnailPath = "{$directory}/{$uuid}_thumb.jpg";
|
||||
|
||||
Storage::disk('local')->put($previewPath, $poster['preview']);
|
||||
Storage::disk('local')->put($thumbnailPath, $poster['thumbnail']);
|
||||
|
||||
$attributes = [
|
||||
...$attributes,
|
||||
'thumbnail_path' => $thumbnailPath,
|
||||
'preview_path' => $previewPath,
|
||||
'width' => $poster['width'],
|
||||
'height' => $poster['height'],
|
||||
'thumbnail_width' => $poster['thumbnail_width'],
|
||||
'thumbnail_height' => $poster['thumbnail_height'],
|
||||
'preview_width' => $poster['preview_width'],
|
||||
'preview_height' => $poster['preview_height'],
|
||||
];
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function storeAudio(UploadedFile $file, string $directory, string $uuid): array
|
||||
{
|
||||
$transcoded = $this->transcodedAudio($file);
|
||||
|
||||
if ($transcoded !== null) {
|
||||
$path = "{$directory}/{$uuid}.m4a";
|
||||
Storage::disk('local')->put($path, (string) file_get_contents($transcoded));
|
||||
$durationMs = $this->probe->durationMs($transcoded);
|
||||
$size = (int) filesize($transcoded);
|
||||
@unlink($transcoded);
|
||||
|
||||
return $this->audioAttributes($path, 'audio/mp4', $size, $durationMs);
|
||||
}
|
||||
|
||||
// Without ffmpeg the recording is kept as delivered.
|
||||
$extension = $this->extensionFor($file, 'webm');
|
||||
$path = "{$directory}/{$uuid}.{$extension}";
|
||||
|
||||
Storage::disk('local')->putFileAs($directory, $file, "{$uuid}.{$extension}");
|
||||
|
||||
// Browsers record audio into containers libmagic reports as video
|
||||
// (WebM, MP4). The client's own type is the honest one here.
|
||||
$clientMime = (string) $file->getClientMimeType();
|
||||
$mime = str_starts_with($clientMime, 'audio/')
|
||||
? $clientMime
|
||||
: ($file->getMimeType() ?: 'audio/webm');
|
||||
|
||||
return $this->audioAttributes(
|
||||
$path,
|
||||
$mime,
|
||||
(int) $file->getSize(),
|
||||
$this->probe->durationMs((string) $file->getRealPath()),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Path of a temporary AAC/MP4 rendition of the upload, or null when
|
||||
* ffmpeg is unavailable or the input could not be decoded.
|
||||
*/
|
||||
private function transcodedAudio(UploadedFile $file): ?string
|
||||
{
|
||||
$target = tempnam(sys_get_temp_dir(), 'voice_').'.m4a';
|
||||
|
||||
if ($this->probe->transcodeAudioToM4a((string) $file->getRealPath(), $target)) {
|
||||
@unlink(substr($target, 0, -4));
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
@unlink($target);
|
||||
@unlink(substr($target, 0, -4));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function audioAttributes(string $path, string $mime, int $size, ?int $durationMs): array
|
||||
{
|
||||
return [
|
||||
'mime_type' => $mime,
|
||||
'path' => $path,
|
||||
'thumbnail_path' => null,
|
||||
'preview_path' => null,
|
||||
'size' => $size,
|
||||
'duration_ms' => $durationMs,
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
'thumbnail_width' => null,
|
||||
'thumbnail_height' => null,
|
||||
'preview_width' => null,
|
||||
'preview_height' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Poster frame of a video, run through the image pipeline so it gets the
|
||||
* same preview and thumbnail sizes as an uploaded photo.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function posterFor(string $videoPath, ?int $durationMs): ?array
|
||||
{
|
||||
$posterPath = tempnam(sys_get_temp_dir(), 'poster_').'.jpg';
|
||||
|
||||
try {
|
||||
if (! $this->probe->posterFrame($videoPath, $posterPath, $durationMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$poster = new UploadedFile($posterPath, 'poster.jpg', 'image/jpeg', null, true);
|
||||
|
||||
return $this->processor->process($poster);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
} finally {
|
||||
@unlink($posterPath);
|
||||
@unlink(substr($posterPath, 0, -4));
|
||||
}
|
||||
}
|
||||
|
||||
private function extensionFor(UploadedFile $file, string $fallback): string
|
||||
{
|
||||
$extension = strtolower((string) ($file->getClientOriginalExtension() ?: $file->guessExtension()));
|
||||
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?? '';
|
||||
|
||||
return $extension !== '' ? $extension : $fallback;
|
||||
}
|
||||
|
||||
private function deleteExistingInCollection(Event $event, string $collection): void
|
||||
{
|
||||
foreach ($event->media()->where('collection', $collection)->get() as $media) {
|
||||
$this->mediaCleaner->purgeMedia($media);
|
||||
$media->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
91
backend/app/Services/MediaCleaner.php
Normal file
91
backend/app/Services/MediaCleaner.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMedia;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Removes media files for every deletion path.
|
||||
*
|
||||
* Database rows disappear on their own through `cascadeOnDelete`, but the
|
||||
* files behind them do not. Paths are therefore collected *before* the rows
|
||||
* are deleted, and each deletion path routes through this service.
|
||||
*/
|
||||
class MediaCleaner
|
||||
{
|
||||
/**
|
||||
* Delete the files of an event's media. The rows are left to the
|
||||
* caller / database cascade.
|
||||
*/
|
||||
public function purgeEventMedia(Event $event): void
|
||||
{
|
||||
$this->deleteFiles($event->media()->get());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the files of every media row belonging to a user, plus the
|
||||
* user's settings background.
|
||||
*/
|
||||
public function purgeUserMedia(User $user): void
|
||||
{
|
||||
$this->deleteFiles(EventMedia::query()->where('user_id', $user->id)->get());
|
||||
|
||||
$this->delete('local', ["settings-media/{$user->id}/background.jpg"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the files of a single media row.
|
||||
*/
|
||||
public function purgeMedia(EventMedia $media): void
|
||||
{
|
||||
$this->deleteFiles([$media]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param iterable<int, EventMedia> $mediaItems
|
||||
*/
|
||||
private function deleteFiles(iterable $mediaItems): void
|
||||
{
|
||||
$pathsByDisk = [];
|
||||
|
||||
foreach ($mediaItems as $media) {
|
||||
$disk = $media->disk ?: 'local';
|
||||
|
||||
foreach ([$media->path, $media->preview_path, $media->thumbnail_path] as $path) {
|
||||
if (is_string($path) && $path !== '') {
|
||||
$pathsByDisk[$disk][] = $path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pathsByDisk as $disk => $paths) {
|
||||
$this->delete($disk, $paths);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $paths
|
||||
*/
|
||||
private function delete(string $disk, array $paths): void
|
||||
{
|
||||
if ($paths === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Storage::disk($disk)->delete($paths);
|
||||
} catch (\Throwable $e) {
|
||||
// A failed file delete must not abort the surrounding deletion —
|
||||
// an orphaned file is recoverable, a half-deleted account is not.
|
||||
Log::warning('Media cleanup failed', [
|
||||
'disk' => $disk,
|
||||
'paths' => $paths,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
169
backend/app/Support/EventFields.php
Normal file
169
backend/app/Support/EventFields.php
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Single source of truth for event field validation and column mapping.
|
||||
*
|
||||
* Both the CRUD endpoints and the batch sync endpoint build their rules from
|
||||
* here, so a field can never be validated on one path and waved through on
|
||||
* the other.
|
||||
*/
|
||||
final class EventFields
|
||||
{
|
||||
/**
|
||||
* Category keys the client offers. Kept as keys, not labels, so the
|
||||
* wording can change without touching stored data.
|
||||
*/
|
||||
public const CATEGORIES = [
|
||||
'family', 'friends', 'love', 'work', 'education', 'health',
|
||||
'travel', 'home', 'hobby', 'loss', 'other',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validation rules per API field, without any presence rule.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public static function rules(): array
|
||||
{
|
||||
return [
|
||||
// Empty is allowed: an event is created with its first entry,
|
||||
// which may well be a photo or a note rather than a title.
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'date' => ['date_format:Y-m-d'],
|
||||
// A span ends on or after the day it starts; null means one day.
|
||||
'endDate' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:date'],
|
||||
'emotion' => ['numeric', 'min:-1', 'max:1'],
|
||||
'milestone' => ['boolean'],
|
||||
'location' => ['nullable', 'string', 'max:255'],
|
||||
'category' => ['nullable', 'string', 'in:'.implode(',', self::CATEGORIES)],
|
||||
'people' => ['nullable', 'array', 'max:30'],
|
||||
// The request middleware turns '' into null; blanks are dropped in toColumns.
|
||||
'people.*' => ['nullable', 'string', 'max:80'],
|
||||
'customColor' => ['nullable', 'string', 'max:20'],
|
||||
'gradientPreset' => ['nullable', 'integer', 'min:0', 'max:9'],
|
||||
'image' => ['nullable', 'string', 'max:500'],
|
||||
'keyImageTitle' => ['nullable', 'string', 'max:255'],
|
||||
'note' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* API fields that must be present when an event is created.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function required(): array
|
||||
{
|
||||
return ['date', 'emotion'];
|
||||
}
|
||||
|
||||
/**
|
||||
* API fields that must be sent on create, but may be empty.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function present(): array
|
||||
{
|
||||
return ['title'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules for creating an event: required fields are mandatory.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public static function storeRules(string $prefix = ''): array
|
||||
{
|
||||
return self::withPresence($prefix, fn (string $field): array => match (true) {
|
||||
in_array($field, self::required(), true) => ['required'],
|
||||
in_array($field, self::present(), true) => ['present'],
|
||||
default => ['sometimes'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules for updating an event: every field is optional, but a field that
|
||||
* *is* sent must still be valid and required fields may not be nulled.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public static function updateRules(string $prefix = ''): array
|
||||
{
|
||||
return self::withPresence($prefix, fn (string $field): array => in_array($field, self::required(), true)
|
||||
? ['sometimes', 'required']
|
||||
: ['sometimes']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map validated API fields onto their database columns.
|
||||
*
|
||||
* Only keys actually present in the payload are returned, so a partial
|
||||
* update never clears a column the client did not mention.
|
||||
*
|
||||
* @param array<string, mixed> $validated
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function toColumns(array $validated): array
|
||||
{
|
||||
$columns = [
|
||||
'title' => 'title',
|
||||
'date' => 'date',
|
||||
'endDate' => 'end_date',
|
||||
'emotion' => 'emotion',
|
||||
'milestone' => 'milestone',
|
||||
'location' => 'location',
|
||||
'category' => 'category',
|
||||
'people' => 'people',
|
||||
'customColor' => 'custom_color',
|
||||
'gradientPreset' => 'gradient_preset',
|
||||
'image' => 'image',
|
||||
'keyImageTitle' => 'key_image_title',
|
||||
'note' => 'note',
|
||||
];
|
||||
|
||||
$data = [];
|
||||
|
||||
foreach ($columns as $field => $column) {
|
||||
if (array_key_exists($field, $validated)) {
|
||||
$data[$column] = $validated[$field];
|
||||
}
|
||||
}
|
||||
|
||||
// The column is NOT NULL; the request middleware turns '' into null.
|
||||
if (array_key_exists('title', $data) && $data['title'] === null) {
|
||||
$data['title'] = '';
|
||||
}
|
||||
|
||||
if (array_key_exists('people', $data)) {
|
||||
// Trimmed, without blanks and duplicates; an empty list is null.
|
||||
$people = array_values(array_unique(array_filter(
|
||||
array_map(fn ($name) => trim((string) $name), $data['people'] ?? []),
|
||||
fn (string $name) => $name !== ''
|
||||
)));
|
||||
$data['people'] = $people === [] ? null : $people;
|
||||
}
|
||||
|
||||
if (array_key_exists('milestone', $data)) {
|
||||
$data['milestone'] = (bool) $data['milestone'];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string): array<int, string> $presence
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private static function withPresence(string $prefix, callable $presence): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
foreach (self::rules() as $field => $fieldRules) {
|
||||
$rules[$prefix.$field] = [...$presence($field), ...$fieldRules];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
132
backend/app/Support/SettingsSchema.php
Normal file
132
backend/app/Support/SettingsSchema.php
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Rules\ScalarSetting;
|
||||
|
||||
/**
|
||||
* Structural schema for the user settings document.
|
||||
*
|
||||
* The settings blob is stored as JSON, so validation is the only thing
|
||||
* standing between the client and whatever ends up being read back as
|
||||
* `preset.id` or a shader uniform. Keys are whitelisted, values are bounded
|
||||
* and required to be scalar, so no nested structure can be smuggled in.
|
||||
*/
|
||||
final class SettingsSchema
|
||||
{
|
||||
/**
|
||||
* Current schema version, stored alongside the settings so a later
|
||||
* migration can tell old documents from new ones.
|
||||
*/
|
||||
public const VERSION = 1;
|
||||
|
||||
/**
|
||||
* Maximum number of stored presets per user.
|
||||
*/
|
||||
public const MAX_PRESETS = 50;
|
||||
|
||||
/**
|
||||
* Maximum length of any string value inside the settings document.
|
||||
*/
|
||||
public const MAX_STRING_LENGTH = 4096;
|
||||
|
||||
/**
|
||||
* Top-level settings keys the API accepts. Anything else is rejected
|
||||
* rather than quietly persisted.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowedKeys(): array
|
||||
{
|
||||
return [
|
||||
'version',
|
||||
'theme',
|
||||
'floatingLines',
|
||||
'appearance',
|
||||
'accentColor',
|
||||
'language',
|
||||
'emotionGradientStart',
|
||||
'emotionGradientEnd',
|
||||
'timelineZoom',
|
||||
'timelineScrollLeft',
|
||||
'showFps',
|
||||
'presets',
|
||||
'activePresetId',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys allowed inside `floatingLines`, mirroring FLOATING_LINES_DEFAULTS
|
||||
* in the Quasar settings store.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function allowedFloatingLineKeys(): array
|
||||
{
|
||||
return [
|
||||
// Lines
|
||||
'speed', 'lineCount', 'spread', 'fanSpread', 'lineSharpness',
|
||||
'lineThickness', 'waveFrequency', 'bezierCurvature', 'circleRadius',
|
||||
'glowSize', 'glowStrength', 'lineBrightness',
|
||||
// Background
|
||||
'bgCenter', 'bgEdge', 'gradientStops', 'backgroundImage',
|
||||
// Horizon
|
||||
'horizonMode', 'horizonOpacity', 'horizonBlend',
|
||||
// Labels
|
||||
'labelSize', 'labelColor', 'labelOpacity', 'labelConnectorLength',
|
||||
// Dot border
|
||||
'dotBorderWidth', 'dotBorderColor',
|
||||
// Mode
|
||||
'lineMode', 'staticLineColor', 'staticLineShadowStrength',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation rules for the settings document.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public static function rules(): array
|
||||
{
|
||||
return [
|
||||
'settings' => ['required', 'array'],
|
||||
'settings.version' => ['nullable', 'integer', 'min:1', 'max:'.self::VERSION],
|
||||
...self::snapshotRules('settings.'),
|
||||
'settings.timelineScrollLeft' => ['nullable', 'numeric', 'min:0'],
|
||||
|
||||
'settings.presets' => ['nullable', 'array', 'max:'.self::MAX_PRESETS],
|
||||
'settings.presets.*' => ['array'],
|
||||
'settings.presets.*.id' => ['required', 'string', 'max:100'],
|
||||
'settings.presets.*.name' => ['required', 'string', 'max:100'],
|
||||
'settings.presets.*.updatedAt' => ['nullable', 'numeric'],
|
||||
'settings.presets.*.settings' => ['required', 'array'],
|
||||
...self::snapshotRules('settings.presets.*.settings.'),
|
||||
|
||||
'settings.activePresetId' => ['nullable', 'string', 'max:100'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules for the fields a preset snapshot shares with the top-level
|
||||
* settings. Every field needs an explicit rule: Laravel's `validated()`
|
||||
* only returns keys that have one, so a field left out here would be
|
||||
* silently dropped when a preset is saved.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
private static function snapshotRules(string $prefix): array
|
||||
{
|
||||
return [
|
||||
$prefix.'theme' => ['nullable', 'string', 'max:50'],
|
||||
$prefix.'appearance' => ['nullable', 'string', 'max:50'],
|
||||
$prefix.'accentColor' => ['nullable', 'string', 'max:50'],
|
||||
$prefix.'language' => ['nullable', 'string', 'max:10'],
|
||||
$prefix.'emotionGradientStart' => ['nullable', 'string', 'max:20'],
|
||||
$prefix.'emotionGradientEnd' => ['nullable', 'string', 'max:20'],
|
||||
$prefix.'timelineZoom' => ['nullable', 'numeric', 'min:0.1', 'max:10'],
|
||||
$prefix.'showFps' => ['nullable', 'boolean'],
|
||||
$prefix.'floatingLines' => ['nullable', 'array'],
|
||||
$prefix.'floatingLines.*' => ['nullable', new ScalarSetting(self::MAX_STRING_LENGTH)],
|
||||
];
|
||||
}
|
||||
}
|
||||
19
backend/config/livewire.php
Normal file
19
backend/config/livewire.php
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|---------------------------------------------------------------------------
|
||||
| Layout
|
||||
|---------------------------------------------------------------------------
|
||||
|
|
||||
| Livewire ships with `layouts::app` as its default, which expects a view
|
||||
| registered under the `layouts` namespace. This application keeps its
|
||||
| layout at `resources/views/components/layouts/app.blade.php`, so the
|
||||
| component layout is pointed at that view explicitly.
|
||||
|
|
||||
*/
|
||||
|
||||
'component_layout' => 'components.layouts.app',
|
||||
|
||||
];
|
||||
350
backend/database/data/lifelines/benz.json
Normal file
350
backend/database/data/lifelines/benz.json
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
{
|
||||
"person": {
|
||||
"name": "Der Benz (Auto)",
|
||||
"email": "benz@thats-me.app",
|
||||
"birthYear": 1981
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"age": 0,
|
||||
"month": 3,
|
||||
"title": "Abholung im Werk",
|
||||
"note": "Ein Verkäufer fotografiert Heinz, 45, im Anzug neben seinem funkelnagelneuen hellelfenbeinfarbenen Mercedes 230 E vor dem Mercedes-Werk in Sindelfingen, die Hand auf dem Stern.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 0,
|
||||
"month": 5,
|
||||
"title": "Erste Urlaubsfahrt",
|
||||
"note": "Ein Foto am Brennerpass: der Benz mit Dachgepäckträger, Heinz, seine Frau Gisela und die 16-jährige Sabine davor, Sabine mit Walkman und genervtem Blick.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 0,
|
||||
"month": 9,
|
||||
"title": "Erste Beule",
|
||||
"note": "Heinz fotografiert die erste Delle im hinteren Kotflügel auf dem Supermarktparkplatz, daneben ein zerknirschter Einkaufswagen.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 1,
|
||||
"month": 6,
|
||||
"title": "Sabines Fahrstunden",
|
||||
"note": "Gisela fotografiert Sabine, 17, mit weißen Knöcheln am Lenkrad des Benz auf einem leeren Parkplatz, Heinz auf dem Beifahrersitz mit angespanntem Gesicht.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 2,
|
||||
"month": 7,
|
||||
"title": "Adria mit vier Personen",
|
||||
"note": "Ein Foto von einem Campingplatz an der Adria: der Benz mit offenen Türen, ein Zelt daneben, Sabine und ihre Freundin Petra sonnen sich auf der Motorhaube.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 3,
|
||||
"month": 4,
|
||||
"title": "100.000 Kilometer",
|
||||
"note": "Heinz fotografiert den Kilometerzähler beim Umspringen auf 100.000, seine Hand mit Ehering am Lenkrad.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 4,
|
||||
"month": 8,
|
||||
"title": "Sabines Abifeier",
|
||||
"note": "Ein Foto vor dem Gymnasium: sechs Abiturienten quetschen sich in und auf den Benz, Sabine mit Sektflasche auf dem Dach, Heinz im Hintergrund entsetzt.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 5,
|
||||
"month": 2,
|
||||
"title": "Winter ohne Garage",
|
||||
"note": "Heinz fotografiert den völlig eingeschneiten Benz vor dem Haus, nur der Stern schaut aus dem Schnee.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 7,
|
||||
"month": 5,
|
||||
"title": "Hochzeitsauto",
|
||||
"note": "Der Hochzeitsfotograf hält den Benz mit weißen Bändern und Blumen auf der Haube fest, Sabine im Brautkleid und ihr Mann Jörg steigen vor der Kirche aus, Heinz hält die Tür.",
|
||||
"area": "Hochzeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 9,
|
||||
"month": 10,
|
||||
"title": "Erster Rost",
|
||||
"note": "Heinz fotografiert in der Werkstatt den Wagenheber und die aufgebockte Karosserie, ein Mechaniker zeigt mit dem Finger auf braune Stellen am Radlauf.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 10,
|
||||
"month": 3,
|
||||
"title": "Zehn Jahre",
|
||||
"note": "Gisela fotografiert Heinz, der den Benz zum zehnten Geburtstag mit einem Eimer und Schwamm poliert, ein selbstgemaltes Schild lehnt an der Stoßstange.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 12,
|
||||
"month": 6,
|
||||
"title": "Max' erste Fahrt",
|
||||
"note": "Sabine fotografiert Heinz, der den wenige Tage alten Enkel Max in einer Babyschale auf die Rückbank des Benz setzt, überfordert und stolz.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 14,
|
||||
"month": 7,
|
||||
"title": "Norwegen mit Anhänger",
|
||||
"note": "Ein Foto an einem norwegischen Fjord: der Benz mit einem kleinen Wohnwagen, Heinz und Gisela mit Regenjacken, der Wagen ist komplett verdreckt.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 16,
|
||||
"month": 11,
|
||||
"title": "Gisela stirbt",
|
||||
"note": "Sabine fotografiert den Benz auf dem Friedhofsparkplatz im Regen, Heinz sitzt allein am Steuer und schaut geradeaus.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 17,
|
||||
"month": 4,
|
||||
"title": "Heinz gibt den Schlüssel ab",
|
||||
"note": "Jörg fotografiert Heinz, 61, wie er Sabine in der Einfahrt den Schlüssel des Benz in die Hand legt, beide sehen nicht in die Kamera.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Heinz, 61, and his daughter Sabine standing in the driveway of a suburban house beside the beige Mercedes W123 saloon, Heinz placing the car key into Sabine's open hand, both looking at the key and not at the camera. The car is completely intact and correctly built: all four doors shut and flush with the body, the exterior mirror on the outside of the door, no panel removed or reversed."
|
||||
},
|
||||
{
|
||||
"age": 17,
|
||||
"month": 9,
|
||||
"title": "Familienkutsche",
|
||||
"note": "Sabine fotografiert den vollgepackten Benz vor dem Kindergarten: Kindersitz, Matschhose, ein halber Apfel im Fußraum.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "The beige Mercedes W123 saloon parked at the kerb in front of a kindergarten building on an autumn morning, seen from the side. The near-side rear door stands wide open towards the camera and is complete in every part: door panel, window frame, glass, handle and hinges at the front edge, showing a child seat on the back seat, muddy rain trousers and a half-eaten apple in the footwell. The other three doors are closed and flush with the body; the car has exactly four doors, no opening or missing panel anywhere else."
|
||||
},
|
||||
{
|
||||
"age": 19,
|
||||
"month": 8,
|
||||
"title": "Gardasee",
|
||||
"note": "Jörg fotografiert Sabine und den 7-jährigen Max, die auf der Motorhaube des Benz mit Blick auf den Gardasee ein Eis essen.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 20,
|
||||
"month": 5,
|
||||
"title": "Max lernt Gänge",
|
||||
"note": "Sabine fotografiert Max, 8, auf Heinz' Schoß am Steuer des Benz auf einem Feldweg, beide grinsen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 22,
|
||||
"month": 2,
|
||||
"title": "TÜV verweigert",
|
||||
"note": "Jörg fotografiert den TÜV-Prüfbericht (unleserlich) auf der Motorhaube und Sabine, die sich die Hand vor das Gesicht hält.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 22,
|
||||
"month": 3,
|
||||
"title": "Unter der Plane",
|
||||
"note": "Sabine fotografiert den Benz unter einer grauen Plane in der Scheune ihrer Nachbarn, nur die Räder schauen hervor.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 25,
|
||||
"month": 6,
|
||||
"title": "Max und die Plane",
|
||||
"note": "Sabine fotografiert Max, 13, der in der Scheune die Plane vom Benz zieht und mit offenem Mund davorsteht.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 26,
|
||||
"month": 4,
|
||||
"title": "Heinz stirbt",
|
||||
"note": "Max fotografiert den staubigen Benz in der Scheune, auf dem Fahrersitz liegt Heinz' alte Schiebermütze.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ",
|
||||
"imageCharacters": [
|
||||
"benz"
|
||||
],
|
||||
"imagePrompt": "The dusty beige Mercedes W123 standing alone in a dim barn between old farm tools, seen through the open driver's window: an old grey flat cap lies on the driver's seat, dust on the dashboard, a shaft of light from a small window. Nobody is in the picture, no person anywhere."
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 9,
|
||||
"title": "Max' erste Schrauberei",
|
||||
"note": "Sabine fotografiert Max, 16, mit ölverschmierten Händen und einem Reparaturhandbuch unter dem offenen Motorraum des Benz.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 7,
|
||||
"title": "Motor läuft wieder",
|
||||
"note": "Ein Freund filmt: Max, 17, springt vom Fahrersitz, als der Motor nach sieben Jahren zum ersten Mal wieder anspringt, die Scheune voller blauem Rauch.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 30,
|
||||
"month": 3,
|
||||
"title": "Bewerbungsfahrt",
|
||||
"note": "Sabine fotografiert Max, 18, im Anzug neben dem Benz vor der Ausbildungswerkstatt, das Auto halb restauriert mit grundierten Kotflügeln.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 31,
|
||||
"month": 10,
|
||||
"title": "Schweißarbeiten",
|
||||
"note": "Ein Kollege fotografiert Max mit Schweißmaske unter dem aufgebockten Benz, Funkenflug.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 5,
|
||||
"title": "Neuer Lack",
|
||||
"note": "Der Lackierer fotografiert Max vor dem frisch lackierten hellelfenbeinfarbenen Benz in der Lackierkabine, Max streicht mit der Hand über die Haube.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 8,
|
||||
"title": "Sabine fährt wieder",
|
||||
"note": "Max fotografiert seine Mutter Sabine, 49, die zum ersten Mal seit elf Jahren wieder am Steuer des Benz sitzt und weint.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 6,
|
||||
"title": "H-Kennzeichen",
|
||||
"note": "Ein Foto vor der Zulassungsstelle: Max schraubt das neue H-Kennzeichen an den Benz, Sabine hält das alte Kennzeichen hoch.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "In front of a vehicle registration office: Max, 25, kneeling at the front bumper of the beige Mercedes W123 and screwing on the new number plate with a screwdriver, Sabine standing beside him holding the old plate up in her hand. Both number plates are flat rectangular plates seen almost straight on, plain white with a plain black border and no readable characters, completely undistorted and not warped or bent."
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 7,
|
||||
"title": "Oldtimertreffen",
|
||||
"note": "Ein Besucher fotografiert Max und den Benz auf einem Oldtimertreffen zwischen anderen W123, ein Schild mit dem Baujahr auf der Haube (unleserlich).",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 36,
|
||||
"month": 9,
|
||||
"title": "Max' Hochzeitsauto",
|
||||
"note": "Der Hochzeitsfotograf hält den Benz mit weißen Bändern fest, diesmal steigen Max und seine Frau Lea vor demselben Kirchenportal aus, Sabine hält die Tür.",
|
||||
"area": "Hochzeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 38,
|
||||
"month": 4,
|
||||
"title": "Panne auf der A3",
|
||||
"note": "Lea fotografiert Max, der auf dem Standstreifen mit Warnweste neben dem Benz mit offener Haube steht, Abschleppwagen im Anflug.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 39,
|
||||
"month": 6,
|
||||
"title": "Max' Tochter",
|
||||
"note": "Lea fotografiert Max, der seine neugeborene Tochter Emma in einer Babyschale auf die Rückbank des Benz setzt – dieselbe Rückbank wie 1993.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Max, 30, putting a newborn baby in a baby car seat onto the back seat of the beige Mercedes W123, seen from the pavement. Exactly one single door of the car is open: the rear door on the pavement side, hinged at its front edge and swung wide open. All three other doors, including the front door next to it, are firmly shut and flush with the body. Max stands in that one open doorway, in the gap between the open door and the car body, bending forward into the car with both arms above the back seat; he does not stand behind the door and no arm reaches over or through it."
|
||||
},
|
||||
{
|
||||
"age": 41,
|
||||
"month": 7,
|
||||
"title": "Brenner, zweite Runde",
|
||||
"note": "Ein Foto am Brennerpass: der Benz mit Dachgepäckträger, Max, Lea und die zweijährige Emma davor – nachgestellt wie das Foto von 1981, Sabine hält das alte Bild daneben.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 43,
|
||||
"month": 10,
|
||||
"title": "Sabine wird 60",
|
||||
"note": "Max fotografiert Sabine, die zu ihrem 60. Geburtstag vor dem Benz eine Fahrt mit Emma auf dem Beifahrersitz macht, beide winken.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 44,
|
||||
"month": 3,
|
||||
"title": "300.000 Kilometer",
|
||||
"note": "Max fotografiert den Kilometerzähler beim Umspringen auf 300.000, seine Hand mit Ehering am Lenkrad, auf dem Beifahrersitz Emma, 5.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 44,
|
||||
"month": 12,
|
||||
"title": "Drei Generationen",
|
||||
"note": "Ein Nachbar fotografiert Sabine, Max und Emma vor dem Benz in der Einfahrt, Emma sitzt auf der Haube, dahinter das Haus, in dem Heinz 1981 den Wagen zum ersten Mal parkte.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
}
|
||||
]
|
||||
}
|
||||
513
backend/database/data/lifelines/bruno.json
Normal file
513
backend/database/data/lifelines/bruno.json
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
{
|
||||
"person": {
|
||||
"name": "Bruno",
|
||||
"email": "bruno@thats-me.app",
|
||||
"birthYear": 2014
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"age": 0,
|
||||
"month": 3,
|
||||
"title": "Geboren",
|
||||
"note": "Die Züchterin fotografiert den Wurf von sieben Welpen, Bruno ist der mit dem weißen Fleck auf der Nase, der als Einziger schläft.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "In a breeder's living room on a soft blanket: a litter of seven newborn yellow Labrador puppies sleeping in a pile next to their mother; one puppy in the middle with a small white patch of fur on the bridge of his nose is the only one fast asleep on his back, tiny, eyes still closed; warm indoor light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": []
|
||||
},
|
||||
{
|
||||
"age": 0,
|
||||
"month": 5,
|
||||
"title": "Einzug bei Lena",
|
||||
"note": "Lena fotografiert Bruno in seiner ersten Nacht im neuen Zuhause: im Körbchen, aber mit dem Kopf auf Lenas Hausschuh.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "First night in a new home: Bruno as a tiny eight-week-old puppy curled up in a wicker dog basket in a hallway, his head resting on a worn grey felt house slipper he dragged in, the puppy tiny compared to the basket; dim evening lamp light, tender",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 0,
|
||||
"month": 6,
|
||||
"title": "Erste Impfung",
|
||||
"note": "Die Tierarzthelferin fotografiert Bruno auf dem Metalltisch, alle vier Pfoten gespreizt, Blick auf die Spritze.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "At the vet: Bruno as a three-month-old puppy standing on a shiny stainless steel examination table, all four legs spread wide and stiff, staring anxiously at a syringe in a vet assistant's hand at the edge of the frame; bright clinical light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 0,
|
||||
"month": 7,
|
||||
"title": "Sofakissen zerlegt",
|
||||
"note": "Lena fotografiert Bruno inmitten einer Wolke aus Füllwatte, ein Kissenzipfel hängt ihm noch aus dem Maul.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "In a living room: Bruno as a four-month-old puppy sitting alone proudly in the middle of an exploded sofa cushion, white polyester stuffing everywhere like a cloud, a corner of the cushion cover hanging from his mouth; only one dog; daylight from a window",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 0,
|
||||
"month": 8,
|
||||
"title": "Welpenschule",
|
||||
"note": "Die Trainerin fotografiert Bruno, der statt „Sitz“ zu machen die Dackeldame Frieda anhimmelt.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "At an outdoor puppy training class on a lawn: Bruno as a five-month-old puppy ignoring the 'sit' command and gazing adoringly at a small brown dachshund puppy sitting next to him; other puppies and owners' legs blurred in the background; summer evening light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 0,
|
||||
"month": 11,
|
||||
"title": "Erste Ostsee",
|
||||
"note": "Lena fotografiert Bruno am Strand von Warnemünde, der vor jeder Welle einen Satz rückwärts macht.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "On a wide sandy Baltic Sea beach in November: Bruno as an eight-month-old puppy mid-jump backwards away from a small incoming wave, ears flying, startled expression, grey sea, empty beach",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 1,
|
||||
"month": 1,
|
||||
"title": "Erster Schnee",
|
||||
"note": "Ein Foto im Park zeigt Bruno mit der Schnauze komplett im Schnee vergraben, nur die Rute ragt heraus.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "In a snowy city park: Bruno has buried his whole snout deep in fresh powder snow, only his ears and his wagging tail stick up, snow on his back, joyful; bright overcast daylight, bare trees, wide shot",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 1,
|
||||
"month": 4,
|
||||
"title": "Kastration",
|
||||
"note": "Lena fotografiert Bruno mit Halskrause, der beim Versuch, durch die Tür zu gehen, am Rahmen hängen bleibt.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "Indoors in an apartment hallway: Bruno wearing a large transparent plastic veterinary cone collar after surgery, stuck sideways in a door frame because the cone is too wide, looking puzzled and slightly offended at the camera; full body visible",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 1,
|
||||
"month": 6,
|
||||
"title": "Erste Wanderung",
|
||||
"note": "Ein Wanderer fotografiert Lena und Bruno auf dem Brocken, Bruno trägt seinen eigenen kleinen Rucksack mit Leckerlis.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "On the rocky summit of the Brocken mountain in the Harz on a sunny June day: Bruno standing proudly wearing a small red dog backpack, tongue out, Lena crouching next to him in hiking clothes with trekking poles, wide view over forested hills",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"lena"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 1,
|
||||
"month": 9,
|
||||
"title": "Ein Kilo Butter",
|
||||
"note": "Lena fotografiert die leere Butterschale auf dem Küchenboden und Bruno daneben, der so tut, als hätte er das nie gesehen.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "On a kitchen floor: an empty white ceramic butter dish licked clean lying on the tiles, and next to it Bruno sitting and looking pointedly away from the camera at the wall, pretending innocence, a bit of butter on his whiskers; morning light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 1,
|
||||
"month": 12,
|
||||
"title": "Weihnachtsbaum umgeworfen",
|
||||
"note": "Ein Foto vom liegenden Weihnachtsbaum, darunter Bruno mit einer Lichterkette um den Hals.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "In a living room on Christmas Eve: a fully decorated Christmas tree toppled over onto the carpet, baubles scattered, and underneath its branches Bruno peeking out with a string of warm fairy lights tangled around his neck, guilty but cute; cosy evening light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 2,
|
||||
"month": 3,
|
||||
"title": "Begleithundprüfung bestanden",
|
||||
"note": "Die Prüferin fotografiert Bruno mit Urkunde im Maul, Lena hält die Leine und sieht erleichterter aus als er.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "On a dog training field in spring: Bruno sitting proudly holding a rolled-up blank white paper tied with a red ribbon in his mouth (no writing), Lena kneeling next to him holding the leash and looking more relieved than he does; overcast daylight",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"lena"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 2,
|
||||
"month": 8,
|
||||
"title": "Erstes Eis in Italien",
|
||||
"note": "Ein Kellner am Gardasee fotografiert Bruno mit einer Kugel Hundeeis, die er in einer Sekunde verschlingt.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "At a lakeside café terrace on Lake Garda in August: Bruno sitting at a café table, snout right in front of a small plain paper cup of white dog ice cream held by a hand, eyes wide with anticipation, blue lake and mountains blurred behind; bright summer sun",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 3,
|
||||
"month": 5,
|
||||
"title": "Jan zieht ein",
|
||||
"note": "Lena fotografiert Bruno, der demonstrativ auf Jans Seite des Betts liegt und ihn nicht anschaut.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "In a bedroom in the morning: Bruno lying stretched out demonstratively across one entire side of a double bed on top of the duvet, head turned away from Jan who stands at the edge of the frame next to moving boxes, looking amused; soft window light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"jan"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 3,
|
||||
"month": 10,
|
||||
"title": "Im Fuchskot gewälzt",
|
||||
"note": "Jan fotografiert Bruno in der Badewanne, Lena mit Wäscheklammer auf der Nase im Hintergrund.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "In a small bathroom: Bruno sitting miserably in a white bathtub full of foam, wet and sad, while Lena behind him holds a shower head with a wooden clothes peg on her nose, laughing; warm bathroom light",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"lena"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 4,
|
||||
"month": 2,
|
||||
"title": "Frieda wiedergetroffen",
|
||||
"note": "Friedas Frauchen fotografiert Bruno und Frieda, die sich nach drei Jahren am Hundeplatz wiedererkennen und minutenlang umeinander springen.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "On a fenced dog park in February: Bruno and a small brown dachshund bouncing around each other mid-air in pure joy, both with mouths open in a play grin, muddy grass, grey winter light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 4,
|
||||
"month": 7,
|
||||
"title": "Planschbecken",
|
||||
"note": "Jan fotografiert Bruno bei 36 Grad im Kinderplanschbecken auf dem Balkon, nur die Nase über Wasser.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "On a small sunny city balcony in July: Bruno lying fully submerged in a small blue inflatable children's paddling pool, only his nose, eyes and ears above the water, blissful expression, harsh midday summer light, potted plants around, balcony railing visible",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 4,
|
||||
"month": 11,
|
||||
"title": "Erste graue Haare",
|
||||
"note": "Ein Nahfoto von Brunos Schnauze mit den ersten weißen Haaren am Kinn, Lena hat es „Silberfuchs“ genannt.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Extreme close-up of Bruno's muzzle: the first few white hairs on his chin clearly visible, brown eyes looking softly at the camera, shallow depth of field, warm autumn window light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 5,
|
||||
"month": 4,
|
||||
"title": "Emma kommt",
|
||||
"note": "Jan fotografiert Bruno, der neben dem Kinderwagen liegt und seit drei Stunden nicht aufgestanden ist.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "In a living room in April: a grey baby stroller with newborn Emma asleep inside, and Bruno lying pressed against the stroller wheel on the floor, chin on his paws, keeping watch, calm and protective; soft daylight",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"emma"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 5,
|
||||
"month": 9,
|
||||
"title": "Zecke",
|
||||
"note": "Lena fotografiert Bruno beim Tierarzt mit geschorener Stelle am Bein nach dem Borreliose-Schreck – Entwarnung.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "At a veterinary clinic: Bruno sitting on the floor with a small shaved patch on his front leg with a tiny bandage, tilting his head at the camera, Lena's hand ruffling his ears reassuringly; clinical light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"lena"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 6,
|
||||
"month": 3,
|
||||
"title": "Alle den ganzen Tag zu Hause",
|
||||
"note": "Ein Foto vom Homeoffice-Schreibtisch: Jan am Laptop, Lena am Telefon, Bruno auf beiden Füßen – das beste Jahr seines Lebens.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Home office during lockdown in March 2020: a wide shot of a living room turned office, Jan typing on a laptop at a desk, Lena on the phone at the dining table, and Bruno lying sprawled across both of their feet under the table, utterly content; daylight",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"jan",
|
||||
"lena"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 6,
|
||||
"month": 10,
|
||||
"title": "„Buno“",
|
||||
"note": "Ein Video-Standbild von Emma, die zum ersten Mal „Buno“ sagt und ihm die Hand auf die Nase legt.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "In a living room: toddler Emma places her small hand on Bruno's nose, Bruno sitting very still and patient, both face to face at eye level; warm afternoon light, slightly blurry like a video still",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"emma"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 7,
|
||||
"month": 6,
|
||||
"title": "Kreuzbandriss",
|
||||
"note": "Lena fotografiert Bruno nach der OP in der Tierklinik, rasiertes Bein, Kopf auf ihrem Schoß.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "In a veterinary clinic recovery room: Bruno lying on a blanket after knee surgery, one hind leg shaved and stitched, his head resting heavily in Lena's lap as she sits on the floor, his eyes half closed; cool clinical light, emotional",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"lena"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 7,
|
||||
"month": 8,
|
||||
"title": "Unterwasserlaufband",
|
||||
"note": "Die Physiotherapeutin fotografiert Bruno auf dem Unterwasserlaufband, Blick wie ein alter Mann in der Aquagymnastik.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "In a dog physiotherapy practice: Bruno walking slowly on an underwater treadmill inside a glass tank, water up to his chest, with the resigned dignified expression of an old man at aqua aerobics; bright practice light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 7,
|
||||
"month": 12,
|
||||
"title": "Wieder auf dem Sofa",
|
||||
"note": "Jan fotografiert Bruno, der zum ersten Mal seit der OP wieder allein aufs Sofa springt und sehr zufrieden mit sich ist.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "In a living room at Christmas: Bruno has just jumped onto a grey sofa for the first time after surgery and sits there extremely pleased with himself, chest out, Christmas tree lights in the background; cosy evening light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 8,
|
||||
"month": 5,
|
||||
"title": "Haus mit Garten",
|
||||
"note": "Lena fotografiert Bruno am Umzugstag mitten auf dem Rasen des neuen Gartens, auf dem Rücken, alle Viere in der Luft.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Moving day in May: Bruno lying on his back in the middle of the green lawn of a small suburban garden, all four legs in the air, tongue lolling, pure bliss, moving boxes and a house with an open patio door in the background; bright spring sun",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 8,
|
||||
"month": 9,
|
||||
"title": "Igel",
|
||||
"note": "Jan fotografiert Bruno mit geschwollener Nase neben einem völlig unbeeindruckten Igel unter der Hecke.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "In a garden under a hedge in September: Bruno with a visibly swollen snout looking wounded at the camera, and next to him a small hedgehog completely unimpressed, curled up on the lawn; evening light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 9,
|
||||
"month": 4,
|
||||
"title": "Tumor an der Milz",
|
||||
"note": "Lena fotografiert Bruno im Wartezimmer vor dem Ultraschall; der Tumor ist gutartig, das weiß aber noch keiner.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "In a veterinary waiting room: Bruno lying on the floor with his head on Lena's shoes as she sits on a chair, waiting, calm but tired; posters on the wall blurred, neutral clinical light, quiet mood",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"lena"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 9,
|
||||
"month": 7,
|
||||
"title": "Letzte Ostsee",
|
||||
"note": "Emma fotografiert Bruno am Strand, der die Wellen inzwischen ignoriert und lieber in der Sonne liegt.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "On a Baltic Sea beach in July: Bruno lying comfortably in warm sand ignoring the small waves rolling in behind him, four-year-old Emma with a bucket digging next to him; soft summer evening light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"emma"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 9,
|
||||
"month": 11,
|
||||
"title": "Rampe für die Treppe",
|
||||
"note": "Jan fotografiert Bruno, der die neue Holzrampe zur Terrasse misstrauisch beschnuppert, Emma zeigt ihm, wie es geht.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "On a garden terrace in November: a new wooden ramp leading up to the patio door, Bruno sniffing it suspiciously with one paw on it, four-year-old Emma standing at the top of the ramp waving him up; only one dog; overcast autumn light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"emma"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 10,
|
||||
"month": 2,
|
||||
"title": "10. Geburtstag",
|
||||
"note": "Lena fotografiert Bruno mit Partyhut vor einer Leberwursttorte mit zehn Kerzen, Emma hat sie selbst dekoriert.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Tenth birthday in a kitchen: Bruno wearing a small paper party hat sitting in front of a dog cake made of liver sausage decorated with ten candles and dog treats, five-year-old Emma next to him proudly presenting the cake she decorated; warm indoor light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"emma"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 10,
|
||||
"month": 5,
|
||||
"title": "Nierenwerte",
|
||||
"note": "Lena fotografiert Bruno beim Tierarzt, der jetzt Spezialfutter bekommt und es zunächst verweigert.",
|
||||
"area": "Alltag",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "In a kitchen: Bruno sitting in front of a full bowl of special diet kibble, looking at it with deep distrust and then up at the camera, refusing to eat; morning light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 10,
|
||||
"month": 8,
|
||||
"title": "Letzter Sommer im Garten",
|
||||
"note": "Jan fotografiert Emma, die Bruno im Garten aus ihrem Erstlesebuch vorliest; Bruno hört zu, mehr oder weniger.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Last summer in the garden in August: Bruno lying on a blanket in the shade of an apple tree, five-year-old Emma sitting next to him reading aloud from a children's picture book (pages blank), holding it up for him to see; golden evening light, tender",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"emma"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 10,
|
||||
"month": 10,
|
||||
"title": "Abschied",
|
||||
"note": "Ein letztes Foto zeigt Bruno auf seiner Decke im Garten, Lena, Jan und Emma um ihn herum, die Tierärztin ist nach Hause gekommen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "In a garden in October: Bruno lying peacefully on his favourite blanket on the lawn, Lena, Jan and Emma kneeling around him stroking him, seen from slightly above, autumn leaves on the grass; soft overcast light, very quiet and emotional",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"bruno",
|
||||
"lena",
|
||||
"jan",
|
||||
"emma"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 10,
|
||||
"month": 11,
|
||||
"title": "Pfotenabdruck",
|
||||
"note": "Lena fotografiert die Erinnerungsecke im Flur: Brunos Pfotenabdruck in Ton, sein Halsband und das Foto mit dem Schnee auf der Nase.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "A small memorial corner on a hallway shelf: a round clay disc with a dog paw print pressed into it, a worn brown leather dog collar with a tag, and a framed photo of a yellow Labrador with a white patch on the nose and snow on his snout; a small candle, soft warm light, still life",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": []
|
||||
}
|
||||
]
|
||||
}
|
||||
195
backend/database/data/lifelines/characters/benz.json
Normal file
195
backend/database/data/lifelines/characters/benz.json
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
{
|
||||
"lifeline": "benz",
|
||||
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
|
||||
"era": {
|
||||
"1981-1995": "shot on 35mm colour negative film with a compact camera: film grain, warm faded colours, slight vignetting, 1980s/early-90s clothing, cars and interiors",
|
||||
"1996-2004": "shot on 35mm film or an early digital camera: slightly soft, muted colours, late-90s/early-2000s clothing and interiors",
|
||||
"2005-2009": "shot on an early digital compact camera or camera phone: slightly soft, mild noise, warm tint, 2000s clothing and interiors",
|
||||
"2010-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing and interiors",
|
||||
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
|
||||
},
|
||||
"familyNote": "The car is the same individual vehicle in every photo: a light ivory (hellelfenbein) Mercedes-Benz W123 230 E saloon, 1981, chrome bumpers, round headlights with the classic rectangular grille, hubcaps, black interior.",
|
||||
"characters": {
|
||||
"benz": {
|
||||
"name": "the Mercedes",
|
||||
"birthYear": 1981,
|
||||
"birthMonth": 3,
|
||||
"aliases": [
|
||||
"Benz",
|
||||
"Mercedes",
|
||||
"Wagen",
|
||||
"Auto"
|
||||
],
|
||||
"description": "a light ivory (hellelfenbein) Mercedes-Benz W123 230 E four-door saloon, model year 1981, chrome bumpers and trim, round headlights and the classic upright grille with the star, steel wheels with silver hubcaps, black MB-Tex interior",
|
||||
"ageLabels": {
|
||||
"0": "brand new",
|
||||
"1": "one year old"
|
||||
},
|
||||
"aging": {
|
||||
"8": "first stone chips, slightly dulled paint, small dent in the rear wing",
|
||||
"17": "tired paint, rust bubbles at the wheel arches, a cracked dashboard, worn seats",
|
||||
"22": "under a grey tarpaulin in a barn, flat tyres, dust, rust at the sills",
|
||||
"30": "half restored: primed grey wings, new sills, engine bay cleaned, still no final paint",
|
||||
"34": "fully restored, gleaming ivory paint, new chrome, period-correct H-plate, like new but with a warm patina inside"
|
||||
},
|
||||
"base": 0,
|
||||
"anchors": [
|
||||
0,
|
||||
8,
|
||||
17,
|
||||
22,
|
||||
30,
|
||||
34
|
||||
],
|
||||
"portrait": "Three-quarter front view of the whole car, parked, in flat daylight, plain background, no people, no text."
|
||||
},
|
||||
"heinz": {
|
||||
"name": "Heinz",
|
||||
"birthYear": 1936,
|
||||
"aliases": [
|
||||
"Heinz",
|
||||
"Opa"
|
||||
],
|
||||
"description": "German man, first owner of the car. Broad face, short grey hair combed to the side, thick dark eyebrows, a grey moustache, glasses with thick frames, a proud upright posture. Style: suits in the 80s, later cardigans and a flat cap.",
|
||||
"aging": {
|
||||
"55": "hair white, moustache white, a bit heavier",
|
||||
"61": "white hair, stooped, tired eyes after his wife's death"
|
||||
},
|
||||
"anchors": [
|
||||
45,
|
||||
55,
|
||||
61
|
||||
]
|
||||
},
|
||||
"gisela": {
|
||||
"name": "Gisela",
|
||||
"birthYear": 1938,
|
||||
"diedYear": 1997,
|
||||
"aliases": [
|
||||
"Gisela",
|
||||
"Oma"
|
||||
],
|
||||
"description": "Heinz's wife. Round friendly face, permed light brown hair, blue eyes, rosy cheeks, always a headscarf in the car. Style: floral dresses, cardigans.",
|
||||
"aging": {
|
||||
"55": "hair grey, permed, glasses"
|
||||
},
|
||||
"anchors": [
|
||||
43,
|
||||
55
|
||||
]
|
||||
},
|
||||
"sabine": {
|
||||
"name": "Sabine",
|
||||
"birthYear": 1965,
|
||||
"aliases": [
|
||||
"Sabine"
|
||||
],
|
||||
"description": "Heinz and Gisela's daughter. Long face, long dark blonde hair (permed in the 80s, later a bob), grey eyes, her father's eyebrows, a wry smile. Style: 80s denim and Walkman as a teen, later blouses and jeans.",
|
||||
"aging": {
|
||||
"23": "big permed hair, 80s wedding style",
|
||||
"33": "shoulder-length bob, a young mother in practical clothes",
|
||||
"49": "chin-length bob with grey strands, fine lines",
|
||||
"60": "short grey hair, glasses, laugh lines"
|
||||
},
|
||||
"anchors": [
|
||||
16,
|
||||
23,
|
||||
33,
|
||||
49,
|
||||
60
|
||||
]
|
||||
},
|
||||
"joerg": {
|
||||
"name": "Jörg",
|
||||
"birthYear": 1962,
|
||||
"aliases": [
|
||||
"Jörg"
|
||||
],
|
||||
"description": "Sabine's husband. Tall, short brown hair, a full beard, brown eyes, a calm smile. Style: checked shirts, jeans.",
|
||||
"aging": {
|
||||
"45": "beard greying, hair thinner"
|
||||
},
|
||||
"anchors": [
|
||||
26,
|
||||
45
|
||||
]
|
||||
},
|
||||
"max": {
|
||||
"name": "Max",
|
||||
"birthYear": 1993,
|
||||
"birthMonth": 6,
|
||||
"aliases": [
|
||||
"Max",
|
||||
"Enkel"
|
||||
],
|
||||
"parents": [
|
||||
"sabine",
|
||||
"joerg"
|
||||
],
|
||||
"description": "Sabine's son, Heinz's grandson. Round face as a child, later a long face with his grandfather's eyebrows, short dark blonde hair, grey eyes, oil under the fingernails from his teens on. Style: hoodies, work overalls, later a suit for special days.",
|
||||
"ageLabels": {
|
||||
"0": "a newborn baby"
|
||||
},
|
||||
"aging": {
|
||||
"0": "newborn, a few days old, wrinkled skin, fine blonde fuzz",
|
||||
"7": "seven-year-old boy, round face, short blonde hair, gap teeth",
|
||||
"13": "thirteen-year-old boy, lanky, dark blonde hair, first acne",
|
||||
"17": "seventeen-year-old, tall, short dark blonde hair, oil-stained hands, focused",
|
||||
"25": "young man, short dark blonde hair, a short beard, his grandfather's eyebrows, calm confident look",
|
||||
"32": "early thirties, short beard, first fine lines"
|
||||
},
|
||||
"base": 17,
|
||||
"baseDescription": "seventeen-year-old young man, long face, thick dark eyebrows, short dark blonde hair, grey eyes, oil-stained hands, focused expression",
|
||||
"anchors": [
|
||||
0,
|
||||
7,
|
||||
13,
|
||||
17,
|
||||
25,
|
||||
32
|
||||
]
|
||||
},
|
||||
"lea": {
|
||||
"name": "Lea",
|
||||
"birthYear": 1994,
|
||||
"aliases": [
|
||||
"Lea"
|
||||
],
|
||||
"description": "Max's wife. Oval face, long curly red-brown hair, green eyes, freckles, a wide laugh. Style: dresses, cardigans.",
|
||||
"aging": {},
|
||||
"anchors": [
|
||||
27
|
||||
]
|
||||
},
|
||||
"emma": {
|
||||
"name": "Emma",
|
||||
"birthYear": 2020,
|
||||
"birthMonth": 6,
|
||||
"aliases": [
|
||||
"Emma"
|
||||
],
|
||||
"parents": [
|
||||
"max",
|
||||
"lea"
|
||||
],
|
||||
"description": "Max and Lea's daughter. Round face, curly red-brown hair like her mother, grey eyes, freckles.",
|
||||
"ageLabels": {
|
||||
"0": "a newborn baby"
|
||||
},
|
||||
"aging": {
|
||||
"0": "newborn, a few days old",
|
||||
"2": "two-year-old toddler, curly red-brown hair, chubby cheeks",
|
||||
"5": "five-year-old girl, curly red-brown hair in a ponytail, freckles"
|
||||
},
|
||||
"base": 2,
|
||||
"baseDescription": "two-year-old toddler girl, round face, curly red-brown hair, grey eyes, chubby cheeks",
|
||||
"anchors": [
|
||||
0,
|
||||
2,
|
||||
5
|
||||
]
|
||||
}
|
||||
},
|
||||
"partners": [],
|
||||
"family": []
|
||||
}
|
||||
88
backend/database/data/lifelines/characters/bruno.json
Normal file
88
backend/database/data/lifelines/characters/bruno.json
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"lifeline": "bruno",
|
||||
"style": "Candid amateur smartphone photo, realistic family snapshot, natural light, no retouching, no text or readable writing anywhere, no watermark.",
|
||||
"portrait": "Close-up portrait of the head and chest, looking straight into the camera, neutral bright daylight, plain light wall background, no text.",
|
||||
"era": {
|
||||
"2014-2016": "shot on an early smartphone: decent sharpness, slightly flat colours",
|
||||
"2017-2024": "shot on a modern smartphone: sharp, clean colours"
|
||||
},
|
||||
"characters": {
|
||||
"bruno": {
|
||||
"name": "Bruno",
|
||||
"birthYear": 2014,
|
||||
"description": "a yellow Labrador retriever mix dog: sandy golden short coat, slightly floppy ears, a distinctive small white patch of fur on the bridge of the nose just above the black nose leather, warm brown eyes, black nose, a lighter cream-coloured chest",
|
||||
"ageLabels": {
|
||||
"0": "a puppy about three months old",
|
||||
"1": "a one-year-old young dog"
|
||||
},
|
||||
"aging": {
|
||||
"0": "a small puppy about three months old: small body, oversized paws, soft baby fur, short puppy muzzle, floppy ears, the white patch on the nose already visible",
|
||||
"1": "a lanky one-year-old young dog, adult size but slim, smooth face",
|
||||
"6": "adult dog in his prime, first few white hairs on the chin",
|
||||
"8": "mature dog, muzzle and chin visibly greying, slightly heavier",
|
||||
"10": "senior dog, muzzle and eyebrows white-grey, slightly cloudy eyes, thinner, gentle tired expression"
|
||||
},
|
||||
"base": 3,
|
||||
"anchors": [
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
6,
|
||||
8,
|
||||
10
|
||||
],
|
||||
"birthMonth": 3
|
||||
},
|
||||
"lena": {
|
||||
"name": "Lena",
|
||||
"birthYear": 1985,
|
||||
"description": "German woman, Bruno's owner. Oval face, long dark brown hair usually in a messy bun, dark brown eyes, a small nose, a wide smile with a dimple on the right cheek, tortoiseshell glasses sometimes. Style: hoodies, jeans, rain jackets, sneakers.",
|
||||
"aging": {
|
||||
"38": "a few fine lines around the eyes, hair a bit shorter"
|
||||
},
|
||||
"anchors": [
|
||||
29,
|
||||
38
|
||||
]
|
||||
},
|
||||
"jan": {
|
||||
"name": "Jan",
|
||||
"birthYear": 1983,
|
||||
"description": "German man, Lena's partner. Long narrow face, short dark blonde hair, blue eyes, a short beard, thin-rimmed glasses, a calm friendly smile. Tall. Style: t-shirts, flannel shirts, a beanie.",
|
||||
"aging": {
|
||||
"40": "beard with a bit of grey, slightly receding hairline"
|
||||
},
|
||||
"anchors": [
|
||||
34,
|
||||
40
|
||||
]
|
||||
},
|
||||
"emma": {
|
||||
"name": "Emma",
|
||||
"birthYear": 2019,
|
||||
"description": "Daughter of Lena and Jan. Round face, fine blonde hair, her mother's dark brown eyes and dimple, a small button nose, a cheeky grin.",
|
||||
"ageLabels": {
|
||||
"0": "a newborn baby",
|
||||
"1": "a toddler about 18 months old"
|
||||
},
|
||||
"aging": {
|
||||
"0": "newborn, a few days old, wrinkled pink skin, fine blonde fuzz, eyes closed",
|
||||
"1": "toddler about 18 months old, chubby cheeks, wispy blonde hair, big dark brown eyes",
|
||||
"5": "five-year-old girl, blonde hair in a ponytail, dimple, missing a front tooth"
|
||||
},
|
||||
"base": 3,
|
||||
"baseDescription": "three-year-old girl, round face, fine blonde hair in two small bunches, dark brown eyes, a dimple on the right cheek, button nose, cheeky grin",
|
||||
"parents": [
|
||||
"lena",
|
||||
"jan"
|
||||
],
|
||||
"anchors": [
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
5
|
||||
],
|
||||
"birthMonth": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
191
backend/database/data/lifelines/characters/jonas.json
Normal file
191
backend/database/data/lifelines/characters/jonas.json
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
{
|
||||
"lifeline": "jonas",
|
||||
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers or screens shown blank or out of focus, no watermark.",
|
||||
"era": {
|
||||
"2005-2009": "shot on an early digital compact camera or first-generation camera phone: slightly soft, mild noise, warm tint, small flash reflections, early-2000s clothing and interiors",
|
||||
"2010-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing and interiors",
|
||||
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
|
||||
},
|
||||
"characters": {
|
||||
"jonas": {
|
||||
"name": "Jonas",
|
||||
"birthYear": 1980,
|
||||
"description": "German man, civil engineer from Cologne. Oval face, medium-short dark brown hair with a slight side part, brown eyes, straight nose, a small mole on his left cheekbone just below the eye, usually light stubble, friendly slightly shy smile with a small gap between the front teeth. Casual style: plain t-shirts, hoodies, checked shirts, later simple shirts and knit sweaters.",
|
||||
"aging": {
|
||||
"30": "hair a touch shorter, face slightly more defined, still no grey",
|
||||
"35": "first fine lines around the eyes, a few grey hairs at the temples, short well-kept beard instead of stubble",
|
||||
"40": "noticeably more grey at the temples and in the beard, deeper laugh lines, slightly fuller face",
|
||||
"45": "salt-and-pepper hair and beard, receding a little at the temples, lines on the forehead, calm settled expression"
|
||||
},
|
||||
"anchors": [
|
||||
25,
|
||||
30,
|
||||
35,
|
||||
40,
|
||||
45
|
||||
],
|
||||
"aliases": [
|
||||
"Jonas"
|
||||
]
|
||||
},
|
||||
"mara": {
|
||||
"name": "Mara",
|
||||
"birthYear": 1980,
|
||||
"description": "German woman, physiotherapist. Heart-shaped face, shoulder-length wavy chestnut brown hair usually tied back loosely, green-grey eyes, light freckles across the nose and cheeks, small chin, warm open laugh showing her teeth. Slim, athletic. Casual style: jeans, simple tops, cardigans, a thin silver necklace she always wears.",
|
||||
"aging": {
|
||||
"35": "hair a bit shorter, a few fine lines around the eyes, freckles still visible",
|
||||
"45": "hair cut to a chin-length bob with some grey strands, lines around the eyes and mouth, same freckles, same necklace",
|
||||
"30": "hair a little longer, otherwise unchanged",
|
||||
"40": "shoulder-length hair with the first grey strands, fine lines around the eyes and mouth, same freckles, same necklace"
|
||||
},
|
||||
"anchors": [
|
||||
25,
|
||||
30,
|
||||
35,
|
||||
40,
|
||||
45
|
||||
],
|
||||
"aliases": [
|
||||
"Mara"
|
||||
]
|
||||
},
|
||||
"emil": {
|
||||
"name": "Emil",
|
||||
"birthYear": 2012,
|
||||
"description": "Son of Jonas and Mara. Round face as a small child that lengthens as he grows, light brown hair that darkens over the years, his mother's green-grey eyes and freckles, his father's small gap between the front teeth, sticking-out ears, a curious, serious look.",
|
||||
"aging": {
|
||||
"0": "newborn, a few days old, wrinkled red-pink skin, fine light fuzz of hair, eyes mostly closed",
|
||||
"2": "toddler, chubby cheeks, light brown wispy hair, big green-grey eyes, first freckles barely visible",
|
||||
"9": "school boy, slim, light brown hair with a fringe, freckles clearly visible, gap between the front teeth, ears sticking out",
|
||||
"13": "young teenager, taller and lanky, hair darker and longer falling over the forehead, freckles, braces on his teeth, ears less prominent, slightly guarded expression",
|
||||
"1": "one-year-old baby, chubby cheeks, fine light hair, big green-grey eyes",
|
||||
"6": "six-year-old boy, round face, light brown hair, freckles, gap between the front teeth, ears sticking out",
|
||||
"11": "eleven-year-old boy, slimmer face, light brown hair, freckles, gap between the front teeth, ears sticking out"
|
||||
},
|
||||
"base": 4,
|
||||
"baseDescription": "four-year-old boy, round face, light brown hair, green-grey eyes, a few freckles on the nose, small gap between the front teeth, sticking-out ears, curious serious look",
|
||||
"anchors": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
6,
|
||||
9,
|
||||
11,
|
||||
13
|
||||
],
|
||||
"parents": [
|
||||
"jonas",
|
||||
"mara"
|
||||
],
|
||||
"aliases": [
|
||||
"Emil",
|
||||
"Sohn"
|
||||
],
|
||||
"ageLabels": {
|
||||
"0": "a newborn baby",
|
||||
"1": "a one-year-old baby"
|
||||
},
|
||||
"birthMonth": 8
|
||||
},
|
||||
"david": {
|
||||
"name": "David",
|
||||
"birthYear": 1980,
|
||||
"description": "Jonas' best friend since school. Broad face, short curly black hair, dark brown eyes, thick eyebrows, wide grin, a bit stocky, often in running gear or sports jackets.",
|
||||
"aging": {
|
||||
"35": "a bit heavier, first grey in the curly hair, short beard",
|
||||
"45": "grey curly hair, fuller face, glasses"
|
||||
},
|
||||
"anchors": [
|
||||
25,
|
||||
35,
|
||||
45
|
||||
],
|
||||
"aliases": [
|
||||
"David"
|
||||
]
|
||||
},
|
||||
"lea": {
|
||||
"name": "Lea",
|
||||
"birthYear": 1983,
|
||||
"aliases": [
|
||||
"Lea"
|
||||
],
|
||||
"description": "Jonas' younger sister. Oval face like her brother, long dark brown hair, brown eyes, the same small gap between the front teeth, a big open laugh, dimples. Style: colourful scarves, denim jackets.",
|
||||
"aging": {
|
||||
"35": "hair shoulder-length, a few fine lines",
|
||||
"42": "some grey strands, softer face"
|
||||
},
|
||||
"anchors": [
|
||||
22,
|
||||
35,
|
||||
42
|
||||
]
|
||||
},
|
||||
"karin": {
|
||||
"name": "Karin",
|
||||
"birthYear": 1955,
|
||||
"aliases": [
|
||||
"Karin",
|
||||
"Mutter",
|
||||
"Eltern"
|
||||
],
|
||||
"description": "Jonas' mother. Round friendly face, short grey-blonde hair, blue eyes behind reading glasses, soft double chin, a warm smile. Style: blouses, cardigans, a pearl necklace on special occasions.",
|
||||
"aging": {
|
||||
"65": "hair white, more lines, slightly smaller",
|
||||
"70": "white hair, walks with a slight stoop, same warm smile"
|
||||
},
|
||||
"anchors": [
|
||||
50,
|
||||
65,
|
||||
70
|
||||
]
|
||||
},
|
||||
"thomas": {
|
||||
"name": "Thomas",
|
||||
"birthYear": 1952,
|
||||
"diedYear": 2020,
|
||||
"aliases": [
|
||||
"Thomas",
|
||||
"Vater",
|
||||
"Eltern"
|
||||
],
|
||||
"description": "Jonas' father, a retired carpenter. Long face, thin grey hair, a grey beard, deep-set brown eyes, big hands with rough skin, a quiet serious expression that breaks into a rare smile. Style: checked shirts, wool vests.",
|
||||
"aging": {
|
||||
"65": "hair white and sparse, beard white",
|
||||
"68": "noticeably thinner and pale, tired eyes, sitting more often"
|
||||
},
|
||||
"anchors": [
|
||||
53,
|
||||
65,
|
||||
68
|
||||
]
|
||||
},
|
||||
"cem": {
|
||||
"name": "Cem",
|
||||
"birthYear": 1982,
|
||||
"aliases": [
|
||||
"Cem"
|
||||
],
|
||||
"description": "Jonas' friend from Cologne, photographer. Turkish-German man, narrow face, short black hair, dark eyes, a well-kept short black beard, always a camera strap around his neck, lively expression. Style: black t-shirts, denim, a beanie.",
|
||||
"aging": {
|
||||
"40": "beard with grey, short hair greying at the temples"
|
||||
},
|
||||
"anchors": [
|
||||
27,
|
||||
40
|
||||
]
|
||||
}
|
||||
},
|
||||
"partners": [
|
||||
{
|
||||
"key": "mara",
|
||||
"from": "2005-10"
|
||||
}
|
||||
],
|
||||
"family": [
|
||||
"mara",
|
||||
"emil"
|
||||
],
|
||||
"familyNote": "Jonas' family is exactly Jonas, Mara and their only child Emil – no other children."
|
||||
}
|
||||
130
backend/database/data/lifelines/characters/lukas.json
Normal file
130
backend/database/data/lifelines/characters/lukas.json
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
{
|
||||
"lifeline": "lukas",
|
||||
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
|
||||
"era": {
|
||||
"2012-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing",
|
||||
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing"
|
||||
},
|
||||
"characters": {
|
||||
"lukas": {
|
||||
"name": "Lukas",
|
||||
"birthYear": 1985,
|
||||
"aliases": [
|
||||
"Lukas"
|
||||
],
|
||||
"description": "German man from the Hunsrück, former IT consultant. Narrow face, deep-set grey-green eyes, a prominent straight nose, dark blonde hair, a small chipped front tooth visible when he laughs, a mole on his right temple. Lean, wiry. Style: at 27 a tired office look (shirt, stubble), on the road sun-bleached hair, tan, a growing beard, worn t-shirts and a battered backpack; back home short hair, a trimmed beard, workshop clothes.",
|
||||
"aging": {
|
||||
"29": "sun-bleached longer hair, deep tan, a full blonde-brown beard, leaner",
|
||||
"32": "beard trimmed, hair shorter, tanned, a few lines from the sun",
|
||||
"35": "very long unkempt beard and long hair at the end of the journey, thin, weathered",
|
||||
"36": "short hair and a trimmed beard again, paler, first grey hairs at the temples",
|
||||
"40": "short greying hair, grey in the beard, calm settled expression, lines around the eyes"
|
||||
},
|
||||
"anchors": [
|
||||
27,
|
||||
29,
|
||||
32,
|
||||
35,
|
||||
36,
|
||||
40
|
||||
]
|
||||
},
|
||||
"aroha": {
|
||||
"name": "Aroha",
|
||||
"birthYear": 1988,
|
||||
"aliases": [
|
||||
"Aroha"
|
||||
],
|
||||
"description": "Māori woman from Nelson, New Zealand. Round face, long thick black hair usually in a loose braid, dark brown eyes, a warm broad smile, a small tā moko tattoo on her wrist, a pounamu (greenstone) pendant she always wears. Style: singlets, shorts, jandals, a fleece in winter.",
|
||||
"aging": {
|
||||
"36": "hair with a few grey strands, fine lines, same pendant"
|
||||
},
|
||||
"anchors": [
|
||||
26,
|
||||
36
|
||||
]
|
||||
},
|
||||
"mia": {
|
||||
"name": "Mia",
|
||||
"birthYear": 2016,
|
||||
"birthMonth": 11,
|
||||
"aliases": [
|
||||
"Mia",
|
||||
"Tochter"
|
||||
],
|
||||
"parents": [
|
||||
"lukas",
|
||||
"aroha"
|
||||
],
|
||||
"description": "Daughter of Lukas and Aroha. Round face, thick dark curly hair, her mother's dark eyes, her father's chipped-tooth grin, light brown skin, always a little pounamu pendant like her mother.",
|
||||
"ageLabels": {
|
||||
"0": "a newborn baby"
|
||||
},
|
||||
"aging": {
|
||||
"0": "newborn, a few days old, dark fuzz of hair",
|
||||
"2": "two-year-old toddler, dark curls, chubby cheeks",
|
||||
"4": "four-year-old girl, dark curly hair in two bunches, big dark eyes",
|
||||
"6": "six-year-old girl, long dark curls, missing a front tooth",
|
||||
"8": "eight-year-old girl, long dark curls in a ponytail, confident grin"
|
||||
},
|
||||
"base": 4,
|
||||
"baseDescription": "four-year-old girl, round face, thick dark curly hair in two bunches, dark brown eyes, light brown skin, cheeky grin",
|
||||
"anchors": [
|
||||
0,
|
||||
2,
|
||||
4,
|
||||
6,
|
||||
8
|
||||
]
|
||||
},
|
||||
"renate": {
|
||||
"name": "Renate",
|
||||
"birthYear": 1955,
|
||||
"aliases": [
|
||||
"Renate",
|
||||
"Mutter",
|
||||
"Eltern"
|
||||
],
|
||||
"description": "Lukas' mother from a Hunsrück village. Small, round face, short grey hair, blue eyes, glasses, a worried warm smile. Style: cardigans, aprons, a rain jacket.",
|
||||
"aging": {
|
||||
"68": "white hair, more lines, slightly stooped"
|
||||
},
|
||||
"anchors": [
|
||||
58,
|
||||
68
|
||||
]
|
||||
},
|
||||
"klaus": {
|
||||
"name": "Klaus",
|
||||
"birthYear": 1950,
|
||||
"diedYear": 2023,
|
||||
"aliases": [
|
||||
"Klaus",
|
||||
"Vater",
|
||||
"Eltern"
|
||||
],
|
||||
"description": "Lukas' father, a retired bicycle mechanic. Tall, thin, a long face, bald with a white fringe, bushy eyebrows, big rough hands, a dry humour. Style: blue work overalls, flat cap.",
|
||||
"aging": {
|
||||
"67": "pale and thin after a heart attack, a walking stick",
|
||||
"72": "very thin, sits more than he stands, oxygen tube sometimes"
|
||||
},
|
||||
"anchors": [
|
||||
62,
|
||||
67,
|
||||
72
|
||||
]
|
||||
}
|
||||
},
|
||||
"partners": [
|
||||
{
|
||||
"key": "aroha",
|
||||
"from": "2014-04",
|
||||
"to": "2018-08"
|
||||
}
|
||||
],
|
||||
"family": [
|
||||
"aroha",
|
||||
"mia"
|
||||
],
|
||||
"familyNote": "Lukas' family is Aroha and their only child Mia; his parents are Renate and Klaus."
|
||||
}
|
||||
192
backend/database/data/lifelines/characters/nachtschicht.json
Normal file
192
backend/database/data/lifelines/characters/nachtschicht.json
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
{
|
||||
"lifeline": "nachtschicht",
|
||||
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
|
||||
"era": {
|
||||
"1981-1995": "shot on 35mm colour negative film with a compact camera: film grain, warm faded colours, slight vignetting, 1980s/early-90s clothing, cars and interiors",
|
||||
"1996-2004": "shot on 35mm film or an early digital camera: slightly soft, muted colours, late-90s/early-2000s clothing and interiors",
|
||||
"2005-2009": "shot on an early digital compact camera or camera phone: slightly soft, mild noise, warm tint, 2000s clothing and interiors",
|
||||
"2010-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing and interiors",
|
||||
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
|
||||
},
|
||||
"familyNote": "The band Nachtschicht has exactly four members: Sven (vocals), Kai (guitar), Jule (bass) and the drummer – Basti until spring 1999, Nele from summer 1999 on. Never five members on stage. Their look evolves from shy school kids (1995) to full rock stars (tattoos, leather, long hair, mohawk around 2002–2010) to greying veterans in leather jackets (2012+).",
|
||||
"characters": {
|
||||
"nachtschicht": {
|
||||
"name": "the band",
|
||||
"birthYear": 1995,
|
||||
"aliases": [
|
||||
"Nachtschicht",
|
||||
"Band"
|
||||
],
|
||||
"description": "the four-piece rock band the band",
|
||||
"anchors": []
|
||||
},
|
||||
"sven": {
|
||||
"name": "Sven",
|
||||
"birthYear": 1979,
|
||||
"aliases": [
|
||||
"Sven"
|
||||
],
|
||||
"description": "Angular face, high cheekbones, intense pale blue eyes, a big expressive mouth, a scar through his left eyebrow. Skinny. Dyed black hair from 1997, tattooed arms from 2002, full beard and grey from 2012.",
|
||||
"aging": {
|
||||
"18": "braces gone, dyed black hair, first black clothes",
|
||||
"21": "first tattoo on the forearm, black hair to the chin",
|
||||
"23": "tattooed arms, long black hair, lean, black sleeveless shirt",
|
||||
"26": "full sleeve tattoos, tattoos reaching up the neck, long black hair, black sleeveless shirt, silver rings",
|
||||
"29": "same tattoos, long black hair, leaner face, black shirt",
|
||||
"33": "full beard, tattoos faded, first grey in hair and beard, black leather jacket over a t-shirt",
|
||||
"39": "grey beard and hair, tattoos faded, lines, calm",
|
||||
"46": "grey, short hair, grey beard, leather jacket, lined face"
|
||||
},
|
||||
"anchors": [
|
||||
16,
|
||||
18,
|
||||
21,
|
||||
23,
|
||||
26,
|
||||
29,
|
||||
33,
|
||||
39,
|
||||
46
|
||||
],
|
||||
"adultBase": 21
|
||||
},
|
||||
"kai": {
|
||||
"name": "Kai",
|
||||
"birthYear": 1979,
|
||||
"aliases": [
|
||||
"Kai"
|
||||
],
|
||||
"description": "Round face, brown eyes, thick dark eyebrows, a wide grin with a chipped front tooth. Look: 1995 acne, short brown hair, band t-shirt; from 2000 shoulder-length brown hair; 2002–2010 long brown hair, leather jacket or vest, sunglasses on stage; from 2012 short grey hair, reading glasses, cardigans, a producer look.",
|
||||
"aging": {
|
||||
"18": "acne gone, hair to the ears, black t-shirts",
|
||||
"21": "shoulder-length brown hair, first stubble",
|
||||
"23": "long brown hair, leather jacket",
|
||||
"26": "long brown hair, leather vest, sunglasses, a bit heavier",
|
||||
"29": "long hair with first grey, beard",
|
||||
"33": "short grey hair, reading glasses, cardigan",
|
||||
"39": "short grey hair, glasses, fuller face",
|
||||
"46": "grey, glasses, grey stubble"
|
||||
},
|
||||
"anchors": [
|
||||
16,
|
||||
18,
|
||||
21,
|
||||
23,
|
||||
26,
|
||||
29,
|
||||
33,
|
||||
39,
|
||||
46
|
||||
],
|
||||
"adultBase": 21
|
||||
},
|
||||
"jule": {
|
||||
"name": "Jule",
|
||||
"birthYear": 1980,
|
||||
"aliases": [
|
||||
"Jule"
|
||||
],
|
||||
"description": "Heart-shaped face, freckles, green eyes, a small nose, a dry half-smile. Look: 1995 two plaits, dungarees; from 1997 short dyed red hair; from 2002 one side of the head shaved, dark red hair, black tank tops, tattoo of a bass clef on the neck; from 2012 short dark red hair with grey roots, black shirts, leather jacket.",
|
||||
"aging": {
|
||||
"17": "short dyed red hair, black clothes",
|
||||
"20": "short red hair, nose ring",
|
||||
"22": "one side of the head shaved, dark red hair, neck tattoo, black tank top",
|
||||
"25": "shaved side, dark red hair, more tattoos on the arms",
|
||||
"28": "shaved side, dark red hair, a young mother, tired eyes",
|
||||
"32": "short dark red hair with grey roots, black shirt",
|
||||
"38": "short hair mostly grey, glasses on a cord",
|
||||
"45": "short grey hair, lined face, leather jacket"
|
||||
},
|
||||
"anchors": [
|
||||
15,
|
||||
17,
|
||||
20,
|
||||
22,
|
||||
25,
|
||||
28,
|
||||
32,
|
||||
38,
|
||||
45
|
||||
],
|
||||
"adultBase": 20
|
||||
},
|
||||
"basti": {
|
||||
"name": "Basti",
|
||||
"birthYear": 1979,
|
||||
"aliases": [
|
||||
"Basti"
|
||||
],
|
||||
"description": "Broad face, curly blonde hair, blue eyes, a friendly open grin. Look: band t-shirts, cargo shorts.",
|
||||
"aging": {
|
||||
"21": "curly blonde hair longer, a goatee"
|
||||
},
|
||||
"anchors": [
|
||||
16,
|
||||
21
|
||||
]
|
||||
},
|
||||
"nele": {
|
||||
"name": "Nele",
|
||||
"birthYear": 1982,
|
||||
"aliases": [
|
||||
"Nele"
|
||||
],
|
||||
"description": "Long narrow face, dark eyes, a strong jaw, a nose ring. Look: 2001 shaved sides with a dark fringe, nose ring, wife-beater; 2002–2010 a blonde mohawk, tattooed shoulders, drumsticks always in hand; from 2012 short grey-blonde hair, plain black clothes.",
|
||||
"aging": {
|
||||
"20": "blonde mohawk, tattooed shoulders",
|
||||
"23": "tall blonde mohawk, more tattoos, muscular arms",
|
||||
"26": "mohawk, muscular, focused",
|
||||
"30": "mohawk shorter, first grey",
|
||||
"33": "short grey-blonde hair, black shirt",
|
||||
"39": "short grey hair, glasses",
|
||||
"43": "short grey hair, lined face, leather jacket"
|
||||
},
|
||||
"anchors": [
|
||||
19,
|
||||
20,
|
||||
23,
|
||||
26,
|
||||
30,
|
||||
33,
|
||||
39,
|
||||
43
|
||||
]
|
||||
},
|
||||
"mutter": {
|
||||
"name": "Svens Mutter",
|
||||
"birthYear": 1950,
|
||||
"aliases": [
|
||||
"Svens Mutter",
|
||||
"Mutter",
|
||||
"Band-Mütter"
|
||||
],
|
||||
"description": "Sven's mother, a warm round-faced woman with short grey hair, glasses, a cardigan, always slightly worried and very proud.",
|
||||
"aging": {
|
||||
"62": "white hair, more lines",
|
||||
"75": "white hair, stooped, glasses on a chain"
|
||||
},
|
||||
"anchors": [
|
||||
45,
|
||||
62,
|
||||
75
|
||||
]
|
||||
}
|
||||
},
|
||||
"partners": [],
|
||||
"family": [],
|
||||
"band": [
|
||||
"sven",
|
||||
"kai",
|
||||
"jule"
|
||||
],
|
||||
"bandDrummer": [
|
||||
{
|
||||
"key": "basti",
|
||||
"to": "1999-05"
|
||||
},
|
||||
{
|
||||
"key": "nele",
|
||||
"from": "1999-07"
|
||||
}
|
||||
]
|
||||
}
|
||||
44
backend/database/data/lifelines/characters/nadja.json
Normal file
44
backend/database/data/lifelines/characters/nadja.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"lifeline": "nadja",
|
||||
"style": "Candid amateur photo, realistic snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
|
||||
"era": {
|
||||
"2012-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, early-2010s clothing and interiors",
|
||||
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
|
||||
},
|
||||
"characters": {
|
||||
"nadja": {
|
||||
"name": "Nadja",
|
||||
"birthYear": 1988,
|
||||
"description": "German woman from Hamburg. Long face with high cheekbones, straight dark blonde hair usually worn in a loose bun or ponytail, grey-blue eyes, slightly hooked nose, a small scar through her left eyebrow, thin lips, a wide confident smile. Tall and slim. Style in her twenties: smart-casual event manager (blazers, blouses, statement earrings); later: work clothes, flannel shirts, hair often tied up with a pencil, hands with small cuts and calluses.",
|
||||
"aging": {
|
||||
"29": "slightly more angular face, hair a bit longer, still no grey, a more tired look around the eyes",
|
||||
"34": "hair cut to shoulder length and worn down or in a short ponytail, first fine lines around the eyes, tanned and healthier looking, no make-up",
|
||||
"37": "a few grey strands at the temples, fine lines around eyes and mouth, calm settled expression, short practical haircut"
|
||||
},
|
||||
"anchors": [24, 29, 34, 37]
|
||||
},
|
||||
"tim": {
|
||||
"name": "Tim",
|
||||
"birthYear": 1987,
|
||||
"description": "German man, Nadja's boyfriend and later husband, later ex-husband. Square jaw, short light brown hair styled up at the front, blue eyes, a straight nose, a dimple in his chin, a charming easy grin, tall and athletic. Style: fitted shirts with rolled sleeves, chinos, sneakers, a big wristwatch.",
|
||||
"aging": {
|
||||
"32": "slightly fuller face, hair a little thinner at the front, tired eyes"
|
||||
},
|
||||
"anchors": [25, 32]
|
||||
},
|
||||
"kim": {
|
||||
"name": "Kim",
|
||||
"birthYear": 1989,
|
||||
"description": "Nadja's best friend, Vietnamese-German woman from Hamburg. Round face, long black hair often in a messy bun, dark brown eyes, small nose, a big warm laugh, a nose stud. Short. Style: oversized sweaters, colourful socks, big glasses with round frames.",
|
||||
"aging": {},
|
||||
"anchors": [31]
|
||||
},
|
||||
"ben": {
|
||||
"name": "Ben",
|
||||
"birthYear": 1985,
|
||||
"description": "German man from Lüneburg, Nadja's later partner. Broad friendly face, short reddish-brown hair and a full reddish beard, hazel eyes, laugh lines, sunburnt freckled skin, a gap-toothed easy smile. Stocky. Style: t-shirts, shorts, outdoor jackets, a cap.",
|
||||
"aging": {},
|
||||
"anchors": [38]
|
||||
}
|
||||
}
|
||||
}
|
||||
182
backend/database/data/lifelines/characters/sophie.json
Normal file
182
backend/database/data/lifelines/characters/sophie.json
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
{
|
||||
"lifeline": "sophie",
|
||||
"style": "Candid amateur photo, realistic snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
|
||||
"era": {
|
||||
"2005-2009": "shot on an early digital compact camera or first-generation camera phone: slightly soft, mild noise, warm tint, small flash reflections, early-2000s clothing and interiors",
|
||||
"2010-2015": "shot on an early smartphone: decent sharpness, slightly flat colours, 2010s clothing and interiors",
|
||||
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
|
||||
},
|
||||
"characters": {
|
||||
"sophie": {
|
||||
"name": "Sophie",
|
||||
"birthYear": 1980,
|
||||
"aliases": [
|
||||
"Sophie"
|
||||
],
|
||||
"description": "German woman from Leipzig, cultural manager and later author. Round soft face, chin-length dark blonde hair with a side fringe, warm brown eyes, a small upturned nose, a beauty mark above the right corner of her mouth, a wide warm smile with slightly crooked front teeth. Medium height, a little curvy. Style: colourful blouses, cardigans, big scarves, tortoiseshell glasses from her mid-thirties on.",
|
||||
"aging": {
|
||||
"30": "hair a bit longer, face slightly slimmer",
|
||||
"35": "tortoiseshell glasses, first fine lines around the eyes, hair shoulder-length",
|
||||
"40": "shoulder-length hair with a few grey strands, laugh lines, glasses",
|
||||
"45": "chin-length hair with visible grey, lines around eyes and mouth, glasses, calm content expression"
|
||||
},
|
||||
"anchors": [
|
||||
25,
|
||||
30,
|
||||
35,
|
||||
40,
|
||||
45
|
||||
]
|
||||
},
|
||||
"felix": {
|
||||
"name": "Felix",
|
||||
"birthYear": 1979,
|
||||
"aliases": [
|
||||
"Felix"
|
||||
],
|
||||
"description": "Sophie's first boyfriend, a touring musician. Thin face, shoulder-length dark wavy hair, grey eyes, stubble, a silver ring in his left ear, a distant dreamy look. Tall and lanky. Style: band t-shirts, leather jacket.",
|
||||
"aging": {},
|
||||
"anchors": [
|
||||
26
|
||||
]
|
||||
},
|
||||
"alina": {
|
||||
"name": "Alina",
|
||||
"birthYear": 1981,
|
||||
"aliases": [
|
||||
"Alina"
|
||||
],
|
||||
"description": "Sophie's best friend and former flatmate. Heart-shaped face, very short red hair, green eyes, freckles everywhere, a wide grin, a small nose piercing. Petite. Style: dungarees, big earrings, later a café apron.",
|
||||
"aging": {
|
||||
"35": "hair a bit longer, still red, a few lines",
|
||||
"45": "red hair with grey roots, more freckles and lines, same grin"
|
||||
},
|
||||
"anchors": [
|
||||
25,
|
||||
35,
|
||||
45
|
||||
]
|
||||
},
|
||||
"nina": {
|
||||
"name": "Nina",
|
||||
"birthYear": 1982,
|
||||
"aliases": [
|
||||
"Nina"
|
||||
],
|
||||
"description": "Sophie's partner and later wife. Long narrow face, long straight black hair usually in a plait, dark brown almond-shaped eyes, a straight nose, a serious face that softens into a shy smile, a small tattoo of a swallow on her left wrist. Slim, tall. Style: plain dark clothes, white shirts, a leather jacket.",
|
||||
"aging": {
|
||||
"35": "hair in a bun more often, first fine lines",
|
||||
"45": "a few grey strands in the black hair, lines around the eyes, same plait"
|
||||
},
|
||||
"anchors": [
|
||||
27,
|
||||
35,
|
||||
45
|
||||
]
|
||||
},
|
||||
"daniel": {
|
||||
"name": "Daniel",
|
||||
"birthYear": 1977,
|
||||
"aliases": [
|
||||
"Daniel"
|
||||
],
|
||||
"description": "Sophie's older brother. Round face like his sister, short brown hair thinning early, brown eyes, a goatee, a friendly tired look, a bit overweight. Style: polo shirts, fleece jackets.",
|
||||
"aging": {
|
||||
"40": "bald on top, goatee with grey",
|
||||
"48": "bald, grey goatee, glasses"
|
||||
},
|
||||
"anchors": [
|
||||
30,
|
||||
40,
|
||||
48
|
||||
]
|
||||
},
|
||||
"petra": {
|
||||
"name": "Petra",
|
||||
"birthYear": 1953,
|
||||
"aliases": [
|
||||
"Petra",
|
||||
"Mutter",
|
||||
"Eltern"
|
||||
],
|
||||
"description": "Sophie's mother from Erfurt, a teacher. Soft round face, short wavy grey hair, brown eyes, a beauty mark like her daughter's, glasses on a chain, a warm energetic smile. Style: colourful blouses, long skirts.",
|
||||
"aging": {
|
||||
"65": "hair white, more lines, a little smaller",
|
||||
"72": "white hair, slower, slightly stooped, same smile"
|
||||
},
|
||||
"anchors": [
|
||||
55,
|
||||
65,
|
||||
72
|
||||
]
|
||||
},
|
||||
"michael": {
|
||||
"name": "Michael",
|
||||
"birthYear": 1950,
|
||||
"aliases": [
|
||||
"Michael",
|
||||
"Vater",
|
||||
"Eltern"
|
||||
],
|
||||
"description": "Sophie's father from Erfurt, retired engineer. Long face, bald with a white fringe of hair, bushy white eyebrows, blue eyes, a big nose, a dry half-smile. Tall, thin. Style: shirts with sweater vests, a hat outdoors.",
|
||||
"aging": {
|
||||
"68": "thinner, walks with a stick after knee surgery",
|
||||
"75": "very thin, stooped, hearing aid, still the dry smile"
|
||||
},
|
||||
"anchors": [
|
||||
58,
|
||||
68,
|
||||
75
|
||||
]
|
||||
},
|
||||
"clara": {
|
||||
"name": "Clara",
|
||||
"birthYear": 2015,
|
||||
"aliases": [
|
||||
"Clara"
|
||||
],
|
||||
"parents": [
|
||||
"nina"
|
||||
],
|
||||
"description": "Daughter of Nina and Sophie (Nina carried her). Nina's long face and dark almond eyes, dark brown hair, a small nose, a serious thoughtful look that turns into a sudden big laugh.",
|
||||
"ageLabels": {
|
||||
"0": "a newborn baby",
|
||||
"1": "a one-year-old baby"
|
||||
},
|
||||
"aging": {
|
||||
"0": "newborn, a few days old, wrinkled pink skin, dark fuzz of hair, eyes closed",
|
||||
"1": "one-year-old baby, chubby cheeks, dark hair, big dark eyes",
|
||||
"3": "three-year-old girl, round face, dark brown hair in a short bob, dark almond eyes",
|
||||
"7": "seven-year-old girl, slimmer face, long dark brown hair in a ponytail, missing a front tooth",
|
||||
"10": "ten-year-old girl, long dark hair, serious dark eyes, headphones often around her neck, a teenager's guarded look starting"
|
||||
},
|
||||
"base": 5,
|
||||
"baseDescription": "five-year-old girl, long face, dark brown hair in two plaits, dark almond-shaped eyes, small nose, serious thoughtful look",
|
||||
"anchors": [
|
||||
0,
|
||||
1,
|
||||
3,
|
||||
5,
|
||||
7,
|
||||
10
|
||||
],
|
||||
"birthMonth": 1
|
||||
}
|
||||
},
|
||||
"partners": [
|
||||
{
|
||||
"key": "felix",
|
||||
"from": "2005-01",
|
||||
"to": "2007-01"
|
||||
},
|
||||
{
|
||||
"key": "nina",
|
||||
"from": "2008-07"
|
||||
}
|
||||
],
|
||||
"family": [
|
||||
"nina",
|
||||
"clara"
|
||||
],
|
||||
"familyNote": "Sophie's family is exactly Sophie, her wife Nina and their only child Clara – no other children, no man in the family."
|
||||
}
|
||||
96
backend/database/data/lifelines/characters/werner.json
Normal file
96
backend/database/data/lifelines/characters/werner.json
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
{
|
||||
"lifeline": "werner",
|
||||
"style": "Candid amateur photo, realistic family snapshot, natural light, natural skin, no retouching, no text or readable writing anywhere, any papers, signs or screens shown blank or out of focus, no watermark.",
|
||||
"era": {
|
||||
"2010-2015": "shot on a cheap digital compact camera or early smartphone: slightly flat colours, mild noise, early-2010s clothing and interiors",
|
||||
"2016-2025": "shot on a modern smartphone: sharp, clean colours, contemporary clothing and interiors"
|
||||
},
|
||||
"characters": {
|
||||
"werner": {
|
||||
"name": "Werner",
|
||||
"birthYear": 1955,
|
||||
"description": "German man from Dortmund, retired steel worker and works council member. Broad weathered face, deep-set pale blue eyes under bushy grey eyebrows, big nose, thin grey hair combed back over a high forehead, a trimmed grey moustache, large hands, a small scar on his chin. Sturdy, slightly overweight, a bit stooped in later years. Style: checked short-sleeve shirts, cardigans, a flat cap outdoors, later a walking stick.",
|
||||
"aging": {
|
||||
"60": "hair thinner and whiter, moustache white, deeper lines, slightly heavier",
|
||||
"65": "noticeably thinner face after his wife's illness, hair mostly white and sparse, tired eyes, grey stubble",
|
||||
"70": "white hair and moustache, a bit stooped, reading glasses on a cord around his neck, warmer, more relaxed expression"
|
||||
},
|
||||
"anchors": [
|
||||
55,
|
||||
60,
|
||||
65,
|
||||
70
|
||||
]
|
||||
},
|
||||
"renate": {
|
||||
"name": "Renate",
|
||||
"birthYear": 1957,
|
||||
"description": "Werner's wife, German woman from Dortmund. Round soft face, short curly hair dyed a warm auburn, brown eyes, rosy cheeks, dimples, small gold stud earrings and reading glasses pushed up into her hair. Plump and cheerful. Style: floral blouses, cardigans, an apron at home.",
|
||||
"aging": {
|
||||
"60": "hair a little greyer at the roots, same warm smile",
|
||||
"63": "very short grey hair growing back after chemotherapy, thinner face, pale but smiling, often a knitted hat"
|
||||
},
|
||||
"anchors": [
|
||||
53,
|
||||
60,
|
||||
63
|
||||
]
|
||||
},
|
||||
"anke": {
|
||||
"name": "Anke",
|
||||
"birthYear": 1982,
|
||||
"description": "Werner's daughter. Her mother's round face and dimples, her father's pale blue eyes, dark blonde hair in a practical ponytail, a warm tired smile, a mother of two. Style: jeans, fleece jackets, sneakers.",
|
||||
"aging": {
|
||||
"40": "a few grey strands, fine lines around the eyes"
|
||||
},
|
||||
"anchors": [
|
||||
28,
|
||||
40
|
||||
]
|
||||
},
|
||||
"juergen": {
|
||||
"name": "Jürgen",
|
||||
"birthYear": 1985,
|
||||
"description": "Werner's son, lives in Canada. Tall, his father's big nose and pale blue eyes, short brown hair, a full brown beard, outdoorsy tan. Style: plaid shirts, down vests.",
|
||||
"aging": {
|
||||
"38": "beard with some grey, slightly heavier"
|
||||
},
|
||||
"anchors": [
|
||||
27,
|
||||
38
|
||||
]
|
||||
},
|
||||
"lina": {
|
||||
"name": "Lina",
|
||||
"birthYear": 2010,
|
||||
"description": "Werner's granddaughter, Anke's daughter. Round face, her grandmother's dimples, straight light blonde hair, pale blue eyes, a gap between the front teeth as a child, later braces, a serious clever look.",
|
||||
"aging": {
|
||||
"0": "newborn, a few days old, wrinkled pink skin, fine blonde fuzz, eyes closed",
|
||||
"6": "six-year-old girl, round face, dimples, long light blonde hair in two plaits, gap between the front teeth",
|
||||
"11": "eleven-year-old girl, slimmer face, long blonde hair in a ponytail, dimples, braces",
|
||||
"15": "fifteen-year-old teenager, shoulder-length blonde hair, dimples, no braces any more, confident look"
|
||||
},
|
||||
"base": 6,
|
||||
"baseDescription": "six-year-old girl, round face, dimples, long light blonde hair in two plaits, pale blue eyes, gap between the front teeth, serious clever look",
|
||||
"anchors": [
|
||||
0,
|
||||
6,
|
||||
11,
|
||||
15
|
||||
],
|
||||
"parents": [
|
||||
"anke"
|
||||
],
|
||||
"birthMonth": 8
|
||||
},
|
||||
"hannelore": {
|
||||
"name": "Hannelore",
|
||||
"birthYear": 1958,
|
||||
"description": "Werner's later partner from the hiking club. Slim, upright posture, a narrow face with high cheekbones, short silver-white hair in a neat modern cut, grey-green eyes, thin-rimmed glasses, a calm friendly smile. Style: outdoor jackets, scarves, hiking boots, small pearl earrings.",
|
||||
"aging": {},
|
||||
"anchors": [
|
||||
65
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
2282
backend/database/data/lifelines/jonas.json
Normal file
2282
backend/database/data/lifelines/jonas.json
Normal file
File diff suppressed because it is too large
Load diff
630
backend/database/data/lifelines/lukas.json
Normal file
630
backend/database/data/lifelines/lukas.json
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
{
|
||||
"person": {
|
||||
"name": "Lukas",
|
||||
"email": "lukas@thats-me.app",
|
||||
"birthYear": 1985
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"age": 27,
|
||||
"month": 2,
|
||||
"title": "Zusammenbruch im Büro",
|
||||
"note": "Ein Kollege fotografiert Lukas im Frankfurter Großraumbüro, der mit dem Kopf auf den Armen am Schreibtisch liegt, drei Monitore voller Tabellen.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 4,
|
||||
"title": "Kündigung",
|
||||
"note": "Ein Selfie im Fahrstuhl mit einem Karton voller Bürosachen und einem Umschlag, Lukas mit leerem, aber ruhigem Blick.",
|
||||
"area": "Persönliche Entscheidung",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 5,
|
||||
"title": "Wohnung aufgelöst",
|
||||
"note": "Ein Selfie in der leeren Wohnung, nur ein Rucksack in der Mitte des Raums, Lukas sitzt daneben auf dem Boden.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 6,
|
||||
"title": "One-Way-Ticket",
|
||||
"note": "Ein Selfie am Gate in Frankfurt mit dem Boarding Pass (unleserlich), Lukas mit riesigem Rucksack und ungläubigem Grinsen.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 7,
|
||||
"title": "Bangkok, erste Nacht",
|
||||
"note": "Ein Selfie in einer neonbeleuchteten Straße in Bangkok, Lukas verschwitzt mit einem Bierchen zwischen Garküchen.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 8,
|
||||
"title": "Motorroller in Laos",
|
||||
"note": "Ein Reisender fotografiert Lukas auf einem alten Roller auf einer roten Staubpiste in Laos, Reisfelder, Wasserbüffel am Straßenrand.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 9,
|
||||
"title": "Angkor Wat",
|
||||
"note": "Ein Mönch fotografiert Lukas bei Sonnenaufgang vor den Türmen von Angkor Wat in Kambodscha, die Silhouette spiegelt sich im Wasserbecken, Lukas barfuß mit Rucksack.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 10,
|
||||
"title": "Vietnam, Halong-Bucht",
|
||||
"note": "Ein Selfie auf einem Holzboot in der Halong-Bucht, Kalksteinfelsen im Nebel, Lukas mit Reisstrohhut.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 12,
|
||||
"title": "Weihnachten im Hostel",
|
||||
"note": "Eine Hostelbesitzerin fotografiert Lukas mit zwölf Backpackern aus acht Ländern beim improvisierten Weihnachtsessen in Hanoi.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 1,
|
||||
"title": "Taj Mahal",
|
||||
"note": "Ein Tourguide fotografiert Lukas auf der berühmten Bank vor dem Taj Mahal in Agra, Morgennebel, ein Affe auf der Mauer neben ihm.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 2,
|
||||
"title": "Indien, Varanasi",
|
||||
"note": "Ein Selfie bei Sonnenaufgang auf den Ghats von Varanasi, hinter Lukas Boote auf dem Ganges und Rauch, sein Blick ernst.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 3,
|
||||
"title": "Zugfahrt durch Rajasthan",
|
||||
"note": "Ein Mitreisender fotografiert Lukas in der offenen Tür eines fahrenden Zuges in Rajasthan, Wüstenlandschaft, Wind im Haar.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 4,
|
||||
"title": "Krank in Delhi",
|
||||
"note": "Ein Selfie in einem heruntergekommenen Hostelbett in Delhi, Lukas abgemagert und bleich, ein Ventilator, eine Infusionsflasche am Bettpfosten.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 5,
|
||||
"title": "Nepal, Annapurna",
|
||||
"note": "Ein Trekkingpartner fotografiert Lukas am Thorong-La-Pass in 5.400 Metern Höhe, Gebetsfahnen, Schnee, ein müdes, strahlendes Gesicht.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 7,
|
||||
"title": "Anruf nach Hause",
|
||||
"note": "Ein Selfie in einem Internetcafé in Kathmandu, Lukas mit Headset beim Videoanruf, auf dem Bildschirm verschwommen seine Eltern.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 9,
|
||||
"title": "Australien, Farmarbeit",
|
||||
"note": "Ein Farmer fotografiert Lukas mit Strohhut und Kisten voller Mangos auf einer Plantage in Queensland, Schweiß, Sonnenbrand.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 10,
|
||||
"title": "Great Barrier Reef",
|
||||
"note": "Ein Tauchlehrer fotografiert Lukas unter Wasser am Great Barrier Reef mit Taucherbrille und Schnorchel, um ihn herum bunte Korallen und ein Schwarm gelber Fische.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 11,
|
||||
"title": "Roadtrip Outback",
|
||||
"note": "Ein Foto vor einem rostigen Kombi mitten im Outback, Lukas und zwei Freunde auf dem Dach, roter Sand bis zum Horizont, Sonnenuntergang.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 12,
|
||||
"title": "Uluru",
|
||||
"note": "Ein Mitreisender fotografiert Lukas bei Sonnenuntergang vor dem rot leuchtenden Uluru, er sitzt auf der Motorhaube des Kombis mit einer Dose Bier.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 1,
|
||||
"title": "Silvester Sydney",
|
||||
"note": "Ein Selfie am Hafen von Sydney mit dem Feuerwerk über der Harbour Bridge im Hintergrund, Lukas zwischen fremden feiernden Menschen.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 3,
|
||||
"title": "Fähre nach Neuseeland",
|
||||
"note": "Ein Selfie an der Reling einer Fähre, graues Meer, Lukas mit Kapuze, Blick auf die Küste der Südinsel.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 4,
|
||||
"title": "Aroha",
|
||||
"note": "Eine Kellnerin fotografiert Lukas und Aroha, eine junge Māori-Frau, beim ersten gemeinsamen Kaffee in einem Café in Nelson, beide verlegen lachend.",
|
||||
"area": "Liebe",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 5,
|
||||
"title": "Milford Sound",
|
||||
"note": "Aroha fotografiert Lukas im Kajak im Milford Sound, steile Felswände mit Wasserfällen im Nebel, ein Delfin neben dem Boot.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 6,
|
||||
"title": "Arbeit auf dem Fischerboot",
|
||||
"note": "Ein Fischer fotografiert Lukas in Ölzeug auf einem Fischkutter vor der Küste, eine Kiste Muscheln in den Händen, Möwen.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 8,
|
||||
"title": "Arohas Familie",
|
||||
"note": "Arohas Mutter fotografiert Lukas beim Hāngi, einem Erdofenfest, mit Arohas großer Familie, Lukas hilft beim Ausgraben der Speisen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 11,
|
||||
"title": "Das Haus am Meer",
|
||||
"note": "Ein Selfie von Lukas und Aroha vor einem kleinen Holzhaus mit Blick auf die Tasman Bay, ein Schlüssel in Arohas Hand.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 30,
|
||||
"month": 2,
|
||||
"title": "30. Geburtstag am Strand",
|
||||
"note": "Aroha fotografiert Lukas am Lagerfeuer am Strand zu seinem 30. Geburtstag, barfuß, mit Gitarre, Freunde im Halbkreis.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 30,
|
||||
"month": 7,
|
||||
"title": "Eltern zu Besuch",
|
||||
"note": "Aroha fotografiert Lukas mit seinen Eltern Renate und Klaus am Flughafen Nelson, Klaus mit Hut, Renate weint und lacht.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 30,
|
||||
"month": 10,
|
||||
"title": "Visum-Sorgen",
|
||||
"note": "Ein Selfie am Küchentisch mit einem Stapel Formulare (unleserlich), Lukas mit angespanntem Blick, Aroha im Hintergrund am Telefon.",
|
||||
"area": "Alltag",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 31,
|
||||
"month": 3,
|
||||
"title": "Aroha ist schwanger",
|
||||
"note": "Ein Selfie von Lukas und Aroha am Strand, Aroha hält ein Ultraschallbild in die Kamera, beide überwältigt.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 31,
|
||||
"month": 6,
|
||||
"title": "Eigenes Boot",
|
||||
"note": "Ein Freund fotografiert Lukas beim Streichen des Rumpfs eines kleinen gebrauchten Fischerboots im Trockendock, der Name noch nicht aufgemalt.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 31,
|
||||
"month": 11,
|
||||
"title": "Mia wird geboren",
|
||||
"note": "Eine Hebamme fotografiert Lukas mit seiner neugeborenen Tochter Mia im Arm im Geburtshaus, Aroha im Bett daneben erschöpft und glücklich.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 2,
|
||||
"title": "Erste Nächte",
|
||||
"note": "Ein übermüdetes Selfie um vier Uhr morgens mit Mia auf der Brust, das Meer im Fenster.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 5,
|
||||
"title": "Klaus' Herzinfarkt",
|
||||
"note": "Ein Selfie am Flughafen Auckland, Lukas allein mit kleinem Rucksack, verzweifelter Blick, auf dem Weg nach Deutschland.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 6,
|
||||
"title": "Am Krankenbett in Koblenz",
|
||||
"note": "Renate fotografiert Lukas an Klaus' Krankenbett in Koblenz, Vater und Sohn halten sich die Hand, Klaus blass mit Schläuchen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 8,
|
||||
"title": "Rückflug mit schlechtem Gewissen",
|
||||
"note": "Ein Selfie im Flugzeugfenster über den Wolken, Lukas mit geschlossenen Augen, zwischen zwei Welten.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 1,
|
||||
"title": "Streit um die Zukunft",
|
||||
"note": "Ein Selfie auf der Veranda des Hauses am Meer, Lukas allein, im Hintergrund Aroha mit Mia auf dem Arm, die ins Haus geht.",
|
||||
"area": "Partnerschaft",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 4,
|
||||
"title": "Mias erster Geburtstag",
|
||||
"note": "Arohas Mutter fotografiert Mia mit einem kleinen Kuchen, Lukas und Aroha jeweils auf einer Seite, beide lächeln, aber nicht zueinander.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 9,
|
||||
"title": "Trennung",
|
||||
"note": "Ein Selfie in einer kleinen Mietwohnung in Nelson, Lukas mit Mias Kinderzeichnung an der Wand und einem Feldbett.",
|
||||
"area": "Partnerschaft",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 2,
|
||||
"title": "Wochenend-Papa",
|
||||
"note": "Eine Passantin fotografiert Lukas und Mia, 2, beim Muschelnsuchen am Strand, Lukas hockt neben ihr, beide konzentriert.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 6,
|
||||
"title": "Südamerika, Flucht nach vorn",
|
||||
"note": "Ein Selfie am Flughafen Santiago de Chile, Lukas mit dem alten Rucksack, entschlossen, aber müde.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 7,
|
||||
"title": "Rio de Janeiro",
|
||||
"note": "Ein Tourist fotografiert Lukas mit ausgebreiteten Armen vor der Christusstatue auf dem Corcovado, unter ihm die Bucht von Rio und der Zuckerhut im Dunst.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 8,
|
||||
"title": "Patagonien",
|
||||
"note": "Ein Wanderer fotografiert Lukas vor den Türmen des Torres del Paine, Wind, Lukas' Bart ist lang geworden.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 9,
|
||||
"title": "Iguazú-Wasserfälle",
|
||||
"note": "Ein tropfnasses Selfie auf dem Steg über dem Teufelsschlund der Iguazú-Fälle, Gischt überall, ein Regenbogen, Lukas lacht mit offenem Mund.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 11,
|
||||
"title": "Salar de Uyuni",
|
||||
"note": "Ein Reisender fotografiert Lukas auf der spiegelnden Salzfläche von Uyuni in Bolivien, Himmel und Boden verschmelzen, Lukas springt.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 2,
|
||||
"title": "Machu Picchu",
|
||||
"note": "Ein Wanderer fotografiert Lukas nach vier Tagen Inka-Trail auf der Terrasse über Machu Picchu, Wolken ziehen durch die Ruinen, Lukas mit langem Bart und geflicktem Rucksack.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 3,
|
||||
"title": "Lockdown in Cusco",
|
||||
"note": "Ein Selfie in einem geschlossenen Hostel in Cusco, Lukas mit Maske am Fenster, leere Gassen, Polizei auf der Straße.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 4,
|
||||
"title": "Videoanruf mit Mia",
|
||||
"note": "Ein Selfie mit dem Handy, auf dem Bildschirm verschwommen Mia, 3, die ihm etwas zeigt, Lukas lächelt mit feuchten Augen.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 7,
|
||||
"title": "Rückholflug",
|
||||
"note": "Ein Mitreisender fotografiert Lukas in einem fast leeren Flugzeug mit Maske, Blick aus dem Fenster auf die Anden.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 9,
|
||||
"title": "Zurück im Hunsrück",
|
||||
"note": "Ein Selfie vor dem Elternhaus in einem Hunsrückdorf, Lukas mit dem alten Rucksack, Renate in der Tür, Klaus mit Gehstock dahinter.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 11,
|
||||
"title": "Altes Kinderzimmer",
|
||||
"note": "Ein Selfie im alten Kinderzimmer mit Weltkarte an der Wand, Lukas, 35, sitzt auf dem zu kleinen Bett und schaut auf die Karte.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 36,
|
||||
"month": 2,
|
||||
"title": "Klaus' Werkstatt",
|
||||
"note": "Renate fotografiert Lukas und Klaus in der alten Fahrradwerkstatt im Schuppen, Klaus erklärt sitzend, Lukas schraubt.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 36,
|
||||
"month": 5,
|
||||
"title": "Erste Kunden im Dorf",
|
||||
"note": "Eine Nachbarin fotografiert Lukas vor dem Schuppen mit einem handgemalten Schild (unleserlich) und drei reparierten Fahrrädern, ein älterer Kunde bezahlt.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 36,
|
||||
"month": 8,
|
||||
"title": "Mia kommt zu Besuch",
|
||||
"note": "Renate fotografiert Lukas am Flughafen Frankfurt, der Mia, 4, hochhebt, Aroha ein paar Schritte dahinter mit den Koffern.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 36,
|
||||
"month": 9,
|
||||
"title": "Drei Wochen Sommer",
|
||||
"note": "Klaus fotografiert Lukas und Mia auf einem Kinderfahrrad und einem alten Herrenrad auf einem Feldweg im Hunsrück, Sonnenblumen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 36,
|
||||
"month": 10,
|
||||
"title": "Abschied am Gate",
|
||||
"note": "Ein Selfie am Flughafen mit Mia auf dem Arm, beide mit verheulten Gesichtern, Aroha hält die Boarding Pässe.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 37,
|
||||
"month": 1,
|
||||
"title": "Winter allein",
|
||||
"note": "Ein Selfie in der kalten Werkstatt bei Kerzenlicht, Lukas mit Mütze und Mias Zeichnung an der Wand hinter ihm.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 37,
|
||||
"month": 4,
|
||||
"title": "Werkstatt wird Laden",
|
||||
"note": "Ein Freund fotografiert Lukas beim Einbau eines großen Fensters in den alten Schuppen, Fahrräder hängen an der Wand, Klaus schaut vom Stuhl aus zu.",
|
||||
"area": "Selbstständigkeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 37,
|
||||
"month": 7,
|
||||
"title": "Dorffest",
|
||||
"note": "Ein Foto vom Dorffest: Lukas am Bierzelttisch mit Nachbarn, ein alter Schulfreund legt ihm den Arm um die Schulter, Lampions.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 37,
|
||||
"month": 9,
|
||||
"title": "Mia, zweiter Sommer",
|
||||
"note": "Renate fotografiert Lukas und Mia, 5, die in der Werkstatt gemeinsam ein kleines Kinderfahrrad in leuchtendem Türkis lackieren.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 38,
|
||||
"month": 3,
|
||||
"title": "Klaus stirbt",
|
||||
"note": "Ein Foto vom Dorffriedhof im März, Lukas und Renate Arm in Arm hinter dem Sarg, das halbe Dorf dahinter.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 38,
|
||||
"month": 6,
|
||||
"title": "Renate zieht in die Werkstatt-Wohnung",
|
||||
"note": "Ein Nachbar fotografiert Lukas, der Renates Sessel in die kleine Wohnung über der Werkstatt trägt, Renate dirigiert von unten.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 38,
|
||||
"month": 8,
|
||||
"title": "Aroha bringt Mia",
|
||||
"note": "Renate fotografiert Lukas und Aroha, die vor der Werkstatt zusammen Kaffee trinken, Mia, 6, fährt Kreise um sie herum, beide entspannt.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 39,
|
||||
"month": 5,
|
||||
"title": "Radtour-Gruppe",
|
||||
"note": "Ein Teilnehmer fotografiert Lukas als Guide einer Gruppe von acht Radfahrern vor der Werkstatt, Lukas mit Karte, alle mit Helmen.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 39,
|
||||
"month": 9,
|
||||
"title": "Mia lernt Deutsch",
|
||||
"note": "Renate fotografiert Lukas und Mia, 7, die am Küchentisch über einem Kinderbuch sitzen, Mia liest laut, Lukas hält sich das Lachen zurück.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 40,
|
||||
"month": 2,
|
||||
"title": "40. Geburtstag",
|
||||
"note": "Renate fotografiert Lukas zu seinem 40. in der Werkstatt, Freunde aus dem Dorf mit Bier, an der Wand hängt die alte Weltkarte aus dem Kinderzimmer mit Stecknadeln.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 40,
|
||||
"month": 7,
|
||||
"title": "Mia bleibt einen Monat",
|
||||
"note": "Ein Nachbar fotografiert Lukas und Mia, 8, die auf dem Dach der Werkstatt sitzen und über das Dorf schauen, Mia mit Lukas' altem Rucksack als Kissen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 40,
|
||||
"month": 10,
|
||||
"title": "Flug nach Nelson gebucht",
|
||||
"note": "Ein Selfie am Laptop mit einer Flugbuchung auf dem Bildschirm (verschwommen), Lukas mit ruhigem Lächeln, daneben eine Postkarte von der Tasman Bay.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 40,
|
||||
"month": 12,
|
||||
"title": "Zwei Zuhause",
|
||||
"note": "Ein Selfie an der Werkstatttür im Schnee, Lukas mit Mütze, in der Hand eine gerahmte Zeichnung von Mia: ein Haus am Meer und ein Haus mit Fahrrädern, dazwischen ein Flugzeug.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv"
|
||||
}
|
||||
]
|
||||
}
|
||||
553
backend/database/data/lifelines/nachtschicht.json
Normal file
553
backend/database/data/lifelines/nachtschicht.json
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
{
|
||||
"person": {
|
||||
"name": "Nachtschicht (Band)",
|
||||
"email": "nachtschicht@thats-me.app",
|
||||
"birthYear": 1995
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"age": 0,
|
||||
"month": 3,
|
||||
"title": "Erste Probe im Keller",
|
||||
"note": "Svens Mutter fotografiert vier 16-Jährige in Schulkapuzenpullis und Turnschuhen im Heizungskeller eines Reihenhauses in Gelsenkirchen: Sven mit Zahnspange am Mikro, Kai mit Pickeln und geliehener Gitarre, Jule mit Zöpfen am Bass, Basti hinter einem Schlagzeug mit Eierkartons an der Wand.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 0,
|
||||
"month": 6,
|
||||
"title": "Der Name",
|
||||
"note": "Kai fotografiert Sven, der mit Edding „Nachtschicht“ auf einen alten Bettbezug schreibt, Jule und Basti halten den Stoff, alle diskutieren.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 0,
|
||||
"month": 11,
|
||||
"title": "Erster Auftritt, Schulaula",
|
||||
"note": "Ein Lehrer fotografiert die Band auf der Bühne der Schulaula beim Herbstfest, brave Schülerkleidung, Sven mit geschlossenen Augen am Mikro, vor der Bühne etwa dreißig Schüler, Jule mit verlegenem Grinsen.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 1,
|
||||
"month": 4,
|
||||
"title": "Demo-Kassette",
|
||||
"note": "Basti fotografiert die vier auf Jules Bett, jeder hält eine kopierte Kassette mit handgeschriebenem Cover, dahinter ein Stapel von fünfzig Stück.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 1,
|
||||
"month": 9,
|
||||
"title": "Jugendzentrum",
|
||||
"note": "Ein Zuschauer fotografiert die Band im Jugendzentrum: verschwitzt, Sven springt, Kai auf dem Boden mit dem Rücken zum Publikum, etwa hundert Leute.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 2,
|
||||
"month": 7,
|
||||
"title": "Bandbus",
|
||||
"note": "Bastis Vater fotografiert die vier vor einem verrosteten roten VW T3 mit Schiebetür, Kai auf dem Dach, Jule mit dem Schlüssel, Sven küsst die Motorhaube.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 2,
|
||||
"month": 10,
|
||||
"title": "Bandcontest Bochum",
|
||||
"note": "Ein Fotograf der Lokalzeitung fotografiert die Band mit einem Pokal auf der Bühne eines Clubs in Bochum, erste schwarze Klamotten, Sven mit gefärbten Haaren, Basti hebt Jule hoch, Sven und Kai umarmen sich.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 3,
|
||||
"month": 2,
|
||||
"title": "Abitur und Streit",
|
||||
"note": "Jule fotografiert Sven und Basti, die im Proberaum mit dem Rücken zueinander sitzen, dazwischen ein Studienführer und ein Tourplan.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 3,
|
||||
"month": 8,
|
||||
"title": "Erste eigene EP",
|
||||
"note": "Ein Tontechniker fotografiert die Band in einem kleinen Studio in Dortmund hinter dem Mischpult, alle lauschen mit geschlossenen Augen der Aufnahme.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 4,
|
||||
"month": 5,
|
||||
"title": "Basti geht",
|
||||
"note": "Kai fotografiert Basti, der seine Drumsticks auf die Snare legt und den Proberaum verlässt, Sven sitzt auf dem Verstärker und schaut zu Boden.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 4,
|
||||
"month": 7,
|
||||
"title": "Nele",
|
||||
"note": "Jule fotografiert Nele, eine 19-jährige Schlagzeugerin mit rasierten Seiten, beim Vorspielen im Proberaum, Sven und Kai mit offenem Mund.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 4,
|
||||
"month": 10,
|
||||
"title": "Erste Tour, sieben Städte",
|
||||
"note": "Ein Fan fotografiert die Band nachts an einer Autobahnraststätte vor dem T3, alle mit Pommes, Nele schläft auf dem Bordstein.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 5,
|
||||
"month": 3,
|
||||
"title": "Plattenvertrag",
|
||||
"note": "Ein Labelmitarbeiter fotografiert die vier an einem Konferenztisch in Berlin, Sven mit frischem Tattoo am Unterarm unterschreibt einen Vertrag (unleserlich), Kai mit schulterlangen Haaren hält die Faust hoch, Jule mit Sektglas, Nele mit Nasenring.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 5,
|
||||
"month": 11,
|
||||
"title": "Studio in Hamburg",
|
||||
"note": "Ein Produzent fotografiert Sven allein in der Gesangskabine um drei Uhr nachts, sichtlich fertig, Notizzettel überall.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 6,
|
||||
"month": 4,
|
||||
"title": "Erstes Album",
|
||||
"note": "Ein Plattenladenbesitzer fotografiert die Band vor einem Regal mit ihrem ersten Album in einem Plattenladen in Gelsenkirchen, Kai küsst eine CD.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 6,
|
||||
"month": 9,
|
||||
"title": "Radio-Hit",
|
||||
"note": "Ein Kellner fotografiert die vier in einer Kneipe, wie sie am Radio hinter dem Tresen ihren eigenen Song hören, Jule hält sich die Ohren zu vor Aufregung.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 7,
|
||||
"month": 2,
|
||||
"title": "Support-Tour Europa",
|
||||
"note": "Ein Roadie fotografiert die Band im Backstagebereich einer großen Halle in Paris, alle sitzen auf Flightcases, Neles Sticks im Mund.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 7,
|
||||
"month": 6,
|
||||
"title": "Erste große Bühne",
|
||||
"note": "Ein Festivalfotograf fotografiert die Band von der Bühnenseite bei einem Festival, vor ihnen ein Meer von zehntausend Menschen im Abendlicht, Sven ohne Hemd mit ausgebreiteten Armen, Kai in Lederjacke mit Gitarre über dem Kopf, Nele mit blondem Irokesenschnitt.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 7,
|
||||
"month": 12,
|
||||
"title": "Sven verliert die Stimme",
|
||||
"note": "Kai fotografiert Sven im Wartezimmer eines HNO-Arztes mit einem Schal um den Hals, ein Zettel mit „Nicht sprechen“ (unleserlich) in der Hand.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 8,
|
||||
"month": 5,
|
||||
"title": "Zweites Album floppt",
|
||||
"note": "Ein Labelmitarbeiter fotografiert die vier in einem Besprechungsraum mit Verkaufszahlen an der Wand (unleserlich), lange Gesichter, Jule schaut aus dem Fenster.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 8,
|
||||
"month": 9,
|
||||
"title": "Kai will aufhören",
|
||||
"note": "Nele fotografiert Kai, der seine Gitarre im Proberaum in den Koffer packt, Sven steht in der Tür mit einer Hand am Rahmen.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 8,
|
||||
"month": 11,
|
||||
"title": "Die Aussprache",
|
||||
"note": "Jule fotografiert Sven und Kai um fünf Uhr morgens auf einer Bank an der Emscher, beide mit Kaffeebechern, nach einer Nacht Reden.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 9,
|
||||
"month": 3,
|
||||
"title": "Neuanfang im Keller",
|
||||
"note": "Svens Mutter fotografiert die Band wieder im Heizungskeller von damals, jetzt mit besseren Instrumenten, aber denselben Eierkartons.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 9,
|
||||
"month": 10,
|
||||
"title": "Das dritte Album",
|
||||
"note": "Ein Tontechniker fotografiert die vier, die in einem Studio um ein einzelnes Mikrofon stehen und den letzten Refrain gemeinsam einsingen.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 10,
|
||||
"month": 4,
|
||||
"title": "Nummer eins",
|
||||
"note": "Ein Fan fotografiert die Band, die in einem Elektromarkt vor einer Wand aus Fernsehern steht, auf allen läuft ihr Video, Nele mit Tränen in den Augen.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 10,
|
||||
"month": 8,
|
||||
"title": "Rock am Ring",
|
||||
"note": "Ein Festivalfotograf fotografiert die Band von hinten auf der Hauptbühne von Rock am Ring, vor ihnen die Menge bis zum Horizont, Pyros, Sven mit tätowierten Armen und Rücken, Kai mit langen Haaren und Lederweste, Jule mit rasierter Seite und Bass über der Schulter, Nele hinter einem riesigen Schlagzeug.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 10,
|
||||
"month": 12,
|
||||
"title": "Preisverleihung",
|
||||
"note": "Ein Pressefotograf fotografiert die vier in Abendgarderobe mit einem Musikpreis auf einem roten Teppich, Kai hat seine Krawatte um den Kopf gebunden.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 11,
|
||||
"month": 5,
|
||||
"title": "Tourbus statt T3",
|
||||
"note": "Ein Roadie fotografiert die Band vor einem riesigen schwarzen Nightliner-Tourbus, daneben zum Vergleich ein Foto des alten roten T3, das Jule hochhält.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 11,
|
||||
"month": 9,
|
||||
"title": "Jule wird Mutter",
|
||||
"note": "Sven fotografiert Jule mit ihrem Neugeborenen im Backstagebereich, Nele und Kai beugen sich darüber, Bühnenlicht im Hintergrund.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 12,
|
||||
"month": 3,
|
||||
"title": "USA-Tour",
|
||||
"note": "Ein Fan fotografiert die Band vor einem Club in Austin, Texas, alle mit Cowboyhüten, Sven mit einem Burrito.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 12,
|
||||
"month": 7,
|
||||
"title": "Wembley-Arena, Support",
|
||||
"note": "Ein Bühnenfotograf fotografiert die Band beim Support-Auftritt in der Wembley-Arena in London, Sven hält das Mikro ins Publikum.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 13,
|
||||
"month": 1,
|
||||
"title": "Erschöpfung",
|
||||
"note": "Kai fotografiert Sven, der in einem Hotelzimmer in Tokio mit Jetlag auf dem Boden liegt, Koffer offen, Vorhänge zu.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Sven the morning after a long-haul flight, sitting on the edge of a bed yawning and rubbing his eyes, still in yesterday's clothes, suitcase open on the floor, curtains drawn with a strip of daylight and a glimpse of neon signs outside. Photo taken by Kai from the doorway.",
|
||||
"imageCharacters": [
|
||||
"sven"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 13,
|
||||
"month": 6,
|
||||
"title": "Stadion, erstes Mal",
|
||||
"note": "Ein Bühnenfotograf fotografiert die Band vom Bühnenrand im Stadion von Gelsenkirchen, ihrer Heimatstadt, 50.000 Menschen, Feuerzeuge und Handylichter, Sven kniet in Lederhose mit tätowiertem Oberkörper, Kai mit Sonnenbrille, Jule mit Bass in die Luft gestreckt.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 13,
|
||||
"month": 10,
|
||||
"title": "Neles Unfall",
|
||||
"note": "Ein Sanitäter fotografiert Nele auf einer Trage hinter der Bühne, das Handgelenk geschient, Jule hält ihre andere Hand.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 14,
|
||||
"month": 2,
|
||||
"title": "Pause",
|
||||
"note": "Ein Kellner fotografiert die vier in einer Eckkneipe in Gelsenkirchen, alle in Winterjacken, Sven hält ein Bier hoch, die Stimmung ernst, aber einig.",
|
||||
"area": "Persönliche Entscheidung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 14,
|
||||
"month": 9,
|
||||
"title": "Kai produziert andere",
|
||||
"note": "Ein Tontechniker fotografiert Kai hinter einem Mischpult mit einer jungen Band im Aufnahmeraum.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 15,
|
||||
"month": 5,
|
||||
"title": "Sven allein auf Tour",
|
||||
"note": "Ein Zuschauer fotografiert Sven allein mit Akustikgitarre auf einer kleinen Kneipenbühne, vielleicht sechzig Leute, ein Schild „Solo“ (unleserlich).",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Sven alone with an acoustic guitar on a small pub stage, about sixty people at tables in a dim room, one spotlight, a beer on the stool next to him. Photo taken by someone in the audience.",
|
||||
"imageCharacters": [
|
||||
"sven"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 15,
|
||||
"month": 11,
|
||||
"title": "Jule bei Nele",
|
||||
"note": "Neles Freundin fotografiert Jule und Nele, die in Neles Wohnzimmer zwischen Kinderspielzeug mit Bass und Übungspad jammen.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 16,
|
||||
"month": 4,
|
||||
"title": "Kais Sohn am Schlagzeug",
|
||||
"note": "Kai fotografiert seinen 4-jährigen Sohn hinter Neles Schlagzeug im alten Proberaum, Nele hält die Sticks.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 17,
|
||||
"month": 6,
|
||||
"title": "Die Nachricht",
|
||||
"note": "Jule fotografiert ihr Handy mit einer Gruppenchat-Nachricht von Sven (unleserlich), im Hintergrund Kai mit hochgezogenen Augenbrauen.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 17,
|
||||
"month": 8,
|
||||
"title": "Reunion im Keller",
|
||||
"note": "Svens Mutter, jetzt 70, fotografiert die vier wieder im Heizungskeller, alle mit grauen Strähnen, Sven mit Vollbart und verblassten Tattoos, Kai mit kurzen grauen Haaren und Lesebrille, Nele mit grauem Kurzhaarschnitt, Jule weint, Sven umarmt Kai.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 17,
|
||||
"month": 10,
|
||||
"title": "Comeback-Single",
|
||||
"note": "Ein Tontechniker fotografiert die Band im Studio, alle vier stoßen mit Kaffeebechern an, auf dem Bildschirm eine Wellenform.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"month": 3,
|
||||
"title": "Ausverkaufte Tour in zwei Stunden",
|
||||
"note": "Ein Manager fotografiert die Band im Büro, Sven hält den Laptop mit „Ausverkauft“ (unleserlich) hoch, Jule mit beiden Händen im Haar.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"month": 7,
|
||||
"title": "Stadion, zweites Mal",
|
||||
"note": "Ein Bühnenfotograf fotografiert die Band im Olympiastadion Berlin, Kais Sohn, 6, mit Gehörschutz an der Bühnenseite, Kai winkt ihm.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Backstage side view of a stadium concert at dusk: Kai on guitar at the edge of the stage waving towards the wings, where a small boy seen from behind wearing large red ear protectors stands next to a roadie; Sven, Jule and Nele playing further back, huge crowd in the background.",
|
||||
"imageCharacters": [
|
||||
"kai",
|
||||
"sven",
|
||||
"jule",
|
||||
"nele"
|
||||
],
|
||||
"imageAnchorAges": {
|
||||
"nele": 33
|
||||
}
|
||||
},
|
||||
{
|
||||
"age": 19,
|
||||
"month": 2,
|
||||
"title": "Svens Vater stirbt",
|
||||
"note": "Kai fotografiert Sven auf dem Friedhof in Gelsenkirchen, die Band steht hinter ihm, alle in Schwarz, Nele hält seinen Arm.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ"
|
||||
},
|
||||
{
|
||||
"age": 19,
|
||||
"month": 9,
|
||||
"title": "Südamerika",
|
||||
"note": "Ein Fan fotografiert die Band auf einer Bühne in São Paulo, 40.000 Menschen singen den Refrain, Sven hält das Mikro ins Publikum und weint.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "The four-piece band on a big festival stage in São Paulo at night, an enormous crowd of 40,000 people with raised arms under stage lights, Sven at the front holding the microphone out towards the crowd, visibly moved, eyes glistening. Photo taken from the crowd.",
|
||||
"imageCharacters": [
|
||||
"sven",
|
||||
"kai",
|
||||
"jule",
|
||||
"nele"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 20,
|
||||
"month": 5,
|
||||
"title": "Dokumentarfilm",
|
||||
"note": "Ein Kameramann fotografiert die vier auf einem Sofa im alten Proberaum vor einem Filmteam, die Eierkartons hängen noch.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 20,
|
||||
"month": 11,
|
||||
"title": "Abschied vom T3",
|
||||
"note": "Jule fotografiert den restaurierten roten T3 im Foyer eines Rockmuseums, die Band davor, Nele liest die Info-Tafel (unleserlich).",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 21,
|
||||
"month": 6,
|
||||
"title": "Kai hört auf zu touren",
|
||||
"note": "Sven fotografiert Kai, der auf einer Parkbank sitzt und ein Bild seines Sohns zeigt, ruhig, entschieden.",
|
||||
"area": "Persönliche Entscheidung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ"
|
||||
},
|
||||
{
|
||||
"age": 21,
|
||||
"month": 10,
|
||||
"title": "Kai bleibt im Studio",
|
||||
"note": "Ein Tontechniker fotografiert Kai hinter dem Mischpult, davor Sven, Jule und Nele mit einer jungen Gitarristin im Aufnahmeraum.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 22,
|
||||
"month": 7,
|
||||
"title": "Tour mit Ersatzgitarristin",
|
||||
"note": "Ein Bühnenfotograf fotografiert die Band mit einer jungen Gitarristin auf einem Festival, Sven zeigt auf sie, das Publikum jubelt.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "The four-piece band on a festival stage in the afternoon with a guest guitarist in her late twenties standing in for Kai, Sven pointing at her with a big smile while the crowd cheers; Kai is not on stage. Photo taken by a stage photographer.",
|
||||
"imageCharacters": [
|
||||
"sven",
|
||||
"jule",
|
||||
"nele"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 23,
|
||||
"month": 3,
|
||||
"title": "Nele wird 40",
|
||||
"note": "Ein Kellner fotografiert die vier, jetzt alle über 40, an einem Restauranttisch, Nele mit Geburtstagskerze auf dem Tiramisu, Kai zum ersten Mal seit Monaten dabei.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv"
|
||||
},
|
||||
{
|
||||
"age": 24,
|
||||
"month": 4,
|
||||
"title": "Grammy-Nominierung",
|
||||
"note": "Ein Pressefotograf fotografiert die Band auf einer Bühne in Los Angeles in Abendgarderobe, Sven mit Sonnenbrille, Jule verdreht die Augen.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 24,
|
||||
"month": 9,
|
||||
"title": "Sven sagt Nein zum Stadion",
|
||||
"note": "Jule fotografiert Sven, der in einem Café einen Tourplan zusammenfaltet und in die Tasche steckt, entspannt.",
|
||||
"area": "Persönliche Entscheidung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral"
|
||||
},
|
||||
{
|
||||
"age": 25,
|
||||
"month": 5,
|
||||
"title": "Clubtour",
|
||||
"note": "Ein Zuschauer fotografiert die Band in einem winzigen Club in Bochum, demselben wie beim Bandcontest 1997, Schweiß von der Decke, dreihundert Leute.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 25,
|
||||
"month": 11,
|
||||
"title": "Zurück in der Schulaula",
|
||||
"note": "Ein Lehrer fotografiert die Band auf der Bühne der Schulaula ihres alten Gymnasiums in Gelsenkirchen, 30 Jahre nach dem ersten Auftritt, Sven mit Tränen, Kai wieder an der Gitarre, vor der Bühne Schüler, Eltern und die Band-Mütter in der ersten Reihe.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
},
|
||||
{
|
||||
"age": 30,
|
||||
"month": 3,
|
||||
"title": "Dreißig Jahre",
|
||||
"note": "Svens Mutter fotografiert die vier auf der Treppe zum Heizungskeller, die Tür offen, dahinter das alte Schlagzeug mit Eierkartons, alle grau, alle in Lederjacken, alle lachen.",
|
||||
"area": "Freundschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv"
|
||||
}
|
||||
]
|
||||
}
|
||||
614
backend/database/data/lifelines/nadja.json
Normal file
614
backend/database/data/lifelines/nadja.json
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
{
|
||||
"person": {
|
||||
"name": "Nadja",
|
||||
"email": "nadja@thats-me.app",
|
||||
"birthYear": 1988
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"age": 24,
|
||||
"month": 3,
|
||||
"title": "Erster Job in der Agentur",
|
||||
"note": "Eine Kollegin fotografiert Nadja an ihrem ersten Tag in der Hamburger Eventagentur, noch mit Besucherausweis am Pulli.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Nadja's first day at a Hamburg event agency: she stands in a modern open-plan office holding a visitor badge clipped to her sweater, a bit nervous, colleagues blurred in the background",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 24,
|
||||
"month": 9,
|
||||
"title": "Tim auf dem Festival",
|
||||
"note": "Ein verwackeltes Selfie im Regen vor der Festivalbühne zeigt Nadja und Tim, die sich vor zwei Stunden kennengelernt haben.",
|
||||
"area": "Liebe",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "A shaky selfie in the rain in front of a festival stage at night, Nadja and Tim squeezed into the frame, both soaked, laughing, festival wristbands, stage lights blurred behind them – they met two hours ago",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 25,
|
||||
"month": 2,
|
||||
"title": "Zusammen in Ottensen",
|
||||
"note": "Ein Selfie zwischen Umzugskartons in der kleinen Altbauwohnung in Ottensen, Tim schleppt im Hintergrund das Sofa.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Selfie between moving boxes in a small old-building apartment in Hamburg-Ottensen, Nadja in front, Tim in the background carrying one end of a sofa",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 25,
|
||||
"month": 7,
|
||||
"title": "Erstes eigenes Großevent",
|
||||
"note": "Ein Kollege fotografiert Nadja mit Headset und Klemmbrett vor dem Eingang der Firmengala, die sie zum ersten Mal allein verantwortet.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Nadja with a headset and a clipboard in front of the entrance of a corporate gala evening, in a black blazer, guests arriving blurred behind her, string lights",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 26,
|
||||
"month": 5,
|
||||
"title": "Projektleitung",
|
||||
"note": "Ein Teamfoto nach der Bekanntgabe, dass Nadja die Projektleitung für den größten Kunden übernimmt.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Team photo in an agency office after an announcement: Nadja in the middle, colleagues around her clapping, a whiteboard behind them, everyone smiling",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 26,
|
||||
"month": 12,
|
||||
"title": "Weihnachten mit beiden Familien",
|
||||
"note": "Nadjas Vater fotografiert die erste gemeinsame Weihnachtstafel beider Familien, Tim und Nadja in der Mitte.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "A long Christmas dinner table in a German family living room, two families together, Nadja and Tim sitting in the middle, candles, wine, a Christmas tree",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 6,
|
||||
"title": "Antrag am Elbstrand",
|
||||
"note": "Eine Spaziergängerin fotografiert den Moment, in dem Tim am Elbstrand vor Nadja kniet, im Hintergrund fährt ein Containerschiff vorbei.",
|
||||
"area": "Liebe",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "At the Elbe river beach in Hamburg at sunset, Tim kneeling in the sand in front of Nadja holding a small ring box, Nadja with both hands over her mouth, a huge container ship passing in the background",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 27,
|
||||
"month": 10,
|
||||
"title": "Krankgeschrieben",
|
||||
"note": "Ein Selfie auf dem Sofa mit Krankschreibung in der Hand zeigt Nadja nach dem Zusammenbruch auf der Messe – der Arzt sagt Erschöpfung, sie sagt Stress.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Selfie on a sofa under a blanket, Nadja looking exhausted and pale, holding a folded sheet of paper (a sick note, shown blank), tea cup on the table",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 5,
|
||||
"title": "Hochzeit in Dänemark",
|
||||
"note": "Der Hochzeitsfotograf hält Nadja und Tim barfuß am Strand von Rømø fest, die Gäste werfen Blüten.",
|
||||
"area": "Hochzeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Wedding on the beach of Rømø in Denmark: Nadja in a simple white dress and Tim in a light suit, both barefoot in the sand, guests throwing flower petals, wind, bright sky",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 28,
|
||||
"month": 8,
|
||||
"title": "Flitterwochen Island",
|
||||
"note": "Ein Selfie vor einem Wasserfall, beide in tropfnassen Regenjacken und mit breitem Grinsen.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Selfie in front of a huge waterfall in Iceland, Nadja and Tim in dripping wet rain jackets, hoods up, grinning widely",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 3,
|
||||
"title": "Eigene Agentur mit Tim",
|
||||
"note": "Ein Freund fotografiert Nadja und Tim vor dem noch leeren Büro in der Schanze, an der Tür klebt ein ausgedrucktes Logo.",
|
||||
"area": "Selbstständigkeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Nadja and Tim standing in front of an empty office space in Hamburg's Schanze quarter, a plain printed logo taped to the glass door (shown blank), both proud, arms around each other",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 29,
|
||||
"month": 11,
|
||||
"title": "Erster großer Kunde",
|
||||
"note": "Ein Selfie mit dem unterschriebenen Jahresvertrag eines Getränkeherstellers, Tim küsst Nadja auf die Wange.",
|
||||
"area": "Selbstständigkeit",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Selfie in the agency office: Nadja holding up a signed contract (paper shown blank), Tim kissing her on the cheek, laptops and coffee cups on the desk",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 30,
|
||||
"month": 1,
|
||||
"title": "30. Geburtstag",
|
||||
"note": "Ein Foto von der Geburtstagsparty im eigenen Büro zeigt Nadja auf dem Schreibtisch tanzend, umringt von Freunden und Team.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "30th birthday party in the agency office at night: Nadja dancing on top of a desk with a glass of sparkling wine, Kim and Tim and friends around her cheering, fairy lights",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim",
|
||||
"kim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 30,
|
||||
"month": 6,
|
||||
"title": "Kinderwunsch-Gespräch",
|
||||
"note": "Ein Selfie vom Balkon nach einem langen Gespräch über Kinder – Nadja will, Tim sagt: nach dem nächsten Geschäftsjahr.",
|
||||
"area": "Partnerschaft",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Selfie on a small balcony in the evening, Nadja alone with a serious, thoughtful look, city rooftops behind her, blue hour; the phone itself is not visible, only her face and one shoulder, arm reaching out of frame",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 30,
|
||||
"month": 12,
|
||||
"title": "Acht Mitarbeiter",
|
||||
"note": "Das Teamfoto zur Weihnachtsfeier zeigt die Agentur mit inzwischen acht Leuten vor dem Bürofenster.",
|
||||
"area": "Selbstständigkeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Christmas party team photo of a small agency, eight people in front of an office window, Nadja and Tim in the middle, paper hats, ugly Christmas sweaters",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 31,
|
||||
"month": 4,
|
||||
"title": "Schwanger",
|
||||
"note": "Ein Selfie im Badezimmer mit dem positiven Test, Nadja hält sich die Hand vor den Mund.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Smartphone selfie of Nadja in a bright tiled bathroom: she has exactly two arms, one reaching out of the frame towards the camera, the other hand pressed over her mouth in surprise, her eyes wide. The positive pregnancy test lies clearly visible on the edge of the washbasin in the foreground. No third arm or hand anywhere in the picture.",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 31,
|
||||
"month": 7,
|
||||
"title": "Fehlgeburt",
|
||||
"note": "Ein Selfie im Krankenhausbett in der zwölften Woche, Nadja schaut aus dem Fenster, Tim ist nicht auf dem Bild.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "Nadja lying in a hospital bed in a gown, turned towards the window, looking outside, pale, an empty chair next to the bed, no one else in the room, muted light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 31,
|
||||
"month": 9,
|
||||
"title": "Tim zieht sich zurück",
|
||||
"note": "Ein Selfie vom Abendessen zeigt Nadja allein am gedeckten Tisch für zwei, Tim ist wieder länger im Büro.",
|
||||
"area": "Partnerschaft",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Selfie at a dinner table set for two with candles, Nadja alone, the second plate untouched, forced small smile, dim kitchen light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 31,
|
||||
"month": 12,
|
||||
"title": "Streit an Weihnachten",
|
||||
"note": "Ein Familienfoto bei Nadjas Eltern, auf dem Tim und Nadja an entgegengesetzten Enden des Tisches sitzen.",
|
||||
"area": "Partnerschaft",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Christmas dinner at Nadja's parents' house: a long table, Nadja sitting at one end and Tim at the opposite end, both looking away from each other, relatives in between",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"tim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 3,
|
||||
"title": "Alle Events abgesagt",
|
||||
"note": "Ein Selfie vor der Wandtafel im Büro, auf der Nadja jeden Termin des Frühjahrs durchgestrichen hat – Lockdown.",
|
||||
"area": "Selbstständigkeit",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "Nadja in front of a large wall planner in an empty office, every appointment of the spring crossed out with thick marker (writing not legible), arms hanging, March 2020 lockdown",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 5,
|
||||
"title": "Tims Affäre",
|
||||
"note": "Ein Selfie im Auto vor dem Büro, verweint, nachdem Nadja die Nachrichten zwischen Tim und einer Mitarbeiterin gelesen hat.",
|
||||
"area": "Partnerschaft",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "Selfie in a parked car in front of an office building, Nadja crying, red eyes, smeared mascara, phone in her other hand",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 6,
|
||||
"title": "Auf Kims Sofa",
|
||||
"note": "Kim fotografiert Nadja mit einer Reisetasche und einem Kissen auf ihrem Sofa – die erste Nacht nach dem Auszug.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "Nadja sitting on Kim's sofa with a travel bag and a pillow, first night after moving out, Kim's arm around her shoulder, both in sweaters, a mug of tea, evening lamp light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"kim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 8,
|
||||
"title": "Insolvenzantrag",
|
||||
"note": "Ein Selfie vor dem Amtsgericht mit dem Umschlag des Insolvenzantrags, Nadja schaut an der Kamera vorbei.",
|
||||
"area": "Selbstständigkeit",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "Selfie in front of a grey German court building (Amtsgericht), Nadja holding a thick brown envelope, not smiling, eyes looking off to the side past the camera, jaw tense, overcast sky",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 10,
|
||||
"title": "Diagnose Depression",
|
||||
"note": "Ein Selfie im Wartezimmer der Psychotherapeutin, das Nadja Kim schickt, mit der Nachricht: bin da.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Selfie in a quiet waiting room of a psychotherapy practice, Nadja in a coat, tired but calm, plants and a water dispenser",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 32,
|
||||
"month": 12,
|
||||
"title": "Weihnachten allein",
|
||||
"note": "Ein Selfie beim Spaziergang an der Elbe am Heiligabend, allein, mit Thermoskanne und Mütze.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Selfie on a walk along the Elbe river on Christmas Eve, Nadja alone in a wool hat and scarf holding a thermos flask, grey water, harbour cranes far away, cold light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 2,
|
||||
"title": "Scheidung eingereicht",
|
||||
"note": "Ein Selfie mit dem Scheidungsantrag auf dem Küchentisch bei Kim, daneben eine halb leere Tasse Tee.",
|
||||
"area": "Partnerschaft",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Selfie at Kim's kitchen table, Nadja with a stack of official papers in front of her (shown blank), a half-empty cup of tea, neutral, sober expression",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 4,
|
||||
"title": "Erste Wanderung",
|
||||
"note": "Ein Selfie in der Lüneburger Heide, außer Atem, das erste Mal seit Monaten draußen für länger als eine Stunde.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Selfie on a hike in the Lüneburg Heath, Nadja out of breath, red cheeks, heather and pine trees, first time outside for longer than an hour in months",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 6,
|
||||
"title": "Aushilfe in der Gärtnerei",
|
||||
"note": "Die Chefin der Gärtnerei fotografiert Nadja mit Erde bis zu den Ellenbogen zwischen Tomatensetzlingen.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Nadja working in a plant nursery, soil up to her elbows, kneeling between tomato seedlings in a greenhouse, hair tied up with a pencil, apron",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 9,
|
||||
"title": "Scheidung rechtskräftig",
|
||||
"note": "Ein Selfie vor dem Gericht, nüchtern, mit dem Beschluss in der Hand. Nicht traurig, nicht froh.",
|
||||
"area": "Partnerschaft",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Selfie in front of a court building, Nadja holding a document (shown blank), neutral face, neither sad nor happy, autumn",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 33,
|
||||
"month": 11,
|
||||
"title": "Holzwerkstatt-Kurs",
|
||||
"note": "Der Kursleiter fotografiert Nadja mit Schutzbrille an der Bandsäge, zum ersten Mal seit langem konzentriert und ruhig.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Nadja at a band saw in an evening woodworking course, safety goggles on, focused and calm, wood shavings, workshop light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 1,
|
||||
"title": "Bewerbung Tischlerlehre",
|
||||
"note": "Ein Selfie mit dem ausgedruckten Bewerbungsschreiben, Nadja mit 34 als Azubi – Kim findet es großartig, ihre Mutter nicht.",
|
||||
"area": "Persönliche Entscheidung",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Selfie at a kitchen table with a printed application letter (shown blank), Nadja with a determined look, a laptop beside her",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 8,
|
||||
"title": "Ausbildungsbeginn",
|
||||
"note": "Der Meister fotografiert die neuen Azubis der Tischlerei in Lüneburg, Nadja ist mit Abstand die Älteste und die Einzige, die lacht.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Group photo of new apprentices in a carpentry workshop in Lüneburg: four teenagers in new work clothes and Nadja, clearly the oldest and the only one laughing, the master craftsman taking the photo from the front, workbenches behind",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 34,
|
||||
"month": 10,
|
||||
"title": "Kleine Wohnung in Lüneburg",
|
||||
"note": "Ein Selfie in der leeren Ein-Zimmer-Wohnung, eine Matratze, eine Pflanze, eine Werkzeugkiste.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Selfie in an empty one-room apartment: a mattress on the floor, one plant, one toolbox, Nadja sitting on the mattress smiling",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 3,
|
||||
"title": "Erstes eigenes Möbelstück",
|
||||
"note": "Ein Kollege fotografiert Nadja neben ihrem ersten selbst gebauten Hocker aus Eiche, sichtbar stolz.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Nadja in a carpentry workshop next to her first self-built oak stool, hand resting on it, proud, sawdust on her flannel shirt",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 7,
|
||||
"title": "Ben am See",
|
||||
"note": "Ein Selfie am Badesteg, nass und lachend, mit Ben, den sie beim Schwimmen kennengelernt hat, weil er ihr Handtuch geklaut hat.",
|
||||
"area": "Liebe",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Selfie on a wooden bathing jetty at a lake, Nadja and Ben both wet-haired and laughing, towels, summer, sparkling water behind them",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"ben"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 35,
|
||||
"month": 12,
|
||||
"title": "Therapie abgeschlossen",
|
||||
"note": "Ein Selfie vor der Praxistür nach der letzten Sitzung, das Nadja an Kim schickt: fertig.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Selfie in front of a practice door with a small brass sign (unreadable), Nadja smiling with relief, winter coat, last therapy session",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 36,
|
||||
"month": 6,
|
||||
"title": "Gesellenprüfung bestanden",
|
||||
"note": "Der Meister fotografiert Nadja mit dem Gesellenbrief und dem Prüfungsstück, einem Sekretär aus Nussbaum.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Nadja holding her journeyman's certificate (shown blank) next to her exam piece, an elegant walnut writing desk, in a carpentry workshop, master craftsman's hands clapping at the edge of the frame",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 36,
|
||||
"month": 9,
|
||||
"title": "Ben zieht ein",
|
||||
"note": "Ein Selfie vor der neuen Zwei-Zimmer-Wohnung mit Ben, zwei Fahrrädern und einer Werkbank im Flur.",
|
||||
"area": "Partnerschaft",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Selfie in front of a new two-room apartment door with Ben, two bicycles and a workbench in the hallway behind them",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"ben"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 36,
|
||||
"month": 11,
|
||||
"title": "Tim in Hamburg getroffen",
|
||||
"note": "Ein Selfie am Hamburger Hauptbahnhof nach einer zufälligen Begegnung mit Tim – zehn Minuten Small Talk, kein Zittern.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Selfie in the busy main hall of Hamburg central station, Nadja calm and composed, trains and crowds blurred behind her",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 37,
|
||||
"month": 4,
|
||||
"title": "Eigene Werkstatt",
|
||||
"note": "Ben fotografiert Nadja vor dem Rolltor ihrer eigenen kleinen Werkstatt am Stadtrand von Lüneburg, das Schild hat sie selbst gefräst.",
|
||||
"area": "Selbstständigkeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Nadja in front of the open roller door of her own small carpentry workshop on the edge of Lüneburg, a hand-milled wooden sign above the door (no readable letters), arms crossed, proud, spring light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 37,
|
||||
"month": 8,
|
||||
"title": "Auftrag aus Hamburg",
|
||||
"note": "Ein Selfie mit einer E-Mail auf dem Handy: eine ehemalige Agenturkundin bestellt einen Konferenztisch.",
|
||||
"area": "Beruf",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Selfie in the workshop holding up her phone screen (blurred), Nadja grinning, a large half-built conference table behind her",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 37,
|
||||
"month": 12,
|
||||
"title": "Jahresrückblick an der Werkbank",
|
||||
"note": "Ben fotografiert Nadja an der Werkbank, Sägemehl im Haar, hinter ihr Kim und ein Glas Sekt – fünf Jahre nach dem schlimmsten Jahr.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Nadja at her workbench with sawdust in her hair, Ben and Kim behind her raising glasses of sparkling wine, workshop decorated with a small string of lights, warm evening",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"nadja",
|
||||
"ben",
|
||||
"kim"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
2277
backend/database/data/lifelines/sophie.json
Normal file
2277
backend/database/data/lifelines/sophie.json
Normal file
File diff suppressed because it is too large
Load diff
617
backend/database/data/lifelines/werner.json
Normal file
617
backend/database/data/lifelines/werner.json
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
{
|
||||
"person": {
|
||||
"name": "Werner",
|
||||
"email": "werner@thats-me.app",
|
||||
"birthYear": 1955
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"age": 55,
|
||||
"month": 4,
|
||||
"title": "35 Jahre im Werk",
|
||||
"note": "Der Werksfotograf hält die Ehrung für 35 Jahre Betriebszugehörigkeit fest, Werner mit Urkunde und Blumenstrauß zwischen Betriebsleiter und Betriebsrat.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Ceremony in a steel plant canteen: Werner in a checked shirt holding a framed certificate (blank) and a bouquet of flowers, flanked by two men in suits shaking his hand, plain painted wall behind them without any banner or signs, fluorescent light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 55,
|
||||
"month": 8,
|
||||
"title": "Enkelin Lina",
|
||||
"note": "Tochter Anke fotografiert Werner im Krankenhaus, der die neugeborene Lina hält, als wäre sie aus Glas.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "In a hospital room, Werner sitting on a chair holding his newborn granddaughter Lina extremely carefully with both arms as if she were made of glass, Anke in the bed behind him smiling tiredly",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"anke",
|
||||
"lina"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 56,
|
||||
"month": 10,
|
||||
"title": "Stent",
|
||||
"note": "Renate fotografiert Werner im Krankenhausbett nach dem Herzkatheter, Daumen hoch, aber blass.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Werner in a hospital bed in a gown after a heart procedure, pale, giving a thumbs up to the camera, a monitor cable on his chest, a bedside table with a water jug",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 57,
|
||||
"month": 5,
|
||||
"title": "Reha und Rauchstopp",
|
||||
"note": "Ein Selfie im Kurpark von Bad Sassendorf zeigt Werner mit Nordic-Walking-Stöcken und ohne Zigarette – seit sechs Wochen.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Selfie",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Selfie in a spa park with autumn-coloured trees, Werner holding two Nordic walking poles, in a sports jacket and flat cap, slightly awkward with the selfie, no cigarette",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 57,
|
||||
"month": 9,
|
||||
"title": "Jürgen geht nach Kanada",
|
||||
"note": "Ein Foto am Flughafen Düsseldorf zeigt Werner und Renate mit Sohn Jürgen vor der Abflugtafel; Werner schaut auf die Koffer.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "At Düsseldorf airport in front of a departure board (blurred, unreadable), Werner and Renate standing with their son Jürgen who has a big backpack and two suitcases; Werner looking down at the suitcases, Renate hugging Jürgen",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate",
|
||||
"juergen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 58,
|
||||
"month": 7,
|
||||
"title": "Erste Wohnmobil-Fahrt",
|
||||
"note": "Ein Selfie vor dem gebrauchten Wohnmobil an der dänischen Nordsee, Renate winkt aus dem Fenster.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Selfie in front of an old white motorhome parked in the dunes at the Danish North Sea coast, Werner in front, Renate waving from the side window, grey sky, grass dunes",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 59,
|
||||
"month": 3,
|
||||
"title": "Werk wird geschlossen",
|
||||
"note": "Ein Pressefoto von der Betriebsversammlung zeigt Werner in der ersten Reihe, als die Schließung des Werks verkündet wird.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "A works meeting in a large industrial hall: rows of workers in overalls sitting on folding chairs, Werner in the front row with crossed arms and a grim face, a man at a lectern on a small stage, harsh hall lighting",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 59,
|
||||
"month": 11,
|
||||
"title": "Sozialplan verhandelt",
|
||||
"note": "Ein Kollege fotografiert Werner als Betriebsrat nach der Unterzeichnung des Sozialplans, müde, aber zufrieden.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Werner at a conference table in a plain office after signing a thick document (blank), pen in hand, tired but satisfied, two folders, a jug of water, a colleague's hand patting his shoulder",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 60,
|
||||
"month": 2,
|
||||
"title": "60. Geburtstag in der Kneipe",
|
||||
"note": "Der Wirt fotografiert die Runde in der Stammkneipe: Werner, Renate, Anke mit Familie und ein Dutzend alte Kollegen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "A crowded corner pub in Dortmund: Werner at the head of a long wooden table with a beer, Renate beside him, Anke with little Lina on her lap, a dozen older men in shirts raising glasses, wood panelling, warm light",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate",
|
||||
"anke",
|
||||
"lina"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 60,
|
||||
"month": 6,
|
||||
"title": "Letzte Schicht",
|
||||
"note": "Ein Kollege fotografiert Werner am letzten Schichttag vor dem Werkstor, den Spind-Schlüssel in der Hand.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Werner standing in front of a large closed steel factory gate on his last working day, holding a small locker key up to the camera, in work clothes with a bag over his shoulder, overcast light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 60,
|
||||
"month": 9,
|
||||
"title": "Ehrenamt bei der Tafel",
|
||||
"note": "Eine Mitarbeiterin fotografiert Werner beim Sortieren von Gemüsekisten an seinem ersten Tag bei der Tafel.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Werner in a food bank warehouse sorting crates of vegetables, wearing a plain dark blue apron without any print or logo, reaching into a crate of carrots, shelves of boxes behind him",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 61,
|
||||
"month": 5,
|
||||
"title": "Enkel Max",
|
||||
"note": "Anke fotografiert Werner mit dem neugeborenen Max auf dem Arm und Lina auf dem Schoß.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "On a sofa in a living room, Werner holding a newborn baby boy in his arms and six-year-old Lina sitting on his lap leaning in to look at her baby brother, Werner beaming",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"lina"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 61,
|
||||
"month": 8,
|
||||
"title": "Zu Besuch in Kanada",
|
||||
"note": "Jürgen fotografiert seine Eltern vor den Rocky Mountains, Werner zum ersten Mal in seinem Leben außerhalb Europas.",
|
||||
"area": "Reisen",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Werner and Renate standing in front of the Rocky Mountains in Canada, snow-capped peaks and a turquoise lake behind them, Werner in a flat cap looking stunned and happy, Jürgen's arm around his father",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate",
|
||||
"juergen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 62,
|
||||
"month": 4,
|
||||
"title": "Diagnose Brustkrebs",
|
||||
"note": "Ein Selfie von Renate im Wartezimmer, das sie Werner schickt, während er draußen im Auto wartet, weil er nicht mit hinein darf.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "Selfie of Renate alone in a hospital waiting room, an anxious, brave little smile, tired eyes, sitting on a plastic chair, the phone itself not visible, only her face and shoulders, a blurred poster on the wall, fluorescent light",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"renate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 62,
|
||||
"month": 10,
|
||||
"title": "Chemo",
|
||||
"note": "Renate fotografiert Werner in der Küche, der zum ersten Mal in seinem Leben nach Rezept kocht – Hühnersuppe.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Werner in a small kitchen cooking chicken soup for the first time in his life, reading glasses on, a cookbook open on the counter (pages blank), a big pot steaming, looking concentrated and slightly overwhelmed",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 63,
|
||||
"month": 3,
|
||||
"title": "Krebsfrei",
|
||||
"note": "Ein Selfie von Renate und Werner vor der Klinik nach dem Befund, beide weinen, beide lachen.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Selfie of Renate and Werner in front of a hospital entrance, Renate with a knitted hat over very short hair, both crying and laughing at the same time, foreheads touching",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 63,
|
||||
"month": 7,
|
||||
"title": "Rente",
|
||||
"note": "Anke fotografiert Werner mit dem Rentenbescheid am Küchentisch, Renate stößt mit Sekt an.",
|
||||
"area": "Beruf",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "At a kitchen table, Werner holding an official letter (blank) with a big grin, Renate next to him in a knitted hat clinking a glass of sparkling wine against his, a cake on the table",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 63,
|
||||
"month": 9,
|
||||
"title": "Norwegen",
|
||||
"note": "Ein Selfie am Geirangerfjord vor dem Wohnmobil, Renate mit kurzem Haar und Mütze, Werner mit Bart.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Selfie in front of a motorhome at a viewpoint over a Norwegian fjord, steep green mountains and dark water, Renate with short grey hair and a hat, Werner with a short grey beard, both in windbreakers",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 64,
|
||||
"month": 5,
|
||||
"title": "Laube am Kanal",
|
||||
"note": "Ein Nachbar fotografiert Werner und Renate vor ihrer neu gepachteten Gartenlaube am Kanal, Werner mit Spaten.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Werner and Renate standing in front of a small wooden allotment cabin by a canal, Werner leaning on a spade, Renate with a watering can, flower beds freshly dug, spring light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 64,
|
||||
"month": 12,
|
||||
"title": "Renate ist müde",
|
||||
"note": "Ein Selfie am Weihnachtsabend, Renate lehnt an Werners Schulter und schläft, während die Enkel spielen.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Selfie on Christmas Eve on a sofa, Renate asleep leaning on Werner's shoulder, Werner looking at the camera with a soft, worried look, Christmas tree lights blurred, grandchildren's toys on the floor",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 65,
|
||||
"month": 2,
|
||||
"title": "Rückfall",
|
||||
"note": "Ein Selfie von Werner allein im Klinikparkhaus, nachdem der Arzt von Metastasen gesprochen hat.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "Selfie of Werner alone in a dim concrete hospital car park, leaning against his car, face grey and empty, winter coat, cold neon light",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 65,
|
||||
"month": 4,
|
||||
"title": "Krankenhaus ohne Besuch",
|
||||
"note": "Ein Selfie von Werner unter Renates Fenster im dritten Stock, Lockdown, sie winkt von oben.",
|
||||
"area": "Familie",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "Selfie of Werner standing on a lawn below a hospital building, looking up towards a third-floor window where a small figure in a hat waves, spring, everything empty because of lockdown, mask hanging under his chin",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 65,
|
||||
"month": 7,
|
||||
"title": "Hospiz",
|
||||
"note": "Eine Pflegerin fotografiert Werner und Renate Hand in Hand auf der Terrasse des Hospizes, Renate im Rollstuhl mit Decke.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "On the terrace of a hospice, Werner and Renate holding hands, Renate very thin in a wheelchair wrapped in a blanket and a knitted hat, Werner on a chair beside her leaning close, summer garden, soft light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"renate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 65,
|
||||
"month": 8,
|
||||
"title": "Abschied von Renate",
|
||||
"note": "Anke fotografiert das leere Bett im Hospiz mit Renates Brille auf dem Nachttisch – Werner wollte dieses Bild haben.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "An empty, neatly made hospice bed by a window, Renate's reading glasses and a small vase with a single flower on the bedside table, morning light, no people",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": []
|
||||
},
|
||||
{
|
||||
"age": 65,
|
||||
"month": 9,
|
||||
"title": "Beerdigung",
|
||||
"note": "Ein Foto vom Friedhof zeigt Werner, Anke und die Enkel, Jürgen ist auf dem Tablet zugeschaltet, das Lina hochhält.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr negativ",
|
||||
"imagePrompt": "At a cemetery after a funeral: Werner in a black suit standing between Anke and eleven-year-old Lina, Lina holding up a tablet on which a bearded man is visible on a video call (screen blurred), grey day, wreaths",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"anke",
|
||||
"lina"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 65,
|
||||
"month": 12,
|
||||
"title": "Erstes Weihnachten allein",
|
||||
"note": "Anke fotografiert Werner an ihrem Weihnachtstisch, den Blick auf den Stuhl, den sie für Renate frei gelassen hat.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Christmas dinner at Anke's house: Werner sitting at the table looking at an empty chair with a folded napkin, Anke standing behind him with a hand on his shoulder, candles, a small tree",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"anke"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 66,
|
||||
"month": 3,
|
||||
"title": "Renates Sachen",
|
||||
"note": "Ein Selfie im Schlafzimmer vor dem offenen Kleiderschrank, Werner hält einen Mantel von Renate, nichts davon kommt weg.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Selfie of Werner in a bedroom in front of an open wardrobe full of women's clothes, holding a beige wool coat in both hands, eyes red, nothing has been given away",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 66,
|
||||
"month": 6,
|
||||
"title": "Lina erklärt das Smartphone",
|
||||
"note": "Anke fotografiert Lina und Werner auf dem Sofa, Lina zeigt ihm, wie man Fotos verschickt; er hat einen Zettel mit Notizen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "On a sofa, eleven-year-old Lina showing her grandfather Werner how to use a smartphone, Werner with reading glasses holding a handwritten note (unreadable), both concentrating, afternoon light",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"lina"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 66,
|
||||
"month": 10,
|
||||
"title": "Trauergruppe",
|
||||
"note": "Ein Gruppenfoto der Trauergruppe im Gemeindehaus, Werner am Rand, der Einzige mit Krawatte.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Group photo of a bereavement group in a church community hall: eight elderly people on chairs in a circle, Werner at the edge, the only one wearing a tie, a coffee thermos on a side table",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 67,
|
||||
"month": 4,
|
||||
"title": "Kassenwart im Kleingarten",
|
||||
"note": "Der Vereinsvorsitzende fotografiert Werner bei seiner Wahl zum Kassenwart, Kassenbuch unterm Arm.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Werner being congratulated at an allotment club meeting, holding a thick ledger under his arm, a handshake from an elderly man in a club vest, a small clubhouse with trophies",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 67,
|
||||
"month": 7,
|
||||
"title": "Allein nach Dänemark",
|
||||
"note": "Ein Selfie am selben Strand wie vor neun Jahren, aber der Beifahrersitz im Wohnmobil ist leer.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Selfie of Werner alone on a Danish North Sea beach in front of the old motorhome, the passenger seat visible through the open door and empty, grey sky, a sad half-smile",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 67,
|
||||
"month": 11,
|
||||
"title": "Wanderverein",
|
||||
"note": "Ein Gruppenfoto der Mittwochswanderer im Sauerland, Werner hinten links, zum ersten Mal wieder lachend auf einem Foto.",
|
||||
"area": "Freizeit",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Group photo of a hiking club in the Sauerland hills in autumn, twelve elderly hikers with poles and backpacks in front of a wooden signpost (unreadable), Werner at the back left laughing for the first time in a long while",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 68,
|
||||
"month": 3,
|
||||
"title": "Kaffee mit Hannelore",
|
||||
"note": "Eine Bedienung fotografiert Werner und Hannelore aus dem Wanderverein beim Kaffeetrinken, beide sitzen sehr aufrecht.",
|
||||
"area": "Liebe",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "In a café, Werner and Hannelore at a small table with coffee and cake, both sitting very upright, a bit formal, Hannelore in a scarf, Werner in a cardigan, a waitress's hand at the edge of the frame",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"hannelore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 68,
|
||||
"month": 8,
|
||||
"title": "Jürgen zu Besuch",
|
||||
"note": "Ein Familienfoto im Garten zeigt Werner mit allen vier Enkeln, Jürgen und seiner kanadischen Frau, zum ersten Mal alle zusammen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Big family photo in a garden: Werner in the middle with four grandchildren around him, Jürgen with a full beard and his wife, Anke, thirteen-year-old Lina, everyone squinting in the summer sun, a barbecue smoking behind",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"juergen",
|
||||
"anke",
|
||||
"lina"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 68,
|
||||
"month": 10,
|
||||
"title": "Hannelore bei Anke",
|
||||
"note": "Anke fotografiert Werner und Hannelore auf ihrem Sofa; Lina schaut skeptisch, Max isst Kuchen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "On Anke's sofa: Werner and Hannelore sitting close together, thirteen-year-old Lina looking at them sceptically from an armchair, a small boy eating cake on the floor, Anke pouring coffee",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"hannelore",
|
||||
"lina",
|
||||
"anke"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 69,
|
||||
"month": 5,
|
||||
"title": "Bodensee mit Hannelore",
|
||||
"note": "Ein Selfie vor dem Wohnmobil am Bodensee, Hannelore auf dem Beifahrersitz, Werner mit neuer Sonnenbrille.",
|
||||
"area": "Reisen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Selfie in front of the motorhome at Lake Constance, Hannelore in the passenger seat waving, Werner with new sunglasses grinning, blue lake and mountains, bright spring",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"hannelore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 69,
|
||||
"month": 9,
|
||||
"title": "Sturz",
|
||||
"note": "Hannelore fotografiert Werner im Krankenhaus mit Hüftprothese und Gehwagen, grimmig, aber aufrecht.",
|
||||
"area": "Persönliche Entwicklung",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Negativ",
|
||||
"imagePrompt": "Werner in a hospital corridor with a walking frame, grim but upright, a hospital gown and a cardigan, Hannelore's hand on his arm, taken by her",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"hannelore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 69,
|
||||
"month": 12,
|
||||
"title": "Weihnachten mit allen",
|
||||
"note": "Lina fotografiert die volle Weihnachtstafel: Werner am Kopfende, Hannelore neben ihm, beide Kinder, alle Enkel.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "Long Christmas dinner table: Werner at the head, Hannelore beside him, Anke, Jürgen and his family, all grandchildren, a bit chaotic and warm, candles and a tree, taken by fifteen-year-old Lina from the end of the table",
|
||||
"imageAspect": "16:9",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"hannelore",
|
||||
"anke",
|
||||
"juergen",
|
||||
"lina"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 70,
|
||||
"month": 2,
|
||||
"title": "70. Geburtstag",
|
||||
"note": "Ein Foto zeigt Lina, inzwischen 14, die eine Rede auf ihren Opa hält, Werner wischt sich mit der Serviette die Augen.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Sehr positiv",
|
||||
"imagePrompt": "At a 70th birthday party in a restaurant: fifteen-year-old Lina standing giving a speech with a sheet of paper (blank), Werner sitting at the table wiping his eyes with a napkin, relatives around, balloons",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"lina"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 70,
|
||||
"month": 6,
|
||||
"title": "Hannelore zieht ein",
|
||||
"note": "Ein Selfie im Flur zwischen Hannelores Kartons, Renates Foto hängt weiterhin an der Wand, Hannelore hat es selbst so gewollt.",
|
||||
"area": "Wohnen",
|
||||
"photo": "Selfie",
|
||||
"rating": "Positiv",
|
||||
"imagePrompt": "Selfie in a hallway between moving boxes, Werner and Hannelore squeezed into the frame, a framed photo of a smiling woman with auburn curls still hanging on the wall behind them",
|
||||
"imageAspect": "3:4",
|
||||
"imageCharacters": [
|
||||
"werner",
|
||||
"hannelore"
|
||||
]
|
||||
},
|
||||
{
|
||||
"age": 70,
|
||||
"month": 11,
|
||||
"title": "Am Grab mit Hannelore",
|
||||
"note": "Hannelore fotografiert Werner an Renates Grab mit einem Strauß Astern; sie hat die Blumen ausgesucht.",
|
||||
"area": "Familie",
|
||||
"photo": "Fremdaufnahme",
|
||||
"rating": "Neutral",
|
||||
"imagePrompt": "Werner standing at a well-kept grave in a cemetery in November, holding a bunch of purple asters, flat cap, coat, fallen leaves on the path, seen from a few metres away, quiet",
|
||||
"imageAspect": "4:3",
|
||||
"imageCharacters": [
|
||||
"werner"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -18,7 +18,11 @@ class EventFactory extends Factory
|
|||
'user_id' => User::factory(),
|
||||
'title' => fake()->sentence(3),
|
||||
'date' => fake()->date(),
|
||||
'end_date' => null,
|
||||
'category' => null,
|
||||
'people' => null,
|
||||
'emotion' => fake()->randomFloat(3, -1, 1),
|
||||
'milestone' => false,
|
||||
'custom_color' => null,
|
||||
'gradient_preset' => fake()->optional(0.3)->numberBetween(0, 9),
|
||||
'image' => null,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ class EventMediaFactory extends Factory
|
|||
'user_id' => $user,
|
||||
'event_id' => Event::factory()->for($user),
|
||||
'collection' => 'gallery',
|
||||
'type' => 'image',
|
||||
'name' => fake()->word().'.jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'disk' => 'local',
|
||||
|
|
@ -33,6 +34,7 @@ class EventMediaFactory extends Factory
|
|||
'thumbnail_path' => 'event-media/test/thumb.jpg',
|
||||
'preview_path' => 'event-media/test/preview.jpg',
|
||||
'size' => 12345,
|
||||
'duration_ms' => null,
|
||||
'width' => 1200,
|
||||
'height' => 800,
|
||||
'thumbnail_width' => 320,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->string('location')->nullable()->after('date');
|
||||
$table->string('key_image_title')->nullable()->after('image');
|
||||
|
||||
// The API accepts image URLs up to 500 characters; the column was
|
||||
// still at the 255 default, so long external URLs failed on MySQL.
|
||||
$table->string('image', 500)->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->dropColumn(['location', 'key_image_title']);
|
||||
$table->string('image')->nullable()->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Event media used to be images only. Gallery uploads may now be videos
|
||||
* and an event can carry one voice note, so a row needs to say what it
|
||||
* holds, how long it plays, and it may exist without a thumbnail (audio,
|
||||
* or a video whose poster frame could not be extracted).
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('event_media', function (Blueprint $table) {
|
||||
$table->string('type', 16)->default('image')->after('collection');
|
||||
$table->unsignedInteger('duration_ms')->nullable()->after('size');
|
||||
$table->string('thumbnail_path')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('event_media', function (Blueprint $table) {
|
||||
$table->dropColumn(['type', 'duration_ms']);
|
||||
$table->string('thumbnail_path')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* A short note next to a media item, so a voice note can be labelled or
|
||||
* amended in writing instead of being re-recorded.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('event_media', function (Blueprint $table) {
|
||||
$table->string('caption', 500)->nullable()->after('name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('event_media', function (Blueprint $table) {
|
||||
$table->dropColumn('caption');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
// Kategorie: a key from the client's fixed list (family, work, ...).
|
||||
$table->string('category', 40)->nullable()->after('location');
|
||||
// Personen: free-text names, kept as a JSON list.
|
||||
$table->json('people')->nullable()->after('category');
|
||||
// Zeitraum: an event may span until this day; null means one day.
|
||||
$table->date('end_date')->nullable()->after('date');
|
||||
// Meilenstein: highlighted on the wave.
|
||||
$table->boolean('milestone')->default(false)->after('emotion');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->dropColumn(['category', 'people', 'end_date', 'milestone']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -34,6 +34,8 @@ class DatabaseSeeder extends Seeder
|
|||
);
|
||||
}
|
||||
|
||||
$this->call(LifelineSeeder::class);
|
||||
|
||||
$hasPersonalAccessClient = Client::query()
|
||||
->where('provider', 'users')
|
||||
->where('revoked', false)
|
||||
|
|
|
|||
137
backend/database/seeders/LifelineSeeder.php
Normal file
137
backend/database/seeders/LifelineSeeder.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Seeds one complete demo lifeline per person from
|
||||
* `database/data/lifelines/*.json` (Jonas, Sophie, ...).
|
||||
*
|
||||
* Every event gets a deterministic client_id, so re-running the seeder
|
||||
* updates title/date/emotion/note in place instead of duplicating events.
|
||||
* `image`, `key_image_title` and attached media are never touched, so the
|
||||
* key images imported by hand survive a re-seed.
|
||||
*/
|
||||
class LifelineSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Namespace for the UUIDv5 client ids of seeded events.
|
||||
*/
|
||||
private const string CLIENT_ID_NAMESPACE = '6f1c2a1e-3b7d-4c4a-9d2e-1f0a5b8c9d01';
|
||||
|
||||
/**
|
||||
* Emotion range per rating, aligned with the label thresholds in the
|
||||
* Quasar EventPanel (> 0.5, > 0.15, > -0.15, > -0.5). Values are spread
|
||||
* inside each band so the LifeWave gets a lively curve while every
|
||||
* event still shows the rating from the source table.
|
||||
*
|
||||
* @var array<string, array{float, float}>
|
||||
*/
|
||||
private const array EMOTION_BANDS = [
|
||||
'Sehr positiv' => [0.55, 0.95],
|
||||
'Positiv' => [0.20, 0.48],
|
||||
'Neutral' => [-0.12, 0.12],
|
||||
'Negativ' => [-0.48, -0.20],
|
||||
'Sehr negativ' => [-0.95, -0.55],
|
||||
];
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
foreach (self::lifelineFiles() as $path) {
|
||||
$this->seedLifeline($path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function lifelineFiles(): array
|
||||
{
|
||||
$files = File::glob(database_path('data/lifelines/*.json'));
|
||||
|
||||
sort($files);
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
private function seedLifeline(string $path): void
|
||||
{
|
||||
$lifeline = json_decode(File::get($path), true, flags: JSON_THROW_ON_ERROR);
|
||||
$person = $lifeline['person'];
|
||||
|
||||
$user = User::query()->updateOrCreate(
|
||||
['email' => $person['email']],
|
||||
[
|
||||
'name' => $person['name'],
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'pass',
|
||||
],
|
||||
);
|
||||
|
||||
foreach ($lifeline['events'] as $event) {
|
||||
$day = $event['day'] ?? null;
|
||||
|
||||
Event::query()->updateOrCreate(
|
||||
['client_id' => self::clientId($person['email'], $event['age'], $event['month'], $day)],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'title' => $event['title'],
|
||||
'date' => self::date($person['birthYear'], $event['age'], $event['month'], $person['email'], $day),
|
||||
'emotion' => self::emotion($event['rating'], $person['email'], $event['age'], $event['month'], $day),
|
||||
'note' => $event['note'],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Events are keyed by age and month; a lifeline with several events in
|
||||
* one month sets an explicit `day`, which then becomes part of the key.
|
||||
*/
|
||||
public static function clientId(string $email, int $age, int $month, ?int $day = null): string
|
||||
{
|
||||
return Uuid::uuid5(self::CLIENT_ID_NAMESPACE, self::key($email, $age, $month, $day))->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Without an explicit day, one is drawn deterministically so the same
|
||||
* event always lands on the same date.
|
||||
*/
|
||||
public static function date(int $birthYear, int $age, int $month, string $email, ?int $day = null): string
|
||||
{
|
||||
$day ??= 1 + self::fraction('day|'.self::key($email, $age, $month), 28);
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $birthYear + $age, $month, $day);
|
||||
}
|
||||
|
||||
public static function emotion(string $rating, string $email, int $age, int $month, ?int $day = null): float
|
||||
{
|
||||
$band = self::EMOTION_BANDS[$rating] ?? throw new RuntimeException("Unknown rating [{$rating}]");
|
||||
[$min, $max] = $band;
|
||||
|
||||
$offset = self::fraction('emotion|'.self::key($email, $age, $month, $day), 1000) / 1000;
|
||||
|
||||
return round($min + ($max - $min) * $offset, 3);
|
||||
}
|
||||
|
||||
private static function key(string $email, int $age, int $month, ?int $day = null): string
|
||||
{
|
||||
return $day === null
|
||||
? "{$email}|{$age}|{$month}"
|
||||
: "{$email}|{$age}|{$month}|{$day}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable pseudo-random integer in [0, $modulo) derived from a seed string.
|
||||
*/
|
||||
private static function fraction(string $seed, int $modulo): int
|
||||
{
|
||||
return crc32($seed) % $modulo;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Actions\Logout;
|
||||
use App\Services\MediaCleaner;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
|
|
@ -10,13 +11,19 @@ new class extends Component {
|
|||
/**
|
||||
* Delete the currently authenticated user.
|
||||
*/
|
||||
public function deleteUser(Logout $logout): void
|
||||
public function deleteUser(Logout $logout, MediaCleaner $mediaCleaner): void
|
||||
{
|
||||
$this->validate([
|
||||
'password' => ['required', 'string', 'current_password'],
|
||||
]);
|
||||
|
||||
tap(Auth::user(), $logout(...))->delete();
|
||||
$user = Auth::user();
|
||||
|
||||
// Rows vanish through the database cascade; the files behind them
|
||||
// have to be collected and removed before that happens.
|
||||
$mediaCleaner->purgeUserMedia($user);
|
||||
|
||||
tap($user, $logout(...))->delete();
|
||||
|
||||
$this->redirect('/', navigate: true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,19 +8,22 @@ use App\Http\Controllers\Api\SettingsMediaController;
|
|||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:api');
|
||||
|
||||
Route::middleware('auth:api')->group(function () {
|
||||
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
|
||||
Route::get('/user', fn (Request $request) => $request->user());
|
||||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
Route::get('/settings', [SettingsController::class, 'show']);
|
||||
Route::put('/settings', [SettingsController::class, 'update']);
|
||||
Route::get('/settings/media/background', [SettingsMediaController::class, 'show']);
|
||||
Route::post('/settings/media/background', [SettingsMediaController::class, 'store']);
|
||||
Route::post('/settings/media/background', [SettingsMediaController::class, 'store'])
|
||||
->middleware('throttle:uploads');
|
||||
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::post('/events/{clientId}/media', [EventMediaController::class, 'store'])
|
||||
->middleware('throttle:uploads');
|
||||
Route::patch('/events/{clientId}/media/{media}', [EventMediaController::class, 'update']);
|
||||
Route::delete('/events/{clientId}/media/{media}', [EventMediaController::class, 'destroy']);
|
||||
|
||||
Route::apiResource('events', EventController::class)->parameters([
|
||||
|
|
|
|||
|
|
@ -82,6 +82,16 @@ test('database seeder creates the presentation api users', function () {
|
|||
->and(Hash::check('pass', $user->password))->toBeTrue();
|
||||
}
|
||||
|
||||
foreach (['jonas' => 'Jonas', 'sophie' => 'Sophie'] as $handle => $name) {
|
||||
$user = User::query()
|
||||
->where('email', "{$handle}@thats-me.app")
|
||||
->first();
|
||||
|
||||
expect($user)->not->toBeNull()
|
||||
->and($user->name)->toBe($name)
|
||||
->and(Hash::check('pass', $user->password))->toBeTrue();
|
||||
}
|
||||
|
||||
$hasPersonalAccessClient = Client::query()
|
||||
->where('provider', 'users')
|
||||
->where('revoked', false)
|
||||
|
|
|
|||
293
backend/tests/Feature/Api/EventAuditFixesTest.php
Normal file
293
backend/tests/Feature/Api/EventAuditFixesTest.php
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMedia;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
Passport::actingAs($this->user);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| A05 / A19 — location, keyImageTitle and long image URLs
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('location and key image title survive a create, update and reload roundtrip', function () {
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$this->postJson('/api/events', [
|
||||
'id' => $clientId,
|
||||
'title' => 'Bergwanderung',
|
||||
'date' => '2014-08-12',
|
||||
'emotion' => 0.75,
|
||||
'location' => 'Zugspitze',
|
||||
'keyImageTitle' => 'Gipfelfoto',
|
||||
])->assertCreated()
|
||||
->assertJsonPath('data.location', 'Zugspitze')
|
||||
->assertJsonPath('data.keyImageTitle', 'Gipfelfoto');
|
||||
|
||||
$this->assertDatabaseHas('events', [
|
||||
'client_id' => $clientId,
|
||||
'location' => 'Zugspitze',
|
||||
'key_image_title' => 'Gipfelfoto',
|
||||
]);
|
||||
|
||||
$this->putJson("/api/events/{$clientId}", ['location' => 'Wank'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.location', 'Wank')
|
||||
->assertJsonPath('data.keyImageTitle', 'Gipfelfoto');
|
||||
|
||||
// Reload: the fields must come back, not be blanked out by the response.
|
||||
$this->getJson("/api/events/{$clientId}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.location', 'Wank')
|
||||
->assertJsonPath('data.keyImageTitle', 'Gipfelfoto');
|
||||
});
|
||||
|
||||
test('an update without location leaves the stored location untouched', function () {
|
||||
$event = Event::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'location' => 'Zugspitze',
|
||||
]);
|
||||
|
||||
$this->putJson("/api/events/{$event->client_id}", ['title' => 'Neuer Titel'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.location', 'Zugspitze');
|
||||
});
|
||||
|
||||
test('an image url of the documented maximum length is accepted', function () {
|
||||
// The column was 255 while the rule allowed 500. Asserted here at the
|
||||
// contract boundary; SQLite does not enforce column width, so the MySQL
|
||||
// side of this is covered by the widened migration, not by this test.
|
||||
$url = 'https://example.com/'.str_repeat('a', 500 - 20);
|
||||
|
||||
expect(strlen($url))->toBe(500);
|
||||
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$this->postJson('/api/events', [
|
||||
'id' => $clientId,
|
||||
'title' => 'Langer Link',
|
||||
'date' => '2025-01-01',
|
||||
'emotion' => 0,
|
||||
'image' => $url,
|
||||
])->assertCreated();
|
||||
|
||||
expect(Event::where('client_id', $clientId)->value('image'))->toBe($url);
|
||||
});
|
||||
|
||||
test('an image url beyond the documented maximum is rejected', function () {
|
||||
$this->postJson('/api/events', [
|
||||
'id' => Str::uuid()->toString(),
|
||||
'title' => 'Zu langer Link',
|
||||
'date' => '2025-01-01',
|
||||
'emotion' => 0,
|
||||
'image' => 'https://example.com/'.str_repeat('a', 500),
|
||||
])->assertStatus(422)->assertJsonValidationErrors('image');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| A08 — cursor pagination across equal dates
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('paginating through events sharing one date loses no records', function () {
|
||||
$sameDay = '2025-05-01';
|
||||
|
||||
collect(range(1, 5))->each(fn (int $n) => Event::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'date' => $sameDay,
|
||||
'title' => "Event {$n}",
|
||||
]));
|
||||
|
||||
$seen = [];
|
||||
$url = '/api/events?limit=1';
|
||||
|
||||
// Guard against an endless loop if the cursor ever stops advancing.
|
||||
for ($page = 0; $page < 20 && $url !== null; $page++) {
|
||||
$response = $this->getJson($url)->assertOk();
|
||||
$payload = $response->json();
|
||||
|
||||
foreach ($payload['data'] as $event) {
|
||||
$seen[] = $event['id'];
|
||||
}
|
||||
|
||||
$next = $payload['links']['next'] ?? null;
|
||||
|
||||
if ($next === null) {
|
||||
$url = null;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
parse_str((string) parse_url($next, PHP_URL_QUERY), $query);
|
||||
$url = '/api/events?'.http_build_query($query);
|
||||
}
|
||||
|
||||
expect($seen)->toHaveCount(5)
|
||||
->and(array_unique($seen))->toHaveCount(5);
|
||||
});
|
||||
|
||||
test('the limit parameter is validated instead of silently coerced', function () {
|
||||
$this->getJson('/api/events?limit=0')->assertStatus(422);
|
||||
$this->getJson('/api/events?limit=500')->assertStatus(422);
|
||||
$this->getJson('/api/events?limit=abc')->assertStatus(422);
|
||||
$this->getJson('/api/events?since=not-a-date')->assertStatus(422);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| A09 — sync shares the CRUD validation rules
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('sync rejects an emotion that plain crud would reject', function () {
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$response = $this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'create',
|
||||
'eventId' => $clientId,
|
||||
'payload' => ['title' => 'Out of range', 'date' => '2025-01-01', 'emotion' => 2],
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
expect($response->json('results.0.status'))->toBe('invalid');
|
||||
$this->assertDatabaseMissing('events', ['client_id' => $clientId]);
|
||||
});
|
||||
|
||||
test('sync rejects an out of range emotion on update too', function () {
|
||||
$event = Event::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'emotion' => 0.5,
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'update',
|
||||
'eventId' => $event->client_id,
|
||||
'payload' => ['emotion' => -7],
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
expect($response->json('results.0.status'))->toBe('invalid');
|
||||
expect((float) $event->fresh()->emotion)->toBe(0.5);
|
||||
});
|
||||
|
||||
test('sync reports a missing event instead of acknowledging it', function () {
|
||||
$response = $this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'update',
|
||||
'eventId' => Str::uuid()->toString(),
|
||||
'payload' => ['title' => 'Ghost'],
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
expect($response->json('results.0.status'))->toBe('not_found');
|
||||
});
|
||||
|
||||
test('sync never returns raw exception text to the client', function () {
|
||||
$event = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$response = $this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'update',
|
||||
'eventId' => $event->client_id,
|
||||
'payload' => ['title' => str_repeat('x', 5000)],
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
$result = $response->json('results.0');
|
||||
|
||||
expect($result['status'])->toBe('invalid')
|
||||
->and($result)->not->toHaveKey('message');
|
||||
});
|
||||
|
||||
test('sync writes location and key image title like crud does', function () {
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'create',
|
||||
'eventId' => $clientId,
|
||||
'payload' => [
|
||||
'title' => 'Via sync',
|
||||
'date' => '2025-01-01',
|
||||
'emotion' => 0,
|
||||
'location' => 'Berlin',
|
||||
'keyImageTitle' => 'Titelbild',
|
||||
],
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('events', [
|
||||
'client_id' => $clientId,
|
||||
'location' => 'Berlin',
|
||||
'key_image_title' => 'Titelbild',
|
||||
]);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| A13 — deleting an event removes its files
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('deleting an event removes its media files', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$event = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
$media = EventMedia::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'event_id' => $event->id,
|
||||
'path' => 'event-media/1/original.jpg',
|
||||
'preview_path' => 'event-media/1/preview.jpg',
|
||||
'thumbnail_path' => 'event-media/1/thumb.jpg',
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($media->path, 'original');
|
||||
Storage::disk('local')->put($media->preview_path, 'preview');
|
||||
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
|
||||
|
||||
$this->deleteJson("/api/events/{$event->client_id}")->assertNoContent();
|
||||
|
||||
Storage::disk('local')->assertMissing($media->path);
|
||||
Storage::disk('local')->assertMissing($media->preview_path);
|
||||
Storage::disk('local')->assertMissing($media->thumbnail_path);
|
||||
$this->assertDatabaseMissing('event_media', ['id' => $media->id]);
|
||||
});
|
||||
|
||||
test('deleting an event through sync removes its media files', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$event = Event::factory()->create(['user_id' => $this->user->id]);
|
||||
$media = EventMedia::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'event_id' => $event->id,
|
||||
'path' => 'event-media/2/original.jpg',
|
||||
'preview_path' => 'event-media/2/preview.jpg',
|
||||
'thumbnail_path' => 'event-media/2/thumb.jpg',
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($media->path, 'original');
|
||||
Storage::disk('local')->put($media->preview_path, 'preview');
|
||||
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
|
||||
|
||||
$this->postJson('/api/events/sync', [
|
||||
'mutations' => [[
|
||||
'action' => 'delete',
|
||||
'eventId' => $event->client_id,
|
||||
'payload' => null,
|
||||
]],
|
||||
])->assertOk();
|
||||
|
||||
Storage::disk('local')->assertMissing($media->path);
|
||||
Storage::disk('local')->assertMissing($media->thumbnail_path);
|
||||
});
|
||||
|
|
@ -168,3 +168,220 @@ test('can delete own media and files', function () {
|
|||
Storage::disk('local')->assertMissing($media->path);
|
||||
Storage::disk('local')->assertMissing($media->thumbnail_path);
|
||||
});
|
||||
|
||||
/**
|
||||
* Minimal MP4 container header. libmagic identifies the `ftyp` box as
|
||||
* video/mp4, which is all the upload validation looks at.
|
||||
*/
|
||||
function fakeMp4(): string
|
||||
{
|
||||
return "\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2mp41".str_repeat("\0", 256);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal WebM (EBML) header, as Chrome's MediaRecorder produces for audio.
|
||||
* libmagic reports it as video/webm.
|
||||
*/
|
||||
function fakeWebm(): string
|
||||
{
|
||||
return "\x1A\x45\xDF\xA3\x9F\x42\x86\x81\x01\x42\xF7\x81\x01\x42\xF2\x81\x04\x42\xF3\x81\x08"
|
||||
."\x42\x82\x84webm\x42\x87\x81\x02\x42\x85\x81\x02".str_repeat("\0", 256);
|
||||
}
|
||||
|
||||
test('can upload a video to the gallery', 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' => 'gallery',
|
||||
'file' => UploadedFile::fake()->createWithContent('clip.mp4', fakeMp4()),
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.collection', 'gallery')
|
||||
->assertJsonPath('data.type', 'video')
|
||||
->assertJsonPath('data.mimeType', 'video/mp4');
|
||||
|
||||
$media = EventMedia::query()->firstOrFail();
|
||||
expect($media->type)->toBe('video')
|
||||
->and($media->path)->toEndWith('.mp4');
|
||||
Storage::disk('local')->assertExists($media->path);
|
||||
|
||||
// The header-only clip has no decodable frame: no poster, but the
|
||||
// upload still succeeds and the client gets an explicit null.
|
||||
expect($response->json('data.originalUrl'))->toBe("/event-media/{$media->id}/original");
|
||||
if ($media->thumbnail_path === null) {
|
||||
expect($response->json('data.thumbnailUrl'))->toBeNull()
|
||||
->and($response->json('data.src'))->toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('key image rejects a video', 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' => 'key_image',
|
||||
'file' => UploadedFile::fake()->createWithContent('clip.mp4', fakeMp4()),
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['file']);
|
||||
|
||||
expect(EventMedia::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('an event can carry several voice notes', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
Passport::actingAs($user);
|
||||
|
||||
$first = $this->postJson("/api/events/{$event->client_id}/media", [
|
||||
'collection' => 'voice_note',
|
||||
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
|
||||
]);
|
||||
|
||||
$first->assertCreated()
|
||||
->assertJsonPath('data.collection', 'voice_note')
|
||||
->assertJsonPath('data.type', 'audio')
|
||||
->assertJsonPath('data.caption', '')
|
||||
->assertJsonPath('data.thumbnailUrl', null)
|
||||
->assertJsonPath('data.previewUrl', null);
|
||||
|
||||
$firstMedia = EventMedia::query()->firstOrFail();
|
||||
Storage::disk('local')->assertExists($firstMedia->path);
|
||||
|
||||
$this->postJson("/api/events/{$event->client_id}/media", [
|
||||
'collection' => 'voice_note',
|
||||
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
|
||||
])->assertCreated();
|
||||
|
||||
expect(EventMedia::query()->where('collection', 'voice_note')->count())->toBe(2);
|
||||
$this->assertDatabaseHas('event_media', ['id' => $firstMedia->id]);
|
||||
Storage::disk('local')->assertExists($firstMedia->path);
|
||||
});
|
||||
|
||||
test('a voice note can be uploaded together with its caption', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
Passport::actingAs($user);
|
||||
|
||||
// Written offline, sent with the file once the connection was back.
|
||||
$this->postJson("/api/events/{$event->client_id}/media", [
|
||||
'collection' => 'voice_note',
|
||||
'caption' => 'Aufgenommen im Zug, ohne Netz',
|
||||
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.caption', 'Aufgenommen im Zug, ohne Netz');
|
||||
|
||||
$this->postJson("/api/events/{$event->client_id}/media", [
|
||||
'collection' => 'voice_note',
|
||||
'caption' => str_repeat('x', 501),
|
||||
'file' => UploadedFile::fake()->createWithContent('sprachnotiz.webm', fakeWebm()),
|
||||
])->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('can set and clear the caption of own media', function () {
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
$media = EventMedia::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'event_id' => $event->id,
|
||||
'collection' => 'voice_note',
|
||||
'type' => 'audio',
|
||||
]);
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->patchJson("/api/events/{$event->client_id}/media/{$media->id}", [
|
||||
'caption' => 'Nachtrag: die Adresse war Hauptstraße 3',
|
||||
])->assertOk()
|
||||
->assertJsonPath('data.caption', 'Nachtrag: die Adresse war Hauptstraße 3');
|
||||
|
||||
$this->patchJson("/api/events/{$event->client_id}/media/{$media->id}", ['caption' => ''])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.caption', '');
|
||||
|
||||
$this->patchJson("/api/events/{$event->client_id}/media/{$media->id}", [
|
||||
'caption' => str_repeat('x', 501),
|
||||
])->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('cannot caption media of another user or another event', function () {
|
||||
$user = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$ownEvent = Event::factory()->create(['user_id' => $user->id]);
|
||||
$otherEvent = Event::factory()->create(['user_id' => $other->id]);
|
||||
$otherMedia = EventMedia::factory()->create(['user_id' => $other->id, 'event_id' => $otherEvent->id]);
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->patchJson("/api/events/{$otherEvent->client_id}/media/{$otherMedia->id}", ['caption' => 'x'])
|
||||
->assertNotFound();
|
||||
|
||||
// Own event, but the media hangs on someone else's event.
|
||||
$this->patchJson("/api/events/{$ownEvent->client_id}/media/{$otherMedia->id}", ['caption' => 'x'])
|
||||
->assertNotFound();
|
||||
|
||||
expect($otherMedia->fresh()->caption)->toBeNull();
|
||||
});
|
||||
|
||||
test('voice note keeps the audio type the browser reported', function () {
|
||||
Storage::fake('local');
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
Passport::actingAs($user);
|
||||
|
||||
// A real temp file with the browser's declared type, as a multipart
|
||||
// upload from MediaRecorder arrives.
|
||||
$path = tempnam(sys_get_temp_dir(), 'voice').'.webm';
|
||||
file_put_contents($path, fakeWebm());
|
||||
$file = new UploadedFile($path, 'sprachnotiz.webm', 'audio/webm', null, true);
|
||||
|
||||
$this->postJson("/api/events/{$event->client_id}/media", [
|
||||
'collection' => 'voice_note',
|
||||
'file' => $file,
|
||||
])->assertCreated()
|
||||
->assertJsonPath('data.mimeType', 'audio/webm');
|
||||
});
|
||||
|
||||
test('voice note rejects an image', 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' => 'voice_note',
|
||||
'file' => UploadedFile::fake()->image('foto.jpg', 400, 300),
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['file']);
|
||||
});
|
||||
|
||||
test('streams an original with its own mime type', 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,
|
||||
'type' => 'video',
|
||||
'mime_type' => 'video/mp4',
|
||||
'path' => 'event-media/test/clip.mp4',
|
||||
'thumbnail_path' => null,
|
||||
'preview_path' => null,
|
||||
]);
|
||||
Storage::disk('local')->put($media->path, fakeMp4());
|
||||
Passport::actingAs($user);
|
||||
|
||||
$this->get("/api/event-media/{$media->id}/original")
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'video/mp4')
|
||||
->assertHeader('Accept-Ranges', 'bytes');
|
||||
|
||||
// No poster frame: the image variants do not exist.
|
||||
$this->get("/api/event-media/{$media->id}/thumb")->assertNotFound();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ test('can filter events by since parameter', function () {
|
|||
'updated_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/events?since=' . now()->subDay()->toISOString());
|
||||
$response = $this->getJson('/api/events?since='.now()->subDay()->toISOString());
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
|
|
@ -184,3 +184,99 @@ test('sync is idempotent for creates', function () {
|
|||
|
||||
expect(Event::where('client_id', $clientId)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('can create an event with an empty title', function () {
|
||||
// An event is created with its first entry, which may be a note or a
|
||||
// photo. The title may therefore be empty, but it has to be sent.
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$this->postJson('/api/events', [
|
||||
'id' => $clientId,
|
||||
'title' => '',
|
||||
'date' => '2024-06-15',
|
||||
'emotion' => 0,
|
||||
'note' => 'Nur eine Notiz',
|
||||
])->assertCreated()
|
||||
->assertJsonPath('data.title', '');
|
||||
|
||||
expect(Event::query()->where('client_id', $clientId)->value('title'))->toBe('');
|
||||
});
|
||||
|
||||
test('title must still be sent when creating an event', function () {
|
||||
$this->postJson('/api/events', [
|
||||
'id' => Str::uuid()->toString(),
|
||||
'date' => '2024-06-15',
|
||||
'emotion' => 0,
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['title']);
|
||||
});
|
||||
|
||||
test('stores category, people, a span and the milestone flag', function () {
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$this->postJson('/api/events', [
|
||||
'id' => $clientId,
|
||||
'title' => 'Umzug nach Hamburg',
|
||||
'date' => '2019-04-01',
|
||||
'endDate' => '2019-04-14',
|
||||
'emotion' => 0.4,
|
||||
'milestone' => true,
|
||||
'category' => 'home',
|
||||
'people' => ['Lena', ' Papa ', '', 'Lena'],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.endDate', '2019-04-14')
|
||||
->assertJsonPath('data.milestone', true)
|
||||
->assertJsonPath('data.category', 'home')
|
||||
// Trimmed, blanks dropped, duplicates removed.
|
||||
->assertJsonPath('data.people', ['Lena', 'Papa']);
|
||||
|
||||
// Clearing the span and the people list on update.
|
||||
$this->putJson("/api/events/{$clientId}", [
|
||||
'endDate' => null,
|
||||
'people' => [],
|
||||
'milestone' => false,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.endDate', null)
|
||||
->assertJsonPath('data.people', [])
|
||||
->assertJsonPath('data.milestone', false);
|
||||
});
|
||||
|
||||
test('rejects an unknown category and a span that ends before it starts', function () {
|
||||
$base = ['id' => Str::uuid()->toString(), 'title' => 'x', 'date' => '2020-05-10', 'emotion' => 0];
|
||||
|
||||
$this->postJson('/api/events', [...$base, 'category' => 'aliens'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['category']);
|
||||
|
||||
$this->postJson('/api/events', [...$base, 'endDate' => '2020-05-09'])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['endDate']);
|
||||
|
||||
$this->postJson('/api/events', [...$base, 'people' => [str_repeat('x', 81)]])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['people.0']);
|
||||
});
|
||||
|
||||
test('sync writes the new fields like crud does', function () {
|
||||
$clientId = Str::uuid()->toString();
|
||||
|
||||
$this->postJson('/api/events/sync', ['mutations' => [[
|
||||
'action' => 'create',
|
||||
'eventId' => $clientId,
|
||||
'payload' => [
|
||||
'title' => 'Abschluss',
|
||||
'date' => '2015-07-01',
|
||||
'emotion' => 0.9,
|
||||
'milestone' => true,
|
||||
'category' => 'education',
|
||||
'people' => ['Prof. Weber'],
|
||||
],
|
||||
]]])->assertOk()->assertJsonPath('results.0.status', 'ok');
|
||||
|
||||
$event = Event::query()->where('client_id', $clientId)->firstOrFail();
|
||||
expect($event->milestone)->toBeTrue()
|
||||
->and($event->category)->toBe('education')
|
||||
->and($event->people)->toBe(['Prof. Weber']);
|
||||
});
|
||||
|
|
|
|||
83
backend/tests/Feature/Api/LoginThrottleTest.php
Normal file
83
backend/tests/Feature/Api/LoginThrottleTest.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Laravel\Passport\ClientRepository;
|
||||
|
||||
beforeEach(function () {
|
||||
RateLimiter::clear('api-login|throttle@example.com|127.0.0.1');
|
||||
RateLimiter::clear('api-login|other@example.com|127.0.0.1');
|
||||
|
||||
// A successful login issues a personal access token.
|
||||
app(ClientRepository::class)->createPersonalAccessGrantClient('Test Personal Access Client', 'users');
|
||||
});
|
||||
|
||||
test('repeated failed logins are locked out with http 429', function () {
|
||||
User::factory()->create([
|
||||
'email' => 'throttle@example.com',
|
||||
'password' => 'correct-horse',
|
||||
]);
|
||||
|
||||
// Five wrong passwords are answered with the normal validation error.
|
||||
foreach (range(1, 5) as $attempt) {
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'throttle@example.com',
|
||||
'password' => 'wrong',
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
// The sixth is refused before the password is ever checked.
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'throttle@example.com',
|
||||
'password' => 'wrong',
|
||||
])->assertStatus(429);
|
||||
|
||||
// Even the correct password stays locked out during the cooldown.
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'throttle@example.com',
|
||||
'password' => 'correct-horse',
|
||||
])->assertStatus(429);
|
||||
});
|
||||
|
||||
test('a successful login clears the failure counter', function () {
|
||||
User::factory()->create([
|
||||
'email' => 'throttle@example.com',
|
||||
'password' => 'correct-horse',
|
||||
]);
|
||||
|
||||
foreach (range(1, 4) as $attempt) {
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'throttle@example.com',
|
||||
'password' => 'wrong',
|
||||
])->assertStatus(422);
|
||||
}
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'throttle@example.com',
|
||||
'password' => 'correct-horse',
|
||||
])->assertOk();
|
||||
|
||||
// The counter is back to zero, so a fresh run of wrong attempts is
|
||||
// answered with 422 again rather than an immediate lockout.
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'throttle@example.com',
|
||||
'password' => 'wrong',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('the throttle is scoped per email so one account cannot lock out another', function () {
|
||||
User::factory()->create(['email' => 'throttle@example.com', 'password' => 'correct-horse']);
|
||||
User::factory()->create(['email' => 'other@example.com', 'password' => 'correct-horse']);
|
||||
|
||||
foreach (range(1, 6) as $attempt) {
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'throttle@example.com',
|
||||
'password' => 'wrong',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'other@example.com',
|
||||
'password' => 'correct-horse',
|
||||
])->assertOk();
|
||||
});
|
||||
119
backend/tests/Feature/Api/SettingsValidationTest.php
Normal file
119
backend/tests/Feature/Api/SettingsValidationTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
Passport::actingAs($this->user);
|
||||
});
|
||||
|
||||
test('a preset that is not an object is rejected', function () {
|
||||
// `presets: [null]` used to satisfy the bare `array` rule and then blew up
|
||||
// in the frontend when it read `preset.id`.
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => ['presets' => [null]],
|
||||
])->assertStatus(422)->assertJsonValidationErrors('settings.presets.0');
|
||||
});
|
||||
|
||||
test('a preset without an id or name is rejected', function () {
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => ['presets' => [['settings' => []]]],
|
||||
])->assertStatus(422)
|
||||
->assertJsonValidationErrors(['settings.presets.0.id', 'settings.presets.0.name']);
|
||||
});
|
||||
|
||||
test('a valid preset is accepted', function () {
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => [
|
||||
'presets' => [[
|
||||
'id' => 'e0a1b2c3',
|
||||
'name' => 'Nachtmodus',
|
||||
'updatedAt' => 1726560000000,
|
||||
'settings' => ['floatingLines' => ['speed' => 1.5, 'lineMode' => 'glow']],
|
||||
]],
|
||||
'activePresetId' => 'e0a1b2c3',
|
||||
],
|
||||
])->assertOk()->assertJsonPath('data.activePresetId', 'e0a1b2c3');
|
||||
});
|
||||
|
||||
test('a preset keeps every snapshot field when stored', function () {
|
||||
// Nested rules make validated() drop any key without an explicit rule, so a
|
||||
// preset used to come back as `{floatingLines: ...}` only.
|
||||
$snapshot = [
|
||||
'theme' => 'dark',
|
||||
'appearance' => 'dark',
|
||||
'accentColor' => 'coral',
|
||||
'language' => 'en',
|
||||
'emotionGradientStart' => '#112233',
|
||||
'emotionGradientEnd' => '#445566',
|
||||
'timelineZoom' => 1.75,
|
||||
'showFps' => true,
|
||||
'floatingLines' => ['speed' => 1.5, 'lineMode' => 'glow'],
|
||||
];
|
||||
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => [
|
||||
'presets' => [['id' => 'p1', 'name' => 'Nachtmodus', 'updatedAt' => 1726560000000, 'settings' => $snapshot]],
|
||||
],
|
||||
])->assertOk();
|
||||
|
||||
expect($this->user->fresh()->settings->settings['presets'][0]['settings'])->toBe($snapshot);
|
||||
});
|
||||
|
||||
test('unknown top level settings keys are rejected instead of stored', function () {
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => ['theme' => 'dark', 'isAdmin' => true],
|
||||
])->assertStatus(422)->assertJsonValidationErrors('settings');
|
||||
|
||||
expect($this->user->fresh()->settings)->toBeNull();
|
||||
});
|
||||
|
||||
test('unknown floating line keys are rejected', function () {
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => ['floatingLines' => ['speed' => 1.0, 'somethingElse' => 5]],
|
||||
])->assertStatus(422)->assertJsonValidationErrors('settings.floatingLines');
|
||||
});
|
||||
|
||||
test('a nested structure inside floating lines is rejected', function () {
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => ['floatingLines' => ['speed' => ['nested' => 'payload']]],
|
||||
])->assertStatus(422)->assertJsonValidationErrors('settings.floatingLines.speed');
|
||||
});
|
||||
|
||||
test('an oversized string inside floating lines is rejected', function () {
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => ['floatingLines' => ['gradientStops' => str_repeat('x', 5000)]],
|
||||
])->assertStatus(422)->assertJsonValidationErrors('settings.floatingLines.gradientStops');
|
||||
});
|
||||
|
||||
test('more presets than the schema allows are rejected', function () {
|
||||
$presets = collect(range(1, 51))->map(fn (int $n) => [
|
||||
'id' => "preset-{$n}",
|
||||
'name' => "Preset {$n}",
|
||||
'settings' => [],
|
||||
])->all();
|
||||
|
||||
$this->putJson('/api/settings', ['settings' => ['presets' => $presets]])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors('settings.presets');
|
||||
});
|
||||
|
||||
test('known floating line values still pass through unchanged', function () {
|
||||
$this->putJson('/api/settings', [
|
||||
'settings' => [
|
||||
'theme' => 'dark',
|
||||
'language' => 'de',
|
||||
'timelineZoom' => 2.5,
|
||||
'showFps' => false,
|
||||
'floatingLines' => [
|
||||
'speed' => 1.25,
|
||||
'lineCount' => 10,
|
||||
'bgCenter' => '#0a0514',
|
||||
'lineMode' => 'glow',
|
||||
],
|
||||
],
|
||||
])->assertOk()
|
||||
->assertJsonPath('data.floatingLines.speed', 1.25)
|
||||
->assertJsonPath('data.floatingLines.lineMode', 'glow');
|
||||
});
|
||||
82
backend/tests/Feature/Console/ImportLifelineImagesTest.php
Normal file
82
backend/tests/Feature/Console/ImportLifelineImagesTest.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\LifelineSeeder;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$this->imageDirectory = sys_get_temp_dir().'/lifeline-images-test-'.uniqid();
|
||||
File::ensureDirectoryExists($this->imageDirectory);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
File::deleteDirectory($this->imageDirectory);
|
||||
});
|
||||
|
||||
function fakeLifelineImage(string $path): void
|
||||
{
|
||||
$image = imagecreatetruecolor(64, 48);
|
||||
imagefill($image, 0, 0, imagecolorallocate($image, 200, 160, 90));
|
||||
imagepng($image, $path);
|
||||
imagedestroy($image);
|
||||
}
|
||||
|
||||
test('import attaches key images to seeded lifeline events by age and month', function () {
|
||||
$this->seed(LifelineSeeder::class);
|
||||
|
||||
foreach (['04-07', '10-02'] as $slug) {
|
||||
fakeLifelineImage("{$this->imageDirectory}/{$slug}.png");
|
||||
}
|
||||
|
||||
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory])
|
||||
->expectsOutputToContain('Imported 2, skipped 0')
|
||||
->assertSuccessful();
|
||||
|
||||
$bruno = User::query()->where('email', 'bruno@thats-me.app')->firstOrFail();
|
||||
$event = Event::query()
|
||||
->where('client_id', LifelineSeeder::clientId('bruno@thats-me.app', 4, 7))
|
||||
->firstOrFail();
|
||||
|
||||
$media = $event->media()->where('collection', 'key_image')->firstOrFail();
|
||||
|
||||
expect($event->image)->toBe("/event-media/{$media->id}/thumb")
|
||||
->and($media->user_id)->toBe($bruno->id)
|
||||
->and($media->mime_type)->toBe('image/jpeg');
|
||||
|
||||
Storage::disk('local')->assertExists([$media->path, $media->preview_path, $media->thumbnail_path]);
|
||||
});
|
||||
|
||||
test('import skips events that already have a key image unless forced', function () {
|
||||
$this->seed(LifelineSeeder::class);
|
||||
|
||||
fakeLifelineImage("{$this->imageDirectory}/04-07.png");
|
||||
|
||||
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory])->assertSuccessful();
|
||||
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory])
|
||||
->expectsOutputToContain('Imported 0, skipped 1')
|
||||
->assertSuccessful();
|
||||
|
||||
$event = Event::query()
|
||||
->where('client_id', LifelineSeeder::clientId('bruno@thats-me.app', 4, 7))
|
||||
->firstOrFail();
|
||||
$firstMedia = $event->media()->where('collection', 'key_image')->firstOrFail();
|
||||
|
||||
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory, '--force' => true])
|
||||
->expectsOutputToContain('Imported 1')
|
||||
->assertSuccessful();
|
||||
|
||||
expect($event->media()->where('collection', 'key_image')->count())->toBe(1)
|
||||
->and($event->media()->where('collection', 'key_image')->first()->id)->not->toBe($firstMedia->id);
|
||||
|
||||
Storage::disk('local')->assertMissing($firstMedia->path);
|
||||
});
|
||||
|
||||
test('import fails when the lifeline user is not seeded', function () {
|
||||
$this->artisan('lifelines:import-images', ['handle' => 'bruno', '--from' => $this->imageDirectory])
|
||||
->expectsOutputToContain('not seeded yet')
|
||||
->assertFailed();
|
||||
});
|
||||
102
backend/tests/Feature/Console/TranscodeVoiceNotesTest.php
Normal file
102
backend/tests/Feature/Console/TranscodeVoiceNotesTest.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMedia;
|
||||
use App\Models\User;
|
||||
use App\Services\EventMediaProbe;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
function legacyVoiceNote(string $mime = 'audio/webm', string $extension = 'webm'): EventMedia
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
$path = "event-media/{$user->id}/{$event->client_id}/legacy.{$extension}";
|
||||
Storage::disk('local')->put($path, 'not really audio');
|
||||
|
||||
return EventMedia::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'user_id' => $user->id,
|
||||
'collection' => 'voice_note',
|
||||
'type' => 'audio',
|
||||
'mime_type' => $mime,
|
||||
'path' => $path,
|
||||
'disk' => 'local',
|
||||
'thumbnail_path' => null,
|
||||
'preview_path' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
test('converts legacy webm voice notes to audio/mp4 and drops the old file', function () {
|
||||
Storage::fake('local');
|
||||
$media = legacyVoiceNote();
|
||||
$oldPath = $media->path;
|
||||
|
||||
$probe = Mockery::mock(EventMediaProbe::class);
|
||||
$probe->shouldReceive('transcodeAudioToM4a')
|
||||
->once()
|
||||
->withArgs(function (string $source, string $target) use ($oldPath): bool {
|
||||
expect($source)->toEndWith($oldPath);
|
||||
expect($target)->toEndWith('legacy.m4a');
|
||||
file_put_contents($target, 'aac bytes');
|
||||
|
||||
return true;
|
||||
})
|
||||
->andReturn(true);
|
||||
$probe->shouldReceive('durationMs')->once()->andReturn(4200);
|
||||
$this->app->instance(EventMediaProbe::class, $probe);
|
||||
|
||||
$this->artisan('media:transcode-voice-notes')
|
||||
->expectsOutputToContain('1 voice note(s) to convert.')
|
||||
->expectsOutputToContain('Converted 1, failed 0.')
|
||||
->assertSuccessful();
|
||||
|
||||
$media->refresh();
|
||||
expect($media->mime_type)->toBe('audio/mp4')
|
||||
->and($media->path)->toEndWith('legacy.m4a')
|
||||
->and($media->duration_ms)->toBe(4200)
|
||||
->and($media->size)->toBe(strlen('aac bytes'));
|
||||
Storage::disk('local')->assertExists($media->path);
|
||||
Storage::disk('local')->assertMissing($oldPath);
|
||||
});
|
||||
|
||||
test('leaves audio/mp4 voice notes alone and reports when nothing is to do', function () {
|
||||
Storage::fake('local');
|
||||
legacyVoiceNote('audio/mp4', 'm4a');
|
||||
|
||||
$probe = Mockery::mock(EventMediaProbe::class);
|
||||
$probe->shouldNotReceive('transcodeAudioToM4a');
|
||||
$this->app->instance(EventMediaProbe::class, $probe);
|
||||
|
||||
$this->artisan('media:transcode-voice-notes')
|
||||
->expectsOutputToContain('already audio/mp4')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
test('dry run only lists what would change', function () {
|
||||
Storage::fake('local');
|
||||
$media = legacyVoiceNote();
|
||||
|
||||
$probe = Mockery::mock(EventMediaProbe::class);
|
||||
$probe->shouldNotReceive('transcodeAudioToM4a');
|
||||
$this->app->instance(EventMediaProbe::class, $probe);
|
||||
|
||||
$this->artisan('media:transcode-voice-notes --dry-run')
|
||||
->expectsOutputToContain("#{$media->id}: audio/webm → audio/mp4")
|
||||
->assertSuccessful();
|
||||
|
||||
expect($media->fresh()->mime_type)->toBe('audio/webm');
|
||||
});
|
||||
|
||||
test('a failed conversion keeps the original and exits with failure', function () {
|
||||
Storage::fake('local');
|
||||
$media = legacyVoiceNote();
|
||||
|
||||
$probe = Mockery::mock(EventMediaProbe::class);
|
||||
$probe->shouldReceive('transcodeAudioToM4a')->once()->andReturn(false);
|
||||
$this->app->instance(EventMediaProbe::class, $probe);
|
||||
|
||||
$this->artisan('media:transcode-voice-notes')->assertFailed();
|
||||
|
||||
expect($media->fresh()->mime_type)->toBe('audio/webm');
|
||||
Storage::disk('local')->assertExists($media->path);
|
||||
});
|
||||
127
backend/tests/Feature/Seeders/LifelineSeederTest.php
Normal file
127
backend/tests/Feature/Seeders/LifelineSeederTest.php
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Database\Seeders\LifelineSeeder;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
function seededLifelineEventCount(): int
|
||||
{
|
||||
return collect(LifelineSeeder::lifelineFiles())
|
||||
->sum(fn (string $path): int => count(json_decode(File::get($path), true)['events']));
|
||||
}
|
||||
|
||||
test('lifeline seeder creates jonas and sophie with a full lifeline each', function () {
|
||||
$this->seed(LifelineSeeder::class);
|
||||
|
||||
foreach (['jonas' => 'Jonas', 'sophie' => 'Sophie'] as $handle => $name) {
|
||||
$user = User::query()->where('email', "{$handle}@thats-me.app")->first();
|
||||
|
||||
expect($user)->not->toBeNull()
|
||||
->and($user->name)->toBe($name)
|
||||
->and(Hash::check('pass', $user->password))->toBeTrue()
|
||||
->and($user->email_verified_at)->not->toBeNull();
|
||||
|
||||
$events = Event::query()->where('user_id', $user->id)->orderBy('date')->get();
|
||||
|
||||
// 21 years (25–45) × 12 months, one event per month
|
||||
expect($events)->toHaveCount(252)
|
||||
->and($events->first()->date)->toStartWith('2005-01-')
|
||||
->and($events->last()->date)->toStartWith('2025-12-')
|
||||
->and($events->pluck('date')->map(fn (string $date): string => substr($date, 0, 7))->unique())->toHaveCount(252);
|
||||
|
||||
foreach ($events as $event) {
|
||||
expect($event->title)->not->toBe('')
|
||||
->and(mb_strlen($event->title))->toBeLessThanOrEqual(40)
|
||||
->and($event->note)->not->toBe('')
|
||||
->and((float) $event->emotion)->toBeGreaterThanOrEqual(-1)
|
||||
->and((float) $event->emotion)->toBeLessThanOrEqual(1)
|
||||
->and($event->image)->toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('lifeline seeder maps ratings onto the emotion bands of the event panel', function () {
|
||||
$this->seed(LifelineSeeder::class);
|
||||
|
||||
$jonas = User::query()->where('email', 'jonas@thats-me.app')->firstOrFail();
|
||||
|
||||
$emotionFor = fn (int $age, int $month): float => (float) Event::query()
|
||||
->where('client_id', LifelineSeeder::clientId('jonas@thats-me.app', $age, $month))
|
||||
->where('user_id', $jonas->id)
|
||||
->value('emotion');
|
||||
|
||||
// 25 / Juli: Lagerfeuer in Slowenien → Sehr positiv
|
||||
expect($emotionFor(25, 7))->toBeGreaterThan(0.5);
|
||||
// 25 / Januar: Erstes eigenes Projekt → Positiv
|
||||
expect($emotionFor(25, 1))->toBeGreaterThan(0.15)->toBeLessThanOrEqual(0.5);
|
||||
// 25 / Februar: Sonntags-Vorkochen → Neutral
|
||||
expect($emotionFor(25, 2))->toBeGreaterThan(-0.15)->toBeLessThanOrEqual(0.15);
|
||||
// 25 / Juni: Spätschicht im Büro → Negativ
|
||||
expect($emotionFor(25, 6))->toBeGreaterThan(-0.5)->toBeLessThanOrEqual(-0.15);
|
||||
// 40 / Mai: Letztes Foto von Thomas → Sehr negativ
|
||||
expect($emotionFor(40, 5))->toBeLessThanOrEqual(-0.5);
|
||||
});
|
||||
|
||||
test('lifeline seeder is idempotent and keeps manually imported key images', function () {
|
||||
$this->seed(LifelineSeeder::class);
|
||||
|
||||
$clientId = LifelineSeeder::clientId('sophie@thats-me.app', 35, 1);
|
||||
$event = Event::query()->where('client_id', $clientId)->firstOrFail();
|
||||
|
||||
$event->update([
|
||||
'image' => 'media/sophie/clara.jpg',
|
||||
'key_image_title' => 'Clara',
|
||||
'title' => 'Manuell geändert',
|
||||
]);
|
||||
|
||||
$this->seed(LifelineSeeder::class);
|
||||
|
||||
$event->refresh();
|
||||
|
||||
expect(Event::query()->count())->toBe(seededLifelineEventCount())
|
||||
->and(User::query()->where('email', 'like', '%@thats-me.app')->count())->toBe(count(LifelineSeeder::lifelineFiles()))
|
||||
->and($event->image)->toBe('media/sophie/clara.jpg')
|
||||
->and($event->key_image_title)->toBe('Clara')
|
||||
->and($event->title)->toBe('Clara ist da');
|
||||
});
|
||||
|
||||
test('database seeder includes the lifelines', function () {
|
||||
$this->seed(DatabaseSeeder::class);
|
||||
|
||||
foreach (['jonas', 'sophie', 'nadja', 'werner', 'bruno', 'benz', 'lukas', 'nachtschicht'] as $handle) {
|
||||
expect(User::query()->where('email', "{$handle}@thats-me.app")->exists())->toBeTrue();
|
||||
}
|
||||
|
||||
expect(Event::query()->count())->toBe(seededLifelineEventCount());
|
||||
});
|
||||
|
||||
test('additional lifelines have gaps and end at their final year', function () {
|
||||
$this->seed(LifelineSeeder::class);
|
||||
|
||||
$range = function (string $handle): array {
|
||||
$user = User::query()->where('email', "{$handle}@thats-me.app")->firstOrFail();
|
||||
$events = Event::query()->where('user_id', $user->id);
|
||||
|
||||
return [$events->count(), substr($events->min('date'), 0, 4), substr($events->max('date'), 0, 4)];
|
||||
};
|
||||
|
||||
// Nadja: 1988 + 24 … 37, Werner: 1955 + 55 … 70, Bruno: 2014 + 0 … 10
|
||||
expect($range('nadja'))->toBe([42, '2012', '2025'])
|
||||
->and($range('werner'))->toBe([41, '2010', '2025'])
|
||||
->and($range('bruno'))->toBe([35, '2014', '2024'])
|
||||
->and($range('benz'))->toBe([37, '1981', '2025'])
|
||||
->and($range('lukas'))->toBe([69, '2012', '2025'])
|
||||
->and($range('nachtschicht'))->toBe([57, '1995', '2025']);
|
||||
});
|
||||
|
||||
test('an explicit day separates several events in the same month', function () {
|
||||
$email = 'nadja@thats-me.app';
|
||||
|
||||
expect(LifelineSeeder::clientId($email, 32, 5))->not->toBe(LifelineSeeder::clientId($email, 32, 5, 12))
|
||||
->and(LifelineSeeder::clientId($email, 32, 5, 12))->not->toBe(LifelineSeeder::clientId($email, 32, 5, 27))
|
||||
->and(LifelineSeeder::date(1988, 32, 5, $email, 12))->toBe('2020-05-12')
|
||||
->and(LifelineSeeder::date(1988, 32, 5, $email))->toMatch('/^2020-05-(0[1-9]|1\d|2[0-8])$/');
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMedia;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
test('deleting a user removes their media files and settings background', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$user = User::factory()->create(['password' => 'password']);
|
||||
$event = Event::factory()->create(['user_id' => $user->id]);
|
||||
$media = EventMedia::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'event_id' => $event->id,
|
||||
'path' => "event-media/{$user->id}/original.jpg",
|
||||
'preview_path' => "event-media/{$user->id}/preview.jpg",
|
||||
'thumbnail_path' => "event-media/{$user->id}/thumb.jpg",
|
||||
]);
|
||||
|
||||
$background = "settings-media/{$user->id}/background.jpg";
|
||||
|
||||
Storage::disk('local')->put($media->path, 'original');
|
||||
Storage::disk('local')->put($media->preview_path, 'preview');
|
||||
Storage::disk('local')->put($media->thumbnail_path, 'thumb');
|
||||
Storage::disk('local')->put($background, 'background');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Volt::test('settings.delete-user-form')
|
||||
->set('password', 'password')
|
||||
->call('deleteUser')
|
||||
->assertHasNoErrors();
|
||||
|
||||
Storage::disk('local')->assertMissing($media->path);
|
||||
Storage::disk('local')->assertMissing($media->preview_path);
|
||||
Storage::disk('local')->assertMissing($media->thumbnail_path);
|
||||
Storage::disk('local')->assertMissing($background);
|
||||
|
||||
expect($user->fresh())->toBeNull();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue