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