Add sports-match type, device tracking, profile visits, and share refactor

- New SportsMatch model/controller and sports UI components/modal
- Move share-modal to a reusable x-share-modal/x-share-button component
- Add VideoSharedWithUser notification and share-to-members flow
- Device/user-agent tracking on views, downloads, share accesses
- ProfileVisit model + migration; subscription source tracking
- Email thumbnail support; remove stale TODO files
This commit is contained in:
ghassan committed 2026-05-29 01:50:28 +03:00
1 parent 6aae6f86b6
commit 73527f3781
61 files changed
+4014 -636

No files matched your search

@@ -0,0 +1,887 @@
{{-- ════════════════════════════════════════════════════════════════════════
Create / Edit Sports Match — progressive-disclosure modal.
Opened from the front-end "Sports" chooser card via openSportsMatchModal().
A match always belongs to one of the user's videos (video_id required).
Only the basic section is needed for a first (draft) save; everything else
lives in collapsed sections and can be completed later by editing the same
record. Bootstrap 5 modal + collapsible blocks. Field names map 1:1 to
SportsMatchController validation.
════════════════════════════════════════════════════════════════════════ --}}
<div class="modal fade" id="sportsMatchModal" tabindex="-1" aria-labelledby="sportsMatchModalLabel"
aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false" data-bs-theme="dark">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content sm-content">
<div class="modal-header sm-header">
<div class="d-flex align-items-center gap-2">
<span class="sm-header-icon"><i class="bi bi-trophy-fill"></i></span>
<div>
<h5 class="modal-title mb-0" id="sportsMatchModalLabel">Create Sports Match</h5>
<small class="text-secondary" id="sm-status-chip">Draft</small>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{-- Friendly note --}}
<div class="alert sm-note d-flex align-items-start gap-2" role="alert">
<i class="bi bi-info-circle-fill mt-1"></i>
<div>Save the basic match info now — you can add scores, stats, officials and more later by editing this match.</div>
</div>
<form id="sportsMatchForm" novalidate>
@csrf
<input type="hidden" name="match_id" id="sm-match-id" value="">
<input type="hidden" name="video_id" id="sm-video-id" value="">
<input type="hidden" name="status" id="sm-status" value="draft">
{{-- Video upload (create) --}}
<div class="sm-group" id="sm-video-create">
<div class="sm-group-title"><i class="bi bi-film"></i> Match video <span class="text-danger">*</span></div>
<div class="sm-video-drop" id="sm-video-drop">
<input type="file" id="sm-video-file" accept="video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv" hidden>
<div id="sm-video-idle" class="sm-video-idle">
<i class="bi bi-cloud-arrow-up"></i>
<div>Click to choose the match video</div>
<small class="text-secondary">MP4, MOV, MKV, WebM, AVI…</small>
</div>
<div id="sm-video-picked" class="sm-video-picked d-none">
<i class="bi bi-film"></i>
<div class="sm-video-meta">
<span class="sm-video-name" id="sm-video-name"></span>
<span class="sm-video-size text-secondary" id="sm-video-size"></span>
</div>
<button type="button" class="btn-close btn-close-white" id="sm-video-clear" aria-label="Remove"></button>
</div>
</div>
<div class="text-danger small mt-2 d-none" id="sm-video-err">Please choose a video to upload.</div>
</div>
{{-- Attached video note (edit) --}}
<div class="sm-group d-none" id="sm-video-attached">
<div class="sm-group-title"><i class="bi bi-film"></i> Match video</div>
<div class="sm-attached d-flex align-items-center gap-2">
<i class="bi bi-play-circle-fill"></i>
<span id="sm-video-attached-title" class="text-truncate">Attached video</span>
</div>
</div>
{{-- Match details --}}
<div class="sm-group">
<div class="sm-group-title"><i class="bi bi-card-text"></i> Match details</div>
<div class="mb-3">
<label for="sm-title" class="form-label">Match title <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="title" id="sm-title" placeholder="e.g. Final — Ali vs. Khan" required>
<div class="invalid-feedback" data-field="title"></div>
</div>
<div class="mb-3">
<label for="sm-event" class="form-label">Event name</label>
<input type="text" class="form-control" name="event_name" id="sm-event" placeholder="e.g. National Championship 2026">
</div>
<div class="row g-3">
<div class="col-sm-6">
<label for="sm-date" class="form-label">Date</label>
<input type="date" class="form-control" name="match_date" id="sm-date">
</div>
<div class="col-sm-6">
<label for="sm-time" class="form-label">Time</label>
<input type="time" class="form-control" name="match_time" id="sm-time">
<div class="invalid-feedback" data-field="match_time"></div>
</div>
</div>
</div>
{{-- Participants --}}
<div class="sm-group">
<div class="sm-group-title"><i class="bi bi-people-fill"></i> Participants</div>
<div class="row g-3">
<div class="col-md-6">
<div class="sm-pcard">
<div class="sm-pcard-head"><span class="sm-pcard-badge sm-badge-1">1</span> Participant 1</div>
<x-sports-image name="media_participant1_photo" label="Photo" class="sm-img-lg" />
<input type="text" class="form-control" name="participant1_name" placeholder="Name">
<input type="text" class="form-control" name="participants[p1_club]" placeholder="Club / team name">
<label class="sm-mini-label">Club logo</label>
<x-sports-image name="media_club1_logo" label="Logo" class="sm-img-sm" />
</div>
</div>
<div class="col-md-6">
<div class="sm-pcard">
<div class="sm-pcard-head"><span class="sm-pcard-badge sm-badge-2">2</span> Participant 2</div>
<x-sports-image name="media_participant2_photo" label="Photo" class="sm-img-lg" />
<input type="text" class="form-control" name="participant2_name" placeholder="Name">
<input type="text" class="form-control" name="participants[p2_club]" placeholder="Club / team name">
<label class="sm-mini-label">Club logo</label>
<x-sports-image name="media_club2_logo" label="Logo" class="sm-img-sm" />
</div>
</div>
</div>
</div>
{{-- Referee --}}
<div class="sm-group">
<div class="sm-group-title"><i class="bi bi-person-badge-fill"></i> Referee</div>
<div class="d-flex align-items-center gap-3">
<x-sports-image name="media_referee_photo" label="Photo" class="sm-img-avatar" />
<div class="flex-grow-1">
<label for="sm-referee" class="form-label">Referee name</label>
<input type="text" class="form-control" name="referee_name" id="sm-referee" placeholder="Referee name">
</div>
</div>
</div>
{{-- Result --}}
<div class="sm-group">
<div class="sm-group-title"><i class="bi bi-flag-fill"></i> Result</div>
<label for="sm-score" class="form-label">Final score</label>
<input type="text" class="form-control" name="result[final_result]" id="sm-score" placeholder="e.g. 3 – 1">
</div>
{{-- ── ADVANCED (revealed only when editing) ───────────────────── --}}
<div id="sm-advanced" class="d-none">
<p class="sm-optional-head">
<i class="bi bi-sliders"></i> Advanced details
<span class="text-secondary">— optional, open only what you need</span>
</p>
<div class="accordion sm-accordion" id="sportsAccordion">
{{-- Match info (sport, type, venue) --}}
<x-sports-section id="meta" title="Match info" icon="bi-info-circle">
<div class="mb-3">
<label for="sm-sport" class="form-label">Sport</label>
<input type="text" class="form-control" name="sport" id="sm-sport" list="sm-sport-list" placeholder="e.g. Boxing, Football, Tennis…">
<datalist id="sm-sport-list">
<option value="Boxing"><option value="MMA"><option value="Kickboxing">
<option value="Wrestling"><option value="Judo"><option value="Taekwondo">
<option value="Football"><option value="Basketball"><option value="Tennis">
<option value="Volleyball"><option value="Badminton"><option value="Cricket">
<option value="Rugby"><option value="Hockey"><option value="Baseball">
<option value="Athletics"><option value="Swimming"><option value="Cycling">
<option value="Esports">
</datalist>
<div class="form-text">Sets the label for the Segments section (Rounds, Sets, Periods…).</div>
</div>
<div class="mb-3"><label class="form-label">Match type</label>
<input type="text" class="form-control" name="match_type" placeholder="Final, Semi-final, Friendly…"></div>
<div class="mb-0"><label class="form-label">Venue name</label>
<input type="text" class="form-control" name="venue_name" placeholder="e.g. City Arena"></div>
</x-sports-section>
{{-- Competition --}}
<x-sports-section id="comp" title="Competition Details" icon="bi-award">
<div class="mb-3"><label class="form-label">Competition name</label>
<input type="text" class="form-control" name="competition[name]"></div>
<div class="mb-3"><label class="form-label">Competition type</label>
<input type="text" class="form-control" name="competition[type]" placeholder="League, Cup, Tournament…"></div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Stage</label>
<input type="text" class="form-control" name="competition[stage]" placeholder="Group, Quarter-final…"></div>
<div class="col-sm-6"><label class="form-label">Season</label>
<input type="text" class="form-control" name="competition[season]" placeholder="2025/26"></div>
</div>
<div class="mb-3"><label class="form-label">Organizer</label>
<input type="text" class="form-control" name="competition[organizer]"></div>
<div class="mb-0"><label class="form-label">Championship / league / tournament name</label>
<input type="text" class="form-control" name="competition[championship_name]"></div>
</x-sports-section>
{{-- Participants details (clubs are in the basic cards) --}}
<x-sports-section id="part" title="Participants Details" icon="bi-people">
<div class="sm-subcard">
<div class="sm-subcard-title">Participant 1</div>
<div class="row g-3">
<div class="col-sm-6"><label class="form-label">Type</label>
<input type="text" class="form-control" name="participants[p1_type]" placeholder="Individual / Team / Pair"></div>
<div class="col-sm-6"><label class="form-label">Country</label>
<input type="text" class="form-control" name="participants[p1_country]"></div>
<div class="col-sm-6"><label class="form-label">Role</label>
<input type="text" class="form-control" name="participants[p1_role]" placeholder="home, away, red, blue…"></div>
</div>
</div>
<div class="sm-subcard">
<div class="sm-subcard-title">Participant 2</div>
<div class="row g-3">
<div class="col-sm-6"><label class="form-label">Type</label>
<input type="text" class="form-control" name="participants[p2_type]" placeholder="Individual / Team / Pair"></div>
<div class="col-sm-6"><label class="form-label">Country</label>
<input type="text" class="form-control" name="participants[p2_country]"></div>
<div class="col-sm-6"><label class="form-label">Role</label>
<input type="text" class="form-control" name="participants[p2_role]" placeholder="home, away, red, blue…"></div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-4"><label class="form-label">Weight class / division</label>
<input type="text" class="form-control" name="participants[weight_class]"></div>
<div class="col-sm-4"><label class="form-label">Gender division</label>
<input type="text" class="form-control" name="participants[gender_division]" placeholder="Men, Women, Mixed"></div>
<div class="col-sm-4"><label class="form-label">Level</label>
<select class="form-select" name="participants[level]">
<option value="">—</option><option>Amateur</option><option>Professional</option>
</select></div>
</div>
<label class="form-label">Extra participants</label>
<div class="form-text mb-2">For relays, doubles or multi-competitor formats.</div>
<div id="sm-extra-list"></div>
<button type="button" class="btn btn-sm btn-outline-light" data-sm-add="extra">
<i class="bi bi-plus-lg"></i> Add participant
</button>
</x-sports-section>
{{-- Additional officials (referee is in the basic section) --}}
<x-sports-section id="off" title="Officials" icon="bi-person-badge">
<div class="form-text mb-2">Judges, umpires, linesmen, doctor, supervisor, timekeeper…</div>
<div id="sm-officials-list"></div>
<button type="button" class="btn btn-sm btn-outline-light" data-sm-add="official">
<i class="bi bi-plus-lg"></i> Add official
</button>
</x-sports-section>
{{-- Venue details --}}
<x-sports-section id="venue" title="Venue Details" icon="bi-geo-alt">
<div class="mb-3"><label class="form-label">Full address</label>
<input type="text" class="form-control" name="venue[address]"></div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">City</label>
<input type="text" class="form-control" name="venue[city]"></div>
<div class="col-sm-6"><label class="form-label">Country</label>
<input type="text" class="form-control" name="venue[country]"></div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">GPS latitude</label>
<input type="text" class="form-control" name="venue[lat]" placeholder="e.g. 26.2235"></div>
<div class="col-sm-6"><label class="form-label">GPS longitude</label>
<input type="text" class="form-control" name="venue[lng]" placeholder="e.g. 50.5876"></div>
</div>
<div class="mb-0"><label class="form-label">Venue notes</label>
<textarea class="form-control" rows="2" name="venue[notes]"></textarea></div>
</x-sports-section>
{{-- Result details (final score is in the basic section) --}}
<x-sports-section id="result" title="Result Details" icon="bi-flag">
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Winner</label>
<input type="text" class="form-control" name="result[winner]"></div>
<div class="col-sm-6"><label class="form-label">Outcome type</label>
<input type="text" class="form-control" name="result[outcome_type]" placeholder="KO, decision, draw, walkover…"></div>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Rank / placement</label>
<input type="text" class="form-control" name="result[rank]"></div>
</div>
<div class="mb-0"><label class="form-label">Result notes</label>
<textarea class="form-control" rows="2" name="result[notes]"></textarea></div>
</x-sports-section>
{{-- Segments --}}
<x-sports-section id="seg" title="Segments" icon="bi-list-ol">
<div class="form-text mb-2">Generic segments — rounds, periods, sets, halves, quarters, laps, innings or maps.</div>
<div id="sm-segments-list"></div>
<button type="button" class="btn btn-sm btn-outline-light" data-sm-add="segment">
<i class="bi bi-plus-lg"></i> <span data-sm-seg-add>Add segment</span>
</button>
</x-sports-section>
{{-- Statistics --}}
<x-sports-section id="stat" title="Statistics" icon="bi-bar-chart">
<div id="sm-stats-list"></div>
<button type="button" class="btn btn-sm btn-outline-light" data-sm-add="statistic">
<i class="bi bi-plus-lg"></i> Add statistic
</button>
</x-sports-section>
{{-- More photos (event poster + image meta) --}}
<x-sports-section id="media" title="More Photos" icon="bi-images">
<div class="form-text mb-3">Participant, club and referee images are in the sections above. Add an event poster and image metadata here.</div>
<label class="form-label">Event poster / banner</label>
<x-sports-image name="media_event_poster" label="Upload &amp; crop" class="sm-img-wide mb-3" />
<div class="mb-3"><label class="form-label">Image caption</label>
<input type="text" class="form-control" name="media[caption]"></div>
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Image alt text</label>
<input type="text" class="form-control" name="media[alt]"></div>
<div class="col-sm-6"><label class="form-label">Image credit</label>
<input type="text" class="form-control" name="media[credit]"></div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="sm-media-public" name="media[public]" value="1">
<label class="form-check-label" for="sm-media-public">Display these images publicly</label>
</div>
</x-sports-section>
{{-- Reviews --}}
<x-sports-section id="rev" title="Reviews &amp; Notes" icon="bi-clipboard-check">
<div class="row g-3 mb-3">
<div class="col-sm-6"><label class="form-label">Review type</label>
<input type="text" class="form-control" name="reviews[review_type]" placeholder="VAR, protest, appeal…"></div>
<div class="col-sm-6"><label class="form-label">Requested by</label>
<input type="text" class="form-control" name="reviews[requested_by]"></div>
</div>
<div class="mb-3"><label class="form-label">Review result</label>
<input type="text" class="form-control" name="reviews[review_result]"></div>
<div class="mb-3"><label class="form-label">Source URL</label>
<input type="url" class="form-control" name="reviews[source_url]" placeholder="https://…"></div>
<div class="mb-3"><label class="form-label">Verification notes</label>
<textarea class="form-control" rows="2" name="reviews[verification_notes]"></textarea></div>
<div class="mb-0"><label class="form-label">Admin notes</label>
<textarea class="form-control" rows="2" name="reviews[admin_notes]"></textarea></div>
</x-sports-section>
</div>
</div>{{-- /advanced --}}
</form>
</div>{{-- /modal-body --}}
<div class="modal-footer sm-footer">
<button type="button" class="btn btn-outline-light" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-light" id="sm-save-basic">
<i class="bi bi-save"></i> Save basic info
</button>
<button type="button" class="btn btn-danger" id="sm-save-continue">
<i class="bi bi-pencil-square"></i> Save &amp; continue editing
</button>
</div>
</div>
</div>
</div>
{{-- ── Repeatable row templates ─────────────────────────────────────────── --}}
<template id="sm-tpl-official">
<div class="sm-row" data-sm-row="official">
<div class="row g-2 align-items-start">
<div class="col-sm-4"><input type="text" class="form-control form-control-sm" data-n="role" placeholder="Role (referee, judge…)"></div>
<div class="col-sm-4"><input type="text" class="form-control form-control-sm" data-n="name" placeholder="Name"></div>
<div class="col-sm-3">
<div class="sm-img-field" data-img="photo">
<input type="file" data-n="photo" accept=".jpg,.jpeg,.png,.webp" hidden>
<input type="hidden" data-n="photo_existing">
<button type="button" class="sm-img-drop sm-img-drop-sm" data-sm-official-crop>
<img class="sm-img-preview sm-img-preview-sm d-none" alt="">
<span class="sm-img-ph"><i class="bi bi-crop"></i><span>Photo</span></span>
</button>
</div>
</div>
<div class="col-sm-1 text-end">
<button type="button" class="btn btn-sm btn-outline-danger" data-sm-remove><i class="bi bi-x-lg"></i></button>
</div>
</div>
</div>
</template>
<template id="sm-tpl-segment">
<div class="sm-row" data-sm-row="segment">
<div class="row g-2 align-items-start">
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="type" placeholder="Type (round, set…)"></div>
<div class="col-sm-2"><input type="text" class="form-control form-control-sm" data-n="number" placeholder="No."></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="score" placeholder="Score / result"></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="winner" placeholder="Winner"></div>
<div class="col-sm-1 text-end"><button type="button" class="btn btn-sm btn-outline-danger" data-sm-remove><i class="bi bi-x-lg"></i></button></div>
<div class="col-12"><input type="text" class="form-control form-control-sm" data-n="notes" placeholder="Notes (optional)"></div>
</div>
</div>
</template>
<template id="sm-tpl-statistic">
<div class="sm-row" data-sm-row="statistic">
<div class="row g-2 align-items-start">
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="name" placeholder="Statistic name"></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="value" placeholder="Value"></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="owner" placeholder="Owner (who/which side)"></div>
<div class="col-sm-2"><input type="text" class="form-control form-control-sm" data-n="notes" placeholder="Notes"></div>
<div class="col-sm-1 text-end"><button type="button" class="btn btn-sm btn-outline-danger" data-sm-remove><i class="bi bi-x-lg"></i></button></div>
</div>
</div>
</template>
{{-- ── Image croppers (outside the form so their inner file inputs aren't submitted) ──
Six form-mode croppers write the cropped file straight onto each hidden input;
one callback-mode cropper serves all dynamic official rows. --}}
<x-image-cropper id="smc_media_participant1_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 1 photo" target-input="sm-file-media_participant1_photo" preview-img="sm-prev-media_participant1_photo" />
<x-image-cropper id="smc_media_participant2_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Participant 2 photo" target-input="sm-file-media_participant2_photo" preview-img="sm-prev-media_participant2_photo" />
<x-image-cropper id="smc_media_referee_photo" :width="380" :height="380" shape="square" output-width="600" title="Crop Referee photo" target-input="sm-file-media_referee_photo" preview-img="sm-prev-media_referee_photo" />
<x-image-cropper id="smc_media_club1_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 1 logo" target-input="sm-file-media_club1_logo" preview-img="sm-prev-media_club1_logo" />
<x-image-cropper id="smc_media_club2_logo" :width="380" :height="380" shape="square" output-width="600" title="Crop Club / team 2 logo" target-input="sm-file-media_club2_logo" preview-img="sm-prev-media_club2_logo" />
<x-image-cropper id="smc_media_event_poster" :width="448" :height="252" shape="square" output-width="1280" title="Crop Event poster / banner" target-input="sm-file-media_event_poster" preview-img="sm-prev-media_event_poster" />
<x-image-cropper id="smc_official" :width="360" :height="360" shape="square" output-width="500" title="Crop Official photo" result-callback="smOfficialCropDone" />
<template id="sm-tpl-extra">
<div class="sm-row" data-sm-row="extra">
<div class="row g-2 align-items-start">
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="name" placeholder="Name"></div>
<div class="col-sm-2"><input type="text" class="form-control form-control-sm" data-n="type" placeholder="Type"></div>
<div class="col-sm-3"><input type="text" class="form-control form-control-sm" data-n="club" placeholder="Club / team"></div>
<div class="col-sm-2"><input type="text" class="form-control form-control-sm" data-n="country" placeholder="Country"></div>
<div class="col-sm-1"><input type="text" class="form-control form-control-sm" data-n="role" placeholder="Role"></div>
<div class="col-sm-1 text-end"><button type="button" class="btn btn-sm btn-outline-danger" data-sm-remove><i class="bi bi-x-lg"></i></button></div>
</div>
</div>
</template>
<style>
.sm-content { background: #181818; border: 1px solid #2a2a2a; }
.sm-header { border-bottom: 1px solid #262626; }
.sm-header-icon {
width: 40px; height: 40px; border-radius: 11px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(245,158,11,.14); color: #f59e0b; font-size: 19px;
}
.sm-note { background: rgba(230,30,30,.08); border: 1px solid rgba(230,30,30,.18); color: #d8d8d8; font-size: 13px; }
.sm-note i { color: #e61e1e; }
.sm-basic .form-label { font-weight: 600; font-size: 13px; }
.sm-optional-head { margin: 22px 0 10px; font-size: 13px; font-weight: 700; color: #9a9a9a; }
.sm-optional-head i { color: #e61e1e; }
.sm-accordion .accordion-item { background: #141414; border: 1px solid #262626; margin-bottom: 8px; border-radius: 10px !important; overflow: hidden; }
.sm-accordion .accordion-button { background: #141414; color: #e8e8e8; font-weight: 600; font-size: 14px; box-shadow: none; }
.sm-accordion .accordion-button:not(.collapsed) { background: #1b1414; color: #fff; }
.sm-accordion .accordion-button:focus { box-shadow: none; border-color: transparent; }
.sm-accordion .accordion-button::after { filter: invert(1) grayscale(1) brightness(1.4); }
.sm-accordion .accordion-button .sm-acc-ico { color: #e61e1e; margin-right: 10px; font-size: 16px; }
.sm-accordion .accordion-body { background: #181818; }
.sm-accordion .badge { font-weight: 600; }
.sm-subcard { background: #111; border: 1px solid #242424; border-radius: 10px; padding: 14px; margin-bottom: 14px; }
.sm-subcard-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: #e61e1e; margin-bottom: 10px; }
.sm-row { background: #111; border: 1px solid #242424; border-radius: 10px; padding: 12px; margin-bottom: 10px; }
.sm-img-field { position: relative; }
.sm-img-preview { display: block; width: 100%; max-height: 130px; object-fit: cover; border-radius: 8px; margin-bottom: 8px; border: 1px solid #2a2a2a; }
.sm-img-preview-sm { max-height: 64px; }
.sm-hr { border-color: #262626; }
.sm-footer { border-top: 1px solid #262626; }
.sm-footer .btn-danger { background: #e61e1e; border-color: #e61e1e; }
.sm-footer .btn-danger:hover { background: #c81818; border-color: #c81818; }
#sportsMatchModal .form-text { color: #6f6f6f; font-size: 12px; }
/* Video upload dropzone */
.sm-video-drop { border: 1.5px dashed #2f2f2f; border-radius: 12px; background: #111; cursor: pointer; transition: border-color .15s, background .15s; }
.sm-video-drop:hover { border-color: #e61e1e; background: #161313; }
.sm-video-drop.sm-invalid { border-color: #dc3545; }
.sm-video-idle { padding: 22px 16px; text-align: center; color: #8a8a8a; }
.sm-video-idle i { font-size: 26px; color: #e61e1e; display: block; margin-bottom: 6px; }
.sm-video-idle div { font-size: 13px; font-weight: 600; color: #cfcfcf; }
.sm-video-picked { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
.sm-video-picked > i { font-size: 22px; color: #e61e1e; }
.sm-video-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.sm-video-name { font-size: 13px; font-weight: 600; color: #e8e8e8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sm-video-size { font-size: 12px; }
/* Cropper-backed image fields */
.sm-img-drop { width: 100%; border: 1.5px dashed #2f2f2f; border-radius: 10px; background: #111; cursor: pointer; padding: 0; overflow: hidden; display: block; position: relative; min-height: 96px; transition: border-color .15s, background .15s; }
.sm-img-drop:hover { border-color: #e61e1e; background: #161313; }
.sm-img-drop-sm { min-height: 64px; }
.sm-img-ph { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; min-height: 96px; color: #8a8a8a; font-size: 12px; }
.sm-img-drop-sm .sm-img-ph { min-height: 64px; }
.sm-img-ph i { font-size: 20px; color: #e61e1e; }
.sm-img-preview { display: block; width: 100%; max-height: 150px; object-fit: cover; }
.sm-img-preview-sm { max-height: 64px; }
/* ── Beautified layout: grouped cards ───────────────────────────────── */
.sm-group { background: #141414; border: 1px solid #242424; border-radius: 14px; padding: 16px 18px; margin-bottom: 14px; }
.sm-group-title { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #cfcfcf; margin-bottom: 14px; }
.sm-group-title i { color: #e61e1e; font-size: 15px; }
#sportsMatchModal .form-label { font-weight: 600; font-size: 12.5px; color: #b9b9b9; }
.sm-attached { background: #111; border: 1px solid #242424; border-radius: 10px; padding: 10px 14px; color: #d8d8d8; font-size: 13px; }
.sm-attached i { color: #e61e1e; }
/* Participant cards */
.sm-pcard { background: #111; border: 1px solid #242424; border-radius: 12px; padding: 14px; height: 100%; }
.sm-pcard-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; color: #cfcfcf; margin-bottom: 12px; }
.sm-pcard-badge { width: 22px; height: 22px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 700; color: #fff; }
.sm-badge-1 { background: #2563eb; }
.sm-badge-2 { background: #e61e1e; }
.sm-pcard .form-control { margin-bottom: 9px; }
.sm-pcard .sm-img-field { margin-bottom: 9px; }
.sm-mini-label { display: block; font-size: 11px; font-weight: 600; color: #8a8a8a; margin: 2px 0 6px; }
/* Image field size variants */
.sm-img-lg .sm-img-drop, .sm-img-lg .sm-img-ph { min-height: 132px; }
.sm-img-lg .sm-img-preview { max-height: 132px; }
.sm-img-sm .sm-img-drop, .sm-img-sm .sm-img-ph { min-height: 60px; }
.sm-img-sm .sm-img-preview { max-height: 60px; }
.sm-img-wide .sm-img-drop, .sm-img-wide .sm-img-ph { min-height: 120px; }
.sm-img-wide .sm-img-preview { max-height: 200px; }
.sm-img-avatar { flex: 0 0 auto; }
.sm-img-avatar .sm-img-drop { width: 74px; height: 74px; min-height: 74px; border-radius: 50%; }
.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; }
</style>
<script>
(function () {
const modalEl = document.getElementById('sportsMatchModal');
if (!modalEl) return;
const form = document.getElementById('sportsMatchForm');
const idInput = document.getElementById('sm-match-id');
const videoIdInp = document.getElementById('sm-video-id');
const statusInp = document.getElementById('sm-status');
const titleEl = document.getElementById('sportsMatchModalLabel');
const statusChip = document.getElementById('sm-status-chip');
const videoFile = document.getElementById('sm-video-file');
let bsModal, _origBtnHtml = {};
// Sport → segment label (only relabels; never rebuilds the form)
const SEG_LABELS = {
boxing: 'Rounds', mma: 'Rounds', kickboxing: 'Rounds', wrestling: 'Rounds', judo: 'Rounds', taekwondo: 'Rounds',
tennis: 'Sets', volleyball: 'Sets', badminton: 'Sets', squash: 'Sets', 'table tennis': 'Sets',
football: 'Periods', soccer: 'Periods', basketball: 'Quarters', rugby: 'Halves', hockey: 'Periods',
cricket: 'Innings', baseball: 'Innings', cycling: 'Laps', athletics: 'Heats', esports: 'Maps',
};
function getModal() {
if (!bsModal) bsModal = new bootstrap.Modal(modalEl);
return bsModal;
}
function token() { return form.querySelector('[name="_token"]').value; }
// ── Public entry point ───────────────────────────────────────────────
window.openSportsMatchModal = function (matchId) {
resetForm();
if (matchId) loadMatch(matchId);
getModal().show();
};
function resetForm() {
form.reset();
idInput.value = '';
videoIdInp.value = '';
statusInp.value = 'draft';
titleEl.textContent = 'Create Sports Match';
statusChip.textContent = 'Draft';
['sm-officials-list', 'sm-segments-list', 'sm-stats-list', 'sm-extra-list']
.forEach(id => { const el = document.getElementById(id); if (el) el.innerHTML = ''; });
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
form.querySelectorAll('.sm-img-preview').forEach(img => { img.src = ''; img.classList.add('d-none'); });
form.querySelectorAll('.sm-img-ph').forEach(ph => ph.classList.remove('d-none'));
document.getElementById('sm-video-create').classList.remove('d-none');
document.getElementById('sm-video-attached').classList.add('d-none');
document.getElementById('sm-advanced').classList.add('d-none'); // advanced shows only when editing
clearVideoFile();
setNowDateTime();
applySegmentLabel('');
}
// Default the date & time pickers to the current date and time
function setNowDateTime() {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
document.getElementById('sm-date').value = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
document.getElementById('sm-time').value = pad(now.getHours()) + ':' + pad(now.getMinutes());
}
// ── Match video upload (create mode) ─────────────────────────────────
document.getElementById('sm-video-drop').addEventListener('click', () => videoFile.click());
videoFile.addEventListener('change', function () {
const f = videoFile.files && videoFile.files[0];
if (!f) return clearVideoFile();
document.getElementById('sm-video-err').classList.add('d-none');
document.getElementById('sm-video-drop').classList.remove('sm-invalid');
document.getElementById('sm-video-name').textContent = f.name;
document.getElementById('sm-video-size').textContent = fmtSize(f.size);
document.getElementById('sm-video-idle').classList.add('d-none');
document.getElementById('sm-video-picked').classList.remove('d-none');
});
document.getElementById('sm-video-clear').addEventListener('click', function (e) {
e.stopPropagation(); clearVideoFile();
});
function clearVideoFile() {
videoFile.value = '';
document.getElementById('sm-video-idle').classList.remove('d-none');
document.getElementById('sm-video-picked').classList.add('d-none');
}
function fmtSize(b) {
if (!b) return '';
const k = 1024, u = ['B', 'KB', 'MB', 'GB'], i = Math.floor(Math.log(b) / Math.log(k));
return (b / Math.pow(k, i)).toFixed(1) + ' ' + u[i];
}
// ── Cropper wiring ───────────────────────────────────────────────────
// Named single images: trigger the field's form-mode cropper.
window.smOpenCropper = function (cropperId) {
if (typeof window['openCropperModal_' + cropperId] !== 'function') return;
window['openCropperModal_' + cropperId]();
const internal = document.getElementById('tcInput_' + cropperId);
if (internal) internal.click();
};
// Reflect a chosen/cropped file into its field (show preview, hide placeholder).
function reflectImage(field) {
if (!field) return;
const input = field.querySelector('input[type="file"]');
const img = field.querySelector('.sm-img-preview');
const ph = field.querySelector('.sm-img-ph');
if (input && input.files && input.files[0]) {
if (img) { img.src = URL.createObjectURL(input.files[0]); img.classList.remove('d-none'); }
if (ph) ph.classList.add('d-none');
}
}
document.querySelectorAll('#sportsMatchModal input[type="file"][name^="media_"]').forEach(inp => {
inp.addEventListener('change', () => reflectImage(inp.closest('.sm-img-field')));
});
// Officials photos: one shared callback-mode cropper routed to the active row.
let _smOfficialField = null;
window.smOfficialCropDone = function (file) {
const field = _smOfficialField;
if (field && file) {
const input = field.querySelector('input[data-n="photo"]');
if (input) {
const dt = new DataTransfer(); dt.items.add(file);
input.files = dt.files;
}
const img = field.querySelector('.sm-img-preview');
const ph = field.querySelector('.sm-img-ph');
if (img) { img.src = URL.createObjectURL(file); img.classList.remove('d-none'); }
if (ph) ph.classList.add('d-none');
}
if (typeof window.closeCropperModal === 'function') window.closeCropperModal('smc_official');
};
// ── Sport label sync ─────────────────────────────────────────────────
document.getElementById('sm-sport').addEventListener('input', function () {
applySegmentLabel(this.value);
});
function applySegmentLabel(sport) {
const label = SEG_LABELS[(sport || '').trim().toLowerCase()] || 'Segments';
const head = document.querySelector('#sm-heading-seg .sm-acc-title');
if (head) head.textContent = label;
const addLbl = document.querySelector('[data-sm-seg-add]');
if (addLbl) addLbl.textContent = 'Add ' + label.toLowerCase().replace(/s$/, '');
}
// ── Repeatable rows ──────────────────────────────────────────────────
const REPEAT = {
official: { tpl: 'sm-tpl-official', list: 'sm-officials-list', name: 'officials' },
segment: { tpl: 'sm-tpl-segment', list: 'sm-segments-list', name: 'segments' },
statistic: { tpl: 'sm-tpl-statistic', list: 'sm-stats-list', name: 'statistics' },
extra: { tpl: 'sm-tpl-extra', list: 'sm-extra-list', name: 'extra_participants' },
};
document.querySelectorAll('[data-sm-add]').forEach(btn => {
btn.addEventListener('click', () => addRow(btn.getAttribute('data-sm-add')));
});
function addRow(kind, values) {
const cfg = REPEAT[kind];
const tpl = document.getElementById(cfg.tpl);
const node = tpl.content.firstElementChild.cloneNode(true);
document.getElementById(cfg.list).appendChild(node);
node.querySelector('[data-sm-remove]').addEventListener('click', () => { node.remove(); renumber(kind); });
// Official rows: wire the shared photo cropper to this row
const cropBtn = node.querySelector('[data-sm-official-crop]');
if (cropBtn) cropBtn.addEventListener('click', () => {
_smOfficialField = node.querySelector('.sm-img-field');
window.smOpenCropper('smc_official');
});
if (values) {
node.querySelectorAll('[data-n]').forEach(inp => {
const key = inp.getAttribute('data-n');
if (key === 'photo') return; // file input — can't prefill
if (key === 'photo_existing' && values.photo) { inp.value = values.photo; }
else if (values[key] != null) inp.value = values[key];
});
const pv = node.querySelector('.sm-img-preview');
const ph = node.querySelector('.sm-img-ph');
if (pv && values.photo_url) { pv.src = values.photo_url; pv.classList.remove('d-none'); if (ph) ph.classList.add('d-none'); }
}
renumber(kind);
return node;
}
// Renumber a repeatable group's inputs to contiguous 0..n names
function renumber(kind) {
const cfg = REPEAT[kind];
const rows = document.querySelectorAll('#' + cfg.list + ' [data-sm-row="' + kind + '"]');
rows.forEach((row, i) => {
row.querySelectorAll('[data-n]').forEach(inp => {
inp.setAttribute('name', cfg.name + '[' + i + '][' + inp.getAttribute('data-n') + ']');
});
});
}
// ── Load an existing record for editing ──────────────────────────────
function loadMatch(id) {
fetch('{{ url('sports-matches') }}/' + id + '/edit', { headers: { 'Accept': 'application/json' } })
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
.then(({ match }) => populate(match))
.catch(() => toast('Could not load this match.', 'error'));
}
function populate(m) {
idInput.value = m.id;
statusInp.value = m.status || 'draft';
titleEl.textContent = 'Edit Sports Match';
statusChip.textContent = (m.status === 'published') ? 'Published' : 'Draft';
// Video is already attached in edit mode — hide the uploader, show a note
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 = m.video_title || ('Video #' + m.video_id);
// Reveal the advanced area for editing
document.getElementById('sm-advanced').classList.remove('d-none');
const set = (name, val) => { const el = form.querySelector('[name="' + name + '"]'); if (el && val != null) el.value = val; };
['video_id','sport','title','event_name','match_type','match_date','match_time',
'participant1_name','participant2_name','referee_name','venue_name'].forEach(k => set(k, m[k]));
applySegmentLabel(m.sport || '');
const grp = (obj, prefix) => { if (!obj) return; Object.keys(obj).forEach(k => { if (k === 'extra') return; set(prefix + '[' + k + ']', obj[k]); }); };
grp(m.competition, 'competition'); grp(m.venue, 'venue'); grp(m.result, 'result'); grp(m.reviews, 'reviews');
grp(m.participants, 'participants');
// media text + image previews
if (m.media) {
set('media[caption]', m.media.caption); set('media[alt]', m.media.alt); set('media[credit]', m.media.credit);
const pub = form.querySelector('[name="media[public]"]'); if (pub) pub.checked = !!m.media.public;
}
Object.entries(m.media_urls || {}).forEach(([key, url]) => {
const field = form.querySelector('.sm-img-field[data-img="' + key + '"]');
if (!field) return;
const img = field.querySelector('.sm-img-preview');
const ph = field.querySelector('.sm-img-ph');
if (img) { img.src = url; img.classList.remove('d-none'); }
if (ph) ph.classList.add('d-none');
});
(m.participants && m.participants.extra || []).forEach(p => addRow('extra', p));
(m.officials || []).forEach(o => addRow('official', o));
(m.segments || []).forEach(s => addRow('segment', s));
(m.statistics || []).forEach(s => addRow('statistic', s));
}
// ── Submit ───────────────────────────────────────────────────────────
document.getElementById('sm-save-basic').addEventListener('click', () => submit('close'));
document.getElementById('sm-save-continue').addEventListener('click', () => submit('continue'));
const saveBtns = [document.getElementById('sm-save-basic'), document.getElementById('sm-save-continue')];
function lockButtons(on) { saveBtns.forEach(b => b.disabled = on); }
function setBtnText(t) {
saveBtns.forEach(b => {
if (_origBtnHtml[b.id] === undefined) _origBtnHtml[b.id] = b.innerHTML;
});
const cont = document.getElementById('sm-save-continue');
cont.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>' + t;
}
function restoreBtnText() {
saveBtns.forEach(b => { if (_origBtnHtml[b.id] !== undefined) b.innerHTML = _origBtnHtml[b.id]; });
}
function clearErrors() {
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
document.getElementById('sm-video-err').classList.add('d-none');
document.getElementById('sm-video-drop').classList.remove('sm-invalid');
}
function submit(intent) {
['official', 'segment', 'statistic', 'extra'].forEach(renumber);
clearErrors();
lockButtons(true);
// Edit mode → just update the record. Create mode → upload the video first.
const chain = idInput.value ? Promise.resolve() : uploadVideoFirst();
chain
.then(() => postMatch(intent))
.catch(e => { if (e && e.message !== 'handled') toast((e && e.message) || 'Save failed', 'error'); })
.finally(() => { lockButtons(false); restoreBtnText(); });
}
// Step 1 (create): upload the match video through the existing pipeline.
function uploadVideoFirst() {
if (!validateCreate()) return Promise.reject(new Error('handled'));
setBtnText('Uploading video…');
const vfd = new FormData();
vfd.append('_token', token());
vfd.append('video', videoFile.files[0]);
vfd.append('title', document.getElementById('sm-title').value.trim());
vfd.append('type', 'match');
vfd.append('visibility', 'public');
vfd.append('download_access', 'disabled');
// Reuse the cropped event poster as the video thumbnail when present.
const poster = document.getElementById('sm-file-media_event_poster');
if (poster && poster.files && poster.files[0]) vfd.append('thumbnail', poster.files[0]);
return fetch('{{ route('videos.store') }}', {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': token() },
body: vfd,
})
.then(async (r) => {
const d = await r.json().catch(() => ({}));
if (r.status === 422) { showErrors(d.errors || {}); throw new Error('handled'); }
if (!r.ok || !d.success) throw new Error(d.message || 'Video upload failed');
return d;
})
.then((d) => { videoIdInp.value = d.video_id; });
}
// Step 2: create/update the match record (video_id already set).
function postMatch(intent) {
setBtnText(idInput.value ? 'Saving…' : 'Saving match…');
const id = idInput.value;
const url = id ? '{{ url('sports-matches') }}/' + id : '{{ route('sports-matches.store') }}';
const fd = new FormData(form);
fd.delete('match_id');
if (id) fd.append('_method', 'PUT');
return fetch(url, {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': token() },
body: fd,
})
.then(async (r) => {
const d = await r.json().catch(() => ({}));
if (r.status === 422) { showErrors(d.errors || {}); throw new Error('handled'); }
if (!r.ok) throw new Error(d.message || 'Save failed');
return d;
})
.then((d) => {
toast(d.message || 'Saved', 'success');
if (intent === 'close') getModal().hide();
else populate(d.match); // switch to edit mode, keep editing the same record
});
}
// Client-side guard so a failed match-save never leaves an orphan video.
function validateCreate() {
let ok = true;
const title = document.getElementById('sm-title');
if (!videoFile.files || !videoFile.files[0]) {
document.getElementById('sm-video-err').classList.remove('d-none');
document.getElementById('sm-video-drop').classList.add('sm-invalid');
ok = false;
}
if (!title.value.trim()) { title.classList.add('is-invalid'); ok = false; }
if (!ok) toast('Please complete the required fields.', 'error');
return ok;
}
function dotToBracket(key) {
const parts = key.split('.');
return parts[0] + parts.slice(1).map(p => '[' + p + ']').join('');
}
function showErrors(errors) {
let first = null;
Object.keys(errors).forEach(key => {
// key may be dot-notation like "officials.0.photo" → bracket form
const el = form.querySelector('[name="' + key + '"]')
|| form.querySelector('[name="' + dotToBracket(key) + '"]');
const fb = form.querySelector('.invalid-feedback[data-field="' + key + '"]');
if (el) { el.classList.add('is-invalid'); first = first || el; }
if (fb) fb.textContent = errors[key][0];
});
if (first) {
const pane = first.closest('.accordion-collapse');
if (pane && !pane.classList.contains('show')) new bootstrap.Collapse(pane, { show: true });
setTimeout(() => first.scrollIntoView({ behavior: 'smooth', block: 'center' }), 150);
}
toast('Please check the highlighted fields.', 'error');
}
function toast(msg, type) {
if (typeof window.showToast === 'function') window.showToast(msg, type);
else console.log('[' + type + '] ' + msg);
}
})();
</script>