ghassan 2b5e480c9a Remove VS screen; fix cropper + sports edit save + headshot/logo mapping
- Remove VS intro from player and thumbnail VS card from match video cards.
  All references (`msb-vs*`, `initVsIntro`, `setVsImg`, `HAS_VS`, `$vs`,
  `$msbThumbVs`, `$hasMatchMeta`) are stripped from scoreboard partials,
  video-card component, and scoreboard script. Related CSS keyframes
  (`msbFadeIn/Out`, `msbDriftA/B`, `msbSweep`) removed.

- Image cropper (resources/views/components/image-cropper.blade.php):
  replace unreliable third-party Cropme integration with a DIY cropper.
  Pan/zoom/rotate wired directly with pointer + wheel events; on save,
  the visible viewport is rendered to an offscreen canvas at the configured
  output-width (with circular clip for `shape="circle"`). Also reparent the
  overlay to <body> on open to escape any transformed Bootstrap-modal
  ancestor's stacking context that clipped it.

- Sports match modal (resources/views/layouts/partials/sports-match-modal.blade.php):
  set fighter photo croppers to 3:4 portrait (285x380) to match the VS
  card's 228x304 frame; club logos + referee stay 1:1 square.

- SportsMatchController::fillFromRequest (line 174): coerce
  `$this->clean($request->input('media', []))` to `[]` when it returns null
  so array_merge doesn't crash under PHP 8's stricter types.

- SportsMatch::headerData(): read fighter headshots + club logos from the
  canonical media.* keys (participant1_photo / participant2_photo /
  club1_logo / club2_logo) that the uploader modal actually saves, with the
  older p1_* / p2_* names kept as a secondary fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-09 23:47:59 +03:00

129 lines
5.0 KiB
PHP

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SportsMatch extends Model
{
use HasFactory;
protected $table = 'sports_matches';
protected $fillable = [
'video_id', 'user_id', 'status',
'sport', 'title', 'event_name', 'match_type',
'match_date', 'match_time',
'participant1_name', 'participant2_name', 'referee_name', 'venue_name',
'competition', 'participants', 'media', 'officials',
'venue', 'result', 'segments', 'statistics', 'reviews',
];
protected $casts = [
'match_date' => 'date',
'competition' => 'array',
'participants' => 'array',
'media' => 'array',
'officials' => 'array',
'venue' => 'array',
'result' => 'array',
'segments' => 'array',
'statistics' => 'array',
'reviews' => 'array',
];
/**
* Shape the record into exactly what the match page header renders. Every value
* is null-safe so a video with no/partial match data degrades to just its title
* instead of falling back to hard-coded demo content.
*/
public function headerData(): array
{
$p = $this->participants ?? [];
$c = $this->competition ?? [];
$v = $this->venue ?? [];
$m = $this->media ?? [];
$trim = fn($x) => (is_string($x) && trim($x) !== '') ? trim($x) : null;
return [
'sport' => $trim($this->sport),
'blue' => [
'name' => $trim($this->participant1_name),
'club' => $trim($p['p1_club'] ?? null),
'flag' => $this->flagFor($p['p1_country'] ?? null),
// Uploader form (sports-match modal) stores these under the
// canonical media.* keys — mirror them to headerData() so the
// scoreboard/VS partials get real image paths.
'club_logo' => $trim($m['club1_logo'] ?? $m['p1_club_logo'] ?? null),
'headshot' => $trim($m['participant1_photo'] ?? $m['p1_headshot'] ?? null),
],
'red' => [
'name' => $trim($this->participant2_name),
'club' => $trim($p['p2_club'] ?? null),
'flag' => $this->flagFor($p['p2_country'] ?? null),
'club_logo' => $trim($m['club2_logo'] ?? $m['p2_club_logo'] ?? null),
'headshot' => $trim($m['participant2_photo'] ?? $m['p2_headshot'] ?? null),
],
'weight_category' => $trim($p['weight_class'] ?? null),
'division' => $trim($c['division'] ?? null),
'championship' => $trim($c['championship_name'] ?? null) ?? $trim($this->event_name),
'match_number' => $trim(($c['match_number'] ?? null) !== null ? (string) $c['match_number'] : null),
'court' => $trim($c['court'] ?? null),
'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", …
'match_date' => $this->match_date?->format('Y-m-d'),
'referee' => [
'name' => $trim($this->referee_name),
'flag' => $this->flagFor($p['referee_country'] ?? null),
],
'venue' => [
'name' => $trim($this->venue_name) ?? $trim($v['name'] ?? null),
'map_link' => $trim($v['map_link'] ?? null),
],
'event_logo' => $trim($m['event_poster'] ?? null),
/*
* Per-video overlay defaults set by the uploader. Shape:
* ['scorebar'=>bool, 'feed'=>bool, 'matchInfo'=>bool, 'timeline'=>bool,
* 'clubs'=>bool, 'flags'=>bool, 'penalties'=>bool, 'cornerLabels'=>'auto|redblue|karate|taekwondo']
* Anything not set falls back to the app defaults in the JS.
*/
'scoreboard_defaults' => is_array($c['scoreboard'] ?? null) ? $c['scoreboard'] : [],
];
}
/** Resolve a country name or ISO2 code to a lowercase flag-icons code (or null). */
private function flagFor(?string $country): ?string
{
$country = trim((string) $country);
if ($country === '') {
return null;
}
$countries = \App\Data\Countries::all();
if (preg_match('/^[A-Za-z]{2}$/', $country) && isset($countries[strtoupper($country)])) {
return strtolower($country);
}
foreach ($countries as $iso2 => $meta) {
if (strcasecmp($meta['name'], $country) === 0) {
return strtolower($iso2);
}
}
return null;
}
public function video(): BelongsTo
{
return $this->belongsTo(Video::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function isDraft(): bool
{
return $this->status === 'draft';
}
}