Match highlights: DB-backed rounds/points, draggable point capture, sports sheet Edit

Match highlights sidebar (videos/types/match.blade.php)
- Replaced hardcoded 3-round/5-point/3-review mock with server-rendered loops
  over $video->matchRounds (with points) and $video->coachReviews. Owner-only
  buttons carry real DB ids to the existing JS handlers.
- Fixed savePoint create branch: was doing parseInt(rawMmSs) which truncated
  "1.15" -> 1 second. Now sends the parsed value that edit already used.
- Added a point-capture bar: clicking + on a round pauses the video and shows a
  bottom bar with a big draggable red marker + nudge buttons. Dragging seeks
  the video live; Confirm reads video.currentTime and opens the Add Point modal
  with mm.ss pre-filled and Action focused. Fixed both server- and JS-rendered
  round buttons so dynamic reloads keep the new flow.

Edit → sports sheet routing
- Added sports_match_id to /videos/{video}/edit JSON.
- openEditVideoModal delegates type=match videos to openSportsMatchModal —
  edit mode when a SportsMatch is linked, create-with-attached-video mode
  when the video is match-typed but has no SportsMatch row yet.
- Sports sheet gained attachExistingVideo() so create-mode can be seeded with
  a video that's already in the library (skips the upload dropzone).

Upload chooser mobile bottom-nav
- Bottom nav "Upload" now opens the type chooser (bottom sheet on mobile,
  centred modal on desktop) instead of hard-navigating to /videos/create.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-08-08 04:01:06 +03:00
parent 76f5ef0c84
commit ad6cbaf742
5 changed files with 384 additions and 229 deletions

View File

@ -1133,6 +1133,9 @@ class VideoController extends Controller
'slides' => $slides, 'slides' => $slides,
'language' => $video->language, 'language' => $video->language,
'audio_tracks' => $audioTracks, 'audio_tracks' => $audioTracks,
'sports_match_id'=> $video->type === 'match'
? \App\Models\SportsMatch::where('video_id', $video->id)->value('id')
: null,
], ],
]); ]);
} }

View File

@ -1362,10 +1362,18 @@
<i class="bi bi-fire"></i> <i class="bi bi-fire"></i>
<span>Trending</span> <span>Trending</span>
</a> </a>
<a href="{{ auth()->check() ? route('videos.create') : route('login') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}"> @auth
<a href="#" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}"
onclick="event.preventDefault(); openUploadChooser();">
<i class="bi bi-plus-circle-fill"></i> <i class="bi bi-plus-circle-fill"></i>
<span>Upload</span> <span>Upload</span>
</a> </a>
@else
<a href="{{ route('login') }}" class="yt-bottom-nav-item">
<i class="bi bi-plus-circle-fill"></i>
<span>Upload</span>
</a>
@endauth
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}"> <a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
<i class="bi bi-collection-play-fill"></i> <i class="bi bi-collection-play-fill"></i>
<span>History</span> <span>History</span>

View File

