Add AI translation for comments and video descriptions
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]>
This commit is contained in:
1 parent
20c8fdcdf3
commit
2d33e39b9b
8 files changed
+749
-22
No files matched your search
@@ -4,6 +4,12 @@
|
||||
use App\Models\CommentLike;
|
||||
|
||||
$currentUserId = Auth::id() ?? 0;
|
||||
$llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled();
|
||||
|
||||
// Flag per commenter: most-frequent access country, falling back to the
|
||||
// user's nationality. Batched so the comments list stays a single query.
|
||||
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
|
||||
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
|
||||
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
|
||||
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count());
|
||||
|
||||
@@ -89,11 +95,16 @@
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
@php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp
|
||||
@if($cFlag)<span class="ytc-flag fi fi-{{ strtolower($cFlag) }}" title="{{ $cFlag }}"></span>@endif
|
||||
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-author">{{ $comment->user->name ?? 'User' }}</a>
|
||||
<span class="ytc-time">{{ ytcTime($comment->created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
|
||||
@if($llmEnabled)
|
||||
<div class="ytc-translation" id="translation-{{ $comment->id }}" style="display:none"></div>
|
||||
@endif
|
||||
|
||||
{{-- Edit form --}}
|
||||
@if($isOwn)
|
||||
@@ -120,6 +131,12 @@
|
||||
@auth
|
||||
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
|
||||
@endauth
|
||||
@if($llmEnabled)
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $comment->id }}" title="Translate this comment">
|
||||
<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="ytc-translate-label">Translate</span>
|
||||
</button>
|
||||
@endif
|
||||
@if($isOwn)
|
||||
<div class="ytc-more-wrap">
|
||||
<button class="ytc-more-btn" title="More actions">
|
||||
@@ -172,10 +189,15 @@
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
@php $rFlag = $commenterCountry[$reply->user_id] ?? null; @endphp
|
||||
@if($rFlag)<span class="ytc-flag fi fi-{{ strtolower($rFlag) }}" title="{{ $rFlag }}"></span>@endif
|
||||
<a href="{{ $reply->user ? route('channel', $reply->user->channel) : '#' }}" class="ytc-author">{{ $reply->user->name ?? 'User' }}</a>
|
||||
<span class="ytc-time">{{ ytcTime($reply->created_at) }}</span>
|
||||
</div>
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div>
|
||||
@if($llmEnabled)
|
||||
<div class="ytc-translation" id="translation-{{ $reply->id }}" style="display:none"></div>
|
||||
@endif
|
||||
|
||||
@if($replyIsOwn)
|
||||
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
|
||||
@@ -197,6 +219,12 @@
|
||||
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
|
||||
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
|
||||
</button>
|
||||
@if($llmEnabled)
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $reply->id }}" title="Translate this comment">
|
||||
<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="ytc-translate-label">Translate</span>
|
||||
</button>
|
||||
@endif
|
||||
@if($replyIsOwn)
|
||||
<div class="ytc-more-wrap">
|
||||
<button class="ytc-more-btn">
|
||||
@@ -498,6 +526,57 @@
|
||||
}
|
||||
.ytc-reply-btn:hover { background: rgba(255,255,255,.1); }
|
||||
|
||||
/* ── Translate ── */
|
||||
.ytc-translate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 6px 10px;
|
||||
border-radius: 18px;
|
||||
transition: background .15s, color .15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ytc-translate-btn:hover { background: rgba(255,255,255,.1); color: var(--text-primary); }
|
||||
.ytc-translate-btn svg { width: 16px; height: 16px; fill: currentColor; }
|
||||
.ytc-translate-btn.loading { opacity: .6; cursor: wait; }
|
||||
.ytc-translate-btn.active { color: #3ea6ff; }
|
||||
.ytc-flag {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,.08);
|
||||
vertical-align: middle;
|
||||
transform: translateY(-1px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ytc-translation {
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(62,166,255,.08);
|
||||
border-left: 2px solid #3ea6ff;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ytc-translation-meta {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
/* ── Three-dot menu ── */
|
||||
.ytc-more-wrap {
|
||||
position: relative;
|
||||
@@ -695,9 +774,16 @@ const YTC = {
|
||||
videoId: '{{ $video->getRouteKey() }}',
|
||||
csrf: '{{ csrf_token() }}',
|
||||
userId: {{ $currentUserId }},
|
||||
llmEnabled: {{ $llmEnabled ? 'true' : 'false' }},
|
||||
meCountry: @json($currentUserId && isset($commenterCountry[$currentUserId]) ? strtolower($commenterCountry[$currentUserId]) : null),
|
||||
deleteId: null,
|
||||
toastTimer: null,
|
||||
};
|
||||
const YTC_TRANSLATE_SVG = '<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>';
|
||||
function targetLang() {
|
||||
const l = (navigator.language || 'en').toLowerCase().split('-')[0];
|
||||
return l || 'en';
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────
|
||||
function esc(s) {
|
||||
@@ -904,8 +990,78 @@ document.getElementById('ytcList')?.addEventListener('click', e => {
|
||||
// Dislike btn
|
||||
const db = e.target.closest('.ytc-dislike-btn');
|
||||
if (db) { e.stopPropagation(); toggleDislike(db); return; }
|
||||
|
||||
});
|
||||
|
||||
// Translate — bound at `document` level once, dispatches to the LATEST
|
||||
// IIFE's handler via window.__ytcToggleTranslate. SPA video-to-video swaps
|
||||
// replace #ytcSection and re-run this script; the new IIFE overwrites
|
||||
// window.__ytcToggleTranslate so it keeps using fresh state (YTC.csrf etc.).
|
||||
window.__ytcToggleTranslate = function (btn) { toggleTranslate(btn); };
|
||||
if (!window.__ytcTranslateDelegated) {
|
||||
window.__ytcTranslateDelegated = true;
|
||||
document.addEventListener('click', e => {
|
||||
const tb = e.target.closest('._comment-translate-trigger');
|
||||
if (!tb) return;
|
||||
e.stopPropagation();
|
||||
if (typeof window.__ytcToggleTranslate === 'function') window.__ytcToggleTranslate(tb);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Translate ─────────────────────────────────────────
|
||||
async function toggleTranslate(btn) {
|
||||
const id = btn.dataset.commentId;
|
||||
const panel = document.getElementById('translation-' + id);
|
||||
const label = btn.querySelector('.ytc-translate-label');
|
||||
if (!panel) return;
|
||||
|
||||
// Toggle off if already shown.
|
||||
if (panel.style.display !== 'none' && panel.dataset.loaded === '1') {
|
||||
panel.style.display = 'none';
|
||||
btn.classList.remove('active');
|
||||
if (label) label.textContent = 'Translate';
|
||||
return;
|
||||
}
|
||||
// Already loaded — just reveal.
|
||||
if (panel.dataset.loaded === '1') {
|
||||
panel.style.display = 'block';
|
||||
btn.classList.add('active');
|
||||
if (label) label.textContent = 'Show original';
|
||||
return;
|
||||
}
|
||||
|
||||
btn.classList.add('loading');
|
||||
if (label) label.textContent = 'Translating…';
|
||||
try {
|
||||
const r = await fetch('/comments/' + id + '/translate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ target: targetLang() })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
|
||||
|
||||
panel.innerHTML = '';
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'ytc-translation-meta';
|
||||
meta.textContent = 'Translated → ' + (d.target || targetLang()).toUpperCase();
|
||||
const body = document.createElement('div');
|
||||
body.textContent = d.translated;
|
||||
panel.appendChild(meta);
|
||||
panel.appendChild(body);
|
||||
|
||||
panel.dataset.loaded = '1';
|
||||
panel.style.display = 'block';
|
||||
btn.classList.add('active');
|
||||
if (label) label.textContent = 'Show original';
|
||||
} catch (err) {
|
||||
toast(err.message || 'Translation failed', 'error');
|
||||
if (label) label.textContent = 'Translate';
|
||||
} finally {
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reply form ────────────────────────────────────────
|
||||
function toggleReplyForm(commentId) {
|
||||
const form = document.getElementById('replyForm' + commentId);
|
||||
@@ -970,10 +1126,12 @@ async function saveEdit(commentId) {
|
||||
|
||||
// ── Post comment ──────────────────────────────────────
|
||||
async function postComment() {
|
||||
if (postComment._busy) return; // guard against double-submit (fast dbl-click, Enter+click, duplicate listeners after SPA swap)
|
||||
const ta = document.getElementById('ytcTextarea');
|
||||
if (!ta) return;
|
||||
const text = ta.value.trim();
|
||||
if (!text) { toast('Write something first', 'error'); return; }
|
||||
postComment._busy = true;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Posting…';
|
||||
try {
|
||||
@@ -1000,6 +1158,7 @@ async function postComment() {
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
postComment._busy = false;
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Comment';
|
||||
}
|
||||
@@ -1012,7 +1171,12 @@ async function postReply(vid, parentId) {
|
||||
const text = input.value.trim();
|
||||
if (!text) { toast('Write something first', 'error'); return; }
|
||||
const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Posting…'; }
|
||||
if (btn) {
|
||||
if (btn.dataset.busy === '1') return; // guard against double-submit
|
||||
btn.dataset.busy = '1';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Posting…';
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/videos/' + vid + '/comments', {
|
||||
method: 'POST',
|
||||
@@ -1035,7 +1199,7 @@ async function postReply(vid, parentId) {
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Reply'; }
|
||||
if (btn) { btn.dataset.busy = ''; btn.disabled = false; btn.textContent = 'Reply'; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,6 +1297,12 @@ function buildCommentEl(c, isReply) {
|
||||
</div>` : '';
|
||||
|
||||
const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : '';
|
||||
const translateBtn = YTC.llmEnabled ? `
|
||||
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="${id}" title="Translate this comment">
|
||||
${YTC_TRANSLATE_SVG}<span class="ytc-translate-label">Translate</span>
|
||||
</button>` : '';
|
||||
const translationPanel = YTC.llmEnabled ? `<div class="ytc-translation" id="translation-${id}" style="display:none"></div>` : '';
|
||||
const flagEl = YTC.meCountry ? `<span class="ytc-flag fi fi-${YTC.meCountry}"></span>` : '';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
|
||||
@@ -1143,10 +1313,12 @@ function buildCommentEl(c, isReply) {
|
||||
</a>
|
||||
<div class="ytc-body-wrap">
|
||||
<div class="ytc-meta">
|
||||
${flagEl}
|
||||
<span class="ytc-author">${name}</span>
|
||||
<span class="ytc-time">${time}</span>
|
||||
</div>
|
||||
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
|
||||
${translationPanel}
|
||||
${editForm}
|
||||
<div class="ytc-actions">
|
||||
<button class="ytc-like-btn" data-id="${id}" title="Like">
|
||||
@@ -1157,6 +1329,7 @@ function buildCommentEl(c, isReply) {
|
||||
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
|
||||
</button>
|
||||
${replyBtn}
|
||||
${translateBtn}
|
||||
${moreMenu}
|
||||
</div>
|
||||
${replyForm}
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
$isVideoOwner = Auth::check() && Auth::id() === $video->user_id;
|
||||
$renderedDescription = \App\Support\HtmlSanitizer::render($video->description ?? '');
|
||||
$hasDesc = $renderedDescription !== '' || isset($descriptionSlot);
|
||||
$showBox = $hasDesc || $isVideoOwner;
|
||||
$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>
|
||||
@@ -36,6 +41,27 @@
|
||||
.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">
|
||||
@@ -51,6 +77,9 @@
|
||||
</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>
|
||||
@@ -60,11 +89,21 @@
|
||||
@if(isset($descriptionSlot))
|
||||
{!! $descriptionSlot !!}
|
||||
@elseif($renderedDescription !== '')
|
||||
<div id="vdbDescShort" class="vdb-desc-text vdb-clamp">{!! $renderedDescription !!}</div>
|
||||
<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)
|
||||
@@ -176,5 +215,92 @@ window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function
|
||||
};
|
||||
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
|
||||
Reference in new issue
Block a user