- New SportsMatch model/controller and sports UI components/modal - Move share-modal to a reusable x-share-modal/x-share-button component - Add VideoSharedWithUser notification and share-to-members flow - Device/user-agent tracking on views, downloads, share accesses - ProfileVisit model + migration; subscription source tracking - Email thumbnail support; remove stale TODO files
308 lines
14 KiB
PHP
308 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers;
|
|
|
|
use App\Models\SportsMatch;
|
|
use App\Services\NasSyncService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
class SportsMatchController extends Controller
|
|
{
|
|
/** Single-image fields stored under the `media` JSON group. */
|
|
private const IMAGE_FIELDS = [
|
|
'media_participant1_photo' => '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,
|
|
];
|
|
}
|
|
}
|