Upload folder using huggingface_hub
Browse files- src/fifa/backend/main.py +76 -33
- src/fifa/frontend/src/views/HighlightsView.vue +129 -143
src/fifa/backend/main.py
CHANGED
|
@@ -159,44 +159,87 @@ def _fetch_knockout() -> dict:
|
|
| 159 |
return buckets
|
| 160 |
|
| 161 |
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
|
| 165 |
-
def
|
|
|
|
|
|
|
|
|
|
| 166 |
try:
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
except Exception:
|
| 171 |
-
|
|
|
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
"espn_url": web,
|
| 195 |
-
"duration": v.get("duration", 0),
|
| 196 |
-
})
|
| 197 |
-
if len(clips) >= 4:
|
| 198 |
-
return clips
|
| 199 |
-
return clips
|
| 200 |
|
| 201 |
|
| 202 |
@app.get("/api/highlights")
|
|
|
|
| 159 |
return buckets
|
| 160 |
|
| 161 |
|
| 162 |
+
_ESPN_BASE_URL = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world"
|
| 163 |
+
_HL_CACHE: list = []
|
| 164 |
+
_HL_CACHE_TS: datetime | None = None
|
| 165 |
+
_HL_TTL_SECS = 1800 # 30 min — YouTube search is slow, don't hammer it
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _get_recent_completed(days_back: int = 7, limit: int = 4) -> list[dict]:
|
| 169 |
+
from datetime import timedelta, timezone
|
| 170 |
+
utc_now = datetime.now(timezone.utc)
|
| 171 |
+
matches: list[dict] = []
|
| 172 |
+
for delta in range(0, days_back + 1):
|
| 173 |
+
target = utc_now - timedelta(days=delta)
|
| 174 |
+
date_str = target.strftime("%Y%m%d")
|
| 175 |
+
try:
|
| 176 |
+
resp = requests.get(f"{_ESPN_BASE_URL}/scoreboard?dates={date_str}", timeout=15)
|
| 177 |
+
if resp.status_code != 200:
|
| 178 |
+
continue
|
| 179 |
+
for ev in resp.json().get("events", []):
|
| 180 |
+
comp = ev.get("competitions", [{}])[0]
|
| 181 |
+
status = comp.get("status", {}).get("type", {}).get("description", "").lower()
|
| 182 |
+
if not any(s in status for s in ["final", "ft", "ended", "full time"]):
|
| 183 |
+
continue
|
| 184 |
+
competitors = comp.get("competitors", [{}, {}])
|
| 185 |
+
home = next((c for c in competitors if c.get("homeAway") == "home"), competitors[0])
|
| 186 |
+
away = next((c for c in competitors if c.get("homeAway") == "away"), competitors[-1])
|
| 187 |
+
home_team = home.get("team", {})
|
| 188 |
+
away_team = away.get("team", {})
|
| 189 |
+
matches.append({
|
| 190 |
+
"home_team": home_team.get("displayName", ""),
|
| 191 |
+
"home_logo": (home_team.get("logos") or [{}])[0].get("href", ""),
|
| 192 |
+
"home_score": home.get("score", ""),
|
| 193 |
+
"away_team": away_team.get("displayName", ""),
|
| 194 |
+
"away_logo": (away_team.get("logos") or [{}])[0].get("href", ""),
|
| 195 |
+
"away_score": away.get("score", ""),
|
| 196 |
+
"date": ev.get("date", "")[:10],
|
| 197 |
+
"round": ev.get("name", ""),
|
| 198 |
+
})
|
| 199 |
+
if len(matches) >= limit:
|
| 200 |
+
return matches
|
| 201 |
+
except Exception:
|
| 202 |
+
continue
|
| 203 |
+
return matches
|
| 204 |
|
| 205 |
|
| 206 |
+
def _yt_search(home: str, away: str) -> tuple[str | None, str | None]:
|
| 207 |
+
"""Return (youtube_url, video_id) via DDGS, None if not found."""
|
| 208 |
+
import re
|
| 209 |
+
query = f"FIFA World Cup 2026 {home} vs {away} highlights"
|
| 210 |
try:
|
| 211 |
+
from ddgs import DDGS
|
| 212 |
+
with DDGS() as ddgs:
|
| 213 |
+
for r in ddgs.text(query, max_results=8):
|
| 214 |
+
url = r.get("href", "")
|
| 215 |
+
m = re.search(r"(?:v=|youtu\.be/)([a-zA-Z0-9_-]{11})", url)
|
| 216 |
+
if m:
|
| 217 |
+
return url, m.group(1)
|
| 218 |
except Exception:
|
| 219 |
+
pass
|
| 220 |
+
return None, None
|
| 221 |
|
| 222 |
+
|
| 223 |
+
def _fetch_highlights() -> list[dict]:
|
| 224 |
+
global _HL_CACHE, _HL_CACHE_TS
|
| 225 |
+
now = datetime.now()
|
| 226 |
+
if _HL_CACHE and _HL_CACHE_TS and (now - _HL_CACHE_TS).total_seconds() < _HL_TTL_SECS:
|
| 227 |
+
return _HL_CACHE
|
| 228 |
+
|
| 229 |
+
matches = _get_recent_completed(days_back=7, limit=4)
|
| 230 |
+
results = []
|
| 231 |
+
for m in matches:
|
| 232 |
+
_, vid_id = _yt_search(m["home_team"], m["away_team"])
|
| 233 |
+
results.append({
|
| 234 |
+
**m,
|
| 235 |
+
"youtube_id": vid_id,
|
| 236 |
+
"youtube_url": f"https://www.youtube.com/watch?v={vid_id}" if vid_id else None,
|
| 237 |
+
"thumbnail": f"https://img.youtube.com/vi/{vid_id}/maxresdefault.jpg" if vid_id else None,
|
| 238 |
+
})
|
| 239 |
+
|
| 240 |
+
_HL_CACHE = results
|
| 241 |
+
_HL_CACHE_TS = now
|
| 242 |
+
return results
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
|
| 245 |
@app.get("/api/highlights")
|
src/fifa/frontend/src/views/HighlightsView.vue
CHANGED
|
@@ -5,7 +5,7 @@ import { getHighlights } from '../services/api.js'
|
|
| 5 |
const loading = ref(true)
|
| 6 |
const error = ref(null)
|
| 7 |
const clips = ref([])
|
| 8 |
-
const playing = ref(null)
|
| 9 |
|
| 10 |
async function load() {
|
| 11 |
try {
|
|
@@ -18,19 +18,15 @@ async function load() {
|
|
| 18 |
}
|
| 19 |
}
|
| 20 |
|
| 21 |
-
function
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
return `${m}:${s}`
|
| 26 |
}
|
| 27 |
|
| 28 |
-
function
|
| 29 |
-
if (
|
| 30 |
-
|
| 31 |
-
} else {
|
| 32 |
-
playing.value = clip.id
|
| 33 |
-
}
|
| 34 |
}
|
| 35 |
|
| 36 |
onMounted(load)
|
|
@@ -40,12 +36,12 @@ onMounted(load)
|
|
| 40 |
<div class="highlights-view">
|
| 41 |
<div class="view-header">
|
| 42 |
<h1 class="view-title">🎬 Match Highlights</h1>
|
| 43 |
-
<span class="subtitle">
|
| 44 |
</div>
|
| 45 |
|
| 46 |
<div v-if="loading" class="loading-spinner">
|
| 47 |
<div class="spinner"></div>
|
| 48 |
-
<span>
|
| 49 |
</div>
|
| 50 |
|
| 51 |
<div v-else-if="error" class="empty-state">
|
|
@@ -55,61 +51,77 @@ onMounted(load)
|
|
| 55 |
|
| 56 |
<div v-else-if="clips.length === 0" class="empty-state">
|
| 57 |
<div class="empty-icon">🎬</div>
|
| 58 |
-
<p>No
|
| 59 |
</div>
|
| 60 |
|
| 61 |
<div v-else class="tiles-grid">
|
| 62 |
<div
|
| 63 |
v-for="clip in clips"
|
| 64 |
-
:key="clip.
|
| 65 |
class="tile card"
|
| 66 |
-
:class="{ expanded: playing === clip.
|
| 67 |
>
|
| 68 |
-
<!--
|
| 69 |
-
<div v-if="playing === clip.
|
| 70 |
-
<
|
| 71 |
-
class="player"
|
| 72 |
-
:src="clip.
|
| 73 |
-
|
| 74 |
-
autoplay
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
></video>
|
| 78 |
<button class="close-btn" @click="playing = null">✕ Close</button>
|
| 79 |
</div>
|
| 80 |
|
| 81 |
-
<!-- Thumbnail (
|
| 82 |
-
<div v-else class="thumb-wrap" @click="
|
|
|
|
| 83 |
<img
|
| 84 |
v-if="clip.thumbnail"
|
| 85 |
:src="clip.thumbnail"
|
| 86 |
-
:alt="clip.
|
| 87 |
class="thumb"
|
|
|
|
| 88 |
/>
|
| 89 |
-
<div v-else class="thumb-placeholder">
|
| 90 |
-
|
| 91 |
-
<div class="play-overlay">
|
| 92 |
-
<div class="play-btn">▶</div>
|
| 93 |
-
</div>
|
| 94 |
|
| 95 |
-
<div class="
|
| 96 |
-
|
|
|
|
| 97 |
</div>
|
| 98 |
</div>
|
| 99 |
|
| 100 |
-
<!--
|
| 101 |
-
<div class="
|
| 102 |
-
<
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
<
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
</div>
|
| 114 |
</div>
|
| 115 |
</div>
|
|
@@ -124,7 +136,6 @@ onMounted(load)
|
|
| 124 |
.view-title { font-size: 22px; font-weight: 700; }
|
| 125 |
.subtitle { font-size: 13px; color: var(--text-muted); }
|
| 126 |
|
| 127 |
-
/* 2×2 grid */
|
| 128 |
.tiles-grid {
|
| 129 |
display: grid;
|
| 130 |
grid-template-columns: repeat(2, 1fr);
|
|
@@ -136,23 +147,19 @@ onMounted(load)
|
|
| 136 |
overflow: hidden;
|
| 137 |
display: flex;
|
| 138 |
flex-direction: column;
|
| 139 |
-
transition: transform 0.2s, box-shadow 0.2s;
|
| 140 |
-
}
|
| 141 |
-
.tile.expanded {
|
| 142 |
-
grid-column: 1 / -1; /* full-width when playing */
|
| 143 |
}
|
|
|
|
| 144 |
|
| 145 |
/* Thumbnail */
|
| 146 |
.thumb-wrap {
|
| 147 |
position: relative;
|
| 148 |
-
cursor: pointer;
|
| 149 |
aspect-ratio: 16 / 9;
|
| 150 |
overflow: hidden;
|
| 151 |
background: #000;
|
|
|
|
| 152 |
}
|
| 153 |
.thumb {
|
| 154 |
-
width: 100%;
|
| 155 |
-
height: 100%;
|
| 156 |
object-fit: cover;
|
| 157 |
transition: transform 0.3s, opacity 0.3s;
|
| 158 |
}
|
|
@@ -160,119 +167,98 @@ onMounted(load)
|
|
| 160 |
.thumb-placeholder {
|
| 161 |
width: 100%; height: 100%;
|
| 162 |
display: flex; align-items: center; justify-content: center;
|
| 163 |
-
font-size: 48px;
|
| 164 |
-
background: var(--bg-secondary);
|
| 165 |
}
|
| 166 |
|
| 167 |
.play-overlay {
|
| 168 |
-
position: absolute;
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
align-items: center;
|
| 172 |
-
justify-content: center;
|
| 173 |
-
background: rgba(0,0,0,0.25);
|
| 174 |
transition: background 0.2s;
|
| 175 |
}
|
| 176 |
-
.thumb-wrap:hover .play-overlay { background: rgba(0,0,0,0.
|
|
|
|
| 177 |
|
| 178 |
.play-btn {
|
| 179 |
-
width:
|
| 180 |
border-radius: 50%;
|
| 181 |
-
background: rgba(255,
|
| 182 |
-
color: #
|
| 183 |
-
font-size:
|
| 184 |
display: flex; align-items: center; justify-content: center;
|
| 185 |
-
box-shadow: 0 4px
|
| 186 |
-
transition: transform 0.2s;
|
| 187 |
-
padding-left: 4px;
|
| 188 |
}
|
| 189 |
-
.thumb-wrap:hover .play-btn { transform: scale(1.1); }
|
| 190 |
-
|
| 191 |
-
.
|
| 192 |
-
|
| 193 |
-
bottom: 8px; right: 8px;
|
| 194 |
-
background: rgba(0,0,0,0.75);
|
| 195 |
-
color: #fff;
|
| 196 |
-
font-size: 11px;
|
| 197 |
-
font-weight: 600;
|
| 198 |
-
padding: 2px 7px;
|
| 199 |
-
border-radius: 4px;
|
| 200 |
}
|
| 201 |
|
| 202 |
-
/*
|
| 203 |
.player-wrap { position: relative; background: #000; }
|
| 204 |
-
.player {
|
| 205 |
-
width: 100%;
|
| 206 |
-
aspect-ratio: 16 / 9;
|
| 207 |
-
display: block;
|
| 208 |
}
|
| 209 |
.close-btn {
|
| 210 |
-
position: absolute;
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
border: none;
|
| 215 |
-
border-radius: 6px;
|
| 216 |
-
padding: 5px 12px;
|
| 217 |
-
font-size: 13px;
|
| 218 |
-
cursor: pointer;
|
| 219 |
}
|
| 220 |
-
.close-btn:hover { background: rgba(
|
| 221 |
|
| 222 |
-
/*
|
| 223 |
-
.
|
| 224 |
padding: 12px 14px 14px;
|
| 225 |
-
display: flex;
|
| 226 |
-
flex-direction: column;
|
| 227 |
-
gap: 6px;
|
| 228 |
-
flex: 1;
|
| 229 |
}
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
line-height: 1.4;
|
| 234 |
-
color: var(--text-primary);
|
| 235 |
}
|
| 236 |
-
.
|
| 237 |
-
|
| 238 |
-
color: var(--text-muted);
|
| 239 |
-
line-height: 1.4;
|
| 240 |
-
display: -webkit-box;
|
| 241 |
-
-webkit-line-clamp: 2;
|
| 242 |
-
-webkit-box-orient: vertical;
|
| 243 |
-
overflow: hidden;
|
| 244 |
}
|
| 245 |
-
.
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
.btn-watch {
|
| 251 |
display: inline-flex; align-items: center; gap: 5px;
|
| 252 |
-
padding:
|
| 253 |
-
border
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
color: var(--accent-gold);
|
| 257 |
-
font-size: 13px;
|
| 258 |
-
font-weight: 600;
|
| 259 |
-
cursor: pointer;
|
| 260 |
-
transition: background 0.2s;
|
| 261 |
}
|
| 262 |
-
.btn-watch:hover { background: rgba(255,
|
| 263 |
-
|
| 264 |
-
.btn-espn {
|
| 265 |
display: inline-flex; align-items: center;
|
| 266 |
-
padding:
|
| 267 |
-
border
|
| 268 |
-
|
| 269 |
-
border: 1px solid var(--border);
|
| 270 |
-
color: var(--text-muted);
|
| 271 |
-
font-size: 12px;
|
| 272 |
-
text-decoration: none;
|
| 273 |
transition: border-color 0.2s, color 0.2s;
|
| 274 |
}
|
| 275 |
-
.btn-
|
|
|
|
| 276 |
|
| 277 |
@media (max-width: 640px) {
|
| 278 |
.tiles-grid { grid-template-columns: 1fr; }
|
|
|
|
| 5 |
const loading = ref(true)
|
| 6 |
const error = ref(null)
|
| 7 |
const clips = ref([])
|
| 8 |
+
const playing = ref(null)
|
| 9 |
|
| 10 |
async function load() {
|
| 11 |
try {
|
|
|
|
| 18 |
}
|
| 19 |
}
|
| 20 |
|
| 21 |
+
function scoreLabel(clip) {
|
| 22 |
+
const hs = clip.home_score, as = clip.away_score
|
| 23 |
+
if (hs === '' || hs == null) return 'vs'
|
| 24 |
+
return `${hs} – ${as}`
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
+
function formatDate(d) {
|
| 28 |
+
if (!d) return ''
|
| 29 |
+
return new Date(d + 'T12:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
onMounted(load)
|
|
|
|
| 36 |
<div class="highlights-view">
|
| 37 |
<div class="view-header">
|
| 38 |
<h1 class="view-title">🎬 Match Highlights</h1>
|
| 39 |
+
<span class="subtitle">Last {{ clips.length }} completed matches · YouTube</span>
|
| 40 |
</div>
|
| 41 |
|
| 42 |
<div v-if="loading" class="loading-spinner">
|
| 43 |
<div class="spinner"></div>
|
| 44 |
+
<span>Finding highlight videos…</span>
|
| 45 |
</div>
|
| 46 |
|
| 47 |
<div v-else-if="error" class="empty-state">
|
|
|
|
| 51 |
|
| 52 |
<div v-else-if="clips.length === 0" class="empty-state">
|
| 53 |
<div class="empty-icon">🎬</div>
|
| 54 |
+
<p>No completed matches yet — check back after games finish.</p>
|
| 55 |
</div>
|
| 56 |
|
| 57 |
<div v-else class="tiles-grid">
|
| 58 |
<div
|
| 59 |
v-for="clip in clips"
|
| 60 |
+
:key="clip.home_team + clip.away_team"
|
| 61 |
class="tile card"
|
| 62 |
+
:class="{ expanded: playing === clip.home_team }"
|
| 63 |
>
|
| 64 |
+
<!-- YouTube iframe (when playing) -->
|
| 65 |
+
<div v-if="playing === clip.home_team && clip.youtube_id" class="player-wrap">
|
| 66 |
+
<iframe
|
| 67 |
+
class="yt-player"
|
| 68 |
+
:src="`https://www.youtube-nocookie.com/embed/${clip.youtube_id}?autoplay=1&rel=0`"
|
| 69 |
+
frameborder="0"
|
| 70 |
+
allow="autoplay; encrypted-media; fullscreen"
|
| 71 |
+
allowfullscreen
|
| 72 |
+
></iframe>
|
|
|
|
| 73 |
<button class="close-btn" @click="playing = null">✕ Close</button>
|
| 74 |
</div>
|
| 75 |
|
| 76 |
+
<!-- Thumbnail (when not playing) -->
|
| 77 |
+
<div v-else class="thumb-wrap" @click="clip.youtube_id && (playing = clip.home_team)">
|
| 78 |
+
<!-- YouTube-generated thumbnail -->
|
| 79 |
<img
|
| 80 |
v-if="clip.thumbnail"
|
| 81 |
:src="clip.thumbnail"
|
| 82 |
+
:alt="`${clip.home_team} vs ${clip.away_team}`"
|
| 83 |
class="thumb"
|
| 84 |
+
@error="$event.target.src = `https://img.youtube.com/vi/${clip.youtube_id}/hqdefault.jpg`"
|
| 85 |
/>
|
| 86 |
+
<div v-else class="thumb-placeholder">⚽</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
+
<div class="play-overlay" :class="{ 'no-video': !clip.youtube_id }">
|
| 89 |
+
<div class="play-btn" v-if="clip.youtube_id">▶</div>
|
| 90 |
+
<div class="no-video-msg" v-else>Video not available</div>
|
| 91 |
</div>
|
| 92 |
</div>
|
| 93 |
|
| 94 |
+
<!-- Match info bar -->
|
| 95 |
+
<div class="match-bar">
|
| 96 |
+
<div class="team-row">
|
| 97 |
+
<div class="team-info">
|
| 98 |
+
<img v-if="clip.home_logo" :src="clip.home_logo" :alt="clip.home_team" class="team-logo" />
|
| 99 |
+
<span class="team-name">{{ clip.home_team }}</span>
|
| 100 |
+
</div>
|
| 101 |
+
<span class="score-pill">{{ scoreLabel(clip) }}</span>
|
| 102 |
+
<div class="team-info team-info-right">
|
| 103 |
+
<span class="team-name">{{ clip.away_team }}</span>
|
| 104 |
+
<img v-if="clip.away_logo" :src="clip.away_logo" :alt="clip.away_team" class="team-logo" />
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
<div class="match-meta">
|
| 108 |
+
<span class="match-round">{{ clip.round }}</span>
|
| 109 |
+
<span class="match-date">{{ formatDate(clip.date) }}</span>
|
| 110 |
+
</div>
|
| 111 |
+
<div class="actions">
|
| 112 |
+
<button
|
| 113 |
+
v-if="clip.youtube_id"
|
| 114 |
+
class="btn-watch"
|
| 115 |
+
@click="playing = clip.home_team"
|
| 116 |
+
>▶ Watch Highlights</button>
|
| 117 |
+
<a
|
| 118 |
+
v-if="clip.youtube_url"
|
| 119 |
+
:href="clip.youtube_url"
|
| 120 |
+
target="_blank"
|
| 121 |
+
rel="noopener"
|
| 122 |
+
class="btn-yt"
|
| 123 |
+
>YouTube ↗</a>
|
| 124 |
+
<span v-if="!clip.youtube_id" class="no-vid-label">No highlight found</span>
|
| 125 |
</div>
|
| 126 |
</div>
|
| 127 |
</div>
|
|
|
|
| 136 |
.view-title { font-size: 22px; font-weight: 700; }
|
| 137 |
.subtitle { font-size: 13px; color: var(--text-muted); }
|
| 138 |
|
|
|
|
| 139 |
.tiles-grid {
|
| 140 |
display: grid;
|
| 141 |
grid-template-columns: repeat(2, 1fr);
|
|
|
|
| 147 |
overflow: hidden;
|
| 148 |
display: flex;
|
| 149 |
flex-direction: column;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
}
|
| 151 |
+
.tile.expanded { grid-column: 1 / -1; }
|
| 152 |
|
| 153 |
/* Thumbnail */
|
| 154 |
.thumb-wrap {
|
| 155 |
position: relative;
|
|
|
|
| 156 |
aspect-ratio: 16 / 9;
|
| 157 |
overflow: hidden;
|
| 158 |
background: #000;
|
| 159 |
+
cursor: pointer;
|
| 160 |
}
|
| 161 |
.thumb {
|
| 162 |
+
width: 100%; height: 100%;
|
|
|
|
| 163 |
object-fit: cover;
|
| 164 |
transition: transform 0.3s, opacity 0.3s;
|
| 165 |
}
|
|
|
|
| 167 |
.thumb-placeholder {
|
| 168 |
width: 100%; height: 100%;
|
| 169 |
display: flex; align-items: center; justify-content: center;
|
| 170 |
+
font-size: 48px; background: var(--bg-secondary);
|
|
|
|
| 171 |
}
|
| 172 |
|
| 173 |
.play-overlay {
|
| 174 |
+
position: absolute; inset: 0;
|
| 175 |
+
display: flex; align-items: center; justify-content: center;
|
| 176 |
+
background: rgba(0,0,0,0.2);
|
|
|
|
|
|
|
|
|
|
| 177 |
transition: background 0.2s;
|
| 178 |
}
|
| 179 |
+
.thumb-wrap:hover .play-overlay { background: rgba(0,0,0,0.4); }
|
| 180 |
+
.play-overlay.no-video { cursor: default; }
|
| 181 |
|
| 182 |
.play-btn {
|
| 183 |
+
width: 60px; height: 60px;
|
| 184 |
border-radius: 50%;
|
| 185 |
+
background: rgba(255,0,0,0.88); /* YouTube red */
|
| 186 |
+
color: #fff;
|
| 187 |
+
font-size: 22px;
|
| 188 |
display: flex; align-items: center; justify-content: center;
|
| 189 |
+
box-shadow: 0 4px 24px rgba(0,0,0,0.6);
|
| 190 |
+
transition: transform 0.2s, background 0.2s;
|
| 191 |
+
padding-left: 4px;
|
| 192 |
}
|
| 193 |
+
.thumb-wrap:hover .play-btn { transform: scale(1.12); background: rgba(255,0,0,1); }
|
| 194 |
+
.no-video-msg {
|
| 195 |
+
background: rgba(0,0,0,0.6); color: var(--text-muted);
|
| 196 |
+
padding: 6px 14px; border-radius: 6px; font-size: 13px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
}
|
| 198 |
|
| 199 |
+
/* YouTube player */
|
| 200 |
.player-wrap { position: relative; background: #000; }
|
| 201 |
+
.yt-player {
|
| 202 |
+
width: 100%; aspect-ratio: 16 / 9; display: block; border: none;
|
|
|
|
|
|
|
| 203 |
}
|
| 204 |
.close-btn {
|
| 205 |
+
position: absolute; top: 10px; right: 10px;
|
| 206 |
+
background: rgba(0,0,0,0.7); color: #fff;
|
| 207 |
+
border: none; border-radius: 6px;
|
| 208 |
+
padding: 5px 12px; font-size: 13px; cursor: pointer;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
}
|
| 210 |
+
.close-btn:hover { background: rgba(200,0,0,0.8); }
|
| 211 |
|
| 212 |
+
/* Match info bar */
|
| 213 |
+
.match-bar {
|
| 214 |
padding: 12px 14px 14px;
|
| 215 |
+
display: flex; flex-direction: column; gap: 8px;
|
|
|
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
+
|
| 218 |
+
.team-row {
|
| 219 |
+
display: flex; align-items: center; gap: 8px;
|
|
|
|
|
|
|
| 220 |
}
|
| 221 |
+
.team-info {
|
| 222 |
+
display: flex; align-items: center; gap: 6px; flex: 1;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
}
|
| 224 |
+
.team-info-right { justify-content: flex-end; }
|
| 225 |
+
.team-logo { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
|
| 226 |
+
.team-name { font-size: 13px; font-weight: 600; }
|
| 227 |
+
.score-pill {
|
| 228 |
+
flex-shrink: 0;
|
| 229 |
+
padding: 3px 12px;
|
| 230 |
+
border-radius: 20px;
|
| 231 |
+
background: rgba(255,215,0,0.12);
|
| 232 |
+
border: 1px solid rgba(255,215,0,0.3);
|
| 233 |
+
font-size: 15px; font-weight: 800;
|
| 234 |
+
color: var(--accent-gold);
|
| 235 |
+
letter-spacing: 1px;
|
| 236 |
}
|
| 237 |
+
|
| 238 |
+
.match-meta {
|
| 239 |
+
display: flex; justify-content: space-between;
|
| 240 |
+
font-size: 11px; color: var(--text-muted);
|
| 241 |
+
}
|
| 242 |
+
.match-round { font-style: italic; }
|
| 243 |
+
|
| 244 |
+
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
| 245 |
.btn-watch {
|
| 246 |
display: inline-flex; align-items: center; gap: 5px;
|
| 247 |
+
padding: 6px 14px; border-radius: 6px;
|
| 248 |
+
background: rgba(255,0,0,0.15); border: 1px solid rgba(255,0,0,0.5);
|
| 249 |
+
color: #ff4444; font-size: 13px; font-weight: 600;
|
| 250 |
+
cursor: pointer; transition: background 0.2s;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
}
|
| 252 |
+
.btn-watch:hover { background: rgba(255,0,0,0.28); }
|
| 253 |
+
.btn-yt {
|
|
|
|
| 254 |
display: inline-flex; align-items: center;
|
| 255 |
+
padding: 6px 12px; border-radius: 6px;
|
| 256 |
+
background: transparent; border: 1px solid var(--border);
|
| 257 |
+
color: var(--text-muted); font-size: 12px; text-decoration: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
transition: border-color 0.2s, color 0.2s;
|
| 259 |
}
|
| 260 |
+
.btn-yt:hover { border-color: #ff4444; color: #ff4444; }
|
| 261 |
+
.no-vid-label { font-size: 12px; color: var(--text-muted); align-self: center; }
|
| 262 |
|
| 263 |
@media (max-width: 640px) {
|
| 264 |
.tiles-grid { grid-template-columns: 1fr; }
|