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:
1 parent
6aae6f86b6
commit
73527f3781
61 files changed
+4014
-636
No files matched your search
@@ -1,291 +0,0 @@
|
||||
<!-- Share Modal -->
|
||||
<div class="modal fade" id="shareModal" tabindex="-1" aria-labelledby="shareModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" style="background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 16px;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--border-color); padding: 20px 24px;">
|
||||
<h5 class="modal-title" id="shareModalLabel" style="font-weight: 600; color: var(--text-primary);">
|
||||
<i class="bi bi-share me-2"></i>Share
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 24px;">
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">Share this link with your friends:</p>
|
||||
|
||||
<div class="share-link-container" style="display: flex; gap: 8px; align-items: center;">
|
||||
<input type="text" id="shareLinkInput" class="form-control" readonly
|
||||
style="background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 12px 16px; border-radius: 8px;">
|
||||
<button type="button" id="copyLinkBtn" class="btn-copy action-btn action-btn-primary">
|
||||
<i class="bi bi-clipboard"></i> <span>Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="copySuccess" class="copy-success" style="display: none; margin-top: 12px; color: #4caf50; font-size: 14px; text-align: center;">
|
||||
<i class="bi bi-check-circle-fill me-1"></i> Link copied to clipboard!
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||||
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">Share on social media:</p>
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<a href="#" id="shareFacebook" class="social-share-btn" target="_blank"
|
||||
style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #1877f2; color: white; text-decoration: none;">
|
||||
<i class="bi bi-facebook"></i>
|
||||
</a>
|
||||
<a href="#" id="shareTwitter" class="social-share-btn" target="_blank"
|
||||
style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #1da1f2; color: white; text-decoration: none;">
|
||||
<i class="bi bi-twitter-x"></i>
|
||||
</a>
|
||||
<a href="#" id="shareWhatsApp" class="social-share-btn" target="_blank"
|
||||
style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #25d366; color: white; text-decoration: none;">
|
||||
<i class="bi bi-whatsapp"></i>
|
||||
</a>
|
||||
<a href="#" id="shareTelegram" class="social-share-btn" target="_blank"
|
||||
style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #0088cc; color: white; text-decoration: none;">
|
||||
<i class="bi bi-telegram"></i>
|
||||
</a>
|
||||
<a href="#" id="shareEmailBtn" class="social-share-btn" role="button"
|
||||
style="display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #6b7280; color: white; text-decoration: none;">
|
||||
<i class="bi bi-envelope-fill"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Send by email --}}
|
||||
<div id="shareEmailSection" style="display:none; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
||||
<p style="color: var(--text-secondary); font-size: 13px; margin-bottom: 12px;">
|
||||
<i class="bi bi-envelope me-1"></i> Send to a friend by email:
|
||||
</p>
|
||||
<input type="email" id="shareEmailTo" class="form-control" placeholder="[email protected]" autocomplete="off"
|
||||
style="background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 10px 14px; border-radius: 8px;">
|
||||
<textarea id="shareEmailMsg" class="form-control" rows="2" maxlength="500" placeholder="Add a short message (optional)"
|
||||
style="background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 10px 14px; border-radius: 8px; margin-top: 8px; resize: vertical;"></textarea>
|
||||
<button type="button" id="shareEmailSend" class="action-btn action-btn-primary" style="width:100%; justify-content:center; margin-top:10px;">
|
||||
<i class="bi bi-send"></i> <span>Send email</span>
|
||||
</button>
|
||||
<div id="shareEmailStatus" style="display:none; margin-top:10px; font-size:13px; text-align:center;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.social-share-btn {
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
}
|
||||
.social-share-btn:hover {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-copy.copied {
|
||||
background: #4caf50 !important;
|
||||
border-color: #4caf50 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function _getLatestCsrf() {
|
||||
var match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
return (typeof csrf !== 'undefined') ? csrf : '';
|
||||
}
|
||||
|
||||
// Set per-open so the "Send by email" form knows the endpoint + which version to send.
|
||||
var _shareEmailUrl = '';
|
||||
var _shareTrack = '';
|
||||
|
||||
async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl) {
|
||||
var csrfToken = _getLatestCsrf();
|
||||
var shareUrl = videoUrl;
|
||||
|
||||
// Preserve the version selector (?track=) from the requested URL — the server's tracked
|
||||
// share link replaces shareUrl below, so we re-attach it afterwards.
|
||||
var trackParam = '';
|
||||
try { trackParam = (new URL(videoUrl, window.location.origin)).searchParams.get('track') || ''; } catch (e) {}
|
||||
_shareEmailUrl = emailUrl || '';
|
||||
_shareTrack = trackParam;
|
||||
|
||||
// Obtain a unique tracked share link from the server
|
||||
if (recordUrl) {
|
||||
try {
|
||||
var res = await fetch(recordUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
var data = await res.json();
|
||||
if (data.url) shareUrl = data.url;
|
||||
}
|
||||
} catch (e) { /* fallback to plain URL */ }
|
||||
}
|
||||
|
||||
// Re-attach the version selector so the recipient opens the right language.
|
||||
if (trackParam) {
|
||||
shareUrl += (shareUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + encodeURIComponent(trackParam);
|
||||
}
|
||||
|
||||
// Mobile: use native share sheet with the unique link
|
||||
if (window.innerWidth <= 768 && navigator.share) {
|
||||
navigator.share({ title: videoTitle, url: shareUrl }).catch(function() {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: show modal
|
||||
_populateShareModal(shareUrl, videoTitle);
|
||||
var modal = new bootstrap.Modal(document.getElementById('shareModal'), { backdrop: true, keyboard: true });
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function _populateShareModal(shareUrl, videoTitle) {
|
||||
document.getElementById('shareLinkInput').value = shareUrl;
|
||||
|
||||
var encodedUrl = encodeURIComponent(shareUrl);
|
||||
var encodedTitle = encodeURIComponent(videoTitle);
|
||||
var waText = encodeURIComponent(videoTitle + '\n' + shareUrl);
|
||||
|
||||
document.getElementById('shareFacebook').href = 'https://www.facebook.com/sharer/sharer.php?u=' + encodedUrl;
|
||||
document.getElementById('shareTwitter').href = 'https://twitter.com/intent/tweet?url=' + encodedUrl + '&text=' + encodedTitle;
|
||||
document.getElementById('shareWhatsApp').href = 'https://wa.me/?text=' + waText;
|
||||
document.getElementById('shareTelegram').href = 'https://t.me/share/url?url=' + encodedUrl + '&text=' + encodedTitle;
|
||||
|
||||
var copyBtn = document.getElementById('copyLinkBtn');
|
||||
copyBtn.innerHTML = '<i class="bi bi-clipboard"></i> <span>Copy</span>';
|
||||
copyBtn.classList.remove('copied');
|
||||
document.getElementById('copySuccess').style.display = 'none';
|
||||
|
||||
// Reset the email form; the envelope button only works when an endpoint was provided.
|
||||
var emailBtn = document.getElementById('shareEmailBtn');
|
||||
var emailSec = document.getElementById('shareEmailSection');
|
||||
if (emailBtn) emailBtn.style.display = _shareEmailUrl ? 'flex' : 'none';
|
||||
if (emailSec) {
|
||||
emailSec.style.display = 'none';
|
||||
var to = document.getElementById('shareEmailTo'); if (to) to.value = '';
|
||||
var msg = document.getElementById('shareEmailMsg'); if (msg) msg.value = '';
|
||||
var st = document.getElementById('shareEmailStatus'); if (st) st.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function _copyToClipboard(text) {
|
||||
// Prefer modern clipboard API (requires HTTPS)
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
// Textarea fallback — works even inside Bootstrap modals
|
||||
return new Promise(function(resolve, reject) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
var ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch(e) {}
|
||||
document.body.removeChild(ta);
|
||||
ok ? resolve() : reject();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var copyBtn = document.getElementById('copyLinkBtn');
|
||||
var shareInput = document.getElementById('shareLinkInput');
|
||||
var copySuccess = document.getElementById('copySuccess');
|
||||
|
||||
if (!copyBtn || !shareInput) return;
|
||||
|
||||
copyBtn.addEventListener('click', function() {
|
||||
_copyToClipboard(shareInput.value).then(function() {
|
||||
copyBtn.innerHTML = '<i class="bi bi-check-lg"></i> <span>Copied!</span>';
|
||||
copyBtn.classList.add('copied');
|
||||
copySuccess.style.display = 'block';
|
||||
setTimeout(function() {
|
||||
copyBtn.innerHTML = '<i class="bi bi-clipboard"></i> <span>Copy</span>';
|
||||
copyBtn.classList.remove('copied');
|
||||
copySuccess.style.display = 'none';
|
||||
}, 2500);
|
||||
}).catch(function() {
|
||||
showToast('Could not copy — please copy the link manually.', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Send by email ──────────────────────────────────────────────
|
||||
var emailBtn = document.getElementById('shareEmailBtn');
|
||||
var emailSec = document.getElementById('shareEmailSection');
|
||||
var emailTo = document.getElementById('shareEmailTo');
|
||||
var emailMsg = document.getElementById('shareEmailMsg');
|
||||
var emailSend = document.getElementById('shareEmailSend');
|
||||
var emailStat = document.getElementById('shareEmailStatus');
|
||||
|
||||
if (emailBtn && emailSec) {
|
||||
emailBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
emailSec.style.display = (emailSec.style.display === 'none' || !emailSec.style.display) ? 'block' : 'none';
|
||||
if (emailSec.style.display === 'block' && emailTo) emailTo.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function _showEmailStatus(msg, color) {
|
||||
if (!emailStat) return;
|
||||
emailStat.textContent = msg;
|
||||
emailStat.style.color = color;
|
||||
emailStat.style.display = 'block';
|
||||
}
|
||||
|
||||
if (emailSend) {
|
||||
emailSend.addEventListener('click', function() {
|
||||
var to = (emailTo && emailTo.value || '').trim();
|
||||
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) {
|
||||
_showEmailStatus('Please enter a valid email address.', '#ef4444');
|
||||
if (emailTo) emailTo.focus();
|
||||
return;
|
||||
}
|
||||
if (!_shareEmailUrl) { _showEmailStatus('Email sharing is unavailable here.', '#ef4444'); return; }
|
||||
|
||||
emailSend.disabled = true;
|
||||
var _orig = emailSend.innerHTML;
|
||||
emailSend.innerHTML = '<i class="bi bi-hourglass-split"></i> <span>Sending…</span>';
|
||||
_showEmailStatus('Sending…', 'var(--text-secondary)');
|
||||
|
||||
var body = new URLSearchParams({
|
||||
_token: '{{ csrf_token() }}',
|
||||
email: to,
|
||||
message: (emailMsg && emailMsg.value || ''),
|
||||
track: _shareTrack || '0',
|
||||
});
|
||||
|
||||
fetch(_shareEmailUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': _getLatestCsrf(),
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body.toString(),
|
||||
})
|
||||
.then(function(r) { return r.json().then(function(d){ return { ok: r.ok, d: d }; }); })
|
||||
.then(function(res) {
|
||||
emailSend.disabled = false;
|
||||
emailSend.innerHTML = _orig;
|
||||
if (res.ok && res.d.success) {
|
||||
_showEmailStatus('Sent! Your friend will get the email shortly.', '#4caf50');
|
||||
if (emailTo) emailTo.value = '';
|
||||
if (emailMsg) emailMsg.value = '';
|
||||
setTimeout(function(){ if (emailSec) emailSec.style.display = 'none'; }, 1800);
|
||||
} else {
|
||||
_showEmailStatus((res.d && res.d.error) || 'Could not send the email. Please try again.', '#ef4444');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
emailSend.disabled = false;
|
||||
emailSend.innerHTML = _orig;
|
||||
_showEmailStatus('Network error — please try again.', '#ef4444');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
@@ -26,10 +26,10 @@
|
||||
<i class="bi bi-arrow-right-short utc-card-arr"></i>
|
||||
</button>
|
||||
|
||||
<button type="button" class="utc-card" data-accent="match" onclick="chooseUploadType('match')">
|
||||
<button type="button" class="utc-card" data-accent="match" onclick="closeUploadChooser(); openSportsMatchModal();">
|
||||
<span class="utc-card-ico"><i class="bi bi-trophy"></i></span>
|
||||
<span class="utc-card-title">Sports</span>
|
||||
<span class="utc-card-desc">Matches with rounds & annotations</span>
|
||||
<span class="utc-card-desc">Create a sports match record</span>
|
||||
<i class="bi bi-arrow-right-short utc-card-arr"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user