'participant1_photo', 'media_participant2_photo' => 'participant2_photo', 'media_referee_photo' => 'referee_photo', 'media_club1_logo' => 'club1_logo', 'media_club2_logo' => 'club2_logo', 'media_event_poster' => 'event_poster', ]; public function store(Request $request, NasSyncService $nas): JsonResponse { $data = $request->validate($this->rules()); $match = new SportsMatch(); $match->user_id = Auth::id(); $this->fillFromRequest($match, $request, $data); $match->save(); $this->handleImages($match, $request, $nas); $match->save(); return response()->json([ 'ok' => true, 'message' => 'Match saved as draft.', 'match' => $this->toEditPayload($match), ]); } public function update(Request $request, SportsMatch $sportsMatch, NasSyncService $nas): JsonResponse { abort_unless($sportsMatch->user_id === Auth::id(), 403); $data = $request->validate($this->rules($sportsMatch)); $this->fillFromRequest($sportsMatch, $request, $data); $this->handleImages($sportsMatch, $request, $nas); $sportsMatch->save(); return response()->json([ 'ok' => true, 'message' => 'Match updated.', 'match' => $this->toEditPayload($sportsMatch), ]); } /** Return the record as JSON so the modal can be re-opened for later editing. */ public function edit(SportsMatch $sportsMatch): JsonResponse { abort_unless($sportsMatch->user_id === Auth::id(), 403); return response()->json(['match' => $this->toEditPayload($sportsMatch)]); } // ── Validation ────────────────────────────────────────────────────────── private function rules(?SportsMatch $existing = null): array { $img = ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120']; return [ // Basic — only these are required for a first (draft) save 'video_id' => ['required', 'integer', Rule::exists('videos', 'id')->where('user_id', Auth::id())], 'status' => ['nullable', Rule::in(['draft', 'published'])], 'title' => ['required', 'string', 'max:255'], 'event_name' => ['nullable', 'string', 'max:255'], 'match_date' => ['nullable', 'date'], 'match_time' => ['nullable', 'date_format:H:i'], 'participant1_name' => ['nullable', 'string', 'max:255'], 'participant2_name' => ['nullable', 'string', 'max:255'], 'referee_name' => ['nullable', 'string', 'max:255'], // revealed later in edit 'sport' => ['nullable', 'string', 'max:80'], 'match_type' => ['nullable', 'string', 'max:80'], 'venue_name' => ['nullable', 'string', 'max:255'], // Optional grouped scalars (free-form, kept generic) 'competition' => ['nullable', 'array'], 'participants' => ['nullable', 'array'], 'extra_participants'=> ['nullable', 'array'], 'venue' => ['nullable', 'array'], 'result' => ['nullable', 'array'], 'reviews' => ['nullable', 'array'], 'media' => ['nullable', 'array'], // Repeatable groups 'officials' => ['nullable', 'array'], 'officials.*.role' => ['nullable', 'string', 'max:80'], 'officials.*.name' => ['nullable', 'string', 'max:255'], 'officials.*.photo' => $img, 'segments' => ['nullable', 'array'], 'segments.*.type' => ['nullable', 'string', 'max:80'], 'segments.*.number' => ['nullable', 'string', 'max:50'], 'segments.*.score' => ['nullable', 'string', 'max:255'], 'segments.*.winner' => ['nullable', 'string', 'max:255'], 'segments.*.notes' => ['nullable', 'string', 'max:2000'], 'statistics' => ['nullable', 'array'], 'statistics.*.name' => ['nullable', 'string', 'max:120'], 'statistics.*.value' => ['nullable', 'string', 'max:120'], 'statistics.*.owner' => ['nullable', 'string', 'max:255'], 'statistics.*.notes' => ['nullable', 'string', 'max:2000'], // Single image fields 'media_participant1_photo' => $img, 'media_participant2_photo' => $img, 'media_referee_photo' => $img, 'media_club1_logo' => $img, 'media_club2_logo' => $img, 'media_event_poster' => $img, ]; } // ── Mapping helpers ───────────────────────────────────────────────────── private function fillFromRequest(SportsMatch $match, Request $request, array $data): void { $match->video_id = $data['video_id']; $match->status = $data['status'] ?? 'draft'; $match->title = $data['title']; $match->event_name = $data['event_name'] ?? null; $match->match_date = $data['match_date'] ?? null; $match->match_time = $data['match_time'] ?? null; $match->participant1_name = $data['participant1_name'] ?? null; $match->participant2_name = $data['participant2_name'] ?? null; $match->referee_name = $data['referee_name'] ?? null; $match->sport = $data['sport'] ?? null; $match->match_type = $data['match_type'] ?? null; $match->venue_name = $data['venue_name'] ?? null; // Optional scalar groups — keep only non-empty values, drop the JSON if empty. $match->competition = $this->clean($request->input('competition', [])); $match->venue = $this->clean($request->input('venue', [])); $match->result = $this->clean($request->input('result', [])); $match->reviews = $this->clean($request->input('reviews', [])); // Participants details + any extra participants in one generic structure. $participants = $this->clean($request->input('participants', [])); $extra = collect($request->input('extra_participants', [])) ->map(fn ($p) => $this->clean($p)) ->filter() ->values() ->all(); if (! empty($extra)) $participants['extra'] = $extra; $match->participants = empty($participants) ? null : $participants; // Repeatable text groups (officials are built in handleImages() since they carry photos). $match->segments = $this->cleanRows($request->input('segments', [])); $match->statistics = $this->cleanRows($request->input('statistics', [])); // Media text fields (caption/alt/credit/public). Preserve existing image // paths already on the record; handleImages() overwrites any replaced ones. $existingMedia = $match->media ?? []; $mediaText = $this->clean($request->input('media', [])); if (isset($mediaText['public'])) { $mediaText['public'] = filter_var($mediaText['public'], FILTER_VALIDATE_BOOLEAN); } $imagePaths = array_intersect_key($existingMedia, array_flip(self::IMAGE_FIELDS)); $merged = array_merge($imagePaths, $mediaText); $match->media = empty($merged) ? null : $merged; } /** Strip empty values from a flat associative array; return null if nothing left. */ private function clean(?array $arr): ?array { if (! is_array($arr)) return null; $out = array_filter($arr, fn ($v) => $v !== null && $v !== '' && $v !== []); return empty($out) ? null : $out; } /** Clean a list of repeatable rows, dropping rows that are entirely empty. */ private function cleanRows(?array $rows): ?array { if (! is_array($rows)) return null; $out = []; foreach ($rows as $row) { if (! is_array($row)) continue; // photo (file) is handled separately; drop transient hidden keys here unset($row['photo']); $clean = $this->clean($row); if ($clean) $out[] = $clean; } return empty($out) ? null : $out; } // ── Image handling ────────────────────────────────────────────────────── private function handleImages(SportsMatch $match, Request $request, NasSyncService $nas): void { $slug = $nas->userSlug($match->user ?: Auth::user()); $media = $match->media ?? []; // Named single images foreach (self::IMAGE_FIELDS as $input => $key) { if ($request->hasFile($input)) { $media[$key] = $this->storeImage($request->file($input), $slug, $match->id, $key, $nas); } } $match->media = empty($media) ? null : $media; // Officials — built from raw input so file inputs align by index. The // modal renumbers rows to contiguous indices before submit. A new photo // file replaces the existing one; otherwise the existing path is kept. $officials = []; foreach (array_values($request->input('officials', [])) as $i => $row) { if (! is_array($row)) continue; $entry = []; if (! empty($row['role'])) $entry['role'] = $row['role']; if (! empty($row['name'])) $entry['name'] = $row['name']; if ($request->hasFile("officials.$i.photo")) { $entry['photo'] = $this->storeImage( $request->file("officials.$i.photo"), $slug, $match->id, "official-$i", $nas ); } elseif (! empty($row['photo_existing'])) { $entry['photo'] = $row['photo_existing']; } if (! empty($entry)) $officials[] = $entry; } $match->officials = empty($officials) ? null : $officials; } /** * Write one uploaded image to the canonical NAS path * (users/{slug}/sports/{matchId}/{key}.{ext}) and return that relative path. */ private function storeImage(UploadedFile $file, string $slug, int $matchId, string $key, NasSyncService $nas): string { $ext = strtolower($file->getClientOriginalExtension() ?: 'jpg'); $rel = "users/{$slug}/sports/{$matchId}/{$key}.{$ext}"; $localAbs = storage_path('app/' . $rel); @mkdir(dirname($localAbs), 0755, true); $file->move(dirname($localAbs), basename($localAbs)); // Push to NAS and drop the local copy when the NAS is reachable. if ($nas->isEnabled()) { $nas->mkdirp(dirname($rel)); if ($nas->putFile($localAbs, $rel)) { @unlink($localAbs); } } return $rel; } /** Shape the record for the edit modal (resolves image paths to URLs). */ private function toEditPayload(SportsMatch $match): array { $media = $match->media ?? []; $mediaUrls = []; // Key the URLs by the form input name (e.g. media_participant1_photo) so the // modal can match each preview to its field via [data-img]. foreach (self::IMAGE_FIELDS as $input => $key) { if (! empty($media[$key])) { $mediaUrls[$input] = route('media.sports-image', $media[$key]); } } $officials = $match->officials ?? []; foreach ($officials as &$o) { if (! empty($o['photo'])) $o['photo_url'] = route('media.sports-image', $o['photo']); } unset($o); return [ 'id' => $match->id, 'video_id' => $match->video_id, 'video_title' => optional($match->video)->title, 'status' => $match->status, 'sport' => $match->sport, 'title' => $match->title, 'event_name' => $match->event_name, 'match_type' => $match->match_type, 'match_date' => optional($match->match_date)->format('Y-m-d'), 'match_time' => $match->match_time ? substr($match->match_time, 0, 5) : null, 'participant1_name' => $match->participant1_name, 'participant2_name' => $match->participant2_name, 'referee_name' => $match->referee_name, 'venue_name' => $match->venue_name, 'competition' => $match->competition, 'participants' => $match->participants, 'venue' => $match->venue, 'result' => $match->result, 'reviews' => $match->reviews, 'media' => $media, 'media_urls' => $mediaUrls, 'officials' => $officials, 'segments' => $match->segments, 'statistics' => $match->statistics, ]; } }