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:
parent
76f5ef0c84
commit
ad6cbaf742
@ -1133,6 +1133,9 @@ class VideoController extends Controller
|
||||
'slides' => $slides,
|
||||
'language' => $video->language,
|
||||
'audio_tracks' => $audioTracks,
|
||||
'sports_match_id'=> $video->type === 'match'
|
||||
? \App\Models\SportsMatch::where('video_id', $video->id)->value('id')
|
||||
: null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@ -1362,10 +1362,18 @@
|
||||
<i class="bi bi-fire"></i>
|
||||
<span>Trending</span>
|
||||
</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>
|
||||
<span>Upload</span>
|
||||
</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' : '' }}">
|
||||
<i class="bi bi-collection-play-fill"></i>
|
||||
<span>History</span>
|
||||
|
||||
@ -233,12 +233,8 @@ window._editCurrentVideoId = null;
|
||||
|
||||
// ── Modal open / close ────────────────────────────────────────────────────────
|
||||
function openEditVideoModal(videoId) {
|
||||
if (window.innerWidth < 992) {
|
||||
window.location.href = `/videos/${videoId}/edit`;
|
||||
return;
|
||||
}
|
||||
window._editCurrentVideoId = videoId;
|
||||
|
||||
// Always resolve type first so a match video opens the sports-match sheet
|
||||
// instead of the generic video-edit modal — same for mobile.
|
||||
fetch(`/videos/${videoId}/edit`, {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
@ -251,6 +247,25 @@ function openEditVideoModal(videoId) {
|
||||
if (!data.success) { showToast('Failed to load video data', 'error'); return; }
|
||||
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}`;
|
||||
|
||||
// Header
|
||||
|
||||
@ -524,6 +524,72 @@
|
||||
.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-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>
|
||||
|
||||
<script>
|
||||
@ -555,12 +621,28 @@
|
||||
function token() { return form.querySelector('[name="_token"]').value; }
|
||||
|
||||
// ── 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();
|
||||
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();
|
||||
};
|
||||
|
||||
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() {
|
||||
form.reset();
|
||||
idInput.value = '';
|
||||
|
||||
@ -2435,162 +2435,64 @@
|
||||
@endauth
|
||||
</div>
|
||||
<div class="event-list" id="officialEvents">
|
||||
<!-- Round markers and points (unchanged) -->
|
||||
<div class="round-marker" data-round="1">
|
||||
<span class="round-badge">ROUND 1</span>
|
||||
@auth
|
||||
@if (isset($video) && Auth::id() === $video->user_id)
|
||||
@php
|
||||
$isOwner = auth()->check() && isset($video) && auth()->id() === $video->user_id;
|
||||
$fmtTime = fn ($s) => sprintf('%02d:%02d', intdiv((int) $s, 60), ((int) $s) % 60);
|
||||
@endphp
|
||||
|
||||
@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">
|
||||
<button class="round-action-btn" onclick="openAddPointModal(1)"
|
||||
<button class="round-action-btn" onclick="beginPointCapture({{ $round->round_number }}, {{ $round->id }})"
|
||||
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>
|
||||
<button class="round-action-btn" onclick="deleteRound(1)"
|
||||
<button class="round-action-btn" onclick="deleteRound({{ $round->id }})"
|
||||
title="Delete round">🗑️</button>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
</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 class="event-label">
|
||||
Blue body kick (1 pt)
|
||||
<span class="pill pill-blue">Blue</span>
|
||||
{{ $label }}
|
||||
@if ($pill)
|
||||
<span class="pill {{ $pill }}">{{ ucfirst($comp) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<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>
|
||||
@auth
|
||||
@if (isset($video) && Auth::id() === $video->user_id)
|
||||
@if ($isOwner)
|
||||
<div class="event-actions">
|
||||
<button class="event-action-btn" onclick="editPoint('pt1')"
|
||||
title="Edit">✏️</button>
|
||||
<button class="event-action-btn delete" onclick="deletePoint('pt1')"
|
||||
title="Delete">🗑️</button>
|
||||
<button class="event-action-btn" onclick="editPoint({{ $point->id }})" title="Edit">✏️</button>
|
||||
<button class="event-action-btn delete" onclick="deletePoint({{ $point->id }})" title="Delete">🗑️</button>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
<div class="event-item" data-time-start="45" data-round="1" data-id="pt2">
|
||||
<div class="event-time">@00:45</div>
|
||||
<div>
|
||||
<div class="event-label">
|
||||
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
|
||||
@endforeach
|
||||
@empty
|
||||
<div class="event-empty" style="padding:24px 12px; text-align:center; color: var(--text-secondary); font-size: 13px;">
|
||||
No rounds or points added yet.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2608,81 +2510,41 @@
|
||||
@endauth
|
||||
</div>
|
||||
<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">
|
||||
<span class="emoji">🔥</span>
|
||||
<span>@00:32–00:50</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>
|
||||
<span class="emoji">{{ $review->emoji ?: '📝' }}</span>
|
||||
<span class="review-time">{{ $range }}</span>
|
||||
</div>
|
||||
<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">
|
||||
<img class="review-author-avatar"
|
||||
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>
|
||||
<span class="review-author-name">{{ $coach }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
@if (isset($video) && Auth::id() === $video->user_id)
|
||||
@if ($isOwner)
|
||||
<div class="event-actions">
|
||||
<button class="event-action-btn" onclick="editReview('rev2')"
|
||||
title="Edit">✏️</button>
|
||||
<button class="event-action-btn delete" onclick="deleteReview('rev2')"
|
||||
title="Delete">🗑️</button>
|
||||
<button class="event-action-btn" onclick="editReview({{ $review->id }})" title="Edit">✏️</button>
|
||||
<button class="event-action-btn delete" onclick="deleteReview({{ $review->id }})" title="Delete">🗑️</button>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
<div class="event-item" data-time-start="205" data-id="rev3">
|
||||
<div class="review-primary-row">
|
||||
<span class="emoji">😄</span>
|
||||
<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
|
||||
@empty
|
||||
<div class="event-empty" style="padding:24px 12px; text-align:center; color: var(--text-secondary); font-size: 13px;">
|
||||
No coach notes yet.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -3166,6 +3028,94 @@
|
||||
</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 -->
|
||||
<div class="match-modal-overlay" id="pointModal">
|
||||
<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>
|
||||
${isOwner ? `
|
||||
<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="deleteRound(${round.id})" title="Delete round">🗑️</button>
|
||||
</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) {
|
||||
if (isDemoMode || !csrfToken) {
|
||||
showToast('Demo Mode', 'info');
|
||||
@ -3979,7 +4026,7 @@
|
||||
},
|
||||
body: JSON.stringify({
|
||||
round_id: roundId,
|
||||
timestamp_seconds: parseInt(timestamp),
|
||||
timestamp_seconds: parsedPointTimestamp,
|
||||
action,
|
||||
points: parseInt(value),
|
||||
competitor,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user