Coach reviews: in-player capture strip, draggable overlay, timeline UI

Capture strip
- Twin of the point-capture strip, embedded inside #ytpWrap. Two time slots
  (Start + optional End) with the scrubber driving whichever is "active".
  Editable mm:ss inputs, zoom (1x/5x/20x), inline note + coach name + emoji
  chip picker. Cancel / Save on the right; Delete in edit mode.
- ▶ preview button plays Start → End and auto-pauses at End; click again to
  stop early. Playback uses the live overlay so previewing shows what the
  viewer will see.
- Removed the #reviewModal popup; openAddReviewModal(), setReviewEmoji(),
  saveReview() and the reviewForm submit binding are gone.

Live-preview overlay + drag-to-position
- As the user types, the coach-note overlay updates on the video in real
  time. In capture mode the overlay is grab-able and draggable to any spot
  over the video (mouse + touch), with a red dashed outline + "↕ drag me"
  hint.
- Overlay position is stored as normalized 0..1 CENTER coords of the video
  wrap and applied via `left: X%; top: Y%; transform: translate(-50%,-50%)`
  so the overlay stays at the same relative spot on any resize / fullscreen.
- Font-size, padding and border-radius scale with the player width via CSS
  container queries (`container-type: inline-size` on #ytpWrap + `clamp(...cqi)`),
  so the note grows/shrinks proportionally.
- Draggable overlay lives above the strip (z-index 70 vs 60) so it can be
  dropped anywhere including over the strip.

Persistence (schema change)
- Migration 2026_08_08_000001_add_position_to_coach_reviews: adds nullable
  decimal(6,4) position_x / position_y to coach_reviews.
- CoachReview fillable + casts updated. store/updateReview validate
  position_x / position_y as numeric between 0 and 1 and persist them.
- showCoachNoteOverlay(text, pos) applies the saved position on playback.

Cards: timeline design (Option B from the mockup)
- Vertical spine with red dots on the left; each review is a chapter-style
  time label above a subtle card. Card body: emoji as the leading indicator,
  note text, then coach name + tools (▶ / ✏️ / 🗑).
- ▶ = playReviewSlowmo(id): plays Start → End once at 1×, then loops back
  and plays again at 0.5×, then pauses and hides the overlay. Only rendered
  when the review has an end_time > start_time.
- Card body still clickable for the classic "jump-and-play-once" behaviour.
- Fixes a latent bug where the primary Save button stayed disabled after a
  successful save, silently swallowing the next click. Both the review and
  point capture strips now re-enable the button in their cancel handlers.

Also
- Design mockup file at public/coach-review-mockups.html shows the four
  candidate layouts side by side (kept for future design iterations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-08-08 07:07:03 +03:00
parent 057ce87564
commit 716693a74f
6 changed files with 1387 additions and 263 deletions

View File

@ -207,6 +207,8 @@ class MatchEventController extends Controller
'note' => 'required|string|max:1000', 'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100', 'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10', 'emoji' => 'nullable|string|max:10',
'position_x' => 'nullable|numeric|between:0,1',
'position_y' => 'nullable|numeric|between:0,1',
]); ]);
// Check if user owns the video // Check if user owns the video
@ -222,6 +224,8 @@ class MatchEventController extends Controller
'note' => $request->note, 'note' => $request->note,
'coach_name' => $request->coach_name, 'coach_name' => $request->coach_name,
'emoji' => $request->emoji ?? '🔥', 'emoji' => $request->emoji ?? '🔥',
'position_x' => $request->position_x,
'position_y' => $request->position_y,
]); ]);
return response()->json([ return response()->json([
@ -239,6 +243,8 @@ class MatchEventController extends Controller
'note' => 'required|string|max:1000', 'note' => 'required|string|max:1000',
'coach_name' => 'required|string|max:100', 'coach_name' => 'required|string|max:100',
'emoji' => 'nullable|string|max:10', 'emoji' => 'nullable|string|max:10',
'position_x' => 'nullable|numeric|between:0,1',
'position_y' => 'nullable|numeric|between:0,1',
]); ]);
// Check if user owns the video // Check if user owns the video
@ -252,6 +258,8 @@ class MatchEventController extends Controller
'note' => $request->note, 'note' => $request->note,
'coach_name' => $request->coach_name, 'coach_name' => $request->coach_name,
'emoji' => $request->emoji, 'emoji' => $request->emoji,
'position_x' => $request->position_x,
'position_y' => $request->position_y,
]); ]);
return response()->json([ return response()->json([

View File

@ -18,6 +18,13 @@ class CoachReview extends Model
'note', 'note',
'coach_name', 'coach_name',
'emoji', 'emoji',
'position_x',
'position_y',
];
protected $casts = [
'position_x' => 'float',
'position_y' => 'float',
]; ];
public function video(): BelongsTo public function video(): BelongsTo

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add drag-and-dropped overlay position for coach notes. Stored as normalized
* 0..1 coordinates of the overlay's CENTER within the video area so it
* survives any viewport / fullscreen resize.
*/
public function up(): void
{
Schema::table('coach_reviews', function (Blueprint $table) {
$table->decimal('position_x', 6, 4)->nullable()->after('emoji');
$table->decimal('position_y', 6, 4)->nullable()->after('position_x');
});
}
public function down(): void
{
Schema::table('coach_reviews', function (Blueprint $table) {
$table->dropColumn(['position_x', 'position_y']);
});
}
};

View File

@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Coach Review — Design Mockups</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
:root {
--bg: #0f0f0f;
--bg-panel: #181818;
--text-primary: #f1f1f1;
--text-secondary: #aaa;
--brand-red: #e61e1e;
--border: #262626;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 32px 16px;
}
.page {
max-width: 1400px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 28px;
}
@media (max-width: 900px) { .page { grid-template-columns: 1fr; } }
.mockup {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 24px;
position: relative;
}
.mockup h2 {
margin: 0 0 4px;
font-size: 15px;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--text-secondary);
font-weight: 700;
}
.mockup h2 .letter {
display: inline-block;
background: var(--brand-red);
color: #fff;
width: 24px; height: 24px;
border-radius: 6px;
text-align: center;
line-height: 24px;
font-size: 13px;
margin-right: 8px;
letter-spacing: 0;
}
.mockup .vibe { font-size: 12px; color: var(--text-secondary); margin: 0 0 18px; }
.mockup .stage {
background: #0f0f0f;
border: 1px solid #202020;
border-radius: 10px;
padding: 14px;
min-height: 260px;
}
.mockup .stage > * + * { margin-top: 8px; }
.pill { font-size: 10px; padding: 2px 6px; border-radius: 4px; font-weight: 600; text-transform: uppercase; }
/* ─── Option A: Sticky-note ─────────────────────────── */
.a-card {
display: flex; gap: 14px;
background: rgba(234, 179, 8, .05);
border: 1px solid rgba(234, 179, 8, .18);
border-left: 4px solid rgba(234, 179, 8, .7);
border-radius: 8px;
padding: 14px 16px;
}
.a-card .emoji { font-size: 26px; line-height: 1; flex-shrink: 0; }
.a-card .body { flex: 1; min-width: 0; }
.a-card .note { font-size: 15px; font-weight: 600; color: #f1f1f1; margin: 0 0 6px; }
.a-card .divider { height: 1px; background: rgba(255,255,255,.08); margin: 8px 0; }
.a-card .meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.a-card .meta-left { font-size: 12px; color: var(--text-secondary); }
.a-card .meta-left b { color: #f1f1f1; font-weight: 500; }
.a-card .meta-left .dot { margin: 0 6px; opacity: .5; }
.a-card .tools { display: inline-flex; gap: 4px; }
/* ─── Option B: Timeline dot ────────────────────────── */
.b-timeline {
position: relative;
padding-left: 32px;
}
.b-timeline::before {
content: '';
position: absolute;
left: 12px; top: 0; bottom: 0;
width: 2px; background: rgba(255,255,255,.08);
}
.b-item { position: relative; margin-bottom: 22px; }
.b-item:last-child { margin-bottom: 0; }
.b-dot {
position: absolute; left: -26px; top: 2px;
width: 12px; height: 12px; border-radius: 50%;
background: var(--brand-red);
box-shadow: 0 0 0 3px rgba(230,30,30,.2), 0 0 0 6px var(--bg-panel);
}
.b-time {
font-size: 11px; font-weight: 700; letter-spacing: .06em;
color: var(--brand-red); text-transform: uppercase;
margin-bottom: 6px;
}
.b-card {
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.06);
border-radius: 8px;
padding: 10px 12px;
display: flex; align-items: flex-start; gap: 10px;
}
.b-card .emoji { font-size: 18px; line-height: 1.2; flex-shrink: 0; }
.b-card .body { flex: 1; min-width: 0; }
.b-card .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 4px; }
.b-card .author { font-size: 11.5px; color: var(--text-secondary); }
.b-card .tools { display: inline-flex; gap: 4px; align-self: center; }
/* ─── Option C: Media card ──────────────────────────── */
.c-card {
display: flex; gap: 14px;
background: #141414;
border: 1px solid rgba(255,255,255,.08);
border-radius: 10px;
padding: 12px;
}
.c-thumb {
width: 72px; height: 72px; flex-shrink: 0;
border-radius: 8px;
background: linear-gradient(135deg, rgba(230,30,30,.28), rgba(230,30,30,.08));
border: 1px solid rgba(230,30,30,.35);
display: flex; align-items: center; justify-content: center;
font-size: 30px;
}
.c-body { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
.c-head { display: flex; align-items: center; gap: 8px; }
.c-time {
font-size: 11px; font-weight: 700; letter-spacing: .04em;
color: #3ea6ff;
background: rgba(62, 166, 255, .12);
padding: 2px 8px; border-radius: 4px;
font-variant-numeric: tabular-nums;
}
.c-note { font-size: 14px; font-weight: 600; color: #f1f1f1; margin: 4px 0; line-height: 1.3; }
.c-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.c-author { font-size: 11.5px; color: var(--text-secondary); }
.c-tools { display: inline-flex; gap: 4px; }
/* ─── Option D: Chat-bubble ─────────────────────────── */
.d-row { display: flex; gap: 10px; align-items: flex-end; }
.d-avatar {
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
background: linear-gradient(135deg, #1f2937, #111827);
border: 2px solid var(--brand-red);
display: flex; align-items: center; justify-content: center;
font-size: 20px; line-height: 1;
box-shadow: 0 4px 12px rgba(0,0,0,.4);
}
.d-bubble {
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.08);
border-radius: 14px 14px 14px 4px;
padding: 10px 14px;
flex: 1; min-width: 0;
}
.d-bubble .note { font-size: 14px; font-weight: 500; color: #f1f1f1; margin: 0 0 6px; line-height: 1.35; }
.d-bubble .meta {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding-top: 6px; border-top: 1px solid rgba(255,255,255,.06);
}
.d-bubble .meta-left { font-size: 11px; color: var(--text-secondary); }
.d-bubble .meta-left b { color: #f1f1f1; font-weight: 500; }
.d-bubble .meta-left .dot { margin: 0 6px; opacity: .5; }
.d-tools { display: inline-flex; gap: 4px; }
/* ─── Shared button styling for mockups ─────────────── */
.btn-icon {
width: 26px; height: 26px; padding: 0;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 5px; border: none;
background: rgba(255,255,255,.06);
color: var(--text-primary); cursor: pointer;
font-size: 12px; line-height: 1;
transition: background .12s;
}
.btn-icon:hover { background: rgba(255,255,255,.14); }
.btn-slowmo {
width: auto; padding: 0 10px; gap: 4px;
background: rgba(230,30,30,.16); color: var(--brand-red);
font-size: 11px;
}
.btn-slowmo:hover { background: rgba(230,30,30,.28); color: #fff; }
/* header */
.header {
text-align: center; margin-bottom: 32px;
}
.header h1 {
margin: 0 0 6px;
font-size: 26px;
color: #fff;
}
.header p {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
}
</style>
</head>
<body>
<div class="header">
<h1>Coach Review Card — 4 design directions</h1>
<p>Same content, four different visual approaches. Pick one and I'll build it.</p>
</div>
<div class="page">
<!-- ═══ Option A: Sticky-note ═══════════════════════════ -->
<div class="mockup">
<h2><span class="letter">A</span> Sticky-note</h2>
<p class="vibe">Warm amber tint, chunky emoji, feels like a coach's Post-it on a locker.</p>
<div class="stage">
<div class="a-card">
<span class="emoji">👀</span>
<div class="body">
<p class="note">Keep Your Hand Up</p>
<div class="divider"></div>
<div class="meta">
<div class="meta-left">
<b>Ghassan</b><span class="dot">·</span>00:48 00:52
</div>
<div class="tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
<div class="a-card">
<span class="emoji">🤔</span>
<div class="body">
<p class="note">Missed the counter opportunity — great angle but no follow-up</p>
<div class="divider"></div>
<div class="meta">
<div class="meta-left">
<b>Coach Sara</b><span class="dot">·</span>02:05 02:20
</div>
<div class="tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ Option B: Timeline dot ═══════════════════════════ -->
<div class="mockup">
<h2><span class="letter">B</span> Timeline dot</h2>
<p class="vibe">Vertical spine with red dots. Reads like a session log — nice for many notes.</p>
<div class="stage">
<div class="b-timeline">
<div class="b-item">
<span class="b-dot"></span>
<div class="b-time">00:48 — 00:52</div>
<div class="b-card">
<span class="emoji">👀</span>
<div class="body">
<p class="note">Keep Your Hand Up</p>
<div class="author">Ghassan</div>
</div>
<div class="tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
<div class="b-item">
<span class="b-dot"></span>
<div class="b-time">02:05 — 02:20</div>
<div class="b-card">
<span class="emoji">🤔</span>
<div class="body">
<p class="note">Missed the counter — no follow-up after the angle change</p>
<div class="author">Coach Sara</div>
</div>
<div class="tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
<div class="b-item">
<span class="b-dot"></span>
<div class="b-time">03:25</div>
<div class="b-card">
<span class="emoji">😄</span>
<div class="body">
<p class="note">Excellent exit and re-entry — perfect execution</p>
<div class="author">Ghassan</div>
</div>
<div class="tools">
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ Option C: Media card ═══════════════════════════ -->
<div class="mockup">
<h2><span class="letter">C</span> Media card</h2>
<p class="vibe">Big emoji tile on the left, blue time badge — feels like a video-platform "content card".</p>
<div class="stage">
<div class="c-card">
<div class="c-thumb">👀</div>
<div class="c-body">
<div class="c-head"><span class="c-time">00:48 → 00:52</span></div>
<p class="c-note">Keep Your Hand Up</p>
<div class="c-foot">
<span class="c-author">Ghassan</span>
<div class="c-tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
<div class="c-card">
<div class="c-thumb">🤔</div>
<div class="c-body">
<div class="c-head"><span class="c-time">02:05 → 02:20</span></div>
<p class="c-note">Missed the counter — no follow-up after the angle change</p>
<div class="c-foot">
<span class="c-author">Coach Sara</span>
<div class="c-tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ═══ Option D: Chat-bubble ═══════════════════════════ -->
<div class="mockup">
<h2><span class="letter">D</span> Chat-bubble</h2>
<p class="vibe">Emoji as coach's avatar + message bubble. Feels like WhatsApp — casual + dense.</p>
<div class="stage">
<div class="d-row">
<div class="d-avatar">👀</div>
<div class="d-bubble">
<p class="note">Keep Your Hand Up</p>
<div class="meta">
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>00:4800:52</div>
<div class="d-tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
<div class="d-row" style="margin-top: 14px;">
<div class="d-avatar">🤔</div>
<div class="d-bubble">
<p class="note">Missed the counter — no follow-up after the angle change</p>
<div class="meta">
<div class="meta-left"><b>Coach Sara</b><span class="dot">·</span>02:0502:20</div>
<div class="d-tools">
<button class="btn-icon btn-slowmo"><i class="bi bi-play-fill"></i><i class="bi bi-hourglass-split"></i></button>
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
<div class="d-row" style="margin-top: 14px;">
<div class="d-avatar">😄</div>
<div class="d-bubble">
<p class="note">Excellent exit and re-entry — perfect execution</p>
<div class="meta">
<div class="meta-left"><b>Ghassan</b><span class="dot">·</span>03:25</div>
<div class="d-tools">
<button class="btn-icon">✏️</button>
<button class="btn-icon">🗑️</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -223,6 +223,10 @@
/* default aspect ratio; overridden per orientation */ /* default aspect ratio; overridden per orientation */
aspect-ratio: 16/9; aspect-ratio: 16/9;
max-height: 70vh; max-height: 70vh;
/* Establish a size container so descendants can size themselves relative
to the player width (used by the coach-note overlay to scale). */
container-type: inline-size;
container-name: ytpwrap;
} }
.ytp-wrap.portrait { aspect-ratio: 9/16; max-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; } .ytp-wrap.portrait { aspect-ratio: 9/16; max-height: 80vh; width: auto; max-width: 100%; margin: 0 auto; }
.ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; } .ytp-wrap.square { aspect-ratio: 1/1; max-height: 75vh; max-width: 75vh; margin: 0 auto; }

File diff suppressed because it is too large Load Diff