Comments get a Translate button that calls the active LLM provider in Admin → AI settings. Each commenter shows a small country flag inferred from their video_views + profile_visits history (falls back to users.nationality); that inferred country drives the language hint passed to the LLM so Portuguese "Linda, linda, linda" translates to "Beautiful" instead of pass-through. Description boxes get a line-paired translation grid: a shared Translate button flips #vdbDescSplit into a two-column CSS grid where every source line sits next to its translation on the same row. Backend splits by newline and asks the LLM for a JSON map keyed by index so counts always match. LlmLyricsService now has translate() + translateLines() with echo-retry (if the model returns the input unchanged while source != target, retry once with a stronger directive) and keeps the Ollama model resident via keep_alive=1h. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
307 lines
17 KiB
PHP
307 lines
17 KiB
PHP
{{--
|
|
Tabbed description + insights box.
|
|
Expects: $video (Video model)
|
|
Optionally accepts: $descriptionSlot — raw HTML to show in the About tab body
|
|
--}}
|
|
@php
|
|
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
|
|
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
|
|
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
|
|
$llmEnabledVdb = $hasDesc && app(\App\Services\LlmLyricsService::class)->isEnabled();
|
|
// Optional pre-rendered block shown above the description inside the About
|
|
// panel. Only the `match` type passes it (the bout summary card); when it is
|
|
// unset every other type renders exactly as before.
|
|
$aboutPrepend = $aboutPrepend ?? null;
|
|
$showBox = $hasDesc || $isVideoOwner || $aboutPrepend !== null;
|
|
@endphp
|
|
@if ($showBox)
|
|
<style>
|
|
/* ── Description box ─────────────────────────────────── */
|
|
.vdb-wrap { background:var(--bg-secondary); border-radius:12px; margin-top:12px; overflow:hidden; border:1px solid var(--border-color); }
|
|
.vdb-tabs { display:flex; border-bottom:1px solid var(--border-color); padding:0 4px; }
|
|
.vdb-tab { background:none; border:none; color:var(--text-secondary); font-size:13px; font-weight:600; padding:0 16px; height:44px; cursor:pointer; position:relative; transition:color .15s; white-space:nowrap; display:flex; align-items:center; gap:6px; }
|
|
.vdb-tab:hover { color:var(--text-primary); }
|
|
.vdb-tab.active { color:var(--text-primary); }
|
|
.vdb-tab.active::after { content:''; position:absolute; bottom:0; left:0; right:0; height:2px; background:#ef4444; border-radius:2px 2px 0 0; }
|
|
.vdb-panel { display:none; padding:14px 16px 16px; }
|
|
.vdb-panel.active { display:block; }
|
|
.vdb-meta { font-size:13px; font-weight:600; color:var(--text-secondary); margin-bottom:10px; display:flex; gap:10px; flex-wrap:wrap; }
|
|
.vdb-desc-text { font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
|
|
.vdb-desc-text p { margin:0 0 8px; }
|
|
.vdb-desc-text p:last-child { margin-bottom:0; }
|
|
.vdb-desc-text h2 { font-size:19px; font-weight:700; margin:6px 0 8px; }
|
|
.vdb-desc-text h3 { font-size:16px; font-weight:700; margin:6px 0 6px; }
|
|
.vdb-desc-text ul, .vdb-desc-text ol { margin:0 0 8px; padding-left:22px; }
|
|
.vdb-desc-text blockquote { margin:0 0 8px; padding-left:12px; border-left:3px solid var(--border-color); color:var(--text-secondary); }
|
|
.vdb-desc-text a { color:#3ea6ff; }
|
|
.vdb-desc-text a.action-btn { display:inline-flex; margin:4px 6px 4px 0; color:inherit; text-decoration:none; vertical-align:middle; }
|
|
.vdb-desc-text.vdb-clamp { max-height:130px; overflow:hidden; -webkit-mask-image:linear-gradient(180deg,#000 70%,transparent); mask-image:linear-gradient(180deg,#000 70%,transparent); }
|
|
.vdb-desc-text.vdb-clamp.vdb-expanded { max-height:none; -webkit-mask-image:none; mask-image:none; }
|
|
.vdb-show-more { display:flex; align-items:center; justify-content:center; gap:6px; margin:12px auto 0; background:var(--bg-secondary); border:1px solid var(--border-color); color:var(--text-primary); font-weight:700; font-size:13px; cursor:pointer; padding:7px 16px; border-radius:18px; transition:background .15s ease, border-color .15s ease; }
|
|
.vdb-show-more:hover { background:var(--bg-hover, rgba(127,127,127,.12)); }
|
|
.vdb-show-more i { font-size:14px; transition:transform .2s ease; }
|
|
.vdb-show-more.expanded i { transform:rotate(180deg); }
|
|
.vdb-translate-btn { display:inline-flex; align-items:center; gap:6px; background:none; border:1px solid var(--border-color); color:var(--text-primary); font-size:12px; font-weight:600; padding:5px 12px; border-radius:16px; cursor:pointer; margin-top:10px; transition:background .15s, color .15s; font-family:inherit; }
|
|
.vdb-translate-btn:hover { background:rgba(255,255,255,.08); }
|
|
.vdb-translate-btn.loading { opacity:.6; cursor:wait; }
|
|
.vdb-translate-btn.active { color:#3ea6ff; border-color:rgba(62,166,255,.4); }
|
|
.vdb-translate-btn svg { width:14px; height:14px; fill:currentColor; }
|
|
.vdb-desc-split { display:block; }
|
|
/* Line-paired view: each row = one original line + its translation, aligned on
|
|
the same baseline so skimming across the divider stays easy. */
|
|
.vdb-desc-split.has-translation #vdbDescShort { display:none; }
|
|
.vdb-translation-grid { display:grid; grid-template-columns:1fr 1fr; column-gap:16px; row-gap:4px; font-size:14px; line-height:1.6; color:var(--text-primary); word-break:break-word; }
|
|
.vdb-translation-meta { grid-column:1 / -1; font-size:11px; color:var(--text-secondary); margin:0 0 4px; text-transform:uppercase; letter-spacing:.4px; }
|
|
.vdb-tr-row { display:contents; }
|
|
.vdb-tr-cell { padding:4px 0; white-space:pre-wrap; }
|
|
.vdb-tr-cell.vdb-tr-orig { padding-right:16px; border-right:1px solid var(--border-color); }
|
|
.vdb-tr-cell.vdb-tr-trans { padding-left:4px; }
|
|
.vdb-tr-cell.vdb-tr-empty { min-height:1em; }
|
|
@media (max-width:768px) {
|
|
.vdb-translation-grid { grid-template-columns:1fr; row-gap:2px; }
|
|
.vdb-tr-cell.vdb-tr-orig { padding:4px 0 2px; border-right:none; }
|
|
.vdb-tr-cell.vdb-tr-trans { padding:0 0 10px 0; color:#9ad1ff; border-bottom:1px dashed var(--border-color); }
|
|
}
|
|
</style>
|
|
|
|
<div class="vdb-wrap" id="vdbWrap">
|
|
<div class="vdb-tabs">
|
|
<button class="vdb-tab active" data-panel="vdb-about" onclick="switchVdbTab('vdb-about',this)">
|
|
<i class="bi bi-card-text"></i> About
|
|
</button>
|
|
@if($isVideoOwner)
|
|
<button class="vdb-tab" data-panel="vdb-insights" onclick="switchVdbTab('vdb-insights',this)">
|
|
<i class="bi bi-bar-chart-line-fill"></i> Insights
|
|
</button>
|
|
@endif
|
|
</div>
|
|
|
|
<div class="vdb-panel active" id="vdb-about">
|
|
@if($aboutPrepend !== null)
|
|
{!! $aboutPrepend !!}
|
|
@endif
|
|
<div class="vdb-meta">
|
|
<span><i class="bi bi-eye" style="margin-right:4px;"></i>{{ number_format($video->view_count) }} views</span>
|
|
<span>•</span>
|
|
<span>{{ $video->created_at->format('M d, Y') }}</span>
|
|
@if($video->duration)<span>•</span><span><i class="bi bi-clock" style="margin-right:4px;"></i>{{ $video->formatted_duration }}</span>@endif
|
|
</div>
|
|
@if(isset($descriptionSlot))
|
|
{!! $descriptionSlot !!}
|
|
@elseif($renderedDescription !== '')
|
|
<div id="vdbDescSplit" class="vdb-desc-split">
|
|
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
|
|
</div>
|
|
<button id="vdbShowMore" class="vdb-show-more" style="display:none;" onclick="toggleVdbDesc(this)"><span>Show more</span> <i class="bi bi-chevron-down"></i></button>
|
|
@else
|
|
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No description added.</p>
|
|
@endif
|
|
@if($llmEnabledVdb)
|
|
<button type="button" class="vdb-translate-btn" id="vdbTranslateBtn"
|
|
data-video-key="{{ $video->getRouteKey() }}"
|
|
data-csrf="{{ csrf_token() }}">
|
|
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
|
|
<span class="vdb-translate-label">Translate</span>
|
|
</button>
|
|
@endif
|
|
</div>
|
|
|
|
@if($isVideoOwner)
|
|
<x-video-insights :video="$video" />
|
|
@endif
|
|
</div>
|
|
|
|
<script>
|
|
// Remember which tab the user opened so SPA navigation between videos keeps it active.
|
|
window._vdbActiveTab = window._vdbActiveTab || 'vdb-about';
|
|
|
|
// Scroll back up to the video player on every SPA video-to-video swap.
|
|
// On mobile the window is locked (see CLAUDE.md mobile scroll model) and `.yt-main`
|
|
// (id="main") is the real scroll container, so we scroll that instead.
|
|
window._spaScrollToVideo = window._spaScrollToVideo || function () {
|
|
const main = document.getElementById('main');
|
|
if (window.innerWidth <= 768 && main) {
|
|
main.scrollTo({ top: 0, behavior: 'smooth' });
|
|
} else {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
};
|
|
|
|
// ── Tab switching ──────────────────────────────────────
|
|
function switchVdbTab(panelId, btn) {
|
|
window._vdbActiveTab = panelId;
|
|
document.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
document.getElementById(panelId).classList.add('active');
|
|
// About just became visible — re-measure now that its panel is laid out, since
|
|
// a measurement taken while it was display:none would have read 0.
|
|
if (panelId === 'vdb-about' && window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
|
|
if (panelId === 'vdb-insights') {
|
|
const panel = document.getElementById('vdb-insights');
|
|
const currentUrl = panel && panel.dataset.insightsBase;
|
|
if (currentUrl && currentUrl !== window._insLoadedUrl) loadInsights();
|
|
}
|
|
}
|
|
|
|
// Re-apply the remembered tab after an SPA swap replaces #vdbWrap's contents.
|
|
// If the remembered tab doesn't exist on the new video (e.g. Insights when the
|
|
// viewer isn't the owner), fall back to About so nothing is left blank.
|
|
function _vdbApplyActiveTab() {
|
|
const wrap = document.getElementById('vdbWrap');
|
|
if (!wrap) return;
|
|
let target = window._vdbActiveTab || 'vdb-about';
|
|
let btn = wrap.querySelector('.vdb-tab[data-panel="' + target + '"]');
|
|
if (!btn) { target = 'vdb-about'; btn = wrap.querySelector('.vdb-tab[data-panel="vdb-about"]'); }
|
|
if (!btn) return;
|
|
wrap.querySelectorAll('.vdb-tab').forEach(b => b.classList.remove('active'));
|
|
wrap.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
const panel = document.getElementById(target);
|
|
if (panel) panel.classList.add('active');
|
|
if (target === 'vdb-insights') {
|
|
const ip = document.getElementById('vdb-insights');
|
|
const currentUrl = ip && ip.dataset.insightsBase;
|
|
if (currentUrl && currentUrl !== window._insLoadedUrl && typeof loadInsights === 'function') loadInsights();
|
|
}
|
|
}
|
|
// Observe #vdbWrap so the active tab is re-applied whenever the SPA layer
|
|
// rewrites its innerHTML during a video-to-video transition.
|
|
(function _vdbWatchSwaps() {
|
|
const wrap = document.getElementById('vdbWrap');
|
|
if (!wrap || wrap._vdbTabObserver) return;
|
|
const obs = new MutationObserver(() => {
|
|
_vdbApplyActiveTab();
|
|
// The swap replaced the description markup — re-evaluate the "Show more"
|
|
// button once layout/fonts/images settle. Central here so every SPA swap
|
|
// path is covered, not just the ones that remember to call it themselves.
|
|
if (window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
|
|
});
|
|
obs.observe(wrap, { childList: true, subtree: false });
|
|
wrap._vdbTabObserver = obs;
|
|
})();
|
|
function toggleVdbDesc(btn) {
|
|
const d = document.getElementById('vdbDescShort');
|
|
if (!d) return;
|
|
const expanded = d.classList.toggle('vdb-expanded');
|
|
btn.classList.toggle('expanded', expanded);
|
|
const label = btn.querySelector('span');
|
|
if (label) label.textContent = expanded ? 'Show less' : 'Show more';
|
|
}
|
|
// Reveal "Show more" only when the description overflows the clamp. Compare the
|
|
// natural content height to the clamp limit (130px) rather than clientHeight,
|
|
// which is unreliable right after a content swap.
|
|
function _vdbCheckOverflow() {
|
|
const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore');
|
|
if (!d || !b) return;
|
|
if (d.classList.contains('vdb-expanded')) { b.style.display = 'flex'; return; }
|
|
// If the About panel isn't laid out yet (e.g. Insights tab active, or mid-swap),
|
|
// scrollHeight reads 0 — don't hide the button on a bogus zero measurement.
|
|
if (d.scrollHeight === 0) return;
|
|
b.style.display = (d.scrollHeight > 138) ? 'flex' : 'none';
|
|
}
|
|
// A single measurement right after an SPA swap is unreliable: web-fonts, images
|
|
// inside the description, and panel layout can all change the height a beat later,
|
|
// each of which flips whether the clamp overflows. Re-check at every settling point.
|
|
window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function () {
|
|
requestAnimationFrame(_vdbCheckOverflow);
|
|
setTimeout(_vdbCheckOverflow, 120);
|
|
setTimeout(_vdbCheckOverflow, 400);
|
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(_vdbCheckOverflow);
|
|
const d = document.getElementById('vdbDescShort');
|
|
if (d) d.querySelectorAll('img').forEach(function (img) {
|
|
if (!img.complete) img.addEventListener('load', _vdbCheckOverflow, { once: true });
|
|
});
|
|
};
|
|
document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck);
|
|
window.addEventListener('load', window._vdbScheduleOverflowCheck);
|
|
|
|
// ── Description translate ─────────────────────────────
|
|
// Lives on the About tab. Falls back to navigator.language for the target.
|
|
function _vdbTargetLang() {
|
|
return ((navigator.language || 'en').toLowerCase().split('-')[0]) || 'en';
|
|
}
|
|
window._vdbToggleTranslate = window._vdbToggleTranslate || async function (btn) {
|
|
const split = document.getElementById('vdbDescSplit');
|
|
const showMore = document.getElementById('vdbShowMore');
|
|
const label = btn.querySelector('.vdb-translate-label');
|
|
if (!split) return;
|
|
let grid = document.getElementById('vdbTranslationGrid');
|
|
|
|
const showTranslation = () => {
|
|
split.classList.add('has-translation');
|
|
if (grid) grid.style.display = 'grid';
|
|
btn.classList.add('active');
|
|
if (label) label.textContent = 'Show original';
|
|
if (showMore) showMore.style.display = 'none';
|
|
};
|
|
const hideTranslation = () => {
|
|
split.classList.remove('has-translation');
|
|
if (grid) grid.style.display = 'none';
|
|
btn.classList.remove('active');
|
|
if (label) label.textContent = 'Translate';
|
|
if (window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck();
|
|
};
|
|
|
|
if (grid && grid.dataset.loaded === '1') {
|
|
split.classList.contains('has-translation') ? hideTranslation() : showTranslation();
|
|
return;
|
|
}
|
|
|
|
btn.classList.add('loading');
|
|
if (label) label.textContent = 'Translating…';
|
|
try {
|
|
const r = await fetch('/videos/' + btn.dataset.videoKey + '/description/translate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': btn.dataset.csrf, 'Accept': 'application/json' },
|
|
body: JSON.stringify({ target: _vdbTargetLang() })
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
|
|
const segs = Array.isArray(d.segments) ? d.segments : [];
|
|
if (!segs.length) throw new Error('Translation failed');
|
|
|
|
// Build the line-paired grid. Each original line and its translation are
|
|
// siblings in the same CSS grid, so a short line on one side still shares
|
|
// its row with its counterpart on the other side.
|
|
if (!grid) {
|
|
grid = document.createElement('div');
|
|
grid.id = 'vdbTranslationGrid';
|
|
grid.className = 'vdb-translation-grid';
|
|
split.appendChild(grid);
|
|
}
|
|
grid.innerHTML = '';
|
|
const meta = document.createElement('div');
|
|
meta.className = 'vdb-translation-meta';
|
|
meta.textContent = 'Translated → ' + (d.target || _vdbTargetLang()).toUpperCase();
|
|
grid.appendChild(meta);
|
|
|
|
segs.forEach(seg => {
|
|
const origText = (seg.original ?? '').toString();
|
|
const trText = (seg.translated ?? '').toString();
|
|
const o = document.createElement('div');
|
|
o.className = 'vdb-tr-cell vdb-tr-orig' + (origText.trim() === '' ? ' vdb-tr-empty' : '');
|
|
o.textContent = origText;
|
|
const t = document.createElement('div');
|
|
t.className = 'vdb-tr-cell vdb-tr-trans' + (trText.trim() === '' ? ' vdb-tr-empty' : '');
|
|
t.textContent = trText;
|
|
grid.appendChild(o);
|
|
grid.appendChild(t);
|
|
});
|
|
grid.dataset.loaded = '1';
|
|
showTranslation();
|
|
} catch (err) {
|
|
if (window.showToast) window.showToast(err.message || 'Translation failed', 'error');
|
|
if (label) label.textContent = 'Translate';
|
|
} finally {
|
|
btn.classList.remove('loading');
|
|
}
|
|
};
|
|
// Delegate so SPA swaps that re-insert #vdbWrap keep working.
|
|
document.addEventListener('click', function (e) {
|
|
const b = e.target.closest('#vdbTranslateBtn');
|
|
if (b) { e.preventDefault(); window._vdbToggleTranslate(b); }
|
|
});
|
|
</script>
|
|
@endif
|