Introduce per-video language support and multiple audio tracks (VideoAudioTrack model + migrations for language, description, title), a reusable language-select component, and a track-editor form. Bundle the self-hosted flag-icons v7.2.3 library and a NAS auto-sync command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
900 lines
61 KiB
PHP
900 lines
61 KiB
PHP
{{--
|
||
Video Insights component.
|
||
Expects: $video (Video model)
|
||
Renders the Insights tab panel + modal + all JS — only for the video owner.
|
||
--}}
|
||
@php
|
||
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
|
||
@endphp
|
||
|
||
@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-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); }
|
||
.ins-card-icon { font-size:18px; margin-bottom:2px; }
|
||
.ins-card-val { font-size:26px; font-weight:900; line-height:1; color:var(--text-primary); }
|
||
.ins-card-label { font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; color:var(--text-secondary); }
|
||
.ins-card-sub { font-size:12px; margin-top:3px; font-weight:600; }
|
||
.ins-card-sub.up { color:#4ade80; } .ins-card-sub.down { color:#f87171; } .ins-card-sub.neu { color:var(--text-secondary); }
|
||
|
||
/* ── Section titles / charts ────────────────────────── */
|
||
.ins-section-title { font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text-secondary); margin-bottom:12px; display:flex; align-items:center; gap:6px; }
|
||
.ins-chart { display:flex; align-items:flex-end; gap:4px; height:80px; margin-bottom:6px; }
|
||
.ins-bar-col { flex:1; display:flex; flex-direction:column; align-items:center; gap:4px; min-width:0; }
|
||
.ins-bar { width:100%; border-radius:4px 4px 0 0; background:#ef444466; transition:height .6s cubic-bezier(.22,.61,.36,1), background .15s; min-height:2px; }
|
||
.ins-bar.clickable { cursor:pointer; }
|
||
.ins-bar.clickable:hover { background:#ef4444cc !important; }
|
||
.ins-bar-today { background:#ef4444 !important; }
|
||
.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; }
|
||
@media(max-width:600px){ .ins-body { grid-template-columns:1fr; } }
|
||
|
||
/* ── Country rows ───────────────────────────────────── */
|
||
.ins-country-row { display:flex; align-items:center; gap:10px; padding:7px 0; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:6px; transition:background .12s; }
|
||
.ins-country-row:last-child { border-bottom:none; }
|
||
.ins-country-row:hover { background:rgba(255,255,255,.04); padding-left:6px; }
|
||
.ins-country-flag { font-size:20px; flex-shrink:0; width:28px; text-align:center; line-height:1; }
|
||
.ins-country-name { flex:1; font-size:13px; font-weight:500; color:var(--text-primary); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.ins-country-bar-wrap { width:100px; height:5px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; flex-shrink:0; }
|
||
.ins-country-bar { height:100%; background:#ef4444; border-radius:4px; }
|
||
.ins-country-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
|
||
.ins-country-cnt { font-size:11px; color:var(--text-secondary); min-width:40px; text-align:right; }
|
||
|
||
/* ── Download rows ───────────────────────────────────── */
|
||
.ins-dl-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; flex-wrap:wrap; gap:8px; }
|
||
.ins-dl-type-pills { display:flex; gap:6px; }
|
||
.ins-dl-pill { display:inline-flex; align-items:center; gap:5px; border-radius:20px; padding:3px 11px; font-size:12px; font-weight:600; }
|
||
.ins-dl-pill.video { border:1px solid rgba(239,68,68,.3); color:#fca5a5; background:rgba(239,68,68,.08); }
|
||
.ins-dl-pill.mp3 { border:1px solid rgba(96,165,250,.3); color:#93c5fd; background:rgba(96,165,250,.08); }
|
||
.ins-dl-pill.guest { border:1px solid rgba(255,255,255,.12); color:var(--text-secondary); background:rgba(255,255,255,.05); }
|
||
.ins-dl-user-row { display:flex; align-items:center; gap:10px; padding:8px 6px; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:8px; transition:background .12s; }
|
||
.ins-dl-user-row:last-child { border-bottom:none; }
|
||
.ins-dl-user-row:hover { background:rgba(255,255,255,.05); }
|
||
.ins-dl-avatar { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
|
||
.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:last-child { border-bottom:none; }
|
||
.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; }
|
||
.ins-recent-type.video { background:rgba(239,68,68,.12); color:#fca5a5; }
|
||
.ins-recent-type.mp3 { background:rgba(96,165,250,.1); color:#93c5fd; }
|
||
.ins-recent-time { color:var(--text-secondary); flex-shrink:0; min-width:70px; text-align:right; }
|
||
.ins-recent-flag { font-size:16px; flex-shrink:0; }
|
||
|
||
/* ── Demographics (gender / age) ─────────────────────── */
|
||
.ins-demo-row { display:flex; align-items:center; gap:8px; padding:5px 0; }
|
||
.ins-demo-sym { font-size:16px; flex-shrink:0; width:18px; text-align:center; }
|
||
.ins-demo-label { font-size:12px; font-weight:600; color:var(--text-primary); min-width:52px; }
|
||
.ins-age-label { min-width:68px; }
|
||
.ins-demo-bar-wrap { flex:1; height:6px; background:rgba(255,255,255,.07); border-radius:4px; overflow:hidden; }
|
||
.ins-demo-bar { height:100%; border-radius:4px; transition:width .6s cubic-bezier(.22,.61,.36,1); }
|
||
.ins-demo-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
|
||
.ins-demo-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
|
||
|
||
/* ── Two-column grid (collapses to 1 col on mobile) ─── */
|
||
.ins-two-col { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
||
@media(max-width:600px){ .ins-two-col { grid-template-columns:1fr; } }
|
||
|
||
/* ── Who Liked rows ──────────────────────────────────── */
|
||
.ins-liker-row { display:flex; align-items:center; gap:10px; padding:7px 6px; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:8px; transition:background .12s; }
|
||
.ins-liker-row:last-child { border-bottom:none; }
|
||
.ins-liker-row:hover { background:rgba(255,255,255,.05); }
|
||
.ins-liker-avatar { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
|
||
.ins-liker-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.ins-liker-time { font-size:11px; color:var(--text-secondary); flex-shrink:0; }
|
||
|
||
/* ── Skeleton ─────────────────────────────────────────── */
|
||
.ins-skeleton { display:flex; flex-direction:column; gap:12px; padding:4px 0; }
|
||
.ins-skel-row { height:14px; border-radius:6px; background:rgba(255,255,255,.06); animation:skelPulse 1.4s ease-in-out infinite; }
|
||
@keyframes skelPulse { 0%,100%{opacity:.5} 50%{opacity:1} }
|
||
|
||
/* ── Insight modal ───────────────────────────────────── */
|
||
.ins-modal-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.72); z-index:9200; display:flex; align-items:center; justify-content:center; padding:16px; animation:insBackIn .15s ease; }
|
||
@keyframes insBackIn { from{opacity:0} to{opacity:1} }
|
||
.ins-modal { background:var(--bg-secondary); border:1px solid var(--border-color); border-radius:18px; width:100%; max-width:500px; max-height:84vh; display:flex; flex-direction:column; overflow:hidden; animation:insModalIn .22s cubic-bezier(.34,1.4,.64,1); }
|
||
@keyframes insModalIn { from{opacity:0;transform:scale(.88) translateY(20px)} to{opacity:1;transform:none} }
|
||
.ins-modal-head { display:flex; align-items:center; gap:12px; padding:16px 18px 13px; border-bottom:1px solid var(--border-color); flex-shrink:0; }
|
||
.ins-modal-icon { font-size:26px; line-height:1; }
|
||
.ins-modal-titles { flex:1; min-width:0; }
|
||
.ins-modal-title { font-size:15px; font-weight:700; color:var(--text-primary); }
|
||
.ins-modal-subtitle { font-size:12px; color:var(--text-secondary); margin-top:1px; }
|
||
.ins-modal-close { background:rgba(255,255,255,.06); border:none; color:var(--text-secondary); border-radius:50%; width:30px; height:30px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:16px; transition:background .15s,color .15s; flex-shrink:0; }
|
||
.ins-modal-close:hover { background:rgba(239,68,68,.2); color:#fca5a5; }
|
||
.ins-modal-body { padding:18px 18px 22px; overflow-y:auto; flex:1; }
|
||
|
||
/* Modal content pieces */
|
||
.ins-modal-hero { text-align:center; padding:4px 0 20px; }
|
||
.ins-modal-hero-num { font-size:52px; font-weight:900; line-height:1; color:var(--text-primary); }
|
||
.ins-modal-hero-flag { font-size:56px; line-height:1; margin-bottom:4px; }
|
||
.ins-modal-hero-label { font-size:13px; color:var(--text-secondary); margin-top:6px; }
|
||
.ins-modal-stat { display:flex; justify-content:space-between; align-items:center; padding:9px 0; border-bottom:1px solid rgba(255,255,255,.05); font-size:13px; }
|
||
.ins-modal-stat:last-child { border-bottom:none; }
|
||
.ins-modal-stat-lbl { color:var(--text-secondary); font-weight:500; display:flex; align-items:center; gap:7px; }
|
||
.ins-modal-stat-val { font-weight:700; color:var(--text-primary); }
|
||
.ins-modal-section { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--text-secondary); margin:18px 0 10px; display:flex; align-items:center; gap:6px; }
|
||
.ins-modal-section:first-child { margin-top:0; }
|
||
|
||
/* Hourly chart (24 bars) */
|
||
.ins-hourly-chart { display:flex; align-items:flex-end; gap:2px; height:60px; margin-bottom:5px; }
|
||
.ins-hourly-col { flex:1; display:flex; flex-direction:column; align-items:center; gap:2px; min-width:0; }
|
||
.ins-hourly-bar { width:100%; border-radius:3px 3px 0 0; background:#ef444455; min-height:2px; transition:height .5s cubic-bezier(.22,.61,.36,1); }
|
||
.ins-hourly-bar.peak { background:#ef4444; }
|
||
.ins-hourly-lbl { font-size:7px; color:var(--text-secondary); text-align:center; overflow:hidden; white-space:nowrap; }
|
||
|
||
/* Person list */
|
||
.ins-person-row { display:flex; align-items:center; gap:10px; padding:9px 0; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:6px; transition:background .12s; }
|
||
.ins-person-row:last-child { border-bottom:none; }
|
||
.ins-person-row:hover { background:rgba(255,255,255,.04); padding-left:6px; }
|
||
.ins-person-avatar { width:36px; height:36px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
|
||
.ins-person-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.ins-person-meta { font-size:11px; color:var(--text-secondary); margin-top:1px; }
|
||
.ins-person-badge { flex-shrink:0; background:rgba(239,68,68,.1); border:1px solid rgba(239,68,68,.25); color:#fca5a5; border-radius:20px; font-size:11px; font-weight:700; padding:2px 9px; }
|
||
.ins-guest-row { display:flex; align-items:center; gap:10px; padding:8px 0; border-bottom:1px solid rgba(255,255,255,.04); font-size:12px; }
|
||
.ins-guest-row:last-child { border-bottom:none; }
|
||
.ins-guest-flag-big { font-size:22px; flex-shrink:0; }
|
||
|
||
/* Download history entries */
|
||
.ins-dl-hist-row { display:flex; align-items:center; gap:10px; padding:9px 12px; border-radius:8px; background:rgba(255,255,255,.03); margin-bottom:6px; font-size:12px; }
|
||
.ins-dl-hist-num { font-size:11px; font-weight:700; color:var(--text-secondary); min-width:18px; text-align:center; }
|
||
.ins-dl-hist-type { font-size:10px; font-weight:700; text-transform:uppercase; padding:2px 8px; border-radius:6px; flex-shrink:0; }
|
||
.ins-dl-hist-type.video { background:rgba(239,68,68,.12); color:#fca5a5; }
|
||
.ins-dl-hist-type.mp3 { background:rgba(96,165,250,.1); color:#93c5fd; }
|
||
.ins-dl-hist-time { flex:1; color:var(--text-secondary); }
|
||
.ins-dl-hist-flag { font-size:15px; flex-shrink:0; }
|
||
|
||
/* Loading state inside modal */
|
||
.ins-modal-loading { display:flex; flex-direction:column; align-items:center; justify-content:center; padding:40px 0; gap:10px; color:var(--text-secondary); font-size:13px; }
|
||
.ins-modal-spin { width:28px; height:28px; border:3px solid rgba(255,255,255,.1); border-top-color:#ef4444; border-radius:50%; animation:spin .7s linear infinite; }
|
||
@keyframes spin { to{transform:rotate(360deg)} }
|
||
</style>
|
||
|
||
{{-- Panel: placed inside .vdb-wrap right after the About panel --}}
|
||
<div class="vdb-panel" id="vdb-insights"
|
||
data-insights-base="{{ url('/videos/' . $video->getRouteKey() . '/insights') }}">
|
||
<div id="insightsContent">
|
||
<div class="ins-skeleton">
|
||
<div class="ins-skel-row" style="width:60%;height:18px;"></div>
|
||
<div class="ins-skel-row" style="width:100%;height:80px;"></div>
|
||
<div class="ins-skel-row" style="width:80%;"></div>
|
||
<div class="ins-skel-row" style="width:90%;"></div>
|
||
<div class="ins-skel-row" style="width:70%;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- Modal (position:fixed, nesting doesn't matter) --}}
|
||
<div id="insModalBackdrop" class="ins-modal-backdrop" style="display:none;" onclick="if(event.target===this)closeInsModal()">
|
||
<div class="ins-modal" onclick="event.stopPropagation()">
|
||
<div class="ins-modal-head">
|
||
<span class="ins-modal-icon" id="insModalIcon"></span>
|
||
<div class="ins-modal-titles">
|
||
<div class="ins-modal-title" id="insModalTitle"></div>
|
||
<div class="ins-modal-subtitle" id="insModalSubtitle"></div>
|
||
</div>
|
||
<button class="ins-modal-close" onclick="closeInsModal()"><i class="bi bi-x-lg"></i></button>
|
||
</div>
|
||
<div class="ins-modal-body" id="insModalBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
window._insLoaded = false;
|
||
window._insLoadedUrl = null;
|
||
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>`;
|
||
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'; };
|
||
const _fmtDt = s => new Date(s).toLocaleDateString('en-US',{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
|
||
|
||
// ── Modal helpers ──────────────────────────────────────
|
||
function _openModal(icon, title, subtitle, html) {
|
||
document.getElementById('insModalIcon').innerHTML = icon;
|
||
document.getElementById('insModalTitle').textContent = title;
|
||
document.getElementById('insModalSubtitle').textContent = subtitle || '';
|
||
document.getElementById('insModalBody').innerHTML = html;
|
||
document.getElementById('insModalBackdrop').style.display = 'flex';
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
function _modalLoading(icon, title, subtitle) {
|
||
_openModal(icon, title, subtitle,
|
||
`<div class="ins-modal-loading"><div class="ins-modal-spin"></div>Loading details…</div>`);
|
||
}
|
||
function closeInsModal() {
|
||
document.getElementById('insModalBackdrop').style.display = 'none';
|
||
document.body.style.overflow = '';
|
||
}
|
||
document.addEventListener('keydown', e => { if(e.key==='Escape') closeInsModal(); });
|
||
|
||
// ── Person list HTML (shared) ──────────────────────────
|
||
function _personListHtml(users, emptyMsg) {
|
||
if (!users || !users.length) return `<p style="font-size:13px;color:var(--text-secondary);margin:0;">${emptyMsg}</p>`;
|
||
return users.map((u,i) => `
|
||
<div class="ins-person-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
|
||
<div style="font-size:11px;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-person-avatar">
|
||
<div style="flex:1;min-width:0;">
|
||
<div class="ins-person-name">${u.name}</div>
|
||
<div class="ins-person-meta">Last seen ${_ago(u.last_at)}</div>
|
||
</div>
|
||
<div class="ins-person-badge">👁 ${u.count}×</div>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// ── Mini bar chart HTML (shared) ───────────────────────
|
||
function _miniBarChart(daily) {
|
||
const max = Math.max(...daily.map(d=>d.count), 1);
|
||
const today = new Date().toLocaleDateString('en-US',{month:'short',day:'numeric'});
|
||
return daily.map(d => {
|
||
const h = Math.round((d.count/max)*56);
|
||
const isT = d.label === today;
|
||
return `<div class="ins-bar-col">
|
||
<div class="ins-bar${isT?' ins-bar-today':''}" style="height:${Math.max(h,2)}px;" title="${d.label}: ${d.count}"></div>
|
||
<div class="ins-bar-label">${d.short}</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── Hourly chart HTML ──────────────────────────────────
|
||
function _hourlyChart(hourly) {
|
||
const max = Math.max(...hourly.map(h=>h.count), 1);
|
||
const peak = hourly.reduce((a,b)=>b.count>a.count?b:a, hourly[0]);
|
||
const bars = hourly.map(h => {
|
||
const ht = Math.round((h.count/max)*54);
|
||
const isPk = h.hour === peak.hour && peak.count > 0;
|
||
return `<div class="ins-hourly-col">
|
||
<div class="ins-hourly-bar${isPk?' peak':''}" style="height:${Math.max(ht,2)}px;" title="${h.label}: ${h.count}"></div>
|
||
<div class="ins-hourly-lbl">${h.hour%6===0?h.label:''}</div>
|
||
</div>`;
|
||
}).join('');
|
||
const peakH = peak.count > 0
|
||
? `<div style="margin-top:8px;"><span class="ins-peak-badge"><i class="bi bi-clock-fill"></i> Peak ${_fmtH(peak.hour)} · ${peak.count} views</span></div>`
|
||
: '';
|
||
return `<div class="ins-hourly-chart">${bars}</div>${peakH}`;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// LOAD MAIN INSIGHTS
|
||
// ══════════════════════════════════════════════════════
|
||
function loadInsights() {
|
||
const panel = document.getElementById('vdb-insights');
|
||
const baseUrl = panel && panel.dataset.insightsBase;
|
||
if (!baseUrl) return;
|
||
|
||
// Reset to skeleton so stale data from the previous video never shows
|
||
document.getElementById('insightsContent').innerHTML =
|
||
`<div class="ins-skeleton">
|
||
<div class="ins-skel-row" style="width:60%;height:18px;"></div>
|
||
<div class="ins-skel-row" style="width:100%;height:80px;"></div>
|
||
<div class="ins-skel-row" style="width:80%;"></div>
|
||
<div class="ins-skel-row" style="width:90%;"></div>
|
||
<div class="ins-skel-row" style="width:70%;"></div>
|
||
</div>`;
|
||
|
||
fetch(baseUrl, { headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'} })
|
||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||
.then(d => { window._insLoaded=true; window._insLoadedUrl=baseUrl; window._insData=d; renderInsights(d); })
|
||
.catch(() => {
|
||
document.getElementById('insightsContent').innerHTML =
|
||
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load insights. Try again later.</p>';
|
||
});
|
||
}
|
||
|
||
function renderInsights(d) {
|
||
const weekBadge = d.week_change>0
|
||
? `<span class="ins-card-sub up">↑ ${d.week_change}% vs last week</span>`
|
||
: d.week_change<0
|
||
? `<span class="ins-card-sub down">↓ ${Math.abs(d.week_change)}% vs last week</span>`
|
||
: `<span class="ins-card-sub neu">— same as last week</span>`;
|
||
|
||
const dlSub = (d.dl_video||d.dl_mp3)
|
||
? `<span class="ins-card-sub neu">🎬 ${_fmt(d.dl_video)} · 🎵 ${_fmt(d.dl_mp3)}</span>`
|
||
: `<span class="ins-card-sub neu">no downloads yet</span>`;
|
||
|
||
// ── Stat cards (open top-level modal on click) ──────
|
||
const cards = `
|
||
<p style="font-size:11px;color:var(--text-secondary);margin:0 0 8px;display:flex;align-items:center;gap:5px;">
|
||
<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-icon">👁️</div><div class="ins-card-val">${_fmt(d.total_views)}</div>
|
||
<div class="ins-card-label">Total Views</div>${weekBadge}
|
||
</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>
|
||
<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>
|
||
</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('');
|
||
|
||
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 chartSection = `
|
||
<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-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>`;
|
||
|
||
// ── Countries (each row clickable) ──────────────────
|
||
let countriesHtml = '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data yet.</p>';
|
||
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">
|
||
<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);flex-shrink:0;"></i>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// ── Viewers section ─────────────────────────────────
|
||
const topViewerRows = (d.top_viewers || []).map((u, i) => `
|
||
<div class="ins-dl-user-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
|
||
<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 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>
|
||
</div>`).join('');
|
||
|
||
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>
|
||
</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">
|
||
<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>
|
||
<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>
|
||
</div>
|
||
</div>`;
|
||
|
||
// ── Who Liked section ───────────────────────────────
|
||
let likersHtml = '';
|
||
if (d.likes > 0 && d.likers && d.likers.length) {
|
||
const likerRows = d.likers.map(u => `
|
||
<div class="ins-liker-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
|
||
<img src="${u.avatar}" alt="${u.name}" class="ins-liker-avatar">
|
||
<div class="ins-liker-name">${u.name}</div>
|
||
<div class="ins-liker-time">${_ago(u.liked_at)}</div>
|
||
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
|
||
</div>`).join('');
|
||
|
||
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-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>`;
|
||
}
|
||
|
||
// ── 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 {
|
||
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
|
||
? 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>
|
||
<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>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
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>`;
|
||
}
|
||
|
||
// ── 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;
|
||
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">
|
||
<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>
|
||
<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>
|
||
</div>`;
|
||
|
||
// ── Share links breakdown ───────────────────────────
|
||
let shareHtml = '';
|
||
if (d.share_links > 0) {
|
||
const linkRows = (d.share_breakdown || []).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 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 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" ${clickAttr}>
|
||
<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">${_fmtDt(s.created_at)} · ${_ago(s.created_at)}</div>
|
||
</div>
|
||
${flagHtml}
|
||
<div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
shareHtml = `
|
||
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
|
||
<div class="ins-section-title" style="margin:0;"><i class="bi bi-share"></i> Share Links — ${_fmt(d.share_links)} created · ${_fmt(d.shares)} unique devices reached</div>
|
||
</div>
|
||
<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>`;
|
||
}
|
||
|
||
document.getElementById('insightsContent').innerHTML =
|
||
cards +
|
||
`<div class="ins-body">${chartSection}
|
||
<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;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// STAT CARD MODALS (top-level summaries)
|
||
// ══════════════════════════════════════════════════════
|
||
function openInsModal_views() {
|
||
const d = window._insData; if(!d) return;
|
||
const changeColor = d.week_change>0?'#4ade80':d.week_change<0?'#f87171':'var(--text-secondary)';
|
||
const changeLabel = d.week_change>0?`↑ ${d.week_change}%`:d.week_change<0?`↓ ${Math.abs(d.week_change)}%`:'— 0%';
|
||
_openModal('👁️','View Analytics','All-time stats',`
|
||
<div class="ins-modal-hero">
|
||
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
|
||
<div class="ins-modal-hero-label">total views</div>
|
||
</div>
|
||
<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-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>
|
||
${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>`:''}`);
|
||
}
|
||
|
||
function openInsModal_viewers() {
|
||
const d = window._insData; if(!d) return;
|
||
const guestV = Math.max(0, d.total_views - d.unique_viewers);
|
||
const mPct = d.total_views>0 ? Math.round(d.unique_viewers/d.total_views*100) : 0;
|
||
_openModal('👤','Viewer Breakdown','Registered vs anonymous',`
|
||
<div class="ins-modal-hero">
|
||
<div class="ins-modal-hero-num">${_fmt(d.unique_viewers)}</div>
|
||
<div class="ins-modal-hero-label">unique registered viewers</div>
|
||
</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"><span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered</span><span class="ins-modal-stat-val">${mPct}% <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(d.unique_viewers)} 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-person-dash"></i> Anonymous</span><span class="ins-modal-stat-val">${100-mPct}% <span style="color:var(--text-secondary);font-weight:400;">(${_fmt(guestV)} views)</span></span></div>
|
||
<div class="ins-modal-section"><i class="bi bi-globe2"></i> Top countries — tap to see viewers</div>
|
||
${(d.countries||[]).slice(0,5).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('')||'<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>'}`);
|
||
}
|
||
|
||
function openInsModal_downloads() {
|
||
const d = window._insData; if(!d) return;
|
||
if (d.downloads===0) {
|
||
_openModal('⬇️','Downloads','No downloads 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 one has downloaded this video yet.</p></div>`);
|
||
return;
|
||
}
|
||
const engRate = d.total_views>0 ? ((d.downloads/d.total_views)*100).toFixed(1) : '0.0';
|
||
_openModal('⬇️','Download Summary',`${_fmt(d.downloads)} total downloads`,`
|
||
<div class="ins-modal-hero">
|
||
<div class="ins-modal-hero-num">${_fmt(d.downloads)}</div>
|
||
<div class="ins-modal-hero-label">total downloads</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;margin-bottom:18px;flex-wrap:wrap;">
|
||
${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>` : ''}
|
||
</div>
|
||
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Download rate</span><span class="ins-modal-stat-val">${engRate}% of viewers</span></div>
|
||
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><span class="ins-modal-stat-lbl"><i class="bi bi-people"></i> Unique downloaders</span><span class="ins-modal-stat-val">${_fmt((d.dl_users||[]).length + (d.dl_guests>0?1:0))}</span></div>
|
||
${(d.dl_users||[]).length ? `
|
||
<div class="ins-modal-section"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered — tap for full history</div>
|
||
${d.dl_users.map((u,i)=>`
|
||
<div class="ins-person-row" style="cursor:pointer;" onclick="closeInsModal();setTimeout(()=>openDownloaderHistory(${u.id},'${u.name.replace(/'/g,"\\'")}','${u.avatar}'),180)">
|
||
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
|
||
<img src="${u.avatar}" class="ins-person-avatar" alt="${u.name}">
|
||
<div style="flex:1;min-width:0;"><div class="ins-person-name">${u.name}</div><div class="ins-person-meta">Last: ${_ago(u.last_at)}</div></div>
|
||
<div class="ins-person-badge">⬇️ ${u.count}×</div>
|
||
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);margin-left:6px;flex-shrink:0;"></i>
|
||
</div>`).join('')}` : ''}
|
||
`);
|
||
}
|
||
|
||
function openInsModal_shares() {
|
||
const d = window._insData; if(!d) return;
|
||
const engRate = d.total_views>0 ? (((d.shares)+d.likes)/d.total_views*100).toFixed(1) : '0.0';
|
||
|
||
const linkRows = (d.share_breakdown||[]).length
|
||
? (d.share_breakdown||[]).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 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 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" ${clickAttr}>
|
||
<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">${_fmtDt(s.created_at)} · ${_ago(s.created_at)}</div>
|
||
</div>
|
||
${flagHtml}
|
||
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
|
||
</div>`;
|
||
}).join('')
|
||
: '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No share links created yet.</p>';
|
||
|
||
_openModal('🔗','Share Reach','Unique devices that opened your share links',`
|
||
<div class="ins-modal-hero">
|
||
<div class="ins-modal-hero-num">${_fmt(d.shares)}</div>
|
||
<div class="ins-modal-hero-label">unique devices reached</div>
|
||
</div>
|
||
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-link-45deg"></i> Links created</span><span class="ins-modal-stat-val">${_fmt(d.share_links)}</span></div>
|
||
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-hand-thumbs-up-fill" style="color:#ef4444;"></i> Likes</span><span class="ins-modal-stat-val">❤️ ${_fmt(d.likes)}</span></div>
|
||
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-activity"></i> Engagement rate</span><span class="ins-modal-stat-val">${engRate}%</span></div>
|
||
<div class="ins-modal-section" style="margin-top:16px;"><i class="bi bi-people-fill"></i> Share links — top by reach</div>
|
||
${linkRows}
|
||
<p style="font-size:11px;color:var(--text-secondary);margin:12px 0 0;line-height:1.5;">Each person who shares gets a unique link. Same device opening the same link counts only once.</p>`);
|
||
}
|
||
|
||
function openInsModal_likes() {
|
||
const d = window._insData; if(!d) return;
|
||
if (d.likes === 0) {
|
||
_openModal('❤️','Likes','No likes 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 one has liked this video yet.</p></div>`);
|
||
return;
|
||
}
|
||
const likeRate = d.total_views>0 ? ((d.likes/d.total_views)*100).toFixed(1) : '0.0';
|
||
const likerRows = (d.likers||[]).map((u,i) => `
|
||
<div class="ins-person-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
|
||
<div style="font-size:11px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
|
||
<img src="${u.avatar}" class="ins-person-avatar" alt="${u.name}">
|
||
<div style="flex:1;min-width:0;">
|
||
<div class="ins-person-name">${u.name}</div>
|
||
<div class="ins-person-meta">${_ago(u.liked_at)}</div>
|
||
</div>
|
||
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;"></i>
|
||
</div>`).join('');
|
||
|
||
_openModal('❤️','Likes',`${_fmt(d.likes)} ${d.likes===1?'person':'people'} liked this`,`
|
||
<div class="ins-modal-hero">
|
||
<div class="ins-modal-hero-num">${_fmt(d.likes)}</div>
|
||
<div class="ins-modal-hero-label">${d.likes===1?'person':'people'} liked this video</div>
|
||
</div>
|
||
<div class="ins-modal-stat"><span class="ins-modal-stat-lbl"><i class="bi bi-eye"></i> Like rate</span><span class="ins-modal-stat-val">${likeRate}% of viewers</span></div>
|
||
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;"><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-section"><i class="bi bi-heart-fill" style="color:#ef4444;"></i> Who liked</div>
|
||
${likerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No data available.</p>'}`);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// DRILL-DOWN: COUNTRY
|
||
// ══════════════════════════════════════════════════════
|
||
function openCountryDetail(code, name, totalCount) {
|
||
const flag = _cflag(code);
|
||
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
|
||
_modalLoading(flag, name||code, `${_fmt(totalCount)} views from this country`);
|
||
|
||
fetch(`${baseUrl}/country/${code}`, {
|
||
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
|
||
})
|
||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||
.then(d => {
|
||
const regHtml = _personListHtml(d.registered_users, 'No registered viewers yet from this country.');
|
||
const miniChart = _miniBarChart(d.daily);
|
||
|
||
document.getElementById('insModalBody').innerHTML = `
|
||
<div class="ins-modal-hero">
|
||
<div class="ins-modal-hero-flag">${flag}</div>
|
||
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
|
||
<div class="ins-modal-hero-label">total views from ${d.country_name||code}</div>
|
||
</div>
|
||
<div class="ins-modal-stat">
|
||
<span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered viewers</span>
|
||
<span class="ins-modal-stat-val">${_fmt((d.registered_users||[]).length)}</span>
|
||
</div>
|
||
<div class="ins-modal-stat" style="border-bottom:none;margin-bottom:18px;">
|
||
<span class="ins-modal-stat-lbl"><i class="bi bi-person-dash"></i> Anonymous views</span>
|
||
<span class="ins-modal-stat-val">${_fmt(d.guest_count)}</span>
|
||
</div>
|
||
<div class="ins-modal-section"><i class="bi bi-graph-up"></i> 14-day trend from ${d.country_name||code}</div>
|
||
<div class="ins-chart" style="height:60px;">${miniChart}</div>
|
||
${(d.registered_users||[]).length ? `
|
||
<div class="ins-modal-section"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered viewers</div>
|
||
${regHtml}` : `
|
||
<div class="ins-modal-section"><i class="bi bi-people"></i> Viewers</div>
|
||
<p style="font-size:13px;color:var(--text-secondary);margin:0;">
|
||
All ${_fmt(d.total_views)} views from ${d.country_name||code} were anonymous.
|
||
</p>`}`;
|
||
|
||
document.getElementById('insModalTitle').textContent = d.country_name || code;
|
||
document.getElementById('insModalSubtitle').textContent = `${_fmt(d.total_views)} views from this country`;
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('insModalBody').innerHTML =
|
||
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load country details.</p>';
|
||
});
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// DRILL-DOWN: SPECIFIC DAY
|
||
// ══════════════════════════════════════════════════════
|
||
function openDayDetail(date, label, totalCount) {
|
||
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
|
||
_modalLoading('📅', label, `${_fmt(totalCount)} views on this day`);
|
||
|
||
fetch(`${baseUrl}/day/${date}`, {
|
||
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
|
||
})
|
||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||
.then(d => {
|
||
const hourlyHtml = _hourlyChart(d.hourly);
|
||
const regHtml = _personListHtml(d.registered_users, 'No registered viewers on this day.');
|
||
|
||
const topCountries = (d.countries||[]).map(c=>`
|
||
<div class="ins-country-row" style="cursor:pointer;" onclick="closeInsModal();setTimeout(()=>openCountryDetail('${c.code}','${c.name}',${c.count}),180)">
|
||
<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>
|
||
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);"></i>
|
||
</div>`).join('') || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No location data.</p>';
|
||
|
||
document.getElementById('insModalIcon').innerHTML = '📅';
|
||
document.getElementById('insModalTitle').textContent = `${d.day_of_week}, ${d.date}`;
|
||
document.getElementById('insModalSubtitle').textContent = `${_fmt(d.total_views)} views on this day`;
|
||
document.getElementById('insModalBody').innerHTML = `
|
||
<div class="ins-modal-hero">
|
||
<div class="ins-modal-hero-num">${_fmt(d.total_views)}</div>
|
||
<div class="ins-modal-hero-label">views on ${d.day_of_week}</div>
|
||
</div>
|
||
<div class="ins-modal-stat">
|
||
<span class="ins-modal-stat-lbl"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Registered</span>
|
||
<span class="ins-modal-stat-val">${_fmt((d.registered_users||[]).length)} viewers (${_fmt(d.total_views - d.guest_count)} 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-person-dash"></i> Anonymous</span>
|
||
<span class="ins-modal-stat-val">${_fmt(d.guest_count)} views</span>
|
||
</div>
|
||
<div class="ins-modal-section"><i class="bi bi-clock"></i> Views by Hour</div>
|
||
${hourlyHtml}
|
||
${(d.registered_users||[]).length ? `
|
||
<div class="ins-modal-section" style="margin-top:18px;"><i class="bi bi-person-check-fill" style="color:#4ade80;"></i> Who watched</div>
|
||
${regHtml}` : ''}
|
||
<div class="ins-modal-section" style="margin-top:18px;"><i class="bi bi-globe2"></i> Top countries — tap to explore</div>
|
||
${topCountries}`;
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('insModalBody').innerHTML =
|
||
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load day details.</p>';
|
||
});
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════
|
||
// DRILL-DOWN: DOWNLOADER HISTORY
|
||
// ══════════════════════════════════════════════════════
|
||
function openDownloaderHistory(userId, userName, userAvatar) {
|
||
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
|
||
_modalLoading('⬇️', userName, 'Loading download history…');
|
||
document.getElementById('insModalIcon').innerHTML = `<img src="${userAvatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
|
||
|
||
fetch(`${baseUrl}/downloader/${userId}`, {
|
||
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
|
||
})
|
||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||
.then(d => {
|
||
document.getElementById('insModalIcon').innerHTML = `<img src="${d.user.avatar}" style="width:32px;height:32px;border-radius:50%;object-fit:cover;">`;
|
||
document.getElementById('insModalTitle').textContent = d.user.name;
|
||
document.getElementById('insModalSubtitle').textContent = `${d.total} download${d.total!==1?'s':''} of this video`;
|
||
|
||
const rows = d.records.map((r,i) => `
|
||
<div class="ins-dl-hist-row">
|
||
<div class="ins-dl-hist-num">${i+1}</div>
|
||
<span class="ins-dl-hist-type ${r.type}">${r.type.toUpperCase()}</span>
|
||
<div class="ins-dl-hist-time">${_fmtDt(r.at)}</div>
|
||
<div class="ins-dl-hist-flag" title="${r.country_name||r.country||'Unknown'}">${_cflag(r.country)}</div>
|
||
</div>`).join('');
|
||
|
||
document.getElementById('insModalBody').innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:12px;padding:4px 0 20px;">
|
||
<img src="${d.user.avatar}" style="width:52px;height:52px;border-radius:50%;object-fit:cover;border:2px solid var(--border-color);">
|
||
<div>
|
||
<div style="font-size:16px;font-weight:700;color:var(--text-primary);">${d.user.name}</div>
|
||
<div style="font-size:13px;color:var(--text-secondary);margin-top:2px;">
|
||
${d.total} download${d.total!==1?'s':''} of this video
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ins-modal-section"><i class="bi bi-clock-history"></i> Download History</div>
|
||
${rows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No records found.</p>'}`;
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('insModalBody').innerHTML =
|
||
'<p style="color:var(--text-secondary);font-size:13px;padding:8px 0;">Could not load download history.</p>';
|
||
});
|
||
}
|
||
</script>
|
||
@endif
|