Match videos: composed VS-card OG preview + richer share metadata
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) <[email protected]>
This commit is contained in:
1 parent
665bad76bf
commit
3e18bd2150
14 files changed
+3562
-21
No files matched your search
@@ -0,0 +1,40 @@
|
||||
@extends('admin.layout')
|
||||
|
||||
@section('title', 'OG Previews')
|
||||
|
||||
@section('content')
|
||||
<div style="padding: 24px;">
|
||||
<h1 style="margin: 0 0 8px; color: #fff;">Generated VS Cards</h1>
|
||||
<p style="color: #aaa; margin: 0 0 20px;">
|
||||
These are the 1200×630 PNGs that WhatsApp, Facebook, and Twitter show
|
||||
when a sports match video is shared. Generated on save; regenerated on
|
||||
edit. Filename is <code>match-{video_id}-{updated_at}.png</code>.
|
||||
</p>
|
||||
|
||||
@if($files->isEmpty())
|
||||
<p style="color: #aaa;">No previews cached yet. Save a match video to generate one.</p>
|
||||
@else
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 20px;">
|
||||
@foreach($files as $f)
|
||||
<div style="background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden;">
|
||||
<a href="{{ route('admin.og-previews.file', $f['name']) }}" target="_blank" style="display: block; background: #000;">
|
||||
<img src="{{ route('admin.og-previews.file', $f['name']) }}"
|
||||
alt="{{ $f['name'] }}"
|
||||
style="display: block; width: 100%; height: auto;">
|
||||
</a>
|
||||
<div style="padding: 12px 14px; color: #ccc; font-size: 13px;">
|
||||
<div style="font-weight: 600; color: #fff;">Video #{{ $f['id'] }}</div>
|
||||
<div style="color: #888; margin-top: 4px; font-family: monospace; font-size: 11px; word-break: break-all;">
|
||||
{{ $f['name'] }}
|
||||
</div>
|
||||
<div style="color: #888; margin-top: 4px;">
|
||||
{{ number_format($f['size'] / 1024, 1) }} KB ·
|
||||
{{ \Carbon\Carbon::createFromTimestamp($f['mtime'])->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -15,6 +15,28 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* When the 3-dot menu is open, promote this card above its siblings so
|
||||
the dropdown-menu (Bootstrap default z-index 1000) isn't visually
|
||||
covered by later cards in the grid. Cards are position:static by
|
||||
default, so siblings later in the DOM naturally paint on top even
|
||||
though the dropdown itself has a high z-index — the stacking is
|
||||
compared at the card level, not the dropdown level. `:has()` triggers
|
||||
only while a menu is `.show`ing, so this doesn't affect the normal
|
||||
grid layout. */
|
||||
.yt-video-card:has(.dropdown-menu.show) {
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Same fix for the ⋮ menu container — .yt-video-info has overflow:hidden
|
||||
(to hard-cap card height for grid parity), which was clipping the
|
||||
dropdown's bottom edge. Allow the dropdown to escape by giving its
|
||||
wrapper a stacking context and letting overflow leak upward from that
|
||||
ancestor only when a menu is open. */
|
||||
.yt-video-card .yt-video-info:has(.dropdown-menu.show) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
|
||||
@@ -205,6 +205,68 @@
|
||||
background: radial-gradient(120% 90% at 50% 40%, #16161f 0%, #0a0a0e 65%, #050507 100%);
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
/* Hidden until JS's fit() has measured the parent and set the real
|
||||
--vs-mini-scale. Without this, a card whose parent had 0 height at
|
||||
registration time (grid still resolving on mobile) would paint the
|
||||
stage at the fallback 0.5 scale — larger than the ~640×360 mobile
|
||||
thumb — clipping to just the center (looks like a broken card). */
|
||||
visibility: hidden;
|
||||
}
|
||||
.vs-mini.vs-mini-fit .vs-mini-stage { visibility: visible; }
|
||||
|
||||
/* ══ Touch / no-hover devices: skip the orchestrated intro entirely and
|
||||
render every element in its final state. The intro relies on ~1.3s
|
||||
of CSS animation-delays; on mobile it was fragile — anything that
|
||||
interrupted timing (late layout resolve, ResizeObserver refires
|
||||
when the browser toolbar hides/shows, portrait class toggles that
|
||||
swap `vsmPanelL` ↔ `vsmPanelT`) left cards frozen mid-animation.
|
||||
On mobile users are scrolling, not hovering to watch a preview,
|
||||
so the intro adds no value — show the final composition instead. ══ */
|
||||
/* Same "no-animation, final-state" treatment when the parent explicitly
|
||||
requests it — used by the OG-image render route so Browsershot
|
||||
screenshots the composed final frame regardless of what Chromium
|
||||
thinks its hover capability is. */
|
||||
.vs-mini-static,
|
||||
.vs-mini-static .vs-mini-panel,
|
||||
.vs-mini-static .vs-mini-info,
|
||||
.vs-mini-static .vs-mini-top,
|
||||
.vs-mini-static .vs-mini-center,
|
||||
.vs-mini-static .vs-mini-bottom {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.vs-mini-static .vs-mini-panel-red { transform: translateX(0) !important; }
|
||||
.vs-mini-static .vs-mini-panel-blue { transform: translateX(0) !important; }
|
||||
.vs-mini-static .vs-mini-top { transform: translateX(-50%) !important; }
|
||||
.vs-mini-static .vs-mini-bottom { transform: translateX(-50%) !important; }
|
||||
.vs-mini-static .vs-mini-center { transform: translate(-50%, -52%) !important; }
|
||||
.vs-mini-static .vs-mini-photo,
|
||||
.vs-mini-static .vs-mini-word,
|
||||
.vs-mini-static .vs-mini-shine { animation: none !important; }
|
||||
|
||||
@media (hover: none) {
|
||||
.vs-mini,
|
||||
.vs-mini .vs-mini-panel,
|
||||
.vs-mini .vs-mini-info,
|
||||
.vs-mini .vs-mini-top,
|
||||
.vs-mini .vs-mini-center,
|
||||
.vs-mini .vs-mini-bottom {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.vs-mini .vs-mini-panel-red { transform: translateX(0) !important; }
|
||||
.vs-mini .vs-mini-panel-blue { transform: translateX(0) !important; }
|
||||
.vs-mini .vs-mini-top { transform: translateX(-50%) !important; }
|
||||
.vs-mini .vs-mini-bottom { transform: translateX(-50%) !important; }
|
||||
.vs-mini .vs-mini-center { transform: translate(-50%, -52%) !important; }
|
||||
/* Also skip the ambient photo drift + VS pulse/shine — they were
|
||||
running fine, but there's no need to burn cycles on animation
|
||||
loops for a scrolling grid on mobile. */
|
||||
.vs-mini .vs-mini-photo,
|
||||
.vs-mini .vs-mini-word,
|
||||
.vs-mini .vs-mini-shine { animation: none !important; }
|
||||
}
|
||||
|
||||
/* Animations only run while the .vs-mini-run class is set (removed +
|
||||
@@ -489,6 +551,9 @@
|
||||
const sw = portrait ? 1080 : 1920;
|
||||
const sh = portrait ? 1920 : 1080;
|
||||
el.style.setProperty('--vs-mini-scale', Math.min(w / sw, h / sh));
|
||||
// Reveal the stage — hidden by default so the CSS's fallback
|
||||
// scale never paints a pre-fit oversized/clipped stage.
|
||||
el.classList.add('vs-mini-fit');
|
||||
}
|
||||
|
||||
const ro = ('ResizeObserver' in window) ? new ResizeObserver(entries => {
|
||||
@@ -518,19 +583,22 @@
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Restart entrance animations whenever the mouse leaves a card thumb
|
||||
// that has a VS mini in it. removing + re-adding the class in the
|
||||
// next frame forces the CSS animation to play from the start.
|
||||
document.addEventListener('mouseleave', (e) => {
|
||||
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
|
||||
if (!thumb) return;
|
||||
const vsm = thumb.querySelector(':scope > .vs-mini');
|
||||
if (!vsm) return;
|
||||
vsm.classList.remove('vs-mini-run');
|
||||
// Reflow-then-reapply — CSS animations only restart when the
|
||||
// running class is removed and reintroduced across a frame.
|
||||
void vsm.offsetWidth;
|
||||
vsm.classList.add('vs-mini-run');
|
||||
}, true);
|
||||
// that has a VS mini in it. Only bind on real hover devices — on touch,
|
||||
// Chrome synthesizes mouseleave on tap-lift and during scroll, which
|
||||
// kept restarting animations and leaving cards frozen mid-intro
|
||||
// (elements at opacity:0 during the 0.5–1.3s animation-delay window).
|
||||
const hasHover = window.matchMedia && window.matchMedia('(hover: hover)').matches;
|
||||
if (hasHover) {
|
||||
document.addEventListener('mouseleave', (e) => {
|
||||
const thumb = e.target?.classList?.contains('yt-video-thumb') ? e.target : null;
|
||||
if (!thumb) return;
|
||||
const vsm = thumb.querySelector(':scope > .vs-mini');
|
||||
if (!vsm) return;
|
||||
vsm.classList.remove('vs-mini-run');
|
||||
void vsm.offsetWidth;
|
||||
vsm.classList.add('vs-mini-run');
|
||||
}, true);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@endonce
|
||||
@@ -0,0 +1,80 @@
|
||||
{{-- ══════════════════════════════════════════════════════════════════════
|
||||
VS OG — 1200×630 standalone page consumed by Browsershot for the
|
||||
social-share preview image. Renders the exact same .vs-mini
|
||||
composition used on video cards, but at fixed 1200×630 with
|
||||
animations disabled (via .vs-mini-static) so the screenshot always
|
||||
captures the final composed frame.
|
||||
|
||||
Public but noindex — only invoked by the internal ogImage()
|
||||
controller through a loopback HTTP request from Browsershot.
|
||||
════════════════════════════════════════════════════════════════════════ --}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OG Preview</title>
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Barlow+Condensed:wght@400;600;700;800&display=swap">
|
||||
{{-- Force http:// on the flag CSS URL — Laravel's asset() caches the
|
||||
scheme on first use (https from AppServiceProvider::forceScheme) and
|
||||
URL::forceScheme('http') in the controller can't override that
|
||||
cache. Chromium hits this endpoint via loopback where https isn't
|
||||
reachable, so we build the URL ourselves. --}}
|
||||
<link rel="stylesheet" href="http://{{ request()->getHost() }}/vendor/flag-icons/css/flag-icons.min.css">
|
||||
<style>
|
||||
/* Canvas is a true 16:9 at 1200×675 so the 1920×1080 vs-mini stage
|
||||
maps cleanly with scale 0.625 (no overflow on either axis). WhatsApp/
|
||||
Facebook accept any aspect ratio for og:image; 1200×675 is inside
|
||||
their recommended range and preserves the full VS composition. */
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
width: 1200px; height: 675px;
|
||||
background: #050507;
|
||||
overflow: hidden;
|
||||
}
|
||||
.og-frame {
|
||||
position: relative;
|
||||
width: 1200px; height: 675px;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Same wrapper the vs-mini expects — .yt-video-thumb is what the mini's
|
||||
`position: absolute; inset: 0` anchors to, and its aspect drives fit(). */
|
||||
.og-frame .yt-video-thumb {
|
||||
position: relative;
|
||||
width: 100%; height: 100%;
|
||||
overflow: hidden;
|
||||
background: #050507;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="og-frame">
|
||||
<div class="yt-video-thumb">
|
||||
{{-- vs-mini reads $video->sportsMatch->headerData() to compose. --}}
|
||||
@include('components.partials.vs-mini', ['video' => $video])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Force the mini into its final composed state — no animation-delay
|
||||
// window, no ResizeObserver races. We also inline the correct scale for
|
||||
// a 1200×630 frame (1200/1920 = 0.625) so we don't depend on the fit()
|
||||
// callback firing before Browsershot captures.
|
||||
(function () {
|
||||
const el = document.querySelector('.vs-mini');
|
||||
if (!el) return;
|
||||
el.classList.add('vs-mini-static', 'vs-mini-fit');
|
||||
el.style.setProperty('--vs-mini-scale', String(1200 / 1920));
|
||||
// Signal readiness for Browsershot's waitUntilNetworkIdle to observe
|
||||
// once webfonts are done rendering.
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(() => { window.__vsOgReady = true; });
|
||||
} else {
|
||||
window.__vsOgReady = true;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,18 +3,87 @@
|
||||
@section('title', ($video->title ?? 'Match Video') . ' | ' . config('app.name'))
|
||||
|
||||
@push('head')
|
||||
@php
|
||||
// ── Build a rich share-preview description from the match record ──
|
||||
// Layout the user requested:
|
||||
// Abdulla Bassam vs Zakaria Shuaiter
|
||||
// National Team Selection Trials · Match# 13 · Court 1 · Anoj K.R - Mahadwa K
|
||||
// 37 views · Aug 09, 2026
|
||||
// WhatsApp collapses newlines to spaces but respects `\n`; other
|
||||
// platforms (Facebook, Telegram, Slack) render them as line breaks.
|
||||
$hd = $video->sportsMatch?->headerData() ?? [];
|
||||
$redName = trim((string) ($hd['red']['name'] ?? ''));
|
||||
$blueName = trim((string) ($hd['blue']['name'] ?? ''));
|
||||
$redFlag = strtoupper(trim((string) ($hd['red']['flag'] ?? '')));
|
||||
$blueFlag = strtoupper(trim((string) ($hd['blue']['flag'] ?? '')));
|
||||
$event = trim((string) ($hd['championship'] ?? ''));
|
||||
$matchNo = trim((string) ($hd['match_number'] ?? ''));
|
||||
$court = trim((string) ($hd['court'] ?? ''));
|
||||
$referee = trim((string) ($hd['referee']['name'] ?? ''));
|
||||
$weight = trim((string) ($hd['weight_category'] ?? ''));
|
||||
|
||||
// ISO2 country code → Unicode regional-indicator emoji flag (e.g. "BH" → 🇧🇭).
|
||||
// Emoji flags are the only way to embed a flag in a text-only <meta>
|
||||
// field. They render on WhatsApp, Telegram, iMessage, and mobile
|
||||
// Facebook — the platforms people actually preview shared links in.
|
||||
// (The self-hosted flag-icons library used elsewhere in the app is
|
||||
// for HTML; meta descriptions can't carry markup.)
|
||||
$flagEmoji = function (string $c): string {
|
||||
if (! preg_match('/^[A-Z]{2}$/', $c)) return '';
|
||||
return mb_chr(0x1F1E6 + (ord($c[0]) - 65), 'UTF-8')
|
||||
. mb_chr(0x1F1E6 + (ord($c[1]) - 65), 'UTF-8');
|
||||
};
|
||||
$blueTag = trim(($flagEmoji($blueFlag) . ' ' . $blueName));
|
||||
$redTag = trim(($flagEmoji($redFlag) . ' ' . $redName));
|
||||
|
||||
// Line 1 — VS matchup (blue vs red, matching the on-card layout).
|
||||
$ogLineVs = '';
|
||||
if ($blueTag !== '' && $redTag !== '') {
|
||||
$ogLineVs = $blueTag . ' vs ' . $redTag;
|
||||
} elseif ($blueTag !== '' || $redTag !== '') {
|
||||
$ogLineVs = $blueTag ?: $redTag;
|
||||
}
|
||||
|
||||
// Line 2 — event context, joined with " · " so it reads well even
|
||||
// when a platform strips the newlines.
|
||||
$ogParts = array_values(array_filter([
|
||||
$event,
|
||||
$weight,
|
||||
$matchNo !== '' ? 'Match# ' . $matchNo : '',
|
||||
$court !== '' ? 'Court ' . $court : '',
|
||||
$referee,
|
||||
], fn ($s) => $s !== ''));
|
||||
$ogLineCtx = implode(' · ', $ogParts);
|
||||
|
||||
// Line 3 — engagement + date, mirroring the video meta row.
|
||||
$viewsLbl = number_format((int) ($video->view_count ?? 0)) . ' views';
|
||||
$dateLbl = optional($video->created_at)->format('M d, Y');
|
||||
$ogLineMeta = trim($viewsLbl . ($dateLbl ? ' · ' . $dateLbl : ''));
|
||||
|
||||
$ogDescription = trim(implode("\n", array_filter([$ogLineVs, $ogLineCtx, $ogLineMeta])));
|
||||
// Fall back to the raw description if the match record is empty.
|
||||
if ($ogDescription === '') {
|
||||
$ogDescription = Str::limit(strip_tags($video->description ?? (config('app.name') . ' — watch now')), 200);
|
||||
}
|
||||
|
||||
// Append a version query so WhatsApp/Facebook recache when the match
|
||||
// (fighters, flags, event, weight) is edited. The ogImage controller
|
||||
// internally keys its file cache by the same stamp.
|
||||
$ogStamp = optional($video->sportsMatch?->updated_at ?? $video->updated_at)->timestamp;
|
||||
$ogUrl = route('videos.ogImage', $video) . ($ogStamp ? '?v=' . $ogStamp : '');
|
||||
@endphp
|
||||
<meta property="og:title" content="{{ $video->title }}">
|
||||
<meta property="og:description" content="{{ Str::limit(strip_tags($video->description ?? config('app.name') . ' — watch now'), 200) }}">
|
||||
<meta property="og:image" content="{{ route('videos.ogImage', $video) }}">
|
||||
<meta property="og:description" content="{{ $ogDescription }}">
|
||||
<meta property="og:image" content="{{ $ogUrl }}">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:height" content="675">
|
||||
<meta property="og:url" content="{{ $video->share_url }}">
|
||||
<meta property="og:type" content="video.other">
|
||||
<meta property="og:site_name" content="{{ config('app.name') }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ $video->title }}">
|
||||
<meta name="twitter:description" content="{{ Str::limit(strip_tags($video->description ?? ''), 200) }}">
|
||||
<meta name="twitter:image" content="{{ route('videos.ogImage', $video) }}">
|
||||
<meta name="twitter:description" content="{{ $ogDescription }}">
|
||||
<meta name="twitter:image" content="{{ $ogUrl }}">
|
||||
@endpush
|
||||
|
||||
@section('extra_styles')
|
||||
|
||||
Reference in new issue
Block a user