Match cards: composed title, header stage from match title, equal-sized grid
- SportsMatchController syncs Video.title with SportsMatch.title on
store/update so the "Match title" field drives the video's display
title everywhere.
- headerData(): "championship" now prefers event_name (event title)
and adds a dedicated "stage" key sourced from SportsMatch.title
(shown between the yellow lines above the weight class in both the
in-player VS overlay and the vs-mini gallery card).
- Video cards render match titles with flags + corner colours
("FINALS – 🇧🇭 Blue vs 🇧🇭 Red (-61 kg)"), matching the video
page header, and clip long titles to one line.
- Video gallery grid uses repeat(N, minmax(0, 1fr)) + grid-auto-rows:1fr
so every card thumbnail renders at exactly the same size; hover-preview
videos use object-fit:cover to stop portrait sources looking smaller.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
04d63cccdb
commit
665bad76bf
@ -34,6 +34,11 @@ class SportsMatchController extends Controller
|
|||||||
$this->handleImages($match, $request, $nas);
|
$this->handleImages($match, $request, $nas);
|
||||||
$match->save();
|
$match->save();
|
||||||
|
|
||||||
|
if ($match->video && $match->video->title !== $match->title) {
|
||||||
|
$match->video->title = $match->title;
|
||||||
|
$match->video->save();
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'message' => 'Match saved as draft.',
|
'message' => 'Match saved as draft.',
|
||||||
@ -51,6 +56,14 @@ class SportsMatchController extends Controller
|
|||||||
$this->handleImages($sportsMatch, $request, $nas);
|
$this->handleImages($sportsMatch, $request, $nas);
|
||||||
$sportsMatch->save();
|
$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();
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'message' => 'Match updated.',
|
'message' => 'Match updated.',
|
||||||
|
|||||||
@ -68,7 +68,14 @@ class SportsMatch extends Model
|
|||||||
],
|
],
|
||||||
'weight_category' => $trim($p['weight_class'] ?? null),
|
'weight_category' => $trim($p['weight_class'] ?? null),
|
||||||
'division' => $trim($c['division'] ?? null),
|
'division' => $trim($c['division'] ?? null),
|
||||||
'championship' => $trim($c['championship_name'] ?? null) ?? $trim($this->event_name),
|
// Sub-header "stage" shown between the two yellow lines above
|
||||||
|
// the weight class (e.g. "Finals", "SEMI FINAL"). Sourced from
|
||||||
|
// the SportsMatch->title column — the form's "Match title" input.
|
||||||
|
'stage' => $trim($this->title),
|
||||||
|
// "Event title" = the championship / tournament name shown above the
|
||||||
|
// fighters. Prefer the SportsMatch->event_name column (the field the
|
||||||
|
// uploader labels "Event title"), fall back to competition.championship_name.
|
||||||
|
'championship' => $trim($this->event_name) ?? $trim($c['championship_name'] ?? null),
|
||||||
'match_number' => $trim(($c['match_number'] ?? null) !== null ? (string) $c['match_number'] : null),
|
'match_number' => $trim(($c['match_number'] ?? null) !== null ? (string) $c['match_number'] : null),
|
||||||
'court' => $trim($c['court'] ?? null),
|
'court' => $trim($c['court'] ?? null),
|
||||||
'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", …
|
'format' => $trim($c['format'] ?? null), // "3 min", "Best of 3", …
|
||||||
|
|||||||
@ -3,9 +3,16 @@
|
|||||||
/videos?filter=playlists. Wrapped in @once so it emits only once. --}}
|
/videos?filter=playlists. Wrapped in @once so it emits only once. --}}
|
||||||
@once
|
@once
|
||||||
<style>
|
<style>
|
||||||
/* Base styles for video card */
|
/* Base styles for video card — every card in the grid renders at the same
|
||||||
|
overall size regardless of type (video / shorts / match / playlist). The
|
||||||
|
grid handles equal widths; height parity is enforced here by making the
|
||||||
|
card a column-flex container, the thumb a strict 16/9 box, and every
|
||||||
|
text row a fixed line-height so missing channel/meta rows never shrink
|
||||||
|
the card. */
|
||||||
.yt-video-card {
|
.yt-video-card {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-video-thumb {
|
.yt-video-card .yt-video-thumb {
|
||||||
@ -61,7 +68,11 @@
|
|||||||
.yt-video-card .yt-video-thumb video {
|
.yt-video-card .yt-video-thumb video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
/* Match the still-image thumbnail: always cover the 16/9 box so
|
||||||
|
portrait and landscape sources render at the same visible size,
|
||||||
|
instead of letterboxing (which made portrait previews look
|
||||||
|
smaller than landscape ones). */
|
||||||
|
object-fit: cover;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -212,6 +223,19 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
/* Hard-cap the info block to guarantee every card has the same total
|
||||||
|
height (thumb + info). Overflow hidden so any accidental extra
|
||||||
|
row can't push the card taller than its neighbours. */
|
||||||
|
height: 76px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.yt-video-card .yt-video-details {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-channel-icon {
|
.yt-video-card .yt-channel-icon {
|
||||||
@ -237,20 +261,28 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
/* Single-line, no wrapping — overflow ellipses. */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card .yt-video-title a {
|
.yt-video-card .yt-video-title a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: flex;
|
/* Block-level with nowrap so ellipsis actually clips the excess.
|
||||||
align-items: baseline;
|
Inline-flex would let the children overflow past the parent. */
|
||||||
gap: 5px;
|
display: block;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
/* Inline children (flags, corner-colored fighter spans) must not break
|
||||||
|
the single-line layout. */
|
||||||
|
.yt-video-card .yt-video-title a > * { vertical-align: middle; }
|
||||||
|
.yt-video-card .yt-video-title a .fi { display: inline-block; }
|
||||||
.vc-lang-flag {
|
.vc-lang-flag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
@ -274,6 +306,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
line-height: 22px;
|
||||||
|
height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card a.yt-channel-name {
|
.yt-video-card a.yt-channel-name {
|
||||||
|
|||||||
@ -27,7 +27,7 @@
|
|||||||
// are hidden by `.vsm-nullable:empty` / `.vsm-nullable-hide` in the
|
// are hidden by `.vsm-nullable:empty` / `.vsm-nullable-hide` in the
|
||||||
// CSS below. Same treatment as the full-page VS.
|
// CSS below. Same treatment as the full-page VS.
|
||||||
$vsm_event = $vsm_str($vsm_hd['championship'] ?? '');
|
$vsm_event = $vsm_str($vsm_hd['championship'] ?? '');
|
||||||
$vsm_stage = $vsm_str($vsm_hd['division'] ?? '') ?: $vsm_str($vsm_hd['format'] ?? '');
|
$vsm_stage = $vsm_str($vsm_hd['stage'] ?? '');
|
||||||
$vsm_weight = $vsm_str($vsm_hd['weight_category'] ?? '');
|
$vsm_weight = $vsm_str($vsm_hd['weight_category'] ?? '');
|
||||||
$vsm_matchNo = $vsm_str($vsm_hd['match_number'] ?? '');
|
$vsm_matchNo = $vsm_str($vsm_hd['match_number'] ?? '');
|
||||||
$vsm_court = $vsm_str($vsm_hd['court'] ?? '');
|
$vsm_court = $vsm_str($vsm_hd['court'] ?? '');
|
||||||
|
|||||||
@ -41,6 +41,38 @@ $langFlag = $forceTrackFlag ?: ($video ? Languages::flag($video->language) : nul
|
|||||||
|
|
||||||
$showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#';
|
$showUrl = $video ? route('videos.show', $video) . ($forceTrackId ? ('?track=' . $forceTrackId) : '') : '#';
|
||||||
|
|
||||||
|
// Composed display title for match videos — matches the header on the
|
||||||
|
// match page: "{match title} – {blue flag+name} vs {red flag+name}
|
||||||
|
// ({weight})", with blue/red corner colours and inline flags. Falls
|
||||||
|
// back to plain $video->title for non-match cards.
|
||||||
|
$displayTitle = $video?->title;
|
||||||
|
$displayTitleHtml = null;
|
||||||
|
if ($video && $video->type === 'match' && $video->sportsMatch) {
|
||||||
|
$hdCard = $video->sportsMatch->headerData();
|
||||||
|
$blueN = $hdCard['blue']['name'] ?? null;
|
||||||
|
$redN = $hdCard['red']['name'] ?? null;
|
||||||
|
$blueF = $hdCard['blue']['flag'] ?? null;
|
||||||
|
$redF = $hdCard['red']['flag'] ?? null;
|
||||||
|
if ($blueN || $redN) {
|
||||||
|
$flagStyle = 'width:16px;height:12px;border-radius:2px;display:inline-block;vertical-align:middle;margin-right:4px;';
|
||||||
|
$mkSide = function ($name, $flag, $color) use ($flagStyle) {
|
||||||
|
if (!$name) return '';
|
||||||
|
$flagHtml = $flag ? '<span class="fi fi-'.e($flag).'" style="'.$flagStyle.'"></span>' : '';
|
||||||
|
return '<span style="color:'.$color.';font-weight:600;">'.$flagHtml.e($name).'</span>';
|
||||||
|
};
|
||||||
|
$blueHtml = $mkSide($blueN, $blueF, '#2563eb');
|
||||||
|
$redHtml = $mkSide($redN, $redF, '#ef4444');
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
if (!empty($video->title)) $parts[] = e(strtoupper($video->title)) . ' –';
|
||||||
|
if ($blueHtml && $redHtml) $parts[] = $blueHtml . ' <span>vs</span> ' . $redHtml;
|
||||||
|
elseif ($blueHtml) $parts[] = $blueHtml;
|
||||||
|
elseif ($redHtml) $parts[] = $redHtml;
|
||||||
|
if (!empty($hdCard['weight_category'])) $parts[] = '(' . e($hdCard['weight_category']) . ')';
|
||||||
|
$displayTitleHtml = implode(' ', $parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Size classes
|
// Size classes
|
||||||
$sizeClasses = match($size) {
|
$sizeClasses = match($size) {
|
||||||
'small' => 'yt-video-card-sm',
|
'small' => 'yt-video-card-sm',
|
||||||
@ -113,7 +145,11 @@ $sizeClasses = match($size) {
|
|||||||
@if($langFlag)
|
@if($langFlag)
|
||||||
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
|
<span class="fi fi-{{ $langFlag }} vc-lang-flag"></span>
|
||||||
@endif
|
@endif
|
||||||
{{ $video->title ?? 'Untitled Video' }}
|
@if($displayTitleHtml)
|
||||||
|
{!! $displayTitleHtml !!}
|
||||||
|
@else
|
||||||
|
{{ $displayTitle ?? 'Untitled Video' }}
|
||||||
|
@endif
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
@if($video && $video->user)
|
@if($video && $video->user)
|
||||||
|
|||||||
@ -7,18 +7,28 @@
|
|||||||
/* ── Regular video grid ── */
|
/* ── Regular video grid ── */
|
||||||
.yt-video-grid {
|
.yt-video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
/* minmax(0, 1fr) keeps every column exactly equal in width.
|
||||||
|
Plain 1fr lets a track grow if a child's min-content is wider
|
||||||
|
than the share, which was making the 3rd column wider (and
|
||||||
|
therefore taller, since the thumb is 16/9) than the first two. */
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
|
align-items: stretch;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
|
.yt-video-grid > .yt-video-card { height: 100%; min-width: 0; }
|
||||||
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
|
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: minmax(0, 1fr); gap: 14px; } }
|
||||||
|
|
||||||
|
|
||||||
/* ── Thumbnail orientation fix ── */
|
/* ── Thumbnail orientation fix ── */
|
||||||
|
|
||||||
|
|
||||||
/* ── Thumbnail orientation fix ── */
|
/* ── Thumbnail orientation fix ──
|
||||||
.yt-video-card .yt-video-thumb img { object-fit: cover; }
|
Always cover so every thumbnail visually fills its 16/9 box the
|
||||||
|
same way. `!important` beats any leftover inline style from the
|
||||||
|
old adjust-fit script (cached page in the wild). */
|
||||||
|
.yt-video-card .yt-video-thumb img { object-fit: cover !important; }
|
||||||
|
|
||||||
/* ── Search result helpers ── */
|
/* ── Search result helpers ── */
|
||||||
.search-info { margin-bottom: 20px; padding: 16px; background: var(--bg-secondary); border-radius: 12px; }
|
.search-info { margin-bottom: 20px; padding: 16px; background: var(--bg-secondary); border-radius: 12px; }
|
||||||
@ -207,18 +217,11 @@
|
|||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
/* Thumbnails render at a uniform size: the .yt-video-thumb box is a
|
||||||
function adjustPlThumb(img) {
|
strict 16/9 container and every image inside it uses `object-fit:
|
||||||
img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover';
|
cover` (set in the shared card CSS) so portrait/square art fills the
|
||||||
}
|
frame instead of shrinking with letterboxing. No per-image adjustment
|
||||||
document.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
|
needed. */
|
||||||
if (img.complete && img.naturalWidth) {
|
|
||||||
adjustPlThumb(img);
|
|
||||||
} else {
|
|
||||||
img.addEventListener('load', function () { adjustPlThumb(img); });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
/* ─── Filter-bar horizontal scroll ─── */
|
/* ─── Filter-bar horizontal scroll ─── */
|
||||||
(function () {
|
(function () {
|
||||||
@ -301,13 +304,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-run any grid-scoped init that the page's other IIFE did on first load.
|
// No per-image sizing needed — CSS `object-fit: cover` on
|
||||||
function reinitGrid(root) {
|
// `.yt-video-thumb img` gives every card the same visible image size.
|
||||||
root.querySelectorAll('.yt-video-thumb img').forEach(function (img) {
|
function reinitGrid(_root) {}
|
||||||
function fit() { img.style.objectFit = img.naturalWidth < img.naturalHeight ? 'contain' : 'cover'; }
|
|
||||||
if (img.complete && img.naturalWidth) fit(); else img.addEventListener('load', fit);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentReq = 0;
|
var currentReq = 0;
|
||||||
async function swapTo(url, pushHist) {
|
async function swapTo(url, pushHist) {
|
||||||
|
|||||||
@ -21,7 +21,9 @@
|
|||||||
$vsStr = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
|
$vsStr = fn ($x) => (is_string($x) && trim($x) !== '') ? trim($x) : '';
|
||||||
|
|
||||||
$vsEvent = $vsStr($hd['championship'] ?? '');
|
$vsEvent = $vsStr($hd['championship'] ?? '');
|
||||||
$vsStage = $vsStr($hd['division'] ?? '') ?: $vsStr($hd['format'] ?? '');
|
// Sub-header stage between the two yellow lines above the weight class.
|
||||||
|
// Sourced from headerData().stage (championship_name → division → format).
|
||||||
|
$vsStage = $vsStr($hd['stage'] ?? '');
|
||||||
$vsWeight = $vsStr($hd['weight_category'] ?? '');
|
$vsWeight = $vsStr($hd['weight_category'] ?? '');
|
||||||
$vsMatchNo = $vsStr($hd['match_number'] ?? '');
|
$vsMatchNo = $vsStr($hd['match_number'] ?? '');
|
||||||
$vsCourt = $vsStr($hd['court'] ?? '');
|
$vsCourt = $vsStr($hd['court'] ?? '');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user