Sports match share links now show the full VS composition (fighters,
flags, event, weight class, referee/court/match chips) in WhatsApp /
Telegram / Facebook / Twitter previews instead of the raw video frame.
- Browsershot + puppeteer-bundled Chromium screenshots a dedicated
1200x675 (true 16:9) render of the vs-mini composition. Chrome runs
through a small wrapper (bin/browsershot-node.sh) that raises the fd
limit and reads its full env, since PHP-FPM's stripped env otherwise
makes Chromium fail to launch.
- Render is JPEG @ q85 (~100 KB) instead of PNG (~700 KB) — WhatsApp
silently drops OG images above roughly 300 KB, which is why music
thumbnails always showed and match cards didn't.
- SportsMatchController@store/update renders synchronously so the
picture is guaranteed to exist before the user copies the share URL;
edits regenerate and old stamped variants are cleaned. The og:image
URL carries a ?v={updated_at} stamp so scrapers recache on edit.
- accessShare() (/s/{token}) now detects scraper user agents and serves
the video page HTML inline instead of a 302 — some crawlers don't
follow redirects when building link previews.
- og:description / twitter:description built from headerData(): flag
emoji + fighter names (blue vs red), event, weight, match#, court,
referee, view count, upload date.
- Fixed 3-dot menu on video cards being hidden behind the next card
(:has(.dropdown-menu.show) promotes the active card above siblings
and lifts the overflow:hidden clip on .yt-video-info).
- vs-mini: skip the entrance animation on touch devices and expose a
.vs-mini-static class so the OG render always captures the final
frame instead of a random mid-animation moment.
Also adds an admin gallery at /admin/og-previews for spot-checking the
generated preview JPEGs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
343 lines
15 KiB
PHP
343 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers;
|
||
|
||
use App\Http\Controllers\VideoController;
|
||
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();
|
||
|
||
if ($match->video && $match->video->title !== $match->title) {
|
||
$match->video->title = $match->title;
|
||
$match->video->save();
|
||
}
|
||
|
||
// Prefetch the OG social-share PNG so WhatsApp/Facebook crawlers
|
||
// get the composed VS card on first share, not the fallback frame.
|
||
if ($match->video) {
|
||
// Blocks the response by ~3–5s while the VS PNG renders. That
|
||
// wait is intentional: the user typically shares the URL right
|
||
// after saving, and WhatsApp/Facebook cache whatever they see
|
||
// on first crawl. If the render hasn't finished when the
|
||
// crawler hits, we'd be stuck on the thumbnail fallback in
|
||
// every future share.
|
||
(new VideoController())->renderMatchOgSync($match->video->fresh(['sportsMatch']));
|
||
}
|
||
|
||
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();
|
||
|
||
// Keep the parent Video's display title in sync with the match title
|
||
// so the match page header, share metadata, and search results all
|
||
// reflect the edited value.
|
||
if ($sportsMatch->video && $sportsMatch->video->title !== $sportsMatch->title) {
|
||
$sportsMatch->video->title = $sportsMatch->title;
|
||
$sportsMatch->video->save();
|
||
}
|
||
|
||
if ($sportsMatch->video) {
|
||
// Synchronous re-render on edit: guarantees the fresh VS card
|
||
// exists at the new versioned URL before the JSON response
|
||
// returns and the user can share the link. See renderMatchOgSync.
|
||
(new VideoController())->renderMatchOgSync($sportsMatch->video->fresh(['sportsMatch']));
|
||
}
|
||
|
||
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 ?? [];
|
||
// clean() returns null when the array is entirely empty — coerce back to []
|
||
// so array_merge() doesn't blow up on PHP 8's stricter type checking.
|
||
$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,
|
||
];
|
||
}
|
||
}
|