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,41 @@
@props([
'video',
'tag' => 'button', // 'button' or 'a'
])
{{-- Canonical share trigger. Opens the single <x-share-modal/> with the full
argument set (link, title, tracked-share, email, members) so every share
entry point offers the same options. Pass a slot for custom inner content;
otherwise it renders a default "Share" label. Extra attributes (class,
style, …) are forwarded to the element. --}}
@php
$auth = auth()->check();
$emailUrl = $auth ? route('videos.shareEmail', $video) : '';
$membersUrl = $auth ? route('videos.shareMembers', $video) : '';
$onclick = sprintf(
"openShareModal('%s','%s','%s','%s','%s')",
e($video->share_url),
addslashes($video->title),
route('videos.recordShare', $video),
$emailUrl,
$membersUrl
);
@endphp
@if($tag === 'a')
<a href="javascript:void(0)" {{ $attributes }} onclick="{{ $onclick }}">
@if($slot->isEmpty())
<i class="bi bi-share"></i> Share
@else
{{ $slot }}
@endif
</a>
@else
<button type="button" {{ $attributes }} onclick="{{ $onclick }}">
@if($slot->isEmpty())
<i class="bi bi-share"></i> <span>Share</span>
@else
{{ $slot }}
@endif
</button>
@endif
@@ -46,6 +46,10 @@
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>
<a href="#" id="shareMembersBtn" class="social-share-btn" role="button" title="Share with members"
style="display: none; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #e61e1e; color: white; text-decoration: none;">
<i class="bi bi-people-fill"></i>
</a>
</div>
</div>
@@ -63,6 +67,25 @@
</button>
<div id="shareEmailStatus" style="display:none; margin-top:10px; font-size:13px; text-align:center;"></div>
</div>
{{-- Share with members --}}
<div id="shareMembersSection" 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-people me-1"></i> Search members and share — they get a notification and an email:
</p>
<div style="position: relative;">
<input type="text" id="shareMemberSearch" class="form-control" placeholder="Search members by name…" autocomplete="off"
style="background: var(--bg-primary); border: 1px solid var(--border-color); color: var(--text-primary); padding: 10px 14px; border-radius: 8px;">
<div id="shareMemberResults" class="share-member-results" style="display:none;"></div>
</div>
<div id="shareMemberChips" class="share-member-chips"></div>
<textarea id="shareMemberMsg" 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: 10px; resize: vertical;"></textarea>
<button type="button" id="shareMemberSend" class="action-btn action-btn-primary" style="width:100%; justify-content:center; margin-top:10px;">
<i class="bi bi-send"></i> <span>Send to members</span>
</button>
<div id="shareMemberStatus" style="display:none; margin-top:10px; font-size:13px; text-align:center;"></div>
</div>
</div>
</div>
</div>
@@ -80,6 +103,31 @@
background: #4caf50 !important;
border-color: #4caf50 !important;
}
/* Member share picker */
.share-member-results {
position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 20;
background: var(--bg-secondary); border: 1px solid var(--border-color);
border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,.5);
max-height: 240px; overflow-y: auto; padding: 4px;
}
.share-member-opt {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-radius: 6px; cursor: pointer;
}
.share-member-opt:hover { background: rgba(255,255,255,.06); }
.share-member-opt img { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.share-member-opt .smo-name { color: var(--text-primary); font-size: 14px; font-weight: 600; line-height: 1.2; }
.share-member-opt .smo-handle { color: var(--text-secondary); font-size: 12px; }
.share-member-empty { padding: 10px; color: var(--text-secondary); font-size: 13px; text-align: center; }
.share-member-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.share-member-chip {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px 4px 4px;
background: rgba(230,30,30,.12); border: 1px solid rgba(230,30,30,.3);
border-radius: 20px; color: var(--text-primary); font-size: 13px;
}
.share-member-chip img { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; }
.share-member-chip button { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 0 2px; line-height: 1; }
.share-member-chip button:hover { color: #e61e1e; }
</style>
<script>
@@ -90,10 +138,11 @@ function _getLatestCsrf() {
}
// Set per-open so the "Send by email" form knows the endpoint + which version to send.
var _shareEmailUrl = '';
var _shareTrack = '';
var _shareEmailUrl = '';
var _shareMembersUrl = '';
var _shareTrack = '';
async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl) {
async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl, membersUrl) {
var csrfToken = _getLatestCsrf();
var shareUrl = videoUrl;
@@ -101,8 +150,9 @@ async function openShareModal(videoUrl, videoTitle, recordUrl, emailUrl) {
// 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;
_shareEmailUrl = emailUrl || '';
_shareMembersUrl = membersUrl || '';
_shareTrack = trackParam;
// Obtain a unique tracked share link from the server
if (recordUrl) {
@@ -166,6 +216,13 @@ function _populateShareModal(shareUrl, videoTitle) {
var msg = document.getElementById('shareEmailMsg'); if (msg) msg.value = '';
var st = document.getElementById('shareEmailStatus'); if (st) st.style.display = 'none';
}
// Reset the members picker; the people button only works when an endpoint was provided.
var membersBtn = document.getElementById('shareMembersBtn');
var membersSec = document.getElementById('shareMembersSection');
if (membersBtn) membersBtn.style.display = _shareMembersUrl ? 'flex' : 'none';
if (typeof _resetMembersPicker === 'function') _resetMembersPicker();
if (membersSec) membersSec.style.display = 'none';
}
function _copyToClipboard(text) {
@@ -286,6 +343,156 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
}
// ── Share with members ─────────────────────────────────────────
var membersBtn = document.getElementById('shareMembersBtn');
var membersSec = document.getElementById('shareMembersSection');
var memberSearch = document.getElementById('shareMemberSearch');
var memberResults = document.getElementById('shareMemberResults');
var memberChips = document.getElementById('shareMemberChips');
var memberMsg = document.getElementById('shareMemberMsg');
var memberSend = document.getElementById('shareMemberSend');
var memberStatus = document.getElementById('shareMemberStatus');
var _selectedMembers = [];
var _searchTimer = null;
function escAttr(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
window._resetMembersPicker = function () {
_selectedMembers = [];
if (memberSearch) memberSearch.value = '';
if (memberMsg) memberMsg.value = '';
if (memberResults) { memberResults.style.display = 'none'; memberResults.innerHTML = ''; }
if (memberStatus) memberStatus.style.display = 'none';
_renderChips();
};
function _showMemberStatus(msg, color) {
if (!memberStatus) return;
memberStatus.textContent = msg; memberStatus.style.color = color; memberStatus.style.display = 'block';
}
function _renderChips() {
if (!memberChips) return;
memberChips.innerHTML = _selectedMembers.map(function (u) {
return '<span class="share-member-chip">'
+ '<img src="' + escAttr(u.avatar) + '" alt="">'
+ escAttr(u.name)
+ '<button type="button" data-id="' + u.id + '" aria-label="Remove"><i class="bi bi-x-lg"></i></button>'
+ '</span>';
}).join('');
memberChips.querySelectorAll('button[data-id]').forEach(function (b) {
b.addEventListener('click', function () {
_selectedMembers = _selectedMembers.filter(function (u) { return String(u.id) !== b.getAttribute('data-id'); });
_renderChips();
});
});
}
function _renderResults(users) {
if (!memberResults) return;
var available = users.filter(function (u) { return !_selectedMembers.some(function (s) { return s.id === u.id; }); });
if (!available.length) {
memberResults.innerHTML = '<div class="share-member-empty">No members found</div>';
} else {
memberResults.innerHTML = available.map(function (u) {
return '<div class="share-member-opt" data-id="' + u.id + '">'
+ '<img src="' + escAttr(u.avatar) + '" alt="">'
+ '<div><div class="smo-name">' + escAttr(u.name) + '</div>'
+ '<div class="smo-handle">@' + escAttr(u.channel) + '</div></div>'
+ '</div>';
}).join('');
memberResults.querySelectorAll('.share-member-opt').forEach(function (el) {
el.addEventListener('click', function () {
var id = parseInt(el.getAttribute('data-id'), 10);
var u = available.find(function (x) { return x.id === id; });
if (u && !_selectedMembers.some(function (s) { return s.id === u.id; })) {
_selectedMembers.push(u);
_renderChips();
}
memberSearch.value = '';
memberResults.style.display = 'none';
memberSearch.focus();
});
});
}
memberResults.style.display = 'block';
}
if (membersBtn && membersSec) {
membersBtn.addEventListener('click', function (e) {
e.preventDefault();
membersSec.style.display = (membersSec.style.display === 'none' || !membersSec.style.display) ? 'block' : 'none';
if (membersSec.style.display === 'block' && memberSearch) memberSearch.focus();
});
}
if (memberSearch) {
memberSearch.addEventListener('input', function () {
var q = memberSearch.value.trim();
clearTimeout(_searchTimer);
if (!q) { memberResults.style.display = 'none'; return; }
_searchTimer = setTimeout(function () {
fetch('{{ route('users.search') }}?q=' + encodeURIComponent(q), {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
})
.then(function (r) { return r.json(); })
.then(function (data) { _renderResults(data.users || []); })
.catch(function () {});
}, 250);
});
document.addEventListener('click', function (e) {
if (memberResults && !memberResults.contains(e.target) && e.target !== memberSearch) {
memberResults.style.display = 'none';
}
});
}
if (memberSend) {
memberSend.addEventListener('click', function () {
if (!_shareMembersUrl) { _showMemberStatus('Member sharing is unavailable here.', '#ef4444'); return; }
if (!_selectedMembers.length) { _showMemberStatus('Select at least one member.', '#ef4444'); return; }
memberSend.disabled = true;
var _orig = memberSend.innerHTML;
memberSend.innerHTML = '<i class="bi bi-hourglass-split"></i> <span>Sending…</span>';
_showMemberStatus('Sending…', 'var(--text-secondary)');
var body = new URLSearchParams();
body.append('_token', '{{ csrf_token() }}');
body.append('message', (memberMsg && memberMsg.value) || '');
_selectedMembers.forEach(function (u) { body.append('user_ids[]', u.id); });
fetch(_shareMembersUrl, {
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) {
memberSend.disabled = false;
memberSend.innerHTML = _orig;
if (res.ok && res.d.success) {
_showMemberStatus('Shared with ' + (res.d.count || _selectedMembers.length) + ' member(s) — notification + email sent.', '#4caf50');
_selectedMembers = []; _renderChips();
if (memberMsg) memberMsg.value = '';
setTimeout(function () { if (membersSec) membersSec.style.display = 'none'; }, 2000);
} else {
_showMemberStatus((res.d && res.d.error) || 'Could not share. Please try again.', '#ef4444');
}
})
.catch(function () {
memberSend.disabled = false;
memberSend.innerHTML = _orig;
_showMemberStatus('Network error — please try again.', '#ef4444');
});
});
}
});
</script>
@@ -0,0 +1,12 @@
@props(['name', 'label' => 'Upload & crop'])
{{-- A cropper-backed image field. The matching <x-image-cropper id="smc_{name}">
(defined once in the sports-match modal) writes the cropped file onto the
hidden input below and updates #sm-prev-{name}. --}}
<div {{ $attributes->merge(['class' => 'sm-img-field']) }} data-img="{{ $name }}">
<input type="file" name="{{ $name }}" id="sm-file-{{ $name }}" accept=".jpg,.jpeg,.png,.webp" hidden>
<button type="button" class="sm-img-drop" onclick="smOpenCropper('smc_{{ $name }}')">
<img class="sm-img-preview d-none" id="sm-prev-{{ $name }}" alt="">
<span class="sm-img-ph"><i class="bi bi-crop"></i><span>{{ $label }}</span></span>
</button>
</div>
@@ -0,0 +1,20 @@
@props(['id', 'title', 'icon' => 'bi-sliders'])
{{-- One collapsible "Optional" block inside the sports-match accordion.
Independent toggle (no data-bs-parent) so several can be open at once. --}}
<div class="accordion-item">
<h2 class="accordion-header" id="sm-heading-{{ $id }}">
<button class="accordion-button collapsed" type="button"
data-bs-toggle="collapse" data-bs-target="#sm-collapse-{{ $id }}"
aria-expanded="false" aria-controls="sm-collapse-{{ $id }}">
<i class="bi {{ $icon }} sm-acc-ico"></i>
<span class="sm-acc-title">{{ $title }}</span>
<span class="badge text-bg-secondary ms-2">Optional</span>
</button>
</h2>
<div id="sm-collapse-{{ $id }}" class="accordion-collapse collapse" aria-labelledby="sm-heading-{{ $id }}">
<div class="accordion-body">
{{ $slot }}
</div>
</div>
</div>
@@ -139,6 +139,7 @@
class="action-btn desktop-action subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}"
data-channel-id="{{ $video->user_id }}"
data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}"
data-source-video-id="{{ $video->id }}"
data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}">
<i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i>
<span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span>
@@ -168,7 +169,7 @@
@if ($video->isShareable())
<button class="action-btn desktop-action"
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}')">
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}', '{{ Auth::check() ? route('videos.shareMembers', $video) : '' }}')">
<i class="bi bi-share"></i><span>Share</span>
</button>
@endif
@@ -238,6 +239,7 @@
class="dropdown-item subscribe-toggle-btn {{ $isSubscribed ? 'subscribed' : '' }}"
data-channel-id="{{ $video->user_id }}"
data-subscribe-url="{{ route('channel.subscribe', $video->user_id) }}"
data-source-video-id="{{ $video->id }}"
data-subscribed="{{ $isSubscribed ? 'true' : 'false' }}">
<i class="bi {{ $isSubscribed ? 'bi-bell-fill' : 'bi-bell' }}"></i>
<span class="subscribe-label">{{ $isSubscribed ? 'Subscribed' : 'Subscribe' }}</span>
@@ -271,7 +273,7 @@
@if ($video->isShareable())
<button class="dropdown-item"
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}')">
onclick="shareCurrent('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}', '{{ Auth::check() ? route('videos.shareEmail', $video) : '' }}', '{{ Auth::check() ? route('videos.shareMembers', $video) : '' }}')">
<i class="bi bi-share"></i> Share
</button>
@endif
@@ -337,9 +339,15 @@
el.textContent = Number(el.dataset.count).toLocaleString() + ' subscribers';
});
var srcVid = btn.dataset.sourceVideoId || '';
var body = srcVid && nowSub ? new URLSearchParams({ source_video_id: srcVid }).toString() : null;
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
headers: Object.assign(
{ 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}
),
body: body,
credentials: 'same-origin',
})
.then(function (r) {
@@ -516,10 +524,10 @@ if (!window._slideshowDlInit) {
// Share the version the viewer is on: append ?track={id} so the recipient's player
// opens directly on that language (window._ytpTrackId; 0 = primary → plain link).
window.shareCurrent = function (baseUrl, title, recordUrl, emailUrl) {
window.shareCurrent = function (baseUrl, title, recordUrl, emailUrl, membersUrl) {
var t = window._ytpTrackId || 0;
var url = t ? baseUrl + (baseUrl.indexOf('?') === -1 ? '?' : '&') + 'track=' + t : baseUrl;
if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl, emailUrl);
if (typeof window.openShareModal === 'function') window.openShareModal(url, title, recordUrl, emailUrl, membersUrl);
};
window.startSlideshowDownload = function (routeKey) {
@@ -3,7 +3,7 @@
@php
use App\Data\Languages;
$videoUrl = $video ? asset('storage/videos/' . $video->filename) : null;
$videoUrl = $video ? route('videos.stream', $video) : null;
$thumbnailUrl = $video && $video->thumbnail
? route('media.thumbnail', $video->thumbnail)
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
@@ -141,9 +141,9 @@ $sizeClasses = match($size) {
@endif
@if($video->isShareable())
<li>
<a class="dropdown-item" href="javascript:void(0)" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<x-share-button :video="$video" tag="a" class="dropdown-item">
<i class="bi bi-share"></i> Share
</a>
</x-share-button>
</li>
@endif
<li><hr class="dropdown-divider"></li>
@@ -997,7 +997,7 @@ function playVideo(element) {
if (!video) return;
video.currentTime = 0;
const isAudio = element.dataset.audio === 'true';
video.volume = isAudio ? 0.4 : 0.10;
video.volume = 0.5;
if (isAudio) {
// Keep thumbnail visible — just play audio, show equalizer
video.play().catch(function() {});
@@ -10,9 +10,10 @@
@if($isVideoOwner)
<style>
/* ── Stat cards ──────────────────────────────────────── */
.ins-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:10px; margin-bottom:18px; }
@media(max-width:860px){ .ins-grid { grid-template-columns:repeat(3,1fr); } }
@media(max-width:560px){ .ins-grid { grid-template-columns:repeat(2,1fr); } }
.ins-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:8px; margin-bottom:18px; }
@media(max-width:1100px){ .ins-grid { grid-template-columns:repeat(4,minmax(0,1fr)); } }
@media(max-width:760px) { .ins-grid { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media(max-width:480px) { .ins-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } }
.ins-card { background:var(--bg-dark); border:1px solid var(--border-color); border-radius:10px; padding:14px 14px 12px; display:flex; flex-direction:column; gap:4px; cursor:pointer; transition:border-color .15s, transform .12s, box-shadow .15s; user-select:none; }
.ins-card:hover { border-color:rgba(239,68,68,.5); transform:translateY(-2px); box-shadow:0 4px 16px rgba(0,0,0,.3); }
.ins-card:active { transform:translateY(0); }
@@ -30,6 +31,18 @@
.ins-bar.clickable { cursor:pointer; }
.ins-bar.clickable:hover { background:#ef4444cc !important; }
.ins-bar-today { background:#ef4444 !important; }
/* Segmented bar: three stacked colour blocks (male / female / other-or-guest) */
.ins-bar-seg { width:100%; display:flex; flex-direction:column; justify-content:flex-end; border-radius:4px 4px 0 0; overflow:hidden; transition:filter .15s, transform .12s; }
.ins-bar-seg.clickable { cursor:pointer; }
.ins-bar-seg.clickable:hover { filter:brightness(1.18); }
.ins-bar-seg .seg { width:100%; transition:height .6s cubic-bezier(.22,.61,.36,1); }
.ins-bar-seg .seg-female { background:#ef4444; } /* female — brand red */
.ins-bar-seg .seg-male { background:#f59e0b; } /* male — warm amber */
.ins-bar-seg .seg-other { background:rgba(255,255,255,.18); } /* other / guest — neutral */
.ins-bar-seg.today { box-shadow:0 0 0 1px rgba(255,255,255,.55) inset; }
.ins-bar-legend { display:flex; gap:14px; flex-wrap:wrap; margin-top:8px; font-size:11px; color:var(--text-secondary); }
.ins-bar-legend span { display:inline-flex; align-items:center; gap:5px; }
.ins-bar-legend i { width:10px; height:10px; border-radius:2px; display:inline-block; }
.ins-bar-label { font-size:9px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; }
.ins-peak-badge { display:inline-flex; align-items:center; gap:6px; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); border-radius:20px; padding:4px 12px; font-size:12px; font-weight:600; color:#fca5a5; }
.ins-body { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@@ -60,8 +73,23 @@
.ins-dl-user-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-dl-user-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; }
.ins-dl-count-badge { flex-shrink:0; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:12px; font-weight:700; padding:2px 10px; }
.ins-recent-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; }
.ins-recent-row { display:flex; align-items:center; gap:10px; padding:7px 6px; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; border-radius:6px; transition:background .12s; }
.ins-recent-row.clickable { cursor:pointer; }
.ins-recent-row.clickable:hover { background:rgba(255,255,255,.05); }
.ins-recent-row:last-child { border-bottom:none; }
.ins-recent-count { flex-shrink:0; background:rgba(239,68,68,.12); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:11px; font-weight:700; padding:2px 8px; }
.ins-recent-scroll { max-height:430px; overflow-y:auto; padding-right:4px; }
.ins-recent-scroll::-webkit-scrollbar { width:6px; }
.ins-recent-scroll::-webkit-scrollbar-thumb { background:rgba(255,255,255,.12); border-radius:3px; }
.ins-recent-scroll::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,.2); }
/* UA bucket rows inside the viewer-detail modal */
.ins-ua-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:13px; }
.ins-ua-row:last-child { border-bottom:none; }
.ins-ua-icon { font-size:16px; flex-shrink:0; width:22px; text-align:center; color:var(--text-secondary); }
.ins-ua-label { flex:1; color:var(--text-primary); font-weight:500; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-ua-bar-wrap { width:80px; height:5px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; flex-shrink:0; }
.ins-ua-bar { height:100%; background:#ef4444; border-radius:4px; }
.ins-ua-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
.ins-recent-avatar { width:28px; height:28px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); display:flex; align-items:center; justify-content:center; font-size:14px; }
.ins-recent-name { flex:1; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-recent-type { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; padding:2px 7px; border-radius:6px; flex-shrink:0; }
@@ -193,6 +221,19 @@ window._insData = null;
// ── Helpers ────────────────────────────────────────────
const _cflag = code => `<span class="fi fi-${(!code||code.length!==2)?'xx':code.toLowerCase()}" style="width:20px;height:15px;border-radius:2px;display:inline-block;flex-shrink:0;vertical-align:middle;"></span>`;
// Compact device/browser line for row meta (e.g. "📱 iPhone · Chrome 124")
function _uaInline(o) {
const dev = o && o.device ? o.device : '';
const br = o && o.browser ? o.browser : '';
if (!dev && !br) return '<span style="opacity:.6;">Device unknown</span>';
let icon = '<i class="bi bi-display"></i>';
if (/iPhone|Android phone|Mobile/i.test(dev)) icon = '<i class="bi bi-phone"></i>';
else if (/iPad|Tablet/i.test(dev)) icon = '<i class="bi bi-tablet"></i>';
const parts = [dev, br].filter(Boolean).join(' · ');
return `${icon} ${parts}`;
}
const _fmt = n => { n=n??0; if(n>=1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+'M'; if(n>=1e3) return (n/1e3).toFixed(1).replace(/\.0$/,'')+'K'; return String(n); };
const _fmtH = h => (h===null||h===undefined) ? '—' : (h%12||12)+':00 '+(h>=12?'PM':'AM');
const _ago = s => { const d=Math.floor((Date.now()-new Date(s))/1000); if(d<60) return d+'s ago'; if(d<3600) return Math.floor(d/60)+'m ago'; if(d<86400) return Math.floor(d/3600)+'h ago'; return Math.floor(d/86400)+'d ago'; };
@@ -232,6 +273,44 @@ function _personListHtml(users, emptyMsg) {
</div>`).join('');
}
// ── Segmented (stacked) daily bars: male / female / other-or-guest ─────
function _segmentedBars(daily, maxHeightPx, opts) {
opts = opts || {};
const max = Math.max(...daily.map(d => d.count), 1);
const todayLbl = new Date().toLocaleDateString('en-US', { month:'short', day:'numeric' });
return daily.map(day => {
const totalH = Math.max(Math.round((day.count / max) * maxHeightPx), day.count > 0 ? 2 : 0);
const male = day.male || 0;
const female = day.female || 0;
const other = day.other || 0;
// Each segment's pixel share rounds against the total so they always sum exactly to totalH
const t = day.count || 1;
const mH = totalH > 0 ? Math.round(totalH * (male / t)) : 0;
const fH = totalH > 0 ? Math.round(totalH * (female / t)) : 0;
const oH = Math.max(0, totalH - mH - fH);
const isToday = day.label === todayLbl;
const tip = `${day.label}: ${day.count} views\n♀ ${female} · ♂ ${male} · other/guest ${other}`;
const clickAttr = opts.onClick
? `onclick="${opts.onClick.replace('__DATE__', day.date).replace('__LABEL__', day.label).replace('__COUNT__', day.count)}"`
: '';
return `<div class="ins-bar-col">
<div class="ins-bar-seg${opts.onClick?' clickable':''}${isToday?' today':''}"
style="height:${totalH || 2}px;" title="${tip}" ${clickAttr}>
<div class="seg seg-female" style="height:${fH}px;"></div>
<div class="seg seg-male" style="height:${mH}px;"></div>
<div class="seg seg-other" style="height:${oH}px;"></div>
</div>
<div class="ins-bar-label">${day.short}</div>
</div>`;
}).join('');
}
const _segLegendHtml = `<div class="ins-bar-legend">
<span><i style="background:#ef4444;"></i> Female</span>
<span><i style="background:#f59e0b;"></i> Male</span>
<span><i style="background:rgba(255,255,255,.18);"></i> Other / Guest</span>
</div>`;
// ── Mini bar chart HTML (shared) ───────────────────────
function _miniBarChart(daily) {
const max = Math.max(...daily.map(d=>d.count), 1);
@@ -308,58 +387,61 @@ function renderInsights(d) {
<i class="bi bi-cursor-fill"></i> Tap any card, chart bar, or country for a detailed breakdown
</p>
<div class="ins-grid">
<div class="ins-card" onclick="openInsModal_views()">
<div class="ins-card" onclick="openInsModal_views()" title="Total views + skip rate">
<div class="ins-card-icon">👁️</div><div class="ins-card-val">${_fmt(d.total_views)}</div>
<div class="ins-card-label">Total Views</div>${weekBadge}
<div class="ins-card-label">Views</div>
<span class="ins-card-sub neu">${d.skip_rate||0}% skipped · ${_fmt(d.views_today)} today</span>
</div>
<div class="ins-card" onclick="openInsModal_viewers()">
<div class="ins-card-icon">👤</div><div class="ins-card-val">${_fmt(d.unique_viewers)}</div>
<div class="ins-card-label">Unique Viewers</div>
<span class="ins-card-sub neu">${_fmt(d.views_today)} today</span>
<div class="ins-card" onclick="openInsModal_reach()" title="Distinct people who saw this video">
<div class="ins-card-icon">📡</div><div class="ins-card-val">${_fmt(d.accounts_reached||0)}</div>
<div class="ins-card-label">Reach</div>
<span class="ins-card-sub neu">${_fmt(d.reached_users||0)} signed-in · ${_fmt(d.reached_guests||0)} guests</span>
</div>
<div class="ins-card" onclick="openInsModal_likes()" title="People who liked this video">
<div class="ins-card-icon">❤️</div><div class="ins-card-val">${_fmt(d.likes)}</div>
<div class="ins-card-label">Likes</div>
<span class="ins-card-sub neu">${d.likes===0?'no likes yet':_fmt(d.likes)+' '+(d.likes===1?'person':'people')}</span>
</div>
<div class="ins-card" onclick="openInsModal_saves()" title="Viewers who added this video to a playlist">
<div class="ins-card-icon">🔖</div><div class="ins-card-val">${_fmt(d.save_count||0)}</div>
<div class="ins-card-label">Saves</div>
<span class="ins-card-sub neu">${d.save_rate||0}% of viewers</span>
</div>
<div class="ins-card" onclick="openInsModal_comments()" title="Comments and replies on this video">
<div class="ins-card-icon">💬</div><div class="ins-card-val">${_fmt(d.comments_count||0)}</div>
<div class="ins-card-label">Comments</div>
<span class="ins-card-sub neu">${d.comments_count===1?'1 comment':_fmt(d.comments_count||0)+' total'}</span>
</div>
<div class="ins-card" onclick="openInsModal_downloads()">
<div class="ins-card-icon">⬇️</div><div class="ins-card-val">${_fmt(d.downloads)}</div>
<div class="ins-card-label">Downloads</div>${dlSub}
</div>
<div class="ins-card" onclick="openInsModal_likes()">
<div class="ins-card-icon">❤️</div><div class="ins-card-val">${_fmt(d.likes)}</div>
<div class="ins-card-label">Likes</div>
<span class="ins-card-sub neu">${d.likes===0?'no likes yet':_fmt(d.likes)+' '+(d.likes===1?'person':'people')}</span>
</div>
<div class="ins-card" onclick="openInsModal_shares()">
<div class="ins-card-icon">🔗</div><div class="ins-card-val">${_fmt(d.shares)}</div>
<div class="ins-card-label">Share Reach</div>
<span class="ins-card-sub neu">${_fmt(d.share_links)} link${d.share_links===1?'':'s'} created</span>
<div class="ins-card" onclick="openInsModal_conversions()" title="Downstream actions this video drove">
<div class="ins-card-icon">🚀</div><div class="ins-card-val">${_fmt((d.shares||0)+(d.profile_visits||0)+(d.new_subscribers||0))}</div>
<div class="ins-card-label">Conversions</div>
<span class="ins-card-sub neu">🔗 ${_fmt(d.shares||0)} · 🚪 ${_fmt(d.profile_visits||0)} · ✨ ${_fmt(d.new_subscribers||0)}</span>
</div>
</div>`;
// ── Bar chart (each bar clickable) ──────────────────
const maxV = Math.max(...d.daily.map(x=>x.count),1);
const todayL = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
const bars = d.daily.map(day => {
const h = Math.round((day.count/maxV)*72);
const isT = day.label===todayL;
const tip = `${day.label}: ${day.count} views`;
return `<div class="ins-bar-col">
<div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;"
title="${tip}" onclick="openDayDetail('${day.date}','${day.label}',${day.count})"></div>
<div class="ins-bar-label">${day.short}</div>
</div>`;
}).join('');
// ── Bar chart (segmented + each bar clickable) ──────
const bars = _segmentedBars(d.daily, 72, {
onClick: `openDayDetail('__DATE__','__LABEL__',__COUNT__)`
});
const peakHtml = d.peak_hour!==null
? `<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>` : '';
const peakInline = d.peak_hour!==null
? ` <span style="font-size:10px;opacity:.7;font-weight:600;text-transform:none;color:#fca5a5;"><i class="bi bi-clock-fill"></i> Peak ${_fmtH(d.peak_hour)}</span>` : '';
const chartSection = `
// Only render the trend chart when there is at least one view to plot
const chartSection = (d.total_views > 0) ? `
<div>
<div class="ins-section-title"><i class="bi bi-graph-up"></i> Views — last 14 days <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap bar for day detail)</span></div>
<div class="ins-section-title"><i class="bi bi-graph-up"></i> Views — last 14 days <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap bar for day detail)</span>${peakInline}</div>
<div class="ins-chart">${bars}</div>
<div style="font-size:11px;color:var(--text-secondary);text-align:right;">${d.views_this_week} views this week</div>
${peakHtml}
</div>`;
${_segLegendHtml}
<div style="font-size:11px;color:var(--text-secondary);text-align:right;margin-top:4px;">${d.views_this_week} views this week</div>
</div>` : '';
// ── Countries (each row clickable) ──────────────────
let countriesHtml = '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data yet.</p>';
let countriesHtml = '';
if (d.countries && d.countries.length) {
countriesHtml = d.countries.map(c => `
<div class="ins-country-row" onclick="openCountryDetail('${c.code}','${c.name}',${c.count})" title="View ${c.name||c.code} details">
@@ -379,7 +461,7 @@ function renderInsights(d) {
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">Last seen ${_ago(u.last_at)}</div>
<div class="ins-dl-user-meta">${_uaInline(u)} · last seen ${_ago(u.last_at)}</div>
</div>
<div class="ins-dl-count-badge">👁 ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
@@ -387,31 +469,47 @@ function renderInsights(d) {
const recentViewerRows = (d.recent_viewers || []).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const clickAttr = r.user_id ? `onclick="window.location.href='/channel/${r.user_channel||r.user_id}'" style="cursor:pointer;" title="View ${r.user_name}'s profile"` : '';
return `<div class="ins-recent-row" ${clickAttr}>
<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}">
<div class="ins-recent-name">${r.user_name}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<span class="ins-recent-time">${_ago(r.at)}</span>
const title = `Tap for full activity — ${r.user_name}`;
const safeName = (r.user_name || 'Viewer').replace(/'/g, "\\'");
return `<div class="ins-dl-user-row" title="${title}"
onclick="openViewerDetail('${r.key}','${safeName}','${avatarSrc}')">
<img src="${avatarSrc}" alt="${r.user_name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${r.user_name} ${_cflag(r.country)}</div>
<div class="ins-dl-user-meta">${_uaInline(r)} · ${_ago(r.last_at)}</div>
</div>
<div class="ins-dl-count-badge">👁 ${r.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`;
}).join('');
const viewersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers — ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
</div>
<div class="ins-two-col">
// Build the Viewers section only if there is something to show on either side
const hasTopViewers = !!topViewerRows;
const hasRecentViewers = !!recentViewerRows;
let viewersHtml = '';
if (hasTopViewers || hasRecentViewers) {
const topCol = hasTopViewers ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Top Viewers</div>
${topViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No registered viewers yet.</p>'}
</div>
${topViewerRows}
</div>` : '';
const recentCol = hasRecentViewers ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div>
${recentViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No views yet.</p>'}
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Recent Activity ${ (d.recent_viewers||[]).length > 10 ? `<span style="font-weight:400;opacity:.55;">(showing 10 — scroll for more)</span>` : '' }
</div>
<div class="ins-recent-scroll">${recentViewerRows}</div>
</div>` : '';
// Use single-column layout when only one side has data
const wrap = (hasTopViewers && hasRecentViewers) ? 'ins-two-col' : '';
viewersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers — ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
</div>
</div>
</div>`;
<div class="${wrap}">${topCol}${recentCol}</div>
</div>`;
}
// ── Who Liked section ───────────────────────────────
let likersHtml = '';
@@ -429,123 +527,114 @@ function renderInsights(d) {
<div class="ins-section-title"><i class="bi bi-heart-fill" style="color:#ef4444;"></i> Liked by — ${_fmt(d.likes)} ${d.likes===1?'person':'people'}</div>
${likerRows}
</div>`;
} else if (d.likes === 0) {
likersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-section-title"><i class="bi bi-heart" style="color:var(--text-secondary);"></i> Likes</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No likes yet.</p>
</div>`;
}
// (When d.likes === 0 the section is omitted entirely.)
// ── Downloads section ───────────────────────────────
let dlHtml = '';
if (d.downloads===0) {
dlHtml = `<div style="margin-top:20px;"><div class="ins-section-title"><i class="bi bi-download"></i> Downloads</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No downloads yet.</p></div>`;
} else {
if (d.downloads > 0) {
const pills = [
d.dl_video ? `<span class="ins-dl-pill video"><i class="bi bi-film"></i> ${_fmt(d.dl_video)} video</span>` : '',
d.dl_mp3 ? `<span class="ins-dl-pill mp3"><i class="bi bi-music-note"></i> ${_fmt(d.dl_mp3)} MP3</span>` : '',
d.dl_guests ? `<span class="ins-dl-pill guest"><i class="bi bi-person"></i> ${_fmt(d.dl_guests)} guest</span>` : '',
].filter(Boolean).join('');
const usersRows = d.dl_users && d.dl_users.length
const usersRows = (d.dl_users && d.dl_users.length)
? d.dl_users.map((u,i) => `
<div class="ins-dl-user-row" onclick="openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}')" title="See full download history">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<img src="${u.avatar}" alt="${u.name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${u.name}</div>
<div class="ins-dl-user-meta">Last: ${_ago(u.last_at)}</div>
<div class="ins-dl-user-meta">${_uaInline(u)} · last ${_ago(u.last_at)}</div>
</div>
<div class="ins-dl-count-badge">⬇️ ${u.count}×</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">Guests only so far.</p>';
: '';
const recentRows = (d.dl_recent||[]).map(r => {
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
const av = `<img src="${avatarSrc}" class="ins-recent-avatar" alt="${r.user_name}">`;
return `<div class="ins-recent-row">${av}
<div class="ins-recent-name">${r.user_name}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<span class="ins-recent-type ${r.type}">${r.type.toUpperCase()}</span>
<span class="ins-recent-time">${_ago(r.at)}</span>
const safeName = (r.user_name || 'Downloader').replace(/'/g, "\\'");
const clickAttr = r.user_id
? `onclick="openDownloaderHistory(${r.user_id},'${safeName}','${avatarSrc}')" style="cursor:pointer;" title="See full download history"`
: `style="cursor:default;"`;
const typePill = `<span class="ins-recent-type ${r.type}" style="margin-right:4px;">${r.type.toUpperCase()}</span>`;
return `<div class="ins-dl-user-row" ${clickAttr}>
<img src="${avatarSrc}" alt="${r.user_name}" class="ins-dl-avatar">
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${r.user_name} ${_cflag(r.country)}</div>
<div class="ins-dl-user-meta">${typePill}${_uaInline(r)} · ${_ago(r.last_at)}</div>
</div>
<div class="ins-dl-count-badge">⬇️ ${r.count}×</div>
${r.user_id ? '<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>' : ''}
</div>`;
}).join('');
const topCol = usersRows ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
</div>
${usersRows}
</div>` : '';
const recentCol = recentRows ? `
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Recent Activity ${ (d.dl_recent||[]).length > 10 ? `<span style="font-weight:400;opacity:.55;">(showing 10 — scroll for more)</span>` : '' }
</div>
<div class="ins-recent-scroll">${recentRows}</div>
</div>` : '';
const dlWrap = (usersRows && recentRows) ? 'ins-two-col' : '';
dlHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-download"></i> Downloads — ${_fmt(d.downloads)} total</div>
<div class="ins-dl-type-pills">${pills}</div>
</div>
<div class="ins-two-col">
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
</div>
${usersRows}
</div>
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Recent Activity</div>
${recentRows}
</div>
</div>
<div class="${dlWrap}">${topCol}${recentCol}</div>
</div>`;
}
// ── Gender breakdown ────────────────────────────────
let genderHtml;
if (d.genders && d.genders.length > 0) {
const gColors = { male: '#4a9eff', female: '#ff6eb0' };
const gSymbols = { male: '♂', female: '♀' };
genderHtml = d.genders.map(g => {
const color = gColors[g.gender] || '#aaa';
const sym = gSymbols[g.gender] || '⚧';
return `<div class="ins-demo-row">
<span class="ins-demo-sym" style="color:${color}">${sym}</span>
<span class="ins-demo-label">${g.gender.charAt(0).toUpperCase()+g.gender.slice(1)}</span>
<div class="ins-demo-bar-wrap"><div class="ins-demo-bar" style="width:${g.pct}%;background:${color}"></div></div>
<span class="ins-demo-pct">${g.pct}%</span>
<span class="ins-demo-cnt">${_fmt(g.count)}</span>
</div>`;
}).join('');
} else {
genderHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No gender data yet — viewers need a profile to appear here.</p>`;
}
// ── Age group breakdown ──────────────────────────────
let ageHtml;
// ── Age group breakdown (segmented by gender: blue=male, pink=female) ──
let ageHtml = '';
if (d.age_groups && d.age_groups.length > 0) {
const maxAge = Math.max(...d.age_groups.map(g => g.count), 1);
ageHtml = d.age_groups.map(g => {
const barW = Math.round(g.count / maxAge * 100);
return `<div class="ins-demo-row">
const t = g.count || 1;
const mPct = Math.round(g.male / t * 100);
const fPct = Math.round(g.female / t * 100);
const oPct = Math.max(0, 100 - mPct - fPct);
const tip = `${g.label}: ${g.count} viewers · ♂ ${g.male} · ♀ ${g.female}${g.other ? ' · other ' + g.other : ''}`;
return `<div class="ins-demo-row" title="${tip}">
<span class="ins-demo-label ins-age-label">${g.label}</span>
<div class="ins-demo-bar-wrap"><div class="ins-demo-bar" style="width:${barW}%;background:var(--brand-red,#e61e1e)"></div></div>
<div class="ins-demo-bar-wrap">
<div style="display:flex;height:100%;width:${barW}%;border-radius:4px;overflow:hidden;">
<div style="width:${mPct}%;background:#4a9eff;" title="Male: ${g.male}"></div>
<div style="width:${fPct}%;background:#ff6eb0;" title="Female: ${g.female}"></div>
${oPct > 0 ? `<div style="width:${oPct}%;background:#facc15;" title="Other: ${g.other}"></div>` : ''}
</div>
</div>
<span class="ins-demo-pct">${g.pct}%</span>
<span class="ins-demo-cnt">${_fmt(g.count)}</span>
</div>`;
}).join('');
} else {
ageHtml = `<p style="font-size:13px;color:var(--text-secondary);margin:0;">No age data yet — viewers need a date of birth on their profile to appear here.</p>`;
}
const demographicsHtml = `
<div style="margin-top:18px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-body">
<div>
<div class="ins-section-title"><i class="bi bi-gender-ambiguous"></i> Viewers by Gender</div>
${genderHtml}
</div>
<div>
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
${ageHtml}
</div>
</div>
const ageLegend = `<div class="ins-bar-legend" style="margin-top:10px;">
<span><i style="background:#4a9eff;"></i> Male</span>
<span><i style="background:#ff6eb0;"></i> Female</span>
</div>`;
// Demographics — only when at least one bucket has data
const demographicsHtml = ageHtml ? `
<div style="margin-top:22px;">
<div class="ins-section-title"><i class="bi bi-people-fill"></i> Viewers by Age Group</div>
${ageHtml}
${ageLegend}
</div>` : '';
// ── Share links breakdown ───────────────────────────
let shareHtml = '';
if (d.share_links > 0) {
@@ -553,9 +642,8 @@ function renderInsights(d) {
const avatarHtml = s.avatar
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
const clickAttr = s.sharer_channel
? `onclick="window.location.href='/channel/${s.sharer_channel}'" style="cursor:pointer;" title="View ${s.sharer}'s profile"`
: `style="cursor:default;"`;
const safeName = (s.sharer || 'Sharer').replace(/'/g, "\\'");
const clickAttr = `onclick="openShareDetail('${s.token}','${safeName}')" style="cursor:pointer;" title="See who came from this link"`;
const flagHtml = s.country
? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>`
: '';
@@ -568,6 +656,7 @@ function renderInsights(d) {
</div>
${flagHtml}
<div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`;
}).join('');
@@ -579,21 +668,40 @@ function renderInsights(d) {
<p style="font-size:11px;color:var(--text-secondary);margin:0 0 10px;line-height:1.5;">Each sharer gets a unique link. The same device opening the same link is counted only once — VPN IP changes don't affect the count.</p>
${linkRows}
</div>`;
} else {
shareHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-section-title"><i class="bi bi-share"></i> Share Links</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet. Tap the Share button to generate a unique tracked link.</p>
</div>`;
}
// (When d.share_links === 0 the section is omitted entirely.)
document.getElementById('insightsContent').innerHTML =
cards +
`<div class="ins-body">${chartSection}
// Left column: chart + demographics. Right column: country audience.
const leftCol = chartSection + demographicsHtml;
const rightCol = countriesHtml ? `
<div>
<div class="ins-section-title"><i class="bi bi-globe2"></i> Audience by Country <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap for viewer breakdown)</span></div>
${countriesHtml}
</div></div>` + viewersHtml + likersHtml + dlHtml + shareHtml + demographicsHtml;
</div>` : '';
let topBlock = '';
if (leftCol && rightCol) {
topBlock = `<div class="ins-body"><div>${leftCol}</div>${rightCol}</div>`;
} else if (leftCol) {
topBlock = `<div>${leftCol}</div>`;
} else if (rightCol) {
topBlock = `<div>${rightCol}</div>`;
}
// Pair likes + shares side-by-side only when both have data;
// otherwise emit each on its own row so empty ones simply disappear.
let likesShareBlock = '';
if (likersHtml && shareHtml) {
likesShareBlock = `<div class="ins-two-col" style="margin-top:0;">
<div>${likersHtml}</div>
<div>${shareHtml}</div>
</div>`;
} else {
likesShareBlock = (likersHtml || '') + (shareHtml || '');
}
document.getElementById('insightsContent').innerHTML =
cards + topBlock + viewersHtml + dlHtml + likesShareBlock;
}
// ══════════════════════════════════════════════════════
@@ -611,16 +719,14 @@ function openInsModal_views() {
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-day"></i> Today</span><span class="ins-modal-stat-val">${_fmt(d.views_today)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar-week"></i> This week</span><span class="ins-modal-stat-val">${_fmt(d.views_this_week)}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-calendar2-minus"></i> Last week</span><span class="ins-modal-stat-val">${_fmt(d.views_last_week)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-graph-up-arrow"></i> Week over week</span>
<span class="ins-modal-stat-val" style="color:${changeColor};">${changeLabel}</span>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-graph-up-arrow"></i> Week over week</span><span class="ins-modal-stat-val" style="color:${changeColor};">${changeLabel}</span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-skip-forward-fill" style="color:#f59e0b;"></i> Skipped early</span><span class="ins-modal-stat-val">${_fmt(d.skipped_views||0)} <span style="color:var(--text-secondary);font-weight:400;">(${d.skip_rate||0}% of views)</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-stopwatch"></i> Skip threshold</span><span class="ins-modal-stat-val" style="font-weight:500;color:var(--text-secondary);">under ${d.skip_threshold||10}s watched</span></div>
<div class="ins-modal-section"><i class="bi bi-graph-up"></i> Last 14 days — tap a bar to see that day</div>
<div class="ins-chart" style="height:72px;">${window._insData.daily.map(day=>{
const h=Math.round((day.count/Math.max(...window._insData.daily.map(x=>x.count),1))*68);
const isT=day.label===new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
return `<div class="ins-bar-col"><div class="ins-bar clickable${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;" title="${day.label}: ${day.count}" onclick="closeInsModal();setTimeout(()=>openDayDetail('${day.date}','${day.label}',${day.count}),180)"></div><div class="ins-bar-label">${day.short}</div></div>`;
}).join('')}</div>
<div class="ins-chart" style="height:72px;">${_segmentedBars(window._insData.daily, 68, {
onClick: `closeInsModal();setTimeout(()=>openDayDetail('__DATE__','__LABEL__',__COUNT__),180)`
})}</div>
${_segLegendHtml}
${d.peak_hour!==null?`<div style="margin-top:10px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak hour: ${_fmtH(d.peak_hour)}</span></div>`:''}`);
}
@@ -689,9 +795,8 @@ function openInsModal_shares() {
const avatarHtml = s.avatar
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
const clickAttr = s.sharer_channel
? `onclick="window.location.href='/channel/${s.sharer_channel}'" style="cursor:pointer;" title="View ${s.sharer}'s profile"`
: `style="cursor:default;"`;
const safeName = (s.sharer || 'Sharer').replace(/'/g, "\\'");
const clickAttr = `onclick="closeInsModal();setTimeout(()=>openShareDetail('${s.token}','${safeName}'),180)" style="cursor:pointer;" title="See who came from this link"`;
const flagHtml = s.country
? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>`
: '';
@@ -704,6 +809,7 @@ function openInsModal_shares() {
</div>
${flagHtml}
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`;
}).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet.</p>';
@@ -750,6 +856,112 @@ function openInsModal_likes() {
${likerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No data available.</p>'}`);
}
function openInsModal_reach() {
const d = window._insData; if(!d) return;
const total = d.accounts_reached || 0;
const usersPct = total>0 ? Math.round((d.reached_users||0)/total*100) : 0;
const guestsPct = total>0 ? 100 - usersPct : 0;
const repeatRatio = total>0 ? (d.total_views/total).toFixed(1) : '0.0';
const countryRows = (d.countries||[]).slice(0,8).map(c=>`
<div class="ins-country-row" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)" style="cursor:pointer;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-bar-wrap"><div class="ins-country-bar" style="width:${c.pct}%;"></div></div>
<div class="ins-country-pct">${c.pct}%</div>
<div class="ins-country-cnt">${_fmt(c.count)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
</div>`).join('');
_openModal('📡','Reach','Distinct people who saw this video',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(total)}</div>
<div class="ins-modal-hero-label">accounts reached</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Signed-in</span><span class="ins-modal-stat-val">${_fmt(d.reached_users||0)} <span style="color:var(--text-secondary);font-weight:400;">(${usersPct}%)</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person"></i> Guest devices</span><span class="ins-modal-stat-val">${_fmt(d.reached_guests||0)} <span style="color:var(--text-secondary);font-weight:400;">(${guestsPct}%)</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-arrow-repeat"></i> Avg views per account</span><span class="ins-modal-stat-val">${repeatRatio}×</span></div>
${countryRows ? `<div class="ins-modal-section"><i class="bi bi-globe2"></i> Top countries — tap for viewers</div>${countryRows}` : ''}`);
}
function openInsModal_saves() {
const d = window._insData; if(!d) return;
if ((d.save_count||0) === 0) {
_openModal('🔖','Saves','Not saved yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">📭</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No viewer has added this video to a playlist yet.</p></div>`);
return;
}
_openModal('🔖','Saves','Viewers who added this to a playlist',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.save_count||0)}</div>
<div class="ins-modal-hero-label">${d.save_count===1?'person':'people'} saved this video</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-percent"></i> Save rate</span><span class="ins-modal-stat-val">${d.save_rate||0}% <span style="color:var(--text-secondary);font-weight:400;">of unique viewers</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check"></i> Unique viewers</span><span class="ins-modal-stat-val">${_fmt(d.unique_viewers||0)}</span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:0;"><span class="ins-modal-stat-lbl"><i class="bi bi-info-circle"></i> Note</span><span class="ins-modal-stat-val" style="font-weight:500;color:var(--text-secondary);font-size:12px;">your own playlists excluded</span></div>`);
}
function openInsModal_comments() {
const d = window._insData; if(!d) return;
if ((d.comments_count||0) === 0) {
_openModal('💬','Comments','No comments yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">💭</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No comments on this video yet.</p></div>`);
return;
}
const commentRate = d.total_views>0 ? ((d.comments_count/d.total_views)*100).toFixed(1) : '0.0';
_openModal('💬','Comments','Conversation on this video',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.comments_count||0)}</div>
<div class="ins-modal-hero-label">comments &amp; replies</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-percent"></i> Comment rate</span><span class="ins-modal-stat-val">${commentRate}% <span style="color:var(--text-secondary);font-weight:400;">of views</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:14px;"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Total views</span><span class="ins-modal-stat-val">${_fmt(d.total_views)}</span></div>
<div style="text-align:center;padding:10px 0 4px;">
<button class="action-btn" onclick="closeInsModal();setTimeout(()=>{var el=document.getElementById('commentsSection')||document.querySelector('.comments-section, [data-comments]');if(el)el.scrollIntoView({behavior:'smooth',block:'start'});},200);">
<i class="bi bi-chat-dots"></i> <span>Jump to comments</span>
</button>
</div>`);
}
function openInsModal_conversions() {
const d = window._insData; if(!d) return;
const total = (d.shares||0) + (d.profile_visits||0) + (d.new_subscribers||0);
if (total === 0) {
_openModal('🚀','Conversions','No conversions yet',`<div style="text-align:center;padding:30px 0;"><div style="font-size:48px;margin-bottom:12px;">🌱</div><p style="color:var(--text-secondary);font-size:14px;margin:0;">No shares, wall visits, or new subscribers driven by this video yet.</p></div>`);
return;
}
const subRate = d.total_views>0 ? ((d.new_subscribers||0)/d.total_views*100).toFixed(1) : '0.0';
const visitRate = d.total_views>0 ? ((d.profile_visits||0)/d.total_views*100).toFixed(1) : '0.0';
// Share-link breakdown reuses the existing shares modal layout
const linkRows = (d.share_breakdown||[]).slice(0,5).map((s,i) => {
const avatarHtml = s.avatar
? `<img src="${s.avatar}" class="ins-dl-avatar" alt="${s.sharer}">`
: `<div class="ins-dl-avatar" style="display:flex;align-items:center;justify-content:center;font-size:16px;">👤</div>`;
const safeName = (s.sharer || 'Sharer').replace(/'/g, "\\'");
const flagHtml = s.country ? `<span style="flex-shrink:0;" title="${s.country_name||s.country}">${_cflag(s.country)}</span>` : '';
return `<div class="ins-dl-user-row" onclick="closeInsModal();setTimeout(()=>openShareDetail('${s.token}','${safeName}'),180)" style="cursor:pointer;">
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
${avatarHtml}
<div style="flex:1;min-width:0;">
<div class="ins-dl-user-name">${s.sharer}</div>
<div class="ins-dl-user-meta">${_ago(s.created_at)}</div>
</div>
${flagHtml}
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`;
}).join('');
_openModal('🚀','Conversions','Downstream actions this video drove',`
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(total)}</div>
<div class="ins-modal-hero-label">total conversions</div>
</div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg" style="color:#60a5fa;"></i> Share reach</span><span class="ins-modal-stat-val">${_fmt(d.shares||0)} <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(d.share_links||0)} link${d.share_links===1?'':'s'})</span></span></div>
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-door-open" style="color:#f59e0b;"></i> Wall visits</span><span class="ins-modal-stat-val">${_fmt(d.profile_visits||0)} <span style="color:var(--text-secondary);font-weight:400;">(${visitRate}% of views)</span></span></div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-stars" style="color:#ef4444;"></i> New subscribers</span><span class="ins-modal-stat-val">${_fmt(d.new_subscribers||0)} <span style="color:var(--text-secondary);font-weight:400;">(${subRate}% of views)</span></span></div>
${linkRows ? `<div class="ins-modal-section"><i class="bi bi-people-fill"></i> Top share links — tap to see who came</div>${linkRows}` : ''}
<p style="font-size:11px;color:var(--text-secondary);margin:12px 0 0;line-height:1.5;">Wall visits and new subscribers are only counted when triggered directly from this video page.</p>`);
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: COUNTRY
// ══════════════════════════════════════════════════════
@@ -895,5 +1107,216 @@ function openDownloaderHistory(userId, userName, userAvatar) {
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load download history.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: SINGLE VIEWER (grouped activity popup)
// ══════════════════════════════════════════════════════
function _uaBucketsHtml(buckets, iconFor) {
if (!buckets || !buckets.length) return '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No data.</p>';
const max = Math.max(...buckets.map(b => b.count), 1);
return buckets.map(b => {
const pct = Math.round((b.count / max) * 100);
return `<div class="ins-ua-row">
<span class="ins-ua-icon">${iconFor(b.label)}</span>
<span class="ins-ua-label">${b.label}</span>
<div class="ins-ua-bar-wrap"><div class="ins-ua-bar" style="width:${pct}%;"></div></div>
<span class="ins-ua-cnt">${b.count}</span>
</div>`;
}).join('');
}
function _iconForDevice(label) {
if (/iPhone|Android phone|Mobile/i.test(label)) return '<i class="bi bi-phone"></i>';
if (/iPad|Tablet/i.test(label)) return '<i class="bi bi-tablet"></i>';
if (/Desktop/i.test(label)) return '<i class="bi bi-display"></i>';
return '<i class="bi bi-question-circle"></i>';
}
function _iconForBrowser(label) {
if (/Chrome/i.test(label)) return '<i class="bi bi-browser-chrome"></i>';
if (/Firefox/i.test(label)) return '<i class="bi bi-browser-firefox"></i>';
if (/Safari/i.test(label)) return '<i class="bi bi-browser-safari"></i>';
if (/Edge/i.test(label)) return '<i class="bi bi-browser-edge"></i>';
if (/Opera/i.test(label)) return '<i class="bi bi-globe2"></i>';
return '<i class="bi bi-globe"></i>';
}
function _iconForOs(label) {
if (/Windows/i.test(label)) return '<i class="bi bi-windows"></i>';
if (/macOS/i.test(label)) return '<i class="bi bi-apple"></i>';
if (/iOS|iPadOS/i.test(label)) return '<i class="bi bi-apple"></i>';
if (/Android/i.test(label)) return '<i class="bi bi-android2"></i>';
if (/Linux|ChromeOS/i.test(label)) return '<i class="bi bi-ubuntu"></i>';
return '<i class="bi bi-question-circle"></i>';
}
function openViewerDetail(who, name, avatar) {
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
_modalLoading('👤', name, 'Loading viewer details…');
document.getElementById('insModalIcon').innerHTML =
`<img src="${avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
fetch(`${baseUrl}/viewer/${encodeURIComponent(who)}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const id = d.identity;
document.getElementById('insModalIcon').innerHTML =
`<img src="${id.avatar || avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
document.getElementById('insModalTitle').textContent = id.name;
document.getElementById('insModalSubtitle').textContent =
`${_fmt(d.total)} view${d.total!==1?'s':''} · first ${_ago(d.first_at)} · last ${_ago(d.last_at)}`;
const countriesHtml = (d.countries||[]).map(c => `
<div class="ins-country-row" style="cursor:default;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-cnt" style="margin-left:auto;">${_fmt(c.count)}×</div>
</div>`).join('') || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>';
const profileBtn = (!id.is_guest && id.channel)
? `<div style="text-align:center;margin:-6px 0 14px;">
<a href="/channel/${id.channel}" class="action-btn" style="display:inline-flex;">
<i class="bi bi-person-circle"></i> <span>View profile</span>
</a>
</div>` : '';
const recentHtml = (d.recent||[]).slice(0, 20).map((r,i) => `
<div class="ins-recent-row">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<div class="ins-recent-name" style="font-weight:500;">${_fmtDt(r.at)}</div>
<span class="ins-recent-time">${_ago(r.at)}</span>
</div>`).join('');
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.total)}</div>
<div class="ins-modal-hero-label">total view${d.total!==1?'s':''} by this ${id.is_guest?'guest':'viewer'}</div>
</div>
${profileBtn}
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-calendar-check"></i> First seen</span>
<span class="ins-modal-stat-val">${_fmtDt(d.first_at)} <span style="color:var(--text-secondary);font-weight:400;">(${_ago(d.first_at)})</span></span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:14px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-clock-history"></i> Last seen</span>
<span class="ins-modal-stat-val">${_fmtDt(d.last_at)} <span style="color:var(--text-secondary);font-weight:400;">(${_ago(d.last_at)})</span></span>
</div>
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Locations</div>
${countriesHtml}
<div class="ins-modal-section"><i class="bi bi-phone"></i> Devices</div>
${_uaBucketsHtml(d.devices, _iconForDevice)}
<div class="ins-modal-section"><i class="bi bi-browser-chrome"></i> Browsers</div>
${_uaBucketsHtml(d.browsers, _iconForBrowser)}
<div class="ins-modal-section"><i class="bi bi-cpu"></i> Operating Systems</div>
${_uaBucketsHtml(d.os, _iconForOs)}
${recentHtml ? `
<div class="ins-modal-section"><i class="bi bi-list-ul"></i> Recent views ${d.recent.length>20?'(showing 20 of '+d.total+')':''}</div>
<div class="ins-recent-scroll" style="max-height:280px;">${recentHtml}</div>` : ''}
`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load viewer details.</p>';
});
}
// ══════════════════════════════════════════════════════
// DRILL-DOWN: SINGLE SHARE LINK
// ══════════════════════════════════════════════════════
function openShareDetail(token, sharerName) {
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
_modalLoading('🔗', sharerName + "'s share link", 'Loading link reach…');
fetch(`${baseUrl}/share/${encodeURIComponent(token)}`, {
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
})
.then(r=>{ if(!r.ok) throw r; return r.json(); })
.then(d => {
const s = d.sharer;
document.getElementById('insModalIcon').innerHTML = s.avatar
? `<img src="${s.avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`
: '🔗';
document.getElementById('insModalTitle').textContent = `${s.name}'s share link`;
document.getElementById('insModalSubtitle').textContent = `Created ${_fmtDt(d.created_at)} · ${_ago(d.created_at)}`;
const countriesHtml = (d.countries||[]).length
? d.countries.map(c => `
<div class="ins-country-row" style="cursor:default;">
<div class="ins-country-flag">${_cflag(c.code)}</div>
<div class="ins-country-name">${c.name||c.code}</div>
<div class="ins-country-cnt" style="margin-left:auto;">${_fmt(c.count)}×</div>
</div>`).join('')
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data yet.</p>';
const recentHtml = (d.recent||[]).slice(0, 50).map((r,i) => `
<div class="ins-recent-row" style="flex-wrap:wrap;">
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
<span class="ins-recent-flag">${_cflag(r.country)}</span>
<div class="ins-recent-name" style="font-weight:500;">${_fmtDt(r.at)}</div>
<span class="ins-recent-time">${_ago(r.at)}</span>
<div style="flex-basis:100%;font-size:11px;color:var(--text-secondary);padding-left:34px;">${_uaInline(r)}</div>
</div>`).join('');
const profileBtn = (!s.is_guest && s.channel)
? `<div style="text-align:center;margin:-6px 0 14px;">
<a href="/channel/${s.channel}" class="action-btn" style="display:inline-flex;">
<i class="bi bi-person-circle"></i> <span>View ${s.name}'s profile</span>
</a>
</div>` : '';
// Devices / browsers / OS sections only render if we have at least one bucket
const devSection = (d.devices||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-phone"></i> Devices</div>
${_uaBucketsHtml(d.devices, _iconForDevice)}` : '';
const brSection = (d.browsers||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-browser-chrome"></i> Browsers</div>
${_uaBucketsHtml(d.browsers, _iconForBrowser)}` : '';
const osSection = (d.os||[]).length ? `
<div class="ins-modal-section"><i class="bi bi-cpu"></i> Operating Systems</div>
${_uaBucketsHtml(d.os, _iconForOs)}` : '';
document.getElementById('insModalBody').innerHTML = `
<div class="ins-modal-hero">
<div class="ins-modal-hero-num">${_fmt(d.reach)}</div>
<div class="ins-modal-hero-label">unique device${d.reach===1?'':'s'} came from this link</div>
</div>
${profileBtn}
<div class="ins-modal-stat">
<span class="ins-modal-stat-lbl"><i class="bi bi-person"></i> Shared by</span>
<span class="ins-modal-stat-val">${s.name}${s.is_guest?' (guest)':''}</span>
</div>
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:14px;">
<span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg"></i> Link created</span>
<span class="ins-modal-stat-val">${_fmtDt(d.created_at)} <span style="color:var(--text-secondary);font-weight:400;">(${_ago(d.created_at)})</span></span>
</div>
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Where viewers came from</div>
${countriesHtml}
${devSection}
${brSection}
${osSection}
${recentHtml ? `
<div class="ins-modal-section"><i class="bi bi-clock-history"></i> Recent visits ${d.recent.length>=50?'(showing 50 of '+d.reach+')':''}</div>
<div class="ins-recent-scroll" style="max-height:320px;">${recentHtml}</div>
` : '<p style="font-size:13px;color:var(--text-secondary);margin:14px 0 0;">No one has opened this link yet.</p>'}
<p style="font-size:11px;color:var(--text-secondary);margin:14px 0 0;line-height:1.5;">
The same device opening this link again is counted only once — VPN IP changes don't inflate the count.
</p>
`;
})
.catch(() => {
document.getElementById('insModalBody').innerHTML =
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load share link details.</p>';
});
}
</script>
@endif
@@ -7,12 +7,8 @@
])
@php
$orientationClass = match($video->orientation ?? 'landscape') {
'portrait' => 'portrait',
'square' => 'square',
'ultrawide' => 'ultrawide',
default => '',
};
// Force 16:9 for every video — orientation classes intentionally disabled
$orientationClass = '';
$hlsUrl = $video->has_hls ? route('videos.hls', ['video' => $video, 'file' => 'master.m3u8']) : null;
$mp4Url = route('videos.stream', $video) . '?v=' . $video->updated_at->timestamp;
$nextUrl = $nextVideo && $playlist ? route('videos.show', $nextVideo) .'?playlist='.$playlist->share_token : null;
@@ -35,7 +31,9 @@
{{-- ══════════════════════════════════════════════
PLAYER WRAP — theater class toggled by JS
══════════════════════════════════════════════ --}}
<div class="ytp-wrap {{ $orientationClass }}" id="ytpWrap">
<div class="ytp-wrap {{ $orientationClass }}" id="ytpWrap"
data-progress-url="{{ route('videos.viewProgress', $video) }}"
data-video-id="{{ $video->id }}">
<div class="ytp" id="videoContainer" tabindex="0">
{{-- ── Video element ── --}}
@@ -1399,6 +1397,73 @@ function init() {
document.addEventListener('DOMContentLoaded', init);
// ── View-progress heartbeat ──────────────────────────────
(function() {
const _csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
let _hbVideoId = null;
let _hbUrl = null;
let _hbLast = 0;
let _hbCompleted = false;
function _refreshHbTarget() {
const wrap = document.getElementById('ytpWrap');
if (!wrap) return;
const vid = parseInt(wrap.dataset.videoId || '0', 10);
if (vid && vid !== _hbVideoId) {
_hbVideoId = vid;
_hbUrl = wrap.dataset.progressUrl || null;
_hbLast = 0;
_hbCompleted = false;
}
}
function _sendHb(completed) {
if (!_hbUrl) return;
const v = document.getElementById('videoPlayer');
if (!v) return;
const cur = Math.floor(v.currentTime || 0);
if (!completed && cur <= _hbLast) return;
const body = new URLSearchParams({ watched_seconds: cur, completed: completed ? '1' : '0' });
try {
if (completed && navigator.sendBeacon) {
const blob = new Blob([body.toString() + '&_token=' + _csrf], { type: 'application/x-www-form-urlencoded' });
navigator.sendBeacon(_hbUrl, blob);
} else {
fetch(_hbUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': _csrf, 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: body.toString(),
keepalive: true,
credentials: 'same-origin',
}).catch(function() {});
}
_hbLast = cur;
if (completed) _hbCompleted = true;
} catch (e) {}
}
function _hbBind() {
const v = document.getElementById('videoPlayer');
if (!v || v._hbBound) return;
v._hbBound = true;
_refreshHbTarget();
v.addEventListener('ended', () => _sendHb(true));
v.addEventListener('pause', () => _sendHb(false));
}
setInterval(function() { _refreshHbTarget(); if (!_hbCompleted) _sendHb(false); }, 5000);
document.addEventListener('visibilitychange', function() {
if (document.visibilityState === 'hidden') _sendHb(false);
});
window.addEventListener('pagehide', function() { _sendHb(false); });
document.addEventListener('DOMContentLoaded', _hbBind);
// also rebind after SPA source swaps
const _origLoad = window._ytpLoadSource;
if (typeof _origLoad === 'function') {
window._ytpLoadSource = function() {
_refreshHbTarget();
return _origLoad.apply(this, arguments);
};
}
})();
})();
</script>
@endonce
@@ -12,9 +12,10 @@
</p>
@if($video->thumbnail)
@php($thumbEmbed = \App\Support\EmailThumbnail::localPath($video))
<div class="email-thumb-wrap">
<a href="{{ route('videos.show', $video) }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
<img src="{{ $thumbEmbed ? $message->embed($thumbEmbed) : route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a>
</div>
@endif
@@ -7,10 +7,11 @@
<p class="email-subtitle">A channel you subscribed to just posted new content.</p>
{{-- Thumbnail --}}
@php($thumbEmbed = \App\Support\EmailThumbnail::localPath($video))
<div class="email-thumb-wrap">
@if($video->thumbnail)
<a href="{{ route('videos.show', $video) }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
<img src="{{ $thumbEmbed ? $message->embed($thumbEmbed) : route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a>
@else
<div class="email-thumb-placeholder">
@@ -23,10 +23,11 @@
@endif
{{-- Cover --}}
@php($thumbEmbed = \App\Support\EmailThumbnail::localPath($video))
<div class="email-thumb-wrap">
@if($video->thumbnail)
<a href="{{ $shareUrl }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $shareTitle }}">
<img src="{{ $thumbEmbed ? $message->embed($thumbEmbed) : route('media.thumbnail', $video->thumbnail) }}" alt="{{ $shareTitle }}">
</a>
@else
<div class="email-thumb-placeholder">
@@ -13,10 +13,11 @@
</p>
{{-- Thumbnail --}}
@php($thumbEmbed = \App\Support\EmailThumbnail::localPath($video))
<div class="email-thumb-wrap">
@if($video->thumbnail)
<a href="{{ route('videos.show', $video) }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
<img src="{{ $thumbEmbed ? $message->embed($thumbEmbed) : route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a>
@else
<div class="email-thumb-placeholder">
+63 -3
View File
@@ -791,6 +791,31 @@
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
}
.delete-otp-input {
width: 100%;
background: #1a1a1a;
border: 1px solid rgba(239, 68, 68, 0.5);
border-radius: 8px;
color: #fff;
padding: 12px 16px;
font-size: 22px;
letter-spacing: 0.4em;
text-align: center;
text-indent: 0.4em;
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
box-sizing: border-box;
transition: border-color .15s ease, box-shadow .15s ease;
}
.delete-otp-input::placeholder {
color: rgba(255, 255, 255, 0.35);
letter-spacing: 0.4em;
}
.delete-otp-input:focus {
outline: none;
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.18);
}
/* ── Playlist controls bar (sidebar, all video types) ── */
.pl-controls-bar {
display: flex;
@@ -1021,6 +1046,11 @@
</style>
@yield('extra_styles')
{{-- Device fingerprint: computes a stable per-device hash on first visit,
caches it in localStorage + the `_fp` cookie so the server can dedupe guests
across IP/VPN/country changes. Loaded async — never blocks paint. --}}
<script src="{{ asset('fp.js') }}" async></script>
</head>
<body class="{{ $bodyClass ?? '' }} {{ session('impersonator_id') ? 'has-impersonate-bar' : '' }}">
<!-- Header -->
@@ -1065,13 +1095,14 @@
@auth
@include('layouts.partials.upload-modal')
@include('layouts.partials.edit-video-modal')
@include('layouts.partials.sports-match-modal')
@endauth
<!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) -->
@include('layouts.partials.add-to-playlist-modal')
<!-- Share Modal - Available on all pages -->
@include('layouts.partials.share-modal')
<x-share-modal />
<!-- Delete Video Modal -->
@auth
@@ -1118,8 +1149,7 @@
</label>
<input type="text" id="deleteOtpInput" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code"
class="form-input"
style="letter-spacing: 0.3em; font-size: 22px; text-align: center;"
class="delete-otp-input"
placeholder="000000">
</div>
@endif
@@ -1426,6 +1456,7 @@
case 'new_user': return '&#x1F389; ' + actor + ' just joined TAKEONE!';
case 'new_subscriber': return '&#x1F514; ' + actor + ' subscribed to your channel';
case 'video_like': return '&#x2764;&#xFE0F; ' + actor + ' liked your video ' + title;
case 'video_shared': return '&#x1F4E4; ' + actor + ' shared a video with you: ' + title + (d.message ? ' <em class="yt-notif-preview">"' + escHtml(d.message) + '"</em>' : '');
case 'new_post': return '&#x1F4DD; ' + actor + ' posted something new';
default: return actor + ' uploaded a new video: ' + title;
}
@@ -2110,5 +2141,34 @@
})();
</script>
{{-- Profile-visit tracking: any link with data-profile-visit-url fires a beacon on click --}}
<script>
(function () {
var _csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
document.addEventListener('click', function (e) {
var a = e.target.closest('[data-profile-visit-url]');
if (!a) return;
var url = a.getAttribute('data-profile-visit-url');
var src = a.getAttribute('data-source-video-id') || '';
if (!url) return;
try {
var body = new URLSearchParams({ source_video_id: src }).toString();
if (navigator.sendBeacon) {
var blob = new Blob([body + '&_token=' + encodeURIComponent(_csrf)], { type: 'application/x-www-form-urlencoded' });
navigator.sendBeacon(url, blob);
} else {
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': _csrf, 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: body,
credentials: 'same-origin',
keepalive: true,
}).catch(function(){});
}
} catch (err) {}
});
})();
</script>
</body>
</html>
@@ -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>
@@ -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 &amp; 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>
+28
View File
@@ -2,6 +2,34 @@
@section('title', $playlist->name . ' | ' . config('app.name'))
@push('head')
@php
$plDesc = trim($playlist->description ?? '');
if ($plDesc === '') {
$plDesc = $playlist->video_count . ' videos' . ($playlist->user ? ' • by ' . $playlist->user->name : '');
}
$plShareUrl = route('playlists.showByToken', $playlist->share_token);
// Use the dedicated OG endpoint so previews always get a 1200x630 JPG under the
// size/format limits of WhatsApp/Telegram/Discord/etc.
$plOgImage = route('playlists.ogImage', $playlist);
@endphp
<meta property="og:title" content="{{ $playlist->name }}">
<meta property="og:description" content="{{ Str::limit(strip_tags($plDesc), 200) }}">
<meta property="og:image" content="{{ $plOgImage }}">
<meta property="og:image:secure_url" content="{{ $plOgImage }}">
<meta property="og:image:type" content="image/jpeg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="{{ $playlist->name }}">
<meta property="og:url" content="{{ $plShareUrl }}">
<meta property="og:type" content="music.playlist">
<meta property="og:site_name" content="{{ config('app.name') }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $playlist->name }}">
<meta name="twitter:description" content="{{ Str::limit(strip_tags($plDesc), 200) }}">
<meta name="twitter:image" content="{{ $plOgImage }}">
@endpush
@section('extra_styles')
<style>
/* ══════════════════════════════════════════════
+1 -1
View File
@@ -243,7 +243,7 @@
@endif
@include('layouts.partials.share-modal')
<x-share-modal />
@endsection
@section('scripts')
@@ -77,14 +77,11 @@
<svg class="ytp-svg-pause" viewBox="0 0 24 24" style="display:none"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
@if(isset($previousVideo) && $prevUrl)
<button class="ytp-button" title="Previous" onclick="window.location.href='{{ $prevUrl }}'">
@if(isset($playlist) && $playlist)
<button class="ytp-button ytp-prev-btn" title="Previous" onclick="if(window.plPrev)window.plPrev();">
<svg viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg>
</button>
@endif
@if(isset($nextVideo) && $nextUrl)
<button class="ytp-button" title="Next" onclick="window.location.href='{{ $nextUrl }}'">
<button class="ytp-button ytp-next-btn" title="Next" onclick="if(window.plNext)window.plNext();">
<svg viewBox="0 0 24 24"><path d="m6 18 8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
@endif
@@ -224,12 +221,16 @@
/* ══ Full ytp styles ══ */
.ytp-wrap {
position: relative; width: 100%; background: #000;
position: relative; width: 100% !important; background: #000;
border-radius: 12px; overflow: hidden;
aspect-ratio: 16/9;
aspect-ratio: 16/9 !important;
height: auto !important;
max-height: none !important;
min-height: 0 !important;
}
.ytp {
.ytp, .audio-ytp {
position: relative; width: 100%; height: 100%;
aspect-ratio: 16/9;
background: #000; outline: none;
user-select: none; overflow: hidden;
font-family: Roboto, Arial, sans-serif;
@@ -1063,6 +1064,9 @@ window._audioPlayerUpdate = function(d) {
var langPopup = document.getElementById('ytpLangPopup');
if (!langWrap || !langPopup || !langBtn) return;
// New song loaded → back to its primary track, so shares don't carry a stale track id.
window._ytpTrackId = 0;
function flagHtml(flag, size) {
var s = size === 'sm' ? 'width:22px;height:16px;border-radius:2px;display:inline-block;flex-shrink:0;'
: 'width:22px;height:16px;border-radius:2px;display:inline-block;';
@@ -1143,6 +1147,8 @@ window._audioPlayerUpdate = function(d) {
var url = opt.dataset.langUrl;
var flag = opt.dataset.langFlag;
var curTime = audio.currentTime;
// Track the selected version so "Share" links to this exact language (0 = primary).
window._ytpTrackId = parseInt(opt.dataset.langId, 10) || 0;
langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); });
opt.classList.add('active');
langPopup.classList.remove('open');
@@ -32,7 +32,10 @@
.vdb-desc-text a.action-btn { display:inline-flex; margin:4px 6px 4px 0; color:inherit; text-decoration:none; vertical-align:middle; }
.vdb-desc-text.vdb-clamp { max-height:130px; overflow:hidden; -webkit-mask-image:linear-gradient(180deg,#000 70%,transparent); mask-image:linear-gradient(180deg,#000 70%,transparent); }
.vdb-desc-text.vdb-clamp.vdb-expanded { max-height:none; -webkit-mask-image:none; mask-image:none; }
.vdb-show-more { background:none; border:none; color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:6px 0 0; }
.vdb-show-more { display:flex; align-items:center; justify-content:center; gap:6px; margin:12px auto 0; background:var(--bg-secondary); border:1px solid var(--border-color); color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:7px 16px; border-radius:18px; transition:background .15s ease, border-color .15s ease; }
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
.vdb-show-more i { font-size:14px; transition:transform .2s ease; }
.vdb-show-more.expanded i { transform:rotate(180deg); }
</style>
<div class="vdb-wrap" id="vdbWrap">
@@ -58,7 +61,7 @@
{!! $descriptionSlot !!}
@elseif($renderedDescription !== '')
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)">Show more</button>
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
@else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@endif
@@ -70,8 +73,24 @@
</div>
<script>
// Remember which tab the user opened so SPA navigation between videos keeps it active.
window._vdbActiveTab = window._vdbActiveTab || 'vdb-about';
// Scroll back up to the video player on every SPA video-to-video swap.
// On mobile the window is locked (see CLAUDE.md mobile scroll model) and `.yt-main`
// (id="main") is the real scroll container, so we scroll that instead.
window._spaScrollToVideo = window._spaScrollToVideo || function () {
const main = document.getElementById('main');
if (window.innerWidth <= 768 && main) {
main.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// ── Tab switching ──────────────────────────────────────
function switchVdbTab(panelId, btn) {
window._vdbActiveTab = panelId;
document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
@@ -82,11 +101,44 @@ function switchVdbTab(panelId, btn) {
if (currentUrl && currentUrl !== window._insLoadedUrl) loadInsights();
}
}
// Re-apply the remembered tab after an SPA swap replaces #vdbWrap's contents.
// If the remembered tab doesn't exist on the new video (e.g. Insights when the
// viewer isn't the owner), fall back to About so nothing is left blank.
function _vdbApplyActiveTab() {
const wrap = document.getElementById('vdbWrap');
if (!wrap) return;
let target = window._vdbActiveTab || 'vdb-about';
let btn = wrap.querySelector('.vdb-tab[data-panel="' + target + '"]');
if (!btn) { target = 'vdb-about'; btn = wrap.querySelector('.vdb-tab[data-panel="vdb-about"]'); }
if (!btn) return;
wrap.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
wrap.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
const panel = document.getElementById(target);
if (panel) panel.classList.add('active');
if (target === 'vdb-insights') {
const ip = document.getElementById('vdb-insights');
const currentUrl = ip && ip.dataset.insightsBase;
if (currentUrl && currentUrl !== window._insLoadedUrl && typeof loadInsights === 'function') loadInsights();
}
}
// Observe #vdbWrap so the active tab is re-applied whenever the SPA layer
// rewrites its innerHTML during a video-to-video transition.
(function _vdbWatchSwaps() {
const wrap = document.getElementById('vdbWrap');
if (!wrap || wrap._vdbTabObserver) return;
const obs = new MutationObserver(() => _vdbApplyActiveTab());
obs.observe(wrap, { childList: true, subtree: false });
wrap._vdbTabObserver = obs;
})();
function toggleVdbDesc(btn) {
const d = document.getElementById('vdbDescShort');
if (!d) return;
const expanded = d.classList.toggle('vdb-expanded');
btn.textContent = expanded ? 'Show less' : 'Show more';
btn.classList.toggle('expanded', expanded);
const label = btn.querySelector('span');
if (label) label.textContent = expanded ? 'Show less' : 'Show more';
}
// Reveal "Show more" only when the description overflows the clamp. Compare the
// natural content height to the clamp limit (130px) rather than clientHeight,
@@ -94,8 +146,8 @@ function toggleVdbDesc(btn) {
function _vdbCheckOverflow() {
const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore');
if (!d || !b) return;
if (d.classList.contains('vdb-expanded')) { b.style.display = 'block'; return; }
b.style.display = (d.scrollHeight > 138) ? 'block' : 'none';
if (d.classList.contains('vdb-expanded')) { b.style.display = 'flex'; return; }
b.style.display = (d.scrollHeight > 138) ? 'flex' : 'none';
}
document.addEventListener('DOMContentLoaded', _vdbCheckOverflow);
window.addEventListener('load', _vdbCheckOverflow);
@@ -17,7 +17,9 @@
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 6px 0; flex-wrap: wrap; gap: 12px;">
{{-- Left: Channel Info --}}
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
<a href="{{ $video->user ? route('channel', $video->user->channel) : '#' }}" class="channel-info text-decoration-none"
@if($video->user) data-profile-visit-url="{{ route('profile.visit', $video->user) }}" data-source-video-id="{{ $video->id }}" @endif
style="color: inherit; display: flex; align-items: center; gap: 12px;">
@if($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px;" alt="{{ $video->user->name }}">
@else
@@ -69,9 +71,9 @@
{{-- Share Button --}}
@if($video->isShareable())
<button class="action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<x-share-button :video="$video" class="action-btn">
<i class="bi bi-share"></i> <span>Share</span>
</button>
</x-share-button>
@endif
</div>
</div>
+20 -13
View File
@@ -648,7 +648,10 @@
.vdb-desc-text { font-size: 14px; line-height: 1.6; color: var(--text-primary); white-space: pre-wrap; }
.vdb-desc-text p { margin-bottom: 8px; }
.vdb-desc-text a { color: #3ea6ff; }
.vdb-show-more { background: none; border: none; color: var(--text-primary); font-weight: 700; font-size: 13px; cursor: pointer; padding: 6px 0 0; }
.vdb-show-more { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 12px auto 0; background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); font-weight: 700; font-size: 13px; cursor: pointer; padding: 7px 16px; border-radius: 18px; transition: background .15s ease, border-color .15s ease; }
.vdb-show-more:hover { background: var(--bg-hover, rgba(127,127,127,.12)); }
.vdb-show-more i { font-size: 14px; transition: transform .2s ease; }
.vdb-show-more.expanded i { transform: rotate(180deg); }
</style>
@@ -680,7 +683,7 @@
<div id="vdbDescShort" class="vdb-desc-text">{{ $needsExpand ? $shortDescription : $fullDescription }}</div>
@if($needsExpand)
<div id="vdbDescFull" class="vdb-desc-text" style="display:none;">{{ $fullDescription }}</div>
<button class="vdb-show-more" onclick="toggleVdbDesc(this)">Show more</button>
<button class="vdb-show-more" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
@endif
@else
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
@@ -707,20 +710,24 @@
function toggleVdbDesc(btn) {
const short = document.getElementById('vdbDescShort');
const full = document.getElementById('vdbDescFull');
const label = btn.querySelector('span');
if (full.style.display === 'none') {
short.style.display = 'none';
full.style.display = 'block';
btn.textContent = 'Show less';
label.textContent = 'Show less';
btn.classList.add('expanded');
} else {
short.style.display = 'block';
full.style.display = 'none';
btn.textContent = 'Show more';
label.textContent = 'Show more';
btn.classList.remove('expanded');
}
}
// ── Share + track ──────────────────────────────────────
function videoShare(url, title, trackUrl) {
openShareModal(url, title);
// Pass the full argument set so email + share-to-members are available (see share component rule).
openShareModal(url, title, '', '{{ auth()->check() ? route('videos.shareEmail', $video) : '' }}', '{{ auth()->check() ? route('videos.shareMembers', $video) : '' }}');
fetch(trackUrl, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', 'Accept': 'application/json' }
@@ -773,7 +780,8 @@
@foreach ($playlistVideos as $index => $playlistVideo)
@php $isCurrent = $playlistVideo->id === $video->id; @endphp
<div class="sidebar-video-card{{ $isCurrent ? ' current-video' : '' }}"
{{ $isCurrent ? '' : "onclick=\"window.location.href='".route('videos.show', $playlistVideo)."?playlist={$playlist->share_token}'\"" }}
data-pl-id="{{ $playlistVideo->id }}"
{{ $isCurrent ? '' : 'onclick="plGoTo(\''.route('videos.show', $playlistVideo).'?playlist='.$playlist->share_token.'\')"' }}
style="{{ $isCurrent ? 'cursor:default;' : 'cursor:pointer;' }}">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
@@ -892,10 +900,9 @@
@endauth
@if ($video->isShareable())
<button class="yt-action-btn"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')" style="flex:1;">
<x-share-button :video="$video" class="yt-action-btn" style="flex:1;">
<i class="bi bi-share"></i><span>Share</span>
</button>
</x-share-button>
@endif
<button class="yt-action-btn" onclick="openAddToPlaylistModal({{ $video->id }})" style="flex:1;">
@@ -921,7 +928,7 @@
@endauth
</div>
@include('layouts.partials.share-modal')
{{-- Share modal is the app-layout singleton (<x-share-modal/>); do not re-render it here. --}}
@include('layouts.partials.edit-video-modal')
@include('layouts.partials.add-to-playlist-modal')
@@ -1000,12 +1007,12 @@
var pos = order.indexOf(curIdx);
var nextPos = (pos + 1) % order.length;
if (pos === order.length - 1 && plLoop !== 'all') return; // end of shuffle, no loop
window.location.href = PL_VIDEOS[order[nextPos]].url;
plGoTo(PL_VIDEOS[order[nextPos]].url);
} else {
if (PL_NEXT_URL) {
window.location.href = PL_NEXT_URL;
plGoTo(PL_NEXT_URL);
} else if (plLoop === 'all' && PL_FIRST_URL) {
window.location.href = PL_FIRST_URL;
plGoTo(PL_FIRST_URL);
}
}
}
@@ -1004,7 +1004,7 @@
</div>
@include('layouts.partials.share-modal')
<x-share-modal />
@include('layouts.partials.edit-video-modal')
@if (Session::has('openEditModal') && Session::get('openEditModal'))
@@ -3109,7 +3109,7 @@
</div>
<!-- Modals -->
@include('layouts.partials.share-modal')
<x-share-modal />
@include('layouts.partials.edit-video-modal')
<!-- Add Round Modal -->
@@ -957,7 +957,7 @@
</div>
@include('layouts.partials.share-modal')
<x-share-modal />
@include('layouts.partials.edit-video-modal')
@if (Session::has('openEditModal') && Session::get('openEditModal'))
+16 -6
View File
@@ -477,6 +477,11 @@
<i class="bi bi-play-circle"></i>
<span class="pl-autoplay-label">Autoplay</span>
</button>
<div class="pl-ctrl-divider"></div>
<button class="pl-ctrl-btn" id="plShareBtn" title="Share playlist"
onclick="openShareModal('{{ route('playlists.showByToken', $playlist->share_token) }}','{{ addslashes($playlist->name) }}','{{ route('playlists.recordShare', $playlist) }}','','')">
<i class="bi bi-share"></i>
</button>
</div>
<script>
@@ -516,16 +521,22 @@
async function plTransitionTo(url, pushHist) {
if(!url||plTransiting) return;
plTransiting=true;
if(window._spaScrollToVideo) window._spaScrollToVideo();
try {
var m=url.match(/\/videos\/([^/?#]+)/);
if(!m){ window.location.href=url; return; }
var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):'';
var resp=await fetch('/videos/'+m[1]+'/player-data'+qs);
var resp;
try { resp=await fetch('/videos/'+m[1]+'/player-data'+qs); }
catch(e){ window.location.href=url; return; }
if(!resp.ok){ window.location.href=url; return; }
var d=await resp.json();
var d;
try { d=await resp.json(); }
catch(e){ window.location.href=url; return; }
// reload video source (HLS or MP4)
if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
catch(e){ console.warn('_ytpLoadSource', e); }
// reset progress bar
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
@@ -545,14 +556,12 @@
if(vid) vid.loop=(plLoop==='one');
PL_CURRENT=d.id;
plRender();
plHighlight(d.id, true);
try { plRender(); plHighlight(d.id, true); } catch(e){ console.warn('plRender', e); }
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
plSwapContent(url);
} catch(e){
console.warn('plTransitionTo',e);
window.location.href=url;
} finally {
plTransiting=false;
}
@@ -783,6 +792,7 @@
async function recTransitionTo(url, pushHist) {
if (!url || recTransiting) return;
recTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try {
var m = url.match(/\/videos\/([^/?#]+)/);
if (!m) { window.location.href = url; return; }
+16 -6
View File
@@ -2555,6 +2555,11 @@
<i class="bi bi-play-circle"></i>
<span class="pl-autoplay-label">Autoplay</span>
</button>
<div class="pl-ctrl-divider"></div>
<button class="pl-ctrl-btn" id="plShareBtn" title="Share playlist"
onclick="openShareModal('{{ route('playlists.showByToken', $playlist->share_token) }}','{{ addslashes($playlist->name) }}','{{ route('playlists.recordShare', $playlist) }}','','')">
<i class="bi bi-share"></i>
</button>
</div>
<script>
@@ -2594,15 +2599,21 @@
async function plTransitionTo(url, pushHist) {
if(!url||plTransiting) return;
plTransiting=true;
if(window._spaScrollToVideo) window._spaScrollToVideo();
try {
var m=url.match(/\/videos\/([^/?#]+)/);
if(!m){ window.location.href=url; return; }
var qs=url.indexOf('?')!==-1?url.substring(url.indexOf('?')):'';
var resp=await fetch('/videos/'+m[1]+'/player-data'+qs);
var resp;
try { resp=await fetch('/videos/'+m[1]+'/player-data'+qs); }
catch(e){ window.location.href=url; return; }
if(!resp.ok){ window.location.href=url; return; }
var d=await resp.json();
var d;
try { d=await resp.json(); }
catch(e){ window.location.href=url; return; }
if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url);
try { if(window._ytpLoadSource) window._ytpLoadSource(d.hls_url, d.stream_url); }
catch(e){ console.warn('_ytpLoadSource', e); }
var pl=document.getElementById('ytpPlayed'),sc=document.getElementById('ytpScrubber');
var cu=document.getElementById('ytpCurrent'),dr=document.getElementById('ytpDuration');
@@ -2617,14 +2628,12 @@
if(vid) vid.loop=(plLoop==='one');
PL_CURRENT=d.id;
plRender();
plHighlight(d.id, true);
try { plRender(); plHighlight(d.id, true); } catch(e){ console.warn('plRender', e); }
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
plSwapContent(url);
} catch(e){
console.warn('plTransitionTo',e);
window.location.href=url;
} finally {
plTransiting=false;
}
@@ -2856,6 +2865,7 @@
async function recTransitionTo(url, pushHist) {
if (!url || recTransiting) return;
recTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try {
var m = url.match(/\/videos\/([^/?#]+)/);
if (!m) { window.location.href = url; return; }
+30 -8
View File
@@ -511,6 +511,11 @@
<i class="bi bi-play-circle"></i>
<span class="pl-autoplay-label">Autoplay</span>
</button>
<div class="pl-ctrl-divider"></div>
<button class="pl-ctrl-btn" id="plShareBtn" title="Share playlist"
onclick="openShareModal('{{ route('playlists.showByToken', $playlist->share_token) }}','{{ addslashes($playlist->name) }}','{{ route('playlists.recordShare', $playlist) }}','','')">
<i class="bi bi-share"></i>
</button>
</div>
<script>
@@ -554,13 +559,18 @@
async function plTransitionTo(url, pushHist) {
if (!url || plTransiting) return;
plTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try {
var m = url.match(/\/videos\/([^/?#]+)/);
if (!m) { window.location.href=url; return; }
var qs = url.indexOf('?')!==-1 ? url.substring(url.indexOf('?')) : '';
var resp = await fetch('/videos/'+m[1]+'/player-data'+qs);
var resp;
try { resp = await fetch('/videos/'+m[1]+'/player-data'+qs); }
catch(e) { window.location.href=url; return; }
if (!resp.ok) { window.location.href=url; return; }
var d = await resp.json();
var d;
try { d = await resp.json(); }
catch(e) { window.location.href=url; return; }
// swap audio src (keeps browser autoplay permission)
var audio = document.getElementById('audioEl');
@@ -571,13 +581,13 @@
}
// update cover / slideshow / title / progress via shared hook
if (window._audioPlayerUpdate) window._audioPlayerUpdate(d);
try { if (window._audioPlayerUpdate) window._audioPlayerUpdate(d); }
catch(e) { console.warn('_audioPlayerUpdate', e); }
// update state
PL_CURRENT = d.id;
if(audio&&plLoop==='one') audio.loop=true; else if(audio) audio.loop=false;
plRender();
plHighlight(d.id, true);
try { plRender(); plHighlight(d.id, true); } catch(e) { console.warn('plRender', e); }
// history
if(pushHist!==false) history.pushState({plVideoId:d.id,url:url},'',url);
@@ -589,7 +599,6 @@
plSwapContent(url);
} catch(e) {
console.warn('plTransitionTo',e);
window.location.href=url;
} finally {
plTransiting=false;
}
@@ -631,8 +640,17 @@
c.classList.toggle('current-video', parseInt(c.dataset.plId)===activeId);
});
if(scroll) {
var active=document.querySelector('.sidebar-video-card.current-video');
if(active) active.scrollIntoView({behavior:'smooth',block:'nearest'});
// Scroll only inside the sidebar list container — never the page —
// so focus stays on the player when the track changes.
var active = document.querySelector('.sidebar-video-card.current-video');
if (!active) return;
var list = active.closest('.recommended-videos-list') || active.parentElement;
if (list) {
var lr = list.getBoundingClientRect();
var ar = active.getBoundingClientRect();
var delta = (ar.top + ar.height/2) - (lr.top + lr.height/2);
list.scrollTo({ top: list.scrollTop + delta, behavior: 'smooth' });
}
}
}
@@ -651,6 +669,9 @@
if(ab){ ab.classList.toggle('pl-ctrl-active',plAutoplay==='1'); ab.title='Autoplay: '+(plAutoplay==='1'?'On':'Off'); }
if(nb) nb.disabled=!adj.next;
if(pb) pb.disabled=!adj.prev;
// In-player prev/next buttons (audio-player.blade.php)
document.querySelectorAll('.ytp-prev-btn').forEach(function(b){ b.disabled=!adj.prev; b.style.opacity=adj.prev?'':'0.4'; });
document.querySelectorAll('.ytp-next-btn').forEach(function(b){ b.disabled=!adj.next; b.style.opacity=adj.next?'':'0.4'; });
}
// ── public API ────────────────────────────────────────────
@@ -768,6 +789,7 @@
async function recTransitionTo(url, pushHist) {
if (recTransiting) return;
recTransiting = true;
if (window._spaScrollToVideo) window._spaScrollToVideo();
try {
var dataUrl = url.split('?')[0] + '/player-data';
var resp = await fetch(dataUrl, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });