Add multi-language audio tracks and self-hosted flag-icons
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) <[email protected]>
This commit is contained in:
1 parent
f8d13457fa
commit
66fd78c10f
314 files changed
+16507
-3172
No files matched your search
@@ -10,8 +10,9 @@
|
||||
@if($isVideoOwner)
|
||||
<style>
|
||||
/* ── Stat cards ──────────────────────────────────────── */
|
||||
.ins-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-bottom:18px; }
|
||||
@media(max-width:680px){ .ins-grid { grid-template-columns:repeat(2,1fr); } }
|
||||
.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); }
|
||||
@@ -157,7 +158,8 @@
|
||||
</style>
|
||||
|
||||
{{-- Panel: placed inside .vdb-wrap right after the About panel --}}
|
||||
<div class="vdb-panel" id="vdb-insights">
|
||||
<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>
|
||||
@@ -185,11 +187,12 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window._insLoaded = false;
|
||||
window._insData = null;
|
||||
window._insLoaded = false;
|
||||
window._insLoadedUrl = null;
|
||||
window._insData = null;
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
const _cflag = code => (!code||code.length!==2) ? '🌐' : code.toUpperCase().split('').map(c=>String.fromCodePoint(0x1F1E6+c.charCodeAt(0)-65)).join('');
|
||||
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'; };
|
||||
@@ -197,7 +200,7 @@ const _fmtDt = s => new Date(s).toLocaleDateString('en-US',{month:'short',day:'n
|
||||
|
||||
// ── Modal helpers ──────────────────────────────────────
|
||||
function _openModal(icon, title, subtitle, html) {
|
||||
document.getElementById('insModalIcon').textContent = icon;
|
||||
document.getElementById('insModalIcon').innerHTML = icon;
|
||||
document.getElementById('insModalTitle').textContent = title;
|
||||
document.getElementById('insModalSubtitle').textContent = subtitle || '';
|
||||
document.getElementById('insModalBody').innerHTML = html;
|
||||
@@ -265,9 +268,23 @@ function _hourlyChart(hourly) {
|
||||
// LOAD MAIN INSIGHTS
|
||||
// ══════════════════════════════════════════════════════
|
||||
function loadInsights() {
|
||||
fetch('{{ route('videos.insights', $video) }}', { headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'} })
|
||||
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._insData=d; renderInsights(d); })
|
||||
.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>';
|
||||
@@ -304,6 +321,11 @@ function renderInsights(d) {
|
||||
<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>
|
||||
@@ -527,15 +549,27 @@ function renderInsights(d) {
|
||||
// ── Share links breakdown ───────────────────────────
|
||||
let shareHtml = '';
|
||||
if (d.share_links > 0) {
|
||||
const linkRows = (d.share_breakdown || []).map((s, i) => `
|
||||
<div class="ins-dl-user-row" style="cursor:default;">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
|
||||
<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>
|
||||
<div class="ins-dl-count-badge" title="Unique devices that opened this link">👁️ ${_fmt(s.reach)}</div>
|
||||
</div>`).join('');
|
||||
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;">
|
||||
@@ -651,15 +685,27 @@ function openInsModal_shares() {
|
||||
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) => `
|
||||
<div class="ins-dl-user-row" style="cursor:default;">
|
||||
<div style="font-size:12px;font-weight:700;color:var(--text-secondary);min-width:18px;text-align:center;">${i+1}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="ins-dl-user-name">${s.sharer}</div>
|
||||
<div class="ins-dl-user-meta">Created: ${_ago(s.created_at)}</div>
|
||||
</div>
|
||||
<div class="ins-dl-count-badge">👁️ ${_fmt(s.reach)}</div>
|
||||
</div>`).join('')
|
||||
? (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',`
|
||||
@@ -675,14 +721,44 @@ function openInsModal_shares() {
|
||||
<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 flag = _cflag(code);
|
||||
const baseUrl = document.getElementById('vdb-insights')?.dataset.insightsBase || '';
|
||||
_modalLoading(flag, name||code, `${_fmt(totalCount)} views from this country`);
|
||||
|
||||
fetch(`{{ url('/videos/' . $video->getRouteKey() . '/insights/country') }}/${code}`, {
|
||||
fetch(`${baseUrl}/country/${code}`, {
|
||||
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
|
||||
})
|
||||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||||
@@ -727,9 +803,10 @@ function openCountryDetail(code, name, totalCount) {
|
||||
// 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(`{{ url('/videos/' . $video->getRouteKey() . '/insights/day') }}/${date}`, {
|
||||
fetch(`${baseUrl}/day/${date}`, {
|
||||
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
|
||||
})
|
||||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||||
@@ -745,7 +822,7 @@ function openDayDetail(date, label, totalCount) {
|
||||
<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').textContent = '📅';
|
||||
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 = `
|
||||
@@ -779,10 +856,11 @@ function openDayDetail(date, label, totalCount) {
|
||||
// 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(`{{ url('/videos/' . $video->getRouteKey() . '/insights/downloader') }}/${userId}`, {
|
||||
fetch(`${baseUrl}/downloader/${userId}`, {
|
||||
headers:{'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}
|
||||
})
|
||||
.then(r=>{ if(!r.ok) throw r; return r.json(); })
|
||||
|
||||
Reference in new issue
Block a user