Compare commits

...
2 Commits
Author SHA1 Message Date
ghassanandClaude Opus 4.7 402c6dc89f Point capture in the player: inline strip, grouped entries, correct scores
Replaces the point Add/Edit popup with an in-player capture strip and fixes
the underlying running-score bug.

Backend (MatchEventController)
- storePoint used pluck('points','competitor'), which collapses same-side
  points into a single row (last value wins) and excluded same-timestamp
  points (< instead of <=). Same-moment Blue+Red produced 1-0 / 0-1 instead
  of 1-1. Replaced with recomputeRoundScores($roundId), a single pass over
  the round ordered by (timestamp_seconds, id) that walks a running total
  and updates each row. Called after create/update/delete so edits ripple.

Player-embedded capture strip (match.blade.php)
- Adding / editing points no longer opens a modal; the video's chrome
  bottom-bar is swapped for a strip inside #ytpWrap that survives fullscreen.
- Editable mm:ss field + draggable red marker + −1s/+1s nudges. Zoom control
  (1x / 5x / 20x) narrows the slider window around the current time for
  frame-level positioning; nudges scale with zoom. Auto-recentre on edge.
- Inline form: Blue / Red / Both toggle (equal-width segments), action text,
  points number. Both mode splits into two per-side rows. Values carry over
  when switching modes so nothing is retyped.
- Enter saves, Esc cancels. Save button auto-disables during in-flight
  requests. On mobile the buttons collapse to icon-only (× / ✓).

Grouped highlights entry
- Points sharing a timestamp within a round collapse into ONE card with a
  chip per side (equal-width Blue/Red pills), a single ✏️/🗑 pair, and a
  meta line coloured by role: ROUND N in amber, blue score in blue, red
  score in red.
- ✏️ on a grouped entry opens the strip in Both mode with both sides
  pre-filled. Saving PUTs both rows; switching to a single competitor
  keeps that side and deletes the other.
- 🗑 on a grouped entry deletes both rows via the shared custom-confirm.
- loadMatchData now writes back to window.matchRounds so subsequent
  edit/lookup handlers find newly-added points without a page reload.

Removed
- #pointModal HTML block and the openAddPointModal / savePoint /
  confirmDeletePoint helpers (superseded by the capture strip).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-08-08 05:31:26 +03:00
ghassanandClaude Opus 4.7 ad6cbaf742 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) <[email protected]>
2026-08-08 04:01:06 +03:00
6 changed files with 1254 additions and 424 deletions

No files matched your search

+38 -29
View File
@@ -99,31 +99,10 @@ class MatchEventController extends Controller
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
} }
// Get ALL previous points in this round (ordered by timestamp) // Create the row first with placeholder scores, then recompute the whole
$previousPoints = MatchPoint::where('match_round_id', $request->round_id) // round's running scores. This is the only correct approach when two
->where('timestamp_seconds', '<', $request->timestamp_seconds) // points can share a timestamp (Blue + Red at the same moment) or when
->orderBy('timestamp_seconds', 'asc') // a point is inserted between existing ones.
->pluck('points', 'competitor')
->toArray();
// Calculate cumulative scores by summing each point value
$scoreBlue = 0;
$scoreRed = 0;
if (isset($previousPoints['blue'])) {
$scoreBlue += $previousPoints['blue'];
}
if (isset($previousPoints['red'])) {
$scoreRed += $previousPoints['red'];
}
// Add current point
if ($request->competitor === 'blue') {
$scoreBlue += $request->points;
} else {
$scoreRed += $request->points;
}
$point = MatchPoint::create([ $point = MatchPoint::create([
'video_id' => $video->id, 'video_id' => $video->id,
'match_round_id' => $request->round_id, 'match_round_id' => $request->round_id,
@@ -132,17 +111,42 @@ class MatchEventController extends Controller
'points' => $request->points, 'points' => $request->points,
'competitor' => $request->competitor, 'competitor' => $request->competitor,
'notes' => $request->notes, 'notes' => $request->notes,
'score_blue' => $scoreBlue, 'score_blue' => 0,
'score_red' => $scoreRed, 'score_red' => 0,
]); ]);
$this->recomputeRoundScores($request->round_id);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'point' => $point, 'point' => $point->fresh(),
'message' => 'Point added successfully!', 'message' => 'Point added successfully!',
]); ]);
} }
/**
* Recompute cumulative score_blue/score_red for every point in a round,
* ordered by (timestamp_seconds, id). Handles same-timestamp ties by
* insertion order, so Blue and Red saved at the same moment both end up
* reflecting the running score after that moment.
*/
private function recomputeRoundScores(int $roundId): void
{
$blue = 0;
$red = 0;
MatchPoint::where('match_round_id', $roundId)
->orderBy('timestamp_seconds', 'asc')
->orderBy('id', 'asc')
->get()
->each(function (MatchPoint $p) use (&$blue, &$red) {
if ($p->competitor === 'blue') $blue += (int) $p->points;
else $red += (int) $p->points;
if ((int) $p->score_blue !== $blue || (int) $p->score_red !== $red) {
$p->update(['score_blue' => $blue, 'score_red' => $red]);
}
});
}
public function updatePoint(Request $request, MatchPoint $point) public function updatePoint(Request $request, MatchPoint $point)
{ {
$request->validate([ $request->validate([
@@ -158,6 +162,7 @@ class MatchEventController extends Controller
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
} }
$roundId = $point->match_round_id;
$point->update([ $point->update([
'timestamp_seconds' => $request->timestamp_seconds, 'timestamp_seconds' => $request->timestamp_seconds,
'action' => $request->action, 'action' => $request->action,
@@ -166,9 +171,11 @@ class MatchEventController extends Controller
'notes' => $request->notes, 'notes' => $request->notes,
]); ]);
$this->recomputeRoundScores($roundId);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'point' => $point, 'point' => $point->fresh(),
'message' => 'Point updated successfully!', 'message' => 'Point updated successfully!',
]); ]);
} }
@@ -180,7 +187,9 @@ class MatchEventController extends Controller
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403); return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
} }
$roundId = $point->match_round_id;
$point->delete(); $point->delete();
$this->recomputeRoundScores($roundId);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
+3
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,
], ],
]); ]);
} }
+12 -4
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
<i class="bi bi-plus-circle-fill"></i> <a href="#" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}"
<span>Upload</span> onclick="event.preventDefault(); openUploadChooser();">
</a> <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' : '' }}"> <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>
@@ -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
@@ -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 = '';
File diff suppressed because it is too large. Load diff