@ -233,12 +233,8 @@ window._editCurrentVideoId = null;
// ── Modal open / close ──────────────────────────────────────────────────────── // ── Modal open / close ────────────────────────────────────────────────────────
function openEditVideoModal(videoId) { function openEditVideoModal(videoId) {
if (window.innerWidth < 992) { // Always resolve type first so a match video opens the sports-match sheet
window.location.href = `/videos/${videoId}/edit`; // instead of the generic video-edit modal — same for mobile.
return;
}
window._editCurrentVideoId = videoId;
fetch(`/videos/${videoId}/edit`, { fetch(`/videos/${videoId}/edit`, {
headers: { headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}', 'X-CSRF-TOKEN': '{{ csrf_token() }}',
@ -251,6 +247,25 @@ function openEditVideoModal(videoId) {
if (!data.success) { showToast('Failed to load video data', 'error'); return; } if (!data.success) { showToast('Failed to load video data', 'error'); return; }
const v = data.video; const v = data.video;
// Match videos always route to the sports-match sheet — edit the existing
// match if one is linked, otherwise open create-mode with the video already
// attached so the user can add the match details.
if (v.type === 'match' && typeof window.openSportsMatchModal === 'function') {
if (v.sports_match_id) {
window.openSportsMatchModal(v.sports_match_id);
} else {
window.openSportsMatchModal({ videoId: v.id, videoTitle: v.title });
}
return;
}
// Mobile fallback for non-match videos: full-page edit view
if (window.innerWidth < 992) {
window.location.href = `/videos/${videoId}/edit`;
return;
}
window._editCurrentVideoId = videoId;
document.getElementById('edit-form').action = `/videos/${videoId}`; document.getElementById('edit-form').action = `/videos/${videoId}`;
// Header // Header

View File

@ -524,6 +524,72 @@
.sm-img-avatar .sm-img-ph { min-height: 74px; gap: 2px; font-size: 10px; } .sm-img-avatar .sm-img-ph { min-height: 74px; gap: 2px; font-size: 10px; }
.sm-img-avatar .sm-img-ph i { font-size: 16px; } .sm-img-avatar .sm-img-ph i { font-size: 16px; }
.sm-img-avatar .sm-img-preview { width: 74px; height: 74px; max-height: 74px; } .sm-img-avatar .sm-img-preview { width: 74px; height: 74px; max-height: 74px; }
/* ── Mobile: render as a bottom sheet ─────────────────────────── */
@media (max-width: 768px) {
#sportsMatchModal .modal-dialog {
margin: 0;
max-width: 100%;
width: 100%;
min-height: 100%;
display: flex;
align-items: flex-end;
}
#sportsMatchModal .sm-content {
border-radius: 20px 20px 0 0;
border-left: none;
border-right: none;
border-bottom: none;
width: 100%;
max-height: 92vh;
display: flex;
flex-direction: column;
box-shadow: 0 -12px 40px rgba(0,0,0,.6);
transform: translateY(100%);
transition: transform .3s cubic-bezier(.22,1,.36,1);
}
#sportsMatchModal.show .sm-content { transform: translateY(0); }
/* Grabber handle at the top */
#sportsMatchModal .sm-header {
position: relative;
padding-top: 18px;
}
#sportsMatchModal .sm-header::before {
content: '';
position: absolute;
top: 6px; left: 50%;
transform: translateX(-50%);
width: 40px; height: 4px;
background: #333; border-radius: 2px;
}
#sportsMatchModal .modal-body {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
flex: 1 1 auto;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
#sportsMatchModal .sm-footer {
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
gap: 6px;
flex-wrap: wrap;
}
#sportsMatchModal .sm-footer .btn {
flex: 1 1 auto;
font-size: 13px;
padding: 10px 12px;
}
/* Slide-up animation on the modal fade class as well */
#sportsMatchModal.fade .modal-dialog { transform: none; }
}
@media (max-width: 480px) {
#sportsMatchModal .sm-header h5 { font-size: 15px; }
#sportsMatchModal .sm-header-icon { width: 34px; height: 34px; font-size: 16px; }
}
</style> </style>
<script> <script>
@ -555,12 +621,28 @@
function token() { return form.querySelector('[name="_token"]').value; } function token() { return form.querySelector('[name="_token"]').value; }
// ── Public entry point ─────────────────────────────────────────────── // ── Public entry point ───────────────────────────────────────────────
window.openSportsMatchModal = function (matchId) { // - openSportsMatchModal(matchId) → edit existing match
// - openSportsMatchModal({videoId, videoTitle}) → create against an existing video
// - openSportsMatchModal() → fresh create (upload a new video)
window.openSportsMatchModal = function (arg) {
resetForm(); resetForm();
if (matchId) loadMatch(matchId); if (typeof arg === 'number' || typeof arg === 'string') {
loadMatch(arg);
} else if (arg && typeof arg === 'object' && arg.videoId) {
attachExistingVideo(arg.videoId, arg.videoTitle || '');
}
getModal().show(); getModal().show();
}; };
function attachExistingVideo(videoId, videoTitle) {
videoIdInp.value = videoId;
document.getElementById('sm-video-create').classList.add('d-none');
document.getElementById('sm-video-attached').classList.remove('d-none');
document.getElementById('sm-video-attached-title').textContent = videoTitle || ('Video #' + videoId);
// Advanced fields are also useful when the video already exists.
document.getElementById('sm-advanced').classList.remove('d-none');
}
function resetForm() { function resetForm() {
form.reset(); form.reset();
idInput.value = ''; idInput.value = '';

View File

@ -2435,162 +2435,64 @@
@endauth @endauth
</div> </div>
<div class="event-list" id="officialEvents"> <div class="event-list" id="officialEvents">
<!-- Round markers and points (unchanged) --> @php
<div class="round-marker" data-round="1"> $isOwner = auth()->check() && isset($video) && auth()->id() === $video->user_id;
<span class="round-badge">ROUND 1</span> $fmtTime = fn ($s) => sprintf('%02d:%02d', intdiv((int) $s, 60), ((int) $s) % 60);
@auth @endphp
@if (isset($video) && Auth::id() === $video->user_id)
@forelse ($video->matchRounds as $round)
<div class="round-marker" data-round="{{ $round->round_number }}">
<span class="round-badge">{{ strtoupper($round->name ?: 'ROUND '.$round->round_number) }}</span>
@if ($isOwner)
<div class="round-actions"> <div class="round-actions">
<button class="round-action-btn" onclick="openAddPointModal(1)" <button class="round-action-btn" onclick="beginPointCapture({{ $round->round_number }}, {{ $round->id }})"
title="Add point">+</button> title="Add point">+</button>
<button class="round-action-btn" onclick="editRound(1)" <button class="round-action-btn" onclick="editRound({{ $round->round_number }}, {{ $round->id }}, {{ json_encode($round->name) }}, {{ (int) $round->start_time_seconds }})"
title="Edit round">✏️</button> title="Edit round">✏️</button>
<button class="round-action-btn" onclick="deleteRound(1)" <button class="round-action-btn" onclick="deleteRound({{ $round->id }})"
title="Delete round">🗑️</button> title="Delete round">🗑️</button>
</div> </div>
@endif @endif
@endauth
</div> </div>
<div class="event-item" data-time-start="20" data-round="1" data-id="pt1">
<div class="event-time">@00:20</div> @foreach ($round->points as $point)
@php
$comp = strtolower($point->competitor ?? '');
$pill = $comp === 'red' ? 'pill-red' : ($comp === 'blue' ? 'pill-blue' : '');
$label = $point->action ?: 'Point';
if ($point->points) $label .= ' ('.$point->points.' pt'.($point->points == 1 ? '' : 's').')';
@endphp
<div class="event-item" data-time-start="{{ (int) $point->timestamp_seconds }}"
data-round="{{ $round->round_number }}" data-id="pt{{ $point->id }}">
<div class="event-time">@{{ $fmtTime($point->timestamp_seconds) }}</div>
<div> <div>
<div class="event-label"> <div class="event-label">
Blue body kick (1 pt) {{ $label }}
<span class="pill pill-blue">Blue</span> @if ($pill)
<span class="pill {{ $pill }}">{{ ucfirst($comp) }}</span>
@endif
</div> </div>
<div class="event-meta"> <div class="event-meta">
Round 1 Score 1 0 Clean contact. {{ $round->name ?: 'Round '.$round->round_number }}
@if ($point->score_blue !== null || $point->score_red !== null)
Score {{ (int) $point->score_blue }} {{ (int) $point->score_red }}
@endif
@if ($point->notes) {{ $point->notes }} @endif
</div> </div>
</div> </div>
@auth @if ($isOwner)
@if (isset($video) && Auth::id() === $video->user_id)
<div class="event-actions"> <div class="event-actions">
<button class="event-action-btn" onclick="editPoint('pt1')" <button class="event-action-btn" onclick="editPoint({{ $point->id }})" title="Edit">✏️</button>
title="Edit">✏️</button> <button class="event-action-btn delete" onclick="deletePoint({{ $point->id }})" title="Delete">🗑️</button>
<button class="event-action-btn delete" onclick="deletePoint('pt1')"
title="Delete">🗑️</button>
</div> </div>
@endif @endif
@endauth
</div> </div>
<div class="event-item" data-time-start="45" data-round="1" data-id="pt2"> @endforeach
<div class="event-time">@00:45</div> @empty
<div> <div class="event-empty" style="padding:24px 12px; text-align:center; color: var(--text-secondary); font-size: 13px;">
<div class="event-label"> No rounds or points added yet.
Blue scores body kick
<span class="pill pill-blue">Blue</span>
</div>
<div class="event-meta">
Round 1 Score 2 0 Judge: Central.
</div>
</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<div class="event-actions">
<button class="event-action-btn" onclick="editPoint('pt2')"
title="Edit">✏️</button>
<button class="event-action-btn delete" onclick="deletePoint('pt2')"
title="Delete">🗑️</button>
</div>
@endif
@endauth
</div>
<div class="round-marker" data-round="2">
<span class="round-badge">ROUND 2</span>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<div class="round-actions">
<button class="round-action-btn" onclick="openAddPointModal(2)"
title="Add point">+</button>
<button class="round-action-btn" onclick="editRound(2)"
title="Edit round">✏️</button>
<button class="round-action-btn" onclick="deleteRound(2)"
title="Delete round">🗑️</button>
</div>
@endif
@endauth
</div>
<div class="event-item" data-time-start="90" data-round="2" data-id="pt3">
<div class="event-time">@01:30</div>
<div>
<div class="event-label">
Red head kick (3 pts)
<span class="pill pill-red">Red</span>
</div>
<div class="event-meta">
Round 2 Score 2 3 Video replay requested.
</div>
</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<div class="event-actions">
<button class="event-action-btn" onclick="editPoint('pt3')"
title="Edit">✏️</button>
<button class="event-action-btn delete" onclick="deletePoint('pt3')"
title="Delete">🗑️</button>
</div>
@endif
@endauth
</div>
<div class="round-marker" data-round="3">
<span class="round-badge">ROUND 3</span>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<div class="round-actions">
<button class="round-action-btn" onclick="openAddPointModal(3)"
title="Add point">+</button>
<button class="round-action-btn" onclick="editRound(3)"
title="Edit round">✏️</button>
<button class="round-action-btn" onclick="deleteRound(3)"
title="Delete round">🗑️</button>
</div>
@endif
@endauth
</div>
<div class="event-item" data-time-start="150" data-round="3" data-id="pt4">
<div class="event-time">@02:30</div>
<div>
<div class="event-label">
Blue spinning backfist (2 pts)
<span class="pill pill-blue">Blue</span>
</div>
<div class="event-meta">
Round 3 Score 4 3 Close round.
</div>
</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<div class="event-actions">
<button class="event-action-btn" onclick="editPoint('pt4')"
title="Edit">✏️</button>
<button class="event-action-btn delete" onclick="deletePoint('pt4')"
title="Delete">🗑️</button>
</div>
@endif
@endauth
</div>
<div class="event-item" data-time-start="180" data-round="3" data-id="pt5">
<div class="event-time">@03:00</div>
<div>
<div class="event-label">
Red leg kick (1 pt)
<span class="pill pill-red">Red</span>
</div>
<div class="event-meta">
Round 3 Score 4 4 Tied!
</div>
</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<div class="event-actions">
<button class="event-action-btn" onclick="editPoint('pt5')"
title="Edit">✏️</button>
<button class="event-action-btn delete" onclick="deletePoint('pt5')"
title="Delete">🗑️</button>
</div>
@endif
@endauth
</div> </div>
@endforelse
</div> </div>
</div> </div>
@ -2608,81 +2510,41 @@
@endauth @endauth
</div> </div>
<div class="event-list" id="reviewEvents"> <div class="event-list" id="reviewEvents">
<div class="event-item" data-time-start="32" data-time-end="50" data-id="rev1"> @forelse ($video->coachReviews as $review)
@php
$start = $fmtTime($review->start_time_seconds);
$end = $review->end_time_seconds ? $fmtTime($review->end_time_seconds) : null;
$range = $end ? ('@'.$start.''.$end) : ('@'.$start);
$coach = $review->coach_name ?: 'Coach';
@endphp
<div class="event-item"
data-time-start="{{ (int) $review->start_time_seconds }}"
@if ($review->end_time_seconds) data-time-end="{{ (int) $review->end_time_seconds }}" @endif
data-id="rev{{ $review->id }}">
<div class="review-primary-row"> <div class="review-primary-row">
<span class="emoji">🔥</span> <span class="emoji">{{ $review->emoji ?: '📝' }}</span>
<span>@00:3200:50</span> <span class="review-time">{{ $range }}</span>
<div class="note-text">Good pressure, but guard too low after first kick</div>
</div>
<div class="review-secondary-row">
<div class="comment-avatar">
<img src="https://picsum.photos/seed/coach-ahmed/80/80" alt="Coach Ahmed">
</div>
<div class="coach-name">Coach Ahmed</div>
</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<div class="event-actions">
<button class="event-action-btn" onclick="editReview('rev1')"
title="Edit">✏️</button>
<button class="event-action-btn delete" onclick="deleteReview('rev1')"
title="Delete">🗑️</button>
</div>
@endif
@endauth
</div>
<div class="event-item" data-time-start="125" data-time-end="140" data-id="rev2">
<div class="review-primary-row">
<span class="emoji">🤔</span>
<span class="review-time">@02:05-02:20</span>
</div> </div>
<div class="review-content"> <div class="review-content">
<h4 class="review-note-title">Missed counter opportunity</h4> @if ($review->note)
<h4 class="review-note-title">{{ $review->note }}</h4>
@endif
<div class="review-author-bar"> <div class="review-author-bar">
<img class="review-author-avatar" <span class="review-author-name">{{ $coach }}</span>
src="https://picsum.photos/seed/coach-sara/80/80" alt="Coach Sara">
<span class="review-author-name">Coach Sara</span>
<span></span>
<span class="review-note-text">Great angle, but no follow up.</span>
</div> </div>
</div> </div>
@auth @if ($isOwner)
@if (isset($video) && Auth::id() === $video->user_id)
<div class="event-actions"> <div class="event-actions">
<button class="event-action-btn" onclick="editReview('rev2')" <button class="event-action-btn" onclick="editReview({{ $review->id }})" title="Edit">✏️</button>
title="Edit">✏️</button> <button class="event-action-btn delete" onclick="deleteReview({{ $review->id }})" title="Delete">🗑️</button>
<button class="event-action-btn delete" onclick="deleteReview('rev2')"
title="Delete">🗑️</button>
</div> </div>
@endif @endif
@endauth
</div> </div>
<div class="event-item" data-time-start="205" data-id="rev3"> @empty
<div class="review-primary-row"> <div class="event-empty" style="padding:24px 12px; text-align:center; color: var(--text-secondary); font-size: 13px;">
<span class="emoji">😄</span> No coach notes yet.
<span class="review-time">@03:25</span>
</div>
<div class="review-content">
<h4 class="review-note-title">Excellent angle change and follow-up</h4>
<div class="review-author-bar">
<img class="review-author-avatar"
src="https://picsum.photos/seed/coach-ahmed/80/80" alt="Coach Ahmed">
<span class="review-author-name">Coach Ahmed</span>
<span></span>
<span class="review-note-text">Ideal example of exit and re-entry.</span>
</div>
</div>
@auth
@if (isset($video) && Auth::id() === $video->user_id)
<div class="event-actions">
<button class="event-action-btn" onclick="editReview('rev3')"
title="Edit">✏️</button>
<button class="event-action-btn delete" onclick="deleteReview('rev3')"
title="Delete">🗑️</button>
</div>
@endif
@endauth
</div> </div>
@endforelse
</div> </div>
</div> </div>
</div> </div>
@ -3166,6 +3028,94 @@
</div> </div>
</div> </div>
<!-- Point-timestamp capture bar (opens when + Point is clicked) -->
<div id="pointCaptureBar" class="point-capture-bar" hidden>
<div class="pcb-inner">
<div class="pcb-head">
<div class="pcb-copy">
<i class="bi bi-crosshair"></i>
<span class="pcb-title">Drag the marker to the moment of the point</span>
</div>
<div class="pcb-clock">
<span id="pcbCurrentTime">00:00</span>
<span class="pcb-clock-sep">/</span>
<span id="pcbDuration">00:00</span>
</div>
</div>
<div class="pcb-scrubber">
<button type="button" class="pcb-step" onclick="pcbNudge(-1)" title="Back 1s"><i class="bi bi-chevron-left"></i></button>
<input type="range" id="pcbSlider" min="0" max="0" step="0.1" value="0" class="pcb-range">
<button type="button" class="pcb-step" onclick="pcbNudge(1)" title="Forward 1s"><i class="bi bi-chevron-right"></i></button>
</div>
<div class="pcb-actions">
<button type="button" class="action-btn" onclick="cancelPointCapture()">
<i class="bi bi-x-lg"></i> <span>Cancel</span>
</button>
<button type="button" class="action-btn action-btn-primary" onclick="confirmPointCapture()">
<i class="bi bi-check2"></i> <span>Use this time</span>
</button>
</div>
</div>
</div>
<style>
.point-capture-bar {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 1080;
background: #0f0f0f; border-top: 1px solid #2a2a2a;
box-shadow: 0 -12px 32px rgba(0,0,0,.5);
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
animation: pcbSlideUp .22s cubic-bezier(.22,1,.36,1);
}
.point-capture-bar[hidden] { display: none; }
@keyframes pcbSlideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.pcb-inner { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 8px; }
.pcb-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.pcb-copy { display: flex; align-items: center; gap: 8px; min-width: 0; color: #ddd; font-size: 13px; font-weight: 600; }
.pcb-copy > i { color: var(--brand-red, #e61e1e); font-size: 18px; flex-shrink: 0; }
.pcb-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pcb-clock { font-variant-numeric: tabular-nums; font-size: 13px; color: #aaa; flex-shrink: 0; }
#pcbCurrentTime { color: #fff; font-weight: 700; }
.pcb-clock-sep { margin: 0 4px; opacity: .5; }
#pcbDuration { color: #888; }
.pcb-scrubber { display: flex; align-items: center; gap: 8px; }
.pcb-step {
width: 34px; height: 34px; flex-shrink: 0; border-radius: 50%;
border: 1px solid #2a2a2a; background: #1a1a1a; color: #ddd;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
transition: background .12s, border-color .12s;
}
.pcb-step:hover { background: #222; border-color: #333; }
/* Big draggable range with brand-red marker */
.pcb-range {
flex: 1; -webkit-appearance: none; appearance: none;
height: 8px; background: transparent; cursor: pointer;
}
.pcb-range::-webkit-slider-runnable-track { height: 8px; background: linear-gradient(#2a2a2a,#222); border-radius: 6px; }
.pcb-range::-moz-range-track { height: 8px; background: linear-gradient(#2a2a2a,#222); border-radius: 6px; }
.pcb-range::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 22px; height: 22px; border-radius: 50%;
background: var(--brand-red, #e61e1e); border: 3px solid #fff;
box-shadow: 0 2px 10px rgba(0,0,0,.6);
margin-top: -7px; cursor: grab;
}
.pcb-range:active::-webkit-slider-thumb { cursor: grabbing; }
.pcb-range::-moz-range-thumb {
width: 22px; height: 22px; border-radius: 50%;
background: var(--brand-red, #e61e1e); border: 3px solid #fff;
box-shadow: 0 2px 10px rgba(0,0,0,.6); cursor: grab;
}
.pcb-actions { display: flex; gap: 8px; flex-shrink: 0; justify-content: flex-end; }
@media (max-width: 640px) {
.pcb-actions { justify-content: stretch; }
.pcb-actions .action-btn { flex: 1; justify-content: center; }
.pcb-title { white-space: normal; }
}
</style>
<!-- Add/Edit Point Modal --> <!-- Add/Edit Point Modal -->
<div class="match-modal-overlay" id="pointModal"> <div class="match-modal-overlay" id="pointModal">
<div class="match-modal-content"> <div class="match-modal-content">
@ -3430,7 +3380,7 @@
<span class="round-badge" data-start-time="${roundStart}" title="Jump to ${roundStart !== '' ? formatTime(roundStart) : 'start'}">${round.name}</span> <span class="round-badge" data-start-time="${roundStart}" title="Jump to ${roundStart !== '' ? formatTime(roundStart) : 'start'}">${round.name}</span>
${isOwner ? ` ${isOwner ? `
<div class="round-actions"> <div class="round-actions">
<button class="round-action-btn" onclick="openAddPointModal(${round.round_number}, ${round.id})" title="Add point">+</button> <button class="round-action-btn" onclick="beginPointCapture(${round.round_number}, ${round.id})" title="Add point">+</button>
<button class="round-action-btn" onclick="editRound(${round.round_number}, ${round.id}, '${safeRoundName}', ${roundStart === '' ? 'null' : roundStart})" title="Edit round">✏️</button> <button class="round-action-btn" onclick="editRound(${round.round_number}, ${round.id}, '${safeRoundName}', ${roundStart === '' ? 'null' : roundStart})" title="Edit round">✏️</button>
<button class="round-action-btn" onclick="deleteRound(${round.id})" title="Delete round">🗑️</button> <button class="round-action-btn" onclick="deleteRound(${round.id})" title="Delete round">🗑️</button>
</div> </div>
@ -3898,6 +3848,103 @@
} }
} }
// ── Point-timestamp capture ──────────────────────────────────────
// Pauses the video and shows a floating bar with a big draggable marker.
// The marker IS the slider — dragging it seeks the video so the user can
// land on the exact frame of the point. Confirm opens the Add Point modal
// with the captured timestamp pre-filled.
let _pcbCtx = null;
function beginPointCapture(roundNumber, roundId) {
if (isDemoMode || !csrfToken) { showToast('Demo Mode', 'info'); return; }
const video = document.getElementById('videoPlayer');
if (!video) { openAddPointModal(roundNumber, roundId); return; }
try { video.pause(); } catch (_) {}
const bar = document.getElementById('pointCaptureBar');
const slider = document.getElementById('pcbSlider');
const clockEl = document.getElementById('pcbCurrentTime');
const durEl = document.getElementById('pcbDuration');
// Wait for metadata before locking in duration
const wireDuration = () => {
const d = Number(video.duration);
if (isFinite(d) && d > 0) {
slider.min = 0;
slider.max = d;
slider.step = 0.1;
durEl.textContent = toMinuteSecondClock(d);
}
};
wireDuration();
if (!(isFinite(video.duration) && video.duration > 0)) {
video.addEventListener('loadedmetadata', wireDuration, { once: true });
}
slider.value = Math.max(0, Math.min(video.duration || 0, video.currentTime || 0));
clockEl.textContent = toMinuteSecondClock(video.currentTime || 0);
// Slider → video seek. Use 'input' so it scrubs live as they drag.
const onSlide = () => {
const t = Number(slider.value) || 0;
try { video.currentTime = t; } catch (_) {}
clockEl.textContent = toMinuteSecondClock(t);
};
// Video → slider (in case the underlying player emits time changes)
const onVideoTime = () => {
if (document.activeElement !== slider) {
slider.value = video.currentTime || 0;
clockEl.textContent = toMinuteSecondClock(video.currentTime || 0);
}
};
slider.addEventListener('input', onSlide);
video.addEventListener('timeupdate', onVideoTime);
video.addEventListener('seeked', onVideoTime);
bar.hidden = false;
// Focus the slider so keyboard arrows work immediately
setTimeout(() => slider.focus(), 30);
_pcbCtx = { roundNumber, roundId, video, slider, onSlide, onVideoTime };
}
function cancelPointCapture() {
const bar = document.getElementById('pointCaptureBar');
if (bar) bar.hidden = true;
if (_pcbCtx) {
_pcbCtx.slider.removeEventListener('input', _pcbCtx.onSlide);
_pcbCtx.video.removeEventListener('timeupdate', _pcbCtx.onVideoTime);
_pcbCtx.video.removeEventListener('seeked', _pcbCtx.onVideoTime);
_pcbCtx = null;
}
}
function pcbNudge(deltaSeconds) {
if (!_pcbCtx) return;
const { video, slider } = _pcbCtx;
const dur = Number(video.duration) || 0;
const next = Math.max(0, Math.min(dur, (Number(video.currentTime) || 0) + deltaSeconds));
try { video.currentTime = next; } catch (_) {}
slider.value = next;
document.getElementById('pcbCurrentTime').textContent = toMinuteSecondClock(next);
}
function confirmPointCapture() {
if (!_pcbCtx) return;
const { roundNumber, roundId, video } = _pcbCtx;
// Read straight off the video — that's the source of truth
const seconds = Math.max(0, Math.round(Number(video.currentTime) || 0));
cancelPointCapture();
openAddPointModal(roundNumber, roundId);
const ts = document.getElementById('pointTimestamp');
if (ts) ts.value = toMinuteSecondInput(seconds);
const action = document.getElementById('pointAction');
if (action) setTimeout(() => action.focus(), 60);
}
function toMinuteSecondClock(totalSeconds) {
const s = Math.max(0, Math.floor(Number(totalSeconds) || 0));
const m = Math.floor(s / 60);
return `${String(m).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
}
function openAddPointModal(roundNumber, roundId) { function openAddPointModal(roundNumber, roundId) {
if (isDemoMode || !csrfToken) { if (isDemoMode || !csrfToken) {
showToast('Demo Mode', 'info'); showToast('Demo Mode', 'info');
@ -3979,7 +4026,7 @@
}, },
body: JSON.stringify({ body: JSON.stringify({
round_id: roundId, round_id: roundId,
timestamp_seconds: parseInt(timestamp), timestamp_seconds: parsedPointTimestamp,
action, action,
points: parseInt(value), points: parseInt(value),
competitor, competitor,